|
|
|
|
@@ -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" }) {
|
|
|
|
|
|