diff --git a/web/src/components/canvas/canvas-batch-table-node.tsx b/web/src/components/canvas/canvas-batch-table-node.tsx new file mode 100644 index 000000000..fe3a54fe3 --- /dev/null +++ b/web/src/components/canvas/canvas-batch-table-node.tsx @@ -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) => void; + onAddRow: () => void; + onRemoveRow: (rowId: string) => void; + onUpdateRow: (rowId: string, patch: Partial) => 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 ( +
event.stopPropagation()}> + {!readOnly ? : null} + +
+ onPatchTable({ operation: operation as CanvasBatchOperation })} /> +
+ 并发 + ({ value, label: String(value) }))} value={table.concurrency} onChange={(concurrency) => onPatchTable({ concurrency: Number(concurrency) })} /> +
+
+ {referenceColumns.length}/{MAX_BATCH_REFERENCE_COLUMNS} 组参考 + {!readOnly ? ( + = MAX_BATCH_REFERENCE_COLUMNS ? "最多支持 6 组参考图" : `新增参考图 ${referenceColumns.length + 1}`}> + + + ) : null} +
+ 已连 {connectedImageCount} · 完成 {completed}/{table.rows.length} + + {!readOnly ? ( +
+ + + + + +
+ ) : null} +
+ +
+
+ 任务 + {referenceColumns.map((column, index) => ( + + {column.label} + {batchReferenceMentionToken(index)} + + ))} + 任务提示词 + 生成结果 + 操作 +
+ + {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 ( +
+
+ {!readOnly ? onUpdateRow(row.id, { enabled })} /> : null} + {index + 1} +
+ {referenceColumns.map((column, columnIndex) => )} +
+ onUpdateRow(row.id, { prompt })} + onSubmit={!readOnly && ready && !status.loading ? () => onGenerate([row.id]) : undefined} + onPointerDown={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + /> +
+ 输入 @ 插入参考图 + {!readOnly ? ⌘/Ctrl + Enter 单行生成 : null} +
+
+
+ {completedRow ? } /> : } +
+
{status.label}
+ {!readOnly && status.retryable && batch && item ? : completedRow ? 已关联到画布 : null} +
+ {status.loading ? : null} +
+ {!readOnly ? ( +
+ +
+ ) : } +
+ ); + }) + ) : ( +
+
+
+
还没有批量任务
+

把图片连接到左侧参考图端口后同步连线,或先添加一行手工配置。

+ {!readOnly ? : null} +
+
+ )} +
+
+ ); +} + +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 ( +
+ {options.map((option) => { + const selected = option.value === value; + return ; + })} +
+ ); +} + +function BatchReferenceHandles({ columns, theme, onConnectStart, onConnectDrop }: { columns: ReturnType; 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) => )}; +} + +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) => { + 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 ( + + + + ); +} + +function batchRowMentionReferences(row: CanvasBatchRow, columns: ReturnType, nodeById: Map): 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 ; + const fallback = ; + return ( + +
+ + {label} +
+
+ ); +} + +function EmptyThumbnail({ theme, compact = false }: { theme: CanvasTheme; compact?: boolean }): ReactNode { + return
; +} + +function rowReady(row: CanvasBatchRow, operation: CanvasBatchOperation, nodeById: Map) { + 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 }; +} diff --git a/web/src/components/canvas/canvas-connections.tsx b/web/src/components/canvas/canvas-connections.tsx index 113456915..c1dd59cf6 100644 --- a/web/src/components/canvas/canvas-connections.tsx +++ b/web/src/components/canvas/canvas-connections.tsx @@ -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); diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index 9bbefaeee..0d0cce431 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -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 ; 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 ; + if (props.node.type === MEDIA_CONVERSION_NODE_TYPE) return ; if (props.isBatchRoot) return ; if (props.node.metadata?.status === "loading") return ; if (props.node.metadata?.status === "error") return ; diff --git a/web/src/components/canvas/canvas-node-generation.ts b/web/src/components/canvas/canvas-node-generation.ts index d92ea0287..389fd1a7b 100644 --- a/web/src/components/canvas/canvas-node-generation.ts +++ b/web/src/components/canvas/canvas-node-generation.ts @@ -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(); + 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; diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index fe08f7c2a..cd82d4447 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -351,7 +351,7 @@ export const CanvasNode = React.memo(function CanvasNode({ }} >
: null}
- {!readOnly && data.type !== CanvasNodeType.Script ? onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null} + {!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.BatchTable ? onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null} {!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection ? onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null} diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx index e23b773fa..b3a2c2b0d 100644 --- a/web/src/components/canvas/canvas-workspace-overlays.tsx +++ b/web/src/components/canvas/canvas-workspace-overlays.tsx @@ -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; 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; 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(null); @@ -162,7 +162,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co const lastPointerRef = useRef(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
} title="文本生成" description="引用当前内容,生成或改写文本" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} /> } title="分镜脚本" description="根据剧情拆解镜头,编排分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} /> + } title="批量创作表" description="汇总多张图片,批量执行换装或创意生图" disabledReason={getDisabledReason(CanvasNodeType.BatchTable)} onClick={() => onCreate(CanvasNodeType.BatchTable)} /> } title="图片生成" description="结合提示词和参考图,生成新的画面" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} /> } title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} /> {canCreateDrawing ? } title="绘图" description="以参考图片为底图,自由绘制和标注" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null} } title="视频生成" description="结合提示词与参考素材,生成动态视频" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} /> } title="音频参考" description="连接文本或角色卡,创建音频生成节点" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} /> + } title="转换" description="本地处理图片或视频" disabledReason={getDisabledReason(CanvasNodeType.MediaConversion)} onClick={() => onCreate(CanvasNodeType.MediaConversion)} />
); diff --git a/web/src/components/canvas/nodes/media-conversion-node.tsx b/web/src/components/canvas/nodes/media-conversion-node.tsx new file mode 100644 index 000000000..b42dceb4c --- /dev/null +++ b/web/src/components/canvas/nodes/media-conversion-node.tsx @@ -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(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) => { + 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 ( +
event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + > +
+ + +
+
{node.title || "转换"}
+
本地处理 · 单图优先
+
+ +
+ +
+
+ {displayResult ? ( + {`${mediaConversionOperationLabel(state.operation)}结果`} + ) : input?.type === CanvasNodeType.Image && sourceUrl ? ( + 转换输入 + ) : input?.type === CanvasNodeType.Video && sourceUrl ? ( +
+ + {effectiveResult ? ( +
+ setShowResult(false)} /> + setShowResult(true)} /> + +
+ ) : null} +
+ +
event.stopPropagation()}> + + +
+ +
+ {notice || mediaConversionOperationDescription(state.operation)} + {showRuntimeRecovery ? ( + + ) : null} +
+
+ ); +} + +function PreviewToggle({ theme, pressed, label, onClick }: { theme: CanvasTheme; pressed: boolean; label: string; onClick: () => void }) { + return ; +} + +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(null); + const menuRef = useRef(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( + <> +