diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index e63c1eb07..f2bc520e6 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -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用于上传 diff --git a/app/api/external/image/generate/route.ts b/app/api/external/image/generate/route.ts index dc955445c..96c1b131d 100644 --- a/app/api/external/image/generate/route.ts +++ b/app/api/external/image/generate/route.ts @@ -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; +}; + +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(); + } } } diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 05de41ba2..6873c28db 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -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 { 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; + const metadata = isPlainObject(data.metadata) + ? { ...(data.metadata as Record) } + : {}; + 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(() => { diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 7bf0b0d5c..301f99287 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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; diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 87be14137..faa7239f6 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -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 = { @@ -60,6 +66,43 @@ const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record = { bolt: Zap, }; +function isImageFile(file: File) { + return file.type.startsWith("image/"); +} + +function extractImageFilesFromDataTransfer(dataTransfer?: DataTransfer | null) { + if (!dataTransfer) return []; + + const seen = new Set(); + 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 && ( +
+ 图片仍在本地,上传完成后可进行 AI 处理 +
+ )} {selectedImageNodes.length > 0 && (
; + const metadata = + data.metadata && + typeof data.metadata === "object" && + !Array.isArray(data.metadata) + ? (data.metadata as Record) + : {}; + + 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)?.src || "")).trim(); + return /^https?:\/\//i.test(src); +} diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 6686ee73e..b4ec5ead1 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -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();