feat(canvas): 画布节点 - 恢复批量创作表与转换能力

This commit is contained in:
ddCat
2026-09-15 20:56:19 +08:00
parent f59b59b3e6
commit c57f1b36ae
36 changed files with 3341 additions and 34 deletions

View File

@@ -0,0 +1,257 @@
import { useCallback, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { Button, Switch, Tooltip } from "antd";
import { Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2 } from "lucide-react";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import {
BATCH_REFERENCE_HANDLE_GAP,
BATCH_REFERENCE_HANDLE_TOP,
MAX_BATCH_REFERENCE_COLUMNS,
batchReferenceColumns,
batchReferenceHandleId,
batchReferenceMentionToken,
} from "@/lib/canvas/canvas-batch-table";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { CanvasBatchOperation, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasNodeData } from "@/types/canvas";
type Props = {
node: CanvasNodeData;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
batch?: CanvasGenerationBatch;
theme: CanvasTheme;
onPatchTable: (patch: Partial<CanvasBatchTableData>) => void;
onAddRow: () => void;
onRemoveRow: (rowId: string) => void;
onUpdateRow: (rowId: string, patch: Partial<CanvasBatchRow>) => void;
onFillRows: () => void;
onGenerate: (rowIds?: string[]) => void;
onRetryItem: (batchId: string, itemId: string) => void;
onAddReferenceColumn: () => void;
onConnectStart: (event: ReactPointerEvent, handleId: string) => void;
onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void;
readOnly?: boolean;
};
const OPERATION_OPTIONS = [
{ value: "try_on", label: "批量换装" },
{ value: "creative", label: "创意生图" },
] satisfies Array<{ value: CanvasBatchOperation; label: string }>;
const CONCURRENCY_OPTIONS = [1, 5, 10] as const;
export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onConnectStart, onConnectDrop, readOnly = false }: Props) {
const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] };
const referenceColumns = batchReferenceColumns(table);
const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]);
const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]);
const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").map((connection) => connection.fromNodeId)).size, [connections, node.id]);
const completed = table.rows.filter((row) => hasNodeMedia(row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined)).length;
const unfinishedReadyCount = table.rows.filter((row) => rowReady(row, table.operation, nodeById) && !hasNodeMedia(row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined)).length;
const gridTemplateColumns = `54px repeat(${referenceColumns.length}, 84px) minmax(280px, 1fr) 146px 76px`;
const subtleSurface = `color-mix(in srgb, ${theme.node.text} 4%, transparent)`;
return (
<div data-canvas-no-zoom data-canvas-wheel-scroll className="relative flex h-full w-full flex-col overflow-visible text-xs" style={{ color: theme.node.text }} onPointerDown={(event) => event.stopPropagation()}>
{!readOnly ? <BatchReferenceHandles columns={referenceColumns} theme={theme} onConnectStart={onConnectStart} onConnectDrop={onConnectDrop} /> : null}
<div className="flex min-h-14 flex-wrap items-center gap-2 border-b px-3 py-2" style={{ borderColor: theme.node.stroke, background: subtleSurface }}>
<BatchChoiceGroup ariaLabel="批量任务类型" theme={theme} disabled={readOnly} options={OPERATION_OPTIONS} value={table.operation} onChange={(operation) => onPatchTable({ operation: operation as CanvasBatchOperation })} />
<div className="flex items-center gap-1.5 rounded-lg px-2 py-1" style={{ background: theme.node.panel }}>
<span className="font-medium" style={{ color: theme.node.muted }}>并发</span>
<BatchChoiceGroup ariaLabel="并发数" theme={theme} disabled={readOnly} compact options={CONCURRENCY_OPTIONS.map((value) => ({ value, label: String(value) }))} value={table.concurrency} onChange={(concurrency) => onPatchTable({ concurrency: Number(concurrency) })} />
</div>
<div className="flex h-7 items-center gap-1 rounded-lg px-2" style={{ background: theme.node.panel, color: theme.node.muted }}>
<span>{referenceColumns.length}/{MAX_BATCH_REFERENCE_COLUMNS} 组参考</span>
{!readOnly ? (
<Tooltip title={referenceColumns.length >= MAX_BATCH_REFERENCE_COLUMNS ? "最多支持 6 组参考图" : `新增参考图 ${referenceColumns.length + 1}`}>
<button type="button" aria-label={`新增参考图 ${referenceColumns.length + 1}`} className="grid size-5 place-items-center rounded-md transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-1 dark:hover:bg-white/10" style={{ color: theme.node.text }} disabled={referenceColumns.length >= MAX_BATCH_REFERENCE_COLUMNS} onClick={onAddReferenceColumn}>
<Plus className="size-3.5" />
</button>
</Tooltip>
) : null}
</div>
<span className="tabular-nums" style={{ color: theme.node.muted }}>已连 {connectedImageCount} · 完成 {completed}/{table.rows.length}</span>
{!readOnly ? (
<div className="ml-auto flex items-center gap-1.5">
<Tooltip title="增量同步画布连线,不会删除已有任务行">
<Button size="small" type="text" icon={<Rows3 className="size-3.5" />} onClick={onFillRows}>同步连线</Button>
</Tooltip>
<Button size="small" type="text" icon={<Plus className="size-3.5" />} onClick={onAddRow}>添加任务</Button>
<Button size="small" type="primary" icon={<Play className="size-3.5" />} disabled={!unfinishedReadyCount} onClick={() => onGenerate()}>
生成未完成项{unfinishedReadyCount ? ` · ${unfinishedReadyCount}` : ""}
</Button>
</div>
) : null}
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto rounded-b-[inherit]">
<div className="sticky top-0 z-10 grid min-w-[820px] items-center border-b px-2 py-2 text-[11px] font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.panel, color: theme.node.muted, gridTemplateColumns }}>
<span className="pl-1">任务</span>
{referenceColumns.map((column, index) => (
<span key={column.id} className="flex min-w-0 flex-col leading-4">
<span className="truncate">{column.label}</span>
<span className="font-normal opacity-50">{batchReferenceMentionToken(index)}</span>
</span>
))}
<span>任务提示词</span>
<span>生成结果</span>
<span className="text-center">操作</span>
</div>
{table.rows.length ? (
table.rows.map((row, index) => {
const output = row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined;
const item = batchItemByRowId.get(row.id);
const status = row.enabled ? rowStatus(item, output) : { label: "已停用", loading: false, retryable: false };
const ready = rowReady(row, table.operation, nodeById);
const completedRow = hasNodeMedia(output);
const references = batchRowMentionReferences(row, referenceColumns, nodeById);
const disabledReason = status.loading ? "当前任务正在生成" : !row.enabled ? "请先启用这一行" : !row.prompt.trim() ? "请填写任务提示词" : table.operation === "try_on" && row.inputNodeIds.length < 2 ? "批量换装至少需要两张参考图" : !ready ? "请补齐有效参考图" : "";
return (
<div key={row.id} className="group grid min-w-[820px] items-center gap-2 border-b px-2 py-2.5 transition-colors hover:bg-black/[.025] dark:hover:bg-white/[.025]" style={{ borderColor: theme.node.stroke, gridTemplateColumns, opacity: row.enabled ? 1 : 0.58 }}>
<div className="flex items-center gap-2 pl-1">
{!readOnly ? <Switch size="small" checked={row.enabled} aria-label={`启用任务 ${index + 1}`} onChange={(enabled) => onUpdateRow(row.id, { enabled })} /> : null}
<span className="tabular-nums" style={{ color: theme.node.muted }}>{index + 1}</span>
</div>
{referenceColumns.map((column, columnIndex) => <ReferenceThumbnail key={column.id} node={nodeById.get(row.inputNodeIds[columnIndex])} label={batchReferenceMentionToken(columnIndex)} theme={theme} />)}
<div className="min-w-0">
<CanvasResourceMentionTextarea
value={row.prompt}
references={references}
readOnly={readOnly}
sendOnEnter={false}
mentionMenuWidth={300}
aria-label={`任务 ${index + 1} 提示词`}
placeholder="描述生成目标,输入 @ 引用本行参考图"
containerClassName="h-[76px]"
className="thin-scrollbar h-full w-full overflow-y-auto rounded-lg border px-3 py-2 text-xs leading-5 outline-none transition-shadow focus-visible:ring-2"
style={{ background: subtleSurface, borderColor: theme.node.stroke, color: theme.node.text }}
onChange={(prompt) => onUpdateRow(row.id, { prompt })}
onSubmit={!readOnly && ready && !status.loading ? () => onGenerate([row.id]) : undefined}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
/>
<div className="mt-1 flex items-center justify-between px-1 text-[10px]" style={{ color: theme.node.faint }}>
<span>输入 @ 插入参考图</span>
{!readOnly ? <span>⌘/Ctrl + Enter 单行生成</span> : null}
</div>
</div>
<div className="flex min-w-0 items-center gap-2">
{completedRow ? <CachedResourceImage eager src={output?.metadata?.previewContent || output?.metadata?.content} storageKey={output?.metadata?.storageKey} alt="生成结果" className="size-12 rounded-lg object-cover" fallback={<EmptyThumbnail theme={theme} compact />} /> : <EmptyThumbnail theme={theme} compact />}
<div className="min-w-0 flex-1">
<div className="truncate font-medium" title={status.label}>{status.label}</div>
{!readOnly && status.retryable && batch && item ? <Button type="link" size="small" className="h-auto p-0 text-[11px]" icon={<RefreshCw className="size-3" />} onClick={() => onRetryItem(batch.id, item.id)}>重试</Button> : completedRow ? <span className="text-[10px]" style={{ color: theme.node.muted }}>已关联到画布</span> : null}
</div>
{status.loading ? <LoaderCircle className="size-4 shrink-0 animate-spin" style={{ color: theme.node.muted }} /> : null}
</div>
{!readOnly ? (
<div className="flex items-center justify-center gap-1">
<Tooltip title={disabledReason || (completedRow ? "重新生成这一行" : "只生成这一行")}>
<Button type={completedRow ? "text" : "primary"} size="small" className="w-8 px-0" disabled={Boolean(disabledReason)} icon={status.loading ? <LoaderCircle className="size-3.5 animate-spin" /> : <Play className="size-3.5" />} onClick={() => onGenerate([row.id])} />
</Tooltip>
<Tooltip title="删除这一行"><Button type="text" size="small" className="w-7 px-0 opacity-60 transition-opacity group-hover:opacity-100" danger icon={<Trash2 className="size-3.5" />} onClick={() => onRemoveRow(row.id)} /></Tooltip>
</div>
) : <span />}
</div>
);
})
) : (
<div className="grid min-h-44 place-items-center px-5 text-center">
<div className="flex max-w-sm flex-col items-center gap-2">
<div className="grid size-10 place-items-center rounded-xl" style={{ background: theme.accent.primarySoft, color: theme.node.text }}><Rows3 className="size-5" /></div>
<div className="font-medium">还没有批量任务</div>
<p className="m-0 leading-5" style={{ color: theme.node.muted }}>把图片连接到左侧参考图端口后同步连线,或先添加一行手工配置。</p>
{!readOnly ? <Button size="small" icon={<Plus className="size-3.5" />} onClick={onAddRow}>添加第一条任务</Button> : null}
</div>
</div>
)}
</div>
</div>
);
}
function BatchChoiceGroup({ ariaLabel, options, value, onChange, theme, compact = false, disabled = false }: { ariaLabel: string; options: Array<{ value: string | number; label: string }>; value: string | number; onChange: (value: string | number) => void; theme: CanvasTheme; compact?: boolean; disabled?: boolean }) {
return (
<div role="group" aria-label={ariaLabel} className="flex items-center rounded-lg p-0.5" style={{ background: theme.node.panel }}>
{options.map((option) => {
const selected = option.value === value;
return <button key={option.value} type="button" aria-pressed={selected} disabled={disabled} className={`rounded-md font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "min-w-7 px-1.5 py-1 text-[10px]" : "px-2.5 py-1.5 text-[11px]"}`} style={{ background: selected ? theme.accent.primary : "transparent", color: selected ? theme.accent.onPrimary : theme.node.muted }} onClick={() => onChange(option.value)}>{option.label}</button>;
})}
</div>
);
}
function BatchReferenceHandles({ columns, theme, onConnectStart, onConnectDrop }: { columns: ReturnType<typeof batchReferenceColumns>; theme: CanvasTheme; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void }) {
const commonStyle = { left: 0, width: 36, height: 36, transform: "translate(-50%, -50%)", transformOrigin: "center" };
return <>{columns.map((column, index) => <BatchReferenceHandle key={column.id} column={column} index={index} theme={theme} commonStyle={commonStyle} onConnectStart={onConnectStart} onConnectDrop={onConnectDrop} />)}</>;
}
function BatchReferenceHandle({ column, index, theme, commonStyle, onConnectStart, onConnectDrop }: { column: { id: string; label: string }; index: number; theme: CanvasTheme; commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void }) {
const [hovered, setHovered] = useState(false);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const handleId = batchReferenceHandleId(column.id);
const reset = useCallback(() => { setHovered(false); setOffset({ x: 0, y: 0 }); }, []);
const update = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
const dx = event.clientX - (bounds.left + bounds.width / 2);
const dy = event.clientY - (bounds.top + bounds.height / 2);
const limit = 10;
setOffset({ x: Math.max(-limit, Math.min(limit, dx)), y: Math.max(-limit, Math.min(limit, dy)) });
}, []);
return (
<Tooltip title={`连接到${column.label}`} placement="left">
<button type="button" aria-label={`${column.label}连线点`} className="group absolute z-[var(--node-z-handle)] grid place-items-center rounded-full outline-none" style={{ ...commonStyle, top: BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP, cursor: "crosshair" }} onPointerEnter={(event) => { setHovered(true); update(event); }} onPointerMove={update} onPointerLeave={reset} onPointerDown={(event) => { event.stopPropagation(); onConnectStart(event, handleId); }} onPointerUp={(event) => { event.stopPropagation(); onConnectDrop?.(event, handleId); }}>
<span className="grid size-[18px] place-items-center rounded-full border text-[8px] font-semibold shadow-sm transition-transform duration-100 group-hover:scale-125 group-focus-visible:scale-125" style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${hovered ? 1.06 : 1})`, background: theme.node.panel, borderColor: theme.accent.primary, color: theme.accent.primary }}>{index + 1}</span>
</button>
</Tooltip>
);
}
function batchRowMentionReferences(row: CanvasBatchRow, columns: ReturnType<typeof batchReferenceColumns>, nodeById: Map<string, CanvasNodeData>): CanvasResourceReference[] {
return columns.flatMap((column, index) => {
const source = nodeById.get(row.inputNodeIds[index]);
if (!source) return [];
return [{ id: `${row.id}:${column.id}:${source.id}`, nodeId: source.id, kind: "image" as const, label: `参考图${index + 1}`, title: `${column.label} · ${source.title || "图片"}`, previewUrl: source.metadata?.previewContent || source.metadata?.content, storageKey: source.metadata?.storageKey, active: true, sourceType: source.type, mentionToken: batchReferenceMentionToken(index) }];
});
}
function ReferenceThumbnail({ node, label, theme }: { node?: CanvasNodeData; label: string; theme: CanvasTheme }) {
if (!node || !hasNodeMedia(node)) return <EmptyThumbnail theme={theme} />;
const fallback = <EmptyThumbnail theme={theme} />;
return (
<Tooltip title={`${label} · ${node.title || "图片"}`}>
<div className="relative size-14 overflow-hidden rounded-lg border" style={{ borderColor: theme.node.stroke }}>
<CachedResourceImage eager src={node.metadata?.previewContent || node.metadata?.content} storageKey={node.metadata?.storageKey} alt={node.title || "参考图"} className="size-14 object-cover" fallback={fallback} />
<span className="absolute bottom-1 left-1 rounded px-1 py-0.5 text-[8px] font-medium text-white" style={{ background: "rgba(0,0,0,.58)" }}>{label}</span>
</div>
</Tooltip>
);
}
function EmptyThumbnail({ theme, compact = false }: { theme: CanvasTheme; compact?: boolean }): ReactNode {
return <div className={`grid shrink-0 place-items-center rounded-lg border ${compact ? "size-12" : "size-14"}`} style={{ borderColor: theme.node.stroke, color: theme.node.placeholder, background: `color-mix(in srgb, ${theme.node.text} 3%, transparent)` }}><ImageIcon className={compact ? "size-4" : "size-5"} /></div>;
}
function rowReady(row: CanvasBatchRow, operation: CanvasBatchOperation, nodeById: Map<string, CanvasNodeData>) {
if (!row.enabled || !row.prompt.trim()) return false;
if (operation === "try_on" && row.inputNodeIds.length < 2) return false;
if (!row.inputNodeIds.length) return false;
return row.inputNodeIds.every((id) => hasNodeMedia(nodeById.get(id)));
}
function hasNodeMedia(node?: CanvasNodeData) {
return Boolean(node?.metadata?.content || node?.metadata?.storageKey);
}
function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined) {
if (hasNodeMedia(output)) return { label: "生成完成", loading: false, retryable: false };
if (item?.status === "failed") return { label: item.errorDetails || "生成失败", loading: false, retryable: true };
if (item?.status === "cancelled") return { label: "已停止", loading: false, retryable: false };
if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", loading: true, retryable: false };
if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", loading: false, retryable: false };
return { label: "待生成", loading: false, retryable: false };
}

View File

@@ -4,6 +4,7 @@ import type { MouseEvent as ReactMouseEvent } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { batchReferenceHandleY } from "@/lib/canvas/canvas-batch-table";
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "@/types/canvas";
export const ConnectionPath = React.memo(function ConnectionPath({
@@ -174,6 +175,8 @@ export function activeConnectionPath(node: CanvasNodeData | undefined, handle: C
* 垂直中心,避免同一个节点因鼠标落点产生漂移的“伪端口”。
*/
export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) {
const batchY = batchReferenceHandleY(node, handleId);
if (batchY !== undefined) return batchY;
if (handleId === "storyboard:context") return node.position.y + node.height - (node.metadata?.storyboardComposerHeight || 104) / 2;
if (!handleId?.startsWith("row:")) return node.position.y + node.height / 2;
const rowId = handleId.slice(4);

View File

@@ -35,6 +35,8 @@ import { HtmlNodeContent } from "./nodes/html-node";
import { PanoramaNodeContent } from "./nodes/panorama-node";
import { SvgNodeContent } from "./nodes/svg-node";
import { ArtCritiqueNodeContent } from "./nodes/ai-art-critique-node";
import { MediaConversionNodeContent } from "./nodes/media-conversion-node";
import { MEDIA_CONVERSION_NODE_TYPE } from "@/lib/media-conversion/contracts";
export type CanvasNodeContentProps = {
node: CanvasNodeData;
@@ -65,12 +67,14 @@ export function CanvasNodeContent(props: CanvasNodeContentProps) {
if (props.node.metadata?.fileUpload) return <CanvasFileUploadContent node={props.node} theme={props.theme} reduceMotion={props.reduceMediaEffects} />;
const hasCustomContent = props.node.type === CanvasNodeType.Config
|| props.node.type === CanvasNodeType.Script
|| props.node.type === CanvasNodeType.BatchTable
|| Boolean(props.node.metadata?.directorSceneId)
|| (props.node.metadata?.workflowKind === "character" && Boolean(props.node.metadata.characterAssetId))
|| (props.node.metadata?.workflowKind === "story_input" && !props.isEditingContent)
|| (props.node.metadata?.workflowKind === "styleboard" && !props.node.metadata.content);
if (hasCustomContent && props.renderNodeContent) return props.renderNodeContent(props.node);
if (props.node.type === ART_CRITIQUE_NODE_TYPE) return <ArtCritiqueNodeContent node={props.node} />;
if (props.node.type === MEDIA_CONVERSION_NODE_TYPE) return <MediaConversionNodeContent node={props.node} theme={props.theme} />;
if (props.isBatchRoot) return <ImageNodeContent {...props} />;
if (props.node.metadata?.status === "loading") return <LoadingContent node={props.node} theme={props.theme} onOpenTaskDetails={props.onOpenTaskDetails} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} onReloadResource={props.onReloadResource} />;

View File

@@ -8,6 +8,7 @@ import { getGenerationResourceNodes, getContextResourceNodes, getMentionResource
import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { isNeutralColorGrade, resolveCanvasColorGradeReference } from "@/lib/canvas/canvas-color-grade";
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import { mediaConversionSourceFingerprint } from "@/lib/media-conversion/contracts";
import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-reference";
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation";
@@ -64,6 +65,12 @@ export type NodeGenerationInput = {
};
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string, assets: Asset[], promptOnly = false): NodeGenerationContext {
const pendingConversion = findPendingMediaConversionInput(nodeId, nodes, connections);
if (pendingConversion) {
const status = pendingConversion.metadata?.mediaConversion?.status;
const reason = status === "stale" ? "输入或方式已变化" : status === "processing" ? "仍在处理中" : "尚未完成本地转换";
throw new Error(`转换节点「${pendingConversion.title || pendingConversion.id}」${reason},完成后才能执行下游生成`);
}
const connectedInputs = withArkAssetReferenceInputs(buildNodeGenerationInputs(nodeId, nodes, connections), nodes, assets);
const sourceNode = nodes.find((node) => node.id === nodeId);
const portraitTextureInput = sourceNode?.type === CanvasNodeType.Image && sourceNode.metadata?.content && sourceNode.metadata?.portraitTexture
@@ -120,6 +127,40 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
};
}
/**
* 转换节点允许提前连到下游,但只有与当前唯一媒体输入匹配的已物化结果才能进入生成请求。
* 沿上游链路检查也覆盖“生成节点 -> 配置节点 -> 转换节点”的接法。
*/
export function findPendingMediaConversionInput(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const nodesById = new Map(nodes.map((node) => [node.id, node]));
const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId);
const visited = new Set<string>();
while (queue.length) {
const sourceId = queue.shift()!;
if (visited.has(sourceId)) continue;
visited.add(sourceId);
const source = nodesById.get(sourceId);
if (!source) continue;
if (source.type === CanvasNodeType.MediaConversion) {
const state = source.metadata?.mediaConversion;
const sourceInputs = connections
.filter((connection) => connection.toNodeId === source.id)
.map((connection) => nodesById.get(connection.fromNodeId))
.filter((input): input is CanvasNodeData => Boolean(input && (input.type === CanvasNodeType.Image || input.type === CanvasNodeType.Video)));
const sourceInput = sourceInputs[0];
const ready = state?.status === "completed"
&& Boolean(state.resultStorageKey)
&& sourceInputs.length === 1
&& Boolean(sourceInput)
&& state.sourceNodeId === sourceInput?.id
&& state.sourceFingerprint === mediaConversionSourceFingerprint(sourceInput!);
if (!ready) return source;
}
connections.filter((connection) => connection.toNodeId === sourceId).forEach((connection) => queue.push(connection.fromNodeId));
}
return null;
}
function removeTrailingInputBlocks(prompt: string, inputs: NodeGenerationInput[]) {
let next = prompt.trim();
let removed = true;

View File

@@ -351,7 +351,7 @@ export const CanvasNode = React.memo(function CanvasNode({
}}
>
<div
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script || isComposerNode ? "overflow-visible" : "overflow-hidden"}`}
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script || data.type === CanvasNodeType.BatchTable || isComposerNode ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: hasImageContent || hasVideoContent || hasAudioContent ? "transparent" : theme.node.fill,
@@ -479,7 +479,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</> : null}
</div>
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} theme={theme} visible={hovered || forceInputVisible} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.BatchTable ? <ConnectionSideRail side="left" scale={scale} theme={theme} visible={hovered || forceInputVisible} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection ? <ConnectionSideRail side="right" scale={scale} theme={theme} visible={hovered} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
</div>

View File

@@ -1,6 +1,6 @@
import { motion, useReducedMotion } from "motion/react";
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { Clapperboard, Image as ImageIcon, List, Music2, Pencil, Video, Workflow as WorkflowIcon } from "lucide-react";
import { Clapperboard, Image as ImageIcon, List, Music2, Pencil, Table2, Video, WandSparkles, Workflow as WorkflowIcon } from "lucide-react";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -154,7 +154,7 @@ function resolveNodePanelWidth(node: CanvasNodeData, viewport: ViewportTransform
return clamp(Math.round(node.width * viewport.k * 1.5), 680, 920);
}
export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, containerRef, canCreateDrawing, getDisabledReason, onCreate, onClose }: { pending: PendingConnectionCreate; viewport: ViewportTransform; viewportSize: { width: number; height: number }; containerRef: RefObject<HTMLDivElement | null>; canCreateDrawing: boolean; getDisabledReason: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, provider?: "runninghub") => string; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, provider?: "runninghub") => void; onClose: () => void }) {
export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, containerRef, canCreateDrawing, getDisabledReason, onCreate, onClose }: { pending: PendingConnectionCreate; viewport: ViewportTransform; viewportSize: { width: number; height: number }; containerRef: RefObject<HTMLDivElement | null>; canCreateDrawing: boolean; getDisabledReason: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, provider?: "runninghub") => string; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, provider?: "runninghub") => void; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const menuRef = useRef<HTMLDivElement>(null);
@@ -162,7 +162,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
const lastPointerRef = useRef<Position | null>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer("connection-create-menu", "var(--z-modal-overlay)");
const menuWidth = Math.min(288, viewportSize.width - 24);
const menuHeight = canCreateDrawing ? 404 : 360;
const menuHeight = canCreateDrawing ? 448 : 404;
const gap = 12;
const initialPosition = getConnectionMenuPosition(pending.position, viewport, viewportSize, menuWidth, menuHeight, gap);
@@ -228,11 +228,13 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
<div className="grid min-w-0 grid-cols-1 gap-1">
<ConnectionCreateOption expanded={activeOption === "文本生成"} motionEnabled={!reducedMotion} icon={<List className="size-4" />} title="文本生成" description="引用当前内容,生成或改写文本" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption expanded={activeOption === "分镜脚本"} motionEnabled={!reducedMotion} icon={<Clapperboard className="size-4" />} title="分镜脚本" description="根据剧情拆解镜头,编排分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} />
<ConnectionCreateOption expanded={activeOption === "批量创作表"} motionEnabled={!reducedMotion} icon={<Table2 className="size-4" />} title="批量创作表" description="汇总多张图片,批量执行换装或创意生图" disabledReason={getDisabledReason(CanvasNodeType.BatchTable)} onClick={() => onCreate(CanvasNodeType.BatchTable)} />
<ConnectionCreateOption expanded={activeOption === "图片生成"} motionEnabled={!reducedMotion} icon={<ImageIcon className="size-4" />} title="图片生成" description="结合提示词和参考图,生成新的画面" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption expanded={activeOption === "生成配置"} motionEnabled={!reducedMotion} icon={<WorkflowIcon className="size-4" />} title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} />
{canCreateDrawing ? <ConnectionCreateOption expanded={activeOption === "绘图"} motionEnabled={!reducedMotion} icon={<Pencil className="size-4" />} title="绘图" description="以参考图片为底图,自由绘制和标注" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null}
<ConnectionCreateOption expanded={activeOption === "视频生成"} motionEnabled={!reducedMotion} icon={<Video className="size-4" />} title="视频生成" description="结合提示词与参考素材,生成动态视频" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption expanded={activeOption === "音频参考"} motionEnabled={!reducedMotion} icon={<Music2 className="size-4" />} title="音频参考" description="连接文本或角色卡,创建音频生成节点" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} />
<ConnectionCreateOption expanded={activeOption === "转换"} motionEnabled={!reducedMotion} icon={<WandSparkles className="size-4" />} title="转换" description="本地处理图片或视频" disabledReason={getDisabledReason(CanvasNodeType.MediaConversion)} onClick={() => onCreate(CanvasNodeType.MediaConversion)} />
</div>
</motion.div>
);

View File

@@ -0,0 +1,577 @@
import { Check, CheckCircle2, ChevronDown, CircleAlert, Download, Image as ImageIcon, LoaderCircle, RefreshCw, Video, WandSparkles } from "lucide-react";
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { getNodeInputKind } from "@/lib/canvas/node-registry";
import type { CanvasTheme } from "@/lib/canvas-theme";
import {
createDefaultMediaConversionState,
isLocalImageOperation,
mediaConversionOperationDescription,
mediaConversionOperationLabel,
mediaConversionSourceFingerprint,
MEDIA_CONVERSION_OPERATION_LABELS,
type MediaConversionNodeState,
type MediaConversionOperation,
} from "@/lib/media-conversion/contracts";
import { convertImageLocally, LocalImageConversionError } from "@/lib/media-conversion/local-converter";
import { getActiveUserScope } from "@/lib/user-scope";
import { runLocalDepthEstimation } from "@/services/depth-runtime";
import { runLocalLineartEstimation } from "@/services/lineart-runtime";
import { runLocalPoseEstimation } from "@/services/pose-runtime";
import { resolveImageUrl, setImageBlob } from "@/services/image-storage";
import { resolveMediaUrl } from "@/services/file-storage";
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
import { useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { useCanvasNodeActions } from "../canvas-node-action-context";
import { useUpstreamNodes } from "../canvas-node-graph-context";
type MediaConversionNodeContentProps = {
node: CanvasNodeData;
theme: CanvasTheme;
};
const OPERATIONS = Object.keys(MEDIA_CONVERSION_OPERATION_LABELS) as MediaConversionOperation[];
export function MediaConversionNodeContent({ node, theme }: MediaConversionNodeContentProps) {
const { updateMetadata, updateNode } = useCanvasNodeActions();
const upstream = useUpstreamNodes(node.id);
const localRuntimeConnection = useLocalRuntimeStore((runtime) => runtime.connection);
const localRuntimeConnecting = useLocalRuntimeStore((runtime) => runtime.connecting);
const reconnectLocalRuntime = useLocalRuntimeStore((runtime) => runtime.connect);
const mediaInputs = upstream.filter((item) => item.type === CanvasNodeType.Image || item.type === CanvasNodeType.Video);
const input = mediaInputs[0];
const inputKind = input ? getNodeInputKind(input.type) : undefined;
const storedState = node.metadata?.mediaConversion;
const state = storedState || createDefaultMediaConversionState();
const currentFingerprint = input ? mediaConversionSourceFingerprint(input) : "";
const hasMultipleInputs = mediaInputs.length > 1;
const isStale = Boolean(state.status === "completed" && (!currentFingerprint || state.sourceFingerprint !== currentFingerprint));
const status = isStale ? "stale" : state.status;
const [sourceUrl, setSourceUrl] = useState("");
const [resultUrl, setResultUrl] = useState("");
const [showResult, setShowResult] = useState(status === "completed" || status === "stale");
const [notice, setNotice] = useState("");
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
let active = true;
const fallback = input?.metadata?.previewContent || input?.metadata?.content || "";
setSourceUrl(fallback);
if (!input?.metadata?.storageKey) return () => { active = false; };
const resolver = input.type === CanvasNodeType.Image ? resolveImageUrl : resolveMediaUrl;
void resolver(input.metadata.storageKey, fallback)
.then((resolved) => { if (active) setSourceUrl(resolved || fallback); })
.catch(() => { if (active) setSourceUrl(fallback); });
return () => { active = false; };
}, [input?.id, input?.metadata?.content, input?.metadata?.previewContent, input?.metadata?.storageKey, input?.type]);
useEffect(() => {
let active = true;
setResultUrl("");
if (!state.resultStorageKey) return () => { active = false; };
void resolveImageUrl(state.resultStorageKey)
.then((resolved) => { if (active) setResultUrl(resolved); })
.catch(() => { if (active) setResultUrl(""); });
return () => { active = false; };
}, [state.resultStorageKey]);
useEffect(() => {
if ((!updateNode && !updateMetadata) || !currentFingerprint || state.status !== "completed" || state.sourceFingerprint === currentFingerprint) return;
const markStale = (current: MediaConversionNodeState | undefined) => {
if (!current || current.status !== "completed") return current;
if (current.sourceFingerprint === currentFingerprint) return current;
return { ...current, status: "stale" as const, updatedAt: new Date().toISOString() };
};
// 用最新 metadata 判状态,避免异步 stale 写入覆盖正在 processing 的结果。
if (updateNode) {
updateNode(node.id, (current) => {
const next = markStale(current.metadata?.mediaConversion);
if (!next || next === current.metadata?.mediaConversion) return current;
return { ...current, metadata: { ...current.metadata, mediaConversion: next } };
});
return;
}
updateMetadata?.(node.id, {
mediaConversion: { ...state, status: "stale", updatedAt: new Date().toISOString() },
});
}, [currentFingerprint, node.id, state, updateMetadata, updateNode]);
useEffect(() => {
if (status === "stale") setShowResult(false);
}, [status]);
useEffect(() => () => abortRef.current?.abort(), []);
const updateState = (patch: Partial<MediaConversionNodeState>) => {
if (updateNode) {
updateNode(node.id, (current) => ({
...current,
metadata: {
...current.metadata,
mediaConversion: {
...(current.metadata?.mediaConversion || createDefaultMediaConversionState()),
...patch,
schemaVersion: 1,
},
},
}));
return;
}
updateMetadata?.(node.id, { mediaConversion: { ...state, ...patch, schemaVersion: 1 } });
};
const selectOperation = (operation: MediaConversionOperation) => {
if (operation === state.operation) return;
setNotice("");
updateState({
operation,
status: state.resultStorageKey ? "stale" : "idle",
errorCode: undefined,
errorMessage: undefined,
updatedAt: new Date().toISOString(),
});
setShowResult(false);
};
const run = async () => {
setNotice("");
if (!input || !currentFingerprint) {
setNotice("请先连接一张图片或一个视频");
return;
}
if (hasMultipleInputs) {
setNotice("转换节点只能连接一个输入,请移除多余连线");
return;
}
if (inputKind === "video") {
updateState({ status: "unavailable", outputKind: "video", errorCode: "video_not_ready", errorMessage: "本地视频转换还在进行短片性能验证", updatedAt: new Date().toISOString() });
setNotice("视频转换暂未开启,先完成单图模型验证");
return;
}
if (!isLocalImageOperation(state.operation) && state.operation !== "depth" && state.operation !== "lineart" && state.operation !== "pose") {
updateState({ status: "unavailable", outputKind: "image", errorCode: "model_missing", errorMessage: "该转换需要先安装并验证本地模型", updatedAt: new Date().toISOString() });
setNotice(mediaConversionOperationDescription(state.operation));
return;
}
if (!sourceUrl) {
updateState({ status: "error", errorCode: "source_unreadable", errorMessage: "图片预览尚未准备好,请稍后重试", updatedAt: new Date().toISOString() });
setNotice("图片预览尚未准备好,请稍后重试");
return;
}
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const startedAt = new Date().toISOString();
updateState({ status: "processing", sourceNodeId: input.id, sourceFingerprint: currentFingerprint, outputKind: "image", errorCode: undefined, errorMessage: undefined, startedAt, updatedAt: startedAt });
try {
const result = state.operation === "depth"
? await runLocalDepthEstimation(sourceUrl, controller.signal)
: state.operation === "lineart"
? await runLocalLineartEstimation(sourceUrl, controller.signal)
: state.operation === "pose"
? await runLocalPoseEstimation(sourceUrl, controller.signal)
: await convertImageLocally(sourceUrl, state.operation, { signal: controller.signal, maxDimension: 1024 });
if (controller.signal.aborted) return;
const storageKey = localConversionStorageKey(node.id, currentFingerprint, state.operation);
const url = await setImageBlob(storageKey, result.blob);
const completedAt = new Date().toISOString();
const detectedPeople = state.operation === "pose" && "personCount" in result && typeof result.personCount === "number" ? result.personCount : undefined;
const completedPatch = {
content: "",
previewContent: "",
storageKey,
mimeType: result.blob.type || "image/png",
bytes: result.blob.size,
naturalWidth: result.width,
naturalHeight: result.height,
} as const;
const completedConversion = {
schemaVersion: 1 as const,
operation: state.operation,
status: "completed" as const,
sourceNodeId: input.id,
sourceFingerprint: currentFingerprint,
outputKind: "image" as const,
resultStorageKey: storageKey,
resultWidth: result.width,
resultHeight: result.height,
...(detectedPeople !== undefined ? { detectedPeople } : {}),
errorCode: undefined,
errorMessage: undefined,
startedAt,
updatedAt: completedAt,
};
if (updateNode) {
updateNode(node.id, (current) => ({
...current,
metadata: {
...current.metadata,
...completedPatch,
mediaConversion: {
...(current.metadata?.mediaConversion || createDefaultMediaConversionState()),
...completedConversion,
},
},
}));
} else {
updateMetadata?.(node.id, {
...completedPatch,
mediaConversion: {
...state,
...completedConversion,
},
});
}
setResultUrl(url);
setShowResult(true);
} catch (error) {
if (controller.signal.aborted) return;
const aborted = error instanceof LocalImageConversionError && error.code === "aborted";
if (aborted) return;
if (error instanceof LocalRuntimeClientError) {
if (error.code === "pose_no_person") {
updateState({ status: "skipped", errorCode: error.code, errorMessage: error.message, detectedPeople: 0, updatedAt: new Date().toISOString() });
setNotice("未检测到可用人物姿态,可更换含人物的图片或选择其他转换方式");
return;
}
const nextStatus = ["depth_model_missing", "depth_runtime_unavailable", "lineart_model_missing", "lineart_runtime_unavailable", "pose_model_missing", "pose_runtime_unavailable"].includes(error.code) ? "unavailable" : "error";
updateState({ status: nextStatus, errorCode: error.code, errorMessage: error.message, updatedAt: new Date().toISOString() });
setNotice(localRuntimeNotice(error, useLocalRuntimeStore.getState().connection));
return;
}
const conversionError = error instanceof LocalImageConversionError ? error : new LocalImageConversionError("source_unreadable", "本地图片转换失败");
const nextStatus = conversionError.code === "model_missing" ? "unavailable" : "error";
updateState({ status: nextStatus, errorCode: conversionError.code, errorMessage: conversionError.message, updatedAt: new Date().toISOString() });
setNotice(conversionError.message);
}
};
const downloadResult = () => {
if (!resultUrl) return;
const link = document.createElement("a");
link.href = resultUrl;
link.download = `${node.title || "转换结果"}-${state.operation}.png`;
link.click();
};
const effectiveResult = Boolean(resultUrl && (status === "completed" || status === "stale"));
const displayResult = showResult && effectiveResult;
const buttonDisabled = !input || hasMultipleInputs || status === "processing";
const statusText = mediaConversionStatusLabel(status);
const previewStatusText = state.operation === "pose" && status === "completed" && typeof state.detectedPeople === "number"
? `${statusText} · ${state.detectedPeople} 人`
: statusText;
const showRuntimeRecovery = (state.operation === "depth" || state.operation === "lineart" || state.operation === "pose") && (localRuntimeConnection === "unreachable" || state.errorCode === "depth_runtime_unavailable" || state.errorCode === "lineart_runtime_unavailable" || state.errorCode === "pose_runtime_unavailable");
const retryLocalRuntime = async () => {
setNotice("");
await reconnectLocalRuntime();
const next = useLocalRuntimeStore.getState();
if (next.connection === "connected") {
setNotice("本地模型服务已连接,可以重新开始转换");
} else {
setNotice(next.error || "本地模型服务仍未连接,请先运行 start-yingce-local.cmd");
}
};
return (
<div
className="flex h-full min-h-0 w-full flex-col gap-2.5 overflow-hidden rounded-[inherit] p-3"
style={{ background: theme.node.panel, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.itemHover, color: theme.accent.primary }}>
<WandSparkles className="size-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[var(--fs-label)] font-semibold">{node.title || "转换"}</div>
<div className="truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>本地处理 · 单图优先</div>
</div>
<StatusBadge status={status} />
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[var(--r-md)] border" style={{ borderColor: theme.node.edge, background: theme.node.fill }}>
{displayResult ? (
<img src={resultUrl} alt={`${mediaConversionOperationLabel(state.operation)}结果`} className="absolute inset-0 size-full object-contain" draggable={false} />
) : input?.type === CanvasNodeType.Image && sourceUrl ? (
<img src={sourceUrl} alt="转换输入" className="absolute inset-0 size-full object-contain" draggable={false} />
) : input?.type === CanvasNodeType.Video && sourceUrl ? (
<video src={sourceUrl} className="absolute inset-0 size-full object-contain" controls preload="metadata" />
) : (
<div className="absolute inset-0 grid place-items-center" style={{ color: theme.node.muted }}>
{input?.type === CanvasNodeType.Video ? <Video className="size-8 opacity-35" aria-hidden="true" /> : <ImageIcon className="size-8 opacity-35" aria-hidden="true" />}
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-black/75 to-transparent px-2.5 pb-2 pt-8 text-white">
<div className="min-w-0">
<div className="truncate text-[var(--fs-micro)] font-semibold">{displayResult ? (status === "stale" ? "上次结果 · 待更新" : "转换结果") : input?.title || "等待输入"}</div>
<div className="mt-0.5 truncate text-[var(--fs-micro)] opacity-75">{hasMultipleInputs ? "已连接多个输入" : input ? `${input.type === CanvasNodeType.Video ? "视频" : "图片"} · ${previewStatusText}` : "连接图片或视频节点"}</div>
</div>
{state.resultWidth && state.resultHeight && displayResult ? <span className="shrink-0 text-[var(--fs-micro)] tabular-nums">{state.resultWidth} × {state.resultHeight}</span> : null}
</div>
</div>
{effectiveResult ? (
<div className="flex gap-1" data-canvas-no-zoom>
<PreviewToggle theme={theme} pressed={!showResult} label="原素材" onClick={() => setShowResult(false)} />
<PreviewToggle theme={theme} pressed={showResult} label="结果" onClick={() => setShowResult(true)} />
<button type="button" aria-label="下载转换结果" title="下载转换结果" className="grid h-6 w-8 shrink-0 place-items-center rounded-[var(--r-sm)] outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.muted, outlineColor: theme.accent.primary }} onClick={(event) => { event.stopPropagation(); downloadResult(); }} onMouseDown={(event) => event.stopPropagation()}>
<Download className="size-3.5" aria-hidden="true" />
</button>
</div>
) : null}
</div>
<div className="flex flex-col gap-3" data-canvas-no-zoom onWheel={(event) => event.stopPropagation()}>
<OperationPicker
theme={theme}
nodeId={node.id}
value={state.operation}
disabled={status === "processing"}
onSelect={selectOperation}
/>
<button
type="button"
data-canvas-no-zoom
className="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-[var(--r-md)] px-4 text-[var(--fs-label)] font-semibold outline-none transition hover:brightness-105 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: theme.accent.primary, color: theme.accent.onPrimary, outlineColor: theme.accent.primary }}
disabled={buttonDisabled}
onClick={(event) => { event.stopPropagation(); void run(); }}
onMouseDown={(event) => event.stopPropagation()}
>
{status === "processing" ? <LoaderCircle className="size-4 animate-spin" aria-hidden="true" /> : null}
{status === "processing" ? "处理中" : status === "completed" || status === "stale" ? "重新转换" : "开始转换"}
</button>
</div>
<div className="flex min-h-4 items-center gap-2 text-[var(--fs-micro)]" style={{ color: status === "unavailable" || status === "skipped" ? "var(--status-warning)" : notice || status === "error" ? "var(--status-error)" : theme.node.muted }}>
<span className="min-w-0 flex-1 truncate" title={notice || mediaConversionOperationDescription(state.operation)}>{notice || mediaConversionOperationDescription(state.operation)}</span>
{showRuntimeRecovery ? (
<button
type="button"
className="shrink-0 rounded-[var(--r-sm)] px-1.5 py-0.5 font-semibold outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10"
style={{ color: theme.accent.primary, outlineColor: theme.accent.primary }}
disabled={localRuntimeConnecting}
onClick={(event) => { event.stopPropagation(); void retryLocalRuntime(); }}
onMouseDown={(event) => event.stopPropagation()}
>
{localRuntimeConnecting ? "连接中" : "重试连接"}
</button>
) : null}
</div>
</div>
);
}
function PreviewToggle({ theme, pressed, label, onClick }: { theme: CanvasTheme; pressed: boolean; label: string; onClick: () => void }) {
return <button type="button" aria-pressed={pressed} className="h-7 flex-1 rounded-[var(--r-sm)] px-2 text-[var(--fs-micro)] font-medium outline-none transition focus-visible:ring-2" style={{ background: pressed ? theme.accent.primary : "color-mix(in oklch, var(--foreground-muted) 10%, transparent)", color: pressed ? theme.accent.onPrimary : "var(--foreground-muted)", outlineColor: theme.accent.primary }} onClick={(event) => { event.stopPropagation(); onClick(); }} onMouseDown={(event) => event.stopPropagation()}>{label}</button>;
}
const OPERATION_MENU_GAP = 8;
const OPERATION_MENU_MARGIN = 8;
const OPERATION_MENU_ITEM_HEIGHT = 44;
function OperationPicker({ theme, nodeId, value, disabled, onSelect }: { theme: CanvasTheme; nodeId: string; value: MediaConversionOperation; disabled: boolean; onSelect: (operation: MediaConversionOperation) => void }) {
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<{ left: number; top: number; width: number; maxHeight: number } | null>(null);
const selectedLabel = mediaConversionOperationLabel(value);
const updatePosition = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
const menuWidth = Math.max(280, Math.round(rect.width));
const menuHeight = Math.min(300, OPERATIONS.length * OPERATION_MENU_ITEM_HEIGHT + 40);
const spaceBelow = window.innerHeight - rect.bottom - OPERATION_MENU_MARGIN;
const spaceAbove = rect.top - OPERATION_MENU_MARGIN;
const openAbove = spaceBelow < menuHeight + OPERATION_MENU_GAP || spaceAbove >= spaceBelow;
const maxLeft = Math.max(OPERATION_MENU_MARGIN, window.innerWidth - menuWidth - OPERATION_MENU_MARGIN);
const maxTop = Math.max(OPERATION_MENU_MARGIN, window.innerHeight - menuHeight - OPERATION_MENU_MARGIN);
const left = Math.min(Math.max(OPERATION_MENU_MARGIN, rect.left), maxLeft);
const top = openAbove
? Math.max(OPERATION_MENU_MARGIN, rect.top - menuHeight - OPERATION_MENU_GAP)
: Math.min(Math.max(OPERATION_MENU_MARGIN, rect.bottom + OPERATION_MENU_GAP), maxTop);
setPosition({ left, top, width: menuWidth, maxHeight: menuHeight });
};
useLayoutEffect(() => {
if (open) updatePosition();
}, [open]);
useEffect(() => {
if (!open) return;
const close = (event: PointerEvent) => {
const target = event.target instanceof Node ? event.target : null;
if (target && (triggerRef.current?.contains(target) || menuRef.current?.contains(target))) return;
setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const reposition = () => updatePosition();
document.addEventListener("pointerdown", close, true);
document.addEventListener("keydown", closeOnEscape, true);
window.addEventListener("resize", reposition);
window.addEventListener("scroll", reposition, true);
return () => {
document.removeEventListener("pointerdown", close, true);
document.removeEventListener("keydown", closeOnEscape, true);
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
};
}, [open]);
const menuStyle: CSSProperties | undefined = position
? {
left: position.left,
top: position.top,
width: position.width,
maxHeight: position.maxHeight,
background: theme.node.panel,
borderColor: theme.node.edge,
color: theme.node.text,
boxShadow: "0 16px 40px rgba(0,0,0,.28), 0 0 0 1px rgba(255,255,255,.06)",
}
: undefined;
const menu = open && position && typeof document !== "undefined"
? createPortal(
<>
<div
className="fixed inset-0 z-[calc(var(--z-toast)-1)]"
aria-hidden="true"
onMouseDown={(event) => { event.stopPropagation(); setOpen(false); }}
onPointerDown={(event) => event.stopPropagation()}
/>
<div
ref={menuRef}
id={`${nodeId}-operation-menu`}
role="listbox"
aria-label="转换方式"
data-canvas-no-zoom
className="fixed z-[var(--z-toast)] overflow-hidden rounded-[var(--r-lg)] border"
style={menuStyle}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<div className="border-b px-3 py-2 text-[var(--fs-micro)] font-semibold" style={{ borderColor: theme.node.edge, color: theme.node.muted }}>
选择转换方式
</div>
<div className="thin-scrollbar overflow-y-auto p-1" style={{ maxHeight: Math.max(0, position.maxHeight - 36) }}>
{OPERATIONS.map((operation) => {
const selected = operation === value;
return (
<button
key={operation}
type="button"
role="option"
aria-selected={selected}
className="flex w-full min-w-0 items-center gap-2 rounded-[var(--r-md)] px-2.5 py-2 text-left transition"
style={{ background: selected ? theme.toolbar.itemHover : "transparent", color: selected ? theme.toolbar.activeText : theme.node.text }}
onMouseEnter={(event) => {
event.currentTarget.style.background = theme.toolbar.itemHover;
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = selected ? theme.toolbar.itemHover : "transparent";
}}
onClick={() => {
onSelect(operation);
setOpen(false);
}}
>
<span className="min-w-0 flex-1">
<span className="block text-[var(--fs-tiny)] font-semibold leading-5">{mediaConversionOperationLabel(operation)}</span>
<span className="mt-0.5 block text-[var(--fs-micro)] leading-relaxed" style={{ color: theme.node.muted }}>{mediaConversionOperationDescription(operation)}</span>
</span>
{selected ? <Check className="size-3.5 shrink-0" aria-hidden="true" /> : null}
</button>
);
})}
</div>
</div>
</>,
document.body,
)
: null;
return (
<div className={`relative ${open ? "z-[var(--z-toast)]" : ""}`}>
<div className="mb-1.5 px-0.5 text-[var(--fs-micro)] font-medium" style={{ color: theme.node.muted }}>转换方式</div>
<button
ref={triggerRef}
id={`${nodeId}-operation`}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? `${nodeId}-operation-menu` : undefined}
disabled={disabled}
data-canvas-no-zoom
className="flex h-9 w-full cursor-pointer items-center gap-2 rounded-[var(--r-md)] border px-2.5 text-left outline-none transition hover:brightness-[1.03] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{
borderColor: open ? theme.accent.primary : theme.node.edge,
background: open ? theme.toolbar.itemHover : theme.node.fill,
color: theme.node.text,
outlineColor: theme.accent.primary,
boxShadow: open ? `0 0 0 1px color-mix(in oklch, ${theme.accent.primary} 35%, transparent)` : undefined,
}}
onClick={() => { if (!disabled) setOpen((current) => !current); }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<span className="min-w-0 flex-1 truncate text-[var(--fs-tiny)] font-semibold">{selectedLabel}</span>
<span
className="grid size-7 shrink-0 place-items-center rounded-[var(--r-sm)] border"
style={{
borderColor: open ? theme.accent.primary : theme.node.edge,
background: theme.node.panel,
color: open ? theme.accent.primary : theme.node.muted,
}}
aria-hidden="true"
>
<ChevronDown className={`size-3.5 transition ${open ? "rotate-180" : ""}`} />
</span>
</button>
{menu}
</div>
);
}
function StatusBadge({ status }: { status: MediaConversionNodeState["status"] | "stale" }) {
if (status === "processing") return <span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[var(--fs-micro)] font-semibold" style={{ color: "var(--status-info)", background: "color-mix(in oklch, var(--status-info) 15%, transparent)" }}><LoaderCircle className="size-3 animate-spin" aria-hidden="true" />处理中</span>;
if (status === "completed") return <span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[var(--fs-micro)] font-semibold" style={{ color: "var(--status-success)", background: "color-mix(in oklch, var(--status-success) 15%, transparent)" }}><CheckCircle2 className="size-3" aria-hidden="true" />已完成</span>;
if (status === "stale") return <span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[var(--fs-micro)] font-semibold" style={{ color: "var(--status-warning)", background: "color-mix(in oklch, var(--status-warning) 15%, transparent)" }}><RefreshCw className="size-3" aria-hidden="true" />待更新</span>;
if (status === "error") return <span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[var(--fs-micro)] font-semibold" style={{ color: "var(--status-error)", background: "color-mix(in oklch, var(--status-error) 15%, transparent)" }}><CircleAlert className="size-3" aria-hidden="true" />失败</span>;
if (status === "unavailable") return <span className="inline-flex shrink-0 items-center rounded-full px-2 py-1 text-[var(--fs-micro)] font-semibold" style={{ color: "var(--status-warning)", background: "color-mix(in oklch, var(--status-warning) 15%, transparent)" }}>本地不可用</span>;
if (status === "skipped") return <span className="inline-flex shrink-0 items-center rounded-full px-2 py-1 text-[var(--fs-micro)] font-semibold" style={{ color: "var(--status-warning)", background: "color-mix(in oklch, var(--status-warning) 15%, transparent)" }}>已跳过</span>;
return <span className="inline-flex shrink-0 items-center rounded-full px-2 py-1 text-[var(--fs-micro)] font-semibold" style={{ color: "var(--foreground-muted)", background: "color-mix(in oklch, var(--foreground-muted) 12%, transparent)" }}>未开始</span>;
}
function mediaConversionStatusLabel(status: MediaConversionNodeState["status"] | "stale") {
if (status === "processing") return "正在处理";
if (status === "completed") return "已完成";
if (status === "stale") return "输入已变化,结果待更新";
if (status === "unavailable") return "本地能力不可用";
if (status === "skipped") return "未检测到人物姿态";
if (status === "error") return "处理失败";
return "尚未转换";
}
function localRuntimeNotice(error: LocalRuntimeClientError, connection: ReturnType<typeof useLocalRuntimeStore.getState>["connection"]) {
if (connection === "unreachable") return "本地模型服务未启动,请双击 start-yingce-local.cmd 后重试";
if (error.code === "depth_model_missing") return "深度模型尚未安装,请先完成本地模型安装";
if (error.code === "lineart_model_missing") return "AI 线稿依赖或模型尚未安装,请先完成本地线稿模型安装";
if (error.code === "pose_model_missing") return "姿态模型或依赖尚未安装,请先完成本地姿态模型安装";
return error.message;
}
function localConversionStorageKey(nodeId: string, fingerprint: string, operation: MediaConversionOperation) {
return `image:${getActiveUserScope()}:media-conversion:${nodeId}:${fingerprint}:${operation}`;
}