Files
NianAIGC/components/seedream-workspace.tsx
2026-09-02 17:02:02 +08:00

421 lines
18 KiB
TypeScript
Raw 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 PointerEvent as ReactPointerEvent } from "react";
import { BoxSelect, Brush, Check, ImageIcon, Loader2, MapPin, Redo2, Trash2, Undo2, Upload, X } from "lucide-react";
import clsx from "clsx";
import { materialPreviewUrl } from "@/lib/client/asset-urls";
import type { PromptMaterial } from "@/lib/prompt/assembler";
import {
seedreamAnnotationToken,
type SeedreamAnnotation,
type SeedreamCreationMode,
type SeedreamPoint
} from "@/lib/seedream/creation";
type AnnotationTool = "box" | "point" | "brush";
type BoxDraft = {
start: SeedreamPoint;
current: SeedreamPoint;
};
type StrokeDraft = {
points: SeedreamPoint[];
};
const maximumInteractiveSources = 10;
export function SeedreamWorkspace({
mode,
sources,
selectedSources,
source,
uploading,
annotations,
onAnnotationsChange,
onSelectSource,
onToggleSource,
onUpload,
onInsertAnnotation
}: {
mode: Exclude<SeedreamCreationMode, "basic">;
sources: PromptMaterial[];
selectedSources: PromptMaterial[];
source?: PromptMaterial;
uploading: boolean;
annotations: SeedreamAnnotation[];
onAnnotationsChange: (annotations: SeedreamAnnotation[]) => void;
onSelectSource: (material: PromptMaterial) => void;
onToggleSource: (material: PromptMaterial) => void;
onUpload: (files: FileList | null) => void;
onInsertAnnotation: (token: string) => void;
}) {
const [tool, setTool] = useState<AnnotationTool>("box");
const [boxDraft, setBoxDraft] = useState<BoxDraft | null>(null);
const [strokeDraft, setStrokeDraft] = useState<StrokeDraft | null>(null);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [redoStack, setRedoStack] = useState<SeedreamAnnotation[]>([]);
const canvasRef = useRef<SVGSVGElement | null>(null);
const sourceKey = materialKey(source);
const selectedSourceKeys = useMemo(() => new Set(selectedSources.map(materialKey)), [selectedSources]);
const sourceIndexByKey = useMemo(() => new Map(selectedSources.map((material, index) => [materialKey(material), index + 1])), [selectedSources]);
const activeAnnotations = useMemo(
() => annotations.filter((annotation) => !annotation.sourceKey || annotation.sourceKey === sourceKey),
[annotations, sourceKey]
);
const selectedAnnotation = activeAnnotations.find((annotation) => annotation.id === selectedAnnotationId);
const nextIndex = useMemo(() => annotations.reduce((maximum, annotation) => Math.max(maximum, annotation.index), 0) + 1, [annotations]);
const selectionLimitReached = selectedSources.length >= maximumInteractiveSources;
useEffect(() => {
setBoxDraft(null);
setStrokeDraft(null);
setSelectedAnnotationId(null);
setRedoStack([]);
}, [sourceKey, mode]);
useEffect(() => {
if (mode === "layers" && tool === "point") setTool("box");
}, [mode, tool]);
function addAnnotation(annotation: SeedreamAnnotation) {
onAnnotationsChange([...annotations, { ...annotation, sourceKey }]);
setSelectedAnnotationId(annotation.id);
setRedoStack([]);
}
function undo() {
const removed = activeAnnotations.at(-1);
if (!removed) return;
onAnnotationsChange(annotations.filter((annotation) => annotation.id !== removed.id));
setRedoStack((items) => [...items, removed]);
if (selectedAnnotationId === removed.id) setSelectedAnnotationId(null);
}
function redo() {
const restored = redoStack.at(-1);
if (!restored) return;
onAnnotationsChange([...annotations, restored]);
setRedoStack((items) => items.slice(0, -1));
setSelectedAnnotationId(restored.id);
}
function removeSelected() {
if (!selectedAnnotation) return;
onAnnotationsChange(annotations.filter((annotation) => annotation.id !== selectedAnnotation.id));
setRedoStack((items) => [...items, selectedAnnotation]);
setSelectedAnnotationId(null);
}
function handlePointerDown(event: ReactPointerEvent<SVGSVGElement>) {
if (!source) return;
const point = pointerCoordinate(event, canvasRef.current);
if (!point) return;
if (tool === "point" && mode === "interactive") {
addAnnotation({ id: annotationID(), index: nextIndex, kind: "point", point });
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
setSelectedAnnotationId(null);
if (tool === "brush") {
setStrokeDraft({ points: [point] });
return;
}
setBoxDraft({ start: point, current: point });
}
function handlePointerMove(event: ReactPointerEvent<SVGSVGElement>) {
const point = pointerCoordinate(event, canvasRef.current);
if (!point) return;
if (boxDraft) {
setBoxDraft((current) => current ? { ...current, current: point } : null);
return;
}
if (strokeDraft) {
setStrokeDraft((current) => {
if (!current) return null;
const previous = current.points.at(-1);
if (previous && pointDistance(previous, point) < 3) return current;
return { points: [...current.points, point] };
});
}
}
function handlePointerUp(event: ReactPointerEvent<SVGSVGElement>) {
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
if (strokeDraft) {
const points = strokeDraft.points.length === 1
? [strokeDraft.points[0], { x: Math.min(1000, strokeDraft.points[0].x + 1), y: Math.min(1000, strokeDraft.points[0].y + 1) }]
: strokeDraft.points;
setStrokeDraft(null);
addAnnotation({ id: annotationID(), index: nextIndex, kind: "stroke", points });
return;
}
if (!boxDraft) return;
const box = normalizedBox(boxDraft.start, boxDraft.current);
setBoxDraft(null);
if (box.right - box.left < 8 || box.bottom - box.top < 8) return;
addAnnotation({ id: annotationID(), index: nextIndex, kind: "box", box });
}
function cancelDraft() {
setBoxDraft(null);
setStrokeDraft(null);
}
const currentImageIndex = sourceIndexByKey.get(sourceKey) || 1;
return (
<section className={clsx("seedream-workspace", mode === "interactive" ? "interactive" : "layers")} aria-label={mode === "interactive" ? "Seedream 交互编辑" : "Seedream 图层拆分"}>
<header className="seedream-workspace-head">
<div>
<strong>{mode === "interactive" ? "精准编辑画布" : "指定拆分区域"}</strong>
<span>{mode === "interactive" ? "可在最多 10 张图片间切换,使用坐标或蓝色涂鸦描述编辑位置" : "可留空自动全拆,也可框选或涂鸦指定希望拆出的元素"}</span>
</div>
<span className="seedream-model-pill">Seedream 5.0 Pro</span>
</header>
{!source ? (
<div className="seedream-source-empty">
<span className="seedream-source-empty-icon" aria-hidden="true"><ImageIcon size={24} /></span>
<strong>{sources.length ? "选择参与生成的图片" : "先上传一张图片"}</strong>
<span>{mode === "interactive" ? "可以选择多张图片进行跨图定位编辑" : "模板效果预览图不会自动参与拆分"}</span>
<label className="button primary">
{uploading ? <Loader2 className="spin" size={16} /> : <Upload size={16} />}
上传图片
<input type="file" multiple={mode === "interactive"} accept={mode === "layers" ? "image/png,image/jpeg" : "image/*"} disabled={uploading} onChange={(event) => {
onUpload(event.currentTarget.files);
event.currentTarget.value = "";
}} />
</label>
</div>
) : (
<div className="seedream-editor-shell">
<div className="seedream-editor-toolbar" role="toolbar" aria-label="标注工具">
<span className="seedream-current-image-badge">图{currentImageIndex}</span>
<button className={clsx(tool === "box" && "active")} type="button" aria-pressed={tool === "box"} title="框选区域" onClick={() => setTool("box")}>
<BoxSelect size={16} />
框选
</button>
{mode === "interactive" ? (
<button className={clsx(tool === "point" && "active")} type="button" aria-pressed={tool === "point"} title="点选位置" onClick={() => setTool("point")}>
<MapPin size={16} />
点选
</button>
) : null}
<button className={clsx(tool === "brush" && "active")} type="button" aria-pressed={tool === "brush"} title="蓝色涂鸦标记" onClick={() => setTool("brush")}>
<Brush size={16} />
涂鸦
</button>
<span className="seedream-editor-toolbar-divider" />
<button type="button" disabled={!activeAnnotations.length} title="撤销" aria-label="撤销当前图片的最后一个标注" onClick={undo}><Undo2 size={16} /></button>
<button type="button" disabled={!redoStack.length} title="重做" aria-label="恢复最后撤销的标注" onClick={redo}><Redo2 size={16} /></button>
<button className="danger" type="button" disabled={!selectedAnnotation} title="删除所选标注" aria-label="删除所选标注" onClick={removeSelected}><Trash2 size={16} /></button>
<label className="seedream-toolbar-upload" title={mode === "interactive" ? "添加图片" : "更换图片"}>
{uploading ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
{mode === "interactive" ? "添加图片" : "更换图片"}
<input type="file" multiple={mode === "interactive"} accept={mode === "layers" ? "image/png,image/jpeg" : "image/*"} disabled={uploading || mode === "interactive" && selectionLimitReached} onChange={(event) => {
onUpload(event.currentTarget.files);
event.currentTarget.value = "";
}} />
</label>
</div>
<div className="seedream-canvas-viewport">
<div className="seedream-canvas-stage">
<img src={materialPreviewUrl(source)} alt={source.name || `${mode === "interactive" ? "编辑" : "拆分"}底图`} draggable={false} />
<svg
ref={canvasRef}
className={clsx("seedream-annotation-canvas", `tool-${tool}`)}
viewBox="0 0 1000 1000"
preserveAspectRatio="none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={cancelDraft}
>
{activeAnnotations.map((annotation) => (
<AnnotationShape
key={annotation.id}
annotation={annotation}
selected={annotation.id === selectedAnnotationId}
onSelect={() => setSelectedAnnotationId(annotation.id)}
/>
))}
{boxDraft ? <DraftBox draft={boxDraft} /> : null}
{strokeDraft ? <DraftStroke draft={strokeDraft} /> : null}
</svg>
</div>
</div>
<div className="seedream-canvas-note">
{mode === "interactive"
? "蓝色涂鸦会在提交时自动合成到对应输入图;框选和点选会转换成官方坐标标签。"
: "蓝色涂鸦会合成到待拆图片;框选会转换为 <bbox> 坐标,不会修改原始素材。"}
</div>
<div className="seedream-annotation-strip" aria-label="当前图片标注">
{activeAnnotations.length ? activeAnnotations.map((annotation) => (
<button
type="button"
className={clsx(annotation.id === selectedAnnotationId && "active")}
key={annotation.id}
onClick={() => {
setSelectedAnnotationId(annotation.id);
onInsertAnnotation(seedreamAnnotationToken(annotation.index, annotation.kind));
}}
title="点击插入提示词"
>
<span>{annotationIcon(annotation.kind)}</span>
{seedreamAnnotationToken(annotation.index, annotation.kind)}
<small>插入提示词</small>
</button>
)) : <span className="seedream-annotation-empty">尚未标记。可以框选、{mode === "interactive" ? "点选或" : "或"}使用蓝色涂鸦。</span>}
</div>
</div>
)}
{mode === "interactive" && sources.length ? (
<div className="seedream-source-picker">
<div className="seedream-source-picker-head">
<span>{`输入图片 ${selectedSources.length}/${maximumInteractiveSources}`}</span>
<small>图片编号按选中顺序生成,用于“图1 / 图2”坐标提示词</small>
</div>
<div>
{sources.map((material) => {
const key = materialKey(material);
const included = selectedSourceKeys.has(key);
const imageIndex = sourceIndexByKey.get(key);
return (
<article className={clsx("seedream-source-card", key === sourceKey && "active", included && "included")} key={key}>
<button
type="button"
className="seedream-source-card-main"
title={material.name || material.label}
aria-pressed={key === sourceKey}
onClick={() => {
if (!included) {
onToggleSource(material);
return;
}
onSelectSource(material);
}}
>
<img src={materialPreviewUrl(material)} alt="" />
<span>{material.name || material.label || "图片"}</span>
</button>
<button
type="button"
className="seedream-source-toggle"
aria-label={included ? `移除${material.name || "图片"}` : `加入${material.name || "图片"}`}
title={included ? "不参与本次生成" : selectionLimitReached ? "最多选择 10 张图片" : "参与本次生成"}
disabled={!included && selectionLimitReached}
onClick={() => onToggleSource(material)}
>
{included ? <><Check size={12} />图{imageIndex}</> : <><X size={12} />未选</>}
</button>
</article>
);
})}
</div>
</div>
) : null}
</section>
);
}
function AnnotationShape({ annotation, selected, onSelect }: { annotation: SeedreamAnnotation; selected: boolean; onSelect: () => void }) {
if (annotation.kind === "point" && annotation.point) {
return (
<g className={clsx("seedream-annotation-shape", selected && "selected")} onPointerDown={(event) => { event.stopPropagation(); onSelect(); }}>
<circle className="seedream-annotation-halo" cx={annotation.point.x} cy={annotation.point.y} r="24" />
<circle className="seedream-annotation-point" cx={annotation.point.x} cy={annotation.point.y} r="9" />
<AnnotationLabel x={annotation.point.x + 18} y={annotation.point.y - 18} index={annotation.index} />
</g>
);
}
if (annotation.kind === "box" && annotation.box) {
const width = annotation.box.right - annotation.box.left;
const height = annotation.box.bottom - annotation.box.top;
return (
<g className={clsx("seedream-annotation-shape", selected && "selected")} onPointerDown={(event) => { event.stopPropagation(); onSelect(); }}>
<rect className="seedream-annotation-box" x={annotation.box.left} y={annotation.box.top} width={width} height={height} rx="4" />
<AnnotationLabel x={annotation.box.left + 10} y={annotation.box.top + 10} index={annotation.index} />
</g>
);
}
if (annotation.kind === "stroke" && annotation.points?.length) {
const first = annotation.points[0];
return (
<g className={clsx("seedream-annotation-shape stroke", selected && "selected")} onPointerDown={(event) => { event.stopPropagation(); onSelect(); }}>
<path className="seedream-annotation-stroke-hit" d={pointsPath(annotation.points)} />
<path className="seedream-annotation-stroke" d={pointsPath(annotation.points)} />
<AnnotationLabel x={first.x + 18} y={first.y - 18} index={annotation.index} />
</g>
);
}
return null;
}
function AnnotationLabel({ x, y, index }: { x: number; y: number; index: number }) {
const safeX = Math.min(955, Math.max(12, x));
const safeY = Math.min(955, Math.max(34, y));
return (
<g className="seedream-annotation-label" transform={`translate(${safeX} ${safeY})`}>
<circle r="20" />
<text textAnchor="middle" dominantBaseline="central">{index}</text>
</g>
);
}
function DraftBox({ draft }: { draft: BoxDraft }) {
const box = normalizedBox(draft.start, draft.current);
return <rect className="seedream-annotation-box draft" x={box.left} y={box.top} width={box.right - box.left} height={box.bottom - box.top} rx="4" />;
}
function DraftStroke({ draft }: { draft: StrokeDraft }) {
return <path className="seedream-annotation-stroke draft" d={pointsPath(draft.points)} />;
}
function annotationIcon(kind: SeedreamAnnotation["kind"]) {
if (kind === "point") return <MapPin size={13} />;
if (kind === "stroke") return <Brush size={13} />;
return <BoxSelect size={13} />;
}
function normalizedBox(start: SeedreamPoint, end: SeedreamPoint) {
return {
left: Math.min(start.x, end.x),
top: Math.min(start.y, end.y),
right: Math.max(start.x, end.x),
bottom: Math.max(start.y, end.y)
};
}
function pointerCoordinate(event: ReactPointerEvent<SVGSVGElement>, canvas: SVGSVGElement | null): SeedreamPoint | null {
if (!canvas) return null;
const bounds = canvas.getBoundingClientRect();
if (!bounds.width || !bounds.height) return null;
return {
x: clamp((event.clientX - bounds.left) / bounds.width * 1000),
y: clamp((event.clientY - bounds.top) / bounds.height * 1000)
};
}
function pointsPath(points: SeedreamPoint[]) {
return points.map((point, index) => `${index === 0 ? "M" : "L"}${point.x} ${point.y}`).join(" ");
}
function pointDistance(a: SeedreamPoint, b: SeedreamPoint) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function clamp(value: number) {
return Math.max(0, Math.min(1000, Math.round(value)));
}
function materialKey(material?: PromptMaterial) {
return material ? material.id || material.url : "";
}
function annotationID() {
return `annotation-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}