feat(canvas): 画布节点 - 恢复批量创作表与转换能力
This commit is contained in:
257
web/src/components/canvas/canvas-batch-table-node.tsx
Normal file
257
web/src/components/canvas/canvas-batch-table-node.tsx
Normal 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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
577
web/src/components/canvas/nodes/media-conversion-node.tsx
Normal file
577
web/src/components/canvas/nodes/media-conversion-node.tsx
Normal 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}`;
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export const NODE_DEFAULT_SIZE = {
|
||||
[CanvasNodeType.Compare]: { width: 520, height: 320, title: "对比" },
|
||||
[CanvasNodeType.Chart]: { width: 480, height: 320, title: "图表" },
|
||||
[CanvasNodeType.ColorGrade]: { width: 420, height: 360, title: "调色" },
|
||||
[CanvasNodeType.MediaConversion]: { width: 480, height: 460, title: "转换" },
|
||||
[CanvasNodeType.BatchTable]: { width: 900, height: 520, title: "批量创作表" },
|
||||
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
|
||||
|
||||
export const NODE_SPECS = {
|
||||
@@ -101,6 +103,23 @@ export const NODE_SPECS = {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.ColorGrade],
|
||||
metadata: { status: "idle" },
|
||||
},
|
||||
[CanvasNodeType.MediaConversion]: { ...NODE_DEFAULT_SIZE[CanvasNodeType.MediaConversion], metadata: { status: "idle" } },
|
||||
[CanvasNodeType.BatchTable]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.BatchTable],
|
||||
metadata: {
|
||||
status: "idle",
|
||||
batchTable: {
|
||||
operation: "try_on",
|
||||
concurrency: 10,
|
||||
referenceColumns: [
|
||||
{ id: "reference-1", label: "参考图 1" },
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
{ id: "reference-3", label: "参考图 3" },
|
||||
],
|
||||
rows: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
||||
|
||||
export function getNodeSpec(type: CanvasNodeType) {
|
||||
|
||||
134
web/src/lib/canvas/canvas-batch-table.ts
Normal file
134
web/src/lib/canvas/canvas-batch-table.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export const TRY_ON_BATCH_PROMPT = "参考图1是人物原图,参考图2是目标服装。保持人物身份、五官、姿态和背景不变,将人物服装替换为参考图2中的款式。准确还原服装版型、颜色、材质、纹理和装饰细节,穿着关系自然,光影与原图一致。";
|
||||
export const CREATIVE_BATCH_PROMPT = "基于参考图创作一张新的商业图片,保留主体身份和关键产品细节,画面构图完整,光影自然。";
|
||||
export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:";
|
||||
export const BATCH_REFERENCE_HANDLE_TOP = 112;
|
||||
export const BATCH_REFERENCE_HANDLE_GAP = 38;
|
||||
export const MAX_BATCH_REFERENCE_COLUMNS = 6;
|
||||
|
||||
export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
|
||||
return [
|
||||
{ id: "reference-1", label: "参考图 1" },
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
{ id: "reference-3", label: "参考图 3" },
|
||||
];
|
||||
}
|
||||
|
||||
export function batchReferenceColumns(table?: CanvasBatchTableData) {
|
||||
const columns = table?.referenceColumns;
|
||||
if (!columns?.length) return defaultBatchReferenceColumns();
|
||||
// Older tables were created before the second default slot existed. They
|
||||
// never had a remove-column action, so extending this exact legacy shape is
|
||||
// a safe migration and makes reference slot 2 immediately selectable.
|
||||
if (columns.length === 1 && columns[0].id === "reference-1") return defaultBatchReferenceColumns();
|
||||
return columns;
|
||||
}
|
||||
|
||||
export function batchReferenceHandleId(columnId: string) {
|
||||
return `${BATCH_REFERENCE_HANDLE_PREFIX}${columnId}`;
|
||||
}
|
||||
|
||||
export function batchReferenceMentionToken(index: number) {
|
||||
return `@参考图${index + 1}`;
|
||||
}
|
||||
|
||||
export function batchReferenceColumnId(handleId?: string) {
|
||||
return handleId?.startsWith(BATCH_REFERENCE_HANDLE_PREFIX) ? handleId.slice(BATCH_REFERENCE_HANDLE_PREFIX.length) : undefined;
|
||||
}
|
||||
|
||||
export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) {
|
||||
if (node.type !== "batch-table") return undefined;
|
||||
const columnId = batchReferenceColumnId(handleId);
|
||||
const columns = batchReferenceColumns(node.metadata?.batchTable);
|
||||
const index = columnId ? columns.findIndex((column) => column.id === columnId) : 0;
|
||||
if (index < 0) return undefined;
|
||||
return node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP;
|
||||
}
|
||||
|
||||
export function batchReferenceHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) {
|
||||
if (node.type !== "batch-table") return undefined;
|
||||
const columns = batchReferenceColumns(node.metadata?.batchTable);
|
||||
let nearestIndex = -1;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
columns.forEach((_, index) => {
|
||||
const distance = Math.abs(worldY - (node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP));
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchReferenceHandleId(columns[nearestIndex].id) : undefined;
|
||||
}
|
||||
|
||||
export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
|
||||
const columns = batchReferenceColumns(node.metadata?.batchTable);
|
||||
const indexById = new Map(columns.map((column, index) => [column.id, index]));
|
||||
const result = columns.map(() => [] as string[]);
|
||||
connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").forEach((connection) => {
|
||||
const columnId = batchReferenceColumnId(connection.toHandleId);
|
||||
const index = columnId ? indexById.get(columnId) : 0;
|
||||
if (index === undefined || result[index].includes(connection.fromNodeId)) return;
|
||||
result[index].push(connection.fromNodeId);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createBatchRow(operation: CanvasBatchOperation, inputNodeIds: string[] = []): CanvasBatchRow {
|
||||
return {
|
||||
id: `batch-row-${nanoid()}`,
|
||||
enabled: true,
|
||||
inputNodeIds,
|
||||
prompt: operation === "try_on" ? TRY_ON_BATCH_PROMPT : CREATIVE_BATCH_PROMPT,
|
||||
};
|
||||
}
|
||||
|
||||
/** 手工追加任务时沿用上一行的参考图,但重新使用当前任务类型的默认提示词。 */
|
||||
export function createInheritedBatchRow(operation: CanvasBatchOperation, rows: CanvasBatchRow[]) {
|
||||
return createBatchRow(operation, [...(rows.at(-1)?.inputNodeIds || [])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected try-on inputs follow the reference layout: all model/person images first,
|
||||
* followed by one shared garment image. Users can still edit any row afterwards.
|
||||
*/
|
||||
export function createBatchRowsFromInputs(operation: CanvasBatchOperation, inputNodeIds: string[]) {
|
||||
if (operation === "creative") return inputNodeIds.map((id) => createBatchRow(operation, [id]));
|
||||
if (inputNodeIds.length < 2) return [];
|
||||
const garmentId = inputNodeIds.at(-1)!;
|
||||
return inputNodeIds.slice(0, -1).map((personId) => createBatchRow(operation, [personId, garmentId]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zip equally sized columns and broadcast singleton columns across every row.
|
||||
*
|
||||
* 同步连线是增量操作:匹配到的行更新参考图,未被本次连线覆盖的手工行继续保留。
|
||||
* 这样用户在表格内追加或编辑的任务不会因为再次点击“同步连线”而被删除。
|
||||
*/
|
||||
export function createBatchRowsFromColumns(operation: CanvasBatchOperation, columns: string[][], previousRows: CanvasBatchRow[] = []) {
|
||||
const rowCount = Math.max(0, ...columns.map((column) => column.length));
|
||||
const unmatchedRows = [...previousRows];
|
||||
const synchronizedRows = Array.from({ length: rowCount }, (_, rowIndex) => {
|
||||
const inputNodeIds = columns.flatMap((column) => {
|
||||
const input = column.length === 1 ? column[0] : column[rowIndex];
|
||||
return input ? [input] : [];
|
||||
});
|
||||
let previousIndex = unmatchedRows.findIndex((row) => sameBatchInputs(row.inputNodeIds, inputNodeIds));
|
||||
if (previousIndex < 0 && inputNodeIds[0]) previousIndex = unmatchedRows.findIndex((row) => row.inputNodeIds[0] === inputNodeIds[0]);
|
||||
const previous = previousIndex >= 0 ? unmatchedRows.splice(previousIndex, 1)[0] : undefined;
|
||||
const inputsUnchanged = previous && previous.inputNodeIds.length === inputNodeIds.length && previous.inputNodeIds.every((id, index) => id === inputNodeIds[index]);
|
||||
return {
|
||||
...createBatchRow(operation, inputNodeIds),
|
||||
...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt } : {}),
|
||||
...(inputsUnchanged && previous?.outputNodeId ? { outputNodeId: previous.outputNodeId } : {}),
|
||||
inputNodeIds,
|
||||
};
|
||||
});
|
||||
return [...synchronizedRows, ...unmatchedRows];
|
||||
}
|
||||
|
||||
function sameBatchInputs(left: string[], right: string[]) {
|
||||
return left.length === right.length && left.every((id, index) => id === right[index]);
|
||||
}
|
||||
@@ -16,10 +16,14 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[],
|
||||
if (acceptedInputKinds.length) {
|
||||
const source = nodes.find((node) => node.id === candidate.fromNodeId);
|
||||
const sourceKind = source ? getNodeInputKind(source.type) : undefined;
|
||||
const hasAcceptedSource = Boolean(sourceKind && acceptedInputKinds.includes(sourceKind));
|
||||
const isMediaConversion = target.type === CanvasNodeType.MediaConversion;
|
||||
const hasAcceptedSource = isMediaConversion
|
||||
? source?.type === CanvasNodeType.Image || source?.type === CanvasNodeType.Video
|
||||
: Boolean(sourceKind && acceptedInputKinds.includes(sourceKind));
|
||||
if (!sourceKind || !hasAcceptedSource) {
|
||||
const labels = acceptedInputKinds.map(acceptedInputKindLabel).join("或");
|
||||
return `${labels}节点只接受${labels}输入`;
|
||||
const targetLabel = isMediaConversion ? "转换" : target.type === CanvasNodeType.BatchTable ? "批量创作表" : labels;
|
||||
return `${targetLabel}节点只接受${labels}输入`;
|
||||
}
|
||||
const maxInputCount = getNodeMaxInputCount(target.type);
|
||||
if (maxInputCount) {
|
||||
@@ -28,7 +32,7 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[],
|
||||
.filter((connection) => connection.toNodeId === target.id)
|
||||
.map((connection) => connection.fromNodeId),
|
||||
).size;
|
||||
if (inputCount > maxInputCount) return `当前节点最多连接 ${maxInputCount} 个输入`;
|
||||
if (inputCount > maxInputCount) return `${isMediaConversion ? "转换" : "当前"}节点最多连接 ${maxInputCount} 个输入`;
|
||||
}
|
||||
}
|
||||
const mode = getNodeGenerationMode(target);
|
||||
@@ -50,10 +54,11 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[],
|
||||
return "";
|
||||
}
|
||||
|
||||
function acceptedInputKindLabel(kind: "image" | "video" | "audio" | "text") {
|
||||
function acceptedInputKindLabel(kind: "image" | "video" | "audio" | "text" | "table_data") {
|
||||
if (kind === "image") return "图片";
|
||||
if (kind === "video") return "视频";
|
||||
if (kind === "audio") return "音频";
|
||||
if (kind === "table_data") return "多维表格";
|
||||
return "文本";
|
||||
}
|
||||
|
||||
@@ -69,7 +74,7 @@ export function connectionInputSummary(targetNodeId: string, nodes: CanvasNodeDa
|
||||
if (!inputKind) return;
|
||||
// 角色卡是跨类型覆盖:落在可计数类型上时改记为角色。
|
||||
if (source.metadata?.workflowKind === "character") input.characterCount += 1;
|
||||
else input[`${inputKind}Count`] += 1;
|
||||
else if (inputKind !== "table_data") input[`${inputKind}Count`] += 1;
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||
import { canvasNodeMentionToken, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { batchReferenceHandleY } from "@/lib/canvas/canvas-batch-table";
|
||||
import { scopedLocalStorage } from "@/lib/user-scope";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
|
||||
@@ -166,7 +167,7 @@ function resetGenerationParamsOnModelSwitch(node: CanvasNodeData, patch: Partial
|
||||
export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle, handleId?: string, scrollTop = 0) {
|
||||
return {
|
||||
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
|
||||
y: storyboardHandleY(node, handleId, scrollTop) ?? node.position.y + node.height / 2,
|
||||
y: batchReferenceHandleY(node, handleId) ?? storyboardHandleY(node, handleId, scrollTop) ?? node.position.y + node.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,19 @@ export function buildImageGenerationMetadata(type: CanvasImageGenerationType, co
|
||||
}
|
||||
|
||||
export function nodeReferenceImage(node: CanvasNodeData): ReferenceImage | null {
|
||||
if (node.type === CanvasNodeType.MediaConversion) {
|
||||
// 转换结果只在成功物化后作为生成参考;运行中、跳过和失败状态都不能透传旧结果。
|
||||
const conversion = node.metadata?.mediaConversion;
|
||||
const storageKey = conversion?.resultStorageKey || node.metadata?.storageKey;
|
||||
if (conversion?.status !== "completed" || !storageKey) return null;
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.title || `conversion-${node.id}.png`,
|
||||
type: node.metadata?.mimeType || "image/png",
|
||||
dataUrl: node.metadata?.content || "",
|
||||
storageKey,
|
||||
};
|
||||
}
|
||||
if (node.type !== CanvasNodeType.Image || (!node.metadata?.content && !node.metadata?.storageKey)) return null;
|
||||
return {
|
||||
id: node.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChartColumn, Clapperboard, Code, Columns2, FileText, Globe, Image as ImageIcon, Music2, PanelTop, Palette, Pencil, Settings2, Shapes, Sparkles, Type, Video, WandSparkles } from "lucide-react";
|
||||
import { ChartColumn, Clapperboard, Code, Columns2, FileText, Globe, Image as ImageIcon, Music2, PanelTop, Palette, Pencil, Settings2, Shapes, Sparkles, Table2, Type, Video, WandSparkles } from "lucide-react";
|
||||
|
||||
import { NODE_SPECS } from "@/constant/canvas";
|
||||
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
|
||||
@@ -154,6 +154,8 @@ const BUILTIN_NODE_TRAITS = {
|
||||
showInCreateMenu: true,
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.BatchTable]: { label: "批量创作表", icon: <Table2 />, minSize: { width: 720, height: 360 }, showInCreateMenu: true, acceptsInputKind: "image", inputKind: "text", resourceKind: () => null },
|
||||
[CanvasNodeType.MediaConversion]: { label: "转换", icon: <WandSparkles />, minSize: { width: 400, height: 360 }, showInCreateMenu: true, resourceKind: (node: CanvasNodeData) => { const c = node.metadata?.mediaConversion; return c?.status === "completed" && c.resultStorageKey ? (c.outputKind === "video" ? "video" : "image") : null; }, acceptsInputKind: ["image", "video"], maxInputCount: 1, inputKind: "image" },
|
||||
[CanvasNodeType.ColorGrade]: {
|
||||
label: "调色",
|
||||
icon: <Palette />,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNo
|
||||
import type { PluginCanvasNodeContribution } from "@/lib/plugins/plugin-types";
|
||||
|
||||
/** 作为上游输入被容量校验计数时归入的类别 */
|
||||
export type CanvasNodeInputKind = "image" | "video" | "audio" | "text";
|
||||
export type CanvasNodeInputKind = "image" | "video" | "audio" | "text" | "table_data";
|
||||
|
||||
/**
|
||||
* 节点定义——注册表的基本单元。
|
||||
|
||||
@@ -20,6 +20,8 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
|
||||
{ id: "folder", label: "文件夹", icon: <Folder />, badge: "6 款", section: "node", defaultOrder: 45, run: (ctx) => ctx.handlers.onAddFolder() },
|
||||
nodeCommand(CanvasNodeType.Image, { defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() }),
|
||||
nodeCommand(CanvasNodeType.Video, { defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() }),
|
||||
nodeCommand(CanvasNodeType.BatchTable, { defaultOrder: 66, run: (ctx) => ctx.handlers.onAddExtensionNode(CanvasNodeType.BatchTable) }),
|
||||
nodeCommand(CanvasNodeType.MediaConversion, { badge: "本地", defaultOrder: 65, run: (ctx) => ctx.handlers.onAddExtensionNode(CanvasNodeType.MediaConversion) }),
|
||||
// 导演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
|
||||
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
|
||||
nodeCommand(CanvasNodeType.Audio, { defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() }),
|
||||
|
||||
89
web/src/lib/media-conversion/contracts.ts
Normal file
89
web/src/lib/media-conversion/contracts.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export const MEDIA_CONVERSION_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export const MEDIA_CONVERSION_NODE_TYPE = "media-conversion" as const;
|
||||
|
||||
export type MediaConversionOperation = "grayscale" | "edge-canny" | "lineart" | "depth" | "pose" | "cutout";
|
||||
export type MediaConversionStatus = "idle" | "processing" | "completed" | "stale" | "skipped" | "unavailable" | "error";
|
||||
export type MediaConversionOutputKind = "image" | "video";
|
||||
|
||||
export type MediaConversionNodeState = {
|
||||
schemaVersion: typeof MEDIA_CONVERSION_SCHEMA_VERSION;
|
||||
operation: MediaConversionOperation;
|
||||
status: MediaConversionStatus;
|
||||
sourceNodeId?: string;
|
||||
sourceFingerprint?: string;
|
||||
outputKind?: MediaConversionOutputKind;
|
||||
resultStorageKey?: string;
|
||||
resultWidth?: number;
|
||||
resultHeight?: number;
|
||||
detectedPeople?: number;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
startedAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export const MEDIA_CONVERSION_OPERATION_LABELS: Record<MediaConversionOperation, string> = {
|
||||
grayscale: "灰度图",
|
||||
"edge-canny": "Canny 边缘",
|
||||
lineart: "AI 线稿",
|
||||
depth: "深度图",
|
||||
pose: "姿态骨架",
|
||||
cutout: "透明抠图",
|
||||
};
|
||||
|
||||
export const MEDIA_CONVERSION_OPERATION_DESCRIPTIONS: Record<MediaConversionOperation, string> = {
|
||||
grayscale: "普通图像算法,适合先检查明暗层次",
|
||||
"edge-canny": "普通图像算法,提取清晰轮廓",
|
||||
lineart: "本地 ControlNet Aux 线稿预处理,首次运行需要加载模型",
|
||||
depth: "本地 Depth Anything V2 Small,首次运行需要加载模型",
|
||||
pose: "本地 OpenPose 人体骨架预处理,未检测到人物时会跳过",
|
||||
cutout: "需要安装并验证本地抠图模型",
|
||||
};
|
||||
|
||||
export function createDefaultMediaConversionState(): MediaConversionNodeState {
|
||||
return {
|
||||
schemaVersion: MEDIA_CONVERSION_SCHEMA_VERSION,
|
||||
operation: "edge-canny",
|
||||
status: "idle",
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaConversionSourceFingerprint(node: CanvasNodeData) {
|
||||
const metadata = node.metadata;
|
||||
const source = [
|
||||
node.id,
|
||||
metadata?.storageKey || "",
|
||||
compactSource(metadata?.content || metadata?.previewContent || ""),
|
||||
metadata?.mimeType || "",
|
||||
metadata?.bytes || "",
|
||||
metadata?.naturalWidth || "",
|
||||
metadata?.naturalHeight || "",
|
||||
metadata?.durationMs || "",
|
||||
].join("|");
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
hash ^= source.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `v1-${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
export function mediaConversionOperationLabel(operation: MediaConversionOperation) {
|
||||
return MEDIA_CONVERSION_OPERATION_LABELS[operation];
|
||||
}
|
||||
|
||||
export function mediaConversionOperationDescription(operation: MediaConversionOperation) {
|
||||
return MEDIA_CONVERSION_OPERATION_DESCRIPTIONS[operation];
|
||||
}
|
||||
|
||||
export function isLocalImageOperation(operation: MediaConversionOperation) {
|
||||
return operation === "grayscale" || operation === "edge-canny";
|
||||
}
|
||||
|
||||
function compactSource(value: string) {
|
||||
if (value.length <= 2048) return value;
|
||||
return `${value.slice(0, 1024)}|${value.slice(-1024)}|${value.length}`;
|
||||
}
|
||||
228
web/src/lib/media-conversion/local-converter.ts
Normal file
228
web/src/lib/media-conversion/local-converter.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { MediaConversionOperation } from "./contracts";
|
||||
|
||||
export type LocalImageConversionResult = {
|
||||
blob: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export class LocalImageConversionError extends Error {
|
||||
constructor(readonly code: "model_missing" | "source_unreadable" | "browser_unsupported" | "aborted", message: string) {
|
||||
super(message);
|
||||
this.name = "LocalImageConversionError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertImageLocally(sourceUrl: string, operation: MediaConversionOperation, options: { signal?: AbortSignal; maxDimension?: number } = {}): Promise<LocalImageConversionResult> {
|
||||
throwIfAborted(options.signal);
|
||||
if (operation !== "grayscale" && operation !== "edge-canny") {
|
||||
throw new LocalImageConversionError("model_missing", "该转换需要先安装并验证本地模型");
|
||||
}
|
||||
|
||||
const image = await loadImage(sourceUrl, options.signal);
|
||||
throwIfAborted(options.signal);
|
||||
const maxDimension = Math.max(256, options.maxDimension || 1024);
|
||||
const scale = Math.min(1, maxDimension / Math.max(image.naturalWidth, image.naturalHeight));
|
||||
const width = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||
const height = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new LocalImageConversionError("browser_unsupported", "当前浏览器无法创建图片处理画布");
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const input = context.getImageData(0, 0, width, height);
|
||||
const output = transformPixels(input.data, width, height, operation, options.signal);
|
||||
const outputImage = new ImageData(output, width, height);
|
||||
context.putImageData(outputImage, 0, 0);
|
||||
const blob = await canvasToBlob(canvas, options.signal);
|
||||
throwIfAborted(options.signal);
|
||||
return { blob, width, height };
|
||||
}
|
||||
|
||||
/** Pure pixel transform used by the browser executor and unit tests. */
|
||||
export function transformPixels(source: Uint8ClampedArray, width: number, height: number, operation: "grayscale" | "edge-canny", signal?: AbortSignal) {
|
||||
if (source.length !== width * height * 4) throw new Error("图片像素尺寸不匹配");
|
||||
if (operation === "grayscale") return grayscalePixels(source, width, height, signal);
|
||||
return cannyPixels(source, width, height, signal);
|
||||
}
|
||||
|
||||
function grayscalePixels(source: Uint8ClampedArray, width: number, height: number, signal?: AbortSignal) {
|
||||
const output = new Uint8ClampedArray(source);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
throwIfAborted(signal);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = (y * width + x) * 4;
|
||||
const value = Math.round(source[index] * 0.299 + source[index + 1] * 0.587 + source[index + 2] * 0.114);
|
||||
output[index] = value;
|
||||
output[index + 1] = value;
|
||||
output[index + 2] = value;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function cannyPixels(source: Uint8ClampedArray, width: number, height: number, signal?: AbortSignal) {
|
||||
const gray = new Float32Array(width * height);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
throwIfAborted(signal);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = (y * width + x) * 4;
|
||||
gray[y * width + x] = source[index] * 0.299 + source[index + 1] * 0.587 + source[index + 2] * 0.114;
|
||||
}
|
||||
}
|
||||
|
||||
const blurred = gaussianBlur(gray, width, height, signal);
|
||||
const magnitude = new Float32Array(width * height);
|
||||
const direction = new Float32Array(width * height);
|
||||
let maximum = 0;
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
throwIfAborted(signal);
|
||||
for (let x = 1; x < width - 1; x += 1) {
|
||||
const index = y * width + x;
|
||||
const horizontal = blurred[index + 1] - blurred[index - 1];
|
||||
const vertical = blurred[index + width] - blurred[index - width];
|
||||
const value = Math.hypot(horizontal, vertical);
|
||||
magnitude[index] = value;
|
||||
direction[index] = Math.atan2(vertical, horizontal);
|
||||
maximum = Math.max(maximum, value);
|
||||
}
|
||||
}
|
||||
|
||||
const thinned = nonMaximumSuppression(magnitude, direction, width, height, signal);
|
||||
const high = maximum * 0.28;
|
||||
const low = high * 0.45;
|
||||
const edges = new Uint8Array(width * height);
|
||||
const stack: number[] = [];
|
||||
for (let index = 0; index < thinned.length; index += 1) {
|
||||
if (thinned[index] >= high && high > 0) {
|
||||
edges[index] = 2;
|
||||
stack.push(index);
|
||||
} else if (thinned[index] >= low && low > 0) {
|
||||
edges[index] = 1;
|
||||
}
|
||||
}
|
||||
while (stack.length) {
|
||||
throwIfAborted(signal);
|
||||
const index = stack.pop()!;
|
||||
const x = index % width;
|
||||
const y = Math.floor(index / width);
|
||||
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
||||
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
||||
if (!offsetX && !offsetY) continue;
|
||||
const nextX = x + offsetX;
|
||||
const nextY = y + offsetY;
|
||||
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) continue;
|
||||
const next = nextY * width + nextX;
|
||||
if (edges[next] !== 1) continue;
|
||||
edges[next] = 2;
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = new Uint8ClampedArray(source.length);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
throwIfAborted(signal);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = y * width + x;
|
||||
const outputIndex = index * 4;
|
||||
const value = edges[index] === 2 ? 0 : 255;
|
||||
output[outputIndex] = value;
|
||||
output[outputIndex + 1] = value;
|
||||
output[outputIndex + 2] = value;
|
||||
output[outputIndex + 3] = source[outputIndex + 3];
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function gaussianBlur(source: Float32Array, width: number, height: number, signal?: AbortSignal) {
|
||||
const output = new Float32Array(source.length);
|
||||
const kernel = [1, 2, 1, 2, 4, 2, 1, 2, 1];
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
throwIfAborted(signal);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let value = 0;
|
||||
let weight = 0;
|
||||
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
||||
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
||||
const nextX = Math.min(width - 1, Math.max(0, x + offsetX));
|
||||
const nextY = Math.min(height - 1, Math.max(0, y + offsetY));
|
||||
const kernelWeight = kernel[(offsetY + 1) * 3 + offsetX + 1];
|
||||
value += source[nextY * width + nextX] * kernelWeight;
|
||||
weight += kernelWeight;
|
||||
}
|
||||
}
|
||||
output[y * width + x] = value / weight;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function nonMaximumSuppression(magnitude: Float32Array, direction: Float32Array, width: number, height: number, signal?: AbortSignal) {
|
||||
const output = new Float32Array(magnitude.length);
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
throwIfAborted(signal);
|
||||
for (let x = 1; x < width - 1; x += 1) {
|
||||
const index = y * width + x;
|
||||
let angle = (direction[index] * 180) / Math.PI;
|
||||
if (angle < 0) angle += 180;
|
||||
const value = magnitude[index];
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
if (angle < 22.5 || angle >= 157.5) {
|
||||
before = magnitude[index - 1];
|
||||
after = magnitude[index + 1];
|
||||
} else if (angle < 67.5) {
|
||||
before = magnitude[index - width + 1];
|
||||
after = magnitude[index + width - 1];
|
||||
} else if (angle < 112.5) {
|
||||
before = magnitude[index - width];
|
||||
after = magnitude[index + width];
|
||||
} else {
|
||||
before = magnitude[index - width - 1];
|
||||
after = magnitude[index + width + 1];
|
||||
}
|
||||
output[index] = value >= before && value >= after ? value : 0;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function loadImage(sourceUrl: string, signal?: AbortSignal) {
|
||||
if (!sourceUrl) throw new LocalImageConversionError("source_unreadable", "没有可读取的图片输入");
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
return await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const abort = () => {
|
||||
image.src = "";
|
||||
reject(new LocalImageConversionError("aborted", "转换已取消"));
|
||||
};
|
||||
if (signal?.aborted) return abort();
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
image.onload = () => {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
reject(new LocalImageConversionError("source_unreadable", "图片无法读取,可能是跨域资源或已失效"));
|
||||
};
|
||||
image.src = sourceUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, signal?: AbortSignal) {
|
||||
throwIfAborted(signal);
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new LocalImageConversionError("browser_unsupported", "当前浏览器无法导出图片结果"));
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): asserts signal is AbortSignal | undefined {
|
||||
if (signal?.aborted) throw new LocalImageConversionError("aborted", "转换已取消");
|
||||
}
|
||||
@@ -2,4 +2,5 @@ import "./eagle";
|
||||
import "./prompt-optimizer";
|
||||
import "./workflows";
|
||||
import "./ai-art-critique";
|
||||
import "./media-conversion";
|
||||
import "./editor/editor-shell";
|
||||
|
||||
37
web/src/lib/plugins/builtin/media-conversion.ts
Normal file
37
web/src/lib/plugins/builtin/media-conversion.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { MEDIA_CONVERSION_NODE_TYPE } from "@/lib/media-conversion/contracts";
|
||||
|
||||
import { registerPlugin } from "../plugin-registry";
|
||||
import { PLUGIN_API_VERSION, type PluginManifest, type RegisteredPlugin } from "../plugin-types";
|
||||
|
||||
export const MEDIA_CONVERSION_PLUGIN_ID = "media-conversion";
|
||||
|
||||
const manifest: PluginManifest = {
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
id: MEDIA_CONVERSION_PLUGIN_ID,
|
||||
name: "媒体转换节点",
|
||||
version: "0.1.0",
|
||||
publishedAt: "2026-09-08",
|
||||
updatedAt: "2026-09-08",
|
||||
description: "在画布中提供图片转换节点,支持灰度、Canny 边缘、AI 线稿、本地 Depth Anything V2 深度图和 OpenPose 姿态骨架。",
|
||||
documentation:
|
||||
"# 媒体转换节点\n\n连接图片到转换节点后,可以在节点内选择灰度图、Canny 边缘、AI 线稿、深度图或姿态骨架。AI 线稿使用本机 ControlNet Aux 预处理模型,深度图使用本机已安装的 Depth Anything V2 Small 模型,姿态骨架使用 ControlNet Aux 的 OpenPose 人体预处理模型运行;这些操作都不会加载 Stable Diffusion 重绘管线。姿态转换只绘制人体骨架,未检测到人物时会跳过并提示。视频转换和透明抠图模型仍在验证,暂时会明确提示不可用。\n\n该插件只读取当前节点的媒体输入,并把结果保存回当前画布节点的本地素材存储。",
|
||||
author: "影策团队",
|
||||
surfaces: ["node"],
|
||||
permissions: ["canvas.read", "canvas.write", "media.read"],
|
||||
trusted: true,
|
||||
runtime: { web: "declarative" },
|
||||
contributes: {
|
||||
transforms: [
|
||||
{
|
||||
id: MEDIA_CONVERSION_NODE_TYPE,
|
||||
input: "media",
|
||||
output: "media",
|
||||
runtime: "declarative",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const mediaConversionPlugin: RegisteredPlugin = { manifest };
|
||||
|
||||
registerPlugin(mediaConversionPlugin);
|
||||
@@ -67,6 +67,7 @@ import { getProject } from "@/services/api/projects";
|
||||
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
|
||||
import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node";
|
||||
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
|
||||
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
||||
import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel";
|
||||
import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
|
||||
@@ -124,6 +125,7 @@ import { useCanvasStyleWorkflow } from "./use-canvas-style-workflow";
|
||||
import { useCanvasDirector } from "./use-canvas-director";
|
||||
import { useCanvasGeneration } from "./use-canvas-generation";
|
||||
import { useCanvasGenerationBatches } from "./use-canvas-generation-batches";
|
||||
import { useCanvasBatchTable } from "./use-canvas-batch-table";
|
||||
import { useCanvasGenerationExecutor, type CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
|
||||
import { useCanvasGenerationRetry } from "./use-canvas-generation-retry";
|
||||
import { useCanvasHistory } from "./use-canvas-history";
|
||||
@@ -1016,6 +1018,7 @@ function InfiniteCanvasPage() {
|
||||
createConnectedNode,
|
||||
getConnectionCreateDisabledReason,
|
||||
handleConnectStart,
|
||||
handleConnectDrop,
|
||||
handleBatchConnectionTargetClick,
|
||||
batchConnectionPreview,
|
||||
beginBatchConnectionMode,
|
||||
@@ -1842,6 +1845,15 @@ function InfiniteCanvasPage() {
|
||||
handleGenerateNode,
|
||||
});
|
||||
|
||||
const { addReferenceColumn: addBatchReferenceColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, patchTable: patchBatchTable, removeRow: removeBatchRow, updateRow: updateBatchRow } = useCanvasBatchTable({
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
setNodes,
|
||||
setConnections,
|
||||
setSelectedNodeIds,
|
||||
enqueueGenerationBatch,
|
||||
});
|
||||
|
||||
const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } =
|
||||
useCanvasStoryboard({
|
||||
projectId,
|
||||
@@ -2036,6 +2048,27 @@ function InfiniteCanvasPage() {
|
||||
if (contentNode.metadata?.workflowKind === "story_input") {
|
||||
return <CanvasStoryInputNodeContent node={contentNode} onEdit={() => openStoryInput(contentNode.id)} />;
|
||||
}
|
||||
if (contentNode.type === CanvasNodeType.BatchTable) {
|
||||
return (
|
||||
<CanvasBatchTableNodeContent
|
||||
node={contentNode}
|
||||
nodes={nodesRef.current}
|
||||
connections={connections}
|
||||
batch={visibleGenerationBatch(contentNode)}
|
||||
theme={theme}
|
||||
onPatchTable={(patch) => patchBatchTable(contentNode.id, patch)}
|
||||
onAddRow={() => addBatchRow(contentNode.id)}
|
||||
onRemoveRow={(rowId) => removeBatchRow(contentNode.id, rowId)}
|
||||
onUpdateRow={(rowId, patch) => updateBatchRow(contentNode.id, rowId, patch)}
|
||||
onFillRows={() => fillRowsFromConnections(contentNode.id)}
|
||||
onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)}
|
||||
onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
|
||||
onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)}
|
||||
onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)}
|
||||
onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (contentNode.type === CanvasNodeType.Script) {
|
||||
const pipeline = deriveStoryboardPipelineProgress(contentNode, nodesRef.current, connectionsRef.current);
|
||||
return (
|
||||
@@ -2104,17 +2137,23 @@ function InfiniteCanvasPage() {
|
||||
);
|
||||
},
|
||||
[
|
||||
addBatchReferenceColumn,
|
||||
addBatchRow,
|
||||
addScriptRow,
|
||||
configInputsById,
|
||||
connections,
|
||||
createAndGenerateScriptVideos,
|
||||
createScriptActionBoards,
|
||||
createScriptImageNodes,
|
||||
createScriptVideoNodes,
|
||||
currentProject?.directorScenes,
|
||||
fillRowsFromConnections,
|
||||
generateBatchRows,
|
||||
generateScriptImages,
|
||||
generateScriptRows,
|
||||
generateScriptVideos,
|
||||
handleConfigNodeChange,
|
||||
handleConnectDrop,
|
||||
handleConnectStart,
|
||||
handleGenerateNode,
|
||||
handleNodeResize,
|
||||
@@ -2122,10 +2161,14 @@ function InfiniteCanvasPage() {
|
||||
mergeVideosByIds,
|
||||
openDirectorWorkbench,
|
||||
openStoryInput,
|
||||
patchBatchTable,
|
||||
removeBatchRow,
|
||||
removeScriptRow,
|
||||
retryFailedBatchItems,
|
||||
runningNodeId,
|
||||
stopRemainingBatchItems,
|
||||
theme,
|
||||
updateBatchRow,
|
||||
updateScriptRow,
|
||||
viewport.k,
|
||||
workspaceMode,
|
||||
@@ -2573,6 +2616,7 @@ function InfiniteCanvasPage() {
|
||||
!isCanvasImageSourceNode(dialogNode) &&
|
||||
!dialogNode.metadata?.fileUpload &&
|
||||
dialogNode.type !== CanvasNodeType.Script &&
|
||||
dialogNode.type !== CanvasNodeType.BatchTable &&
|
||||
dialogNode.type !== CanvasNodeType.Drawing &&
|
||||
dialogNode.type !== CanvasNodeType.Panorama &&
|
||||
!selectionBox &&
|
||||
@@ -2699,7 +2743,7 @@ function InfiniteCanvasPage() {
|
||||
onDelete={(node) => deleteNodes(new Set([node.id]))}
|
||||
/>
|
||||
|
||||
{isMiniMapOpen && !focusMode ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} canvasContainerRef={containerRef} onViewportPreviewChange={previewViewport} onViewportChange={handleViewportChange} /> : null}
|
||||
{isMiniMapOpen && !focusMode ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} canvasContainerRef={containerRef} onViewportPreviewChange={previewViewport} onViewportChange={handleViewportChange} /> : null}
|
||||
|
||||
{!focusMode ? (
|
||||
<CanvasOverlayLayerContainer
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar";
|
||||
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
|
||||
import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
|
||||
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
|
||||
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
|
||||
@@ -17,6 +18,7 @@ import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BAC
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
|
||||
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { getContextResourceNodes } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getPublicCanvasShare } from "@/services/api/canvas-share";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position, type ViewportTransform } from "@/types/canvas";
|
||||
@@ -233,7 +235,11 @@ export default function SharedCanvasPage() {
|
||||
metadata: { ...node.metadata, frame: { collapsed, expandedWidth: collapsed ? node.width : frame?.expandedWidth || node.width, expandedHeight: collapsed ? node.height : frame?.expandedHeight || node.height } },
|
||||
};
|
||||
}));
|
||||
const renderSharedNode = useCallback((node: CanvasNodeData): ReactNode => node.type === CanvasNodeType.Script ? <SharedScriptNode node={node} onUnauthorized={unauthorized} /> : <SharedConfigNode node={node} onUnauthorized={unauthorized} />, [unauthorized]);
|
||||
const renderSharedNode = useCallback((node: CanvasNodeData): ReactNode => {
|
||||
if (node.type === CanvasNodeType.Script) return <SharedScriptNode node={node} onUnauthorized={unauthorized} />;
|
||||
if (node.type === CanvasNodeType.BatchTable) return <CanvasBatchTableNodeContent node={node} nodes={nodes} connections={connections} batch={node.metadata?.generationBatches?.at(-1)} theme={theme} readOnly onPatchTable={() => {}} onAddRow={() => {}} onRemoveRow={() => {}} onUpdateRow={() => {}} onFillRows={() => {}} onGenerate={() => {}} onRetryItem={() => {}} onAddReferenceColumn={() => {}} onConnectStart={() => {}} />;
|
||||
return <SharedConfigNode node={node} onUnauthorized={unauthorized} />;
|
||||
}, [connections, nodes, theme, unauthorized]);
|
||||
const toolbarNodeKey = selectedNodeId;
|
||||
const toolbarNode = toolbarNodeKey ? nodeById.get(toolbarNodeKey) || null : null;
|
||||
|
||||
|
||||
153
web/src/pages/canvas/use-canvas-batch-table.ts
Normal file
153
web/src/pages/canvas/use-canvas-batch-table.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from "react";
|
||||
import { App } from "antd";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { batchInputColumns, batchReferenceColumns, createBatchRowsFromColumns, createInheritedBatchRow } from "@/lib/canvas/canvas-batch-table";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { buildGenerationConfig, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasBatchRow, type CanvasBatchTableData, type CanvasConnection, type CanvasGenerationBatchMode, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type Options = {
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
connectionsRef: { current: CanvasConnection[] };
|
||||
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
||||
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
|
||||
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined;
|
||||
};
|
||||
|
||||
export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) {
|
||||
const { message, modal } = App.useApp();
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
|
||||
const patchTable = useCallback((nodeId: string, patch: Partial<CanvasBatchTableData>) => {
|
||||
setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, rows: [], ...node.metadata?.batchTable, ...patch } } }));
|
||||
}, [setNodes]);
|
||||
|
||||
const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial<CanvasBatchRow>) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
if (!node?.metadata?.batchTable) return;
|
||||
patchTable(nodeId, { rows: node.metadata.batchTable.rows.map((row) => row.id === rowId ? { ...row, ...patch } : row) });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const addRow = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
patchTable(nodeId, { rows: [...table.rows, createInheritedBatchRow(table.operation, table.rows)] });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const removeRow = useCallback((nodeId: string, rowId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
patchTable(nodeId, { rows: table.rows.filter((row) => row.id !== rowId) });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const addReferenceColumn = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const columns = batchReferenceColumns(table);
|
||||
if (columns.length >= 6) return message.info("最多支持 6 组参考图");
|
||||
const nextIndex = columns.length + 1;
|
||||
patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] });
|
||||
}, [message, nodesRef, patchTable]);
|
||||
|
||||
const fillRowsFromConnections = useCallback((nodeId: string) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
const table = node?.metadata?.batchTable;
|
||||
if (!node || !table) return;
|
||||
const nodeById = new Map(nodesRef.current.map((item) => [item.id, item]));
|
||||
const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => {
|
||||
const input = nodeById.get(inputNodeId);
|
||||
return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey);
|
||||
}));
|
||||
if (!columns.some((column) => column.length)) return message.warning("请先把图片节点连接到批量创作表");
|
||||
const rows = createBatchRowsFromColumns(table.operation, columns, table.rows);
|
||||
if (!rows.length) return message.warning("批量换装至少需要一张人物图和一张服装图");
|
||||
patchTable(nodeId, { rows });
|
||||
const added = Math.max(0, rows.length - table.rows.length);
|
||||
message.success(added ? `已同步连线并新增 ${added} 行,原有任务均已保留` : "已同步最新连线,原有任务均已保留");
|
||||
}, [connectionsRef, message, nodesRef, patchTable]);
|
||||
|
||||
const generateRows = useCallback(async (nodeId: string, requestedRowIds?: string[]) => {
|
||||
const sourceNode = nodesRef.current.find((item) => item.id === nodeId);
|
||||
const table = sourceNode?.metadata?.batchTable;
|
||||
if (!sourceNode || !table) return;
|
||||
const imageModel = effectiveConfig.imageModel || effectiveConfig.model;
|
||||
if (!isAiConfigReady(effectiveConfig, imageModel)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
return;
|
||||
}
|
||||
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
|
||||
const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null;
|
||||
const rows = table.rows.filter((row) => {
|
||||
if (!row.enabled || (requested && !requested.has(row.id)) || !row.prompt.trim()) return false;
|
||||
if (table.operation === "try_on" && row.inputNodeIds.length < 2) return false;
|
||||
if (!row.inputNodeIds.length || row.inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false;
|
||||
const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined;
|
||||
if (output && activeNodeIds.has(output.id)) return false;
|
||||
// 操作列的单行生成允许对已完成结果重新生成;顶部批量按钮仍只提交未完成项。
|
||||
if (requested) return true;
|
||||
return !output?.metadata?.content;
|
||||
});
|
||||
if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词");
|
||||
const confirmed = await new Promise<boolean>((resolve) => modal.confirm({
|
||||
title: `确认提交 ${rows.length} 个批量图片任务`,
|
||||
content: `模型:${modelDisplayName(effectiveConfig, imageModel)};并发上限:${table.concurrency}。这些任务可能消耗积分或产生外部模型费用。`,
|
||||
okText: "确认生成",
|
||||
cancelText: "取消",
|
||||
centered: true,
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
}));
|
||||
if (!confirmed) return;
|
||||
|
||||
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const nextNodes = [...nodesRef.current];
|
||||
let nextConnections = [...connectionsRef.current];
|
||||
const outputByRowId = new Map<string, string>();
|
||||
const targets: Array<{ rowId: string; nodeId: string }> = [];
|
||||
rows.forEach((row, index) => {
|
||||
const existingIndex = row.outputNodeId ? nextNodes.findIndex((node) => node.id === row.outputNodeId && node.type === CanvasNodeType.Image) : -1;
|
||||
const metadata = {
|
||||
...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}),
|
||||
prompt: row.prompt.trim(),
|
||||
composerContent: row.prompt.trim(),
|
||||
model: buildGenerationConfig(effectiveConfig, undefined, "image").model,
|
||||
generationMode: "image" as const,
|
||||
generationType: "edit" as const,
|
||||
workflowKind: "final" as const,
|
||||
workflowTitle: `${table.operation === "try_on" ? "换装" : "创意"}任务 ${index + 1}`,
|
||||
status: "idle" as const,
|
||||
batchSourceNodeId: nodeId,
|
||||
batchRowId: row.id,
|
||||
batchOperation: table.operation,
|
||||
batchInputNodeIds: row.inputNodeIds,
|
||||
};
|
||||
const output = existingIndex >= 0
|
||||
? { ...nextNodes[existingIndex], metadata }
|
||||
: createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata);
|
||||
output.title = `${table.operation === "try_on" ? "换装" : "创意"} · ${index + 1}`;
|
||||
if (existingIndex >= 0) nextNodes[existingIndex] = output;
|
||||
else nextNodes.push(output);
|
||||
nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id);
|
||||
row.inputNodeIds.forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id }));
|
||||
nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id });
|
||||
outputByRowId.set(row.id, output.id);
|
||||
targets.push({ rowId: row.id, nodeId: output.id });
|
||||
});
|
||||
const sourceIndex = nextNodes.findIndex((node) => node.id === sourceNode.id);
|
||||
nextNodes[sourceIndex] = { ...sourceNode, metadata: { ...sourceNode.metadata, batchTable: { ...table, rows: table.rows.map((row) => outputByRowId.has(row.id) ? { ...row, outputNodeId: outputByRowId.get(row.id) } : row) } } };
|
||||
nodesRef.current = nextNodes;
|
||||
connectionsRef.current = nextConnections;
|
||||
setNodes(nextNodes);
|
||||
setConnections(nextConnections);
|
||||
setSelectedNodeIds(new Set(targets.map((target) => target.nodeId)));
|
||||
if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency: table.concurrency })) message.success(`${targets.length} 个任务已加入并发队列`);
|
||||
}, [connectionsRef, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, modal, nodesRef, setConnections, setNodes, setSelectedNodeIds]);
|
||||
|
||||
return { addReferenceColumn, addRow, fillRowsFromConnections, generateRows, patchTable, removeRow, updateRow };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { latchCanvasConnectionApproach, type CanvasConnectionApproach } from "@/
|
||||
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { batchSourceRestriction, buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections, type CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { batchReferenceHandleAtY } from "@/lib/canvas/canvas-batch-table";
|
||||
import { connectedNodeCenterFromEdgeDrop } from "@/lib/canvas/canvas-connected-node-placement";
|
||||
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
|
||||
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
|
||||
@@ -227,7 +228,7 @@ export function useCanvasConnectionController({
|
||||
setContextMenu(null);
|
||||
}, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
|
||||
|
||||
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
|
||||
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
|
||||
const nodeType = type;
|
||||
if (nodeType === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
|
||||
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
|
||||
@@ -319,8 +320,7 @@ export function useCanvasConnectionController({
|
||||
setConnections(nextConnections);
|
||||
setSelectedNodeIds(new Set([newNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
if (nodeType === CanvasNodeType.Script) setDialogNodeId(null);
|
||||
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.BatchTable && nodeType !== CanvasNodeType.Audio && nodeType !== CanvasNodeType.MediaConversion) setDialogNodeId(newNode.id);
|
||||
const skippedCount = batchPlan.skipped.length;
|
||||
const duplicateCount = batchPlan.duplicates.length;
|
||||
const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : "";
|
||||
@@ -386,13 +386,12 @@ export function useCanvasConnectionController({
|
||||
setSelectedNodeIds(new Set([newNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
if (nodeType === CanvasNodeType.Drawing) setDrawingNodeId(newNode.id);
|
||||
else if (nodeType === CanvasNodeType.Script) setDialogNodeId(null);
|
||||
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.BatchTable && nodeType !== CanvasNodeType.Audio && nodeType !== CanvasNodeType.MediaConversion) setDialogNodeId(newNode.id);
|
||||
closeConnectionCreateMenu();
|
||||
setConnecting(null);
|
||||
}, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, runtimeStatuses, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]);
|
||||
|
||||
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
|
||||
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
|
||||
const nodeType = type;
|
||||
if (nodeType === CanvasNodeType.Config) {
|
||||
if (workflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, workflowProvider)) return "RunningHub 工作流插件未启用";
|
||||
@@ -426,8 +425,8 @@ export function useCanvasConnectionController({
|
||||
.reverse()
|
||||
.forEach((node) => {
|
||||
const scrollTop = scriptScrollTopById[node.id] || 0;
|
||||
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : undefined;
|
||||
if (node.type === CanvasNodeType.Script && !targetHandleId) return;
|
||||
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : node.type === CanvasNodeType.BatchTable ? batchReferenceHandleAtY(node, world.y, handleRadius) : undefined;
|
||||
if ((node.type === CanvasNodeType.Script || node.type === CanvasNodeType.BatchTable) && !targetHandleId) return;
|
||||
// Ordinary nodes expose one centered input/output port. Only
|
||||
// storyboard rows have a meaningful vertical target position.
|
||||
const targetAnchorRatio = undefined;
|
||||
@@ -473,8 +472,8 @@ export function useCanvasConnectionController({
|
||||
.reverse()
|
||||
.forEach((node) => {
|
||||
const scrollTop = scriptScrollTopById[node.id] || 0;
|
||||
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : undefined;
|
||||
if (node.type === CanvasNodeType.Script && !targetHandleId) return;
|
||||
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : node.type === CanvasNodeType.BatchTable ? batchReferenceHandleAtY(node, world.y, handleRadius) : undefined;
|
||||
if ((node.type === CanvasNodeType.Script || node.type === CanvasNodeType.BatchTable) && !targetHandleId) return;
|
||||
const targetAnchorRatio = undefined;
|
||||
const anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop);
|
||||
const dx = world.x - anchor.x;
|
||||
@@ -646,12 +645,21 @@ export function useCanvasConnectionController({
|
||||
// reset, an old quick-create menu can coexist with the new draft line;
|
||||
// pointerup then sees the stale pending menu and silently aborts.
|
||||
if (pendingConnectionCreateRef.current) closeConnectionCreateMenu();
|
||||
if (connectingParamsRef.current) setConnecting(null);
|
||||
if (batchConnectionPreviewRef.current && handleType === "target") {
|
||||
commitBatchConnection(batchConnectionPreviewRef.current.sourceNodeIds, nodeId, handleId, anchorRatio);
|
||||
clearBatchConnection();
|
||||
return;
|
||||
}
|
||||
// A batch table exposes real target pins inside its content. When a normal
|
||||
// image output is dragged onto one, commit that active connection instead
|
||||
// of starting a new connection session from the target pin.
|
||||
const activeConnection = connectingParamsRef.current;
|
||||
if (activeConnection && handleType === "target" && activeConnection.nodeId !== nodeId) {
|
||||
connectNodes(activeConnection, nodeId, handleId, anchorRatio);
|
||||
setConnecting(null);
|
||||
return;
|
||||
}
|
||||
if (activeConnection) setConnecting(null);
|
||||
if (batchConnectionPreviewRef.current) clearBatchConnection();
|
||||
connectingPointerIdRef.current = event.pointerId;
|
||||
connectingPointerStartRef.current = { x: event.clientX, y: event.clientY };
|
||||
@@ -660,7 +668,21 @@ export function useCanvasConnectionController({
|
||||
setConnectionTargetNodeId(null);
|
||||
setConnectionTargetAnchorRatio(undefined);
|
||||
setSelectedConnectionId(null);
|
||||
}, [clearBatchConnection, closeConnectionCreateMenu, commitBatchConnection, screenToCanvas, setConnecting, setSelectedConnectionId]);
|
||||
}, [clearBatchConnection, closeConnectionCreateMenu, commitBatchConnection, connectNodes, screenToCanvas, setConnecting, setSelectedConnectionId]);
|
||||
|
||||
const handleConnectDrop = useCallback((event: ReactPointerEvent, nodeId: string, handleId?: string) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (batchConnectionPreviewRef.current) {
|
||||
commitBatchConnection(batchConnectionPreviewRef.current.sourceNodeIds, nodeId, handleId);
|
||||
clearBatchConnection();
|
||||
return;
|
||||
}
|
||||
const current = connectingParamsRef.current;
|
||||
if (!current || current.nodeId === nodeId) return;
|
||||
connectNodes(current, nodeId, handleId);
|
||||
setConnecting(null);
|
||||
}, [clearBatchConnection, commitBatchConnection, connectNodes, setConnecting]);
|
||||
|
||||
useEffect(() => {
|
||||
const cancelPendingPointerMove = () => {
|
||||
@@ -799,6 +821,7 @@ export function useCanvasConnectionController({
|
||||
createConnectedNode,
|
||||
getConnectionCreateDisabledReason,
|
||||
handleConnectStart,
|
||||
handleConnectDrop,
|
||||
handleBatchConnectionTargetClick,
|
||||
batchConnectionPreview,
|
||||
beginBatchConnectionMode,
|
||||
|
||||
@@ -56,7 +56,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
);
|
||||
|
||||
const enqueueGenerationBatch = useCallback(
|
||||
(sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: BatchTarget[]) => {
|
||||
(sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: BatchTarget[], options?: { concurrency?: number }) => {
|
||||
const sourceNode = nodesRef.current.find((node) => node.id === sourceNodeId);
|
||||
if (!sourceNode || !targets.length) return;
|
||||
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
|
||||
@@ -73,6 +73,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
mode,
|
||||
status: "queued",
|
||||
items: availableTargets.map((target) => ({ id: nanoid(), ...target, status: "waiting", retryCount: 0 })),
|
||||
concurrency: options?.concurrency ? Math.max(1, Math.min(10, Math.floor(options.concurrency))) : undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -181,14 +182,18 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
for (const sourceNode of currentNodes) {
|
||||
for (const batch of sourceNode.metadata?.generationBatches || []) {
|
||||
if (batch.projectId !== projectId || batch.status === "completed" || batch.status === "cancelled") continue;
|
||||
let batchAvailableSlots = batch.concurrency
|
||||
? Math.max(0, batch.concurrency - batch.items.filter((item) => ["submitting", "queued", "running"].includes(item.status)).length)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
for (const item of batch.items) {
|
||||
if (item.status !== "waiting" || availableSlots <= 0) continue;
|
||||
if (item.status !== "waiting" || availableSlots <= 0 || batchAvailableSlots <= 0) continue;
|
||||
const node = nodeById.get(item.nodeId);
|
||||
if (!node) continue;
|
||||
// 已绑定任务或已有成品的节点交给恢复/对账链路处理,绝不重复提交。
|
||||
if (node.metadata?.taskId || (node.metadata?.status === "success" && node.metadata.content)) continue;
|
||||
candidates.push({ batch, item, node });
|
||||
availableSlots -= 1;
|
||||
batchAvailableSlots -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +150,7 @@ export function useCanvasNodeOperations({
|
||||
if (workflowTitle) node.title = workflowTitle;
|
||||
commitNodes([...nodesRef.current, node]);
|
||||
selectNodes(new Set([node.id]));
|
||||
if (type === CanvasNodeType.Script) setDialogNodeId(null);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.BatchTable && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing && type !== CanvasNodeType.MediaConversion) setDialogNodeId(node.id);
|
||||
}, [commitNodes, defaultDrawingEngine, effectiveConfig.runningHub.enabled, effectiveConfig.runningHub.workflows.length, getCanvasCenter, message, nodesRef, runtimeStatuses, selectNodes, setDialogNodeId, tldrawLicenseKey]);
|
||||
|
||||
const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; theme: CanvasFolderTheme; createdAt: string }) => {
|
||||
|
||||
202
web/src/services/depth-runtime.ts
Normal file
202
web/src/services/depth-runtime.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
|
||||
import type { LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
|
||||
|
||||
const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
||||
const DATA_URL_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=_-]+)$/;
|
||||
|
||||
export type LocalDepthResult = {
|
||||
blob: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
modelId: string;
|
||||
device: "cpu";
|
||||
};
|
||||
|
||||
export type LocalDepthRuntimeStatus = {
|
||||
ok: true;
|
||||
module: "depth-estimation";
|
||||
apiVersion: 1;
|
||||
ready: boolean;
|
||||
configured: boolean;
|
||||
modelCached: boolean;
|
||||
loaded: boolean;
|
||||
device: "cpu";
|
||||
modelId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function runLocalDepthEstimation(sourceUrl: string, signal?: AbortSignal): Promise<LocalDepthResult> {
|
||||
const dataUrl = await sourceToDataUrl(sourceUrl, signal);
|
||||
const value = await requestJson<unknown>("/depth-estimation/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ schemaVersion: 1, operation: "depth", dataUrl }),
|
||||
headers: { "content-type": "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "depth-estimation" || value.apiVersion !== 1 || !isRecord(value.result)) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度结果响应无效");
|
||||
}
|
||||
return parseResult(value.result);
|
||||
}
|
||||
|
||||
export async function readLocalDepthRuntimeStatus(signal?: AbortSignal) {
|
||||
return parseStatus(await requestJson<unknown>("/depth-estimation/status", { method: "GET", signal }));
|
||||
}
|
||||
|
||||
async function sourceToDataUrl(sourceUrl: string, signal?: AbortSignal) {
|
||||
if (sourceUrl.startsWith("data:")) {
|
||||
const match = DATA_URL_PATTERN.exec(sourceUrl);
|
||||
if (!match) throw new LocalRuntimeClientError("depth_input_invalid", "深度转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
const bytes = bytesFromBase64(match[2]!);
|
||||
assertImageSize(bytes);
|
||||
return `data:${match[1]};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(sourceUrl, { credentials: "include", redirect: "error", cache: "no-store", signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new LocalRuntimeClientError("depth_input_invalid", "深度转换输入图片无法读取");
|
||||
}
|
||||
if (!response.ok) throw new LocalRuntimeClientError("depth_input_invalid", "深度转换输入图片无法读取", response.status);
|
||||
const mimeType = imageMime(response.headers.get("content-type") || response.type);
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) {
|
||||
throw new LocalRuntimeClientError("depth_input_invalid", "深度转换图片不能超过 12MB", 400);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
assertImageSize(bytes);
|
||||
return `data:${mimeType};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
|
||||
async function requestJson<T = unknown>(path: string, init: RequestInit = {}) {
|
||||
const { response, body: responseBody } = await requestRuntimeResponse(path, init);
|
||||
const body = responseBody === undefined ? await parseJsonResponse(response) : responseBody;
|
||||
if (!response.ok) {
|
||||
const code = isRecord(body) && typeof body.code === "string" ? body.code : "depth_runtime_unavailable";
|
||||
const message = isRecord(body) && typeof body.message === "string" ? body.message : "本机深度运行时不可用";
|
||||
throw new LocalRuntimeClientError(code, message, response.status);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
async function requestRuntimeResponse(path: string, init: RequestInit = {}) {
|
||||
let refreshed = false;
|
||||
while (true) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (await ensureTransport()).request(path, init);
|
||||
} catch (error) {
|
||||
if (refreshed || !isSessionRefreshError(error)) throw error;
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (!refreshed && response.status === 401) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const body = await parseJsonResponse(response);
|
||||
if (!refreshed && shouldRefreshSession(response, body)) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
return { response, body };
|
||||
}
|
||||
return { response };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTransport(): Promise<LocalRuntimeTransport> {
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") await state.connect();
|
||||
const current = useLocalRuntimeStore.getState();
|
||||
if (current.connection !== "connected") throw new LocalRuntimeClientError("depth_runtime_unavailable", current.error || "本机深度运行时不可用");
|
||||
return getLocalRuntimeSessionClient();
|
||||
}
|
||||
|
||||
async function reconnectTransport() {
|
||||
const client = getLocalRuntimeSessionClient();
|
||||
client.revokeLocalSession();
|
||||
await useLocalRuntimeStore.getState().connect();
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") throw new LocalRuntimeClientError("depth_runtime_unavailable", state.error || "本机深度运行时不可用");
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: Response) {
|
||||
const text = await boundedText(response, MAX_RESPONSE_BYTES);
|
||||
try {
|
||||
return text ? JSON.parse(text) as unknown : {};
|
||||
} catch {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度响应无效", response.status);
|
||||
}
|
||||
}
|
||||
|
||||
async function boundedText(response: Response, limit: number) {
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度响应过大", response.status);
|
||||
const text = await response.text();
|
||||
if (text.length > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度响应过大", response.status);
|
||||
return text;
|
||||
}
|
||||
|
||||
function parseResult(value: Record<string, unknown>): LocalDepthResult {
|
||||
if (value.mimeType !== "image/png" || !isPositiveInteger(value.width) || !isPositiveInteger(value.height) || typeof value.modelId !== "string" || value.device !== "cpu" || typeof value.pngBase64 !== "string") {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度结果无效");
|
||||
}
|
||||
const bytes = bytesFromBase64(value.pngBase64, "runtime_response_invalid", "本机深度结果无效");
|
||||
if (!bytes.length || bytes.length > MAX_RESPONSE_BYTES) throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度结果过大");
|
||||
return { blob: new Blob([bytes], { type: "image/png" }), width: value.width, height: value.height, modelId: value.modelId, device: "cpu" };
|
||||
}
|
||||
|
||||
function parseStatus(value: unknown): LocalDepthRuntimeStatus {
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "depth-estimation" || value.apiVersion !== 1 || typeof value.ready !== "boolean" || typeof value.configured !== "boolean" || typeof value.modelCached !== "boolean" || typeof value.loaded !== "boolean" || value.device !== "cpu" || typeof value.modelId !== "string" || (value.reason !== undefined && typeof value.reason !== "string")) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度状态无效");
|
||||
}
|
||||
return value as LocalDepthRuntimeStatus;
|
||||
}
|
||||
|
||||
function imageMime(value: string): "image/jpeg" | "image/png" | "image/webp" {
|
||||
const mime = value.startsWith("image/") ? value.split(";", 1)[0] : "";
|
||||
if (mime === "image/jpeg" || mime === "image/png" || mime === "image/webp") return mime;
|
||||
throw new LocalRuntimeClientError("depth_input_invalid", "深度转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
}
|
||||
|
||||
function assertImageSize(bytes: Uint8Array) {
|
||||
if (!bytes.byteLength || bytes.byteLength > MAX_IMAGE_BYTES) throw new LocalRuntimeClientError("depth_input_invalid", "深度转换图片不能超过 12MB", 400);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesFromBase64(value: string, code = "depth_input_invalid", message = "深度转换输入图片无效") {
|
||||
let binary: string;
|
||||
try { binary = atob(value.replaceAll("-", "+").replaceAll("_", "/")); } catch { throw new LocalRuntimeClientError(code, message, code === "depth_input_invalid" ? 400 : 0); }
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isSessionRefreshError(error: unknown): error is LocalRuntimeClientError {
|
||||
return error instanceof LocalRuntimeClientError && ["session_required", "session_invalid", "scope_denied"].includes(error.code);
|
||||
}
|
||||
|
||||
function shouldRefreshSession(response: Response, body: unknown) {
|
||||
return response.status === 401 || (response.status === 403 && isRecord(body) && body.code === "scope_denied");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
202
web/src/services/lineart-runtime.ts
Normal file
202
web/src/services/lineart-runtime.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
|
||||
import type { LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
|
||||
|
||||
const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
||||
const DATA_URL_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=_-]+)$/;
|
||||
|
||||
export type LocalLineartResult = {
|
||||
blob: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
modelId: string;
|
||||
device: "cpu";
|
||||
};
|
||||
|
||||
export type LocalLineartRuntimeStatus = {
|
||||
ok: true;
|
||||
module: "lineart-estimation";
|
||||
apiVersion: 1;
|
||||
ready: boolean;
|
||||
configured: boolean;
|
||||
modelCached: boolean;
|
||||
loaded: boolean;
|
||||
device: "cpu";
|
||||
modelId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function runLocalLineartEstimation(sourceUrl: string, signal?: AbortSignal): Promise<LocalLineartResult> {
|
||||
const dataUrl = await sourceToDataUrl(sourceUrl, signal);
|
||||
const value = await requestJson<unknown>("/lineart-estimation/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ schemaVersion: 1, operation: "lineart", dataUrl }),
|
||||
headers: { "content-type": "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "lineart-estimation" || value.apiVersion !== 1 || !isRecord(value.result)) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿结果响应无效");
|
||||
}
|
||||
return parseResult(value.result);
|
||||
}
|
||||
|
||||
export async function readLocalLineartRuntimeStatus(signal?: AbortSignal) {
|
||||
return parseStatus(await requestJson<unknown>("/lineart-estimation/status", { method: "GET", signal }));
|
||||
}
|
||||
|
||||
async function sourceToDataUrl(sourceUrl: string, signal?: AbortSignal) {
|
||||
if (sourceUrl.startsWith("data:")) {
|
||||
const match = DATA_URL_PATTERN.exec(sourceUrl);
|
||||
if (!match) throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
const bytes = bytesFromBase64(match[2]!);
|
||||
assertImageSize(bytes);
|
||||
return `data:${match[1]};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(sourceUrl, { credentials: "include", redirect: "error", cache: "no-store", signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿输入图片无法读取");
|
||||
}
|
||||
if (!response.ok) throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿输入图片无法读取", response.status);
|
||||
const mimeType = imageMime(response.headers.get("content-type") || response.type);
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) {
|
||||
throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿图片不能超过 12MB", 400);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
assertImageSize(bytes);
|
||||
return `data:${mimeType};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
|
||||
async function requestJson<T = unknown>(path: string, init: RequestInit = {}) {
|
||||
const { response, body: responseBody } = await requestRuntimeResponse(path, init);
|
||||
const body = responseBody === undefined ? await parseJsonResponse(response) : responseBody;
|
||||
if (!response.ok) {
|
||||
const code = isRecord(body) && typeof body.code === "string" ? body.code : "lineart_runtime_unavailable";
|
||||
const message = isRecord(body) && typeof body.message === "string" ? body.message : "本机 AI 线稿运行时不可用";
|
||||
throw new LocalRuntimeClientError(code, message, response.status);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
async function requestRuntimeResponse(path: string, init: RequestInit = {}) {
|
||||
let refreshed = false;
|
||||
while (true) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (await ensureTransport()).request(path, init);
|
||||
} catch (error) {
|
||||
if (refreshed || !isSessionRefreshError(error)) throw error;
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (!refreshed && response.status === 401) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const body = await parseJsonResponse(response);
|
||||
if (!refreshed && shouldRefreshSession(response, body)) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
return { response, body };
|
||||
}
|
||||
return { response };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTransport(): Promise<LocalRuntimeTransport> {
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") await state.connect();
|
||||
const current = useLocalRuntimeStore.getState();
|
||||
if (current.connection !== "connected") throw new LocalRuntimeClientError("lineart_runtime_unavailable", current.error || "本机 AI 线稿运行时不可用");
|
||||
return getLocalRuntimeSessionClient();
|
||||
}
|
||||
|
||||
async function reconnectTransport() {
|
||||
const client = getLocalRuntimeSessionClient();
|
||||
client.revokeLocalSession();
|
||||
await useLocalRuntimeStore.getState().connect();
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") throw new LocalRuntimeClientError("lineart_runtime_unavailable", state.error || "本机 AI 线稿运行时不可用");
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: Response) {
|
||||
const text = await boundedText(response, MAX_RESPONSE_BYTES);
|
||||
try {
|
||||
return text ? JSON.parse(text) as unknown : {};
|
||||
} catch {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿响应无效", response.status);
|
||||
}
|
||||
}
|
||||
|
||||
async function boundedText(response: Response, limit: number) {
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿响应过大", response.status);
|
||||
const text = await response.text();
|
||||
if (text.length > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿响应过大", response.status);
|
||||
return text;
|
||||
}
|
||||
|
||||
function parseResult(value: Record<string, unknown>): LocalLineartResult {
|
||||
if (value.mimeType !== "image/png" || !isPositiveInteger(value.width) || !isPositiveInteger(value.height) || typeof value.modelId !== "string" || value.device !== "cpu" || typeof value.pngBase64 !== "string") {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿结果无效");
|
||||
}
|
||||
const bytes = bytesFromBase64(value.pngBase64, "runtime_response_invalid", "本机 AI 线稿结果无效");
|
||||
if (!bytes.length || bytes.length > MAX_RESPONSE_BYTES) throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿结果过大");
|
||||
return { blob: new Blob([bytes], { type: "image/png" }), width: value.width, height: value.height, modelId: value.modelId, device: "cpu" };
|
||||
}
|
||||
|
||||
function parseStatus(value: unknown): LocalLineartRuntimeStatus {
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "lineart-estimation" || value.apiVersion !== 1 || typeof value.ready !== "boolean" || typeof value.configured !== "boolean" || typeof value.modelCached !== "boolean" || typeof value.loaded !== "boolean" || value.device !== "cpu" || typeof value.modelId !== "string" || (value.reason !== undefined && typeof value.reason !== "string")) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿状态无效");
|
||||
}
|
||||
return value as LocalLineartRuntimeStatus;
|
||||
}
|
||||
|
||||
function imageMime(value: string): "image/jpeg" | "image/png" | "image/webp" {
|
||||
const mime = value.startsWith("image/") ? value.split(";", 1)[0] : "";
|
||||
if (mime === "image/jpeg" || mime === "image/png" || mime === "image/webp") return mime;
|
||||
throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
}
|
||||
|
||||
function assertImageSize(bytes: Uint8Array) {
|
||||
if (!bytes.byteLength || bytes.byteLength > MAX_IMAGE_BYTES) throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿图片不能超过 12MB", 400);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesFromBase64(value: string, code = "lineart_input_invalid", message = "AI 线稿输入图片无效") {
|
||||
let binary: string;
|
||||
try { binary = atob(value.replaceAll("-", "+").replaceAll("_", "/")); } catch { throw new LocalRuntimeClientError(code, message, code === "lineart_input_invalid" ? 400 : 0); }
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isSessionRefreshError(error: unknown): error is LocalRuntimeClientError {
|
||||
return error instanceof LocalRuntimeClientError && ["session_required", "session_invalid", "scope_denied"].includes(error.code);
|
||||
}
|
||||
|
||||
function shouldRefreshSession(response: Response, body: unknown) {
|
||||
return response.status === 401 || (response.status === 403 && isRecord(body) && body.code === "scope_denied");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
463
web/src/services/local-runtime-session.ts
Normal file
463
web/src/services/local-runtime-session.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { openDB } from "idb";
|
||||
import { canonicalize } from "json-canonicalize";
|
||||
|
||||
const configuredRuntimeEndpoint = typeof import.meta.env === "object" ? import.meta.env.VITE_FRAMEFIELD_LOCAL_RUNTIME_ENDPOINT : undefined;
|
||||
export const LOCAL_RUNTIME_ENDPOINT = resolveLocalRuntimeEndpoint(configuredRuntimeEndpoint);
|
||||
const KEY_DATABASE = "framefield-local-runtime";
|
||||
const KEY_STORE = "browser-keys";
|
||||
const KEY_RECORD_ID = "default";
|
||||
|
||||
export function resolveLocalRuntimeEndpoint(value: string | undefined) {
|
||||
if (value === undefined) return "http://127.0.0.1:17371";
|
||||
if (!value || value !== value.trim() || value.includes(",")) {
|
||||
throw new Error("Local Runtime endpoint must be one exact loopback origin");
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash || url.origin !== value) {
|
||||
throw new Error("Local Runtime endpoint must be one exact loopback origin");
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
export type RuntimeBrowserKeyRecord = {
|
||||
id: typeof KEY_RECORD_ID;
|
||||
privateKey: CryptoKey;
|
||||
publicKeyJwk: JsonWebKey;
|
||||
keyId: string;
|
||||
registered: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeBrowserKeyStore = {
|
||||
load(): Promise<RuntimeBrowserKeyRecord | undefined>;
|
||||
save(record: RuntimeBrowserKeyRecord): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
};
|
||||
|
||||
export type RuntimePublicSession = {
|
||||
sessionId: string;
|
||||
keyId: string;
|
||||
scopes: string[];
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type LocalRuntimeConnection = { state: "connected"; session: RuntimePublicSession; runtimeVersion: number } | { state: "origin_not_trusted"; runtimeVersion: number };
|
||||
|
||||
type RuntimeInfo = {
|
||||
runtime: "framefield-local-runtime";
|
||||
apiVersion: 2;
|
||||
protocolVersion: "framefield-runtime-session-v1";
|
||||
runtimeInstanceId: string;
|
||||
originTrusted: boolean;
|
||||
};
|
||||
|
||||
type RuntimeChallenge = {
|
||||
state: "challenge";
|
||||
challengeId: string;
|
||||
nonce: string;
|
||||
runtimeInstanceId: string;
|
||||
expiresAt: string;
|
||||
keyId: string;
|
||||
};
|
||||
|
||||
type LocalRuntimeSessionClientOptions = {
|
||||
origin?: string;
|
||||
keyStore?: RuntimeBrowserKeyStore;
|
||||
fetch?: typeof fetch;
|
||||
crypto?: Crypto;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export class LocalRuntimeClientError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly status = 0,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "LocalRuntimeClientError";
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalRuntimeSessionClient {
|
||||
private readonly origin: string;
|
||||
private readonly keyStore: RuntimeBrowserKeyStore;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly cryptoImpl: Crypto;
|
||||
private readonly now: () => number;
|
||||
private session?: RuntimePublicSession;
|
||||
private runtimeInstanceId?: string;
|
||||
private pending?: RuntimeChallenge;
|
||||
private connectPromise?: Promise<LocalRuntimeConnection>;
|
||||
|
||||
constructor(options: LocalRuntimeSessionClientOptions = {}) {
|
||||
this.origin = exactOrigin(options.origin ?? globalThis.location?.origin ?? "");
|
||||
this.keyStore = options.keyStore ?? createIndexedDbRuntimeKeyStore();
|
||||
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
this.cryptoImpl = options.crypto ?? globalThis.crypto;
|
||||
this.now = options.now ?? Date.now;
|
||||
if (!this.cryptoImpl?.subtle || typeof this.cryptoImpl.getRandomValues !== "function") {
|
||||
throw new LocalRuntimeClientError("webcrypto_unavailable", "浏览器不支持本机安全连接");
|
||||
}
|
||||
}
|
||||
|
||||
currentSession() {
|
||||
return this.session ? { ...this.session, scopes: [...this.session.scopes] } : undefined;
|
||||
}
|
||||
|
||||
connect(signal?: AbortSignal) {
|
||||
if (this.session && Date.parse(this.session.expiresAt) > this.now()) {
|
||||
return Promise.resolve<LocalRuntimeConnection>({
|
||||
state: "connected",
|
||||
session: this.currentSession()!,
|
||||
runtimeVersion: 2,
|
||||
});
|
||||
}
|
||||
this.connectPromise ??= this.connectOnce(signal).finally(() => {
|
||||
this.connectPromise = undefined;
|
||||
});
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
async request(pathAndQuery: string, init: RequestInit = {}) {
|
||||
const session = this.session;
|
||||
if (!session || Date.parse(session.expiresAt) <= this.now()) {
|
||||
this.session = undefined;
|
||||
throw new LocalRuntimeClientError("session_required", "本机会话尚未建立", 401);
|
||||
}
|
||||
const url = exactRuntimeUrl(pathAndQuery);
|
||||
const method = String(init.method ?? "GET").toUpperCase();
|
||||
const bodyBytes = requestBodyBytes(init.body);
|
||||
const headers = new Headers(init.headers);
|
||||
if (headers.has("authorization") || headers.has("x-canvas-agent-token")) {
|
||||
throw new LocalRuntimeClientError("legacy_bearer_rejected", "正常本机请求不能携带旧版凭据");
|
||||
}
|
||||
const timestamp = this.now();
|
||||
const requestNonce = randomBase64Url(this.cryptoImpl, 16);
|
||||
const lastEventId = headers.get("last-event-id");
|
||||
const payload = {
|
||||
protocol: "framefield-runtime-request-v1",
|
||||
sessionId: session.sessionId,
|
||||
keyId: session.keyId,
|
||||
method,
|
||||
pathAndQuery: `${url.pathname}${url.search}`,
|
||||
bodySha256: await sha256Base64Url(this.cryptoImpl, bodyBytes),
|
||||
lastEventId,
|
||||
origin: this.origin,
|
||||
endpoint: LOCAL_RUNTIME_ENDPOINT,
|
||||
runtimeInstanceId: this.runtimeInstanceId!,
|
||||
requestNonce,
|
||||
timestamp,
|
||||
sessionExpiresAt: session.expiresAt,
|
||||
};
|
||||
const key = await this.requireKey();
|
||||
const proof = await signP1363(this.cryptoImpl, key.privateKey, canonicalize(payload));
|
||||
headers.set("X-Framefield-Runtime-Session", session.sessionId);
|
||||
headers.set("X-Framefield-Runtime-Timestamp", String(timestamp));
|
||||
headers.set("X-Framefield-Runtime-Nonce", requestNonce);
|
||||
headers.set("X-Framefield-Runtime-Proof", proof);
|
||||
const response = await this.fetchImpl(url, {
|
||||
...init,
|
||||
method,
|
||||
headers,
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 401) this.session = undefined;
|
||||
return response;
|
||||
}
|
||||
|
||||
revokeLocalSession() {
|
||||
this.session = undefined;
|
||||
this.pending = undefined;
|
||||
}
|
||||
|
||||
private async connectOnce(signal?: AbortSignal): Promise<LocalRuntimeConnection> {
|
||||
const info = await this.readInfo(signal);
|
||||
if (!info.originTrusted) {
|
||||
this.session = undefined;
|
||||
this.pending = undefined;
|
||||
return { state: "origin_not_trusted", runtimeVersion: info.apiVersion };
|
||||
}
|
||||
if (this.runtimeInstanceId && this.runtimeInstanceId !== info.runtimeInstanceId) {
|
||||
this.session = undefined;
|
||||
this.pending = undefined;
|
||||
}
|
||||
this.runtimeInstanceId = info.runtimeInstanceId;
|
||||
const key = await this.requireKey();
|
||||
let challenge = this.pending;
|
||||
if (!challenge || Date.parse(challenge.expiresAt) <= this.now()) {
|
||||
challenge = await this.createChallenge(key, signal);
|
||||
this.pending = challenge;
|
||||
}
|
||||
if (challenge.runtimeInstanceId !== info.runtimeInstanceId || challenge.keyId !== key.keyId) {
|
||||
this.pending = undefined;
|
||||
throw new LocalRuntimeClientError("challenge_invalid", "本机会话挑战无效");
|
||||
}
|
||||
const signature = await signP1363(
|
||||
this.cryptoImpl,
|
||||
key.privateKey,
|
||||
canonicalize({
|
||||
protocol: "framefield-runtime-session-v1",
|
||||
challengeId: challenge.challengeId,
|
||||
nonce: challenge.nonce,
|
||||
origin: this.origin,
|
||||
endpoint: LOCAL_RUNTIME_ENDPOINT,
|
||||
runtimeInstanceId: challenge.runtimeInstanceId,
|
||||
expiresAt: challenge.expiresAt,
|
||||
}),
|
||||
);
|
||||
const exchange = await this.jsonFetch("/runtime/session/exchange", { challengeId: challenge.challengeId, signature }, signal, true);
|
||||
if (!exchange.response.ok) throw responseError(exchange.response, exchange.body);
|
||||
const session = parseSession(exchange.body, key.keyId);
|
||||
this.pending = undefined;
|
||||
this.session = session;
|
||||
if (!key.registered) {
|
||||
key.registered = true;
|
||||
await this.keyStore.save(key);
|
||||
}
|
||||
return { state: "connected", session: this.currentSession()!, runtimeVersion: info.apiVersion };
|
||||
}
|
||||
|
||||
private async readInfo(signal?: AbortSignal) {
|
||||
const response = await this.fetchImpl(`${LOCAL_RUNTIME_ENDPOINT}/runtime/info`, {
|
||||
method: "GET",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
signal,
|
||||
});
|
||||
const body = await safeJson(response);
|
||||
if (!response.ok) throw responseError(response, body);
|
||||
return parseInfo(body);
|
||||
}
|
||||
|
||||
private async createChallenge(key: RuntimeBrowserKeyRecord, signal?: AbortSignal) {
|
||||
const payload = key.registered ? { keyId: key.keyId } : { publicKeyJwk: key.publicKeyJwk };
|
||||
const result = await this.jsonFetch("/runtime/session/challenge", payload, signal, true);
|
||||
if (result.response.status === 404 && key.registered) {
|
||||
key.registered = false;
|
||||
await this.keyStore.save(key);
|
||||
const retry = await this.jsonFetch("/runtime/session/challenge", { publicKeyJwk: key.publicKeyJwk }, signal, true);
|
||||
if (!retry.response.ok) throw responseError(retry.response, retry.body);
|
||||
return parseChallenge(retry.body, key.keyId);
|
||||
}
|
||||
if (!result.response.ok) throw responseError(result.response, result.body);
|
||||
return parseChallenge(result.body, key.keyId);
|
||||
}
|
||||
|
||||
private async jsonFetch(path: string, body: unknown, signal?: AbortSignal, allowError = false) {
|
||||
const response = await this.fetchImpl(`${LOCAL_RUNTIME_ENDPOINT}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
signal,
|
||||
});
|
||||
const parsed = await safeJson(response);
|
||||
if (!allowError && !response.ok) throw responseError(response, parsed);
|
||||
return { response, body: parsed };
|
||||
}
|
||||
|
||||
private async requireKey() {
|
||||
const existing = await this.keyStore.load();
|
||||
if (existing) {
|
||||
validateKeyRecord(existing);
|
||||
return existing;
|
||||
}
|
||||
const pair = await this.cryptoImpl.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
|
||||
const publicKeyJwk = await this.cryptoImpl.subtle.exportKey("jwk", pair.publicKey);
|
||||
validatePublicJwk(publicKeyJwk);
|
||||
const keyId = await browserKeyId(this.cryptoImpl, publicKeyJwk);
|
||||
const record: RuntimeBrowserKeyRecord = {
|
||||
id: KEY_RECORD_ID,
|
||||
privateKey: pair.privateKey,
|
||||
publicKeyJwk,
|
||||
keyId,
|
||||
registered: false,
|
||||
};
|
||||
await this.keyStore.save(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
export function createIndexedDbRuntimeKeyStore(): RuntimeBrowserKeyStore {
|
||||
const database = openDB(KEY_DATABASE, 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(KEY_STORE)) db.createObjectStore(KEY_STORE);
|
||||
},
|
||||
});
|
||||
return {
|
||||
async load() {
|
||||
return (await (await database).get(KEY_STORE, KEY_RECORD_ID)) as RuntimeBrowserKeyRecord | undefined;
|
||||
},
|
||||
async save(record) {
|
||||
await (await database).put(KEY_STORE, record, KEY_RECORD_ID);
|
||||
},
|
||||
async clear() {
|
||||
await (await database).delete(KEY_STORE, KEY_RECORD_ID);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function browserKeyId(cryptoImpl: Crypto, jwk: JsonWebKey) {
|
||||
return await sha256Base64Url(
|
||||
cryptoImpl,
|
||||
new TextEncoder().encode(
|
||||
canonicalize({
|
||||
crv: jwk.crv,
|
||||
kty: jwk.kty,
|
||||
x: jwk.x,
|
||||
y: jwk.y,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function signP1363(cryptoImpl: Crypto, privateKey: CryptoKey, payload: string) {
|
||||
const signature = new Uint8Array(await cryptoImpl.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, new TextEncoder().encode(payload)));
|
||||
if (signature.byteLength !== 64) {
|
||||
throw new LocalRuntimeClientError("signature_invalid", "浏览器签名格式不受支持");
|
||||
}
|
||||
return base64Url(signature);
|
||||
}
|
||||
|
||||
async function sha256Base64Url(cryptoImpl: Crypto, value: Uint8Array) {
|
||||
const bytes = new Uint8Array(value.byteLength);
|
||||
bytes.set(value);
|
||||
return base64Url(new Uint8Array(await cryptoImpl.subtle.digest("SHA-256", bytes.buffer)));
|
||||
}
|
||||
|
||||
function randomBase64Url(cryptoImpl: Crypto, bytes: number) {
|
||||
return base64Url(cryptoImpl.getRandomValues(new Uint8Array(bytes)));
|
||||
}
|
||||
|
||||
function base64Url(value: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of value) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function requestBodyBytes(body: BodyInit | null | undefined) {
|
||||
if (body === undefined || body === null) return new Uint8Array();
|
||||
if (typeof body === "string") return new TextEncoder().encode(body);
|
||||
if (body instanceof Uint8Array) return body;
|
||||
throw new LocalRuntimeClientError("request_body_invalid", "本机请求体格式无效");
|
||||
}
|
||||
|
||||
function exactRuntimeUrl(pathAndQuery: string) {
|
||||
if (!pathAndQuery.startsWith("/") || pathAndQuery.includes("#")) {
|
||||
throw new LocalRuntimeClientError("request_target_invalid", "本机请求路径无效");
|
||||
}
|
||||
const url = new URL(pathAndQuery, LOCAL_RUNTIME_ENDPOINT);
|
||||
if (url.origin !== LOCAL_RUNTIME_ENDPOINT || `${url.pathname}${url.search}` !== pathAndQuery) {
|
||||
throw new LocalRuntimeClientError("request_target_invalid", "本机请求路径无效");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function exactOrigin(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.pathname !== "/" || url.search || url.hash || url.origin !== value) throw new Error();
|
||||
return url.origin;
|
||||
} catch {
|
||||
throw new LocalRuntimeClientError("origin_invalid", "当前页面来源无效");
|
||||
}
|
||||
}
|
||||
|
||||
function validatePublicJwk(jwk: JsonWebKey) {
|
||||
if (
|
||||
jwk.kty !== "EC" ||
|
||||
jwk.crv !== "P-256" ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(jwk.x ?? "") ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(jwk.y ?? "") ||
|
||||
jwk.d !== undefined ||
|
||||
jwk.ext !== true ||
|
||||
!Array.isArray(jwk.key_ops) ||
|
||||
jwk.key_ops.length !== 1 ||
|
||||
jwk.key_ops[0] !== "verify"
|
||||
) {
|
||||
throw new LocalRuntimeClientError("browser_key_invalid", "浏览器公钥无效");
|
||||
}
|
||||
}
|
||||
|
||||
function validateKeyRecord(record: RuntimeBrowserKeyRecord) {
|
||||
validatePublicJwk(record.publicKeyJwk);
|
||||
if (
|
||||
record.id !== KEY_RECORD_ID ||
|
||||
record.privateKey.type !== "private" ||
|
||||
record.privateKey.extractable ||
|
||||
record.privateKey.algorithm.name !== "ECDSA" ||
|
||||
record.privateKey.usages.length !== 1 ||
|
||||
record.privateKey.usages[0] !== "sign" ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(record.keyId)
|
||||
) {
|
||||
throw new LocalRuntimeClientError("browser_key_invalid", "浏览器密钥无效");
|
||||
}
|
||||
}
|
||||
|
||||
function parseInfo(value: Record<string, unknown>): RuntimeInfo {
|
||||
assertExactKeys(value, ["runtime", "apiVersion", "protocolVersion", "runtimeInstanceId", "originTrusted"]);
|
||||
if (
|
||||
value.runtime !== "framefield-local-runtime" ||
|
||||
value.apiVersion !== 2 ||
|
||||
value.protocolVersion !== "framefield-runtime-session-v1" ||
|
||||
typeof value.runtimeInstanceId !== "string" ||
|
||||
!/^[A-Za-z0-9_-]{8,160}$/.test(value.runtimeInstanceId) ||
|
||||
typeof value.originTrusted !== "boolean"
|
||||
) {
|
||||
throw new LocalRuntimeClientError("runtime_incompatible", "本机运行时版本不兼容");
|
||||
}
|
||||
return value as RuntimeInfo;
|
||||
}
|
||||
|
||||
function parseChallenge(value: Record<string, unknown>, keyId: string): RuntimeChallenge {
|
||||
assertExactKeys(value, ["state", "challengeId", "nonce", "runtimeInstanceId", "expiresAt", "keyId"]);
|
||||
if (value.state !== "challenge" || typeof value.challengeId !== "string" || typeof value.nonce !== "string" || typeof value.runtimeInstanceId !== "string" || typeof value.expiresAt !== "string" || value.keyId !== keyId) {
|
||||
throw new LocalRuntimeClientError("challenge_invalid", "本机会话挑战无效");
|
||||
}
|
||||
return value as RuntimeChallenge;
|
||||
}
|
||||
|
||||
function parseSession(value: Record<string, unknown>, keyId: string): RuntimePublicSession {
|
||||
assertExactKeys(value, ["sessionId", "keyId", "scopes", "expiresAt"]);
|
||||
if (typeof value.sessionId !== "string" || value.sessionId.length < 16 || value.keyId !== keyId || !Array.isArray(value.scopes) || value.scopes.some((scope) => typeof scope !== "string") || typeof value.expiresAt !== "string") {
|
||||
throw new LocalRuntimeClientError("session_invalid", "本机会话响应无效");
|
||||
}
|
||||
return value as RuntimePublicSession;
|
||||
}
|
||||
|
||||
function assertExactKeys(value: Record<string, unknown>, keys: readonly string[]) {
|
||||
const allowed = new Set(keys);
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeJson(response: Response) {
|
||||
try {
|
||||
const value = (await response.json()) as unknown;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
||||
return value as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效", response.status);
|
||||
}
|
||||
}
|
||||
|
||||
function responseError(response: Response, body: Record<string, unknown>) {
|
||||
const code = typeof body.code === "string" && body.code in PUBLIC_RUNTIME_ERROR_MESSAGES ? body.code : "runtime_request_failed";
|
||||
return new LocalRuntimeClientError(code, PUBLIC_RUNTIME_ERROR_MESSAGES[code], response.status);
|
||||
}
|
||||
|
||||
const PUBLIC_RUNTIME_ERROR_MESSAGES: Record<string, string> = {
|
||||
challenge_invalid: "本机会话挑战无效",
|
||||
invalid_public_key: "浏览器公钥无效",
|
||||
origin_not_trusted: "当前页面来源未获本机授权",
|
||||
rate_limited: "本机连接请求过多,请稍后重试",
|
||||
registration_not_found: "浏览器密钥尚未注册",
|
||||
runtime_internal_error: "本机运行时请求失败",
|
||||
runtime_request_failed: "本机运行时请求失败",
|
||||
};
|
||||
153
web/src/services/local-runtime.ts
Normal file
153
web/src/services/local-runtime.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
|
||||
|
||||
export type LocalRuntimeModuleId = "canvas-agent" | "dreamina" | "portrait-clearance" | "depth-estimation" | "lineart-estimation" | "pose-estimation";
|
||||
export type LocalRuntimeScope = "runtime:status" | "runtime:revoke" | "canvas:connect" | "dreamina:status" | "dreamina:login" | "dreamina:logout" | "dreamina:run" | "dreamina:models" | "dreamina:generate" | "portrait:status" | "portrait:model" | "portrait:run" | "portrait:read" | "depth:status" | "depth:run" | "lineart:status" | "lineart:run" | "pose:status" | "pose:run";
|
||||
|
||||
export type LocalRuntimeModuleDescriptor = {
|
||||
id: LocalRuntimeModuleId;
|
||||
displayName: string;
|
||||
apiVersion: 1;
|
||||
scopes: LocalRuntimeScope[];
|
||||
};
|
||||
|
||||
export type LocalRuntimeStatus = {
|
||||
runtime: {
|
||||
id: "framefield-local-runtime";
|
||||
version: string;
|
||||
apiVersion: 2;
|
||||
};
|
||||
modules: LocalRuntimeModuleDescriptor[];
|
||||
};
|
||||
|
||||
export type LocalRuntimeTransport = {
|
||||
request(pathAndQuery: string, init?: RequestInit): Promise<Response>;
|
||||
};
|
||||
|
||||
const MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
const MODULE_SCOPES: Record<LocalRuntimeModuleId, ReadonlySet<LocalRuntimeScope>> = {
|
||||
"canvas-agent": new Set(["canvas:connect"]),
|
||||
dreamina: new Set(["dreamina:status", "dreamina:login", "dreamina:logout", "dreamina:run", "dreamina:models", "dreamina:generate"]),
|
||||
"portrait-clearance": new Set(["portrait:status", "portrait:model", "portrait:run", "portrait:read"]),
|
||||
"depth-estimation": new Set(["depth:status", "depth:run"]),
|
||||
"lineart-estimation": new Set(["lineart:status", "lineart:run"]),
|
||||
"pose-estimation": new Set(["pose:status", "pose:run"]),
|
||||
};
|
||||
|
||||
export async function readLocalRuntimeStatus(client: LocalRuntimeTransport, signal?: AbortSignal): Promise<LocalRuntimeStatus> {
|
||||
const response = await client.request("/runtime/status", { method: "GET", signal });
|
||||
if (response.redirected || response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
|
||||
throw invalidResponse(response.status);
|
||||
}
|
||||
const body = await readBoundedJson(response);
|
||||
if (!response.ok) {
|
||||
const code = response.status === 401 ? "session_required" : response.status === 403 ? "scope_denied" : "runtime_request_failed";
|
||||
throw new LocalRuntimeClientError(code, publicRequestError(code), response.status);
|
||||
}
|
||||
return parseRuntimeStatus(body, response.status);
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
|
||||
throw invalidResponse(response.status);
|
||||
}
|
||||
if (!response.body) throw invalidResponse(response.status);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const item = await reader.read();
|
||||
if (item.done) break;
|
||||
total += item.value.byteLength;
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw invalidResponse(response.status);
|
||||
}
|
||||
chunks.push(item.value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
||||
} catch {
|
||||
throw invalidResponse(response.status);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRuntimeStatus(value: unknown, status: number): LocalRuntimeStatus {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ["modules", "ok", "runtime"]) || value.ok !== true || !isRecord(value.runtime) || !Array.isArray(value.modules)) {
|
||||
throw invalidResponse(status);
|
||||
}
|
||||
const runtime = value.runtime;
|
||||
if (!hasExactKeys(runtime, ["apiVersion", "id", "version"]) || runtime.id !== "framefield-local-runtime" || runtime.apiVersion !== 2 || typeof runtime.version !== "string" || !/^[A-Za-z0-9._+:-]{1,80}$/.test(runtime.version)) {
|
||||
throw invalidResponse(status);
|
||||
}
|
||||
|
||||
const ids = new Set<LocalRuntimeModuleId>();
|
||||
const modules = value.modules.map((item) => {
|
||||
if (
|
||||
!isRecord(item) ||
|
||||
!hasExactKeys(item, ["apiVersion", "displayName", "id", "scopes"]) ||
|
||||
!isModuleId(item.id) ||
|
||||
ids.has(item.id) ||
|
||||
item.apiVersion !== 1 ||
|
||||
typeof item.displayName !== "string" ||
|
||||
item.displayName.trim() !== item.displayName ||
|
||||
item.displayName.length < 1 ||
|
||||
item.displayName.length > 80 ||
|
||||
!Array.isArray(item.scopes)
|
||||
) {
|
||||
throw invalidResponse(status);
|
||||
}
|
||||
const scopes = item.scopes as unknown[];
|
||||
const allowedScopes = MODULE_SCOPES[item.id];
|
||||
if (scopes.some((scope) => typeof scope !== "string" || !allowedScopes.has(scope as LocalRuntimeScope)) || new Set(scopes).size !== scopes.length) {
|
||||
throw invalidResponse(status);
|
||||
}
|
||||
ids.add(item.id);
|
||||
return {
|
||||
id: item.id,
|
||||
displayName: item.displayName,
|
||||
apiVersion: 1 as const,
|
||||
scopes: [...scopes] as LocalRuntimeScope[],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
runtime: { id: "framefield-local-runtime", version: runtime.version, apiVersion: 2 },
|
||||
modules,
|
||||
};
|
||||
}
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, expected: string[]) {
|
||||
const keys = Object.keys(value).sort();
|
||||
return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isModuleId(value: unknown): value is LocalRuntimeModuleId {
|
||||
return value === "canvas-agent" || value === "dreamina" || value === "portrait-clearance" || value === "depth-estimation" || value === "lineart-estimation" || value === "pose-estimation";
|
||||
}
|
||||
|
||||
function invalidResponse(status: number) {
|
||||
return new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效", status);
|
||||
}
|
||||
|
||||
function publicRequestError(code: string) {
|
||||
if (code === "session_required") return "本机会话已失效,请重新连接";
|
||||
if (code === "scope_denied") return "本机会话权限不足";
|
||||
return "本机运行时请求失败";
|
||||
}
|
||||
213
web/src/services/pose-runtime.ts
Normal file
213
web/src/services/pose-runtime.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
|
||||
import type { LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
|
||||
|
||||
const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
||||
const DATA_URL_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=_-]+)$/;
|
||||
|
||||
export type LocalPoseResult = {
|
||||
blob: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
modelId: string;
|
||||
device: "cpu";
|
||||
backend: "openpose-body";
|
||||
personCount: number;
|
||||
people?: unknown[];
|
||||
};
|
||||
|
||||
export type LocalPoseRuntimeStatus = {
|
||||
ok: true;
|
||||
module: "pose-estimation";
|
||||
apiVersion: 1;
|
||||
ready: boolean;
|
||||
configured: boolean;
|
||||
modelCached: boolean;
|
||||
loaded: boolean;
|
||||
device: "cpu";
|
||||
modelId: string;
|
||||
backend: "openpose-body";
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function runLocalPoseEstimation(sourceUrl: string, signal?: AbortSignal): Promise<LocalPoseResult> {
|
||||
const dataUrl = await sourceToDataUrl(sourceUrl, signal);
|
||||
const value = await requestJson<unknown>("/pose-estimation/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ schemaVersion: 1, operation: "pose", dataUrl }),
|
||||
headers: { "content-type": "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "pose-estimation" || value.apiVersion !== 1 || !isRecord(value.result)) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态结果响应无效");
|
||||
}
|
||||
return parseResult(value.result);
|
||||
}
|
||||
|
||||
export async function readLocalPoseRuntimeStatus(signal?: AbortSignal) {
|
||||
return parseStatus(await requestJson<unknown>("/pose-estimation/status", { method: "GET", signal }));
|
||||
}
|
||||
|
||||
async function sourceToDataUrl(sourceUrl: string, signal?: AbortSignal) {
|
||||
if (sourceUrl.startsWith("data:")) {
|
||||
const match = DATA_URL_PATTERN.exec(sourceUrl);
|
||||
if (!match) throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
const bytes = bytesFromBase64(match[2]!);
|
||||
assertImageSize(bytes);
|
||||
return `data:${match[1]};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(sourceUrl, { credentials: "include", redirect: "error", cache: "no-store", signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换输入图片无法读取");
|
||||
}
|
||||
if (!response.ok) throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换输入图片无法读取", response.status);
|
||||
const mimeType = imageMime(response.headers.get("content-type") || response.type);
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换图片不能超过 12MB", 400);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
assertImageSize(bytes);
|
||||
return `data:${mimeType};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
|
||||
async function requestJson<T = unknown>(path: string, init: RequestInit = {}) {
|
||||
const { response, body: responseBody } = await requestRuntimeResponse(path, init);
|
||||
const body = responseBody === undefined ? await parseJsonResponse(response) : responseBody;
|
||||
if (!response.ok) {
|
||||
const code = isRecord(body) && typeof body.code === "string" ? body.code : "pose_runtime_unavailable";
|
||||
const message = isRecord(body) && typeof body.message === "string" ? body.message : "本机姿态运行时不可用";
|
||||
throw new LocalRuntimeClientError(code, message, response.status);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
async function requestRuntimeResponse(path: string, init: RequestInit = {}) {
|
||||
let refreshed = false;
|
||||
while (true) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (await ensureTransport()).request(path, init);
|
||||
} catch (error) {
|
||||
if (refreshed || !isSessionRefreshError(error)) throw error;
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (!refreshed && response.status === 401) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const body = await parseJsonResponse(response);
|
||||
if (!refreshed && shouldRefreshSession(response, body)) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
return { response, body };
|
||||
}
|
||||
return { response };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTransport(): Promise<LocalRuntimeTransport> {
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") await state.connect();
|
||||
const current = useLocalRuntimeStore.getState();
|
||||
if (current.connection !== "connected") throw new LocalRuntimeClientError("pose_runtime_unavailable", current.error || "本机姿态运行时不可用");
|
||||
return getLocalRuntimeSessionClient();
|
||||
}
|
||||
|
||||
async function reconnectTransport() {
|
||||
const client = getLocalRuntimeSessionClient();
|
||||
client.revokeLocalSession();
|
||||
await useLocalRuntimeStore.getState().connect();
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") throw new LocalRuntimeClientError("pose_runtime_unavailable", state.error || "本机姿态运行时不可用");
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: Response) {
|
||||
const text = await boundedText(response, MAX_RESPONSE_BYTES);
|
||||
try { return text ? JSON.parse(text) as unknown : {}; } catch { throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态响应无效", response.status); }
|
||||
}
|
||||
|
||||
async function boundedText(response: Response, limit: number) {
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态响应过大", response.status);
|
||||
const text = await response.text();
|
||||
if (text.length > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态响应过大", response.status);
|
||||
return text;
|
||||
}
|
||||
|
||||
function parseResult(value: Record<string, unknown>): LocalPoseResult {
|
||||
if (value.mimeType !== "image/png" || !isPositiveInteger(value.width) || !isPositiveInteger(value.height) || typeof value.modelId !== "string" || value.device !== "cpu" || value.backend !== "openpose-body" || !isNonNegativeInteger(value.personCount) || typeof value.pngBase64 !== "string") {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态结果无效");
|
||||
}
|
||||
const bytes = bytesFromBase64(value.pngBase64, "runtime_response_invalid", "本机姿态结果无效");
|
||||
if (!bytes.length || bytes.length > MAX_RESPONSE_BYTES) throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态结果过大");
|
||||
return {
|
||||
blob: new Blob([bytes], { type: "image/png" }),
|
||||
width: value.width,
|
||||
height: value.height,
|
||||
modelId: value.modelId,
|
||||
device: "cpu",
|
||||
backend: "openpose-body",
|
||||
personCount: value.personCount,
|
||||
people: Array.isArray(value.people) ? value.people : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStatus(value: unknown): LocalPoseRuntimeStatus {
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "pose-estimation" || value.apiVersion !== 1 || typeof value.ready !== "boolean" || typeof value.configured !== "boolean" || typeof value.modelCached !== "boolean" || typeof value.loaded !== "boolean" || value.device !== "cpu" || typeof value.modelId !== "string" || value.backend !== "openpose-body" || (value.reason !== undefined && typeof value.reason !== "string")) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态状态无效");
|
||||
}
|
||||
return value as LocalPoseRuntimeStatus;
|
||||
}
|
||||
|
||||
function imageMime(value: string): "image/jpeg" | "image/png" | "image/webp" {
|
||||
const mime = value.startsWith("image/") ? value.split(";", 1)[0] : "";
|
||||
if (mime === "image/jpeg" || mime === "image/png" || mime === "image/webp") return mime;
|
||||
throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
}
|
||||
|
||||
function assertImageSize(bytes: Uint8Array) {
|
||||
if (!bytes.byteLength || bytes.byteLength > MAX_IMAGE_BYTES) throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换图片不能超过 12MB", 400);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesFromBase64(value: string, code = "pose_input_invalid", message = "姿态转换输入图片无效") {
|
||||
let binary: string;
|
||||
try { binary = atob(value.replaceAll("-", "+").replaceAll("_", "/")); } catch { throw new LocalRuntimeClientError(code, message, code === "pose_input_invalid" ? 400 : 0); }
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isSessionRefreshError(error: unknown): error is LocalRuntimeClientError {
|
||||
return error instanceof LocalRuntimeClientError && ["session_required", "session_invalid", "scope_denied"].includes(error.code);
|
||||
}
|
||||
|
||||
function shouldRefreshSession(response: Response, body: unknown) {
|
||||
return response.status === 401 || (response.status === 403 && isRecord(body) && body.code === "scope_denied");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
180
web/src/stores/use-local-runtime-store.ts
Normal file
180
web/src/stores/use-local-runtime-store.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { readLocalRuntimeStatus, type LocalRuntimeModuleDescriptor, type LocalRuntimeStatus, type LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
import { LocalRuntimeClientError, LocalRuntimeSessionClient, type LocalRuntimeConnection } from "@/services/local-runtime-session";
|
||||
|
||||
export type LocalRuntimeConnectionState = "idle" | "connecting" | "connected" | "origin_not_trusted" | "unreachable" | "incompatible" | "runtime_error";
|
||||
|
||||
type LocalRuntimeSessionTransport = LocalRuntimeTransport & {
|
||||
connect(signal?: AbortSignal): Promise<LocalRuntimeConnection>;
|
||||
revokeLocalSession?(): void;
|
||||
};
|
||||
|
||||
type LocalRuntimeStore = {
|
||||
connection: LocalRuntimeConnectionState;
|
||||
connecting: boolean;
|
||||
runtime: LocalRuntimeStatus["runtime"] | null;
|
||||
modules: LocalRuntimeModuleDescriptor[];
|
||||
error: string;
|
||||
connect(signal?: AbortSignal): Promise<void>;
|
||||
};
|
||||
|
||||
type LocalRuntimeStoreDependencies = {
|
||||
client?: LocalRuntimeSessionTransport;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 8_000;
|
||||
let browserClient: LocalRuntimeSessionClient | undefined;
|
||||
|
||||
export function getLocalRuntimeSessionClient() {
|
||||
browserClient ??= new LocalRuntimeSessionClient();
|
||||
return browserClient;
|
||||
}
|
||||
|
||||
export function createLocalRuntimeStore(dependencies: LocalRuntimeStoreDependencies = {}) {
|
||||
const client = () => dependencies.client ?? getLocalRuntimeSessionClient();
|
||||
let revision = 0;
|
||||
let activeController: AbortController | undefined;
|
||||
|
||||
return create<LocalRuntimeStore>((set, get) => ({
|
||||
connection: "idle",
|
||||
connecting: false,
|
||||
runtime: null,
|
||||
modules: [],
|
||||
error: "",
|
||||
connect: async (signal) => {
|
||||
if (signal?.aborted) return;
|
||||
const requestRevision = ++revision;
|
||||
activeController?.abort();
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
let timedOut = false;
|
||||
const cancel = () => controller.abort();
|
||||
if (signal?.aborted) controller.abort();
|
||||
else signal?.addEventListener("abort", cancel, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
},
|
||||
Math.max(1, dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const previous = get();
|
||||
set({
|
||||
connection: previous.connection === "connected" ? "connected" : "connecting",
|
||||
connecting: true,
|
||||
error: "",
|
||||
});
|
||||
try {
|
||||
const result = await client().connect(controller.signal);
|
||||
if (requestRevision !== revision) return;
|
||||
if (result.state === "origin_not_trusted") {
|
||||
set({
|
||||
connection: "origin_not_trusted",
|
||||
connecting: false,
|
||||
runtime: null,
|
||||
modules: [],
|
||||
error: "本机连接需要重新建立",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let status: LocalRuntimeStatus;
|
||||
try {
|
||||
status = await readLocalRuntimeStatus(client(), controller.signal);
|
||||
} catch (error) {
|
||||
if (!(error instanceof LocalRuntimeClientError) || (error.code !== "session_required" && error.code !== "scope_denied")) throw error;
|
||||
client().revokeLocalSession?.();
|
||||
const reconnected = await client().connect(controller.signal);
|
||||
if (reconnected.state !== "connected") throw error;
|
||||
status = await readLocalRuntimeStatus(client(), controller.signal);
|
||||
}
|
||||
if (requestRevision !== revision) return;
|
||||
set({
|
||||
connection: "connected",
|
||||
connecting: false,
|
||||
runtime: status.runtime,
|
||||
modules: status.modules,
|
||||
error: "",
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestRevision !== revision) return;
|
||||
if (signal?.aborted || (controller.signal.aborted && !timedOut)) {
|
||||
set({
|
||||
connection: previous.connection,
|
||||
connecting: false,
|
||||
runtime: previous.runtime,
|
||||
modules: previous.modules,
|
||||
error: previous.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const failure = connectionFailure(error, timedOut);
|
||||
if (get().connection === "connected") {
|
||||
set({ connecting: false, error: failure.error });
|
||||
} else {
|
||||
set({
|
||||
connection: failure.connection,
|
||||
connecting: false,
|
||||
runtime: null,
|
||||
modules: [],
|
||||
error: failure.error,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", cancel);
|
||||
if (activeController === controller) activeController = undefined;
|
||||
}
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export const useLocalRuntimeStore = createLocalRuntimeStore();
|
||||
|
||||
export function startLocalRuntimeBootstrap(
|
||||
connect: (signal?: AbortSignal) => Promise<void>,
|
||||
schedule: (run: () => void) => () => void = (run) => {
|
||||
const timer = window.setTimeout(run, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
let started = false;
|
||||
const cancelScheduled = schedule(() => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
void connect(controller.signal);
|
||||
});
|
||||
return () => {
|
||||
cancelScheduled();
|
||||
controller.abort();
|
||||
};
|
||||
}
|
||||
|
||||
export function useLocalRuntimeBootstrap(enabled = true) {
|
||||
const connect = useLocalRuntimeStore((state) => state.connect);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
return startLocalRuntimeBootstrap(connect);
|
||||
}, [connect, enabled]);
|
||||
}
|
||||
|
||||
function connectionFailure(error: unknown, timedOut: boolean) {
|
||||
if (timedOut) {
|
||||
return { connection: "unreachable" as const, error: "本机服务连接超时" };
|
||||
}
|
||||
if (error instanceof LocalRuntimeClientError) {
|
||||
if (error.code === "origin_not_trusted") {
|
||||
return { connection: "origin_not_trusted" as const, error: "本机连接需要重新建立" };
|
||||
}
|
||||
if (["browser_key_invalid", "challenge_invalid", "origin_invalid", "runtime_incompatible", "runtime_response_invalid", "signature_invalid", "webcrypto_unavailable"].includes(error.code)) {
|
||||
return { connection: "incompatible" as const, error: error.message };
|
||||
}
|
||||
return { connection: "runtime_error" as const, error: error.message };
|
||||
}
|
||||
return { connection: "unreachable" as const, error: "未发现本机服务,请确认本机服务已启用" };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CanvasColorGrade } from "@/lib/canvas/canvas-color-grade";
|
||||
import type { MediaConversionNodeState } from "@/lib/media-conversion/contracts";
|
||||
import type { AssetCategory } from "@/lib/asset-category";
|
||||
import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-texture";
|
||||
import type { StyleExecutionPlan } from "@/lib/canvas/style-profile";
|
||||
@@ -34,6 +35,8 @@ export enum CanvasNodeType {
|
||||
Compare = "compare",
|
||||
Chart = "chart",
|
||||
ColorGrade = "colorgrade",
|
||||
MediaConversion = "media-conversion",
|
||||
BatchTable = "batch-table",
|
||||
}
|
||||
|
||||
/** Runtime IDs contributed by plugins share the persisted node type field. */
|
||||
@@ -54,7 +57,7 @@ export type StoryboardShotDuration = "auto" | "5" | "10" | "15" | "30";
|
||||
export type StoryboardShotCount = "auto" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10";
|
||||
export type StoryboardVideoInputMode = "direct" | "keyframe";
|
||||
export type CanvasGenerationMode = "text" | "image" | "video" | "audio";
|
||||
export type CanvasGenerationBatchMode = "storyboard_image" | "storyboard_video" | "action_board";
|
||||
export type CanvasGenerationBatchMode = "storyboard_image" | "storyboard_video" | "action_board" | "batch_image";
|
||||
export type CanvasGenerationBatchStatus = "queued" | "running" | "partial_failed" | "completed" | "cancelled";
|
||||
export type CanvasGenerationBatchItemStatus = "waiting" | "submitting" | "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
export type CanvasImageGenerationType = "generation" | "edit";
|
||||
@@ -155,10 +158,16 @@ export type CanvasGenerationBatch = {
|
||||
mode: CanvasGenerationBatchMode;
|
||||
status: CanvasGenerationBatchStatus;
|
||||
items: CanvasGenerationBatchItem[];
|
||||
concurrency?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CanvasBatchOperation = "try_on" | "creative";
|
||||
export type CanvasBatchRow = { id: string; enabled: boolean; inputNodeIds: string[]; prompt: string; outputNodeId?: string };
|
||||
export type CanvasBatchReferenceColumn = { id: string; label: string };
|
||||
export type CanvasBatchTableData = { operation: CanvasBatchOperation; concurrency: number; referenceColumns?: CanvasBatchReferenceColumn[]; rows: CanvasBatchRow[] };
|
||||
|
||||
export type CanvasSkillSnapshot = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -361,6 +370,7 @@ export type CanvasNodeMetadata = {
|
||||
chartKind?: "bar" | "line";
|
||||
/** 调色节点的参数;缺省视为未调色。 */
|
||||
colorGrade?: CanvasColorGrade;
|
||||
mediaConversion?: MediaConversionNodeState;
|
||||
/** 用户手动拉伸过尺寸;图片按真实比例自动适配时避让它。 */
|
||||
manualSize?: boolean;
|
||||
storyboard?: StoryboardData;
|
||||
@@ -369,6 +379,11 @@ export type CanvasNodeMetadata = {
|
||||
storyboardVideoInputMode?: StoryboardVideoInputMode;
|
||||
storyboardComposerHeight?: number;
|
||||
generationBatches?: CanvasGenerationBatch[];
|
||||
batchTable?: CanvasBatchTableData;
|
||||
batchSourceNodeId?: string;
|
||||
batchRowId?: string;
|
||||
batchOperation?: CanvasBatchOperation;
|
||||
batchInputNodeIds?: string[];
|
||||
frame?: {
|
||||
collapsed: boolean;
|
||||
expandedWidth: number;
|
||||
@@ -454,7 +469,7 @@ export type CanvasConnection = {
|
||||
toHandleId?: string;
|
||||
fromAnchorRatio?: number;
|
||||
toAnchorRatio?: number;
|
||||
relation?: "storyboard-output" | "storyboard-asset-reference";
|
||||
relation?: "storyboard-output" | "storyboard-asset-reference" | "batch-output";
|
||||
storyboardRowId?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
|
||||
import { canvasConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { defaultConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
@@ -13,11 +14,17 @@ const nodes: CanvasNodeData[] = [
|
||||
{ id: "script", type: CanvasNodeType.Script, title: "分镜脚本", position: { x: 520, y: 0 }, width: 640, height: 520, metadata: { storyboard: { rows: [{ id: "row-1", shotNumber: 1, durationSeconds: 6, plotDescription: "", dialogue: "", characters: [], narrativeIntent: "", viewerPOV: "", performanceBlocking: "", shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", mustHave: [], optionalDetails: [], continuityOut: "", negativePrompt: "", assetBindings: [], status: "idle" }] } } },
|
||||
{ id: "config", type: CanvasNodeType.Config, title: "图片配置", position: { x: 520, y: 560 }, width: 360, height: 420, metadata: { generationMode: "image" } },
|
||||
{ id: "frame", type: CanvasNodeType.Frame, title: "背板", position: { x: 0, y: 680 }, width: 500, height: 500 },
|
||||
{ id: "batch-table", type: CanvasNodeType.BatchTable, title: "批量创作表", position: { x: 1000, y: 0 }, width: 900, height: 520 },
|
||||
];
|
||||
|
||||
const baseConfig = { ...defaultConfig };
|
||||
|
||||
describe("planBatchConnections", () => {
|
||||
it("only accepts image inputs for the batch creation table", () => {
|
||||
expect(canvasConnectionError(baseConfig, nodes, [], { fromNodeId: "image-a", toNodeId: "batch-table" })).toBe("");
|
||||
expect(canvasConnectionError(baseConfig, nodes, [], { fromNodeId: "text-a", toNodeId: "batch-table" })).toContain("批量创作表节点只接受图片输入");
|
||||
});
|
||||
|
||||
it("plans all legal source nodes and preserves the target handle", () => {
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["text-a", "text-b"],
|
||||
@@ -91,6 +98,35 @@ describe("planBatchConnections", () => {
|
||||
expect(result.connected).toEqual(["image-a", "image-b"]);
|
||||
expect(result.connections).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("connects every selected image to a newly created batch table", () => {
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["image-a", "image-b"],
|
||||
targetNodeId: "batch-table",
|
||||
nodes,
|
||||
connections: [],
|
||||
config: baseConfig,
|
||||
allowCapacityOverflow: true,
|
||||
});
|
||||
|
||||
expect(result.connected).toEqual(["image-a", "image-b"]);
|
||||
expect(result.connections.every((connection) => connection.toNodeId === "batch-table")).toBe(true);
|
||||
expect(result.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves a batch table reference-column target handle", () => {
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["image-a"],
|
||||
targetNodeId: "batch-table",
|
||||
targetHandleId: "batch-reference:reference-2",
|
||||
nodes,
|
||||
connections: [],
|
||||
config: baseConfig,
|
||||
allowCapacityOverflow: true,
|
||||
});
|
||||
|
||||
expect(result.connections[0]?.toHandleId).toBe("batch-reference:reference-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas connection anchors", () => {
|
||||
|
||||
88
web/test/canvas-batch-table.test.ts
Normal file
88
web/test/canvas-batch-table.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
batchReferenceHandleAtY,
|
||||
batchReferenceHandleId,
|
||||
batchReferenceMentionToken,
|
||||
createBatchRowsFromColumns,
|
||||
createBatchRowsFromInputs,
|
||||
createInheritedBatchRow,
|
||||
TRY_ON_BATCH_PROMPT,
|
||||
} from "@/lib/canvas/canvas-batch-table";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
describe("batch creation table", () => {
|
||||
test("creates one try-on row per person with the final image as the shared garment", () => {
|
||||
const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]);
|
||||
|
||||
expect(rows.map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
["person-3", "garment"],
|
||||
]);
|
||||
expect(rows.every((row) => row.prompt === TRY_ON_BATCH_PROMPT)).toBe(true);
|
||||
});
|
||||
|
||||
test("creates one creative row per connected image", () => {
|
||||
expect(createBatchRowsFromInputs("creative", ["a", "b"]).map((row) => row.inputNodeIds)).toEqual([["a"], ["b"]]);
|
||||
});
|
||||
|
||||
test("broadcasts a singleton reference column across rows", () => {
|
||||
expect(createBatchRowsFromColumns("try_on", [["person-1", "person-2"], ["garment"]]).map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("inherits the previous row references when adding a manual task", () => {
|
||||
const row = createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]);
|
||||
|
||||
expect(row.inputNodeIds).toEqual(["person-1", "garment"]);
|
||||
expect(row.inputNodeIds).not.toBe(createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]).inputNodeIds);
|
||||
expect(row.prompt).toBe(TRY_ON_BATCH_PROMPT);
|
||||
});
|
||||
|
||||
test("syncing connections preserves unmatched manual rows", () => {
|
||||
const manual = { id: "manual-row", enabled: false, inputNodeIds: ["manual-image"], prompt: "保留手工任务" };
|
||||
const rows = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [manual]);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[1]).toEqual(manual);
|
||||
});
|
||||
|
||||
test("syncing unchanged references keeps the row identity and linked output", () => {
|
||||
const existing = { id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "保留提示词", outputNodeId: "output-1" };
|
||||
const [row] = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [existing]);
|
||||
|
||||
expect(row).toEqual(existing);
|
||||
});
|
||||
|
||||
test("uses stable prompt mention tokens for positional references", () => {
|
||||
expect(batchReferenceMentionToken(0)).toBe("@参考图1");
|
||||
expect(batchReferenceMentionToken(1)).toBe("@参考图2");
|
||||
});
|
||||
|
||||
test("new canvas node has durable batch defaults", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(node.title).toBe("批量创作表");
|
||||
expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] });
|
||||
});
|
||||
test("keeps the second reference handle addressable", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
const referenceColumns = [
|
||||
{ id: "reference-1", label: "参考图 1" },
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
];
|
||||
node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } };
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
|
||||
test("chooses the nearest reference handle when magnetic hit areas overlap", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38, 90)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,10 @@ test("connection menu omits the header and provides descriptions for every optio
|
||||
expect(component).not.toContain("ChevronRight");
|
||||
expect(component).not.toContain("absolute inset-x-8 top-0 h-px");
|
||||
const options = component.match(/<ConnectionCreateOption [^\n]+/g) || [];
|
||||
expect(options).toHaveLength(8);
|
||||
expect(options).toHaveLength(9);
|
||||
for (const option of options) expect(option).toMatch(/description="[^"]+"/);
|
||||
expect(component).toContain('title="批量创作表"');
|
||||
expect(component).toContain("onCreate(CanvasNodeType.BatchTable)");
|
||||
});
|
||||
|
||||
test("add-node submenu uses a four-column medium-size grid", () => {
|
||||
|
||||
108
web/test/media-conversion.test.ts
Normal file
108
web/test/media-conversion.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy";
|
||||
import { findPendingMediaConversionInput } from "../src/components/canvas/canvas-node-generation";
|
||||
import { getNodeAcceptedInputKinds, getNodeMaxInputCount, getNodeResourceKind } from "../src/lib/canvas/node-registry";
|
||||
import { mediaConversionSourceFingerprint } from "../src/lib/media-conversion/contracts";
|
||||
import { convertImageLocally, LocalImageConversionError, transformPixels } from "../src/lib/media-conversion/local-converter";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||
import type { AiConfig } from "../src/stores/use-config-store";
|
||||
|
||||
function node(type: CanvasNodeType, id: string, metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData {
|
||||
return { id, type, title: id, position: { x: 0, y: 0 }, width: 380, height: 440, metadata };
|
||||
}
|
||||
|
||||
const config = {} as AiConfig;
|
||||
|
||||
describe("本地转换节点注册", () => {
|
||||
test("只允许一个图片或视频输入,完成后才作为图片素材", () => {
|
||||
expect(getNodeAcceptedInputKinds(CanvasNodeType.MediaConversion)).toEqual(["image", "video"]);
|
||||
expect(getNodeMaxInputCount(CanvasNodeType.MediaConversion)).toBe(1);
|
||||
expect(getNodeResourceKind(node(CanvasNodeType.MediaConversion, "conversion"))).toBeNull();
|
||||
expect(getNodeResourceKind(node(CanvasNodeType.MediaConversion, "conversion", {
|
||||
mediaConversion: { schemaVersion: 1, operation: "grayscale", status: "completed", resultStorageKey: "image:scope:result" },
|
||||
}))).toBe("image");
|
||||
expect(getNodeResourceKind(node(CanvasNodeType.MediaConversion, "conversion", {
|
||||
mediaConversion: { schemaVersion: 1, operation: "pose", status: "skipped", detectedPeople: 0 },
|
||||
}))).toBeNull();
|
||||
});
|
||||
|
||||
test("连接规则接受图片和视频,拒绝没有可读媒体的节点及第二个输入", () => {
|
||||
const image = node(CanvasNodeType.Image, "image");
|
||||
const video = node(CanvasNodeType.Video, "video");
|
||||
const text = node(CanvasNodeType.Text, "text");
|
||||
const conversion = node(CanvasNodeType.MediaConversion, "conversion");
|
||||
const nodes = [image, video, text, conversion];
|
||||
const candidate = (fromNodeId: string): CanvasConnection => ({ id: `candidate-${fromNodeId}`, fromNodeId, toNodeId: conversion.id });
|
||||
|
||||
expect(canvasConnectionError(config, nodes, [], candidate(image.id))).toBe("");
|
||||
expect(canvasConnectionError(config, nodes, [], candidate(video.id))).toBe("");
|
||||
expect(canvasConnectionError(config, nodes, [], candidate(text.id))).toContain("转换节点只接受图片或视频输入");
|
||||
expect(canvasConnectionError(config, nodes, [{ id: "existing", fromNodeId: image.id, toNodeId: conversion.id }], candidate(video.id))).toContain("转换节点最多连接 1 个输入");
|
||||
});
|
||||
|
||||
test("下游生成会阻止未完成或待更新的转换结果", () => {
|
||||
const conversion = node(CanvasNodeType.MediaConversion, "conversion", {
|
||||
mediaConversion: { schemaVersion: 1, operation: "depth", status: "unavailable" },
|
||||
});
|
||||
const input = node(CanvasNodeType.Image, "input", { content: "data:image/png;base64,source" });
|
||||
const target = node(CanvasNodeType.Image, "target");
|
||||
const connections: CanvasConnection[] = [
|
||||
{ id: "input-conversion", fromNodeId: input.id, toNodeId: conversion.id },
|
||||
{ id: "conversion-target", fromNodeId: conversion.id, toNodeId: target.id },
|
||||
];
|
||||
expect(findPendingMediaConversionInput(target.id, [input, conversion, target], connections)?.id).toBe(conversion.id);
|
||||
conversion.metadata = { mediaConversion: { schemaVersion: 1, operation: "grayscale", status: "completed", sourceNodeId: input.id, sourceFingerprint: "v1-unknown", resultStorageKey: "image:scope:result" } };
|
||||
expect(findPendingMediaConversionInput(target.id, [input, conversion, target], connections)?.id).toBe(conversion.id);
|
||||
conversion.metadata.mediaConversion.sourceFingerprint = mediaConversionSourceFingerprint(input);
|
||||
expect(findPendingMediaConversionInput(target.id, [input, conversion, target], connections)).toBeNull();
|
||||
});
|
||||
|
||||
test("完成后的转换结果可作为生成参考图", async () => {
|
||||
const { nodeReferenceImage } = await import("../src/lib/canvas/canvas-project-generation");
|
||||
expect(nodeReferenceImage(node(CanvasNodeType.MediaConversion, "conversion", {
|
||||
storageKey: "image:scope:result",
|
||||
mimeType: "image/png",
|
||||
mediaConversion: {
|
||||
schemaVersion: 1,
|
||||
operation: "grayscale",
|
||||
status: "completed",
|
||||
resultStorageKey: "image:scope:result",
|
||||
},
|
||||
}))).toMatchObject({
|
||||
id: "conversion",
|
||||
storageKey: "image:scope:result",
|
||||
type: "image/png",
|
||||
});
|
||||
expect(nodeReferenceImage(node(CanvasNodeType.MediaConversion, "conversion", {
|
||||
mediaConversion: { schemaVersion: 1, operation: "grayscale", status: "processing" },
|
||||
}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("图片本地转换算法", () => {
|
||||
test("灰度转换保留 alpha 并使用感知亮度", () => {
|
||||
const pixels = new Uint8ClampedArray([255, 0, 0, 200, 0, 255, 0, 100]);
|
||||
const output = transformPixels(pixels, 2, 1, "grayscale");
|
||||
expect(Array.from(output)).toEqual([76, 76, 76, 200, 150, 150, 150, 100]);
|
||||
});
|
||||
|
||||
test("Canny 转换输出黑白边缘图并保留 alpha", () => {
|
||||
const pixels = new Uint8ClampedArray([
|
||||
0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255,
|
||||
0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255,
|
||||
0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255,
|
||||
]);
|
||||
const output = transformPixels(pixels, 3, 3, "edge-canny");
|
||||
for (let index = 0; index < output.length; index += 4) {
|
||||
expect([0, 255]).toContain(output[index]);
|
||||
expect(output[index]).toBe(output[index + 1]);
|
||||
expect(output[index]).toBe(output[index + 2]);
|
||||
expect(output[index + 3]).toBe(255);
|
||||
}
|
||||
});
|
||||
|
||||
test("浏览器算法转换把模型操作交给本机 Runtime", async () => {
|
||||
await expect(convertImageLocally("", "depth")).rejects.toMatchObject<LocalImageConversionError>({ code: "model_missing" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user