fix: stabilize canvas uploads and image load
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-23 17:30:16 +08:00
parent 6e6acea09e
commit 48f222998b
9 changed files with 363 additions and 32 deletions

View File

@ -224,11 +224,17 @@ export async function POST(request: Request) {
// ✅ 优化原图(保持JPG/PNG格式)
// WebP格式不优化(用于预览图,保持原样)
if (file.type === 'image/jpeg' || file.type === 'image/jpg' || file.type === 'image/png') {
const shouldOptimizeImage =
uploadPurpose !== "canvas-upload" &&
(file.type === 'image/jpeg' || file.type === 'image/jpg' || file.type === 'image/png');
if (shouldOptimizeImage) {
optimizedBuffer = await optimizeImage(optimizedBuffer, file.type);
} else if (file.type === 'image/webp' || file.type === 'image/svg+xml') {
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
console.log(`[Upload] ${file.type}格式,跳过优化,直接上传`);
} else if (uploadPurpose === "canvas-upload") {
console.log("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop");
}
// 将Buffer转换为ArrayBuffer用于上传

View File

@ -21,6 +21,103 @@ import {
export const runtime = "nodejs";
export const maxDuration = 120;
const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__";
const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2;
const DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE = 8;
const DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS = 15_000;
class ExternalImageApiBusyError extends Error {
readonly retryAfterSeconds: number;
constructor(message: string, retryAfterSeconds = 15) {
super(message);
this.name = "ExternalImageApiBusyError";
this.retryAfterSeconds = retryAfterSeconds;
}
}
type ExternalImageApiWaiter = {
resolve: (release: () => void) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
type ExternalImageApiLimiterState = {
running: number;
queue: ExternalImageApiWaiter[];
};
function parsePositiveInteger(value: unknown, fallback: number) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getExternalImageApiLimiterState(): ExternalImageApiLimiterState {
const globalState = globalThis as typeof globalThis & {
[EXTERNAL_IMAGE_API_LIMITER_KEY]?: ExternalImageApiLimiterState;
};
if (!globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]) {
globalState[EXTERNAL_IMAGE_API_LIMITER_KEY] = {
running: 0,
queue: [],
};
}
return globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]!;
}
function acquireExternalImageApiSlot(): Promise<() => void> {
const state = getExternalImageApiLimiterState();
const maxConcurrent = parsePositiveInteger(
process.env.EXTERNAL_IMAGE_API_MAX_CONCURRENT,
DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT,
);
const maxQueue = parsePositiveInteger(
process.env.EXTERNAL_IMAGE_API_MAX_QUEUE,
DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE,
);
const queueTimeoutMs = parsePositiveInteger(
process.env.EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
);
const release = () => {
state.running = Math.max(0, state.running - 1);
const next = state.queue.shift();
if (!next) return;
clearTimeout(next.timer);
state.running += 1;
next.resolve(release);
};
if (state.running < maxConcurrent) {
state.running += 1;
return Promise.resolve(release);
}
if (state.queue.length >= maxQueue) {
return Promise.reject(
new ExternalImageApiBusyError("图片生成服务繁忙,请稍后重试"),
);
}
return new Promise((resolve, reject) => {
const waiter: ExternalImageApiWaiter = {
resolve,
reject,
timer: setTimeout(() => {
const index = state.queue.indexOf(waiter);
if (index >= 0) {
state.queue.splice(index, 1);
}
reject(new ExternalImageApiBusyError("图片生成排队超时,请稍后重试"));
}, queueTimeoutMs),
};
state.queue.push(waiter);
});
}
const requestSchema = z.object({
prompt: z.string().trim().min(1, "prompt 不能为空"),
negativePrompt: z.string().trim().optional(),
@ -159,8 +256,10 @@ export async function POST(request: Request) {
const startedAt = Date.now();
let logId: string | null = null;
let releaseExternalImageApiSlot: (() => void) | null = null;
try {
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
const payload = await parseRequestPayload(request);
const candidateModels = await buildExternalImageApiModelCandidates({
referenceImageCount: payload.referenceImages?.length || 0,
@ -304,6 +403,21 @@ export async function POST(request: Request) {
.where(eq(externalImageApiRequestLog.id, logId));
}
if (error instanceof ExternalImageApiBusyError) {
return NextResponse.json(
{
success: false,
error: error.message,
},
{
status: 429,
headers: {
"Retry-After": String(error.retryAfterSeconds),
},
},
);
}
return NextResponse.json(
{
success: false,
@ -311,5 +425,9 @@ export async function POST(request: Request) {
},
{ status: 500 },
);
} finally {
if (releaseExternalImageApiSlot) {
releaseExternalImageApiSlot();
}
}
}

View File

@ -153,6 +153,10 @@ import {
type VideoCanvasMarkerValue,
type VideoCanvasSymbolMarkerId,
} from "@/lib/canvas/video-canvas-markers";
import {
LOCAL_UPLOAD_PENDING_METADATA_KEY,
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
} from "@/lib/canvas/local-upload-state";
import {
getCanvasToolbarItem,
getDefaultCanvasToolbarItem,
@ -912,6 +916,41 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function sanitizeCanvasStateForPersist(state: CanvasState): CanvasState {
return {
...state,
nodes: state.nodes.map((node) => {
if (node.type !== "image") return node;
const data = node.data as Record<string, any>;
const metadata = isPlainObject(data.metadata)
? { ...(data.metadata as Record<string, unknown>) }
: {};
const localPreviewUrl = String(
metadata[LOCAL_UPLOAD_PREVIEW_METADATA_KEY] || ""
);
const src = String(data.src || "");
if (!src.startsWith("blob:") && !localPreviewUrl.startsWith("blob:")) {
return node;
}
delete metadata[LOCAL_UPLOAD_PREVIEW_METADATA_KEY];
delete metadata[LOCAL_UPLOAD_PENDING_METADATA_KEY];
return {
...node,
data: {
...data,
src: "",
status: data.status === "failed" ? "failed" : "generating",
metadata,
} as CanvasNode["data"],
};
}),
};
}
function normalizeFiniteNumber(value: unknown, fallback: number) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
@ -3267,9 +3306,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]);
const buildPersistedCanvasData = useCallback(() => {
return {
...getCanvasState(),
};
return sanitizeCanvasStateForPersist(getCanvasState());
}, [getCanvasState]);
const handleExportVideoCanvasProject = useCallback(() => {

View File

@ -87,6 +87,10 @@ import {
normalizeCanvasSkillTags,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
import {
isCanvasImageReadyForAi,
isCanvasImageUploadPending,
} from "@/lib/canvas/local-upload-state";
import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
@ -2705,7 +2709,7 @@ export function CanvasChatPanel({
// 获取选中的图片节点
const selectedImageNodes = nodes.filter(
n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src
n => selectedNodeIds.includes(n.id) && isCanvasImageReadyForAi(n)
);
const handleAssistantVote = useCallback(
@ -2759,8 +2763,9 @@ export function CanvasChatPanel({
const getCanvasNodeAttachment = useCallback(
(node: any, fallbackName: string): PickedObjectAttachment | null => {
if (isCanvasImageUploadPending(node)) return null;
const src = (node?.data as any)?.src;
if (!src || typeof src !== "string") return null;
if (!src || typeof src !== "string" || !isValidChatHttpUrl(src)) return null;
const nodeTitle =
String((node?.data as any)?.title || "").trim() ||
@ -5981,7 +5986,7 @@ export function CanvasChatPanel({
// 🆕 Agent模式上下文(画布图片布局)
const buildAutoContext = useCallback(() => {
const imageNodes = nodes.filter(n => n.type === 'image' && (n.data as any)?.src);
const imageNodes = nodes.filter((n) => isCanvasImageReadyForAi(n));
if (imageNodes.length === 0) return "";
const MAX_CONTEXT_IMAGES = 6;

View File

@ -49,6 +49,12 @@ import {
normalizeCanvasToolbarConfig,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import {
isCanvasImageReadyForAi,
isCanvasImageUploadPending,
LOCAL_UPLOAD_PENDING_METADATA_KEY,
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
} from "@/lib/canvas/local-upload-state";
import { inferOriginalImageUrl } from "@/lib/image-url";
const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record<string, LucideIcon> = {
@ -60,6 +66,43 @@ const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record<string, LucideIcon> = {
bolt: Zap,
};
function isImageFile(file: File) {
return file.type.startsWith("image/");
}
function extractImageFilesFromDataTransfer(dataTransfer?: DataTransfer | null) {
if (!dataTransfer) return [];
const seen = new Set<string>();
const files: File[] = [];
const pushFile = (file: File | null) => {
if (!file || !isImageFile(file)) return;
const key = `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
if (seen.has(key)) return;
seen.add(key);
files.push(file);
};
Array.from(dataTransfer.files || []).forEach(pushFile);
Array.from(dataTransfer.items || []).forEach((item) => {
if (item.kind === "file") {
pushFile(item.getAsFile());
}
});
return files;
}
function isEditablePasteTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
return Boolean(
target.closest(
'input, textarea, select, [contenteditable="true"], [role="textbox"], [data-canvas-no-drag="true"]'
)
);
}
function getNodeVideoCanvasMarkers(node: CanvasNode) {
const metadata = (node.data as { metadata?: unknown } | undefined)?.metadata;
return normalizeVideoCanvasMarkers(
@ -370,10 +413,20 @@ export function CanvasWorkspace({
(node) =>
selectedNodeIds.includes(node.id) &&
node.type === "image" &&
Boolean((node.data as any)?.src)
isCanvasImageReadyForAi(node)
),
[nodes, selectedNodeIds]
);
const selectedPendingImageUploadCount = useMemo(
() =>
nodes.filter(
(node) =>
selectedNodeIds.includes(node.id) &&
node.type === "image" &&
isCanvasImageUploadPending(node)
).length,
[nodes, selectedNodeIds]
);
const selectedImageLocalEditItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageLocalEdit"
@ -1034,7 +1087,7 @@ export function CanvasWorkspace({
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
if (!file.type.startsWith("image/")) continue;
if (!isImageFile(file)) continue;
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
continue;
@ -1042,25 +1095,27 @@ export function CanvasWorkspace({
const dimensions = await getImageDimensionsFromFile(file);
const cleanName = file.name.replace(/\.[^.]+$/, "");
const previewUrl = URL.createObjectURL(file);
const placeholderId = addNode({
type: "image",
position: { x: baseX + index * spacing, y: baseY + index * spacing },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: "",
src: previewUrl,
status: "generating",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
[LOCAL_UPLOAD_PREVIEW_METADATA_KEY]: previewUrl,
[LOCAL_UPLOAD_PENDING_METADATA_KEY]: true,
},
},
connections: [],
});
try {
const result = await imageGenerationService.uploadImage(file);
void imageGenerationService.uploadImage(file).then((result) => {
if (result.success && result.data?.url) {
updateNode(placeholderId, {
data: {
@ -1075,29 +1130,45 @@ export function CanvasWorkspace({
},
},
});
URL.revokeObjectURL(previewUrl);
toast.success("图片已添加到画布");
} else {
updateNode(placeholderId, {
data: {
src: "",
status: "failed",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
},
});
toast.error(result.error || "图片上传失败");
return;
}
} catch (_error) {
updateNode(placeholderId, {
data: {
src: "",
src: previewUrl,
status: "failed",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
uploadError: result.error || "图片上传失败",
[LOCAL_UPLOAD_PREVIEW_METADATA_KEY]: previewUrl,
[LOCAL_UPLOAD_PENDING_METADATA_KEY]: false,
},
},
});
toast.error(result.error || "图片上传失败");
}).catch(() => {
updateNode(placeholderId, {
data: {
src: previewUrl,
status: "failed",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
uploadError: "图片上传失败",
[LOCAL_UPLOAD_PREVIEW_METADATA_KEY]: previewUrl,
[LOCAL_UPLOAD_PENDING_METADATA_KEY]: false,
},
},
});
toast.error("图片上传失败");
}
});
}
}, [addNode, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
@ -1112,7 +1183,7 @@ export function CanvasWorkspace({
const inContainer = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (!inContainer) return;
const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/"));
const files = extractImageFilesFromDataTransfer(e.dataTransfer);
if (files.length === 0) return;
e.preventDefault();
dragCounterRef.current = 0;
@ -1155,12 +1226,43 @@ export function CanvasWorkspace({
dragCounterRef.current = 0;
setIsFileDragActive(false);
const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/"));
const files = extractImageFilesFromDataTransfer(e.dataTransfer);
if (files.length > 0) {
await processDroppedFiles(files, e.clientX, e.clientY);
}
}, [isFileDrag, processDroppedFiles]);
React.useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (hasBlockingDialogOpen() || isEditablePasteTarget(e.target)) return;
const container = containerRef.current;
if (!container) return;
const activeElement = document.activeElement;
if (
activeElement instanceof HTMLElement &&
activeElement !== document.body &&
!container.contains(activeElement)
) {
return;
}
const files = extractImageFilesFromDataTransfer(e.clipboardData);
if (files.length === 0) return;
e.preventDefault();
const rect = container.getBoundingClientRect();
await processDroppedFiles(
files,
rect.left + rect.width / 2,
rect.top + rect.height / 2
);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [hasBlockingDialogOpen, processDroppedFiles]);
const handleDownloadSelectedImages = useCallback(async () => {
if (selectedImageNodes.length === 0) {
toast.error("请先框选要下载的图片");
@ -1881,6 +1983,14 @@ export function CanvasWorkspace({
)}
{/* 选中图片快捷操作 */}
{selectedPendingImageUploadCount > 0 && selectedImageNodes.length === 0 && (
<div
className="absolute bottom-16 left-4 z-30 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 shadow-sm"
data-canvas-overlay
>
图片仍在本地,上传完成后可进行 AI 处理
</div>
)}
{selectedImageNodes.length > 0 && (
<div
className="absolute bottom-16 left-4 z-30 flex items-center gap-2"

View File

@ -113,6 +113,7 @@ import {
isCanvasToolbarItemEnabled,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { cn, fetcher } from "@/lib/utils";
import type {
CanvasTextEditPayloadItem,
@ -166,6 +167,20 @@ interface LayerSplitBounds {
coverage: number;
}
const AI_CLOUD_IMAGE_ACTIONS = new Set([
"rmbg",
"watermark",
"enhance",
"extend",
"layer-split",
"box-cutout",
"local-edit",
"text-edit",
"prompt-regenerate",
"generate-3d-model",
"generate-3d-world",
]);
type AngleEditPanelMode = "subject" | "camera";
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
type ImageAdjustPanelTab = "light" | "color" | "detail" | "effects";
@ -5906,6 +5921,13 @@ export function ImageNode({
toast.error("请先上传图片");
return;
}
if (
AI_CLOUD_IMAGE_ACTIONS.has(action) &&
(isCanvasImageUploadPending(node) || !isHttpImageUrl(data.src))
) {
toast.error("图片仍在本地,上传完成后再进行 AI 处理");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;

View File

@ -1,7 +1,7 @@
export const CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY = "canvas_image_task_queue_config";
export const MIN_CANVAS_IMAGE_TASK_CONCURRENCY = 1;
export const DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY = 8;
export const DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY = 3;
export const MAX_CANVAS_IMAGE_TASK_CONCURRENCY = 100;
export const MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;
export const DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;

View File

@ -0,0 +1,34 @@
import type { CanvasNode } from "@/types/canvas";
export const LOCAL_UPLOAD_PREVIEW_METADATA_KEY = "localUploadPreviewUrl";
export const LOCAL_UPLOAD_PENDING_METADATA_KEY = "localUploadPending";
export function isLocalPreviewUrl(value: unknown) {
return typeof value === "string" && value.startsWith("blob:");
}
export function isCanvasImageUploadPending(node: CanvasNode | null | undefined) {
if (!node || node.type !== "image") return false;
const data = (node.data || {}) as Record<string, any>;
const metadata =
data.metadata &&
typeof data.metadata === "object" &&
!Array.isArray(data.metadata)
? (data.metadata as Record<string, any>)
: {};
return (
metadata[LOCAL_UPLOAD_PENDING_METADATA_KEY] === true ||
isLocalPreviewUrl(data.src) ||
isLocalPreviewUrl(metadata[LOCAL_UPLOAD_PREVIEW_METADATA_KEY])
);
}
export function isCanvasImageReadyForAi(node: CanvasNode | null | undefined) {
if (!node || node.type !== "image" || isCanvasImageUploadPending(node)) {
return false;
}
const src = String(((node.data as Record<string, any>)?.src || "")).trim();
return /^https?:\/\//i.test(src);
}

View File

@ -7,6 +7,7 @@ import type {
CanvasWorkspaceType,
} from '@/types/canvas';
import type { VideoGenerationMode, VideoRatio, VideoResolution } from '@/types/ai';
import { fetchUploadWithRetry } from '@/lib/client/upload-with-retry';
import { requestLongTaskResult } from '@/lib/client/long-task';
import type {
EnhanceProKreaModel,
@ -247,11 +248,9 @@ export const imageGenerationService = {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('purpose', 'canvas-upload');
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
});
const response = await fetchUploadWithRetry(formData);
if (!response.ok) {
const errorData = await response.json();