feat(canvas): 新增视频节点字幕体系与多轨时间线编辑(关联 #187) (#191)

* fix(ci): 后端测试 - 修复 responses 流式测试数据与 Grok 生图测试 SSRF 放行

* feat(canvas): 新增视频节点字幕体系与多轨时间线编辑(关联 #187)

---------

Co-authored-by: zhaoyue <zhaoyue@rongshantech.com>
This commit is contained in:
爱笑的毛毛虫
2026-08-08 18:28:28 +08:00
committed by GitHub
parent b26b641ec3
commit 62b9c420dd
33 changed files with 3724 additions and 30 deletions

View File

@@ -42,6 +42,8 @@ type CanvasNodeToolbarProps = {
onAngle: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onExtractVideoLastFrame: (node: CanvasNodeData) => void;
onSubtitles: (node: CanvasNodeData) => void;
onTimeline: (node: CanvasNodeData) => void;
extractingVideoFrame: boolean;
onReversePrompt: (node: CanvasNodeData) => void;
onRetry: (node: CanvasNodeData) => void;
@@ -103,6 +105,8 @@ export function CanvasNodeToolbar({
onAngle,
onViewImage,
onExtractVideoLastFrame,
onSubtitles,
onTimeline,
extractingVideoFrame,
onReversePrompt,
onRetry,
@@ -240,7 +244,7 @@ export function CanvasNodeToolbar({
onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
onNodeSplit: onSplit, onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage,
onNodeExtractVideoLastFrame: onExtractVideoLastFrame, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize,
onNodeToggleLocked: onToggleLocked, onNodeCopyPrompt: copyImagePrompt,
onNodeSubtitles: onSubtitles, onNodeTimeline: onTimeline, onNodeToggleLocked: onToggleLocked, onNodeCopyPrompt: copyImagePrompt,
} as Partial<ToolbarHandlers> as ToolbarHandlers;
const nodeHoverCtx: ToolContext = {

View File

@@ -18,6 +18,8 @@ import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-refer
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
import { VideoPlayer } from "@/components/video-player";
import { createDefaultSubtitleStyle } from "@/types/timeline";
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
@@ -901,7 +903,34 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
function VideoNodeContent({ node, theme, reduceMediaEffects }: NodeContentRendererProps) {
const playWhenReadyRef = useRef(false);
const playerBoxRef = useRef<HTMLDivElement>(null);
const { url, loading, load } = useNodeResourceUrl(node, false);
const subtitleEntries = node.metadata?.subtitleEntries || [];
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
const [currentTimeMs, setCurrentTimeMs] = useState(0);
const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null);
// 视频元素由 vidstack 内部创建,直接监听原生事件取分辨率;只有存在字幕时才跟踪播放时间,避免无字幕节点频繁重渲染。
useEffect(() => {
const box = playerBoxRef.current;
const video = box?.querySelector("video");
if (!video) return;
const handleLoadedMetadata = () => {
if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize({ width: video.videoWidth, height: video.videoHeight });
};
video.addEventListener("loadedmetadata", handleLoadedMetadata);
handleLoadedMetadata();
if (!subtitleEntries.length) {
return () => video.removeEventListener("loadedmetadata", handleLoadedMetadata);
}
const handleTimeUpdate = () => setCurrentTimeMs(Math.round(video.currentTime * 1000));
video.addEventListener("timeupdate", handleTimeUpdate);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
};
}, [subtitleEntries.length, url]);
if (!node.metadata?.content)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
@@ -912,7 +941,35 @@ function VideoNodeContent({ node, theme, reduceMediaEffects }: NodeContentRender
if (!url) {
return <DeferredMediaLoad icon={loading ? <LoaderCircle className="size-5 animate-spin" /> : <Play className="size-5 fill-current" />} label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />;
}
return <VideoPlayer src={url} mimeType={node.metadata?.mimeType} title={node.title || "视频"} preload={reduceMediaEffects ? "none" : "metadata"} autoPlay={playWhenReadyRef.current} onCanPlay={() => { playWhenReadyRef.current = false; }} brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls />;
// 视频画面按实际分辨率等比适配节点盒子,字幕叠加层与画面同框,不在黑边上错位。
const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height);
const fitHeight = Math.min(node.height, node.width / Math.max(0.01, sourceRatio));
const fitWidth = Math.round(fitHeight * sourceRatio);
const activeEntry = subtitleEntries.find((entry) => currentTimeMs >= entry.startMs && currentTimeMs < entry.endMs);
const activeHighlight = activeEntry ? (node.metadata?.subtitleHighlights || []).find((item) => item.entryIndex === activeEntry.index) : undefined;
return (
<div ref={playerBoxRef} className="relative flex h-full w-full items-center justify-center overflow-hidden rounded-[var(--node-radius)] bg-black">
<div className="relative" style={{ width: fitWidth, height: Math.round(fitHeight) }}>
<VideoPlayer
src={url}
mimeType={node.metadata?.mimeType}
title={node.title || "视频"}
preload={reduceMediaEffects ? "none" : "metadata"}
autoPlay={playWhenReadyRef.current}
onCanPlay={() => { playWhenReadyRef.current = false; }}
brandColor={theme.accent.primary}
className="h-full w-full rounded-[var(--node-radius)] bg-black"
dataCanvasNoZoom
compactControls
/>
{activeEntry && activeEntry.text.trim() ? (
<CanvasSubtitleOverlay text={activeEntry.text} highlight={activeHighlight} style={subtitleStyle} />
) : null}
</div>
</div>
);
}
function AudioNodeContent({ node, theme }: NodeContentRendererProps) {

View File

@@ -20,11 +20,16 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
const [inserting, setInserting] = useState(false);
const items = useMemo(() => {
const mediaById = new Map(mediaAssets.map((asset) => [asset.id, asset]));
return (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", character: asset }];
const media = mediaById.get(asset.id);
return media && media.kind !== "model" && media.kind !== "entity" ? [{ id: asset.id, category: asset.category || media.category || "other", media }] : [];
});
if (projectItems.length) return projectItems;
// 自由画布(未关联短剧项目)或项目资产为空/后端未返回时,回退到本地素材库,避免弹窗空白
return mediaAssets
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
.map((media) => ({ id: media.id, category: media.category || "other", media }));
}, [detail?.assets, mediaAssets]);
const categories = useMemo(() => ["all", ...Array.from(new Set(items.map((item) => item.category)))], [items]);
const visible = category === "all" ? items : items.filter((item) => item.category === category);
@@ -47,7 +52,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border py-0 pl-4 pr-12"><h2 className="text-sm font-semibold">引用项目角色与资产</h2><span className="text-[var(--fs-label)] text-foreground/42">已选 {selectedIds.size} 项</span></header>
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] sm:grid-cols-[150px_minmax(0,1fr)] sm:grid-rows-1">
<nav className="thin-scrollbar flex min-w-0 gap-1 overflow-x-auto border-b border-border p-2 sm:block sm:overflow-y-auto sm:border-b-0 sm:border-r" aria-label="项目资产分类">{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`flex h-11 min-w-[104px] shrink-0 items-center justify-between rounded-md px-2 text-xs sm:w-full sm:min-w-0 ${category === item ? "bg-foreground/[.08] font-medium" : "text-foreground/55 hover:bg-foreground/[.04]"}`}><span>{categoryLabels[item] || "其他"}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item === "all" ? items.length : items.filter((asset) => asset.category === item).length}</span></button>)}</nav>
<div className="thin-scrollbar min-h-0 overflow-y-auto p-3">{visible.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">{visible.map((item) => <ProjectAssetCard key={item.id} item={item} selected={selectedIds.has(item.id)} onToggle={() => toggle(item.id)} />)}</div> : <WorkspaceState icon="assets" compact className="h-full" title="此分类没有可引用资产" description="先在项目角色与资产中完成角色确认或素材关联。" />}</div>
<div className="thin-scrollbar min-h-0 overflow-y-auto p-3">{visible.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">{visible.map((item) => <ProjectAssetCard key={item.id} item={item} selected={selectedIds.has(item.id)} onToggle={() => toggle(item.id)} />)}</div> : <WorkspaceState icon="assets" compact className="h-full" title="此分类没有可引用资产" description={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,下方展示本地素材库;也可先上传素材后再引用。"} />}</div>
</div>
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-border px-3"><span className="text-[var(--fs-tiny)] text-foreground/42">角色引用会在生成时解析当前角色版本</span><div className="flex gap-2"><Button size="small" onClick={onClose}>取消</Button><Button size="small" type="primary" disabled={!selectedIds.size} loading={inserting} onClick={() => void insert()}>引入 {selectedIds.size || ""} 项</Button></div></footer>
</div>

View File

@@ -0,0 +1,616 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, ColorPicker, Input, InputNumber, Modal, Progress, Segmented, Switch } from "antd";
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 { useThemeStore } from "@/stores/use-theme-store";
import { useConfigStore, type AiConfig } from "@/stores/use-config-store";
import { resolveMediaUrl } from "@/services/file-storage";
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
import { resourceIdFromStorageKey } from "@/services/api/resources";
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<CanvasNodeMetadata>) => void;
};
export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, onSave }: CanvasSubtitleDialogProps) {
const { message, modal } = App.useApp();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const [entries, setEntries] = useState<SrtEntry[]>([]);
const [highlights, setHighlights] = useState<SubtitleHighlight[]>([]);
const [style, setStyle] = useState<SubtitleStyle>(createDefaultSubtitleStyle);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<SubtitleHighlightProgress | null>(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<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textInputRef = useRef<HTMLInputElement>(null);
const previewBoxRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(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();
}, []);
// 打开弹窗时解析视频地址,用于字幕叠加预览。
// 远端资源优先走节点同款缓存下载(对象 URL),失败再退回资源代理地址。
useEffect(() => {
if (!open) return;
let cancelled = false;
setCurrentTimeMs(0);
setVideoSize(null);
setVideoError(false);
const storageKey = node.metadata?.storageKey || "";
const fallback = node.metadata?.content || "";
const applyUrl = (url: string) => {
if (!cancelled) setVideoUrl(url);
};
if (resourceIdFromStorageKey(storageKey)) {
void cacheResourceObjectUrl(storageKey)
.then((cached) => {
if (cancelled) return;
if (cached) {
setVideoUrl(cached);
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
})
.catch(() => {
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
});
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
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<SrtEntry>) => {
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 = (
<div className="flex min-h-0 flex-col gap-2">
<div ref={previewBoxRef} className="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-xl border" style={{ borderColor: theme.toolbar.border, background: "#000" }}>
{videoUrl ? (
<div className="relative" style={previewDisplay ? { width: previewDisplay.width, height: previewDisplay.height } : { width: "100%", maxWidth: 640, aspectRatio: "16 / 9" }}>
<video
ref={videoRef}
className="block h-full w-full"
src={videoUrl}
controls
playsInline
preload="metadata"
onLoadedMetadata={(event) => {
const video = event.currentTarget;
if (video.videoWidth > 0 && video.videoHeight > 0) {
setVideoSize({ width: video.videoWidth, height: video.videoHeight });
}
}}
onError={() => setVideoError(true)}
onTimeUpdate={(event) => setCurrentTimeMs(Math.round(event.currentTarget.currentTime * 1000))}
/>
{videoError ? <div className="absolute inset-0 grid place-items-center text-xs opacity-70">视频预览加载失败,请检查素材是否仍然可用</div> : null}
{activeEntry ? (
<div className={`pointer-events-none absolute inset-x-0 flex justify-center px-4 ${activePositionClass}`}>
<span className="rounded-lg px-2 py-0.5 text-center leading-7" style={{ background: "rgba(0,0,0,.62)", color: style.color, fontSize: style.fontSize, borderRadius: style.highlightRadius, maxWidth: "90%" }}>
<SubtitleHighlightedText text={activeEntry.text} highlight={activeHighlight} style={style} />
</span>
</div>
) : null}
</div>
) : (
<div className="grid h-40 w-full max-w-[640px] place-items-center px-4 text-center text-xs opacity-60">{videoError ? "视频预览加载失败,请检查素材是否仍然可用" : "暂无视频素材可预览,可先在节点上传或生成视频"}</div>
)}
</div>
<div className="shrink-0 text-center text-xs opacity-40">点击字幕条目可跳转预览</div>
</div>
);
const styleControls = (
<>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>字号</span>
<InputNumber size="small" min={12} max={40} value={style.fontSize} onChange={(fontSize) => setStyle((current) => ({ ...current, fontSize: fontSize ?? current.fontSize }))} className="w-20" />
</label>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>颜色</span>
<ColorPicker size="small" value={style.color} onChange={(color) => setStyle((current) => ({ ...current, color: color.toHexString() }))} />
</label>
<div className="text-xs opacity-70">
<div className="mb-1">位置</div>
<Segmented
block
size="small"
value={style.position}
options={[
{ label: "顶部", value: "top" },
{ label: "居中", value: "center" },
{ label: "底部", value: "bottom" },
]}
onChange={(position) => setStyle((current) => ({ ...current, position: position as SubtitlePosition }))}
/>
</div>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>单条上限</span>
<span className="flex items-center gap-1">
<InputNumber
size="small"
min={MIN_CHARS_PER_ENTRY}
max={MAX_CHARS_PER_ENTRY_LIMIT}
value={style.maxCharsPerEntry}
onChange={(maxCharsPerEntry) => setStyle((current) => ({ ...current, maxCharsPerEntry: maxCharsPerEntry ?? DEFAULT_MAX_CHARS_PER_ENTRY }))}
className="w-20"
/>
<span className="opacity-40">字</span>
</span>
</label>
<div className="border-t pt-3" style={{ borderColor: theme.toolbar.border }}>
<div className="mb-2 flex items-center justify-between text-xs font-medium opacity-55">
<span>关键词高亮</span>
<Switch size="small" checked={style.highlightEnabled} onChange={(highlightEnabled) => setStyle((current) => ({ ...current, highlightEnabled }))} />
</div>
<div className="space-y-2.5">
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>背景色</span>
<ColorPicker size="small" value={style.highlightBackgroundColor} onChange={(color) => setStyle((current) => ({ ...current, highlightBackgroundColor: color.toHexString() }))} />
</label>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>文字色</span>
<ColorPicker size="small" value={style.highlightTextColor} onChange={(color) => setStyle((current) => ({ ...current, highlightTextColor: color.toHexString() }))} />
</label>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>内边距 X</span>
<InputNumber size="small" min={0} max={24} value={style.highlightPaddingX} onChange={(highlightPaddingX) => setStyle((current) => ({ ...current, highlightPaddingX: highlightPaddingX ?? 0 }))} className="w-20" />
</label>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>内边距 Y</span>
<InputNumber size="small" min={0} max={24} value={style.highlightPaddingY} onChange={(highlightPaddingY) => setStyle((current) => ({ ...current, highlightPaddingY: highlightPaddingY ?? 0 }))} className="w-20" />
</label>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>圆角</span>
<InputNumber size="small" min={0} max={24} value={style.highlightRadius} onChange={(highlightRadius) => setStyle((current) => ({ ...current, highlightRadius: highlightRadius ?? 0 }))} className="w-20" />
</label>
</div>
</div>
</>
);
const entriesBlock = (
<div className="thin-scrollbar min-h-0 space-y-2 overflow-y-auto pr-1">
{entries.length ? (
entries.map((entry, idx) => {
const highlight = highlightByEntry.get(entry.index);
return (
<div
key={`${entry.index}-${idx}`}
className="cursor-pointer rounded-xl border p-2.5 transition-colors"
title="点击跳转到该条字幕时间"
style={{ background: theme.node.fill, borderColor: activeEntry?.index === entry.index ? theme.accent.primary : theme.node.stroke }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest("input,button,textarea")) return;
seekToEntry(entry);
}}
>
<div className="flex items-center gap-1.5">
<span className="grid size-6 shrink-0 place-items-center rounded-md text-xs font-semibold" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
{idx + 1}
</span>
<InputNumber size="small" min={0} step={100} value={entry.startMs} onChange={(startMs) => updateEntry(idx, { startMs: startMs ?? 0 })} className="w-32" aria-label={`第 ${idx + 1} 条开始时间(毫秒)`} />
<span className="text-xs opacity-40">→</span>
<InputNumber size="small" min={0} step={100} value={entry.endMs} onChange={(endMs) => updateEntry(idx, { endMs: endMs ?? 0 })} className="w-32" aria-label={`第 ${idx + 1} 条结束时间(毫秒)`} />
<button
type="button"
title="切分这条字幕"
className="ml-auto grid size-7 place-items-center rounded-lg border transition-colors hover:opacity-75"
style={{ borderColor: theme.toolbar.border }}
disabled={running}
onClick={() => splitEntry(idx)}
>
<Scissors className="size-3.5" />
</button>
<button
type="button"
title="删除这条字幕"
className="grid size-7 place-items-center rounded-lg border text-red-500 transition-colors hover:opacity-75"
style={{ borderColor: theme.toolbar.border }}
disabled={running}
onClick={() => deleteEntry(idx)}
>
<Trash2 className="size-3.5" />
</button>
</div>
<Input.TextArea value={entry.text} autoSize={{ minRows: 1, maxRows: 3 }} placeholder="字幕文本" className="mt-2" onChange={(event) => updateEntry(idx, { text: event.target.value })} />
{highlight ? (
<div className="mt-1.5 text-xs" style={{ color: theme.accent.primary }}>
重点:{highlight.highlightText}
</div>
) : null}
</div>
);
})
) : (
<div className="grid h-40 place-items-center text-center">
<div className="text-xs opacity-45">
<ListPlus className="mx-auto mb-2 size-8" />
还没有字幕:导入 SRT 或文本文件,或点击工具栏“新增字幕”。
</div>
</div>
)}
</div>
);
const title = (
<div className="flex min-w-0 items-center gap-2.5 pr-10">
<span className="grid size-8 shrink-0 place-items-center rounded-lg" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
<Captions className="size-4" />
</span>
<div className="min-w-0">
<div className="truncate text-[var(--fs-heading-lg)] font-semibold leading-6 tracking-[-0.02em]">字幕编辑</div>
<div className="truncate text-xs opacity-45">{node.title || "视频节点"}</div>
</div>
</div>
);
return (
<Modal className="canvas-subtitle-dialog" title={title} open={open} centered footer={null} width={1120} destroyOnHidden onCancel={onClose} styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}>
<div className="flex h-[min(72vh,680px)] min-h-[420px] flex-col text-sm" style={{ color: theme.node.text }}>
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<input
ref={fileInputRef}
type="file"
accept=".srt"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) importSrt(file);
event.target.value = "";
}}
/>
<input
ref={textInputRef}
type="file"
accept=".txt,text/plain"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) importPlainText(file);
event.target.value = "";
}}
/>
<Button size="small" icon={<FileUp className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
导入 SRT
</Button>
<Button size="small" icon={<FileUp className="size-3.5" />} onClick={() => textInputRef.current?.click()}>
导入文本
</Button>
<Button size="small" icon={<FileDown className="size-3.5" />} disabled={!entries.length} onClick={exportSrt}>
导出 SRT
</Button>
<Button size="small" icon={<Scissors className="size-3.5" />} disabled={!entries.length || running} onClick={resegmentAll}>
自动切分
</Button>
<Button size="small" icon={<Plus className="size-3.5" />} disabled={running} onClick={addEntry}>
新增字幕
</Button>
<Button
size="small"
danger
icon={<Trash2 className="size-3.5" />}
disabled={!entries.length || running}
onClick={() =>
modal.confirm({
title: "清空全部字幕",
content: `将删除全部 ${entries.length} 条字幕及关键词高亮,此操作不可撤销。`,
okText: "清空",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: () => {
// 互通加固:清空全部立即持久化到节点并同步时间线,避免清空后直接关闭弹窗(未点保存)导致重开视频节点旧字幕复活。
onSave(node.id, {
subtitleEntries: [],
subtitleHighlights: [],
subtitleStyle: style,
subtitleUpdatedAt: new Date().toISOString(),
});
setEntries([]);
setHighlights([]);
message.success("已清空全部字幕并同步到视频节点与时间线");
},
})
}
>
清空全部
</Button>
<Button
size="small"
type={running ? "default" : "primary"}
icon={running ? <LoaderCircle className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
disabled={!entries.length}
onClick={running ? cancelAiHighlight : () => void runAiHighlight()}
>
{running ? "取消高亮" : "AI 关键词高亮"}
</Button>
</div>
{progress && running ? (
<div className="border-b px-4 py-2" style={{ borderColor: theme.toolbar.border }}>
<Progress percent={progress.percent} size="small" format={() => `${progress.processedEntries}/${progress.totalEntries} 条 · 批次 ${progress.batchIndex}/${progress.batchTotal}`} />
</div>
) : null}
<div className="min-h-0 flex-1 px-4 py-3">
<div className="grid h-full grid-cols-[minmax(250px,300px)_minmax(0,1fr)_minmax(230px,270px)] gap-3">
{entriesBlock}
{previewBlock}
<div className="thin-scrollbar min-h-0 space-y-3 overflow-y-auto pr-1">
<div className="text-xs font-medium opacity-55">字幕样式</div>
{styleControls}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t px-4 py-3" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<div className="text-xs opacity-45">
{entries.length} 条字幕 · 单条上限 {style.maxCharsPerEntry || DEFAULT_MAX_CHARS_PER_ENTRY} 字
</div>
<div className="flex items-center gap-2">
<Button disabled={running} onClick={onClose}>
取消
</Button>
<Button type="primary" disabled={running} onClick={handleSave}>
保存
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,38 @@
import type { CSSProperties } from "react";
import type { SubtitleHighlight, SubtitleStyle } from "@/types/timeline";
import { SubtitleHighlightedText } from "./canvas-subtitle-text";
type CanvasSubtitleOverlayProps = {
text: string;
highlight?: SubtitleHighlight;
style: SubtitleStyle;
};
const POSITION_STYLE: Record<SubtitleStyle["position"], CSSProperties> = {
top: { top: "6%" },
center: { top: "50%", transform: "translateY(-50%)" },
bottom: { bottom: "8%" },
};
/**
* 画布视频节点上的实时字幕叠加层:按字幕样式定位到视频画面内。
* 纯展示层(pointer-events-none),不拦截画布拖拽与播放器控件。
*/
export function CanvasSubtitleOverlay({ text, highlight, style }: CanvasSubtitleOverlayProps) {
return (
<div className="pointer-events-none absolute inset-x-0 z-10 flex justify-center" style={{ ...POSITION_STYLE[style.position], paddingLeft: "4%", paddingRight: "4%" }}>
<span
className="max-w-full whitespace-pre-wrap text-center"
style={{
fontSize: style.fontSize,
lineHeight: 1.4,
color: style.color,
textShadow: "0 1px 3px rgba(0, 0, 0, 0.9)",
}}
>
<SubtitleHighlightedText text={text} highlight={highlight} style={style} />
</span>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import type { SubtitleHighlight, SubtitleStyle } from "@/types/timeline";
type SubtitleHighlightedTextProps = {
text: string;
highlight?: SubtitleHighlight;
style: SubtitleStyle;
};
/**
* 字幕文本的统一样式渲染:未启用高亮或没有高亮数据时原样输出,否则给重点词上底色。
* 字幕弹窗与画布视频节点叠加层共用,保证两处高亮效果一致。
*/
export function SubtitleHighlightedText({ text, highlight, style }: SubtitleHighlightedTextProps) {
if (!style.highlightEnabled || !highlight) return text;
return (
<>
{text.slice(0, highlight.start)}
<span
style={{
background: style.highlightBackgroundColor,
color: style.highlightTextColor,
borderRadius: style.highlightRadius,
padding: `${style.highlightPaddingY}px ${style.highlightPaddingX}px`,
}}
>
{text.slice(highlight.start, highlight.end)}
</span>
{text.slice(highlight.end)}
</>
);
}

View File

@@ -0,0 +1,922 @@
// 二期:多轨时间线编辑弹窗。
// 数据源是项目级 TimelineProject:视频/音频节点自动入轨,字幕条目转字幕片段。
// 交互:拖拽移动片段(吸附播放头/片段边缘)、左右边缘裁剪、删除、播放头跳转。
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { App, Button, Dropdown, Input, InputNumber, Modal, Progress, Tooltip } from "antd";
import { Captions, Clapperboard, FolderOpen, Library, Lock, LockOpen, Maximize2, MoreHorizontal, Music2, Plus, Scissors, Trash2, Upload, Video, Wand2, ZoomIn, ZoomOut } from "lucide-react";
import { saveAs } from "file-saver";
import { CanvasTimelineRuler } from "./canvas-timeline-ruler";
import { CanvasTimelinePreview } from "./canvas-timeline-preview";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { buildTimelineFromNodes, isNodeInTimeline, syncTimelineSubtitleClips } from "@/lib/timeline/timeline-build";
import { canPlaceAt, clampClipDurationByNeighbors, findNearestAvailablePlacement } from "@/lib/timeline/timeline-placement";
import { computeSnap } from "@/lib/timeline/timeline-snap";
import { DEFAULT_AUDIO_TRACK_ID, DEFAULT_VIDEO_TRACK_ID, normalizeTimelineProject } from "@/lib/timeline/timeline-tracks";
import { formatTimelineTime, getTimelineTrackWidth, getFitTimelineZoom, zoomIn, zoomOut } from "@/lib/timeline/timeline-view";
import { exportTimelineToMp4 } from "@/lib/timeline/timeline-export";
import type { TimelineRenderSource } from "@/lib/timeline/timeline-to-ffmpeg";
import type { CanvasNodeData } from "@/types/canvas";
import type { SrtEntry, TimelineClip, TimelineDirectMedia, TimelineProject } from "@/types/timeline";
const MIN_CLIP_DURATION_MS = 100;
const TRACK_ROW_HEIGHT = 52;
const SNAP_THRESHOLD_PX = 8;
const BASE_PX_PER_SECOND = 96;
type CanvasTimelineDialogProps = {
node: CanvasNodeData;
open: boolean;
nodes: CanvasNodeData[];
timeline: TimelineProject | null;
onClose: () => void;
onOpenSubtitleDialog?: (nodeId: string) => void;
onSave: (timeline: TimelineProject) => void;
onSaveSubtitles: (nodeId: string, entries: SrtEntry[]) => void;
/** 打开项目素材库选择器(由页面层接线) */
onOpenAssetLibrary?: () => void;
/** 打开项目资产库选择器(由页面层接线) */
onOpenProjectAssets?: () => void;
/** 上传本地音视频并返回直连媒体描述(仅时间线作用域,不创建画布节点) */
onUploadLocalFiles?: (files: File[]) => Promise<TimelineDirectMedia[]>;
/** 页面层持有:素材库/上传创建节点后,调用该引用把节点加入时间线草稿 */
addNodeToTimelineRef?: MutableRefObject<((node: CanvasNodeData) => void) | null>;
/** 页面层持有:素材库/项目资产直连媒体(不落画布)入轨通道 */
addMediaToTimelineRef?: MutableRefObject<((media: TimelineDirectMedia) => void) | null>;
/** 把合成结果保存为新视频节点放回画布(页面层接线) */
onCreateAssembledNode?: (blob: Blob, title: string) => Promise<CanvasNodeData | null>;
};
type ClipDragMode = "move" | "trim-start" | "trim-end";
type DragState =
| { mode: "move"; clipId: string; startClientX: number; originalStartMs: number }
| { mode: "trim-start"; clipId: string; startClientX: number; originalStartMs: number; originalDurationMs: number; originalSourceStartMs: number }
| { mode: "trim-end"; clipId: string; startClientX: number; originalStartMs: number; originalDurationMs: number }
| null;
export function CanvasTimelineDialog({
node,
open,
nodes,
timeline,
onClose,
onOpenSubtitleDialog,
onSave,
onSaveSubtitles,
onOpenAssetLibrary,
onOpenProjectAssets,
onUploadLocalFiles,
addNodeToTimelineRef,
addMediaToTimelineRef,
onCreateAssembledNode,
}: CanvasTimelineDialogProps) {
const { message } = App.useApp();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [draft, setDraft] = useState<TimelineProject>(() => buildTimelineFromNodes([]));
const [playheadMs, setPlayheadMs] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
const [snapEnabled, setSnapEnabled] = useState(true);
const [previewPlaying, setPreviewPlaying] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportPercent, setExportPercent] = useState(0);
const [exportDetail, setExportDetail] = useState("");
const dragRef = useRef<DragState>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const trackAreaRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const addGroupProbeRef = useRef<HTMLDivElement>(null);
const moreBtnRef = useRef<HTMLSpanElement>(null);
const initializedRef = useRef(false);
// 打开弹窗:优先复用项目时间线,否则从画布节点自动构建;播放头定位到触发节点片段。
// 仅首次打开时构建草稿:素材上传/素材库插入会触发 nodes 变化,不能因此重建草稿丢弃编辑。
useEffect(() => {
if (!open) {
initializedRef.current = false;
return;
}
if (initializedRef.current) return;
initializedRef.current = true;
const base = timeline ? normalizeTimelineProject(timeline) : buildTimelineFromNodes(nodes);
const next = timeline ? syncTimelineSubtitleClips(base, nodes) : base;
setDraft(next);
setPlayheadMs(0);
setZoomLevel(1);
setSelectedClipId(null);
setPreviewPlaying(false);
const triggerClip = next.clips.find((clip) => clip.nodeId === node.id);
if (triggerClip) setPlayheadMs(triggerClip.startMs);
}, [open, node.id, nodes, timeline]);
const pxPerMs = (BASE_PX_PER_SECOND / 1_000) * zoomLevel;
const durationMs = Math.max(1_000, draft.durationMs);
const [viewportWidth, setViewportWidth] = useState(900);
const trackWidth = useMemo(() => getTimelineTrackWidth(durationMs, zoomLevel, viewportWidth), [durationMs, zoomLevel, viewportWidth]);
useEffect(() => {
if (!open) return;
const measure = () => {
setViewportWidth(trackAreaRef.current?.clientWidth || 900);
};
measure();
const observer = new ResizeObserver(measure);
if (trackAreaRef.current) observer.observe(trackAreaRef.current);
return () => observer.disconnect();
}, [open]);
// 工具栏自适应:按钮总数过多或容器变窄时,把「添加素材/上传本地/素材库/项目资产」收进「更多」菜单,
// 工具栏保持单行不换行;容器变宽后自动恢复内联展示。
// 素材组用隐藏探针测量自然宽度,展开/收起两种形态下算出的核心区宽度一致,判定不随当前状态震荡。
const [addGroupCollapsed, setAddGroupCollapsed] = useState(false);
const toolbarObserverRef = useRef<ResizeObserver | null>(null);
const measureToolbar = useCallback(() => {
const el = toolbarRef.current;
const probe = addGroupProbeRef.current;
if (!el || !probe) return;
const addGroupWidth = probe.offsetWidth;
const shownAddWidth = addGroupCollapsed ? (moreBtnRef.current?.offsetWidth ?? 56) : addGroupWidth;
const expandedWidth = el.scrollWidth - shownAddWidth + addGroupWidth;
setAddGroupCollapsed(expandedWidth > el.clientWidth + 2);
}, [addGroupCollapsed]);
const ensureToolbarObserved = useCallback(() => {
const el = toolbarRef.current;
if (!el || toolbarObserverRef.current) return;
const observer = new ResizeObserver(measureToolbar);
toolbarObserverRef.current = observer;
observer.observe(el);
measureToolbar();
}, [measureToolbar]);
useEffect(() => {
if (!open) return;
// AntD Modal 内容经门户延迟挂载,首次 effect 时 toolbarRef 可能为空;
// afterOpenChange 兜底确保动画结束后一定完成测量与观察。
ensureToolbarObserved();
return () => {
toolbarObserverRef.current?.disconnect();
toolbarObserverRef.current = null;
};
}, [open, ensureToolbarObserved]);
const applyDraft = (updater: (current: TimelineProject) => TimelineProject) => {
setDraft((current) => {
const next = updater(current);
const endMs = next.clips.reduce((max, clip) => Math.max(max, clip.startMs + clip.durationMs), 0);
return { ...next, durationMs: Math.max(endMs, 1_000) };
});
};
const updateClip = (clipId: string, patch: Partial<TimelineClip>) => {
applyDraft((current) => ({
...current,
clips: current.clips.map((clip) => (clip.id === clipId ? { ...clip, ...patch } : clip)),
}));
};
const updateSelectedMediaNumbers = (patch: Partial<TimelineClip>) => {
if (!selectedClipId) return;
updateClip(selectedClipId, patch);
};
const moveClip = (clipId: string, candidateStartMs: number) => {
const clip = draft.clips.find((item) => item.id === clipId);
if (!clip) return;
const snapped = snapEnabled
? computeSnap({
candidateMs: candidateStartMs,
playheadMs,
clips: draft.clips,
excludeClipId: clipId,
pxPerMs,
thresholdPx: SNAP_THRESHOLD_PX,
enabled: true,
})
: { snappedMs: candidateStartMs, targets: [] };
const nextStartMs = Math.max(0, Math.round(snapped.snappedMs));
const result = canPlaceAt({
trackId: clip.trackId,
startMs: nextStartMs,
durationMs: clip.durationMs,
excludeClipId: clipId,
clips: draft.clips,
});
if (!result.ok) return;
updateClip(clipId, { startMs: nextStartMs });
};
// 裁剪统一基于拖动起点快照计算,避免“当前值 + 累计 delta”造成的漂移与越界。
const trimClipStart = (clipId: string, deltaMs: number, original: { startMs: number; durationMs: number; sourceStartMs: number }) => {
const clip = draft.clips.find((item) => item.id === clipId);
if (!clip) return;
const rightEdgeMs = original.startMs + original.durationMs;
// 向左延长最多到源文件起点:时间轴起点不能早于 original.start - sourceStart。
const minStartMs = clip.sourceStartMs === undefined ? 0 : Math.max(0, original.startMs - original.sourceStartMs);
const newStartMs = Math.max(minStartMs, Math.min(rightEdgeMs - MIN_CLIP_DURATION_MS, original.startMs + deltaMs));
const newDurationMs = rightEdgeMs - newStartMs;
// 源内裁剪:sourceStart 必须落在 [0, sourceDuration - newDuration],右缘不越源。
const sourceDurationMs = clip.sourceDurationMs;
let sourceStartMs = original.sourceStartMs + (newStartMs - original.startMs);
if (sourceDurationMs !== undefined) {
sourceStartMs = Math.max(0, Math.min(Math.max(0, sourceDurationMs - newDurationMs), sourceStartMs));
}
const collision = canPlaceAt({
trackId: clip.trackId,
startMs: newStartMs,
durationMs: newDurationMs,
excludeClipId: clipId,
clips: draft.clips,
});
if (!collision.ok) return;
updateClip(clipId, {
startMs: Math.round(newStartMs),
durationMs: Math.round(newDurationMs),
...(clip.sourceStartMs !== undefined ? { sourceStartMs: Math.round(sourceStartMs) } : {}),
});
};
const trimClipEnd = (clipId: string, deltaMs: number, original: { startMs: number; durationMs: number }) => {
const clip = draft.clips.find((item) => item.id === clipId);
if (!clip) return;
const maxDurationMs = clip.sourceDurationMs !== undefined && clip.sourceStartMs !== undefined ? Math.max(0, clip.sourceDurationMs - clip.sourceStartMs) : undefined;
const maxDuration = clampClipDurationByNeighbors({
clipId,
startMs: original.startMs,
requestedDurationMs: original.durationMs + deltaMs,
trackId: clip.trackId,
clips: draft.clips,
maxDurationMs,
});
const newDurationMs = Math.max(MIN_CLIP_DURATION_MS, maxDuration);
updateClip(clipId, { durationMs: Math.round(newDurationMs) });
};
const handleClipPointerDown = (event: React.PointerEvent<HTMLDivElement>, clipId: string, mode: ClipDragMode) => {
const clip = draft.clips.find((item) => item.id === clipId);
if (!clip) return;
event.preventDefault();
event.stopPropagation();
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
setSelectedClipId(clipId);
if (mode === "move") {
dragRef.current = { mode, clipId, startClientX: event.clientX, originalStartMs: clip.startMs };
} else if (mode === "trim-start") {
dragRef.current = {
mode,
clipId,
startClientX: event.clientX,
originalStartMs: clip.startMs,
originalDurationMs: clip.durationMs,
originalSourceStartMs: clip.sourceStartMs || 0,
};
} else {
dragRef.current = { mode, clipId, startClientX: event.clientX, originalStartMs: clip.startMs, originalDurationMs: clip.durationMs };
}
};
const handleClipPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
if (!event.isPrimary) return;
const deltaMs = Math.round((event.clientX - drag.startClientX) / Math.max(pxPerMs, 1e-6));
if (drag.mode === "move") {
moveClip(drag.clipId, drag.originalStartMs + deltaMs);
} else if (drag.mode === "trim-start") {
trimClipStart(drag.clipId, deltaMs, {
startMs: drag.originalStartMs,
durationMs: drag.originalDurationMs,
sourceStartMs: drag.originalSourceStartMs,
});
} else {
trimClipEnd(drag.clipId, deltaMs, {
startMs: drag.originalStartMs,
durationMs: drag.originalDurationMs,
});
}
};
const handleClipPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
dragRef.current = null;
try {
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
} catch {
// 指针已释放或未捕获时忽略
}
};
const deleteSelectedClip = () => {
if (!selectedClipId) {
message.info("请先点击选中一个片段");
return;
}
applyDraft((current) => ({
...current,
clips: current.clips.filter((clip) => clip.id !== selectedClipId),
}));
setSelectedClipId(null);
};
const removeClip = (clipId: string) => {
applyDraft((current) => ({
...current,
clips: current.clips.filter((clip) => clip.id !== clipId),
}));
if (selectedClipId === clipId) setSelectedClipId(null);
};
const addNodeToTimeline = (targetNode: CanvasNodeData) => {
const kind = targetNode.type === "video" ? "video" : targetNode.type === "audio" ? "audio" : null;
if (!kind) return;
const trackId = kind === "video" ? DEFAULT_VIDEO_TRACK_ID : DEFAULT_AUDIO_TRACK_ID;
const targetTrack = draft.tracks.find((track) => track.id === trackId);
if (!targetTrack || targetTrack.locked) {
message.warning(kind === "video" ? "视频轨已锁定或不存在" : "音频轨已锁定或不存在");
return;
}
const durationMs = targetNode.metadata?.durationMs && targetNode.metadata.durationMs > 0 ? Math.round(targetNode.metadata.durationMs) : 4_000;
const placement = findNearestAvailablePlacement({
targetStartMs: playheadMs,
durationMs,
trackId,
clips: draft.clips,
});
// 找不到可用空隙时(目标位置冲突且无足够间隙)回退到轨道末尾追加,避免片段重叠破坏后续放置/导出。
const startMs = placement.fits ? placement.startMs : draft.clips.filter((item) => item.trackId === trackId).reduce((max, item) => Math.max(max, item.startMs + item.durationMs), 0);
const clip: TimelineClip = {
id: `clip-${targetNode.id}-${Date.now()}`,
kind,
nodeId: targetNode.id,
trackId,
startMs,
durationMs,
title: targetNode.title || (kind === "video" ? "视频片段" : "音频片段"),
sourceStartMs: 0,
sourceDurationMs: durationMs,
...(kind === "audio" ? { volume: 1, fadeInMs: 0, fadeOutMs: 0 } : {}),
};
applyDraft((current) => ({ ...current, clips: [...current.clips, clip] }));
setSelectedClipId(clip.id);
message.success(`已添加「${clip.title}」到时间线`);
};
// 把“添加节点”能力暴露给页面层(素材库/本地上传创建节点后回填到草稿)。
useEffect(() => {
if (!addNodeToTimelineRef) return;
addNodeToTimelineRef.current = addNodeToTimeline;
return () => {
addNodeToTimelineRef.current = null;
};
}, [addNodeToTimeline, addNodeToTimelineRef]);
// 直连媒体(仅时间线作用域,不落画布):素材库/项目资产/本地上传直接入轨。
const addDirectMediaToTimeline = (media: TimelineDirectMedia) => {
const kind = media.kind === "video" ? "video" : media.kind === "audio" ? "audio" : null;
if (!kind) {
message.info("图片/文本素材暂不支持直接入轨,请先在画布中添加节点");
return;
}
const trackId = kind === "video" ? DEFAULT_VIDEO_TRACK_ID : DEFAULT_AUDIO_TRACK_ID;
const targetTrack = draft.tracks.find((track) => track.id === trackId);
if (!targetTrack || targetTrack.locked) {
message.warning(kind === "video" ? "视频轨已锁定或不存在" : "音频轨已锁定或不存在");
return;
}
const durationMs = media.durationMs && media.durationMs > 0 ? Math.round(media.durationMs) : 4_000;
const placement = findNearestAvailablePlacement({
targetStartMs: playheadMs,
durationMs,
trackId,
clips: draft.clips,
});
const clip: TimelineClip = {
id: `clip-media-${media.id}-${Date.now()}`,
kind,
nodeId: `timeline-media-${media.id}-${Date.now()}`,
trackId,
startMs: placement.startMs,
durationMs,
title: media.title || (kind === "video" ? "视频片段" : "音频片段"),
sourceStartMs: 0,
sourceDurationMs: durationMs,
directMedia: media,
...(kind === "audio" ? { volume: 1, fadeInMs: 0, fadeOutMs: 0 } : {}),
};
applyDraft((current) => ({ ...current, clips: [...current.clips, clip] }));
setSelectedClipId(clip.id);
message.success(`已添加「${clip.title}」到时间线`);
};
// 把“直连媒体入轨”能力暴露给页面层(时间线作用域插入不重复落画布)。
useEffect(() => {
if (!addMediaToTimelineRef) return;
addMediaToTimelineRef.current = addDirectMediaToTimeline;
return () => {
addMediaToTimelineRef.current = null;
};
}, [addDirectMediaToTimeline, addMediaToTimelineRef]);
// 播放头处分割:把视频/音频片段切成两段,右段源内起点整体后移,保持源文件内容连续。
const splitClipAtPlayhead = () => {
const clip = draft.clips.find((item) => (item.kind === "video" || item.kind === "audio") && playheadMs > item.startMs && playheadMs < item.startMs + item.durationMs);
if (!clip) {
message.info("请先将播放头移动到片段内部,再执行分割");
return;
}
const cutMs = playheadMs - clip.startMs;
if (cutMs < MIN_CLIP_DURATION_MS || clip.durationMs - cutMs < MIN_CLIP_DURATION_MS) {
message.warning("分割点太靠近片段边缘");
return;
}
const left: TimelineClip = { ...clip, id: `${clip.id}-left-${Date.now()}`, durationMs: Math.round(cutMs) };
const right: TimelineClip = {
...clip,
id: `${clip.id}-right-${Date.now()}`,
startMs: playheadMs,
durationMs: Math.round(clip.durationMs - cutMs),
sourceStartMs: (clip.sourceStartMs || 0) + Math.round(cutMs),
sourceDurationMs: clip.sourceDurationMs !== undefined ? Math.max(0, Math.round(clip.sourceDurationMs - cutMs)) : Math.round(clip.durationMs - cutMs),
};
applyDraft((current) => ({ ...current, clips: [...current.clips.filter((item) => item.id !== clip.id), left, right] }));
setSelectedClipId(left.id);
message.success("已在播放头处分割片段");
};
const handleLocalUpload = async (files: FileList | null) => {
const list = files ? Array.from(files) : [];
if (!list.length) return;
if (!onUploadLocalFiles) {
message.warning("本地上传暂未接线");
return;
}
const medias = await onUploadLocalFiles(list);
for (const media of medias) addDirectMediaToTimeline(media);
};
const handleSave = () => {
// 互通修复:时间线草稿只在打开时初始化,期间节点字幕可能在字幕弹窗中被清空(或节点数据被外部更新)。
// 保存前以节点当前字幕为准做定向校准:节点字幕已为空时,剔除草稿残留的旧字幕片段并回写空数组,
// 避免「清空后重开视频节点旧字幕复活」;节点仍有字幕时保留草稿内用户的时间线编辑(拖动/删减/文本)。
const clearedSubtitleNodeIds = new Set(nodes.filter((item) => (item.metadata?.subtitleEntries?.length ?? 0) === 0).map((item) => item.id));
const base = normalizeTimelineProject({ ...draft, updatedAt: new Date().toISOString() });
const reconciledClips = clearedSubtitleNodeIds.size ? base.clips.filter((clip) => !(clip.kind === "subtitle" && clearedSubtitleNodeIds.has(clip.nodeId))) : base.clips;
const normalized = normalizeTimelineProject({ ...base, clips: reconciledClips });
onSave(normalized);
// 整合方向:时间线字幕片段与节点字幕互通。保存时按视频节点回写 subtitleEntries(含空数组)。
// 回写集合 = 项目时间线原有字幕节点 ∪ 校准后仍有字幕片段的节点 ∪ 节点当前仍有字幕数据的节点:
// 覆盖“首次打开时间线(项目尚无 timeline)时删除字幕片段”这类 timeline 为空导致的漏回写,
// 保证删除全部字幕片段后节点字幕被同步清空,重开视频节点不再显示旧字幕。
const previousSubtitleNodeIds = new Set((timeline?.clips || []).filter((clip) => clip.kind === "subtitle").map((clip) => clip.nodeId));
const nextSubtitleNodeIds = new Set(normalized.clips.filter((clip) => clip.kind === "subtitle").map((clip) => clip.nodeId));
const nodeSubtitleNodeIds = new Set(nodes.filter((item) => (item.metadata?.subtitleEntries?.length ?? 0) > 0).map((item) => item.id));
const subtitleNodeIds = new Set([...previousSubtitleNodeIds, ...nextSubtitleNodeIds, ...nodeSubtitleNodeIds]);
subtitleNodeIds.forEach((subNodeId) => {
onSaveSubtitles(subNodeId, buildSubtitleEntriesForNode(subNodeId, normalized));
});
message.success("时间线已保存");
onClose();
};
// 组装导出:把当前草稿按片段顺序合成一个 MP4 Blob(导出下载与生成新片段共用)。
const runExport = async (): Promise<Blob> => {
const videoClips = draft.clips.filter((clip) => clip.kind === "video");
if (!videoClips.length) throw new Error("时间线没有视频片段,无法导出");
const sources: TimelineRenderSource[] = [];
for (const clip of videoClips) {
const sourceNode = nodes.find((item) => item.id === clip.nodeId);
const media = clip.directMedia;
if (!sourceNode && !media) continue;
sources.push({
nodeId: clip.nodeId,
fileName: "input-" + sources.length + ".mp4",
durationMs: clip.sourceDurationMs || clip.durationMs,
storageKey: sourceNode?.metadata?.storageKey || media?.storageKey,
url: sourceNode?.metadata?.content || media?.url || undefined,
});
}
if (!sources.length) throw new Error("找不到可导出的视频素材,请确认视频节点包含媒体");
setExporting(true);
setExportPercent(0);
setExportDetail("准备导出");
try {
return await exportTimelineToMp4(normalizeTimelineProject(draft), sources, {
onProgress: ({ percent, detail }) => {
setExportPercent(percent);
setExportDetail(detail);
},
});
} finally {
setExporting(false);
setExportPercent(0);
setExportDetail("");
}
};
const handleExport = async () => {
try {
const blob = await runExport();
saveAs(blob, (node.title || "成片") + ".mp4");
message.success("成片导出完成");
} catch (error) {
message.error(error instanceof Error ? error.message : "导出失败");
}
};
// 组装能力闭环:合成结果不落地下载,而是作为新视频节点放回画布,可继续编辑字幕与样式。
const handleCreateAssembledNode = async () => {
if (!onCreateAssembledNode) {
message.warning("保存回画布暂未接线");
return;
}
try {
const blob = await runExport();
const created = await onCreateAssembledNode(blob, (node.title || "成片") + "-新片段");
if (created) message.success("已生成新视频片段并放到画布,可继续编辑字幕与样式");
} catch (error) {
message.error(error instanceof Error ? error.message : "生成新片段失败");
}
};
const addableNodes = nodes.filter((item) => (item.type === "video" || item.type === "audio") && Boolean(item.metadata?.content || item.metadata?.storageKey) && !isNodeInTimeline(item.id, draft));
const selectedSubtitleClip = selectedClipId ? draft.clips.find((clip) => clip.id === selectedClipId && clip.kind === "subtitle") || null : null;
const selectedMediaClip = selectedClipId ? draft.clips.find((clip) => clip.id === selectedClipId && (clip.kind === "video" || clip.kind === "audio")) || null : null;
const selectedMediaRightEdgeMs = selectedMediaClip ? selectedMediaClip.startMs + selectedMediaClip.durationMs : 0;
const selectedMediaMinStartMs = selectedMediaClip ? (selectedMediaClip.sourceStartMs ? Math.max(0, selectedMediaClip.startMs - selectedMediaClip.sourceStartMs) : 0) : 0;
const selectedMediaMaxStartMs = selectedMediaClip ? Math.max(selectedMediaMinStartMs, selectedMediaRightEdgeMs - MIN_CLIP_DURATION_MS) : 0;
const selectedMediaSourceDurationMs = selectedMediaClip?.sourceDurationMs;
const selectedMediaMaxDurationMs = selectedMediaSourceDurationMs !== undefined ? Math.max(MIN_CLIP_DURATION_MS, selectedMediaSourceDurationMs) : 3_600_000;
const selectedMediaMaxSourceStartMs = selectedMediaSourceDurationMs !== undefined ? Math.max(0, selectedMediaSourceDurationMs - (selectedMediaClip?.durationMs || 0)) : 3_600_000;
const hasVideoClips = draft.clips.some((clip) => clip.kind === "video");
// 时间线全局时间 → 视频节点本地时间:以该节点视频片段起点为 0 基准重建 SrtEntry。
// source 传保存时校准后的时间线(normalized):节点字幕已清空时直接回写空数组,
// 避免草稿残留的旧字幕片段把节点上已清空的字幕重新写回。
const buildSubtitleEntriesForNode = (subNodeId: string, source: TimelineProject = draft): SrtEntry[] => {
const currentNode = nodes.find((item) => item.id === subNodeId);
if ((currentNode?.metadata?.subtitleEntries?.length ?? 0) === 0) return [];
const videoClip = source.clips.find((clip) => clip.nodeId === subNodeId && clip.kind === "video");
const baseMs = videoClip?.startMs ?? 0;
return source.clips
.filter((clip) => clip.nodeId === subNodeId && clip.kind === "subtitle" && Boolean(clip.text))
.sort((a, b) => a.startMs - b.startMs)
.map((clip, index) => ({
index: index + 1,
startMs: Math.max(0, Math.round(clip.startMs - baseMs)),
endMs: Math.max(0, Math.round(clip.startMs + clip.durationMs - baseMs)),
text: (clip.text || "").trim(),
}));
};
const trackLabel = (trackId: string) => draft.tracks.find((track) => track.id === trackId)?.label || trackId;
const renderTrackRow = (trackId: string, label: string) => {
const clips = draft.clips.filter((clip) => clip.trackId === trackId).sort((a, b) => a.startMs - b.startMs);
return (
<div key={trackId} className="relative border-b" style={{ height: TRACK_ROW_HEIGHT, borderColor: theme.timeline.trackBorder, background: theme.timeline.trackFill }}>
{clips.map((clip) => {
const isSelected = clip.id === selectedClipId;
const left = Math.round(clip.startMs * pxPerMs);
const width = Math.max(8, Math.round(clip.durationMs * pxPerMs));
const isSubtitle = clip.kind === "subtitle";
const clipFill = isSubtitle ? theme.timeline.clipSubtitle : clip.kind === "audio" ? theme.timeline.clipAudio : theme.timeline.clipVideo;
return (
<div
key={clip.id}
className={`absolute top-1 flex h-[calc(100%-8px)] cursor-grab touch-none select-none items-center overflow-hidden rounded-md border text-xs leading-none active:cursor-grabbing`}
style={{
left,
width,
background: isSelected ? theme.accent.primary : clipFill,
borderColor: isSelected ? theme.timeline.clipSelectedBorder : theme.timeline.trackBorder,
color: isSelected ? "#fff" : theme.node.text,
}}
onPointerDown={(event) => handleClipPointerDown(event, clip.id, "move")}
onPointerMove={handleClipPointerMove}
onPointerUp={handleClipPointerUp}
onPointerCancel={handleClipPointerUp}
onClick={(event) => event.stopPropagation()}
title={`${clip.title || label} · ${formatTimelineTime(clip.startMs)}~${formatTimelineTime(clip.startMs + clip.durationMs)}`}
>
{!isSubtitle ? (
<div
className="absolute bottom-0 left-0 top-0 w-2 cursor-ew-resize opacity-0 hover:opacity-100"
style={{ background: theme.timeline.handle }}
onPointerDown={(event) => handleClipPointerDown(event, clip.id, "trim-start")}
/>
) : null}
<span className="pointer-events-none min-w-0 flex-1 truncate px-2">{clip.title || trackLabel(clip.trackId)}</span>
{!isSubtitle ? (
<div className="absolute bottom-0 right-0 top-0 w-2 cursor-ew-resize opacity-0 hover:opacity-100" style={{ background: theme.timeline.handle }} onPointerDown={(event) => handleClipPointerDown(event, clip.id, "trim-end")} />
) : null}
</div>
);
})}
<div className="pointer-events-none absolute inset-y-0" style={{ left: Math.round(playheadMs * pxPerMs), width: 2, background: theme.timeline.playhead }} />
</div>
);
};
const title = (
<div className="flex min-w-0 items-center gap-2.5 pr-10">
<span className="grid size-8 shrink-0 place-items-center rounded-lg" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
<Clapperboard className="size-4" />
</span>
<div className="min-w-0">
<div className="truncate text-[var(--fs-heading-lg)] font-semibold leading-6 tracking-[-0.02em]">多轨时间线</div>
<div className="truncate text-xs opacity-45">第二期 · 轨道编辑与素材编排</div>
</div>
</div>
);
return (
<Modal
className="canvas-timeline-dialog"
title={title}
open={open}
centered
footer={null}
width={1160}
destroyOnHidden
onCancel={onClose}
afterOpenChange={(visible) => {
if (visible) ensureToolbarObserved();
}}
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
>
<div className="flex h-[min(76vh,760px)] min-h-[420px] flex-col text-sm" style={{ color: theme.node.text }}>
<div ref={toolbarRef} className="flex flex-nowrap items-center gap-2 overflow-hidden border-b px-4 py-3" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<span className="min-w-24 rounded-md border px-2 py-1 text-xs font-semibold tabular-nums" style={{ borderColor: theme.toolbar.border, background: theme.node.fill, color: theme.accent.primary }}>
{formatTimelineTime(playheadMs)}
</span>
<Tooltip title={snapEnabled ? "关闭吸附" : "开启吸附"}>
<Button size="small" icon={<Scissors className="size-3.5" />} onClick={() => setSnapEnabled((value) => !value)}>
{snapEnabled ? "吸附开" : "吸附关"}
</Button>
</Tooltip>
<Button size="small" icon={<ZoomOut className="size-3.5" />} onClick={() => setZoomLevel((value) => zoomOut(value))} />
<Button size="small" icon={<ZoomIn className="size-3.5" />} onClick={() => setZoomLevel((value) => zoomIn(value))} />
<Button size="small" icon={<Maximize2 className="size-3.5" />} onClick={() => setZoomLevel(getFitTimelineZoom(durationMs, viewportWidth))}>
适应
</Button>
{addGroupCollapsed ? (
<Dropdown
trigger={["click"]}
placement="bottomLeft"
menu={{
items: [
...(addableNodes.length
? [
{
key: "add",
label: "添加素材",
icon: <Plus className="size-3.5" />,
children: addableNodes.map((item) => ({
key: item.id,
label: (
<span className="inline-flex max-w-56 items-center gap-2 truncate">
{item.type === "video" ? <Video className="size-3.5" /> : <Music2 className="size-3.5" />}
{item.title || "未命名素材"}
</span>
),
onClick: () => addNodeToTimeline(item),
})),
},
]
: []),
{ key: "upload", label: "上传本地", icon: <Upload className="size-3.5" />, onClick: () => uploadInputRef.current?.click() },
{ key: "library", label: "素材库", icon: <Library className="size-3.5" />, disabled: !onOpenAssetLibrary, onClick: () => onOpenAssetLibrary?.() },
{ key: "assets", label: "项目资产", icon: <FolderOpen className="size-3.5" />, disabled: !onOpenProjectAssets, onClick: () => onOpenProjectAssets?.() },
],
}}
>
<span ref={moreBtnRef}>
<Button size="small" icon={<MoreHorizontal className="size-3.5" />}>
更多
</Button>
</span>
</Dropdown>
) : (
<>
<Dropdown
trigger={["click"]}
placement="bottomLeft"
disabled={!addableNodes.length}
menu={{
items: addableNodes.map((item) => ({
key: item.id,
label: (
<span className="inline-flex max-w-56 items-center gap-2 truncate">
{item.type === "video" ? <Video className="size-3.5" /> : <Music2 className="size-3.5" />}
{item.title || "未命名素材"}
</span>
),
onClick: () => addNodeToTimeline(item),
})),
}}
>
<Button size="small" icon={<Plus className="size-3.5" />} disabled={!addableNodes.length}>
添加素材
</Button>
</Dropdown>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => uploadInputRef.current?.click()}>
上传本地
</Button>
<Button size="small" icon={<Library className="size-3.5" />} disabled={!onOpenAssetLibrary} onClick={() => onOpenAssetLibrary?.()}>
素材库
</Button>
<Button size="small" icon={<FolderOpen className="size-3.5" />} disabled={!onOpenProjectAssets} onClick={() => onOpenProjectAssets?.()}>
项目资产
</Button>
</>
)}
<div ref={addGroupProbeRef} aria-hidden="true" className="invisible pointer-events-none absolute left-0 top-0 flex items-center gap-2">
<Dropdown trigger={["click"]} placement="bottomLeft" disabled={!addableNodes.length} menu={{ items: [] }}>
<Button size="small" icon={<Plus className="size-3.5" />}>
添加素材
</Button>
</Dropdown>
<Button size="small" icon={<Upload className="size-3.5" />}>
上传本地
</Button>
<Button size="small" icon={<Library className="size-3.5" />}>
素材库
</Button>
<Button size="small" icon={<FolderOpen className="size-3.5" />}>
项目资产
</Button>
</div>
<input
ref={uploadInputRef}
type="file"
accept="video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav"
multiple
className="hidden"
onChange={(event) => {
void handleLocalUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="small" icon={<Scissors className="size-3.5" />} onClick={splitClipAtPlayhead}>
分割
</Button>
<div className="ml-auto flex items-center gap-2">
<Button size="small" type="primary" icon={<Clapperboard className="size-3.5" />} loading={exporting} disabled={!hasVideoClips} onClick={() => void handleExport()}>
导出成片
</Button>
<Button size="small" icon={<Wand2 className="size-3.5" />} loading={exporting} disabled={!hasVideoClips} onClick={() => void handleCreateAssembledNode()}>
生成新片段
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedClipId} onClick={deleteSelectedClip}>
删除片段
</Button>
<Button size="small" disabled={!draft.clips.length} onClick={onClose}>
取消
</Button>
<Button size="small" type="primary" disabled={!draft.clips.length} onClick={handleSave}>
保存
</Button>
</div>
</div>
<CanvasTimelinePreview clips={draft.clips} nodes={nodes} playheadMs={playheadMs} playing={previewPlaying} theme={theme} onTogglePlay={() => setPreviewPlaying((value) => !value)} onPlayheadChange={setPlayheadMs} />
<div className="flex min-h-0 flex-1">
<div className="flex w-44 shrink-0 flex-col border-r" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<div className="shrink-0 border-b" style={{ height: 32, borderColor: theme.toolbar.border }} />
{draft.tracks
.slice()
.sort((a, b) => a.order - b.order)
.map((track) => (
<div key={track.id} className="flex items-center gap-2 border-b px-3" style={{ height: TRACK_ROW_HEIGHT, borderColor: theme.toolbar.border }}>
<span className="grid size-6 shrink-0 place-items-center rounded-md text-xs font-semibold" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
{track.kind === "video" ? "V" : track.kind === "audio" ? "A" : "S"}
</span>
<span className="min-w-0 flex-1 truncate text-xs">{track.label}</span>
{track.locked ? <Lock className="size-3 opacity-40" /> : <LockOpen className="size-3 opacity-40" />}
</div>
))}
</div>
<div ref={trackAreaRef} className="thin-scrollbar min-w-0 flex-1 overflow-auto" data-canvas-no-zoom>
<div ref={scrollRef} style={{ width: trackWidth }} className="min-h-full">
<CanvasTimelineRuler
pxPerMs={pxPerMs}
durationMs={durationMs}
playheadMs={playheadMs}
width={trackWidth}
theme={theme}
onSeek={(ms) => {
setPreviewPlaying(false);
setPlayheadMs(ms);
}}
/>
{draft.tracks
.slice()
.sort((a, b) => a.order - b.order)
.map((track) => renderTrackRow(track.id, track.label))}
</div>
</div>
</div>
{exporting ? (
<div className="border-t px-4 py-2" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<Progress percent={exportPercent} size="small" format={() => exportDetail} />
</div>
) : null}
{selectedSubtitleClip ? (
<div className="border-t px-4 py-2.5" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<div className="mb-1.5 flex items-center gap-2 text-xs">
<span className="font-semibold" style={{ color: theme.accent.primary }}>
字幕编辑
</span>
<span className="opacity-45">修改后保存将同步写回对应视频节点的字幕数据</span>
{onOpenSubtitleDialog ? (
<Button size="small" className="ml-auto" icon={<Captions className="size-3.5" />} onClick={() => onOpenSubtitleDialog(selectedSubtitleClip.nodeId)}>
精细编辑(SRT/高亮/样式)
</Button>
) : null}
</div>
<div className="flex items-start gap-3">
<Input.TextArea autoSize={{ minRows: 1, maxRows: 3 }} value={selectedSubtitleClip.text || ""} placeholder="字幕文本" className="flex-1" onChange={(event) => updateClip(selectedSubtitleClip.id, { text: event.target.value })} />
<div className="flex shrink-0 items-center gap-1.5 text-xs">
<InputNumber size="small" min={0} step={100} value={selectedSubtitleClip.startMs} onChange={(startMs) => updateClip(selectedSubtitleClip.id, { startMs: startMs ?? 0 })} className="w-28" />
<span className="opacity-40">→</span>
<InputNumber
size="small"
min={0}
step={100}
value={selectedSubtitleClip.startMs + selectedSubtitleClip.durationMs}
onChange={(endMs) => updateClip(selectedSubtitleClip.id, { durationMs: Math.max(MIN_CLIP_DURATION_MS, (endMs ?? 0) - selectedSubtitleClip.startMs) })}
className="w-28"
/>
<span className="opacity-40">毫秒</span>
</div>
</div>
</div>
) : null}
{selectedMediaClip ? (
<div className="border-t px-4 py-2.5" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<div className="mb-1.5 flex items-center gap-2 text-xs">
<span className="font-semibold" style={{ color: theme.accent.primary }}>
片段编辑
</span>
<span className="min-w-0 flex-1 truncate opacity-45">{selectedMediaClip.title || trackLabel(selectedMediaClip.trackId)}</span>
<Button size="small" icon={<Scissors className="size-3.5" />} onClick={splitClipAtPlayhead}>
在播放头分割
</Button>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs">
<span className="opacity-50">起点</span>
<InputNumber
size="small"
min={selectedMediaMinStartMs}
max={selectedMediaMaxStartMs}
step={100}
className="w-28"
value={selectedMediaClip.startMs}
onChange={(value) => updateSelectedMediaNumbers({ startMs: Math.max(selectedMediaMinStartMs, Math.min(selectedMediaMaxStartMs, value ?? selectedMediaClip.startMs)) })}
/>
<span className="opacity-50">时长</span>
<InputNumber
size="small"
min={MIN_CLIP_DURATION_MS}
max={selectedMediaMaxDurationMs}
step={100}
className="w-28"
value={selectedMediaClip.durationMs}
onChange={(value) => updateSelectedMediaNumbers({ durationMs: Math.max(MIN_CLIP_DURATION_MS, Math.min(selectedMediaMaxDurationMs, value ?? selectedMediaClip.durationMs)) })}
/>
<span className="opacity-50">源内起点</span>
<InputNumber
size="small"
min={0}
max={selectedMediaMaxSourceStartMs}
step={100}
className="w-28"
value={selectedMediaClip.sourceStartMs ?? 0}
onChange={(value) => updateSelectedMediaNumbers({ sourceStartMs: Math.max(0, Math.min(selectedMediaMaxSourceStartMs, value ?? (selectedMediaClip.sourceStartMs || 0))) })}
/>
<span className="opacity-50">源内结束</span>
<span className="tabular-nums" style={{ color: theme.accent.primary }}>
{formatTimelineTime((selectedMediaClip.sourceStartMs || 0) + selectedMediaClip.durationMs)}
</span>
<span className="ml-auto hidden opacity-45 sm:inline">数值微调精确裁剪,播放头处可分割;拖拽手柄快速裁剪</span>
</div>
</div>
) : null}
<div className="flex items-center gap-3 border-t px-4 py-2.5" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<span className="text-xs opacity-60">
总时长 {formatTimelineTime(durationMs)} · {draft.clips.length} 个片段 · {draft.tracks.length} 条轨道
</span>
<span className="ml-auto truncate text-xs opacity-45">拖拽片段移动,左右边缘裁剪,字幕片段来自视频节点的字幕数据</span>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,185 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Pause, Play } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { resolveMediaUrl } from "@/services/file-storage";
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
import { createDefaultSubtitleStyle } from "@/types/timeline";
import type { TimelineClip } from "@/types/timeline";
import type { CanvasNodeData } from "@/types/canvas";
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
type CanvasTimelinePreviewProps = {
clips: TimelineClip[];
nodes: CanvasNodeData[];
playheadMs: number;
playing: boolean;
theme: CanvasTheme;
onTogglePlay: () => void;
onPlayheadChange: (ms: number) => void;
};
const PREVIEW_WIDTH = 300;
const PREVIEW_HEIGHT = 168;
const AUTO_ADVANCE_GAP_MS = 500;
/**
* 时间线弹窗的所见即所得预览:显示播放头所在视频片段,并叠加当前字幕。
* 播放时以视频时间为基准推进播放头;暂停时播放头(标尺跳转)驱动视频画面。
*/
export function CanvasTimelinePreview({ clips, nodes, playheadMs, playing, theme, onTogglePlay, onPlayheadChange }: CanvasTimelinePreviewProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [videoUrl, setVideoUrl] = useState("");
const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null);
// 源内定位目标(秒);视频换源后 metadata 尚未加载时先记录,loadedmetadata 后再应用。
const targetSeekSecRef = useRef<number | null>(null);
const videoClips = useMemo(() => clips.filter((clip) => clip.kind === "video").sort((a, b) => a.startMs - b.startMs), [clips]);
const activeVideoClip = useMemo(() => videoClips.find((clip) => playheadMs >= clip.startMs && playheadMs < clip.startMs + clip.durationMs) || null, [playheadMs, videoClips]);
const activeSubtitleClip = useMemo(() => clips.find((clip) => clip.kind === "subtitle" && playheadMs >= clip.startMs && playheadMs < clip.startMs + clip.durationMs && Boolean(clip.text?.trim())) || null, [clips, playheadMs]);
const activeNode = useMemo(() => (activeVideoClip ? nodes.find((node) => node.id === activeVideoClip.nodeId) || null : null), [activeVideoClip, nodes]);
const subtitleStyle = activeNode?.metadata?.subtitleStyle || createDefaultSubtitleStyle();
const activeHighlight = useMemo(() => {
if (!activeSubtitleClip || activeSubtitleClip.subtitleEntryIndex === undefined || activeSubtitleClip.subtitleEntryIndex < 0 || !activeNode) return undefined;
// subtitleEntryIndex 与字幕条目 index 同为 1 基,直接匹配,不再 +1(此前高亮永远匹配不上)。
return (activeNode.metadata?.subtitleHighlights || []).find((item) => item.entryIndex === activeSubtitleClip.subtitleEntryIndex);
}, [activeNode, activeSubtitleClip]);
// 解析当前片段视频地址(与字幕弹窗同一套缓存/回退策略)。
useEffect(() => {
const node = activeNode;
const media = activeVideoClip?.directMedia;
setVideoUrl("");
setVideoSize(null);
if (!node && !media) return;
let cancelled = false;
// 直连媒体片段(directMedia,不落画布)与画布节点走同一套缓存/回退解析策略
const storageKey = node?.metadata?.storageKey || media?.storageKey || "";
const fallback = node?.metadata?.content || media?.url || "";
const applyUrl = (url: string) => {
if (!cancelled) setVideoUrl(url);
};
if (resourceIdFromStorageKey(storageKey)) {
void cacheResourceObjectUrl(storageKey)
.then((cached) => {
if (cancelled) return;
if (cached) {
setVideoUrl(cached);
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
})
.catch(() => {
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
});
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
return () => {
cancelled = true;
};
}, [activeNode, activeVideoClip]);
// 播放/暂停与外部跳转(标尺拖动)时同步视频位置;播放前先定位到片段源内起点,
// 避免裁剪后的片段从原始视频 0s 开始播放;播放期间视频时间反向驱动播放头,不做回跳。
useEffect(() => {
const video = videoRef.current;
if (!video || !activeVideoClip || !videoUrl) return;
const sourceStartSec = (activeVideoClip.sourceStartMs || 0) / 1000;
const sourceDurationSec = (activeVideoClip.sourceDurationMs || activeVideoClip.durationMs) / 1000;
const targetSec = sourceStartSec + Math.max(0, playheadMs - activeVideoClip.startMs) / 1000;
const clampedTargetSec = Math.min(targetSec, sourceStartSec + Math.max(0, sourceDurationSec - 0.05));
targetSeekSecRef.current = clampedTargetSec;
if (video.readyState >= 1 && Math.abs(video.currentTime - clampedTargetSec) > 0.05) {
video.currentTime = clampedTargetSec;
}
if (playing) {
void video.play().catch(() => undefined);
} else {
video.pause();
}
}, [activeVideoClip, playheadMs, playing, videoUrl]);
// 视频换源后元数据加载完成,应用之前记录的源内定位目标。
const handleVideoLoadedMetadata = (video: HTMLVideoElement) => {
if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize({ width: video.videoWidth, height: video.videoHeight });
if (targetSeekSecRef.current != null && video.readyState >= 1) {
const target = Math.min(targetSeekSecRef.current, Math.max(0, (video.duration || 0) - 0.05));
if (Math.abs(video.currentTime - target) > 0.05) video.currentTime = target;
}
};
const handleTimeUpdate = () => {
const video = videoRef.current;
if (!video || !activeVideoClip || !playing) return;
const sourceStartSec = (activeVideoClip.sourceStartMs || 0) / 1000;
const timelineMs = activeVideoClip.startMs + Math.round((video.currentTime - sourceStartSec) * 1000);
onPlayheadChange(Math.max(0, timelineMs));
const clipEndMs = activeVideoClip.startMs + activeVideoClip.durationMs;
if (timelineMs >= clipEndMs) {
const next = videoClips.find((clip) => clip.startMs >= clipEndMs - AUTO_ADVANCE_GAP_MS && clip.id !== activeVideoClip.id);
if (next) {
onPlayheadChange(next.startMs);
} else {
onTogglePlay();
}
}
};
const previewDisplay = useMemo(() => {
if (!videoSize || videoSize.height <= 0) return null;
const ratio = videoSize.width / videoSize.height;
let width = PREVIEW_WIDTH;
let height = width / ratio;
if (height > PREVIEW_HEIGHT) {
height = PREVIEW_HEIGHT;
width = Math.round(height * ratio);
}
return { width: Math.round(width), height: Math.round(height) };
}, [videoSize]);
return (
<div className="flex items-center gap-3 border-b px-4 py-2.5" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<div className="relative grid shrink-0 place-items-center overflow-hidden rounded-lg bg-black" style={{ width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT }} data-canvas-no-zoom>
{videoUrl && activeVideoClip ? (
<>
<div className="relative" style={previewDisplay ? { width: previewDisplay.width, height: previewDisplay.height } : { width: "100%", height: "100%" }}>
<video ref={videoRef} className="block h-full w-full" src={videoUrl} playsInline preload="metadata" onLoadedMetadata={(event) => handleVideoLoadedMetadata(event.currentTarget)} onTimeUpdate={handleTimeUpdate} />
{activeSubtitleClip ? <CanvasSubtitleOverlay text={activeSubtitleClip.text || ""} highlight={activeHighlight} style={subtitleStyle} /> : null}
</div>
<button
type="button"
className="absolute inset-0 z-10 grid place-items-center"
aria-label={playing ? "暂停预览" : "播放预览"}
onClick={(event) => {
event.stopPropagation();
onTogglePlay();
}}
>
<span className="grid size-10 place-items-center rounded-full bg-black/45 text-white backdrop-blur transition hover:scale-105 hover:bg-black/60">
{playing ? <Pause className="size-4 fill-current" /> : <Play className="size-4 fill-current" />}
</span>
</button>
</>
) : (
<div className="px-4 text-center text-xs opacity-55">该位置无视频片段</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
<span className="truncate font-semibold" style={{ color: theme.accent.primary }}>
{activeVideoClip?.title || "无视频片段"}
</span>
<span className="opacity-45 tabular-nums">{activeVideoClip ? `${formatTimelineTime(activeVideoClip.startMs)} ~ ${formatTimelineTime(activeVideoClip.startMs + activeVideoClip.durationMs)}` : ""}</span>
</div>
<div className="mt-1.5 max-h-16 min-h-10 overflow-y-auto rounded-lg border px-2.5 py-1.5 text-xs leading-5" style={{ borderColor: theme.toolbar.border, background: theme.node.fill }}>
{activeSubtitleClip ? activeSubtitleClip.text : <span className="opacity-40">此处无字幕,点选字幕片段后在下方编辑</span>}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
// 时间线标尺:按缩放渲染刻度与播放头,点击/拖拽设置播放头时间。
import { useMemo, useRef } from "react";
import { formatTimelineTime, getRulerTickStep } from "@/lib/timeline/timeline-view";
import type { CanvasTheme } from "@/lib/canvas-theme";
type CanvasTimelineRulerProps = {
pxPerMs: number;
durationMs: number;
playheadMs: number;
width: number;
theme: CanvasTheme;
onSeek: (ms: number) => void;
};
export function CanvasTimelineRuler({ pxPerMs, durationMs, playheadMs, width, theme, onSeek }: CanvasTimelineRulerProps) {
const barRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef(false);
const ticks = useMemo(() => {
const stepMs = getRulerTickStep(pxPerMs);
const count = Math.max(1, Math.ceil(durationMs / stepMs));
return Array.from({ length: count + 1 }, (_, index) => {
const ms = index * stepMs;
const x = Math.round(ms * pxPerMs);
return { ms, x, major: index % 5 === 0 };
});
}, [durationMs, pxPerMs]);
const seekFromEvent = (clientX: number) => {
const rect = barRef.current?.getBoundingClientRect();
if (!rect) return;
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / Math.max(1, rect.width)));
onSeek(Math.round(ratio * durationMs));
};
return (
<div
ref={barRef}
className="relative shrink-0 cursor-col-resize select-none overflow-hidden border-b"
style={{ height: 32, borderColor: theme.toolbar.border, background: theme.toolbar.panel }}
onPointerDown={(event) => {
draggingRef.current = true;
event.currentTarget.setPointerCapture(event.pointerId);
seekFromEvent(event.clientX);
}}
onPointerMove={(event) => {
if (draggingRef.current) seekFromEvent(event.clientX);
}}
onPointerUp={(event) => {
draggingRef.current = false;
event.currentTarget.releasePointerCapture(event.pointerId);
}}
>
{ticks.map((tick) => (
<div key={tick.ms} className="absolute top-0 h-full" style={{ left: tick.x }}>
<div className="absolute top-0 w-px" style={{ height: tick.major ? 10 : 5, background: theme.timeline.rulerTick }} />
{tick.major ? (
<span className="absolute left-1 top-1 whitespace-nowrap text-[10px] leading-none opacity-60" style={{ color: theme.timeline.rulerLabel }}>
{formatTimelineTime(tick.ms)}
</span>
) : null}
</div>
))}
<div className="pointer-events-none absolute bottom-0 top-0" style={{ left: Math.round(playheadMs * pxPerMs), width: 2, background: theme.timeline.playhead }}>
<span className="absolute -top-0.5 left-1 whitespace-nowrap text-[10px] font-semibold leading-none" style={{ color: theme.timeline.playhead }}>
{formatTimelineTime(playheadMs)}
</span>
</div>
</div>
);
}

View File

@@ -139,7 +139,7 @@ export function CanvasToolbar({
onNodeInfo: () => {}, onNodeDelete: () => {}, onNodeRetry: () => {}, onNodeEditText: () => {}, onNodeDecreaseFont: () => {}, onNodeIncreaseFont: () => {},
onNodeToggleDialog: () => {}, onNodeAnnotate: () => {}, onNodeGenerateImage: () => {}, onNodeUpload: () => {}, onNodeDownload: () => {}, onNodeSaveAsset: () => {},
onNodeMaskEdit: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {},
onNodeSuperResolve: () => {}, onNodeAngle: () => {}, onNodeViewImage: () => {}, onNodeExtractVideoLastFrame: () => {}, onNodeReversePrompt: () => {},
onNodeSuperResolve: () => {}, onNodeAngle: () => {}, onNodeViewImage: () => {}, onNodeExtractVideoLastFrame: () => {}, onNodeSubtitles: () => {}, onNodeTimeline: () => {}, onNodeReversePrompt: () => {},
onNodeToggleFreeResize: () => {}, onNodeToggleLocked: () => {}, onNodeCopyPrompt: () => {},
} as ToolbarHandlers;