修复了一点bug,然后增加了图层拆分更多的功能

This commit is contained in:
andy committed 2026-09-02 16:06:45 +08:00
1 parent 1305a9cdd8
commit ce8372e878
31 files changed
+2567 -391

No files matched your search

+4 -370
View File
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
import { ArrowDown, ArrowUp, Check, CircleDollarSign, Download, Eye, EyeOff, Film, ImageIcon, ImagePlus, Info, Layers3, Loader2, Move, Music, Pencil, Plus, RefreshCw, RotateCcw, Save, ScanLine, Send, Upload, WandSparkles, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react";
import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, Layers3, Loader2, Music, Pencil, Plus, RefreshCw, Save, ScanLine, Send, Upload, WandSparkles, X } from "lucide-react";
import clsx from "clsx";
import { clampPage, pageItems, Pagination } from "@/components/pagination";
import { SeedreamWorkspace } from "@/components/seedream-workspace";
import { SeedreamLayerResultViewer, type SeedreamLayerOutput } from "@/components/seedream-layer-result-viewer";
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
import {
SEEDANCE_20_MODEL,
@@ -28,9 +29,7 @@ import {
import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders";
import { formatBillingAmount } from "@/lib/billing";
import { assetContentUrl, assetPreviewUrl, materialPreviewUrl, storedAssetPreviewUrl } from "@/lib/client/asset-urls";
import { downloadSeedreamLayerComposition } from "@/lib/client/seedream-layer-compose";
import { createSeedreamMarkedFile } from "@/lib/client/seedream-markup";
import { downloadFilesAsZip } from "@/lib/client/zip-download";
import {
buildSeedreamInteractivePrompt,
buildSeedreamLayerPrompt,
@@ -2017,7 +2016,7 @@ function TaskDetailModal({
</div>
</div>
{layerOutputs.length ? (
<SeedreamLayerResultViewer jobId={job.id} outputs={layerOutputs} />
<SeedreamLayerResultViewer jobId={job.id} outputs={layerOutputs} assetLibrary={[...assets.values()]} />
) : outputAssets.length ? (
<div className="task-detail-output-grid">
{outputAssets.map((asset) => (
@@ -2128,365 +2127,6 @@ function TaskDetailModal({
);
}
type SeedreamLayerOutput = {
asset: Asset;
zIndex: number;
name: string;
description?: string;
size?: [number, number];
boundingBox?: {
absolute?: [number, number, number, number];
normalized?: [number, number, number, number];
};
};
function SeedreamLayerResultViewer({ jobId, outputs }: { jobId: string; outputs: SeedreamLayerOutput[] }) {
const ordered = useMemo(() => [...outputs].sort((a, b) => a.zIndex - b.zIndex), [outputs]);
const base = ordered.find((output) => output.zIndex === 0) || ordered[0];
const layers = ordered.filter((output) => output !== base);
const baseSize = base.size || [1, 1];
const layerSignature = `${jobId}:${ordered.map((output) => [output.asset.id, output.zIndex, output.size, output.boundingBox?.normalized, output.boundingBox?.absolute].join(":"))}`;
const [hiddenIds, setHiddenIds] = useState<Set<string>>(() => new Set());
const [selectedId, setSelectedId] = useState<string | null>(layers[0]?.asset.id ?? base.asset.id);
const [layerOrder, setLayerOrder] = useState<string[]>(() => layers.map((layer) => layer.asset.id));
const [transforms, setTransforms] = useState<Record<string, SeedreamLayerBox>>(() => initialSeedreamLayerTransforms(layers, baseSize));
const [interaction, setInteraction] = useState<SeedreamLayerInteraction | null>(null);
const [zipDownloading, setZipDownloading] = useState(false);
const [exporting, setExporting] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const layerById = useMemo(() => new Map(layers.map((layer) => [layer.asset.id, layer])), [layers]);
const displayLayers = layerOrder.map((id) => layerById.get(id)).filter((layer): layer is SeedreamLayerOutput => Boolean(layer));
const selectedLayer = selectedId ? layerById.get(selectedId) : undefined;
const selectedTransform = selectedLayer ? transforms[selectedLayer.asset.id] : undefined;
const listOutputs = [...displayLayers].reverse().concat(base);
const previewMaximumWidth = Math.max(1, 500 * baseSize[0] / baseSize[1]);
useEffect(() => {
setHiddenIds(new Set());
setSelectedId(layers[0]?.asset.id ?? base.asset.id);
setLayerOrder(layers.map((layer) => layer.asset.id));
setTransforms(initialSeedreamLayerTransforms(layers, baseSize));
setInteraction(null);
setDownloadError(null);
}, [layerSignature]);
function toggle(id: string) {
setHiddenIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function downloadAll() {
setZipDownloading(true);
setDownloadError(null);
try {
await downloadFilesAsZip(ordered.map((output) => ({
name: layerDownloadName(output),
url: assetDownloadUrl(output.asset)
})), `seedream-layers-${jobId}.zip`);
} catch (err) {
setDownloadError(err instanceof Error ? err.message : "打包下载失败");
} finally {
setZipDownloading(false);
}
}
async function exportComposition() {
setExporting(true);
setDownloadError(null);
try {
await downloadSeedreamLayerComposition({
width: baseSize[0],
height: baseSize[1],
fileName: `seedream-composition-${jobId}.png`,
items: [base, ...displayLayers].map((output) => ({
url: assetContentUrl(output.asset),
visible: !hiddenIds.has(output.asset.id),
box: output === base ? fullSeedreamLayerBox : transforms[output.asset.id] || fullSeedreamLayerBox
}))
});
} catch (err) {
setDownloadError(err instanceof Error ? err.message : "导出合成图片失败");
} finally {
setExporting(false);
}
}
function resetEditor() {
setHiddenIds(new Set());
setLayerOrder(layers.map((layer) => layer.asset.id));
setTransforms(initialSeedreamLayerTransforms(layers, baseSize));
setSelectedId(layers[0]?.asset.id ?? base.asset.id);
setInteraction(null);
setDownloadError(null);
}
function reorderLayer(id: string, direction: -1 | 1) {
setLayerOrder((current) => {
const index = current.indexOf(id);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= current.length) return current;
const next = [...current];
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
return next;
});
}
function scaleLayer(id: string, factor: number) {
setTransforms((current) => {
const box = current[id];
return box ? { ...current, [id]: scaleSeedreamLayerBox(box, factor) } : current;
});
}
function startInteraction(event: ReactPointerEvent<HTMLElement>, id: string, mode: SeedreamLayerInteraction["mode"]) {
const box = transforms[id];
if (!box) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
setSelectedId(id);
setInteraction({ id, mode, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startBox: box });
}
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;
const deltaX = (event.clientX - interaction.startX) / bounds.width * 1000;
const deltaY = (event.clientY - interaction.startY) / bounds.height * 1000;
const nextBox = interaction.mode === "move"
? moveSeedreamLayerBox(interaction.startBox, deltaX, deltaY)
: resizeSeedreamLayerBox(interaction.startBox, deltaX, deltaY);
setTransforms((current) => ({ ...current, [interaction.id]: nextBox }));
}
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;
setTransforms((current) => {
const box = current[id];
return box ? { ...current, [id]: moveSeedreamLayerBox(box, direction[0] * step, direction[1] * step) } : current;
});
}
return (
<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.asset.id, 0.9)}>缩小</button>
<button className="button mini-button" type="button" disabled={!selectedLayer} onClick={() => selectedLayer && scaleLayer(selectedLayer.asset.id, 1.1)}>放大</button>
<button className="button mini-button" type="button" onClick={resetEditor}><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}
>
{!hiddenIds.has(base.asset.id) ? <img className="seedream-layer-base" src={assetPreviewUrl(base.asset)} alt={base.name} draggable={false} /> : null}
{displayLayers.map((layer, stackIndex) => {
const box = transforms[layer.asset.id];
if (!box || hiddenIds.has(layer.asset.id)) return null;
return (
<button
type="button"
key={layer.asset.id}
className={clsx("seedream-layer-overlay", selectedId === layer.asset.id && "selected")}
aria-label={`移动${layer.name}图层`}
style={{
left: `${box[0] / 10}%`,
top: `${box[1] / 10}%`,
width: `${(box[2] - box[0]) / 10}%`,
height: `${(box[3] - box[1]) / 10}%`,
zIndex: stackIndex + 1
}}
onPointerDown={(event) => startInteraction(event, layer.asset.id, "move")}
onKeyDown={(event) => handleLayerKeyDown(event, layer.asset.id)}
onClick={() => setSelectedId(layer.asset.id)}
>
<img src={assetPreviewUrl(layer.asset)} alt="" draggable={false} />
<span className="seedream-layer-resize-handle" aria-hidden="true" onPointerDown={(event) => startInteraction(event, layer.asset.id, "resize")} />
</button>
);
})}
</div>
{selectedTransform ? (
<div className="seedream-layer-inspector" aria-live="polite">
<strong>{selectedLayer?.name}</strong>
<span>X {Math.round(selectedTransform[0] / 10)}%</span>
<span>Y {Math.round(selectedTransform[1] / 10)}%</span>
<span>宽 {Math.round((selectedTransform[2] - selectedTransform[0]) / 10)}%</span>
<span>高 {Math.round((selectedTransform[3] - selectedTransform[1]) / 10)}%</span>
</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>
{downloadError ? <div className="seedream-layer-download-error" role="alert">{downloadError}</div> : null}
</div>
<div className="seedream-layer-list" aria-label="拆分图层列表">
<div className="seedream-layer-list-head"><span>图层</span><small>上方图层优先显示</small></div>
{listOutputs.map((output) => {
const isBase = output === base;
const visible = !hiddenIds.has(output.asset.id);
const orderIndex = layerOrder.indexOf(output.asset.id);
return (
<article
className={clsx(selectedId === output.asset.id && "active")}
key={output.asset.id}
tabIndex={0}
aria-current={selectedId === output.asset.id ? "true" : undefined}
onClick={() => setSelectedId(output.asset.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedId(output.asset.id);
}
}}
>
<button className="seedream-layer-visibility" type="button" title={visible ? "隐藏图层" : "显示图层"} aria-label={visible ? `隐藏${output.name}` : `显示${output.name}`} onClick={(event) => { event.stopPropagation(); toggle(output.asset.id); }}>
{visible ? <Eye size={15} /> : <EyeOff size={15} />}
</button>
<span className="seedream-layer-thumb"><img src={assetPreviewUrl(output.asset)} alt="" /></span>
<span className="seedream-layer-copy">
<strong>{isBase ? "底图" : output.name}</strong>
<small>{output.description || (isBase ? "固定画布" : `原始层级 ${output.zIndex}`)}</small>
</span>
{isBase ? <span className="seedream-layer-fixed">固定</span> : (
<span className="seedream-layer-order-actions">
<button type="button" title="上移一层" aria-label={`上移${output.name}`} disabled={orderIndex === layerOrder.length - 1} onClick={(event) => { event.stopPropagation(); reorderLayer(output.asset.id, 1); }}><ArrowUp size={13} /></button>
<button type="button" title="下移一层" aria-label={`下移${output.name}`} disabled={orderIndex === 0} onClick={(event) => { event.stopPropagation(); reorderLayer(output.asset.id, -1); }}><ArrowDown size={13} /></button>
</span>
)}
<a className="icon-button" href={assetDownloadUrl(output.asset)} title={`下载 ${output.name}`} aria-label={`下载 ${output.name}`} onClick={(event) => event.stopPropagation()}>
<Download size={14} />
</a>
</article>
);
})}
</div>
</div>
);
}
type SeedreamLayerBox = [number, number, number, number];
type SeedreamLayerInteraction = {
id: string;
mode: "move" | "resize";
pointerId: number;
startX: number;
startY: number;
startBox: SeedreamLayerBox;
};
const fullSeedreamLayerBox: SeedreamLayerBox = [0, 0, 1000, 1000];
function initialSeedreamLayerTransforms(layers: SeedreamLayerOutput[], baseSize: [number, number]) {
return Object.fromEntries(layers.map((layer, index) => [layer.asset.id, initialSeedreamLayerBox(layer, baseSize, index)])) as Record<string, SeedreamLayerBox>;
}
function initialSeedreamLayerBox(layer: SeedreamLayerOutput, baseSize: [number, number], index: number): SeedreamLayerBox {
const normalized = layer.boundingBox?.normalized;
if (normalized) return sanitizeSeedreamLayerBox(normalized);
const absolute = layer.boundingBox?.absolute;
if (absolute && baseSize[0] > 1 && baseSize[1] > 1) {
return sanitizeSeedreamLayerBox([
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 sanitizeSeedreamLayerBox([(1000 - width) / 2 + offset, (1000 - height) / 2 + offset, (1000 + width) / 2 + offset, (1000 + height) / 2 + offset]);
}
const offset = Math.min(index * 25, 150);
return [150 + offset, 150 + offset, 850, 850];
}
function sanitizeSeedreamLayerBox(value: SeedreamLayerBox): SeedreamLayerBox {
const scale = Math.max(...value.map((item) => Math.abs(item))) <= 1 ? 1000 : 1;
const left = clampNumber(value[0] * scale, 0, 980);
const top = clampNumber(value[1] * scale, 0, 980);
const right = clampNumber(value[2] * scale, left + 20, 1000);
const bottom = clampNumber(value[3] * scale, top + 20, 1000);
return [left, top, right, bottom];
}
function moveSeedreamLayerBox(box: SeedreamLayerBox, deltaX: number, deltaY: number): SeedreamLayerBox {
const width = box[2] - box[0];
const height = box[3] - box[1];
const left = clampNumber(box[0] + deltaX, 0, 1000 - width);
const top = clampNumber(box[1] + deltaY, 0, 1000 - height);
return [left, top, left + width, top + height];
}
function resizeSeedreamLayerBox(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 minimumFactor = Math.max(20 / width, 20 / height);
const maximumFactor = Math.min((1000 - box[0]) / width, (1000 - box[1]) / height);
const factor = clampNumber(requestedFactor, minimumFactor, maximumFactor);
return [box[0], box[1], box[0] + width * factor, box[1] + height * factor];
}
function scaleSeedreamLayerBox(box: SeedreamLayerBox, requestedFactor: number): SeedreamLayerBox {
const width = box[2] - box[0];
const height = box[3] - box[1];
const factor = clampNumber(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 = clampNumber(centerX - nextWidth / 2, 0, 1000 - nextWidth);
const top = clampNumber(centerY - nextHeight / 2, 0, 1000 - nextHeight);
return [left, top, left + nextWidth, top + nextHeight];
}
function clampNumber(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
function seedreamLayerOutputs(job: GenerationJob, outputAssets: Asset[]): SeedreamLayerOutput[] {
const response = asRecord(job.responsePayload);
const raw = asRecord(response.raw);
@@ -2510,12 +2150,6 @@ function seedreamLayerOutputs(job: GenerationJob, outputAssets: Asset[]): Seedre
});
}
function layerDownloadName(output: SeedreamLayerOutput) {
const extension = output.asset.name.match(/\.[a-z0-9]+$/i)?.[0] || (output.zIndex === 0 ? ".jpg" : ".png");
const label = output.zIndex === 0 ? "00-base" : `${String(output.zIndex).padStart(2, "0")}-${output.name}`;
return `${label}${extension}`;
}
function coordinateTuple(value: unknown): [number, number, number, number] | undefined {
if (!Array.isArray(value) || value.length !== 4) return undefined;
const numbers = value.map(numericValue);
File diff suppressed because it is too large. Load diff