"use client"; import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; import { ArrowDown, ArrowUp, Check, Download, Eye, EyeOff, ImagePlus, Loader2, Move, Pencil, Plus, RefreshCw, RotateCcw, RotateCw, Search, Trash2, Upload, X } from "lucide-react"; import clsx from "clsx"; import { assetContentUrl, assetPreviewUrl } from "@/lib/client/asset-urls"; import { waitForJobOutputAssets } from "@/lib/client/generation-result-sync"; import { downloadSeedreamLayerComposition } from "@/lib/client/seedream-layer-compose"; import { downloadFilesAsZip } from "@/lib/client/zip-download"; import type { SeedreamCompositionLayer, SeedreamLayerBox, SeedreamLayerComposition, SeedreamLayerDraft, SeedreamLayerSource } from "@/lib/seedream/layer-composition"; import type { Asset, GenerationJob } from "@/lib/types"; export type SeedreamLayerOutput = { asset: Asset; zIndex: number; name: string; description?: string; size?: [number, number]; boundingBox?: { absolute?: SeedreamLayerBox; normalized?: SeedreamLayerBox; }; }; type SaveState = "loading" | "saved" | "dirty" | "saving" | "error"; type LayerInteraction = { id: string; mode: "move" | "resize" | "rotate"; pointerId: number; startX: number; startY: number; startBox: SeedreamLayerBox; startRotation: number; startPointerAngle?: number; }; type EditDialogState = { layerId: string; prompt: string; phase: "idle" | "submitting" | "polling" | "ready"; job?: GenerationJob; result?: Asset; error?: string; }; const baseSelectionID = "__seedream_base__"; const fullLayerBox: SeedreamLayerBox = [0, 0, 1000, 1000]; export function SeedreamLayerResultViewer({ jobId, outputs, assetLibrary }: { jobId: string; outputs: SeedreamLayerOutput[]; assetLibrary: Asset[]; }) { const ordered = useMemo(() => [...outputs].sort((left, right) => left.zIndex - right.zIndex), [outputs]); const base = (ordered.find((output) => output.zIndex === 0) || ordered[0]) as SeedreamLayerOutput; const providerLayers = useMemo(() => ordered.filter((output) => output !== base), [base, ordered]); const baseSize: [number, number] = base.size || [1, 1]; const outputSignature = `${jobId}:${ordered.map((output) => [output.asset.id, output.zIndex, output.size, output.boundingBox?.normalized, output.boundingBox?.absolute].join(":"))}`; const assetLibrarySignature = assetLibrary.map((asset) => `${asset.id}:${asset.updatedAt}`).join("|"); const initialDraft = useMemo(() => initialLayerDraft(base, providerLayers, baseSize), [base, baseSize, providerLayers]); const [draft, setDraft] = useState(initialDraft); const [assetCache, setAssetCache] = useState>(() => assetMap([...assetLibrary, ...ordered.map((output) => output.asset)])); const [selectedId, setSelectedId] = useState(providerLayers[0] ? `provider-${providerLayers[0].asset.id}` : baseSelectionID); const [interaction, setInteraction] = useState(null); const [saveState, setSaveState] = useState("loading"); const [saveError, setSaveError] = useState(null); const [actionError, setActionError] = useState(null); const [actionNotice, setActionNotice] = useState(null); const [libraryOpen, setLibraryOpen] = useState(false); const [libraryQuery, setLibraryQuery] = useState(""); const [uploading, setUploading] = useState(false); const [zipDownloading, setZipDownloading] = useState(false); const [exporting, setExporting] = useState(false); const [editDialog, setEditDialog] = useState(null); const previewRef = useRef(null); const uploadInputRef = useRef(null); const editAbortRef = useRef(null); const hydratedRef = useRef(false); const draftRef = useRef(draft); const versionRef = useRef(0); const lastSavedRef = useRef(serializeDraft(initialDraft)); const savingRef = useRef(false); const pendingSaveRef = useRef(false); const selectedLayer = draft.layers.find((layer) => layer.id === selectedId); const baseAsset = assetCache.get(draft.baseAssetId) || base.asset; const displayLayers = draft.layers; const listLayers = [...displayLayers].reverse(); const previewMaximumWidth = Math.max(1, 560 * baseSize[0] / baseSize[1]); const currentSerializedDraft = serializeDraft(draft); const libraryAssets = useMemo(() => { const query = libraryQuery.trim().toLocaleLowerCase(); return [...assetCache.values()] .filter((asset) => isLayerAsset(asset) && asset.id !== draft.baseAssetId) .filter((asset) => !query || `${asset.name} ${asset.tags.join(" ")}`.toLocaleLowerCase().includes(query)) .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) .slice(0, 80); }, [assetCache, draft.baseAssetId, libraryQuery]); useEffect(() => { setAssetCache((current) => assetMap([...current.values(), ...assetLibrary, ...ordered.map((output) => output.asset)])); }, [assetLibrarySignature, outputSignature]); useEffect(() => { let active = true; hydratedRef.current = false; setSaveState("loading"); setSaveError(null); setActionError(null); setActionNotice(null); setInteraction(null); setLibraryOpen(false); setEditDialog(null); editAbortRef.current?.abort(); async function load() { try { const [compositionResponse, assetsResponse] = await Promise.all([ fetch(`/api/layer-compositions/${encodeURIComponent(jobId)}`, { cache: "no-store" }), fetch("/api/assets", { cache: "no-store" }) ]); const compositionPayload = await readJSON(compositionResponse); if (!compositionResponse.ok) throw new Error(payloadError(compositionPayload, "读取图层工程失败")); const assetsPayload = assetsResponse.ok ? await readJSON(assetsResponse) : {}; if (!active) return; const loadedAssets = Array.isArray(assetsPayload.assets) ? assetsPayload.assets as Asset[] : []; setAssetCache((current) => assetMap([...current.values(), ...loadedAssets, ...ordered.map((output) => output.asset)])); let composition = isLayerComposition(compositionPayload.composition) ? compositionPayload.composition : null; if (!composition) { let initializeResponse = await putLayerComposition(jobId, 0, initialDraft); if (initializeResponse.status === 409) { initializeResponse = await fetch(`/api/layer-compositions/${encodeURIComponent(jobId)}`, { cache: "no-store" }); } const initializePayload = await readJSON(initializeResponse); if (!initializeResponse.ok) throw new Error(payloadError(initializePayload, "初始化图层工程失败")); if (!isLayerComposition(initializePayload.composition)) throw new Error("初始化图层工程返回数据不完整"); composition = initializePayload.composition; } const nextDraft = composition ? draftFromComposition(composition) : initialDraft; versionRef.current = composition?.version || 0; draftRef.current = nextDraft; lastSavedRef.current = serializeDraft(nextDraft); setDraft(nextDraft); setSelectedId(nextDraft.layers[0]?.id || baseSelectionID); setSaveState("saved"); } catch (error) { if (!active) return; draftRef.current = initialDraft; lastSavedRef.current = ""; versionRef.current = 0; setDraft(initialDraft); setSelectedId(initialDraft.layers[0]?.id || baseSelectionID); setSaveState("error"); setSaveError(errorMessage(error, "读取图层工程失败;当前仍可临时编辑")); } finally { if (active) hydratedRef.current = true; } } void load(); return () => { active = false; editAbortRef.current?.abort(); }; }, [jobId, outputSignature]); useEffect(() => { draftRef.current = draft; if (!hydratedRef.current || currentSerializedDraft === lastSavedRef.current) return; setSaveState((current) => current === "saving" ? current : "dirty"); const timer = window.setTimeout(() => void saveCurrentDraft(), 650); return () => window.clearTimeout(timer); }, [currentSerializedDraft, jobId]); useEffect(() => { return () => { if (!hydratedRef.current || serializeDraft(draftRef.current) === lastSavedRef.current) return; if (savingRef.current) { pendingSaveRef.current = true; return; } const pending = draftRef.current; void fetch(`/api/layer-compositions/${encodeURIComponent(jobId)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ version: versionRef.current, ...pending }), keepalive: true }).catch(() => undefined); }; }, [jobId]); async function saveCurrentDraft() { if (!hydratedRef.current) return; if (savingRef.current) { pendingSaveRef.current = true; return; } const target = draftRef.current; const targetSerialized = serializeDraft(target); if (targetSerialized === lastSavedRef.current) { setSaveState("saved"); return; } savingRef.current = true; pendingSaveRef.current = false; let savedSuccessfully = false; setSaveState("saving"); setSaveError(null); try { let response = await putLayerComposition(jobId, versionRef.current, target); if (response.status === 409) { const latest = await fetch(`/api/layer-compositions/${encodeURIComponent(jobId)}`, { cache: "no-store" }); const latestPayload = await readJSON(latest); if (!latest.ok) throw new Error(payloadError(latestPayload, "同步图层工程版本失败")); versionRef.current = isLayerComposition(latestPayload.composition) ? latestPayload.composition.version : 0; response = await putLayerComposition(jobId, versionRef.current, target); } const payload = await readJSON(response); if (!response.ok) throw new Error(payloadError(payload, "保存图层工程失败")); if (!isLayerComposition(payload.composition)) throw new Error("保存图层工程返回数据不完整"); versionRef.current = payload.composition.version; lastSavedRef.current = targetSerialized; savedSuccessfully = true; setSaveState("saved"); } catch (error) { setSaveState("error"); setSaveError(errorMessage(error, "保存图层工程失败")); } finally { savingRef.current = false; if (savedSuccessfully && (pendingSaveRef.current || serializeDraft(draftRef.current) !== lastSavedRef.current)) { pendingSaveRef.current = false; window.setTimeout(() => void saveCurrentDraft(), 120); } } } function updateDraft(updater: (current: SeedreamLayerDraft) => SeedreamLayerDraft) { setDraft((current) => { const next = updater(current); draftRef.current = next; return next; }); } function updateLayer(id: string, updater: (layer: SeedreamCompositionLayer) => SeedreamCompositionLayer) { updateDraft((current) => ({ ...current, layers: current.layers.map((layer) => layer.id === id ? updater(layer) : layer) })); } function toggleVisibility(id: string) { if (id === baseSelectionID) { updateDraft((current) => ({ ...current, baseVisible: !current.baseVisible })); return; } updateLayer(id, (layer) => ({ ...layer, visible: !layer.visible })); } function reorderLayer(id: string, direction: -1 | 1) { updateDraft((current) => { const index = current.layers.findIndex((layer) => layer.id === id); const nextIndex = index + direction; if (index < 0 || nextIndex < 0 || nextIndex >= current.layers.length) return current; const layers = [...current.layers]; [layers[index], layers[nextIndex]] = [layers[nextIndex], layers[index]]; return { ...current, layers }; }); } function scaleLayer(id: string, factor: number) { updateLayer(id, (layer) => ({ ...layer, box: scaleLayerBox(layer.box, factor) })); } function rotateLayer(id: string, delta: number) { updateLayer(id, (layer) => ({ ...layer, rotation: normalizeRotation(layer.rotation + delta) })); } function resetLayout() { const providerBoxes = new Map(providerLayers.map((output, index) => [output.asset.id, initialLayerBox(output, baseSize, index)])); updateDraft((current) => ({ ...current, baseVisible: true, layers: current.layers.map((layer) => ({ ...layer, visible: true, rotation: 0, box: providerBoxes.get(layer.assetId) || centeredLayerBox() })) })); setInteraction(null); setActionError(null); setActionNotice("已重置当前图层布局,新增图层仍会保留。"); } function removeLayer(id: string) { const layer = draft.layers.find((item) => item.id === id); if (!layer) return; updateDraft((current) => ({ ...current, layers: current.layers.filter((item) => item.id !== id) })); setSelectedId(baseSelectionID); setActionNotice(`已从工程移除“${layer.name}”,原素材仍保留在素材库。`); } function startInteraction(event: ReactPointerEvent, layer: SeedreamCompositionLayer, mode: LayerInteraction["mode"]) { event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); setSelectedId(layer.id); let startPointerAngle: number | undefined; if (mode === "rotate" && previewRef.current) { const bounds = previewRef.current.getBoundingClientRect(); const centerX = bounds.left + ((layer.box[0] + layer.box[2]) / 2000) * bounds.width; const centerY = bounds.top + ((layer.box[1] + layer.box[3]) / 2000) * bounds.height; startPointerAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX); } setInteraction({ id: layer.id, mode, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startBox: layer.box, startRotation: layer.rotation, startPointerAngle }); } function continueInteraction(event: ReactPointerEvent) { if (!interaction || interaction.pointerId !== event.pointerId || !previewRef.current) return; event.preventDefault(); const bounds = previewRef.current.getBoundingClientRect(); if (!bounds.width || !bounds.height) return; if (interaction.mode === "rotate") { const centerX = bounds.left + ((interaction.startBox[0] + interaction.startBox[2]) / 2000) * bounds.width; const centerY = bounds.top + ((interaction.startBox[1] + interaction.startBox[3]) / 2000) * bounds.height; const pointerAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX); const delta = (pointerAngle - (interaction.startPointerAngle || 0)) * 180 / Math.PI; updateLayer(interaction.id, (layer) => ({ ...layer, rotation: normalizeRotation(interaction.startRotation + delta) })); return; } const deltaX = (event.clientX - interaction.startX) / bounds.width * 1000; const deltaY = (event.clientY - interaction.startY) / bounds.height * 1000; const box = interaction.mode === "move" ? moveLayerBox(interaction.startBox, deltaX, deltaY) : resizeLayerBox(interaction.startBox, deltaX, deltaY); updateLayer(interaction.id, (layer) => ({ ...layer, box })); } function finishInteraction(event: ReactPointerEvent) { if (interaction?.pointerId === event.pointerId) setInteraction(null); } function handleLayerKeyDown(event: KeyboardEvent, id: string) { const direction = event.key === "ArrowLeft" ? [-1, 0] : event.key === "ArrowRight" ? [1, 0] : event.key === "ArrowUp" ? [0, -1] : event.key === "ArrowDown" ? [0, 1] : null; if (!direction) return; event.preventDefault(); const step = event.shiftKey ? 50 : 10; updateLayer(id, (layer) => ({ ...layer, box: moveLayerBox(layer.box, direction[0] * step, direction[1] * step) })); } async function exportComposition() { setExporting(true); setActionError(null); try { await downloadSeedreamLayerComposition({ width: baseSize[0], height: baseSize[1], fileName: `seedream-composition-${jobId}.png`, items: [ { url: assetContentUrl(baseAsset), visible: draft.baseVisible, box: fullLayerBox, rotation: 0 }, ...draft.layers.map((layer) => { const asset = assetCache.get(layer.assetId); return asset ? { url: assetContentUrl(asset), visible: layer.visible, box: layer.box, rotation: layer.rotation } : null; }).filter((item): item is { url: string; visible: boolean; box: SeedreamLayerBox; rotation: number } => Boolean(item)) ] }); } catch (error) { setActionError(errorMessage(error, "导出合成图片失败")); } finally { setExporting(false); } } async function downloadAll() { setZipDownloading(true); setActionError(null); try { const items = [ { asset: baseAsset, name: `00-底图-${baseAsset.name}` }, ...draft.layers.map((layer, index) => ({ asset: assetCache.get(layer.assetId), name: `${String(index + 1).padStart(2, "0")}-${layer.name}` })) ].filter((item): item is { asset: Asset; name: string } => Boolean(item.asset)); await downloadFilesAsZip(items.map((item) => ({ name: safeDownloadName(item.name, item.asset.name), url: assetContentUrl(item.asset) })), `seedream-layer-project-${jobId}.zip`); } catch (error) { setActionError(errorMessage(error, "打包下载失败")); } finally { setZipDownloading(false); } } async function handleLayerUpload(event: ChangeEvent) { const files = Array.from(event.target.files || []); event.target.value = ""; if (!files.length) return; if (files.some((file) => !file.type.startsWith("image/"))) { setActionError("新增图层只支持图片文件。"); return; } setUploading(true); setActionError(null); try { const formData = new FormData(); files.forEach((file) => formData.append("files", file)); const response = await fetch("/api/assets/upload", { method: "POST", body: formData }); const payload = await readJSON(response); if (!response.ok) throw new Error(payloadError(payload, "上传图层失败")); const uploaded = Array.isArray(payload.assets) ? payload.assets as Asset[] : []; if (!uploaded.length) throw new Error("上传成功,但没有返回可用图片素材"); setAssetCache((current) => assetMap([...current.values(), ...uploaded])); const additions = await Promise.all(uploaded.map((asset) => layerFromAsset(asset, "upload", baseSize))); updateDraft((current) => ({ ...current, layers: [...current.layers, ...additions] })); setSelectedId(additions[additions.length - 1].id); setLibraryOpen(false); setActionNotice(`已新增 ${additions.length} 个图层。`); } catch (error) { setActionError(errorMessage(error, "上传图层失败")); } finally { setUploading(false); } } async function addLibraryAsset(asset: Asset) { setActionError(null); const layer = await layerFromAsset(asset, "library", baseSize); updateDraft((current) => ({ ...current, layers: [...current.layers, layer] })); setSelectedId(layer.id); setLibraryOpen(false); setActionNotice(`已把“${asset.name}”加入图层工程。`); } function openLayerEdit(layer: SeedreamCompositionLayer) { setActionError(null); setEditDialog({ layerId: layer.id, prompt: "", phase: "idle" }); } async function submitLayerEdit() { if (!editDialog) return; const layer = draftRef.current.layers.find((item) => item.id === editDialog.layerId); const asset = layer ? assetCache.get(layer.assetId) : undefined; const instruction = editDialog.prompt.trim(); if (!layer || !asset) { setEditDialog((current) => current ? { ...current, error: "当前图层素材不存在" } : current); return; } if (!instruction) { setEditDialog((current) => current ? { ...current, error: "请先填写编辑指令" } : current); return; } const controller = new AbortController(); editAbortRef.current?.abort(); editAbortRef.current = controller; setEditDialog((current) => current ? { ...current, phase: "submitting", error: undefined, result: undefined, job: undefined } : current); try { const response = await fetch("/api/generations/image", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ capability: "image.generate", engine: "seedream", prompt: `请编辑 @图片1:${instruction}`, materials: [{ id: asset.id, url: asset.url, name: asset.name, label: "@图片1", type: "image" }], settings: { size: "1.5K", outputFormat: "png", optimizeMode: "standard", imageCount: 1, creationMode: "interactive" } }), signal: controller.signal }); const payload = await readJSON(response); if (!response.ok) throw new Error(payloadError(payload, "提交图层编辑失败")); const submitted = payload.job as GenerationJob | undefined; if (!submitted?.id) throw new Error("图层编辑任务返回数据不完整"); setEditDialog((current) => current ? { ...current, phase: "polling", job: submitted } : current); const completed = await waitForImageJob(submitted.id, controller.signal); if (completed.status !== "succeeded") throw new Error(completed.error?.message || "图层编辑失败"); setEditDialog((current) => current ? { ...current, phase: "polling", job: completed } : current); await resolveLayerEditResult(completed, controller.signal); } catch (error) { if (controller.signal.aborted) return; setEditDialog((current) => current ? { ...current, phase: "idle", error: errorMessage(error, "图层编辑失败") } : current); } } async function retryLayerEditResultSync() { const job = editDialog?.job; if (!job?.id || job.status !== "succeeded") return; const controller = new AbortController(); editAbortRef.current?.abort(); editAbortRef.current = controller; setEditDialog((current) => current ? { ...current, phase: "polling", error: undefined } : current); try { await resolveLayerEditResult(job, controller.signal); } catch (error) { if (controller.signal.aborted) return; setEditDialog((current) => current ? { ...current, phase: "idle", error: errorMessage(error, "同步编辑结果失败") } : current); } } async function resolveLayerEditResult(job: GenerationJob, signal: AbortSignal) { const assets = await waitForJobOutputAssets(job, signal); const result = assets[0]; if (!result) throw new Error("编辑任务已完成,但结果素材同步超时;请点击“重新同步结果”,不会重复计费"); setAssetCache((current) => assetMap([...current.values(), ...assets])); setEditDialog((current) => current ? { ...current, phase: "ready", job, result, error: undefined } : current); } function applyEditResult(mode: "replace" | "add") { if (!editDialog?.result || !editDialog.job) return; const source = draftRef.current.layers.find((layer) => layer.id === editDialog.layerId); if (!source) return; if (mode === "replace") { updateLayer(source.id, (layer) => ({ ...layer, assetId: editDialog.result!.id, source: "seedream-edit", editJobId: editDialog.job!.id, originalAssetId: layer.originalAssetId || layer.assetId })); setActionNotice(`已用即梦编辑结果替换“${source.name}”。`); } else { const addition: SeedreamCompositionLayer = { ...source, id: newLayerID(), assetId: editDialog.result.id, name: `${source.name} 编辑版`, source: "seedream-edit", editJobId: editDialog.job.id, originalAssetId: source.assetId }; updateDraft((current) => { const index = current.layers.findIndex((layer) => layer.id === source.id); const layers = [...current.layers]; layers.splice(index + 1, 0, addition); return { ...current, layers }; }); setSelectedId(addition.id); setActionNotice(`已把即梦编辑结果作为新图层加入工程。`); } setEditDialog(null); } const selectedAsset = selectedLayer ? assetCache.get(selectedLayer.assetId) : undefined; const editBusy = editDialog?.phase === "submitting" || editDialog?.phase === "polling"; const canRetryEditResultSync = editDialog?.phase !== "ready" && editDialog?.job?.status === "succeeded" && !editDialog.result; return (
{saveState === "loading" || saveState === "saving" ? : saveState === "saved" ? : null} {saveStateLabel(saveState)} {saveState === "error" ? : null} 图层位置、层级、显隐和旋转会保存到数据库
拖动、缩放或旋转选中图层;方向键可微调
setSelectedId(baseSelectionID)} > {draft.baseVisible ? {base.name} : null} {displayLayers.map((layer, stackIndex) => { const asset = assetCache.get(layer.assetId); if (!asset || !layer.visible) return null; return ( ); })}
{selectedLayer ? (
{selectedLayer.name} X {Math.round(selectedLayer.box[0] / 10)}% Y {Math.round(selectedLayer.box[1] / 10)}% 宽 {Math.round((selectedLayer.box[2] - selectedLayer.box[0]) / 10)}% 高 {Math.round((selectedLayer.box[3] - selectedLayer.box[1]) / 10)}%
) :
底图固定在画布底部
}
合成导出采用当前显隐、位置、尺寸、旋转和层级
{saveError ?
{saveError}
: null} {actionError ?
{actionError}
: null} {actionNotice ?
{actionNotice}
: null}
图层上方图层优先显示
{libraryOpen ? (
void handleLayerUpload(event)} />
{libraryAssets.map((asset) => ( ))} {!libraryAssets.length ?
暂无匹配图片,可直接上传新图片。
: null}
) : null}
{listLayers.map((layer) => { const asset = assetCache.get(layer.assetId); const orderIndex = draft.layers.findIndex((item) => item.id === layer.id); return (
setSelectedId(layer.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSelectedId(layer.id); } }} > {asset ? : } {layer.name} {layerSourceLabel(layer.source)} · {Math.round(layer.rotation)}° {asset ? event.stopPropagation()}> : null}
); })}
setSelectedId(baseSelectionID)}> 底图固定画布 固定 event.stopPropagation()}>
{editDialog ? (
Seedream 5.0 Pro

编辑图层:{draft.layers.find((layer) => layer.id === editDialog.layerId)?.name}

{editDialog.result ? 即梦编辑结果 : selectedAsset ? 待编辑图层 : } {editDialog.result ? "编辑结果" : "当前图层"}