Files
ttstd-infinite-canvas/web/src/components/canvas/nodes/media-conversion-node.tsx

361 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CheckCircle2, CircleAlert, Download, Image as ImageIcon, LoaderCircle, RefreshCw, Video, WandSparkles } from "lucide-react";
import { useEffect, useRef, useState } from "react";
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 } = 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 (!updateMetadata || state.status !== "completed" || (currentFingerprint && state.sourceFingerprint === currentFingerprint)) return;
updateMetadata(node.id, {
mediaConversion: { ...state, status: "stale", updatedAt: new Date().toISOString() },
});
}, [currentFingerprint, node.id, state, updateMetadata]);
useEffect(() => {
if (status === "stale") setShowResult(false);
}, [status]);
useEffect(() => () => abortRef.current?.abort(), []);
const updateState = (patch: Partial<MediaConversionNodeState>) => {
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;
updateMetadata?.(node.id, {
content: "",
previewContent: "",
storageKey,
mimeType: result.blob.type || "image/png",
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,
},
});
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 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`}
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>
<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"
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" ? "处理中" : 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-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>;
}
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}`;
}