feat(canvas): 媒体转换节点 - 改进操作选择并支持完成后作为生成参考

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-09 09:46:18 +08:00
parent d79b323af0
commit 18244c213f
6 changed files with 296 additions and 42 deletions

View File

@@ -1,5 +1,6 @@
import { CheckCircle2, CircleAlert, Download, Image as ImageIcon, LoaderCircle, RefreshCw, Video, WandSparkles } from "lucide-react";
import { useEffect, useRef, useState } from "react";
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";
@@ -35,7 +36,7 @@ type MediaConversionNodeContentProps = {
const OPERATIONS = Object.keys(MEDIA_CONVERSION_OPERATION_LABELS) as MediaConversionOperation[];
export function MediaConversionNodeContent({ node, theme }: MediaConversionNodeContentProps) {
const { updateMetadata } = useCanvasNodeActions();
const { updateMetadata, updateNode } = useCanvasNodeActions();
const upstream = useUpstreamNodes(node.id);
const localRuntimeConnection = useLocalRuntimeStore((runtime) => runtime.connection);
const localRuntimeConnecting = useLocalRuntimeStore((runtime) => runtime.connecting);
@@ -78,11 +79,25 @@ export function MediaConversionNodeContent({ node, theme }: MediaConversionNodeC
}, [state.resultStorageKey]);
useEffect(() => {
if (!updateMetadata || state.status !== "completed" || (currentFingerprint && state.sourceFingerprint === currentFingerprint)) return;
updateMetadata(node.id, {
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]);
}, [currentFingerprint, node.id, state, updateMetadata, updateNode]);
useEffect(() => {
if (status === "stale") setShowResult(false);
@@ -91,6 +106,20 @@ export function MediaConversionNodeContent({ node, theme }: MediaConversionNodeC
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 } });
};
@@ -151,7 +180,7 @@ export function MediaConversionNodeContent({ node, theme }: MediaConversionNodeC
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;
updateMetadata?.(node.id, {
const completedPatch = {
content: "",
previewContent: "",
storageKey,
@@ -159,22 +188,44 @@ export function MediaConversionNodeContent({ node, theme }: MediaConversionNodeC
bytes: result.blob.size,
naturalWidth: result.width,
naturalHeight: result.height,
mediaConversion: {
...state,
schemaVersion: 1,
status: "completed",
sourceNodeId: input.id,
sourceFingerprint: currentFingerprint,
outputKind: "image",
resultStorageKey: storageKey,
resultWidth: result.width,
resultHeight: result.height,
...(detectedPeople !== undefined ? { detectedPeople } : {}),
errorCode: undefined,
errorMessage: undefined,
updatedAt: completedAt,
},
});
} 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) {
@@ -277,29 +328,24 @@ export function MediaConversionNodeContent({ node, theme }: MediaConversionNodeC
) : null}
</div>
<div className="flex items-center gap-2" data-canvas-no-zoom onWheel={(event) => event.stopPropagation()}>
<label className="sr-only" htmlFor={`${node.id}-operation`}>转换方式</label>
<select
id={`${node.id}-operation`}
<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"}
className="h-8 min-w-0 flex-1 rounded-[var(--r-sm)] border bg-transparent px-2 text-[var(--fs-tiny)] outline-none focus-visible:ring-2"
style={{ borderColor: theme.node.edge, color: theme.node.text, outlineColor: theme.accent.primary }}
onChange={(event) => selectOperation(event.target.value as MediaConversionOperation)}
onMouseDown={(event) => event.stopPropagation()}
>
{OPERATIONS.map((operation) => <option key={operation} value={operation}>{mediaConversionOperationLabel(operation)}</option>)}
</select>
onSelect={selectOperation}
/>
<button
type="button"
data-canvas-no-zoom
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--r-sm)] px-3 text-[var(--fs-tiny)] font-semibold outline-none transition hover:brightness-105 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50"
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-3.5 animate-spin" aria-hidden="true" /> : null}
{status === "processing" ? <LoaderCircle className="size-4 animate-spin" aria-hidden="true" /> : null}
{status === "processing" ? "处理中" : status === "completed" || status === "stale" ? "重新转换" : "开始转换"}
</button>
</div>
@@ -324,7 +370,178 @@ export function MediaConversionNodeContent({ node, theme }: MediaConversionNodeC
}
function PreviewToggle({ theme, pressed, label, onClick }: { theme: CanvasTheme; pressed: boolean; label: string; onClick: () => void }) {
return <button type="button" aria-pressed={pressed} className="h-6 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>;
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" }) {

View File

@@ -26,7 +26,7 @@ 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: 380, height: 440, title: "转换" },
[CanvasNodeType.MediaConversion]: { width: 480, height: 460, title: "转换" },
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
export const NODE_SPECS = {

View File

@@ -232,6 +232,19 @@ export function buildImageGenerationMetadata(type: CanvasImageGenerationType, co
}
export function nodeReferenceImage(node: CanvasNodeData): ReferenceImage | null {
if (node.type === CanvasNodeType.MediaConversion) {
// 转换节点完成后才算图片素材:结果写在 storageKey / resultStorageKey,content 会被清空。
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,

View File

@@ -168,7 +168,7 @@ const BUILTIN_NODE_TRAITS = {
[CanvasNodeType.MediaConversion]: {
label: "转换",
icon: <WandSparkles />,
minSize: { width: 320, height: 360 },
minSize: { width: 400, height: 360 },
showInCreateMenu: true,
resourceKind: (node: CanvasNodeData) => {
const conversion = node.metadata?.mediaConversion;

View File

@@ -11,9 +11,12 @@ function isVideo(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Vi
function isAudio(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Audio; }
function isText(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Text; }
function isConfig(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Config; }
function hasImage(ctx: ToolContext) { return isImage(ctx) && Boolean(ctx.nodeMetadata?.content); }
function hasVideo(ctx: ToolContext) { return isVideo(ctx) && Boolean(ctx.nodeMetadata?.content); }
function hasAudio(ctx: ToolContext) { return isAudio(ctx) && Boolean(ctx.nodeMetadata?.content); }
function hasMediaPayload(ctx: ToolContext) {
return Boolean(ctx.nodeMetadata?.content || ctx.nodeMetadata?.storageKey || ctx.nodeMetadata?.previewContent);
}
function hasImage(ctx: ToolContext) { return isImage(ctx) && hasMediaPayload(ctx); }
function hasVideo(ctx: ToolContext) { return isVideo(ctx) && hasMediaPayload(ctx); }
function hasAudio(ctx: ToolContext) { return isAudio(ctx) && Boolean(ctx.nodeMetadata?.content || ctx.nodeMetadata?.storageKey); }
function isCharacterReference(ctx: ToolContext) { return isText(ctx) && ctx.nodeMetadata?.workflowKind === "character" && Boolean(ctx.nodeMetadata?.characterAssetId); }
function isEditableText(ctx: ToolContext) { return isText(ctx) && !isCharacterReference(ctx); }
function canOpenDialog(ctx: ToolContext) { return isEditableText(ctx) || (isImage(ctx) && !isCanvasImageSourceNode(ctx.node)) || isVideo(ctx); }

View File

@@ -57,6 +57,27 @@ describe("本地转换节点注册", () => {
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("图片本地转换算法", () => {