import { useEffect, useMemo, useRef, useState } from "react"; import { App, Button, ColorPicker, Input, InputNumber, Progress, Segmented } from "antd"; import { AppModal } from "@/components/ui/product/app-modal"; import { Switch } from "@/components/ui/base/switch"; import { Captions, FileDown, FileUp, ListPlus, LoaderCircle, Plus, Scissors, Sparkles, Trash2 } from "lucide-react"; import { saveAs } from "file-saver"; import { canvasThemes } from "@/lib/canvas-theme"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { useConfigStore, type AiConfig } from "@/stores/use-config-store"; import { resolveMediaUrl } from "@/services/file-storage"; import { parseSrt, serializeSrtEntries } from "@/lib/timeline/srt-parser"; import { DEFAULT_MAX_CHARS_PER_ENTRY, MAX_CHARS_PER_ENTRY_LIMIT, MIN_CHARS_PER_ENTRY, resegmentSrtEntries, splitLongEntry } from "@/lib/timeline/srt-resegment"; import { buildFallbackHighlights, remapHighlightsAfterResegment } from "@/lib/timeline/subtitle-highlights"; import { generateSubtitleHighlights, type SubtitleHighlightProgress } from "@/lib/timeline/subtitle-highlight-runner"; import { createDefaultSubtitleStyle, type SrtEntry, type SubtitleHighlight, type SubtitlePosition, type SubtitleStyle } from "@/types/timeline"; import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas"; import { SubtitleHighlightedText } from "./canvas-subtitle-text"; type CanvasSubtitleDialogProps = { node: CanvasNodeData; open: boolean; projectId: string; config: AiConfig; onClose: () => void; onSave: (nodeId: string, patch: Partial) => void; }; export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, onSave }: CanvasSubtitleDialogProps) { const { message, modal } = App.useApp(); const theme = canvasThemes[useActiveTheme()]; const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const [entries, setEntries] = useState([]); const [highlights, setHighlights] = useState([]); const [style, setStyle] = useState(createDefaultSubtitleStyle); const [running, setRunning] = useState(false); const [progress, setProgress] = useState(null); const [videoUrl, setVideoUrl] = useState(""); const [currentTimeMs, setCurrentTimeMs] = useState(0); const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); const [previewBoxWidth, setPreviewBoxWidth] = useState(0); const [viewportHeight, setViewportHeight] = useState(0); const [videoError, setVideoError] = useState(false); const abortRef = useRef(null); const fileInputRef = useRef(null); const textInputRef = useRef(null); const previewBoxRef = useRef(null); const videoRef = useRef(null); // 打开弹窗时从节点 metadata 同步字幕数据;关闭后卸载,重开重新读取。 useEffect(() => { if (!open) return; setEntries(node.metadata?.subtitleEntries || []); setHighlights(node.metadata?.subtitleHighlights || []); setStyle(node.metadata?.subtitleStyle || createDefaultSubtitleStyle()); setRunning(false); setProgress(null); }, [open, node]); useEffect(() => { return () => abortRef.current?.abort(); }, []); // 打开弹窗时解析视频地址,用于字幕叠加预览;远端资源统一走稳定的云端地址。 useEffect(() => { if (!open) return; let cancelled = false; setCurrentTimeMs(0); setVideoSize(null); setVideoError(false); const storageKey = node.metadata?.storageKey || ""; const fallback = node.metadata?.content || ""; void resolveMediaUrl(storageKey, fallback).then((url) => { if (!cancelled) setVideoUrl(url); }); return () => { cancelled = true; }; }, [open, node]); // 监听预览容器与视口尺寸,视频按分辨率等比缩放,不撑满也不变形。 useEffect(() => { if (!open || !videoUrl) return; const measure = () => { setPreviewBoxWidth(previewBoxRef.current?.clientWidth || 0); setViewportHeight(window.innerHeight); }; measure(); const observer = new ResizeObserver(measure); if (previewBoxRef.current) observer.observe(previewBoxRef.current); window.addEventListener("resize", measure); return () => { observer.disconnect(); window.removeEventListener("resize", measure); }; }, [open, videoUrl]); const previewDisplay = useMemo(() => { if (!videoSize || videoSize.height <= 0) return null; const maxHeight = Math.round((viewportHeight || window.innerHeight) * 0.45); const maxWidth = Math.max(1, previewBoxWidth || 800); const ratio = videoSize.width / videoSize.height; let width = Math.min(maxWidth, videoSize.width); let height = width / ratio; if (height > maxHeight) { height = maxHeight; width = Math.round(height * ratio); } return { width: Math.round(width), height: Math.round(height) }; }, [previewBoxWidth, videoSize, viewportHeight]); const highlightByEntry = new Map(highlights.map((item) => [item.entryIndex, item])); const activeEntry = entries.find((entry) => currentTimeMs >= entry.startMs && currentTimeMs < entry.endMs); const activeHighlight = activeEntry ? highlightByEntry.get(activeEntry.index) : undefined; const activePositionClass = style.position === "top" ? "top-3" : style.position === "center" ? "top-1/2 -translate-y-1/2" : "bottom-4"; const updateEntry = (position: number, patch: Partial) => { setEntries((current) => current.map((entry, idx) => (idx === position ? { ...entry, ...patch } : entry))); }; const renumber = (list: SrtEntry[]) => list.map((entry, idx) => ({ ...entry, index: idx + 1 })); const normalizeEntries = (list: SrtEntry[]) => renumber( list .filter((entry) => entry.text.trim()) .map((entry) => ({ ...entry, startMs: Math.max(0, entry.startMs), endMs: Math.max(0, entry.endMs) })) .sort((a, b) => a.startMs - b.startMs || a.index - b.index), ); const addEntry = () => { setEntries((current) => { const last = current[current.length - 1]; const startMs = last ? last.endMs : 0; return [...current, { index: current.length + 1, startMs, endMs: startMs + 2_000, text: "" }]; }); }; const deleteEntry = (position: number) => { const removed = entries[position]; if (!removed) return; const next = normalizeEntries(entries.filter((_, idx) => idx !== position)); const { remapped } = remapHighlightsAfterResegment(highlights, next); // 互通加固:逐条删除立即持久化到节点并同步时间线,与「清空全部」行为一致, // 避免删除后直接关闭弹窗(未点保存)导致重开视频节点旧字幕复活。 setEntries(next); setHighlights(next.length ? remapped : []); onSave(node.id, { subtitleEntries: next, subtitleHighlights: next.length ? remapped : [], subtitleStyle: style, subtitleUpdatedAt: new Date().toISOString(), }); message.success(next.length ? "已删除该条字幕并同步到视频节点与时间线" : "字幕已清空并同步到视频节点与时间线"); }; const splitEntry = (position: number) => { const entry = entries[position]; if (!entry || !entry.text.trim()) { message.warning("该条字幕没有可切分的内容"); return; } const segments = splitLongEntry(entry, Math.max(2, Math.ceil(entry.text.length / 2))); const next = renumber([...entries.slice(0, position), ...segments, ...entries.slice(position + 1)]); setEntries(next); const { remapped } = remapHighlightsAfterResegment(highlights, next); setHighlights(remapped); }; const seekToEntry = (entry: SrtEntry) => { const video = videoRef.current; if (video) { video.currentTime = Math.max(0, entry.startMs / 1000); setCurrentTimeMs(entry.startMs); } }; const resegmentAll = () => { if (!entries.length) return; const maxChars = style.maxCharsPerEntry || DEFAULT_MAX_CHARS_PER_ENTRY; const next = resegmentSrtEntries(entries, maxChars); if (next.length === entries.length) { message.info(`所有字幕均未超过单条上限 ${maxChars} 字,无需切分;可在“字幕样式”中调低上限后重试`); return; } setEntries(next); const { remapped, dropped } = remapHighlightsAfterResegment(highlights, next); setHighlights(remapped); message.success(`自动切分完成:${entries.length} 条 → ${next.length} 条`); if (dropped.length) message.info(`已移除 ${dropped.length} 条未能匹配的旧高亮`); }; const importSrt = (file: File) => { const reader = new FileReader(); reader.onload = () => { const parsed = parseSrt(String(reader.result || "")); if (!parsed.length) { message.warning("未解析到有效字幕:文件需为 SRT 格式(序号 + 时间码 + 正文),纯文本请用“导入文本”"); return; } const autoSegmented = style.autoResegment ? resegmentSrtEntries(parsed, style.maxCharsPerEntry || DEFAULT_MAX_CHARS_PER_ENTRY) : parsed; setEntries(renumber(autoSegmented)); setHighlights([]); message.success(autoSegmented.length > parsed.length ? `已导入 ${parsed.length} 条字幕,按单条上限自动切分为 ${autoSegmented.length} 条` : `已导入 ${parsed.length} 条字幕`); }; reader.readAsText(file); }; // 纯文本导入:一行一条字幕,按视频时长平均分配起止时间;视频时长未知时按每条 4 秒估算。 const importPlainText = (file: File) => { const reader = new FileReader(); reader.onload = () => { const lines = String(reader.result || "") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); if (!lines.length) { message.warning("文本中没有可导入的内容"); return; } const durationMs = node.metadata?.durationMs; const stepMs = durationMs && durationMs > 0 ? durationMs / lines.length : 4_000; const entries: SrtEntry[] = lines.map((text, idx) => ({ index: idx + 1, startMs: Math.round(idx * stepMs), endMs: Math.round((idx + 1) * stepMs), text, })); setEntries(entries); setHighlights([]); message.success(durationMs && durationMs > 0 ? `已导入 ${entries.length} 条字幕,按视频时长 ${formatDurationMs(durationMs)} 平均分配` : `已导入 ${entries.length} 条字幕(视频时长未知,按每条 4 秒估算)`); }; reader.readAsText(file); }; const exportSrt = () => { const content = serializeSrtEntries(entries); if (!content) { message.warning("暂无字幕可导出"); return; } saveAs(new Blob([content], { type: "text/plain;charset=utf-8" }), `${node.title || "subtitle"}.srt`); }; const runAiHighlight = async () => { if (!entries.length) { message.warning("请先添加字幕内容"); return; } if (!isAiConfigReady(config, config.textModel)) { // 未配置文本模型时本地回退:按终止标点取首段作为高亮,保证功能不中断。 setHighlights(buildFallbackHighlights(entries)); message.info("未配置可用的文本模型,已使用本地标点高亮"); return; } const controller = new AbortController(); abortRef.current = controller; setRunning(true); setProgress({ batchIndex: 0, batchTotal: 1, processedEntries: 0, totalEntries: entries.length, percent: 0 }); try { const nextHighlights = await generateSubtitleHighlights(entries, { projectId, nodeId: node.id, config, signal: controller.signal, onProgress: setProgress, }); setHighlights(nextHighlights); message.success(`已生成 ${nextHighlights.length} 条关键词高亮`); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; message.error(error instanceof Error ? error.message : "关键词高亮生成失败"); } finally { setRunning(false); setProgress(null); abortRef.current = null; } }; const cancelAiHighlight = () => { abortRef.current?.abort(); }; const handleSave = () => { const normalized = normalizeEntries(entries); const { remapped } = remapHighlightsAfterResegment(highlights, normalized); onSave(node.id, { subtitleEntries: normalized, subtitleHighlights: normalized.length ? remapped : [], subtitleStyle: style, subtitleUpdatedAt: new Date().toISOString(), }); message.success(normalized.length ? "字幕已保存" : "字幕已清空并保存"); onClose(); }; const formatDurationMs = (ms: number) => { const seconds = Math.round(ms / 1000); return seconds >= 60 ? `${Math.floor(seconds / 60)}分${seconds % 60}秒` : `${seconds}秒`; }; const previewBlock = (
{videoUrl ? (
) : (
{videoError ? "视频预览加载失败,请检查素材是否仍然可用" : "暂无视频素材可预览,可先在节点上传或生成视频"}
)}
点击字幕条目可跳转预览
); const styleControls = ( <>
位置
setStyle((current) => ({ ...current, position: position as SubtitlePosition }))} />
关键词高亮 setStyle((current) => ({ ...current, highlightEnabled }))} />
); const entriesBlock = (
{entries.length ? ( entries.map((entry, idx) => { const highlight = highlightByEntry.get(entry.index); return (
{ const target = event.target as HTMLElement; if (target.closest("input,button,textarea")) return; seekToEntry(entry); }} >
{idx + 1} updateEntry(idx, { startMs: startMs ?? 0 })} className="w-32" aria-label={`第 ${idx + 1} 条开始时间(毫秒)`} /> → updateEntry(idx, { endMs: endMs ?? 0 })} className="w-32" aria-label={`第 ${idx + 1} 条结束时间(毫秒)`} />
updateEntry(idx, { text: event.target.value })} /> {highlight ? (
重点:{highlight.highlightText}
) : null}
); }) ) : (
还没有字幕:导入 SRT 或文本文件,或点击工具栏“新增字幕”。
)}
); const title = (
字幕编辑
{node.title || "视频节点"}
); return (
{ const file = event.target.files?.[0]; if (file) importSrt(file); event.target.value = ""; }} /> { const file = event.target.files?.[0]; if (file) importPlainText(file); event.target.value = ""; }} />
{progress && running ? (
`${progress.processedEntries}/${progress.totalEntries} 条 · 批次 ${progress.batchIndex}/${progress.batchTotal}`} />
) : null}
{entriesBlock} {previewBlock}
字幕样式
{styleControls}
{entries.length} 条字幕 · 单条上限 {style.maxCharsPerEntry || DEFAULT_MAX_CHARS_PER_ENTRY} 字
); }