diff --git a/web/src/components/canvas/nodes/media-conversion-node.tsx b/web/src/components/canvas/nodes/media-conversion-node.tsx index 22df85491..b42dceb4c 100644 --- a/web/src/components/canvas/nodes/media-conversion-node.tsx +++ b/web/src/components/canvas/nodes/media-conversion-node.tsx @@ -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) => { + 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} -
event.stopPropagation()}> - - + onSelect={selectOperation} + />
@@ -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 ; + return ; +} + +const OPERATION_MENU_GAP = 8; +const OPERATION_MENU_MARGIN = 8; +const OPERATION_MENU_ITEM_HEIGHT = 44; + +function OperationPicker({ theme, nodeId, value, disabled, onSelect }: { theme: CanvasTheme; nodeId: string; value: MediaConversionOperation; disabled: boolean; onSelect: (operation: MediaConversionOperation) => void }) { + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState<{ left: number; top: number; width: number; maxHeight: number } | null>(null); + const selectedLabel = mediaConversionOperationLabel(value); + + const updatePosition = () => { + const rect = triggerRef.current?.getBoundingClientRect(); + if (!rect) return; + const menuWidth = Math.max(280, Math.round(rect.width)); + const menuHeight = Math.min(300, OPERATIONS.length * OPERATION_MENU_ITEM_HEIGHT + 40); + const spaceBelow = window.innerHeight - rect.bottom - OPERATION_MENU_MARGIN; + const spaceAbove = rect.top - OPERATION_MENU_MARGIN; + const openAbove = spaceBelow < menuHeight + OPERATION_MENU_GAP || spaceAbove >= spaceBelow; + const maxLeft = Math.max(OPERATION_MENU_MARGIN, window.innerWidth - menuWidth - OPERATION_MENU_MARGIN); + const maxTop = Math.max(OPERATION_MENU_MARGIN, window.innerHeight - menuHeight - OPERATION_MENU_MARGIN); + const left = Math.min(Math.max(OPERATION_MENU_MARGIN, rect.left), maxLeft); + const top = openAbove + ? Math.max(OPERATION_MENU_MARGIN, rect.top - menuHeight - OPERATION_MENU_GAP) + : Math.min(Math.max(OPERATION_MENU_MARGIN, rect.bottom + OPERATION_MENU_GAP), maxTop); + setPosition({ left, top, width: menuWidth, maxHeight: menuHeight }); + }; + + useLayoutEffect(() => { + if (open) updatePosition(); + }, [open]); + + useEffect(() => { + if (!open) return; + const close = (event: PointerEvent) => { + const target = event.target instanceof Node ? event.target : null; + if (target && (triggerRef.current?.contains(target) || menuRef.current?.contains(target))) return; + setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + const reposition = () => updatePosition(); + document.addEventListener("pointerdown", close, true); + document.addEventListener("keydown", closeOnEscape, true); + window.addEventListener("resize", reposition); + window.addEventListener("scroll", reposition, true); + return () => { + document.removeEventListener("pointerdown", close, true); + document.removeEventListener("keydown", closeOnEscape, true); + window.removeEventListener("resize", reposition); + window.removeEventListener("scroll", reposition, true); + }; + }, [open]); + + const menuStyle: CSSProperties | undefined = position + ? { + left: position.left, + top: position.top, + width: position.width, + maxHeight: position.maxHeight, + background: theme.node.panel, + borderColor: theme.node.edge, + color: theme.node.text, + boxShadow: "0 16px 40px rgba(0,0,0,.28), 0 0 0 1px rgba(255,255,255,.06)", + } + : undefined; + + const menu = open && position && typeof document !== "undefined" + ? createPortal( + <> +