fix(canvas): restore AI layout replacement
This commit is contained in:
parent
bf6bd01fcb
commit
71b3ce56cf
@ -38,6 +38,7 @@ import {
|
|||||||
ImagePlus,
|
ImagePlus,
|
||||||
Key,
|
Key,
|
||||||
Layers,
|
Layers,
|
||||||
|
LayoutTemplate,
|
||||||
Layers3,
|
Layers3,
|
||||||
Loader2,
|
Loader2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@ -1061,7 +1062,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
|
|||||||
imageRemoveBackground: Scissors,
|
imageRemoveBackground: Scissors,
|
||||||
imageBoxCutout: Scan,
|
imageBoxCutout: Scan,
|
||||||
imageLocalEdit: Wand2,
|
imageLocalEdit: Wand2,
|
||||||
imageTemplateReplace: ImageIcon,
|
imageLayoutReplace: LayoutTemplate,
|
||||||
imageRemoveWatermark: Eraser,
|
imageRemoveWatermark: Eraser,
|
||||||
imageResize: Maximize2,
|
imageResize: Maximize2,
|
||||||
imageTextEdit: Type,
|
imageTextEdit: Type,
|
||||||
|
|||||||
@ -1,930 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Check,
|
|
||||||
CircleAlert,
|
|
||||||
ImagePlus,
|
|
||||||
Images,
|
|
||||||
LayoutTemplate,
|
|
||||||
Loader2,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Trash2,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
createCanvasTemplateReplaceSlots,
|
|
||||||
MAX_CANVAS_TEMPLATE_REPLACE_SOURCES,
|
|
||||||
normalizeCanvasTemplateReplaceSources,
|
|
||||||
replaceCanvasTemplateSourceSlot,
|
|
||||||
type CanvasTemplateReplaceSource,
|
|
||||||
} from "@/lib/canvas/template-replace-batch";
|
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { imageGenerationService } from "@/services/canvas";
|
|
||||||
|
|
||||||
const TEMPLATE_REPLACE_POLL_INTERVAL_MS = 2500;
|
|
||||||
|
|
||||||
type TemplateReplaceStatus =
|
|
||||||
| "waiting"
|
|
||||||
| "submitting"
|
|
||||||
| "queued"
|
|
||||||
| "processing"
|
|
||||||
| "completed"
|
|
||||||
| "failed";
|
|
||||||
|
|
||||||
type TemplateReplaceQueueStatus = {
|
|
||||||
queuePosition?: number;
|
|
||||||
sameModelRunningCount?: number;
|
|
||||||
concurrencyLimit?: number;
|
|
||||||
estimatedWaitMs?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CanvasTemplateReplaceResult = {
|
|
||||||
id: string;
|
|
||||||
batchId: string;
|
|
||||||
source: CanvasTemplateReplaceSource;
|
|
||||||
referenceImageUrl: string;
|
|
||||||
resultImageUrl?: string;
|
|
||||||
taskId?: string;
|
|
||||||
status: TemplateReplaceStatus;
|
|
||||||
queueStatus?: TemplateReplaceQueueStatus;
|
|
||||||
error?: string;
|
|
||||||
addedToCanvas?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PersistedTemplateReplaceBatch = {
|
|
||||||
version: 1;
|
|
||||||
slots: Array<CanvasTemplateReplaceSource | null>;
|
|
||||||
referenceImageUrl: string;
|
|
||||||
supplementalPrompt: string;
|
|
||||||
results: CanvasTemplateReplaceResult[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type CanvasTemplateReplaceDialogProps = {
|
|
||||||
availableSources: CanvasTemplateReplaceSource[];
|
|
||||||
canvasId: string;
|
|
||||||
initialSources: CanvasTemplateReplaceSource[];
|
|
||||||
open: boolean;
|
|
||||||
projectId?: string | null;
|
|
||||||
triggerNodeId: string;
|
|
||||||
onAddToCanvas: (
|
|
||||||
result: CanvasTemplateReplaceResult,
|
|
||||||
resultIndex: number
|
|
||||||
) => void | Promise<void>;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isHttpImageUrl(value: unknown): value is string {
|
|
||||||
return /^https?:\/\//i.test(String(value || "").trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number) {
|
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLocalId(prefix: string) {
|
|
||||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
||||||
return `${prefix}-${crypto.randomUUID()}`;
|
|
||||||
}
|
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeQueueStatus(value: unknown): TemplateReplaceQueueStatus | undefined {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
||||||
const queue = value as Record<string, unknown>;
|
|
||||||
const normalizeNumber = (entry: unknown) => {
|
|
||||||
const number = Number(entry);
|
|
||||||
return Number.isFinite(number) ? Math.max(0, number) : undefined;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
queuePosition: normalizeNumber(queue.queuePosition),
|
|
||||||
sameModelRunningCount: normalizeNumber(queue.sameModelRunningCount),
|
|
||||||
concurrencyLimit: normalizeNumber(queue.concurrencyLimit),
|
|
||||||
estimatedWaitMs: normalizeNumber(queue.estimatedWaitMs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusLabel(result: CanvasTemplateReplaceResult) {
|
|
||||||
if (result.status === "waiting") return "等待上一张";
|
|
||||||
if (result.status === "submitting") return "正在提交";
|
|
||||||
if (result.status === "queued") {
|
|
||||||
const position = Math.max(0, Number(result.queueStatus?.queuePosition || 0));
|
|
||||||
return position > 0 ? `排队中(前方 ${position} 个)` : "排队中";
|
|
||||||
}
|
|
||||||
if (result.status === "processing") return "处理中";
|
|
||||||
if (result.status === "completed") return "已完成";
|
|
||||||
return "失败";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPersistedBatch(
|
|
||||||
storageKey: string
|
|
||||||
): PersistedTemplateReplaceBatch | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(window.localStorage.getItem(storageKey) || "null") as
|
|
||||||
| Partial<PersistedTemplateReplaceBatch>
|
|
||||||
| null;
|
|
||||||
if (!raw || raw.version !== 1) return null;
|
|
||||||
|
|
||||||
const slots = createCanvasTemplateReplaceSlots(
|
|
||||||
Array.isArray(raw.slots) ? raw.slots : []
|
|
||||||
);
|
|
||||||
const referenceImageUrl = isHttpImageUrl(raw.referenceImageUrl)
|
|
||||||
? raw.referenceImageUrl.trim()
|
|
||||||
: "";
|
|
||||||
const results = (Array.isArray(raw.results) ? raw.results : [])
|
|
||||||
.map((result): CanvasTemplateReplaceResult | null => {
|
|
||||||
if (!result || typeof result !== "object") return null;
|
|
||||||
const source = normalizeCanvasTemplateReplaceSources([result.source])[0];
|
|
||||||
if (!source || !isHttpImageUrl(result.referenceImageUrl)) return null;
|
|
||||||
const taskId = String(result.taskId || "").trim() || undefined;
|
|
||||||
const resultImageUrl = isHttpImageUrl(result.resultImageUrl)
|
|
||||||
? result.resultImageUrl.trim()
|
|
||||||
: undefined;
|
|
||||||
let status: TemplateReplaceStatus = "waiting";
|
|
||||||
if (resultImageUrl) status = "completed";
|
|
||||||
else if (result.status === "failed") status = "failed";
|
|
||||||
else if (taskId) status = "queued";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(result.id || createLocalId("template-result")),
|
|
||||||
batchId: String(result.batchId || createLocalId("template-batch")),
|
|
||||||
source,
|
|
||||||
referenceImageUrl: result.referenceImageUrl.trim(),
|
|
||||||
resultImageUrl,
|
|
||||||
taskId,
|
|
||||||
status,
|
|
||||||
queueStatus: normalizeQueueStatus(result.queueStatus),
|
|
||||||
error: String(result.error || "").trim() || undefined,
|
|
||||||
addedToCanvas: result.addedToCanvas === true,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((result): result is CanvasTemplateReplaceResult => Boolean(result));
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
slots,
|
|
||||||
referenceImageUrl,
|
|
||||||
supplementalPrompt: String(raw.supplementalPrompt || "").slice(0, 2000),
|
|
||||||
results,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CanvasTemplateReplaceDialog({
|
|
||||||
availableSources,
|
|
||||||
canvasId,
|
|
||||||
initialSources,
|
|
||||||
open,
|
|
||||||
projectId,
|
|
||||||
triggerNodeId,
|
|
||||||
onAddToCanvas,
|
|
||||||
onOpenChange,
|
|
||||||
}: CanvasTemplateReplaceDialogProps) {
|
|
||||||
const uploadIdPrefix = useId();
|
|
||||||
const storageKey = `canvas-template-replace:${canvasId}:${triggerNodeId}`;
|
|
||||||
const [slots, setSlots] = useState<Array<CanvasTemplateReplaceSource | null>>(
|
|
||||||
() => createCanvasTemplateReplaceSlots(initialSources)
|
|
||||||
);
|
|
||||||
const [referenceImageUrl, setReferenceImageUrl] = useState("");
|
|
||||||
const [supplementalPrompt, setSupplementalPrompt] = useState("");
|
|
||||||
const [results, setResults] = useState<CanvasTemplateReplaceResult[]>([]);
|
|
||||||
const [canvasPickerSlot, setCanvasPickerSlot] = useState<number | null>(null);
|
|
||||||
const [canvasSearch, setCanvasSearch] = useState("");
|
|
||||||
const [uploadingSlots, setUploadingSlots] = useState<number[]>([]);
|
|
||||||
const [isUploadingReference, setIsUploadingReference] = useState(false);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
const hydratedRef = useRef(false);
|
|
||||||
const skipNextPersistRef = useRef(false);
|
|
||||||
const runningRef = useRef(false);
|
|
||||||
const mountedRef = useRef(true);
|
|
||||||
const resultsRef = useRef<CanvasTemplateReplaceResult[]>([]);
|
|
||||||
|
|
||||||
const setResultsWithRef = useCallback(
|
|
||||||
(
|
|
||||||
updater:
|
|
||||||
| CanvasTemplateReplaceResult[]
|
|
||||||
| ((current: CanvasTemplateReplaceResult[]) => CanvasTemplateReplaceResult[])
|
|
||||||
) => {
|
|
||||||
setResults((current) => {
|
|
||||||
const next = typeof updater === "function" ? updater(current) : updater;
|
|
||||||
resultsRef.current = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
mountedRef.current = true;
|
|
||||||
return () => {
|
|
||||||
mountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || hydratedRef.current) return;
|
|
||||||
hydratedRef.current = true;
|
|
||||||
const persisted = getPersistedBatch(storageKey);
|
|
||||||
if (persisted) {
|
|
||||||
skipNextPersistRef.current = true;
|
|
||||||
setSlots(persisted.slots);
|
|
||||||
setReferenceImageUrl(persisted.referenceImageUrl);
|
|
||||||
setSupplementalPrompt(persisted.supplementalPrompt);
|
|
||||||
resultsRef.current = persisted.results;
|
|
||||||
setResults(persisted.results);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSlots(createCanvasTemplateReplaceSlots(initialSources));
|
|
||||||
}, [initialSources, open, storageKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hydratedRef.current || typeof window === "undefined") return;
|
|
||||||
if (skipNextPersistRef.current) {
|
|
||||||
skipNextPersistRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload: PersistedTemplateReplaceBatch = {
|
|
||||||
version: 1,
|
|
||||||
slots,
|
|
||||||
referenceImageUrl,
|
|
||||||
supplementalPrompt,
|
|
||||||
results,
|
|
||||||
};
|
|
||||||
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
|
||||||
}, [referenceImageUrl, results, slots, storageKey, supplementalPrompt]);
|
|
||||||
|
|
||||||
const selectedSources = useMemo(
|
|
||||||
() => normalizeCanvasTemplateReplaceSources(slots),
|
|
||||||
[slots]
|
|
||||||
);
|
|
||||||
const selectedUrls = useMemo(
|
|
||||||
() => new Set(selectedSources.map((source) => source.imageUrl)),
|
|
||||||
[selectedSources]
|
|
||||||
);
|
|
||||||
const filteredCanvasSources = useMemo(() => {
|
|
||||||
const query = canvasSearch.trim().toLowerCase();
|
|
||||||
return availableSources.filter(
|
|
||||||
(source) =>
|
|
||||||
!query ||
|
|
||||||
source.title.toLowerCase().includes(query) ||
|
|
||||||
source.imageUrl.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}, [availableSources, canvasSearch]);
|
|
||||||
const completedCount = results.filter((result) => result.status === "completed").length;
|
|
||||||
const canEditSources = !isProcessing && results.length === 0;
|
|
||||||
const canStart =
|
|
||||||
selectedSources.length > 0 &&
|
|
||||||
isHttpImageUrl(referenceImageUrl) &&
|
|
||||||
!isProcessing &&
|
|
||||||
results.length === 0 &&
|
|
||||||
uploadingSlots.length === 0 &&
|
|
||||||
!isUploadingReference;
|
|
||||||
|
|
||||||
const updateResult = useCallback(
|
|
||||||
(id: string, patch: Partial<CanvasTemplateReplaceResult>) => {
|
|
||||||
setResultsWithRef((current) =>
|
|
||||||
current.map((result) => (result.id === id ? { ...result, ...patch } : result))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[setResultsWithRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const uploadImage = useCallback(async (file: File, fallbackMessage: string) => {
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
throw new Error("请上传图片文件");
|
|
||||||
}
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
const response = await fetchUploadWithRetry(formData);
|
|
||||||
const payload = await response.json().catch(() => null);
|
|
||||||
const uploadedUrl = String(payload?.url || "").trim();
|
|
||||||
if (!response.ok || !isHttpImageUrl(uploadedUrl)) {
|
|
||||||
throw new Error(payload?.error || fallbackMessage);
|
|
||||||
}
|
|
||||||
return uploadedUrl;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSourceUpload = useCallback(
|
|
||||||
async (slotIndex: number, file?: File) => {
|
|
||||||
if (!file || !canEditSources) return;
|
|
||||||
setUploadingSlots((current) => [...new Set([...current, slotIndex])]);
|
|
||||||
try {
|
|
||||||
const imageUrl = await uploadImage(file, "替换素材上传失败");
|
|
||||||
setSlots((current) =>
|
|
||||||
replaceCanvasTemplateSourceSlot(current, slotIndex, {
|
|
||||||
id: createLocalId("template-upload"),
|
|
||||||
imageUrl,
|
|
||||||
title: file.name || `上传素材${slotIndex + 1}`,
|
|
||||||
origin: "upload",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "替换素材上传失败");
|
|
||||||
} finally {
|
|
||||||
setUploadingSlots((current) => current.filter((index) => index !== slotIndex));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[canEditSources, uploadImage]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleReferenceUpload = useCallback(
|
|
||||||
async (file?: File) => {
|
|
||||||
if (!file || !canEditSources) return;
|
|
||||||
setIsUploadingReference(true);
|
|
||||||
try {
|
|
||||||
setReferenceImageUrl(await uploadImage(file, "目标模版图上传失败"));
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "目标模版图上传失败");
|
|
||||||
} finally {
|
|
||||||
setIsUploadingReference(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[canEditSources, uploadImage]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pollTask = useCallback(
|
|
||||||
async (resultId: string, taskId: string) => {
|
|
||||||
while (mountedRef.current) {
|
|
||||||
const taskResponse = await imageGenerationService.getImageTask(taskId);
|
|
||||||
const task = taskResponse.data?.task;
|
|
||||||
if (!taskResponse.success || !task) {
|
|
||||||
await sleep(TEMPLATE_REPLACE_POLL_INTERVAL_MS);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = String(task.status || "").toLowerCase();
|
|
||||||
const queueStatus = normalizeQueueStatus(task.queueStatus);
|
|
||||||
if (status === "pending") {
|
|
||||||
updateResult(resultId, { status: "queued", queueStatus, error: undefined });
|
|
||||||
} else if (status === "running" || status === "processing") {
|
|
||||||
updateResult(resultId, { status: "processing", queueStatus, error: undefined });
|
|
||||||
} else if (status === "succeeded" || status === "completed") {
|
|
||||||
const image = task.result?.images?.[0];
|
|
||||||
const imageUrl = String(image?.originalUrl || image?.thumbnailUrl || "").trim();
|
|
||||||
if (!isHttpImageUrl(imageUrl)) {
|
|
||||||
updateResult(resultId, { taskId: undefined });
|
|
||||||
throw new Error("任务已完成,但没有返回结果图片");
|
|
||||||
}
|
|
||||||
updateResult(resultId, {
|
|
||||||
status: "completed",
|
|
||||||
resultImageUrl: imageUrl,
|
|
||||||
queueStatus: undefined,
|
|
||||||
error: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else if (["failed", "cancelled", "expired", "banned"].includes(status)) {
|
|
||||||
updateResult(resultId, { taskId: undefined });
|
|
||||||
throw new Error(task.error || "模版替换生成失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(TEMPLATE_REPLACE_POLL_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[updateResult]
|
|
||||||
);
|
|
||||||
|
|
||||||
const runSequential = useCallback(
|
|
||||||
async (resultIds?: string[]) => {
|
|
||||||
if (runningRef.current) return;
|
|
||||||
runningRef.current = true;
|
|
||||||
setIsProcessing(true);
|
|
||||||
const requestedIds = resultIds ? new Set(resultIds) : null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const orderedIds = resultsRef.current
|
|
||||||
.filter((result) => !requestedIds || requestedIds.has(result.id))
|
|
||||||
.map((result) => result.id);
|
|
||||||
|
|
||||||
for (const resultId of orderedIds) {
|
|
||||||
if (!mountedRef.current) return;
|
|
||||||
const current = resultsRef.current.find((result) => result.id === resultId);
|
|
||||||
if (!current || current.status === "completed") continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let taskId = current.taskId;
|
|
||||||
if (!taskId) {
|
|
||||||
updateResult(resultId, {
|
|
||||||
status: "submitting",
|
|
||||||
error: undefined,
|
|
||||||
queueStatus: undefined,
|
|
||||||
});
|
|
||||||
const submitResult = await imageGenerationService.layoutReplace({
|
|
||||||
sourceImageUrl: current.source.imageUrl,
|
|
||||||
referenceImageUrl: current.referenceImageUrl,
|
|
||||||
supplementalPrompt: supplementalPrompt.trim() || undefined,
|
|
||||||
sourceNodeId: current.source.sourceNodeId,
|
|
||||||
agentProjectId: projectId || undefined,
|
|
||||||
referenceLabel: "无限画布批量模版替换目标图",
|
|
||||||
imageSize: "2K",
|
|
||||||
debugMeta: {
|
|
||||||
operation: "canvas-template-replace-batch",
|
|
||||||
batchId: current.batchId,
|
|
||||||
canvasId,
|
|
||||||
sourceIndex: resultsRef.current.findIndex(
|
|
||||||
(result) => result.id === resultId
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
taskId = String(submitResult.data?.taskId || "").trim();
|
|
||||||
if (!submitResult.success || !taskId) {
|
|
||||||
throw new Error(submitResult.error || "模版替换提交失败");
|
|
||||||
}
|
|
||||||
updateResult(resultId, { taskId, status: "queued" });
|
|
||||||
}
|
|
||||||
await pollTask(resultId, taskId);
|
|
||||||
} catch (error) {
|
|
||||||
updateResult(resultId, {
|
|
||||||
status: "failed",
|
|
||||||
queueStatus: undefined,
|
|
||||||
error: error instanceof Error ? error.message : "模版替换生成失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
runningRef.current = false;
|
|
||||||
if (mountedRef.current) setIsProcessing(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[canvasId, pollTask, projectId, supplementalPrompt, updateResult]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!open ||
|
|
||||||
!hydratedRef.current ||
|
|
||||||
runningRef.current ||
|
|
||||||
!results.some((result) =>
|
|
||||||
["waiting", "submitting", "queued", "processing"].includes(result.status)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void runSequential();
|
|
||||||
}, [open, results, runSequential]);
|
|
||||||
|
|
||||||
const handleStart = useCallback(() => {
|
|
||||||
if (!canStart) return;
|
|
||||||
const batchId = createLocalId("template-batch");
|
|
||||||
const nextResults = selectedSources.map(
|
|
||||||
(source, index): CanvasTemplateReplaceResult => ({
|
|
||||||
id: createLocalId("template-result"),
|
|
||||||
batchId,
|
|
||||||
source,
|
|
||||||
referenceImageUrl: referenceImageUrl.trim(),
|
|
||||||
status: index === 0 ? "submitting" : "waiting",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
resultsRef.current = nextResults;
|
|
||||||
setResults(nextResults);
|
|
||||||
toast.success(`已开始处理 ${nextResults.length} 张图片`);
|
|
||||||
void runSequential(nextResults.map((result) => result.id));
|
|
||||||
}, [canStart, referenceImageUrl, runSequential, selectedSources]);
|
|
||||||
|
|
||||||
const handleRetry = useCallback(
|
|
||||||
(result: CanvasTemplateReplaceResult) => {
|
|
||||||
if (isProcessing) return;
|
|
||||||
updateResult(result.id, {
|
|
||||||
status: result.taskId ? "queued" : "waiting",
|
|
||||||
error: undefined,
|
|
||||||
queueStatus: undefined,
|
|
||||||
});
|
|
||||||
window.setTimeout(() => void runSequential([result.id]), 0);
|
|
||||||
},
|
|
||||||
[isProcessing, runSequential, updateResult]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddResult = useCallback(
|
|
||||||
async (result: CanvasTemplateReplaceResult, resultIndex: number) => {
|
|
||||||
if (!result.resultImageUrl || result.addedToCanvas) return;
|
|
||||||
try {
|
|
||||||
await onAddToCanvas(result, resultIndex);
|
|
||||||
updateResult(result.id, { addedToCanvas: true });
|
|
||||||
toast.success("结果已添加到画布");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "添加到画布失败");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onAddToCanvas, updateResult]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddAll = useCallback(async () => {
|
|
||||||
const completedResults = resultsRef.current.filter(
|
|
||||||
(result) => result.status === "completed" && !result.addedToCanvas
|
|
||||||
);
|
|
||||||
for (const result of completedResults) {
|
|
||||||
const resultIndex = resultsRef.current.findIndex((item) => item.id === result.id);
|
|
||||||
await handleAddResult(result, resultIndex);
|
|
||||||
}
|
|
||||||
}, [handleAddResult]);
|
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
if (isProcessing) return;
|
|
||||||
const nextSlots = createCanvasTemplateReplaceSlots(initialSources);
|
|
||||||
resultsRef.current = [];
|
|
||||||
setResults([]);
|
|
||||||
setSlots(nextSlots);
|
|
||||||
setReferenceImageUrl("");
|
|
||||||
setSupplementalPrompt("");
|
|
||||||
setCanvasPickerSlot(null);
|
|
||||||
if (typeof window !== "undefined") window.localStorage.removeItem(storageKey);
|
|
||||||
}, [initialSources, isProcessing, storageKey]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[1080px] flex-col gap-0 overflow-hidden p-0 sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
|
|
||||||
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12 text-left sm:px-6 sm:py-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-base">
|
|
||||||
<LayoutTemplate className="h-4 w-4" />
|
|
||||||
模版替换
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="mt-1">
|
|
||||||
最多 5 张替换素材,共用 1 张目标模版图。
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
{results.length > 0 && !isProcessing ? (
|
|
||||||
<Button className="shrink-0" onClick={handleReset} size="sm" variant="outline">
|
|
||||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
新建一批
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<Label>替换素材</Label>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{selectedSources.length}/{MAX_CANVAS_TEMPLATE_REPLACE_SOURCES}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
|
|
||||||
{slots.map((source, slotIndex) => {
|
|
||||||
const isUploading = uploadingSlots.includes(slotIndex);
|
|
||||||
const uploadId = `${uploadIdPrefix}-source-${slotIndex}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="group relative aspect-square min-w-0 overflow-hidden rounded-lg border bg-muted/30"
|
|
||||||
key={`source-slot-${slotIndex}`}
|
|
||||||
>
|
|
||||||
{source ? (
|
|
||||||
<>
|
|
||||||
<AgentPreviewImage
|
|
||||||
alt={source.title}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
previewQuality={78}
|
|
||||||
previewWidth={360}
|
|
||||||
src={source.imageUrl}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-2 py-1.5 text-[11px] text-white">
|
|
||||||
{slotIndex + 1}. {source.title}
|
|
||||||
</div>
|
|
||||||
{canEditSources ? (
|
|
||||||
<button
|
|
||||||
aria-label={`移除素材 ${slotIndex + 1}`}
|
|
||||||
className="absolute top-1.5 right-1.5 flex h-7 w-7 items-center justify-center rounded-md bg-black/70 text-white opacity-100 hover:bg-black sm:opacity-0 sm:group-hover:opacity-100"
|
|
||||||
onClick={() =>
|
|
||||||
setSlots((current) =>
|
|
||||||
replaceCanvasTemplateSourceSlot(current, slotIndex, null)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-2 text-muted-foreground">
|
|
||||||
{isUploading ? (
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ImagePlus className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
<span className="text-xs">素材 {slotIndex + 1}</span>
|
|
||||||
{canEditSources ? (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
onClick={() => setCanvasPickerSlot(slotIndex)}
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Images className="mr-1 h-3 w-3" />
|
|
||||||
画布
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<label htmlFor={uploadId}>
|
|
||||||
<Upload className="h-3 w-3" />
|
|
||||||
</label>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
|
||||||
className="sr-only"
|
|
||||||
disabled={!canEditSources || isUploading}
|
|
||||||
id={uploadId}
|
|
||||||
onChange={(event) => {
|
|
||||||
const file = event.currentTarget.files?.[0];
|
|
||||||
event.currentTarget.value = "";
|
|
||||||
void handleSourceUpload(slotIndex, file);
|
|
||||||
}}
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canvasPickerSlot !== null && canEditSources ? (
|
|
||||||
<div className="rounded-lg border bg-background p-3">
|
|
||||||
<div className="mb-3 flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
className="h-9"
|
|
||||||
onChange={(event) => setCanvasSearch(event.currentTarget.value)}
|
|
||||||
placeholder="搜索画布图片"
|
|
||||||
value={canvasSearch}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => setCanvasPickerSlot(null)}
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid max-h-52 grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-5 md:grid-cols-7">
|
|
||||||
{filteredCanvasSources.map((source) => {
|
|
||||||
const alreadySelected = selectedUrls.has(source.imageUrl);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"relative aspect-square overflow-hidden rounded-lg border bg-muted/30 text-left",
|
|
||||||
alreadySelected
|
|
||||||
? "cursor-not-allowed opacity-40"
|
|
||||||
: "hover:border-primary"
|
|
||||||
)}
|
|
||||||
disabled={alreadySelected}
|
|
||||||
key={source.id}
|
|
||||||
onClick={() => {
|
|
||||||
setSlots((current) =>
|
|
||||||
replaceCanvasTemplateSourceSlot(
|
|
||||||
current,
|
|
||||||
canvasPickerSlot,
|
|
||||||
source
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setCanvasPickerSlot(null);
|
|
||||||
setCanvasSearch("");
|
|
||||||
}}
|
|
||||||
title={source.title}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<AgentPreviewImage
|
|
||||||
alt={source.title}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
previewQuality={72}
|
|
||||||
previewWidth={240}
|
|
||||||
src={source.imageUrl}
|
|
||||||
/>
|
|
||||||
<span className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-1.5 py-1 text-[10px] text-white">
|
|
||||||
{source.title}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`${uploadIdPrefix}-reference`}>目标模版图</Label>
|
|
||||||
<div className="flex min-w-0 gap-2">
|
|
||||||
<Input
|
|
||||||
className="min-w-0"
|
|
||||||
disabled={!canEditSources}
|
|
||||||
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
|
|
||||||
placeholder="填写图片链接或上传"
|
|
||||||
value={referenceImageUrl}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
className="shrink-0"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<label htmlFor={`${uploadIdPrefix}-reference`}>
|
|
||||||
{isUploadingReference ? (
|
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Upload className="mr-1.5 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
上传
|
|
||||||
</label>
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
|
||||||
className="sr-only"
|
|
||||||
disabled={!canEditSources || isUploadingReference}
|
|
||||||
id={`${uploadIdPrefix}-reference`}
|
|
||||||
onChange={(event) => {
|
|
||||||
const file = event.currentTarget.files?.[0];
|
|
||||||
event.currentTarget.value = "";
|
|
||||||
void handleReferenceUpload(file);
|
|
||||||
}}
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-[clamp(160px,28dvh,280px)] items-center justify-center overflow-hidden rounded-lg border border-dashed bg-muted/20">
|
|
||||||
{isUploadingReference ? (
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
) : isHttpImageUrl(referenceImageUrl) ? (
|
|
||||||
<AgentPreviewImage
|
|
||||||
alt="目标模版图"
|
|
||||||
className="max-h-full max-w-full object-contain"
|
|
||||||
previewQuality={82}
|
|
||||||
previewWidth={720}
|
|
||||||
src={referenceImageUrl}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LayoutTemplate className="h-8 w-8 text-muted-foreground/45" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Label htmlFor={`${uploadIdPrefix}-prompt`}>补充要求</Label>
|
|
||||||
<Textarea
|
|
||||||
className="min-h-20 max-h-36"
|
|
||||||
disabled={!canEditSources}
|
|
||||||
id={`${uploadIdPrefix}-prompt`}
|
|
||||||
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
|
|
||||||
placeholder="可选:补充材质、颜色或替换细节"
|
|
||||||
value={supplementalPrompt}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 space-y-2">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<Label>生图结果</Label>
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{completedCount}/{results.length}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<div className="flex h-[clamp(220px,42dvh,420px)] items-center justify-center rounded-lg border border-dashed bg-muted/20 text-muted-foreground">
|
|
||||||
<div className="text-center">
|
|
||||||
<Images className="mx-auto mb-2 h-7 w-7 opacity-45" />
|
|
||||||
<p className="text-sm">待生成</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid max-h-[clamp(300px,52dvh,560px)] grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
|
||||||
{results.map((result, resultIndex) => {
|
|
||||||
const active = ["submitting", "queued", "processing"].includes(
|
|
||||||
result.status
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-w-0 overflow-hidden rounded-lg border bg-background"
|
|
||||||
key={result.id}
|
|
||||||
>
|
|
||||||
<div className="relative aspect-[4/3] bg-muted/30">
|
|
||||||
<AgentPreviewImage
|
|
||||||
alt={`${result.source.title}结果`}
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
previewQuality={82}
|
|
||||||
previewWidth={640}
|
|
||||||
src={result.resultImageUrl || result.source.imageUrl}
|
|
||||||
/>
|
|
||||||
{result.status !== "completed" ? (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-[1px]">
|
|
||||||
<div className="max-w-[85%] text-center">
|
|
||||||
{active ? (
|
|
||||||
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
|
|
||||||
) : result.status === "failed" ? (
|
|
||||||
<CircleAlert className="mx-auto mb-2 h-5 w-5 text-destructive" />
|
|
||||||
) : (
|
|
||||||
<Loader2 className="mx-auto mb-2 h-5 w-5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<p className="text-sm">{getStatusLabel(result)}</p>
|
|
||||||
{result.error ? (
|
|
||||||
<p className="mt-1 line-clamp-2 text-destructive text-xs">
|
|
||||||
{result.error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-2 p-2">
|
|
||||||
<span className="min-w-0 truncate text-xs">
|
|
||||||
{resultIndex + 1}. {result.source.title}
|
|
||||||
</span>
|
|
||||||
{result.status === "completed" ? (
|
|
||||||
<Button
|
|
||||||
className="h-8 shrink-0 px-2 text-xs"
|
|
||||||
disabled={result.addedToCanvas}
|
|
||||||
onClick={() => void handleAddResult(result, resultIndex)}
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{result.addedToCanvas ? (
|
|
||||||
<Check className="mr-1 h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
{result.addedToCanvas ? "已添加" : "添加到画布"}
|
|
||||||
</Button>
|
|
||||||
) : result.status === "failed" ? (
|
|
||||||
<Button
|
|
||||||
className="h-8 shrink-0 px-2 text-xs"
|
|
||||||
disabled={isProcessing}
|
|
||||||
onClick={() => handleRetry(result)}
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<span className="shrink-0 text-muted-foreground text-xs">
|
|
||||||
{getStatusLabel(result)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="shrink-0 gap-2 border-t bg-background px-4 py-3 sm:px-6">
|
|
||||||
<Button onClick={() => onOpenChange(false)} type="button" variant="outline">
|
|
||||||
{isProcessing ? "收起" : "关闭"}
|
|
||||||
</Button>
|
|
||||||
{completedCount > 0 ? (
|
|
||||||
<Button
|
|
||||||
disabled={!results.some(
|
|
||||||
(result) => result.status === "completed" && !result.addedToCanvas
|
|
||||||
)}
|
|
||||||
onClick={() => void handleAddAll()}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Plus className="mr-1.5 h-4 w-4" />
|
|
||||||
全部添加到画布
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button disabled={!canStart} onClick={handleStart} type="button">
|
|
||||||
{isProcessing ? (
|
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<LayoutTemplate className="mr-1.5 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{isProcessing
|
|
||||||
? `处理中 ${completedCount}/${results.length}`
|
|
||||||
: `批量处理${selectedSources.length ? `(${selectedSources.length})` : ""}`}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -20,12 +20,12 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
Download,
|
Download,
|
||||||
FileImage,
|
|
||||||
Flag,
|
Flag,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Heart,
|
Heart,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
|
LayoutTemplate,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Minus,
|
Minus,
|
||||||
@ -716,14 +716,14 @@ export function CanvasWorkspace({
|
|||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageLocalEdit"
|
"imageLocalEdit"
|
||||||
);
|
);
|
||||||
const selectedImageTemplateReplaceItem = getCanvasToolbarItem(
|
const selectedImageLayoutReplaceItem = getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageTemplateReplace"
|
"imageLayoutReplace"
|
||||||
);
|
);
|
||||||
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
||||||
const selectedImageLocalEditEnabled = true;
|
const selectedImageLocalEditEnabled = true;
|
||||||
// 模版替换属于主无限画布图片的固定入口,兼容没有新 key 的历史配置。
|
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
|
||||||
const selectedImageTemplateReplaceEnabled = true;
|
const selectedImageLayoutReplaceEnabled = true;
|
||||||
|
|
||||||
// Store actions
|
// Store actions
|
||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
@ -2536,7 +2536,7 @@ export function CanvasWorkspace({
|
|||||||
{selectedImageLocalEditItem.label}
|
{selectedImageLocalEditItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedImageTemplateReplaceEnabled &&
|
{selectedImageLayoutReplaceEnabled &&
|
||||||
selectedImageNodes.length === 1 ? (
|
selectedImageNodes.length === 1 ? (
|
||||||
<Button
|
<Button
|
||||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
@ -2547,16 +2547,16 @@ export function CanvasWorkspace({
|
|||||||
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requestImageNodeAction(selectedImageNodes[0].id, "template-replace")
|
requestImageNodeAction(selectedImageNodes[0].id, "layout-replace")
|
||||||
}
|
}
|
||||||
title={selectedImageTemplateReplaceItem.label}
|
title={selectedImageLayoutReplaceItem.label}
|
||||||
>
|
>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-3.5 w-3.5"
|
className="h-3.5 w-3.5"
|
||||||
fallback={FileImage}
|
fallback={LayoutTemplate}
|
||||||
item={selectedImageTemplateReplaceItem}
|
item={selectedImageLayoutReplaceItem}
|
||||||
/>
|
/>
|
||||||
{selectedImageTemplateReplaceItem.label}
|
{selectedImageLayoutReplaceItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
Globe2,
|
Globe2,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Layers,
|
Layers,
|
||||||
|
LayoutTemplate,
|
||||||
Loader2,
|
Loader2,
|
||||||
Link2,
|
Link2,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
@ -52,10 +53,6 @@ import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceO
|
|||||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||||
import {
|
|
||||||
CanvasTemplateReplaceDialog,
|
|
||||||
type CanvasTemplateReplaceResult,
|
|
||||||
} from "@/components/canvas/CanvasTemplateReplaceDialog";
|
|
||||||
import {
|
import {
|
||||||
areImageAdjustmentsEqual,
|
areImageAdjustmentsEqual,
|
||||||
buildImageAdjustmentFilterStyle,
|
buildImageAdjustmentFilterStyle,
|
||||||
@ -140,6 +137,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@ -225,7 +223,6 @@ import {
|
|||||||
} from "@/lib/ai-wash/config";
|
} from "@/lib/ai-wash/config";
|
||||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||||
import type { CanvasTemplateReplaceSource } from "@/lib/canvas/template-replace-batch";
|
|
||||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
||||||
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
||||||
@ -660,6 +657,8 @@ const CANVAS_3D_POLL_INTERVAL_MS = 2000;
|
|||||||
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
|
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
|
||||||
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
|
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
|
||||||
const AI_WASH_TASK_POLL_LIMIT = 80;
|
const AI_WASH_TASK_POLL_LIMIT = 80;
|
||||||
|
const LAYOUT_REPLACE_TASK_POLL_INTERVAL_MS = 2500;
|
||||||
|
const LAYOUT_REPLACE_TASK_POLL_LIMIT = 240;
|
||||||
const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000;
|
const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000;
|
||||||
const HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
|
const HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
|
||||||
const HYDRATED_IMAGE_TASK_POLL_LIMIT = 240;
|
const HYDRATED_IMAGE_TASK_POLL_LIMIT = 240;
|
||||||
@ -805,6 +804,76 @@ async function pollAiWashQuickTask(taskId: string) {
|
|||||||
throw new Error("AI洗图任务轮询超时,请稍后重试");
|
throw new Error("AI洗图任务轮询超时,请稍后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pollLayoutReplaceTask(taskId: string) {
|
||||||
|
for (let attempt = 0; attempt < LAYOUT_REPLACE_TASK_POLL_LIMIT; attempt += 1) {
|
||||||
|
await sleep(LAYOUT_REPLACE_TASK_POLL_INTERVAL_MS);
|
||||||
|
const taskResult = await imageGenerationService.getImageTask(taskId);
|
||||||
|
const task = taskResult.data?.task;
|
||||||
|
const status = String(task?.status || "").toLowerCase();
|
||||||
|
|
||||||
|
if (!taskResult.success || !task) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "succeeded" || status === "completed") {
|
||||||
|
const image = task.result?.images?.[0];
|
||||||
|
const imageUrl = image?.originalUrl || image?.thumbnailUrl || "";
|
||||||
|
if (!imageUrl) {
|
||||||
|
throw new Error("排版替换已完成但未返回图片");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
imageUrl,
|
||||||
|
thumbnailUrl: image?.thumbnailUrl || imageUrl,
|
||||||
|
modelId: String(task.result?.model || task.result?.modelRef || "").trim(),
|
||||||
|
prompt: String(task.result?.prompt || "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
|
||||||
|
throw new Error(task.error || "排版替换生成失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("排版替换任务等待超时,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveImageNaturalDimensions(imageUrl: string) {
|
||||||
|
const source = String(imageUrl || "").trim();
|
||||||
|
if (!source || typeof window === "undefined") {
|
||||||
|
throw new Error("参考图地址不可用");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<{ width: number; height: number }>((resolve, reject) => {
|
||||||
|
const image = new window.Image();
|
||||||
|
const cleanup = () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
image.onload = null;
|
||||||
|
image.onerror = null;
|
||||||
|
};
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("读取参考图尺寸超时"));
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
image.onload = () => {
|
||||||
|
const width = image.naturalWidth || image.width;
|
||||||
|
const height = image.naturalHeight || image.height;
|
||||||
|
cleanup();
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
resolve({ width, height });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error("参考图尺寸不可用"));
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("读取参考图尺寸失败"));
|
||||||
|
};
|
||||||
|
image.decoding = "async";
|
||||||
|
image.src = buildImageProxyUrl(source, { delivery: "proxy" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const isTerminal3DFailureStatus = (status?: string | null) =>
|
const isTerminal3DFailureStatus = (status?: string | null) =>
|
||||||
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
|
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
|
||||||
|
|
||||||
@ -1357,9 +1426,9 @@ export function ImageNode({
|
|||||||
);
|
);
|
||||||
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
|
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
|
||||||
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
|
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
|
||||||
const imageTemplateReplaceItem = getCanvasToolbarItem(
|
const imageLayoutReplaceItem = getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageTemplateReplace"
|
"imageLayoutReplace"
|
||||||
);
|
);
|
||||||
const imageRemoveWatermarkItem = getCanvasToolbarItem(
|
const imageRemoveWatermarkItem = getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
@ -1433,8 +1502,8 @@ export function ImageNode({
|
|||||||
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
|
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
|
||||||
// 也必须默认展示,避免历史配置把新功能吞掉。
|
// 也必须默认展示,避免历史配置把新功能吞掉。
|
||||||
const imageLocalEditEnabled = true;
|
const imageLocalEditEnabled = true;
|
||||||
// 模版替换是无限画布图片的固定能力,历史配置缺少新 key 时仍保持可见。
|
// 排版替换是无限画布图片的核心 AI 入口;旧配置缺少该 key 时也保持可见。
|
||||||
const imageTemplateReplaceEnabled = true;
|
const imageLayoutReplaceEnabled = true;
|
||||||
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
|
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageLayerSplit"
|
"imageLayerSplit"
|
||||||
@ -1500,7 +1569,7 @@ export function ImageNode({
|
|||||||
{ key: "textEdit", enabled: imageTextEditEnabled },
|
{ key: "textEdit", enabled: imageTextEditEnabled },
|
||||||
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
|
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
|
||||||
{ key: "localEdit", enabled: imageLocalEditEnabled },
|
{ key: "localEdit", enabled: imageLocalEditEnabled },
|
||||||
{ key: "templateReplace", enabled: imageTemplateReplaceEnabled },
|
{ key: "layoutReplace", enabled: imageLayoutReplaceEnabled },
|
||||||
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
|
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
|
||||||
{ key: "resize", enabled: imageResizeEnabled },
|
{ key: "resize", enabled: imageResizeEnabled },
|
||||||
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
|
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
|
||||||
@ -1536,7 +1605,7 @@ export function ImageNode({
|
|||||||
imageRemoveBackgroundEnabled ||
|
imageRemoveBackgroundEnabled ||
|
||||||
imageRemoveWatermarkEnabled ||
|
imageRemoveWatermarkEnabled ||
|
||||||
imageEnhanceEnabled ||
|
imageEnhanceEnabled ||
|
||||||
imageTemplateReplaceEnabled ||
|
imageLayoutReplaceEnabled ||
|
||||||
imageNoteEnabled ||
|
imageNoteEnabled ||
|
||||||
hasCanvasAgentAnnotations ||
|
hasCanvasAgentAnnotations ||
|
||||||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
|
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
|
||||||
@ -1695,7 +1764,12 @@ export function ImageNode({
|
|||||||
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
|
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
|
||||||
);
|
);
|
||||||
const [regeneratePrompt, setRegeneratePrompt] = useState("");
|
const [regeneratePrompt, setRegeneratePrompt] = useState("");
|
||||||
const [templateReplaceDialogOpen, setTemplateReplaceDialogOpen] = useState(false);
|
const [layoutReplaceDialogOpen, setLayoutReplaceDialogOpen] = useState(false);
|
||||||
|
const [layoutReplaceReferenceUrl, setLayoutReplaceReferenceUrl] = useState("");
|
||||||
|
const [layoutReplacePrompt, setLayoutReplacePrompt] = useState("");
|
||||||
|
const [isUploadingLayoutReplaceReference, setIsUploadingLayoutReplaceReference] =
|
||||||
|
useState(false);
|
||||||
|
const [isSubmittingLayoutReplace, setIsSubmittingLayoutReplace] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||||
@ -2324,51 +2398,6 @@ export function ImageNode({
|
|||||||
y: number;
|
y: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const displayTitle = getImageNodeTitle(data, node.id);
|
const displayTitle = getImageNodeTitle(data, node.id);
|
||||||
const availableTemplateReplaceSources = useMemo<CanvasTemplateReplaceSource[]>(
|
|
||||||
() =>
|
|
||||||
allNodes.flatMap((canvasNode) => {
|
|
||||||
if (canvasNode.type !== "image") return [];
|
|
||||||
const imageData = canvasNode.data as ImageNodeData;
|
|
||||||
if (!isHttpImageUrl(imageData.src)) return [];
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: canvasNode.id,
|
|
||||||
imageUrl: imageData.src,
|
|
||||||
title: getImageNodeTitle(imageData, canvasNode.id),
|
|
||||||
origin: "canvas" as const,
|
|
||||||
sourceNodeId: canvasNode.id,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
[allNodes]
|
|
||||||
);
|
|
||||||
const initialTemplateReplaceSources = useMemo<CanvasTemplateReplaceSource[]>(() => {
|
|
||||||
const sourceByNodeId = new Map(
|
|
||||||
availableTemplateReplaceSources.map((source) => [source.sourceNodeId, source])
|
|
||||||
);
|
|
||||||
const currentSource = sourceByNodeId.get(node.id) ||
|
|
||||||
(isHttpImageUrl(data.src)
|
|
||||||
? {
|
|
||||||
id: node.id,
|
|
||||||
imageUrl: data.src,
|
|
||||||
title: displayTitle,
|
|
||||||
origin: "canvas" as const,
|
|
||||||
sourceNodeId: node.id,
|
|
||||||
}
|
|
||||||
: null);
|
|
||||||
return [
|
|
||||||
currentSource,
|
|
||||||
...selectedNodeIds
|
|
||||||
.filter((selectedNodeId) => selectedNodeId !== node.id)
|
|
||||||
.map((selectedNodeId) => sourceByNodeId.get(selectedNodeId) || null),
|
|
||||||
].filter((source): source is CanvasTemplateReplaceSource => Boolean(source));
|
|
||||||
}, [
|
|
||||||
availableTemplateReplaceSources,
|
|
||||||
data.src,
|
|
||||||
displayTitle,
|
|
||||||
node.id,
|
|
||||||
selectedNodeIds,
|
|
||||||
]);
|
|
||||||
const agentExecutionSnapshotId =
|
const agentExecutionSnapshotId =
|
||||||
typeof data.metadata?.agentExecutionSnapshotId === "string"
|
typeof data.metadata?.agentExecutionSnapshotId === "string"
|
||||||
? data.metadata.agentExecutionSnapshotId.trim()
|
? data.metadata.agentExecutionSnapshotId.trim()
|
||||||
@ -6113,17 +6142,22 @@ export function ImageNode({
|
|||||||
await detectTextItems(true);
|
await detectTextItems(true);
|
||||||
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
||||||
|
|
||||||
const handleOpenTemplateReplace = useCallback(() => {
|
const handleOpenLayoutReplace = useCallback(() => {
|
||||||
if (!canvasId) {
|
setCropPanelOpen(false);
|
||||||
toast.error("当前图片未绑定画布,暂时无法进行模版替换");
|
setCropInteraction(null);
|
||||||
return;
|
setImageAdjustPanelOpen(false);
|
||||||
}
|
setFlipRotatePanelOpen(false);
|
||||||
if (!data.src || !isHttpImageUrl(data.src)) {
|
setAnglePanelOpen(false);
|
||||||
toast.error("图片上传完成后再进行模版替换");
|
setTextEditPanelOpen(false);
|
||||||
return;
|
setNotePanelOpen(false);
|
||||||
}
|
setPromptDialogOpen(false);
|
||||||
setTemplateReplaceDialogOpen(true);
|
setAiWashQuickPanelOpen(false);
|
||||||
}, [canvasId, data.src]);
|
resetBoxCutoutState();
|
||||||
|
resetLocalEditState();
|
||||||
|
setLayoutReplaceReferenceUrl("");
|
||||||
|
setLayoutReplacePrompt("");
|
||||||
|
setLayoutReplaceDialogOpen(true);
|
||||||
|
}, [resetBoxCutoutState, resetLocalEditState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
|
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
|
||||||
@ -6147,14 +6181,14 @@ export function ImageNode({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageNodeActionRequest.action === "template-replace") {
|
if (imageNodeActionRequest.action === "layout-replace") {
|
||||||
handleOpenTemplateReplace();
|
handleOpenLayoutReplace();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
clearImageNodeActionRequest,
|
clearImageNodeActionRequest,
|
||||||
handleEnterBoxCutoutMode,
|
handleEnterBoxCutoutMode,
|
||||||
handleEnterLocalEditMode,
|
handleEnterLocalEditMode,
|
||||||
handleOpenTemplateReplace,
|
handleOpenLayoutReplace,
|
||||||
handleOpenTextEdit,
|
handleOpenTextEdit,
|
||||||
imageNodeActionRequest,
|
imageNodeActionRequest,
|
||||||
node.id,
|
node.id,
|
||||||
@ -6444,6 +6478,248 @@ export function ImageNode({
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLayoutReplaceReferenceUpload = useCallback(
|
||||||
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
toast.error("请上传图片文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploadingLayoutReplaceReference(true);
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadChatAttachmentBlob(file, file.name);
|
||||||
|
setLayoutReplaceReferenceUrl(uploaded.url);
|
||||||
|
toast.success("参考图已上传");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "参考图上传失败");
|
||||||
|
} finally {
|
||||||
|
setIsUploadingLayoutReplaceReference(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmitLayoutReplace = useCallback(async () => {
|
||||||
|
const sourceUrl = String(data.src || "").trim();
|
||||||
|
const referenceUrl = layoutReplaceReferenceUrl.trim();
|
||||||
|
|
||||||
|
if (!sourceUrl) {
|
||||||
|
toast.error("请先上传图片");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCanvasImageUploadPending(node) || !isHttpImageUrl(sourceUrl)) {
|
||||||
|
toast.error("图片仍在本地,上传完成后再进行 AI 处理");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isHttpImageUrl(referenceUrl)) {
|
||||||
|
toast.error("请先上传或填写可访问的参考图地址");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isProcessing || isSubmittingLayoutReplace) {
|
||||||
|
toast.info("正在处理中,请稍候...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionName = "排版替换";
|
||||||
|
const resultTitle = `${displayTitle}-${actionName}`;
|
||||||
|
const displayPrompt = `${actionName}处理中...`;
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
|
||||||
|
setLayoutReplaceDialogOpen(false);
|
||||||
|
setIsCreatingDetachedResultNode(true);
|
||||||
|
setIsProcessing(true);
|
||||||
|
setIsSubmittingLayoutReplace(true);
|
||||||
|
setAiWashQuickPanelOpen(false);
|
||||||
|
|
||||||
|
const newNodeId = addNode({
|
||||||
|
type: "image",
|
||||||
|
position: {
|
||||||
|
x: node.position.x + node.size.width + 20,
|
||||||
|
y: node.position.y,
|
||||||
|
},
|
||||||
|
size: { width: node.size.width, height: node.size.height },
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "generating",
|
||||||
|
prompt: displayPrompt,
|
||||||
|
title: resultTitle,
|
||||||
|
originalSrc: sourceUrl,
|
||||||
|
metadata: {
|
||||||
|
editMode: "layout-replace",
|
||||||
|
sourceNodeId: node.id,
|
||||||
|
layoutReferenceUrl: referenceUrl,
|
||||||
|
layoutReplaceStartedAt: createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connections: [node.id],
|
||||||
|
});
|
||||||
|
selectNode(newNodeId);
|
||||||
|
|
||||||
|
let aspectRatio = guessNearestAspectRatio(node.size.width, node.size.height);
|
||||||
|
let referenceDimensions: { width: number; height: number } | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
referenceDimensions = await resolveImageNaturalDimensions(referenceUrl);
|
||||||
|
aspectRatio = guessNearestAspectRatio(
|
||||||
|
referenceDimensions.width,
|
||||||
|
referenceDimensions.height
|
||||||
|
);
|
||||||
|
const resultSize = fitSizeWithinBox({
|
||||||
|
maxHeight: Math.max(
|
||||||
|
320,
|
||||||
|
Math.min(1800, Math.round(Math.max(node.size.height, node.size.width) * 3.2))
|
||||||
|
),
|
||||||
|
maxWidth: Math.max(160, Math.min(900, Math.round(node.size.width))),
|
||||||
|
minHeight: 120,
|
||||||
|
minWidth: 120,
|
||||||
|
sourceHeight: referenceDimensions.height,
|
||||||
|
sourceWidth: referenceDimensions.width,
|
||||||
|
});
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
size: resultSize,
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "generating",
|
||||||
|
prompt: displayPrompt,
|
||||||
|
title: resultTitle,
|
||||||
|
originalSrc: sourceUrl,
|
||||||
|
metadata: {
|
||||||
|
editMode: "layout-replace",
|
||||||
|
sourceNodeId: node.id,
|
||||||
|
layoutReferenceUrl: referenceUrl,
|
||||||
|
layoutReferenceWidth: referenceDimensions.width,
|
||||||
|
layoutReferenceHeight: referenceDimensions.height,
|
||||||
|
layoutAspectRatio: aspectRatio,
|
||||||
|
layoutReplaceStartedAt: createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (dimensionError) {
|
||||||
|
console.warn("[Canvas Layout Replace] 读取参考图尺寸失败:", dimensionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitResult = await imageGenerationService.layoutReplace({
|
||||||
|
sourceImageUrl: sourceUrl,
|
||||||
|
referenceImageUrl: referenceUrl,
|
||||||
|
supplementalPrompt: layoutReplacePrompt,
|
||||||
|
imageSize: "2K",
|
||||||
|
sourceNodeId: node.id,
|
||||||
|
referenceLabel: "画布排版参考图",
|
||||||
|
debugMeta: {
|
||||||
|
layoutReferenceUrl: referenceUrl,
|
||||||
|
layoutReferenceWidth: referenceDimensions?.width,
|
||||||
|
layoutReferenceHeight: referenceDimensions?.height,
|
||||||
|
layoutAspectRatio: aspectRatio,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!submitResult.success) {
|
||||||
|
throw new Error(submitResult.error || "排版替换提交失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = submitResult.data?.taskId;
|
||||||
|
if (!taskId) {
|
||||||
|
throw new Error("排版替换未返回任务ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "generating",
|
||||||
|
prompt: displayPrompt,
|
||||||
|
title: resultTitle,
|
||||||
|
originalSrc: sourceUrl,
|
||||||
|
metadata: {
|
||||||
|
editMode: "layout-replace",
|
||||||
|
sourceNodeId: node.id,
|
||||||
|
taskId,
|
||||||
|
layoutReferenceUrl: referenceUrl,
|
||||||
|
layoutReferenceWidth: referenceDimensions?.width,
|
||||||
|
layoutReferenceHeight: referenceDimensions?.height,
|
||||||
|
layoutAspectRatio: aspectRatio,
|
||||||
|
layoutReplaceStartedAt: createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completed = await pollLayoutReplaceTask(taskId);
|
||||||
|
|
||||||
|
if (!completed?.imageUrl) {
|
||||||
|
throw new Error("排版替换未返回结果图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: completed.imageUrl,
|
||||||
|
status: "completed",
|
||||||
|
prompt: `${actionName}结果`,
|
||||||
|
title: resultTitle,
|
||||||
|
originalSrc: sourceUrl,
|
||||||
|
model: completed.modelId || undefined,
|
||||||
|
metadata: {
|
||||||
|
editMode: "layout-replace",
|
||||||
|
sourceNodeId: node.id,
|
||||||
|
taskId,
|
||||||
|
thumbnailUrl: completed.thumbnailUrl || completed.imageUrl,
|
||||||
|
layoutReferenceUrl: referenceUrl,
|
||||||
|
layoutReferenceWidth: referenceDimensions?.width,
|
||||||
|
layoutReferenceHeight: referenceDimensions?.height,
|
||||||
|
layoutAspectRatio: aspectRatio,
|
||||||
|
normalizedImageSize: referenceDimensions
|
||||||
|
? `${referenceDimensions.width}x${referenceDimensions.height}`
|
||||||
|
: undefined,
|
||||||
|
layoutReplaceStartedAt: createdAt,
|
||||||
|
layoutReplaceCompletedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success("排版替换结果已生成到画布");
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "排版替换失败";
|
||||||
|
console.error("[Canvas Layout Replace] failed:", error);
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "failed",
|
||||||
|
prompt: `${actionName}失败`,
|
||||||
|
title: `${resultTitle}-失败`,
|
||||||
|
originalSrc: sourceUrl,
|
||||||
|
metadata: {
|
||||||
|
editMode: "layout-replace",
|
||||||
|
sourceNodeId: node.id,
|
||||||
|
error: message,
|
||||||
|
layoutReferenceUrl: referenceUrl,
|
||||||
|
layoutReferenceWidth: referenceDimensions?.width,
|
||||||
|
layoutReferenceHeight: referenceDimensions?.height,
|
||||||
|
layoutAspectRatio: aspectRatio,
|
||||||
|
layoutReplaceStartedAt: createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsCreatingDetachedResultNode(false);
|
||||||
|
setIsProcessing(false);
|
||||||
|
setIsSubmittingLayoutReplace(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
addNode,
|
||||||
|
data.src,
|
||||||
|
displayTitle,
|
||||||
|
isProcessing,
|
||||||
|
isSubmittingLayoutReplace,
|
||||||
|
layoutReplacePrompt,
|
||||||
|
layoutReplaceReferenceUrl,
|
||||||
|
node,
|
||||||
|
selectNode,
|
||||||
|
updateNode,
|
||||||
|
]);
|
||||||
|
|
||||||
// 获取操作的中文名称
|
// 获取操作的中文名称
|
||||||
const getActionName = (action: string): string => {
|
const getActionName = (action: string): string => {
|
||||||
const names: Record<string, string> = {
|
const names: Record<string, string> = {
|
||||||
@ -6464,6 +6740,7 @@ export function ImageNode({
|
|||||||
"prompt-regenerate": "重绘",
|
"prompt-regenerate": "重绘",
|
||||||
"box-cutout": "框选抠图",
|
"box-cutout": "框选抠图",
|
||||||
"local-edit": "局部编辑",
|
"local-edit": "局部编辑",
|
||||||
|
"layout-replace": "排版替换",
|
||||||
};
|
};
|
||||||
return names[action] || action;
|
return names[action] || action;
|
||||||
};
|
};
|
||||||
@ -6906,8 +7183,8 @@ export function ImageNode({
|
|||||||
handleEnterLocalEditMode();
|
handleEnterLocalEditMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "template-replace") {
|
if (action === "layout-replace") {
|
||||||
handleOpenTemplateReplace();
|
handleOpenLayoutReplace();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "scale-adjust") {
|
if (action === "scale-adjust") {
|
||||||
@ -7078,55 +7355,6 @@ export function ImageNode({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTemplateReplaceAddToCanvas = useCallback(
|
|
||||||
(result: CanvasTemplateReplaceResult, resultIndex: number) => {
|
|
||||||
const resultImageUrl = String(result.resultImageUrl || "").trim();
|
|
||||||
if (!isHttpImageUrl(resultImageUrl)) {
|
|
||||||
throw new Error("模版替换结果地址不可用");
|
|
||||||
}
|
|
||||||
|
|
||||||
const gap = 24;
|
|
||||||
const column = resultIndex % 2;
|
|
||||||
const row = Math.floor(resultIndex / 2);
|
|
||||||
const addedNodeId = addNode({
|
|
||||||
type: "image",
|
|
||||||
position: {
|
|
||||||
x: node.position.x + node.size.width + gap + column * (node.size.width + gap),
|
|
||||||
y: node.position.y + row * (node.size.height + gap),
|
|
||||||
},
|
|
||||||
size: { width: node.size.width, height: node.size.height },
|
|
||||||
data: {
|
|
||||||
src: resultImageUrl,
|
|
||||||
status: "completed",
|
|
||||||
prompt: "模版替换结果",
|
|
||||||
title: `${result.source.title}-模版替换`,
|
|
||||||
originalSrc: result.source.imageUrl,
|
|
||||||
metadata: {
|
|
||||||
operation: "canvas-template-replace-batch",
|
|
||||||
batchId: result.batchId,
|
|
||||||
taskId: result.taskId,
|
|
||||||
sourceNodeId: result.source.sourceNodeId,
|
|
||||||
sourceImageUrl: result.source.imageUrl,
|
|
||||||
referenceImageUrl: result.referenceImageUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
connections: result.source.sourceNodeId
|
|
||||||
? [result.source.sourceNodeId]
|
|
||||||
: [node.id],
|
|
||||||
});
|
|
||||||
selectNode(addedNodeId);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
addNode,
|
|
||||||
node.id,
|
|
||||||
node.position.x,
|
|
||||||
node.position.y,
|
|
||||||
node.size.height,
|
|
||||||
node.size.width,
|
|
||||||
selectNode,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleLayerSplitComplete = useCallback(
|
const handleLayerSplitComplete = useCallback(
|
||||||
(
|
(
|
||||||
layers: Array<{
|
layers: Array<{
|
||||||
@ -9724,16 +9952,23 @@ export function ImageNode({
|
|||||||
{imageTextEditItem.label}
|
{imageTextEditItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{imageTemplateReplaceEnabled ? (
|
{imageLayoutReplaceEnabled ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("template-replace")}>
|
<DropdownMenuItem
|
||||||
<CanvasToolbarIcon
|
onClick={() => handleAction("layout-replace")}
|
||||||
className="mr-2 h-4 w-4"
|
>
|
||||||
fallback={FileImage}
|
{isSubmittingLayoutReplace ? (
|
||||||
item={imageTemplateReplaceItem}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
/>
|
) : (
|
||||||
<span>{imageTemplateReplaceItem.label}</span>
|
<CanvasToolbarIcon
|
||||||
</DropdownMenuItem>
|
className="mr-2 h-4 w-4"
|
||||||
) : null}
|
fallback={LayoutTemplate}
|
||||||
|
item={imageLayoutReplaceItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{imageLayoutReplaceItem.label}</span>
|
||||||
|
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
{imageExtendEnabled ? (
|
{imageExtendEnabled ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
@ -9885,16 +10120,128 @@ export function ImageNode({
|
|||||||
prompt={regeneratePrompt}
|
prompt={regeneratePrompt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CanvasTemplateReplaceDialog
|
<Dialog
|
||||||
availableSources={availableTemplateReplaceSources}
|
onOpenChange={(open) => {
|
||||||
canvasId={canvasId}
|
if (!isSubmittingLayoutReplace) {
|
||||||
initialSources={initialTemplateReplaceSources}
|
setLayoutReplaceDialogOpen(open);
|
||||||
onAddToCanvas={handleTemplateReplaceAddToCanvas}
|
}
|
||||||
onOpenChange={setTemplateReplaceDialogOpen}
|
}}
|
||||||
open={templateReplaceDialogOpen}
|
open={layoutReplaceDialogOpen}
|
||||||
projectId={projectId}
|
>
|
||||||
triggerNodeId={node.id}
|
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[760px] flex-col gap-0 overflow-hidden p-0 sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)] sm:max-w-[760px]">
|
||||||
/>
|
<DialogHeader className="shrink-0 border-b border-slate-200 px-4 py-3 pr-12 text-left sm:px-6 sm:py-4 dark:border-white/10">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
<LayoutTemplate className="h-4 w-4" />
|
||||||
|
排版替换
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
选择版式参考图,保留参考图文字图标排版并仅替换产品主体
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<Label>当前图片</Label>
|
||||||
|
<div className="flex h-[clamp(160px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-slate-200 bg-slate-50 dark:border-white/10 dark:bg-zinc-900">
|
||||||
|
{data.src ? (
|
||||||
|
<img
|
||||||
|
alt={displayTitle}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
draggable={false}
|
||||||
|
src={buildImageProxyUrl(data.src, { width: 560 })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<Label htmlFor={`layout-replace-reference-url-${node.id}`}>
|
||||||
|
参考图
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`layout-replace-reference-url-${node.id}`}
|
||||||
|
onChange={(event) =>
|
||||||
|
setLayoutReplaceReferenceUrl(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
placeholder="https://..."
|
||||||
|
value={layoutReplaceReferenceUrl}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||||||
|
disabled={isUploadingLayoutReplaceReference}
|
||||||
|
onChange={(event) => {
|
||||||
|
void handleLayoutReplaceReferenceUpload(event);
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<div className="flex h-[clamp(160px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-dashed border-slate-200 bg-slate-50 dark:border-white/10 dark:bg-zinc-900">
|
||||||
|
{isUploadingLayoutReplaceReference ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
) : isHttpImageUrl(layoutReplaceReferenceUrl.trim()) ? (
|
||||||
|
<img
|
||||||
|
alt="排版参考图"
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
draggable={false}
|
||||||
|
src={buildImageProxyUrl(layoutReplaceReferenceUrl.trim(), {
|
||||||
|
width: 560,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LayoutTemplate className="h-6 w-6 text-slate-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Label htmlFor={`layout-replace-prompt-${node.id}`}>补充要求</Label>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[80px] max-h-[180px]"
|
||||||
|
id={`layout-replace-prompt-${node.id}`}
|
||||||
|
onChange={(event) =>
|
||||||
|
setLayoutReplacePrompt(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
placeholder="可选,只写产品替换细节;不会改写参考图文字、图标和排版"
|
||||||
|
value={layoutReplacePrompt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 gap-2 border-t border-slate-200 bg-background px-4 py-3 sm:px-6 dark:border-white/10">
|
||||||
|
<Button
|
||||||
|
disabled={isSubmittingLayoutReplace}
|
||||||
|
onClick={() => setLayoutReplaceDialogOpen(false)}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
isProcessing ||
|
||||||
|
isSubmittingLayoutReplace ||
|
||||||
|
isUploadingLayoutReplaceReference ||
|
||||||
|
!isHttpImageUrl(layoutReplaceReferenceUrl.trim())
|
||||||
|
}
|
||||||
|
onClick={() => void handleSubmitLayoutReplace()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isSubmittingLayoutReplace ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
开始生成
|
||||||
|
<ImageActionCreditBadge
|
||||||
|
className="ml-1 bg-white/18 text-white"
|
||||||
|
compact
|
||||||
|
label={localEditCostShortLabel}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<ThreeDGenerationDialog
|
<ThreeDGenerationDialog
|
||||||
costLabel={
|
costLabel={
|
||||||
@ -10055,6 +10402,7 @@ export function ImageNode({
|
|||||||
!isEditingTitle &&
|
!isEditingTitle &&
|
||||||
!sceneFusionDialogOpen &&
|
!sceneFusionDialogOpen &&
|
||||||
!promptDialogOpen &&
|
!promptDialogOpen &&
|
||||||
|
!layoutReplaceDialogOpen &&
|
||||||
!threedGenerationDialogMode &&
|
!threedGenerationDialogMode &&
|
||||||
!notePanelOpen &&
|
!notePanelOpen &&
|
||||||
!boxCutoutMode &&
|
!boxCutoutMode &&
|
||||||
@ -10473,21 +10821,29 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isSelectedImageActionInline("templateReplace") ? (
|
{isSelectedImageActionInline("layoutReplace") ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing || isSubmittingLayoutReplace}
|
||||||
onClick={() => handleAction("template-replace")}
|
onClick={() => handleAction("layout-replace")}
|
||||||
title={imageTemplateReplaceItem.label}
|
title={imageLayoutReplaceItem.label}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<CanvasToolbarIcon
|
{isSubmittingLayoutReplace ? (
|
||||||
className="mr-1.5 h-4 w-4"
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
fallback={FileImage}
|
) : (
|
||||||
item={imageTemplateReplaceItem}
|
<CanvasToolbarIcon
|
||||||
/>
|
className="mr-1.5 h-4 w-4"
|
||||||
{imageTemplateReplaceItem.label}
|
fallback={LayoutTemplate}
|
||||||
</Button>
|
item={imageLayoutReplaceItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{imageLayoutReplaceItem.label}
|
||||||
|
<ImageActionCreditBadge
|
||||||
|
compact
|
||||||
|
label={localEditCostShortLabel}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isSelectedImageActionInline("removeWatermark") ? (
|
{isSelectedImageActionInline("removeWatermark") ? (
|
||||||
@ -10764,16 +11120,23 @@ export function ImageNode({
|
|||||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{isSelectedImageActionInMore("templateReplace") ? (
|
{isSelectedImageActionInMore("layoutReplace") ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("template-replace")}>
|
<DropdownMenuItem
|
||||||
<CanvasToolbarIcon
|
onClick={() => handleAction("layout-replace")}
|
||||||
className="mr-2 h-4 w-4"
|
>
|
||||||
fallback={FileImage}
|
{isSubmittingLayoutReplace ? (
|
||||||
item={imageTemplateReplaceItem}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
/>
|
) : (
|
||||||
<span>{imageTemplateReplaceItem.label}</span>
|
<CanvasToolbarIcon
|
||||||
</DropdownMenuItem>
|
className="mr-2 h-4 w-4"
|
||||||
) : null}
|
fallback={LayoutTemplate}
|
||||||
|
item={imageLayoutReplaceItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{imageLayoutReplaceItem.label}</span>
|
||||||
|
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
{isSelectedImageActionInMore("removeWatermark") ? (
|
{isSelectedImageActionInMore("removeWatermark") ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export const CANVAS_TOOLBAR_CONFIG_SETTING_KEY = "canvas_toolbar_config";
|
export const CANVAS_TOOLBAR_CONFIG_SETTING_KEY = "canvas_toolbar_config";
|
||||||
export const DEFAULT_SELECTED_IMAGE_INLINE_ACTION_COUNT = 7;
|
export const DEFAULT_SELECTED_IMAGE_INLINE_ACTION_COUNT = 8;
|
||||||
export const MIN_SELECTED_IMAGE_INLINE_ACTION_COUNT = 3;
|
export const MIN_SELECTED_IMAGE_INLINE_ACTION_COUNT = 3;
|
||||||
export const MAX_SELECTED_IMAGE_INLINE_ACTION_COUNT = 12;
|
export const MAX_SELECTED_IMAGE_INLINE_ACTION_COUNT = 12;
|
||||||
|
|
||||||
@ -221,10 +221,10 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
|||||||
description: "框选多个彩色区域并按区域提示词局部改图",
|
description: "框选多个彩色区域并按区域提示词局部改图",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "imageTemplateReplace",
|
key: "imageLayoutReplace",
|
||||||
group: "节点工具栏",
|
group: "节点工具栏",
|
||||||
label: "模版替换",
|
label: "排版替换",
|
||||||
description: "用当前图片和最多四张补充图片批量替换目标模版中的主体",
|
description: "参考详情图的文字排版、视觉层级和尺寸比例生成替换结果",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "imageRemoveWatermark",
|
key: "imageRemoveWatermark",
|
||||||
@ -785,16 +785,11 @@ export function normalizeCanvasToolbarConfig(
|
|||||||
const items = {} as Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>;
|
const items = {} as Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>;
|
||||||
|
|
||||||
DEFAULT_CANVAS_TOOLBAR_ITEMS.forEach((defaultItem) => {
|
DEFAULT_CANVAS_TOOLBAR_ITEMS.forEach((defaultItem) => {
|
||||||
const legacyRawItem =
|
|
||||||
defaultItem.key === "imageTemplateReplace"
|
|
||||||
? rawItems.imagePsdTemplateReplace
|
|
||||||
: undefined;
|
|
||||||
const rawItemValue = rawItems[defaultItem.key] ?? legacyRawItem;
|
|
||||||
const rawItem =
|
const rawItem =
|
||||||
rawItemValue &&
|
rawItems[defaultItem.key] &&
|
||||||
typeof rawItemValue === "object" &&
|
typeof rawItems[defaultItem.key] === "object" &&
|
||||||
!Array.isArray(rawItemValue)
|
!Array.isArray(rawItems[defaultItem.key])
|
||||||
? (rawItemValue as Record<string, unknown>)
|
? (rawItems[defaultItem.key] as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
const label = normalizeString(rawItem.label, 40) || defaultItem.label;
|
const label = normalizeString(rawItem.label, 40) || defaultItem.label;
|
||||||
const iconUrl = normalizeString(rawItem.iconUrl, 2000);
|
const iconUrl = normalizeString(rawItem.iconUrl, 2000);
|
||||||
|
|||||||
@ -208,15 +208,15 @@ export const CANVAS_CAPABILITY_REGISTRY = [
|
|||||||
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
|
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
|
||||||
}),
|
}),
|
||||||
defineCapability({
|
defineCapability({
|
||||||
id: "image.template_replace",
|
id: "image.layout_replace",
|
||||||
title: "模版替换",
|
title: "排版替换",
|
||||||
description: "使用选中图片与目标模版图执行批量主体替换。",
|
description: "参考详情图的排版与尺寸比例生成新的 AI 图片结果。",
|
||||||
clientAction: "template-replace",
|
clientAction: "layout-replace",
|
||||||
toolbarActionKey: "templateReplace",
|
toolbarActionKey: "layoutReplace",
|
||||||
toolbarSettingKey: "imageTemplateReplace",
|
toolbarSettingKey: "imageLayoutReplace",
|
||||||
executionMode: "interactive",
|
executionMode: "interactive",
|
||||||
risk: "write",
|
risk: "external",
|
||||||
modelPolicy: "none",
|
modelPolicy: "feature-setting",
|
||||||
confirmation: "interactive",
|
confirmation: "interactive",
|
||||||
inputSchema: emptyArgsSchema,
|
inputSchema: emptyArgsSchema,
|
||||||
agent: { exposed: false },
|
agent: { exposed: false },
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export const CANVAS_CAPABILITY_IDS = [
|
|||||||
"image.text_edit",
|
"image.text_edit",
|
||||||
"image.box_cutout",
|
"image.box_cutout",
|
||||||
"image.local_edit",
|
"image.local_edit",
|
||||||
"image.template_replace",
|
"image.layout_replace",
|
||||||
"image.scale_adjust",
|
"image.scale_adjust",
|
||||||
"image.remove_watermark",
|
"image.remove_watermark",
|
||||||
"image.resize",
|
"image.resize",
|
||||||
@ -39,7 +39,7 @@ export type CanvasCapabilityClientAction =
|
|||||||
| "text-edit"
|
| "text-edit"
|
||||||
| "box-cutout"
|
| "box-cutout"
|
||||||
| "local-edit"
|
| "local-edit"
|
||||||
| "template-replace"
|
| "layout-replace"
|
||||||
| "scale-adjust"
|
| "scale-adjust"
|
||||||
| "watermark"
|
| "watermark"
|
||||||
| "resize"
|
| "resize"
|
||||||
@ -65,7 +65,7 @@ export type SelectedImageToolbarActionKey =
|
|||||||
| "textEdit"
|
| "textEdit"
|
||||||
| "boxCutout"
|
| "boxCutout"
|
||||||
| "localEdit"
|
| "localEdit"
|
||||||
| "templateReplace"
|
| "layoutReplace"
|
||||||
| "removeWatermark"
|
| "removeWatermark"
|
||||||
| "resize"
|
| "resize"
|
||||||
| "multiAngle"
|
| "multiAngle"
|
||||||
|
|||||||
@ -1,79 +0,0 @@
|
|||||||
export const MAX_CANVAS_TEMPLATE_REPLACE_SOURCES = 5;
|
|
||||||
|
|
||||||
export type CanvasTemplateReplaceSource = {
|
|
||||||
id: string;
|
|
||||||
imageUrl: string;
|
|
||||||
title: string;
|
|
||||||
origin: "canvas" | "upload";
|
|
||||||
sourceNodeId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeSource(
|
|
||||||
source: CanvasTemplateReplaceSource | null | undefined
|
|
||||||
): CanvasTemplateReplaceSource | null {
|
|
||||||
if (!source) return null;
|
|
||||||
|
|
||||||
const imageUrl = String(source.imageUrl || "").trim();
|
|
||||||
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(source.id || imageUrl).trim() || imageUrl,
|
|
||||||
imageUrl,
|
|
||||||
title: String(source.title || "替换素材").trim() || "替换素材",
|
|
||||||
origin: source.origin === "upload" ? "upload" : "canvas",
|
|
||||||
sourceNodeId: String(source.sourceNodeId || "").trim() || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCanvasTemplateReplaceSources(
|
|
||||||
sources: Array<CanvasTemplateReplaceSource | null | undefined>
|
|
||||||
): CanvasTemplateReplaceSource[] {
|
|
||||||
const seenUrls = new Set<string>();
|
|
||||||
const normalized: CanvasTemplateReplaceSource[] = [];
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
const nextSource = normalizeSource(source);
|
|
||||||
if (!nextSource || seenUrls.has(nextSource.imageUrl)) continue;
|
|
||||||
seenUrls.add(nextSource.imageUrl);
|
|
||||||
normalized.push(nextSource);
|
|
||||||
if (normalized.length >= MAX_CANVAS_TEMPLATE_REPLACE_SOURCES) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCanvasTemplateReplaceSlots(
|
|
||||||
sources: Array<CanvasTemplateReplaceSource | null | undefined>
|
|
||||||
): Array<CanvasTemplateReplaceSource | null> {
|
|
||||||
const slots = normalizeCanvasTemplateReplaceSources(sources);
|
|
||||||
return Array.from(
|
|
||||||
{ length: MAX_CANVAS_TEMPLATE_REPLACE_SOURCES },
|
|
||||||
(_, index) => slots[index] || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replaceCanvasTemplateSourceSlot(
|
|
||||||
slots: Array<CanvasTemplateReplaceSource | null>,
|
|
||||||
slotIndex: number,
|
|
||||||
source: CanvasTemplateReplaceSource | null
|
|
||||||
): Array<CanvasTemplateReplaceSource | null> {
|
|
||||||
const nextSlots = createCanvasTemplateReplaceSlots([]);
|
|
||||||
slots.slice(0, MAX_CANVAS_TEMPLATE_REPLACE_SOURCES).forEach((item, index) => {
|
|
||||||
nextSlots[index] = normalizeSource(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (slotIndex < 0 || slotIndex >= MAX_CANVAS_TEMPLATE_REPLACE_SOURCES) {
|
|
||||||
return nextSlots;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedSource = normalizeSource(source);
|
|
||||||
if (normalizedSource) {
|
|
||||||
nextSlots.forEach((item, index) => {
|
|
||||||
if (index !== slotIndex && item?.imageUrl === normalizedSource.imageUrl) {
|
|
||||||
nextSlots[index] = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
nextSlots[slotIndex] = normalizedSource;
|
|
||||||
return nextSlots;
|
|
||||||
}
|
|
||||||
@ -6,7 +6,6 @@ export default defineConfig({
|
|||||||
/routes\/canvas-chat-helpers\.test\.ts/,
|
/routes\/canvas-chat-helpers\.test\.ts/,
|
||||||
/routes\/agent-main-ai-template-entry\.test\.ts/,
|
/routes\/agent-main-ai-template-entry\.test\.ts/,
|
||||||
/routes\/canvas-agent-operations\.test\.ts/,
|
/routes\/canvas-agent-operations\.test\.ts/,
|
||||||
/routes\/canvas-capability-runtime\.test\.ts/,
|
|
||||||
/routes\/canvas-overlay-density\.test\.ts/,
|
/routes\/canvas-overlay-density\.test\.ts/,
|
||||||
/routes\/image-message-contracts\.test\.ts/,
|
/routes\/image-message-contracts\.test\.ts/,
|
||||||
/routes\/production-local-fallbacks\.test\.ts/,
|
/routes\/production-local-fallbacks\.test\.ts/,
|
||||||
@ -14,7 +13,7 @@ export default defineConfig({
|
|||||||
/routes\/settings-visual-consistency\.test\.ts/,
|
/routes\/settings-visual-consistency\.test\.ts/,
|
||||||
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
||||||
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
||||||
/routes\/canvas-template-replace-entry\.test\.ts/,
|
/routes\/canvas-layout-replace-entry\.test\.ts/,
|
||||||
/routes\/model-classification\.test\.ts/,
|
/routes\/model-classification\.test\.ts/,
|
||||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||||
|
|||||||
@ -38,13 +38,8 @@ expectIncludes(
|
|||||||
);
|
);
|
||||||
expectIncludes(
|
expectIncludes(
|
||||||
helpersConfig,
|
helpersConfig,
|
||||||
"/routes\\/canvas-template-replace-entry\\.test\\.ts/",
|
"/routes\\/canvas-layout-replace-entry\\.test\\.ts/",
|
||||||
"canvas template replacement regression target"
|
"canvas PSD template regression target"
|
||||||
);
|
|
||||||
expectIncludes(
|
|
||||||
helpersConfig,
|
|
||||||
"/routes\\/canvas-capability-runtime\\.test\\.ts/",
|
|
||||||
"canvas capability runtime regression target"
|
|
||||||
);
|
);
|
||||||
expectIncludes(helpersConfig, 'reporter: process.env.CI ? "github" : "list"', "fast non-html reporter");
|
expectIncludes(helpersConfig, 'reporter: process.env.CI ? "github" : "list"', "fast non-html reporter");
|
||||||
expectIncludes(helpersConfig, "timeout: 60 * 1000", "bounded helper test timeout");
|
expectIncludes(helpersConfig, "timeout: 60 * 1000", "bounded helper test timeout");
|
||||||
|
|||||||
@ -22,7 +22,7 @@ const SELECTED_IMAGE_TOOLBAR_ACTIONS = [
|
|||||||
"textEdit",
|
"textEdit",
|
||||||
"boxCutout",
|
"boxCutout",
|
||||||
"localEdit",
|
"localEdit",
|
||||||
"templateReplace",
|
"layoutReplace",
|
||||||
"removeWatermark",
|
"removeWatermark",
|
||||||
"resize",
|
"resize",
|
||||||
"multiAngle",
|
"multiAngle",
|
||||||
|
|||||||
@ -47,7 +47,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
defaultCanvasToolbarConfig,
|
defaultCanvasToolbarConfig,
|
||||||
getCanvasToolbarItem,
|
getCanvasToolbarItem,
|
||||||
normalizeCanvasToolbarConfig,
|
|
||||||
} from "@/lib/canvas-toolbar-config";
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification";
|
import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification";
|
||||||
import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing";
|
import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing";
|
||||||
@ -4876,36 +4875,18 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the canvas template replacement action enabled by default", () => {
|
test("keeps the AI layout replacement action enabled by default", () => {
|
||||||
expect(
|
expect(
|
||||||
getCanvasToolbarItem(
|
getCanvasToolbarItem(
|
||||||
defaultCanvasToolbarConfig,
|
defaultCanvasToolbarConfig,
|
||||||
"imageTemplateReplace"
|
"imageLayoutReplace"
|
||||||
)
|
)
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
label: "模版替换",
|
label: "排版替换",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("migrates the legacy PSD toolbar setting into canvas template replacement", () => {
|
|
||||||
const normalized = normalizeCanvasToolbarConfig({
|
|
||||||
items: {
|
|
||||||
imagePsdTemplateReplace: {
|
|
||||||
enabled: false,
|
|
||||||
label: "旧模版入口",
|
|
||||||
iconUrl: "https://example.com/template-icon.png",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(normalized.items.imageTemplateReplace).toEqual({
|
|
||||||
enabled: false,
|
|
||||||
label: "旧模版入口",
|
|
||||||
iconUrl: "https://example.com/template-icon.png",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("syncs image notes into structured canvas annotations", () => {
|
test("syncs image notes into structured canvas annotations", () => {
|
||||||
const first = syncCanvasNodeNoteAnnotationMetadata({
|
const first = syncCanvasNodeNoteAnnotationMetadata({
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
43
tests/routes/canvas-layout-replace-entry.test.ts
Normal file
43
tests/routes/canvas-layout-replace-entry.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
function readSource(path: string) {
|
||||||
|
return readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("canvas AI layout replacement entry", () => {
|
||||||
|
test("keeps the historical AI generation flow wired without routing to PSD", () => {
|
||||||
|
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
|
||||||
|
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
|
||||||
|
const toolbarConfig = readSource("lib/canvas-toolbar-config.ts");
|
||||||
|
const capabilityRegistry = readSource("lib/canvas/capabilities/registry.ts");
|
||||||
|
const route = readSource("app/api/canvas/layout-replace/route.ts");
|
||||||
|
|
||||||
|
expect(toolbarConfig).toContain('key: "imageLayoutReplace"');
|
||||||
|
expect(toolbarConfig).toContain('label: "排版替换"');
|
||||||
|
expect(toolbarConfig).not.toContain('key: "imagePsdTemplateReplace"');
|
||||||
|
|
||||||
|
expect(imageNode).toContain(
|
||||||
|
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imageLayoutReplace"'
|
||||||
|
);
|
||||||
|
expect(imageNode).toContain('isSelectedImageActionInline("layoutReplace")');
|
||||||
|
expect(imageNode).toContain('isSelectedImageActionInMore("layoutReplace")');
|
||||||
|
expect(imageNode).toContain('handleAction("layout-replace")');
|
||||||
|
expect(imageNode).toContain("handleOpenLayoutReplace");
|
||||||
|
expect(imageNode).toContain("handleSubmitLayoutReplace");
|
||||||
|
expect(imageNode).toContain("imageGenerationService.layoutReplace({");
|
||||||
|
expect(imageNode).toContain("pollLayoutReplaceTask(taskId)");
|
||||||
|
expect(imageNode).toContain('editMode: "layout-replace"');
|
||||||
|
expect(imageNode).toContain("max-h-[calc(100dvh-1rem)]");
|
||||||
|
expect(imageNode).not.toContain("/psd-template?launch=");
|
||||||
|
|
||||||
|
expect(workspace).toContain(
|
||||||
|
'requestImageNodeAction(selectedImageNodes[0].id, "layout-replace")'
|
||||||
|
);
|
||||||
|
expect(capabilityRegistry).toContain('id: "image.layout_replace"');
|
||||||
|
expect(capabilityRegistry).toContain('clientAction: "layout-replace"');
|
||||||
|
expect(route).toContain("createCanvasImageTask({");
|
||||||
|
expect(route).toContain("await enqueueCanvasImageTask(task.id)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,73 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import {
|
|
||||||
createCanvasTemplateReplaceSlots,
|
|
||||||
MAX_CANVAS_TEMPLATE_REPLACE_SOURCES,
|
|
||||||
replaceCanvasTemplateSourceSlot,
|
|
||||||
type CanvasTemplateReplaceSource,
|
|
||||||
} from "@/lib/canvas/template-replace-batch";
|
|
||||||
|
|
||||||
function readSource(path: string) {
|
|
||||||
return readFileSync(resolve(process.cwd(), path), "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function source(id: string): CanvasTemplateReplaceSource {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
imageUrl: `https://example.com/${id}.png`,
|
|
||||||
title: id,
|
|
||||||
origin: "canvas",
|
|
||||||
sourceNodeId: id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("canvas template replacement entry", () => {
|
|
||||||
test("keeps the selected-image toolbar action wired to the in-canvas batch dialog", () => {
|
|
||||||
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
|
|
||||||
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
|
|
||||||
const dialog = readSource("components/canvas/CanvasTemplateReplaceDialog.tsx");
|
|
||||||
|
|
||||||
expect(imageNode).toContain(
|
|
||||||
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imageTemplateReplace"'
|
|
||||||
);
|
|
||||||
expect(imageNode).toContain('isSelectedImageActionInline("templateReplace")');
|
|
||||||
expect(imageNode).toContain('isSelectedImageActionInMore("templateReplace")');
|
|
||||||
expect(imageNode).toContain('handleAction("template-replace")');
|
|
||||||
expect(imageNode).toContain("<CanvasTemplateReplaceDialog");
|
|
||||||
expect(imageNode).not.toContain("/psd-template?launch=");
|
|
||||||
expect(imageNode).not.toContain("/api/psd-templates?summary=1");
|
|
||||||
expect(workspace).toContain(
|
|
||||||
'requestImageNodeAction(selectedImageNodes[0].id, "template-replace")'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dialog).toContain("MAX_CANVAS_TEMPLATE_REPLACE_SOURCES");
|
|
||||||
expect(dialog).toContain("imageGenerationService.layoutReplace");
|
|
||||||
expect(dialog).toContain("for (const resultId of orderedIds)");
|
|
||||||
expect(dialog).toContain('return "等待上一张"');
|
|
||||||
expect(dialog).toContain("排队中(前方");
|
|
||||||
expect(dialog).toContain("全部添加到画布");
|
|
||||||
expect(dialog).toContain("canvas-template-replace:");
|
|
||||||
expect(dialog).toContain("max-h-[calc(100dvh-1rem)]");
|
|
||||||
expect(dialog).toContain("min-h-0 flex-1 overflow-y-auto");
|
|
||||||
expect(dialog).toContain("shrink-0 gap-2 border-t");
|
|
||||||
expect(dialog).not.toContain("PSD");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps five fixed slots and deduplicates a source moved between slots", () => {
|
|
||||||
const slots = createCanvasTemplateReplaceSlots([
|
|
||||||
source("a"),
|
|
||||||
source("b"),
|
|
||||||
source("c"),
|
|
||||||
source("d"),
|
|
||||||
source("e"),
|
|
||||||
source("f"),
|
|
||||||
]);
|
|
||||||
expect(slots).toHaveLength(MAX_CANVAS_TEMPLATE_REPLACE_SOURCES);
|
|
||||||
expect(slots.map((item) => item?.id)).toEqual(["a", "b", "c", "d", "e"]);
|
|
||||||
|
|
||||||
const moved = replaceCanvasTemplateSourceSlot(slots, 4, source("a"));
|
|
||||||
expect(moved[0]).toBeNull();
|
|
||||||
expect(moved[4]?.id).toBe("a");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user