1054 lines
49 KiB
TypeScript
1054 lines
49 KiB
TypeScript
"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 });
|
||
});
|
||
}
|