Files
NianAIGC/components/seedream-layer-result-viewer.tsx

1054 lines
49 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<SeedreamLayerDraft>(initialDraft);
const [assetCache, setAssetCache] = useState<Map<string, Asset>>(() => assetMap([...assetLibrary, ...ordered.map((output) => output.asset)]));
const [selectedId, setSelectedId] = useState<string>(providerLayers[0] ? `provider-${providerLayers[0].asset.id}` : baseSelectionID);
const [interaction, setInteraction] = useState<LayerInteraction | null>(null);
const [saveState, setSaveState] = useState<SaveState>("loading");
const [saveError, setSaveError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(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<EditDialogState | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const editAbortRef = useRef<AbortController | null>(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<HTMLElement>, 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<HTMLDivElement>) {
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<HTMLDivElement>) {
if (interaction?.pointerId === event.pointerId) setInteraction(null);
}
function handleLayerKeyDown(event: KeyboardEvent<HTMLButtonElement>, 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<HTMLInputElement>) {
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 (
<div className="seedream-layer-project">
<div className="seedream-layer-project-status" aria-live="polite">
<span className={clsx("seedream-layer-save-state", `is-${saveState}`)}>
{saveState === "loading" || saveState === "saving" ? <Loader2 className="spin" size={12} /> : saveState === "saved" ? <Check size={12} /> : null}
{saveStateLabel(saveState)}
</span>
{saveState === "error" ? <button type="button" onClick={() => void saveCurrentDraft()}></button> : null}
<small></small>
</div>
<div className="seedream-layer-viewer">
<div className="seedream-layer-preview-column">
<div className="seedream-layer-editor-toolbar">
<span><Move size={13} /></span>
<div>
<button className="button mini-button" type="button" disabled={!selectedLayer} onClick={() => selectedLayer && scaleLayer(selectedLayer.id, 0.9)}></button>
<button className="button mini-button" type="button" disabled={!selectedLayer} onClick={() => selectedLayer && scaleLayer(selectedLayer.id, 1.1)}></button>
<button className="button mini-button icon-only" title="向左旋转 90°" aria-label="向左旋转 90°" type="button" disabled={!selectedLayer} onClick={() => selectedLayer && rotateLayer(selectedLayer.id, -90)}><RotateCcw size={13} /></button>
<button className="button mini-button icon-only" title="向右旋转 90°" aria-label="向右旋转 90°" type="button" disabled={!selectedLayer} onClick={() => selectedLayer && rotateLayer(selectedLayer.id, 90)}><RotateCw size={13} /></button>
<button className="button mini-button" type="button" onClick={resetLayout}><RotateCcw size={13} /></button>
</div>
</div>
<div
ref={previewRef}
className={clsx("seedream-layer-preview", interaction && "is-editing")}
style={{ aspectRatio: `${baseSize[0]} / ${baseSize[1]}`, width: `min(100%, ${previewMaximumWidth}px)` }}
onPointerMove={continueInteraction}
onPointerUp={finishInteraction}
onPointerCancel={finishInteraction}
onClick={() => setSelectedId(baseSelectionID)}
>
{draft.baseVisible ? <img className="seedream-layer-base" src={assetPreviewUrl(baseAsset)} alt={base.name} draggable={false} /> : null}
{displayLayers.map((layer, stackIndex) => {
const asset = assetCache.get(layer.assetId);
if (!asset || !layer.visible) return null;
return (
<button
type="button"
key={layer.id}
className={clsx("seedream-layer-overlay", selectedId === layer.id && "selected")}
aria-label={`移动${layer.name}图层`}
style={{
left: `${layer.box[0] / 10}%`,
top: `${layer.box[1] / 10}%`,
width: `${(layer.box[2] - layer.box[0]) / 10}%`,
height: `${(layer.box[3] - layer.box[1]) / 10}%`,
zIndex: stackIndex + 1,
transform: `rotate(${layer.rotation}deg)`
}}
onPointerDown={(event) => startInteraction(event, layer, "move")}
onKeyDown={(event) => handleLayerKeyDown(event, layer.id)}
onClick={(event) => { event.stopPropagation(); setSelectedId(layer.id); }}
>
<img src={assetPreviewUrl(asset)} alt="" draggable={false} />
<span className="seedream-layer-rotation-stem" aria-hidden="true" />
<span className="seedream-layer-rotation-handle" title="拖动旋转" aria-hidden="true" onPointerDown={(event) => startInteraction(event, layer, "rotate")}><RotateCw size={10} /></span>
<span className="seedream-layer-resize-handle" aria-hidden="true" onPointerDown={(event) => startInteraction(event, layer, "resize")} />
</button>
);
})}
</div>
{selectedLayer ? (
<div className="seedream-layer-inspector" aria-live="polite">
<strong>{selectedLayer.name}</strong>
<span>X {Math.round(selectedLayer.box[0] / 10)}%</span>
<span>Y {Math.round(selectedLayer.box[1] / 10)}%</span>
<span> {Math.round((selectedLayer.box[2] - selectedLayer.box[0]) / 10)}%</span>
<span> {Math.round((selectedLayer.box[3] - selectedLayer.box[1]) / 10)}%</span>
<label>
<input type="number" min={-180} max={180} step={1} value={Math.round(selectedLayer.rotation)} onChange={(event) => updateLayer(selectedLayer.id, (layer) => ({ ...layer, rotation: normalizeRotation(Number(event.target.value) || 0) }))} />°
</label>
</div>
) : <div className="seedream-layer-inspector"><strong></strong><span></span></div>}
<div className="seedream-layer-preview-actions">
<span></span>
<div>
<button className="button mini-button" type="button" disabled={exporting} onClick={() => void exportComposition()}>
{exporting ? <Loader2 className="spin" size={14} /> : <Download size={14} />}
PNG
</button>
<button className="button mini-button" type="button" disabled={zipDownloading} onClick={() => void downloadAll()}>
{zipDownloading ? <Loader2 className="spin" size={14} /> : <Download size={14} />}
ZIP
</button>
</div>
</div>
{saveError ? <div className="seedream-layer-download-error" role="alert">{saveError}</div> : null}
{actionError ? <div className="seedream-layer-download-error" role="alert">{actionError}</div> : null}
{actionNotice ? <div className="seedream-layer-action-notice" role="status">{actionNotice}</div> : null}
</div>
<div className="seedream-layer-list-column">
<div className="seedream-layer-list-head">
<div><span></span><small></small></div>
<button className="button mini-button" type="button" onClick={() => setLibraryOpen((open) => !open)}><Plus size={13} /></button>
</div>
{libraryOpen ? (
<div className="seedream-layer-library">
<div className="seedream-layer-library-actions">
<button className="button mini-button primary" type="button" disabled={uploading} onClick={() => uploadInputRef.current?.click()}>
{uploading ? <Loader2 className="spin" size={13} /> : <Upload size={13} />}
</button>
<label><Search size={13} /><input value={libraryQuery} onChange={(event) => setLibraryQuery(event.target.value)} placeholder="搜索素材库" /></label>
<button className="icon-button" type="button" title="关闭素材库" aria-label="关闭素材库" onClick={() => setLibraryOpen(false)}><X size={14} /></button>
</div>
<input ref={uploadInputRef} type="file" accept="image/*" multiple hidden onChange={(event) => void handleLayerUpload(event)} />
<div className="seedream-layer-library-grid">
{libraryAssets.map((asset) => (
<button type="button" key={asset.id} onClick={() => void addLibraryAsset(asset)}>
<img src={assetPreviewUrl(asset)} alt="" />
<span title={asset.name}>{asset.name}</span>
<small><ImagePlus size={11} /></small>
</button>
))}
{!libraryAssets.length ? <div className="seedream-layer-library-empty"></div> : null}
</div>
</div>
) : null}
<div className="seedream-layer-list" aria-label="拆分图层列表">
{listLayers.map((layer) => {
const asset = assetCache.get(layer.assetId);
const orderIndex = draft.layers.findIndex((item) => item.id === layer.id);
return (
<article
className={clsx(selectedId === layer.id && "active")}
key={layer.id}
tabIndex={0}
aria-current={selectedId === layer.id ? "true" : undefined}
onClick={() => setSelectedId(layer.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedId(layer.id);
}
}}
>
<button className="seedream-layer-visibility" type="button" title={layer.visible ? "隐藏图层" : "显示图层"} aria-label={layer.visible ? `隐藏${layer.name}` : `显示${layer.name}`} onClick={(event) => { event.stopPropagation(); toggleVisibility(layer.id); }}>
{layer.visible ? <Eye size={15} /> : <EyeOff size={15} />}
</button>
<span className="seedream-layer-thumb">{asset ? <img src={assetPreviewUrl(asset)} alt="" /> : <ImagePlus size={15} />}</span>
<span className="seedream-layer-copy">
<strong>{layer.name}</strong>
<small>{layerSourceLabel(layer.source)} · {Math.round(layer.rotation)}°</small>
</span>
<span className="seedream-layer-order-actions">
<button type="button" title="上移一层" aria-label={`上移${layer.name}`} disabled={orderIndex === draft.layers.length - 1} onClick={(event) => { event.stopPropagation(); reorderLayer(layer.id, 1); }}><ArrowUp size={13} /></button>
<button type="button" title="下移一层" aria-label={`下移${layer.name}`} disabled={orderIndex === 0} onClick={(event) => { event.stopPropagation(); reorderLayer(layer.id, -1); }}><ArrowDown size={13} /></button>
</span>
<span className="seedream-layer-row-actions">
<button type="button" title="用即梦编辑此图层" aria-label={`用即梦编辑${layer.name}`} onClick={(event) => { event.stopPropagation(); openLayerEdit(layer); }}><Pencil size={13} /></button>
<button type="button" title="从工程移除" aria-label={`移除${layer.name}`} onClick={(event) => { event.stopPropagation(); removeLayer(layer.id); }}><Trash2 size={13} /></button>
{asset ? <a href={assetContentUrl(asset)} title={`下载 ${layer.name}`} aria-label={`下载 ${layer.name}`} onClick={(event) => event.stopPropagation()}><Download size={13} /></a> : null}
</span>
</article>
);
})}
<article className={clsx(selectedId === baseSelectionID && "active")} tabIndex={0} onClick={() => setSelectedId(baseSelectionID)}>
<button className="seedream-layer-visibility" type="button" title={draft.baseVisible ? "隐藏底图" : "显示底图"} aria-label={draft.baseVisible ? "隐藏底图" : "显示底图"} onClick={(event) => { event.stopPropagation(); toggleVisibility(baseSelectionID); }}>
{draft.baseVisible ? <Eye size={15} /> : <EyeOff size={15} />}
</button>
<span className="seedream-layer-thumb"><img src={assetPreviewUrl(baseAsset)} alt="" /></span>
<span className="seedream-layer-copy"><strong></strong><small></small></span>
<span className="seedream-layer-fixed"></span>
<span className="seedream-layer-row-actions"><a href={assetContentUrl(baseAsset)} title="下载底图" aria-label="下载底图" onClick={(event) => event.stopPropagation()}><Download size={13} /></a></span>
</article>
</div>
</div>
</div>
{editDialog ? (
<div className="seedream-layer-edit-backdrop" role="presentation">
<div className="seedream-layer-edit-dialog" role="dialog" aria-modal="true" aria-labelledby="seedream-layer-edit-title">
<header>
<div>
<span>Seedream 5.0 Pro</span>
<h3 id="seedream-layer-edit-title">{draft.layers.find((layer) => layer.id === editDialog.layerId)?.name}</h3>
</div>
<button className="icon-button" type="button" disabled={editBusy} aria-label="关闭图层编辑" title={editBusy ? "任务提交中" : "关闭"} onClick={() => setEditDialog(null)}><X size={16} /></button>
</header>
<div className="seedream-layer-edit-body">
<div className="seedream-layer-edit-preview">
{editDialog.result ? <img src={assetPreviewUrl(editDialog.result)} alt="即梦编辑结果" /> : selectedAsset ? <img src={assetPreviewUrl(selectedAsset)} alt="待编辑图层" /> : <ImagePlus size={20} />}
<span>{editDialog.result ? "编辑结果" : "当前图层"}</span>
</div>
<label>
<textarea
value={editDialog.prompt}
disabled={editBusy || editDialog.phase === "ready"}
onChange={(event) => setEditDialog((current) => current ? { ...current, prompt: event.target.value, error: undefined } : current)}
placeholder="例如:把杯子的颜色改成蓝色,保持透明背景和主体位置"
/>
</label>
<p>{canRetryEditResultSync ? "编辑任务已经完成,重新同步只读取现有结果,不会再次提交任务或计费。" : "会新建一个 Seedream 交互编辑任务,并按当前 Seedream 5.0 Pro 费率计费。模型可能改变透明边缘,请在替换前预览结果。"}</p>
{editDialog.phase === "polling" ? <div className="seedream-layer-edit-progress"><Loader2 className="spin" size={14} />{editDialog.job?.status === "succeeded" ? "任务已完成,正在同步结果素材" : "结果生成中"} {editDialog.job?.id}</div> : null}
{editDialog.error ? <div className="seedream-layer-download-error" role="alert">{editDialog.error}</div> : null}
</div>
<footer>
{editDialog.phase === "ready" ? (
<>
<button className="button" type="button" onClick={() => applyEditResult("add")}><Plus size={14} /></button>
<button className="button primary" type="button" onClick={() => applyEditResult("replace")}><Check size={14} /></button>
</>
) : (
<button className="button primary" type="button" disabled={editBusy || (!canRetryEditResultSync && !editDialog.prompt.trim())} onClick={() => void (canRetryEditResultSync ? retryLayerEditResultSync() : submitLayerEdit())}>
{editBusy ? <Loader2 className="spin" size={14} /> : canRetryEditResultSync ? <RefreshCw size={14} /> : <Pencil size={14} />}
{editDialog.phase === "polling" ? (editDialog.job?.status === "succeeded" ? "同步结果" : "等待结果") : canRetryEditResultSync ? "重新同步结果" : "开始编辑"}
</button>
)}
</footer>
</div>
</div>
) : null}
</div>
);
}
function initialLayerDraft(base: SeedreamLayerOutput, layers: SeedreamLayerOutput[], baseSize: [number, number]): SeedreamLayerDraft {
return {
baseAssetId: base.asset.id,
baseVisible: true,
layers: layers.map((layer, index) => ({
id: `provider-${layer.asset.id}`,
assetId: layer.asset.id,
name: layer.name,
description: layer.description,
visible: true,
box: initialLayerBox(layer, baseSize, index),
rotation: 0,
source: "provider"
}))
};
}
function initialLayerBox(layer: SeedreamLayerOutput, baseSize: [number, number], index: number): SeedreamLayerBox {
const normalized = layer.boundingBox?.normalized;
if (normalized) return sanitizeLayerBox(normalized);
const absolute = layer.boundingBox?.absolute;
if (absolute && baseSize[0] > 1 && baseSize[1] > 1) {
return sanitizeLayerBox([
absolute[0] / baseSize[0] * 1000,
absolute[1] / baseSize[1] * 1000,
absolute[2] / baseSize[0] * 1000,
absolute[3] / baseSize[1] * 1000
]);
}
if (layer.size && baseSize[0] > 1 && baseSize[1] > 1) {
const width = Math.min(900, Math.max(80, layer.size[0] / baseSize[0] * 1000));
const height = Math.min(900, Math.max(80, layer.size[1] / baseSize[1] * 1000));
const offset = Math.min(index * 22, 120);
return sanitizeLayerBox([(1000 - width) / 2 + offset, (1000 - height) / 2 + offset, (1000 + width) / 2 + offset, (1000 + height) / 2 + offset]);
}
return centeredLayerBox(index);
}
function sanitizeLayerBox(value: SeedreamLayerBox): SeedreamLayerBox {
const scale = Math.max(...value.map((item) => Math.abs(item))) <= 1 ? 1000 : 1;
const left = clamp(value[0] * scale, 0, 980);
const top = clamp(value[1] * scale, 0, 980);
const right = clamp(value[2] * scale, left + 20, 1000);
const bottom = clamp(value[3] * scale, top + 20, 1000);
return [left, top, right, bottom];
}
function centeredLayerBox(index = 0): SeedreamLayerBox {
const offset = Math.min(index * 20, 100);
return [150 + offset, 150 + offset, 850, 850];
}
function moveLayerBox(box: SeedreamLayerBox, deltaX: number, deltaY: number): SeedreamLayerBox {
const width = box[2] - box[0];
const height = box[3] - box[1];
const left = clamp(box[0] + deltaX, 0, 1000 - width);
const top = clamp(box[1] + deltaY, 0, 1000 - height);
return [left, top, left + width, top + height];
}
function resizeLayerBox(box: SeedreamLayerBox, deltaX: number, deltaY: number): SeedreamLayerBox {
const width = box[2] - box[0];
const height = box[3] - box[1];
const requestedFactor = Math.max((width + deltaX) / width, (height + deltaY) / height);
const factor = clamp(requestedFactor, Math.max(20 / width, 20 / height), Math.min((1000 - box[0]) / width, (1000 - box[1]) / height));
return [box[0], box[1], box[0] + width * factor, box[1] + height * factor];
}
function scaleLayerBox(box: SeedreamLayerBox, requestedFactor: number): SeedreamLayerBox {
const width = box[2] - box[0];
const height = box[3] - box[1];
const factor = clamp(requestedFactor, Math.max(20 / width, 20 / height), Math.min(1000 / width, 1000 / height));
const nextWidth = width * factor;
const nextHeight = height * factor;
const centerX = (box[0] + box[2]) / 2;
const centerY = (box[1] + box[3]) / 2;
const left = clamp(centerX - nextWidth / 2, 0, 1000 - nextWidth);
const top = clamp(centerY - nextHeight / 2, 0, 1000 - nextHeight);
return [left, top, left + nextWidth, top + nextHeight];
}
async function layerFromAsset(asset: Asset, source: SeedreamLayerSource, baseSize: [number, number]): Promise<SeedreamCompositionLayer> {
const size = await imageDimensions(asset).catch(() => undefined);
return {
id: newLayerID(),
assetId: asset.id,
name: asset.name || "新增图层",
visible: true,
box: size ? fittedLayerBox(size, baseSize) : centeredLayerBox(),
rotation: 0,
source
};
}
function fittedLayerBox(size: [number, number], baseSize: [number, number]): SeedreamLayerBox {
if (size[0] <= 0 || size[1] <= 0 || baseSize[0] <= 0 || baseSize[1] <= 0) return centeredLayerBox();
const relativeAspect = (size[0] / size[1]) / (baseSize[0] / baseSize[1]);
const width = relativeAspect >= 1 ? 720 : Math.max(100, 720 * relativeAspect);
const height = relativeAspect >= 1 ? Math.max(100, 720 / relativeAspect) : 720;
return [(1000 - width) / 2, (1000 - height) / 2, (1000 + width) / 2, (1000 + height) / 2];
}
function imageDimensions(asset: Asset) {
return new Promise<[number, number]>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve([image.naturalWidth, image.naturalHeight]);
image.onerror = () => reject(new Error("读取图片尺寸失败"));
image.src = assetPreviewUrl(asset);
});
}
function normalizeRotation(value: number) {
let rotation = value % 360;
if (rotation > 180) rotation -= 360;
if (rotation <= -180) rotation += 360;
return Math.round(rotation * 1000) / 1000;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
function assetMap(assets: Asset[]) {
return new Map(assets.map((asset) => [asset.id, asset]));
}
function isLayerAsset(asset: Asset) {
return asset.kind === "image" || asset.kind === "reference" || asset.kind === "mask";
}
function newLayerID() {
const value = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `layer-${value}`;
}
function serializeDraft(draft: SeedreamLayerDraft) {
return JSON.stringify(draft);
}
function draftFromComposition(composition: SeedreamLayerComposition): SeedreamLayerDraft {
return {
baseAssetId: composition.baseAssetId,
baseVisible: composition.baseVisible,
layers: composition.layers.map((layer) => ({ ...layer, box: [...layer.box] as SeedreamLayerBox }))
};
}
function isLayerComposition(value: unknown): value is SeedreamLayerComposition {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return typeof record.jobId === "string"
&& typeof record.baseAssetId === "string"
&& typeof record.baseVisible === "boolean"
&& typeof record.version === "number"
&& Array.isArray(record.layers);
}
function putLayerComposition(jobId: string, version: number, draft: SeedreamLayerDraft) {
return fetch(`/api/layer-compositions/${encodeURIComponent(jobId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version, ...draft })
});
}
async function readJSON(response: Response): Promise<Record<string, unknown>> {
return response.json().catch(() => ({})) as Promise<Record<string, unknown>>;
}
function payloadError(payload: Record<string, unknown>, fallback: string) {
return typeof payload.error === "string" && payload.error.trim() ? payload.error : fallback;
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
function saveStateLabel(state: SaveState) {
if (state === "loading") return "读取工程";
if (state === "saving") return "正在保存";
if (state === "dirty") return "等待保存";
if (state === "error") return "保存失败";
return "已自动保存";
}
function layerSourceLabel(source: SeedreamLayerSource) {
if (source === "upload") return "上传图层";
if (source === "library") return "素材库";
if (source === "seedream-edit") return "即梦编辑";
return "原始拆分";
}
function safeDownloadName(label: string, originalName: string) {
const extension = originalName.includes(".") ? originalName.slice(originalName.lastIndexOf(".")) : ".png";
const sanitized = label.replace(/[\\/:*?"<>|]/g, "-").slice(0, 120);
return sanitized.toLocaleLowerCase().endsWith(extension.toLocaleLowerCase()) ? sanitized : `${sanitized}${extension}`;
}
async function waitForImageJob(jobId: string, signal: AbortSignal): Promise<GenerationJob> {
const deadline = Date.now() + 10 * 60 * 1000;
while (Date.now() < deadline) {
await abortableDelay(1800, signal);
const response = await fetch(`/api/generations/image/${encodeURIComponent(jobId)}`, { cache: "no-store", signal });
const payload = await readJSON(response);
if (!response.ok) throw new Error(payloadError(payload, "读取图层编辑任务失败"));
const job = payload.job as GenerationJob | undefined;
if (!job) throw new Error("图层编辑任务返回数据不完整");
if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return job;
}
throw new Error("图层编辑等待超时,任务仍可在任务模块中查看");
}
function abortableDelay(milliseconds: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const timer = window.setTimeout(resolve, milliseconds);
signal.addEventListener("abort", () => {
window.clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
}