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(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) => { 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 (
event.stopPropagation()} onWheel={(event) => event.stopPropagation()} >
{node.title || "转换"}
本地处理 · 单图优先
{displayResult ? ( {`${mediaConversionOperationLabel(state.operation)}结果`} ) : input?.type === CanvasNodeType.Image && sourceUrl ? ( 转换输入 ) : input?.type === CanvasNodeType.Video && sourceUrl ? (
{effectiveResult ? (
setShowResult(false)} /> setShowResult(true)} />
) : null}
event.stopPropagation()}>
{notice || mediaConversionOperationDescription(state.operation)} {showRuntimeRecovery ? ( ) : null}
); } function PreviewToggle({ theme, pressed, label, onClick }: { theme: CanvasTheme; pressed: boolean; label: string; onClick: () => void }) { return ; } function StatusBadge({ status }: { status: MediaConversionNodeState["status"] | "stale" }) { if (status === "processing") return ; if (status === "completed") return ; if (status === "stale") return ; if (status === "error") return ; if (status === "unavailable") return 本地不可用; if (status === "skipped") return 已跳过; return 未开始; } 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["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}`; }