Files
ttstd-infinite-canvas/web/src/pages/canvas/components/libtv-import-dialog.tsx
T
wurendao007andddCat c891497df9 perf(canvas): 画布媒体 - 优化媒体加载、播放器与节点交互 (#357)
Merge origin/main at 2b7375f and retain canvas media performance, player layout, and interaction updates.

Co-authored-by: ddCat <2695092045@qq.com>
2026-08-31 09:00:37 +08:00

177 lines
8.7 KiB
TypeScript

import { App, Button, Input, Modal, Tag } from "antd";
import { CircleAlert, ExternalLink, Import, LoaderCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { buildLibTVImagePreviewUrl, buildLibTVVideoPreviewUrl, buildLibTVVideoSourceUrl, formatLibTVBatchTime, parseLibTVProjectUUID } from "@/lib/canvas/libtv-import";
import { importLibTVCanvas, type LibTVImportResult } from "@/services/api/libtv";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
type Props = {
open: boolean;
projectId: string;
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
onClose: () => void;
onApply: (nodes: CanvasNodeData[], connections: CanvasConnection[]) => Promise<void>;
};
function buildCanvasNodes(result: LibTVImportResult, viewport: ViewportTransform, viewportSize: { width: number; height: number }) {
const minX = Math.min(...result.nodes.map((node) => node.x));
const minY = Math.min(...result.nodes.map((node) => node.y));
const maxX = Math.max(...result.nodes.map((node) => node.x + node.width));
const maxY = Math.max(...result.nodes.map((node) => node.y + node.height));
const offsetX = (viewportSize.width / 2 - viewport.x) / viewport.k - (minX + maxX) / 2;
const offsetY = (viewportSize.height / 2 - viewport.y) / viewport.k - (minY + maxY) / 2;
return result.nodes.map<CanvasNodeData>((node) => ({
id: node.id,
type: node.type === "video" ? CanvasNodeType.Video : CanvasNodeType.Image,
title: node.title,
position: { x: node.x + offsetX, y: node.y + offsetY },
width: node.width,
height: node.height,
metadata: {
content: node.type === "video" ? buildLibTVVideoSourceUrl(node.content) : node.content,
previewContent: node.type === "image" ? buildLibTVImagePreviewUrl(node.content) : buildLibTVVideoPreviewUrl(node.content) || undefined,
prompt: node.prompt,
model: node.model,
status: node.status || "idle",
errorDetails: node.errorDetails,
naturalWidth: node.naturalWidth,
naturalHeight: node.naturalHeight,
durationMs: node.durationMs,
mimeType: node.mimeType,
importSource: node.metadata,
},
}));
}
export function LibTVImportDialog({ open, projectId, viewport, viewportSize, onClose, onApply }: Props) {
const { message } = App.useApp();
const [value, setValue] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<LibTVImportResult | null>(null);
const uuid = useMemo(() => parseLibTVProjectUUID(value), [value]);
const reset = () => {
setValue("");
setResult(null);
};
const close = () => {
if (loading) return;
reset();
onClose();
};
const changeValue = (nextValue: string) => {
setValue(nextValue);
setResult(null);
};
const load = async () => {
if (!uuid) {
message.error("请填写 LibTV 画布 UUID 或链接");
return;
}
setLoading(true);
try {
setResult(await importLibTVCanvas(projectId, uuid));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取 LibTV 画布失败");
} finally {
setLoading(false);
}
};
const apply = async () => {
if (!result) return;
setLoading(true);
try {
await onApply(buildCanvasNodes(result, viewport, viewportSize), result.connections);
reset();
onClose();
message.success(`已导入 ${result.importedNodeCount} 个节点和 ${result.importedConnectionCount} 条连接`);
} catch (error) {
message.error(error instanceof Error ? error.message : "保存导入结果失败");
} finally {
setLoading(false);
}
};
return (
<Modal
className="workspace-modal"
open={open}
onCancel={close}
title="导入 LibTV 画布"
width={620}
footer={
result ? (
[
<Button key="close" onClick={close}>
关闭
</Button>,
<Button key="apply" type="primary" icon={<Import className="size-4" />} loading={loading} onClick={() => void apply()}>
确认导入
</Button>,
]
) : (
<Button type="primary" loading={loading} onClick={() => void load()}>
读取画布
</Button>
)
}
>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">LibTV 画布 UUID 或链接</label>
<Input
value={value}
onChange={(event) => changeValue(event.target.value)}
placeholder="粘贴 32 位 UUID、画布链接或分享链接"
disabled={loading}
suffix={uuid && value !== uuid ? <ExternalLink className="size-4 text-foreground/35" /> : null}
/>
</div>
{loading && !result ? (
<div className="flex items-center gap-2 text-sm text-foreground/55">
<LoaderCircle className="size-4 animate-spin" />
正在读取 LibTV 画布…
</div>
) : null}
{result ? (
<div className="space-y-3">
<div className="rounded-xl p-4" style={{ background: "var(--library-surface)" }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{result.projectName || "LibTV 画布"}</div>
<div className="mt-1 text-sm text-foreground/60">可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线</div>
</div>
<Tag color="blue">批次:{formatLibTVBatchTime(result.batchCreatedAt)}</Tag>
</div>
<div className="mt-3 text-xs leading-5 text-foreground/50">节点会保留相对位置,并整体放到当前可视区域中心。</div>
</div>
{result.skippedNodes.length || result.skippedConnections.length || result.multiResultNodeCount || result.staleNodeCount || result.reusedFailedNodeCount || result.placeholderNodeCount || result.convertedSpecialCount ? (
<div className="flex items-start gap-2 rounded-lg px-3 py-2.5 text-xs leading-5 text-foreground/60" style={{ background: "var(--surface-hover)" }}>
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<div>
{result.skippedNodes.length ? <div>{result.skippedNodes.length} 个暂不支持的节点未导入,相关连线已自动忽略。</div> : null}
{!result.skippedNodes.length && result.skippedConnections.length ? <div>{result.skippedConnections.length} 条无效连线已自动忽略。</div> : null}
{result.reusedFailedNodeCount ? <div>{result.reusedFailedNodeCount} 个最近任务失败但仍有历史结果的节点已保留。</div> : null}
{result.placeholderNodeCount ? <div>{result.placeholderNodeCount} 个尚未生成结果的节点已作为占位节点保留。</div> : null}
{result.convertedSpecialCount ? <div>{result.convertedSpecialCount} 个特殊节点已转换为图片参考节点。</div> : null}
{result.multiResultNodeCount ? <div>{result.multiResultNodeCount} 个多结果节点已使用首个结果。</div> : null}
{result.staleNodeCount ? <div>{result.staleNodeCount} 个过期标记节点仍保留现有结果。</div> : null}
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Tag>等待确认导入</Tag>
</div>
</div>
) : null}
</div>
</Modal>
);
}