fix: stabilize canvas uploads and image load
This commit is contained in:
parent
6e6acea09e
commit
48f222998b
@ -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用于上传
|
||||
|
||||
118
app/api/external/image/generate/route.ts
vendored
118
app/api/external/image/generate/route.ts
vendored
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
34
lib/canvas/local-upload-state.ts
Normal file
34
lib/canvas/local-upload-state.ts
Normal 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);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user