完善生图5.0的功能
This commit is contained in:
1 parent
88a4268d40
commit
678d213f40
31 files changed
+3281
-107
No files matched your search
+815
-67
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,422 @@
|
||||
"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>
|
||||
)}
|
||||
|
||||
{sources.length ? (
|
||||
<div className="seedream-source-picker">
|
||||
<div className="seedream-source-picker-head">
|
||||
<span>{mode === "interactive" ? `输入图片 ${selectedSources.length}/${maximumInteractiveSources}` : "选择待拆图片"}</span>
|
||||
{mode === "interactive" ? <small>图片编号按选中顺序生成,用于“图1 / 图2”坐标提示词</small> : null}
|
||||
</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 (mode === "interactive" && !included) {
|
||||
onToggleSource(material);
|
||||
return;
|
||||
}
|
||||
onSelectSource(material);
|
||||
}}
|
||||
>
|
||||
<img src={materialPreviewUrl(material)} alt="" />
|
||||
<span>{material.name || material.label || "图片"}</span>
|
||||
</button>
|
||||
{mode === "interactive" ? (
|
||||
<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>
|
||||
) : null}
|
||||
</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)}`;
|
||||
}
|
||||
Reference in new issue
Block a user