feat(editor): AI 编辑助手悬浮面板、插件权限体系与时间线渲染管线(M4–M6.3) (#411)
* feat(editor): 编辑器实施 M0-M1 - 剪辑入口占位与插件 SDK v2 垂直切片 - detail.tsx 新增剪辑成片视图(editor tab,常驻),概览页插入剪辑步骤卡 - plugin-types.ts 新增 v2 类型(EditorSlotKind/EditorPluginPermission/PluginManifestV2),修复权限数组交叉归约 bug - editor-slot-registry.ts 插槽注册表(优先级排序、幂等注册、订阅机制) - plugin-registry.ts 注册器 v1/v2 分支校验,v1 路径零改动 - editor-shell.tsx 垂直切片插件:timeline-panel 插槽真实渲染,验证插件链路全通 - 测试:插槽注册表 5 用例 + 命令测试骨架,v1 插件回归 21 例零回归 * feat(editor): 命令协议黄金文件 - editor-commands 纯函数 reducer + 8 个黄金 op * feat(editor): 快照撤销历史 - editor-history 有界 200 层 undo/redo * feat(editor): 命令状态机 store - dispatch/手势预览/1.5s 防抖保存/串行保存链 * fix(editor): createEditorStore 返回 zustand hook 类型修复 typecheck * feat(editor): 手势 echo 最小件 - 时间线面板拖拽移动/裁剪 + store 接线与本地持久化 * docs(editor): M2 完成状态记录 * feat(editor): M3.1 时间线面板选中联动 - store 选中状态 + 片段高亮/点击取消 * feat(editor): M3.2 预览监视器 - preview-renderer 插槽 + 真实媒体帧/播放头/时间码 * feat(editor): M3.3 片段检查器 - inspector 插槽 + 属性编辑入队可撤销 * feat(editor): M3.4 asset-ingest 素材库插件(addClip 命令入轨) * feat(editor): M3.4 补充 - host 上下文(项目信息+资产)、editor.tsx 接线、asset-ingest 纯函数、死代码清理 * feat(editor): M3.5 subtitle-tools - SRT import/export + rebuild from node snapshot * feat(editor): M3.6 transcription - deterministic mock ASR to subtitle track * feat(editor): M3.7 export - render plan + ffmpeg.wasm fallback export * feat(editor): M3.8 - render all 8 editor slots in three-pane layout + ai-assistant placeholder * docs(editor): M3 完成状态 - 8 插件 + 全插槽接线,冒烟待确认 * feat(editor): 编辑器媒体与时间线打磨 - 资产来源分组筛选、时间线全宽布局、拖拽末片段修复、OSS 降级本地存储、Concat 风格控制条与检查器 * fix(plugins): 合并上游后修复插件分类筛选对 v2 清单的类型兼容 * feat(editor): 时间线工具态与轨道操作打磨 - 工具按钮选中仅图标变色、监视器 Concat 式控制条、removeTrack 黄金命令与轨道行头移除按钮、maskClosable 迁移 * fix(api): 资源上传超 MaxBytesReader 上限时报中文大小限制而非英文裸 400 * fix(editor): 监视器控制条 - 修复播放按钮 JSX 模板闭合花括号缺失导致的编译错误 * feat(api): 本地媒体导入分片化对齐 Concat - >50MB 视频不再失败 - 分片会话三接口(POST开始/PUT单片/complete合并)、前端超50MB自动切8MB分片并失败整传重试、默认配额放宽(日200MB→2GB、总2GB→20GB)、UploadResourceFile 豁免单文件限 * feat(editor): 轨道行头新增可见性/静音开关按钮 - 复用现有图标资源,按轨道类型显隐;命令层新增 setTrackFlag 黄金命令(flag 白名单 fail-closed)并补单测 * docs(progress): 记录轨道可见/静音开关改动与复测项 * docs(editor): M4 转写与渲染任务设计 - 复用 Task 表与状态机、独立创建 API、worker 领取类型过滤、whisper HTTP provider(OpenAI 兼容 /v1/audio) * fix(api): OSS 上传失败自动降级本地存储时 body 已被 Transport 关闭 - newOSSRequest 用 NopCloser 包装请求体,服务端提前返回(签名 403)时不再关闭调用方持有的文件,降级分支 Seek 重读生效;补回归测试 * docs(progress): 记录媒体导入根治(分片+OSS 降级修复)复测项 * feat(api): 视频播放副本转码 H.265→H.264 + variant=playback 服务与启动回填 - 上传视频后异步探测 codec,H.265/AV1 走 moov 快速探测并转码 H.264 播放副本;GET /api/user-data/...?variant=playback 就绪时服务副本(独立 ETag :pb),未就绪回退原件并撤销后缀防 304 黑屏;storeResource 触发、启动幂等回填存量;前端播放失败自动轮询副本就绪后刷新 * fix(editor): 轨道至少保留一条 - 不可移除轨道点击 X 时居中浮现原因提示(2.2s 自动消失, aria-live);修正可见性 toggle 传参 (visible===false 语义) ;轨道行内移除按钮改为常驻(不可移除时点击给提示) * feat(timeline): M4.1 字幕转写后台任务 - whisper.cpp 本地执行 - 新增 timeline_transcription 任务类型,worker 按类型分叉独立执行器(不走计费与模型渠道);输入 resourceId+language,读取用户资源后 ffmpeg 预处理 16k 单声道 wav,POST 本地 whisper.cpp /inference,结果写回 segments+srt+language;未配置 CANVAS_WHISPER_BASE_URL 等失败均落明确终态;feature 门控;进度分阶段;含解析/SRT/快速失败单元测试 * feat(media): 素材卡片时长与媒体面板打磨 - 导入媒体解析时长(前端 media-metadata + 后端 project_asset durationMs 透出)随卡片展示;媒体面板/监视器/时间线相关样式细节收尾 * docs(progress): 阶段 C 监视器播放复检清单 - H.265 播放副本/真实播放驱动/时长链路待浏览器确认 * feat(timeline): M4.1 转写任务创建 API - POST /api/timeline/transcriptions - service CreateTimelineTranscriptionTask:IsDraining 守护 + feature 门控 + 资源归属与可转写类型校验 + 排队计 active 限额(含任务级/项目级存储估算);local/whisper.cpp 标记,任务不经过模型路由与计费;createTaskWithinStorageQuota 复用与 ErrActiveTaskLimit 用户可读映射;测试覆盖排队成功(owner/type/provider 断言)、跨用户资源拒绝、非音视频拒绝 * feat(timeline): M4.3 字幕转写前端接线 - 真实任务替换 mock - 新增 services/api/timeline-tasks.ts(POST /timeline/transcriptions,复用 apiClient+request);EditorTranscription 改为 创建任务→waitForGenerationTask 轮询(25min)→结果 segments 映射 SrtEntry[]→rebuildSubtitleClips 写入字幕轨道;实时展示 stage/progress,失败/空识别/无字幕轨道分别提示;素材选择支持音频与视频(取 storageKey 中的 resource id 提交) * fix(media): 播放副本状态收敛与轮询终态 - 无ffmpeg/远端存储/编码不可处理落 none 避免重复探测;前端 none 停轮询回原生播放并加 processing 上限 * feat(timeline): M4.2 时间线渲染任务 - POST /api/timeline/renders 创建(buildRenderPlan 校验含可渲染媒体、配额入队、local/ffmpeg、60min 超时);worker 顶部按类型分叉 processTimelineRender;ffmpeg 合成产物写资源存储并经 ResultJSON 记 resourceId;render 计划/结果测试 PASS * fix(resource): 转码原子抢占与 OSS 请求体加固 - ClaimPlaybackTranscode 空/none→processing 单实例抢占避免重复转码,runPlaybackTranscode defer recover 落 failed;If-Range 与带 :pb 后缀的 serveETag 比较;无请求体的 OSS 请求不再包 NopCloser(nil)(防 Go 1.26 body 探测 nil 解引用) * style(editor): 修正字幕回写面板缩进 * docs(timeline): M4 设计文档按实现回写 - 单一 worker 池 + processClaimedTask 顶部按类型分叉(决策 3/4),端点统一为复数 /api/timeline/transcriptions 与 /api/timeline/renders(注册于 RegisterTaskRoutes),ResultJSON/feature gate/ffmpeg env 同步;Runbook M4 表格与状态行更新;auth.go API 前缀白名单补 timeline * fix(service): 修复上游 v1.2.5 公告配图删除保护自锁与测试夹具缺口 上游 #385 资源引用走查器对公告配图/草稿做了硬性引用检查,导致三个问题: - UpdateAnnouncement 在更新公告前对旧配图走查,必然命中当前公告自身引用而自锁, 公告替换配图功能实际不可用;DiscardAnnouncementImage 同理被草稿自身引用阻塞。 修复:ensureResourceHasNoBusinessReferences 支持忽略指定引用(当前公告/当前草稿), 其余引用仍按原语义拦截。 - admin DeleteAdminResources 将公告草稿引用视为阻塞,但删除事务会级联清理草稿与 资源绑定,service 层永不进入事务,回滚审计注入测试无法到达预期路径。修复:service 预检时过滤公告草稿引用,与 repository 级联语义对齐。 - project_settings_test.go 夹具未建 ResourceReferenceSnapshot 走查的 Task/Session/ Message/TaskLog/Result/Announcement/AnnouncementImageDraft 表,报 no such table。 修复:夹具 AutoMigrate 补齐。 后端全量 go test ./... 通过。 * feat(editor): 成片导出接入后端渲染任务(M4.2 前端接线)- timeline-tasks 增 createTimelineRenderTask;editor-export 主路径改 POST /timeline/renders 轮询取 resourceId 内嵌预览/下载,ffmpeg.wasm 降级保留 * feat(editor): M5 插件权限执行(fail-closed)- plugin-permission-check 新建(8 插槽最小权限表/判定/断言);SlotStack 渲染前过滤缺权限插槽改诊断条;editor-shell manifest 补 timeline.command/export.run;测试 10 用例含 editor-shell 真实回归 * feat(editor): M6.1 AI 命令 schema + M6.2 时间线摘要 - ai-command-schema(parse/dry-run 校验/12-op 提示词契约/版本化+golden);timeline-summary(确定性中文摘要/折叠上限);各 20/10 用例全绿 * docs(progress): M6 质检清单 - M6.1 schema/M6.2 摘要标记完成(含测试计数),M6.3 待模型源确认;清理 M5.1 重复条目 * feat(editor): AI 命令 schema 支持只读问答 - commands 空数组合法化(无法执行/纯提问时 reasoning 直接作答),新增 AI_EDITING_MAX_COMMANDS=8 上限防失控编辑,提示词同步 * feat(editor): AI 助手重构为右上悬浮对话面板(M6.3)+ 素材入库原子化收尾 - editor-ai-assistant.tsx: 占位面板替换为 OpenAI Canvas 风格右上悬浮对话面板; 面板内文本模型选择器(effective.textModels,选择即持久化并覆写请求模型), 消息流 user/assistant/error/preview 四类气泡 + 思考中占位,空态引导示例 chips 一键发送,底部输入条;顶部工具栏常驻 Sparkles 入口(aria-pressed 切换)。 修复上一版重复/截断函数声明(appendToEntry 双定义、settleAnswer 三声明等)。 宿主链路:buildAiEditingSystemPrompt(summarizeTimeline(project)) 喂上下文 → parseAiCommandPlan → validateAiCommandBatch dry-run(任一非法整批拒绝)→ ≤3 条 dispatch 直执行 / >3 条预览确认卡片;宿主拒绝时 failTurn 回填 FAILED_MARK 可重试。 - editor-asset-ingest/editor-preview-monitor/detail/editor.tsx: 配套接线与打磨 (编辑器顶栏 AI 入口、素材导入面板、预览监视、项目页顶栏整理)。 - backend: LinkProjectAsset 事务内原子创建资产 + 项目链接(OnConflict 幂等), 资产首次入库不再预落库,失败整体回滚,消除"有资产无链接"孤儿记录。 - docs: pending-test.mdx / runbook M6.3 状态更新(tsc + 测试 + 构建通过, 浏览器冒烟待真实文本渠道)。
This commit is contained in:
41
web/src/lib/media-metadata.ts
Normal file
41
web/src/lib/media-metadata.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// 媒体时长探测(纯前端工具,M3.4)。
|
||||
// 上传视频/音频前用 <video>/<audio> 元数据读取真实时长(毫秒),
|
||||
// 随上传 meta 传给后端存 Resource.DurationMs → 资产 summary 透出 → 时间线片段真实时长。
|
||||
// 解码能力仅用于读取 duration,不需要能播放编码。
|
||||
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
|
||||
function isProbeable(file: File): boolean {
|
||||
return /^video\//.test(file.type) || /^audio\//.test(file.type);
|
||||
}
|
||||
|
||||
export async function probeMediaDurationMs(file: File): Promise<number | undefined> {
|
||||
if (!isProbeable(file)) return undefined;
|
||||
const url = URL.createObjectURL(file);
|
||||
const el: HTMLVideoElement | HTMLAudioElement = /^video\//.test(file.type)
|
||||
? document.createElement("video")
|
||||
: document.createElement("audio");
|
||||
el.preload = "metadata";
|
||||
el.muted = true; // video 自动加载元数据在某些浏览器需要非 autoplay;muted 仅避免噪音
|
||||
try {
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
window.setTimeout(() => reject(new Error("probe timeout")), PROBE_TIMEOUT_MS);
|
||||
});
|
||||
const meta = new Promise<number>((resolve) => {
|
||||
el.onloadedmetadata = () => {
|
||||
const d = el.duration;
|
||||
resolve(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : 0);
|
||||
};
|
||||
el.onerror = () => resolve(0);
|
||||
});
|
||||
el.src = url;
|
||||
const durationMs = await Promise.race([meta, timeout]);
|
||||
return durationMs > 0 ? durationMs : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
427
web/src/lib/plugins/builtin/editor/editor-ai-assistant.tsx
Normal file
427
web/src/lib/plugins/builtin/editor/editor-ai-assistant.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
// AI 编辑助手(editor-shell 预设插件贡献 ai-assistant 插槽,M3.8 注册;入口在编辑器顶部工具按钮浮层)。
|
||||
// M6.3:对话面板 —— 时间线摘要注入 system prompt,LLM 输出受 ai-command-schema 约束;
|
||||
// ≤3 条命令直执行,>3 条先预览确认(ADR-0007:AI 编辑交互=预设插件且命令受约束)。
|
||||
// 命令逐条 dispatch;宿主 fail-closed:无效命令被 registry 拒绝并写入 saveError(顶部提示条),
|
||||
// 面板汇报为中性“已提交”,执行结果以时间线实际变化为准。
|
||||
// 模型显式选择:不再盲信 config.model 默认值(历史默认可能是图像模型导致文本请求失败),
|
||||
// 面板顶部提供文本能力模型下拉,请求时同时覆写 config.model/textModel,避免默认模型错误。
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Bot, Loader2, RotateCcw, Send, ShieldAlert } from "lucide-react";
|
||||
import { requestImageQuestion } from "@/services/api/image";
|
||||
import type { AiTextMessage } from "@/services/api/image-contracts";
|
||||
import { useConfigStore, useEffectiveConfig, modelOptionLabel } from "@/stores/use-config-store";
|
||||
import { useEditorStoreContext } from "@/components/editor/editor-context";
|
||||
import { summarizeTimeline } from "@/lib/timeline/timeline-summary";
|
||||
import {
|
||||
buildAiEditingSystemPrompt,
|
||||
parseAiCommandPlan,
|
||||
validateAiCommandBatch,
|
||||
type AiCommandPlan,
|
||||
type AiCommandBatchVerdict,
|
||||
} from "@/lib/timeline/ai-command-schema";
|
||||
|
||||
type UiEntry =
|
||||
| { kind: "user"; id: string; text: string }
|
||||
| { kind: "assistant"; id: string; text: string }
|
||||
| { kind: "error"; id: string; text: string; retry: string }
|
||||
| { kind: "preview"; id: string; plan: AiCommandPlan };
|
||||
|
||||
const FAILED_MARK = "(上一轮指令被宿主拒绝)";
|
||||
const RETRY_MODIFY_PREFIX = "宿主拒绝了上一轮输出,错误:";
|
||||
const MAX_VISIBLE_RAW = 240;
|
||||
const PREVIEW_HEAD = "以下指令将直接修改时间线,请确认后执行:";
|
||||
// 空态建议指令:点击直接发送(对应参考图的 suggestion chips)。
|
||||
const SUGGESTED_PROMPTS = [
|
||||
"把第二段素材换成封面图",
|
||||
"删掉所有片段之间的空隙",
|
||||
"给全部字幕加上黑色描边",
|
||||
"当前时间线一共有多少片段?",
|
||||
];
|
||||
|
||||
/** 指令行人类可读摘要(op + payload 截断),用于执行汇报与预览清单。 */
|
||||
function commandLine(cmd: { op: string; payload?: unknown }, index: number): string {
|
||||
const brief = JSON.stringify(cmd.payload);
|
||||
const trimmed = brief.length > 96 ? `${brief.slice(0, 96)}…` : brief;
|
||||
return `${index + 1}. ${cmd.op} ${trimmed}`;
|
||||
}
|
||||
|
||||
function describePlan(plan: AiCommandPlan): string {
|
||||
const lines = plan.commands.map((c, i) => commandLine(c, i));
|
||||
return [plan.reasoning?.trim(), `已提交 ${plan.commands.length} 条指令:`, ...lines]
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
|
||||
export function EditorAiAssistant() {
|
||||
const { project, dispatch } = useEditorStoreContext();
|
||||
const updateConfig = useConfigStore((s) => s.updateConfig);
|
||||
const effective = useEffectiveConfig();
|
||||
const textModels = effective.textModels ?? [];
|
||||
const defaultModel =
|
||||
effective.textModel && textModels.includes(effective.textModel) ? effective.textModel : textModels[0] ?? "";
|
||||
// 用户在本面板显式选择过的模型优先;未选择时跟随全局 textModel / 首个文本模型。
|
||||
const [model, setModel] = useState("");
|
||||
const modelValue = model || defaultModel || "";
|
||||
const [entries, setEntries] = useState<UiEntry[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const historyRef = useRef<AiTextMessage[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
// 条目 id 走 ref(非模块级可变变量):StrictMode 双调用 updater 时也无跨组件副作用。
|
||||
const idSeqRef = useRef(0);
|
||||
function nextId(): string {
|
||||
idSeqRef.current += 1;
|
||||
return `msg-${idSeqRef.current}`;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [entries, busy]);
|
||||
|
||||
function onModelChange(value: string): void {
|
||||
setModel(value);
|
||||
updateConfig("textModel", value);
|
||||
}
|
||||
|
||||
function patchEntry(id: string, text: string): void {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== id || e.kind === "preview") return e;
|
||||
return { ...e, text };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function appendToEntry(id: string, delta: string): void {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== id || e.kind === "preview") return e;
|
||||
return { ...e, text: e.text + delta };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 逐条 dispatch 并把目标条目替换为“已提交 N 条指令”汇报气泡。
|
||||
* 预览确认卡因此整卡替换、按钮卸载,重复点击不会二次提交同一批指令。 */
|
||||
function applyPlan(targetId: string, plan: AiCommandPlan): void {
|
||||
for (const cmd of plan.commands) dispatch(cmd);
|
||||
const report = describePlan(plan);
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.id === targetId ? { kind: "assistant", id: targetId, text: report } : e)),
|
||||
);
|
||||
pushHistory({
|
||||
role: "assistant",
|
||||
content: `已提交 ${plan.commands.length} 条指令:${plan.commands.map((c) => c.op).join("、")}`,
|
||||
});
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
function dropEntry(id: string): void {
|
||||
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||
}
|
||||
|
||||
function pushHistory(msg: AiTextMessage): void {
|
||||
historyRef.current = [...historyRef.current, msg];
|
||||
}
|
||||
|
||||
function failTurn(assistantId: string, reason: string, userText: string, fixable: boolean): void {
|
||||
patchEntry(
|
||||
assistantId,
|
||||
`指令未应用:${reason.slice(0, MAX_VISIBLE_RAW)}${reason.length > MAX_VISIBLE_RAW ? "…" : ""}`,
|
||||
);
|
||||
pushHistory({ role: "assistant", content: FAILED_MARK });
|
||||
const retry = fixable ? `${RETRY_MODIFY_PREFIX}${reason}。请只输出修正后的命令 JSON。` : userText;
|
||||
const errId = nextId();
|
||||
setEntries((prev) => [...prev, { kind: "error", id: errId, text: reason, retry }]);
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function send(rawOverride?: string): Promise<void> {
|
||||
const text = (rawOverride ?? input).trim();
|
||||
if (!text || busy || !project || !modelValue) return;
|
||||
setInput("");
|
||||
const userId = nextId();
|
||||
const assistantId = nextId();
|
||||
setEntries((prev) => [...prev, { kind: "user", id: userId, text }]);
|
||||
setEntries((prev) => [...prev, { kind: "assistant", id: assistantId, text: "" }]);
|
||||
setBusy(true);
|
||||
|
||||
const userMsg: AiTextMessage = { role: "user", content: text };
|
||||
const history = [...historyRef.current, userMsg];
|
||||
try {
|
||||
// 同时覆写 model 与 textModel,绕过 config.model 可能是图像模型的默认值问题。
|
||||
const answer = await requestImageQuestion(
|
||||
{
|
||||
...effective,
|
||||
model: modelValue,
|
||||
textModel: modelValue,
|
||||
systemPrompt: buildAiEditingSystemPrompt(summarizeTimeline(project)),
|
||||
},
|
||||
history,
|
||||
(delta) => appendToEntry(assistantId, delta),
|
||||
);
|
||||
patchEntry(assistantId, answer);
|
||||
settleAnswer(assistantId, answer, text);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
patchEntry(assistantId, `请求失败:${reason.slice(0, MAX_VISIBLE_RAW)}`);
|
||||
const errId = nextId();
|
||||
setEntries((prev) => [...prev, { kind: "error", id: errId, text: reason, retry: text }]);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function settleAnswer(assistantId: string, answer: string, userText: string): void {
|
||||
if (!project) {
|
||||
failTurn(assistantId, "编辑器项目已不可用,无法校验指令。", userText, false);
|
||||
return;
|
||||
}
|
||||
let plan: AiCommandPlan;
|
||||
try {
|
||||
plan = parseAiCommandPlan(answer);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
failTurn(assistantId, reason, userText, true);
|
||||
return;
|
||||
}
|
||||
if (plan.commands.length === 0) {
|
||||
const visible = plan.reasoning?.trim() || answer;
|
||||
patchEntry(assistantId, visible);
|
||||
pushHistory({ role: "assistant", content: visible });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
const verdict: AiCommandBatchVerdict = validateAiCommandBatch(project, plan);
|
||||
if (!verdict.ok) {
|
||||
failTurn(
|
||||
assistantId,
|
||||
`命令校验失败:${verdict.op}(第 ${verdict.index + 1} 条):${verdict.error}`,
|
||||
userText,
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (plan.commands.length <= 3) {
|
||||
applyPlan(assistantId, plan);
|
||||
return;
|
||||
}
|
||||
patchEntry(assistantId, plan.reasoning?.trim() || `${PREVIEW_HEAD}共 ${plan.commands.length} 条。`);
|
||||
const previewId = nextId();
|
||||
setEntries((prev) => [...prev, { kind: "preview", id: previewId, plan }]);
|
||||
pushHistory({
|
||||
role: "assistant",
|
||||
content: `输出 ${plan.commands.length} 条指令,等待用户确认后执行。`,
|
||||
});
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
function confirmPreview(entryId: string): void {
|
||||
const entry = entries.find(
|
||||
(e): e is Extract<UiEntry, { kind: "preview" }> => e.id === entryId && e.kind === "preview",
|
||||
);
|
||||
if (!entry || busy) return;
|
||||
// applyPlan 会把预览卡整卡替换为汇报气泡:按钮卸载,重复点击不会二次提交同一批指令。
|
||||
applyPlan(entryId, entry.plan);
|
||||
}
|
||||
|
||||
function dropPreview(entryId: string): void {
|
||||
dropEntry(entryId);
|
||||
}
|
||||
|
||||
function retry(entryId: string): void {
|
||||
const entry = entries.find(
|
||||
(e): e is Extract<UiEntry, { kind: "error" }> => e.id === entryId && e.kind === "error",
|
||||
);
|
||||
if (!entry || busy) return;
|
||||
void send(entry.retry);
|
||||
}
|
||||
|
||||
const hasProject = Boolean(project);
|
||||
const ready = hasProject && Boolean(modelValue);
|
||||
const canSend = ready && !busy && input.trim().length > 0;
|
||||
const placeholder = !hasProject
|
||||
? "编辑器尚未就绪"
|
||||
: !modelValue
|
||||
? "未检测到文本模型,请先在「AI 设置」启用文本渠道"
|
||||
: "描述编辑意图,或提问当前时间线…";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-[var(--director-sequencer-surface)]">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--director-sequencer-border)] px-3 py-2">
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-[var(--director-dock-fg)]/50">
|
||||
对话模型
|
||||
</span>
|
||||
{textModels.length > 0 ? (
|
||||
<select
|
||||
value={modelValue}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
disabled={busy}
|
||||
aria-label="选择文本对话模型"
|
||||
title="选择文本对话模型"
|
||||
className="h-7 min-w-0 flex-1 rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] px-1.5 text-xs text-[var(--director-dock-fg)] outline-none focus:border-[var(--director-accent)] disabled:opacity-50 [&>option]:bg-[var(--director-sequencer-surface-raised)] [&>option]:text-[var(--director-dock-fg)]"
|
||||
>
|
||||
{textModels.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{modelOptionLabel(effective, m)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-[11px] text-[var(--director-warning)]">
|
||||
未检测到可用文本模型,请先配置渠道
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3"
|
||||
>
|
||||
{entries.length === 0 && (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-4 text-center">
|
||||
<div className="grid size-10 place-items-center rounded-full bg-[var(--director-dock-active-surface)]">
|
||||
<Bot className="size-4 text-[var(--director-dock-fg)]/80" />
|
||||
</div>
|
||||
<p className="text-xs text-[var(--director-dock-fg)]/70">AI 剪辑助手</p>
|
||||
<p className="max-w-[240px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/50">
|
||||
基于当前时间线提问,或描述编辑意图(如"把第二段素材换成封面图")。
|
||||
修改指令会先在宿主侧校验,数量超过 3 条时需你确认后才执行。
|
||||
</p>
|
||||
<div className="flex max-w-[260px] flex-wrap items-center justify-center gap-1.5">
|
||||
{SUGGESTED_PROMPTS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={!ready || busy}
|
||||
onClick={() => void send(s)}
|
||||
className="rounded-full border border-[var(--director-sequencer-border)] px-2 py-1 text-[10px] text-[var(--director-dock-fg)]/70 transition-colors hover:border-[var(--director-accent)] hover:text-[var(--director-accent)] disabled:opacity-40"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
if (entry.kind === "user") {
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="max-w-[86%] self-end whitespace-pre-wrap break-words rounded-xl rounded-br-sm bg-[var(--director-accent)] px-2.5 py-1.5 text-xs leading-relaxed text-[var(--director-on-accent)]"
|
||||
>
|
||||
{entry.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "assistant") {
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="max-w-[92%] self-start whitespace-pre-wrap break-words rounded-xl rounded-bl-sm border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-2.5 py-1.5 text-[11px] leading-relaxed text-[var(--director-dock-fg)]"
|
||||
>
|
||||
{entry.text || (
|
||||
<span className="inline-flex items-center gap-1.5 text-[var(--director-dock-fg)]/60">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
思考中…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "error") {
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="max-w-[92%] self-start overflow-hidden rounded-xl rounded-bl-sm border border-[var(--director-danger)]/40 bg-[var(--director-danger)]/10 px-2.5 py-1.5"
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<ShieldAlert className="mt-0.5 size-3 shrink-0 text-[var(--director-danger)]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] leading-relaxed text-[var(--director-danger)]">
|
||||
{entry.text}
|
||||
</p>
|
||||
{entry.retry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retry(entry.id)}
|
||||
disabled={busy}
|
||||
className="mt-1 inline-flex items-center gap-1 rounded border border-[var(--director-sequencer-border)] px-1.5 py-0.5 text-[10px] text-[var(--director-dock-fg)]/80 hover:bg-[var(--director-control-hover)] disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="size-2.5" />
|
||||
修正后重试
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="max-w-[92%] self-start overflow-hidden rounded-xl rounded-bl-sm border border-[var(--director-warning)]/40 bg-[var(--director-warning)]/10"
|
||||
>
|
||||
<p className="border-b border-[var(--director-warning)]/25 px-2.5 py-1.5 text-[11px] font-medium text-[var(--director-dock-fg)]">
|
||||
{PREVIEW_HEAD}
|
||||
</p>
|
||||
<div className="max-h-44 overflow-y-auto px-2.5 py-1.5">
|
||||
{entry.plan.commands.map((cmd, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="py-0.5 font-mono text-[10px] leading-relaxed text-[var(--director-dock-fg)]/80"
|
||||
>
|
||||
{commandLine(cmd, i)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1.5 border-t border-[var(--director-warning)]/25 px-2.5 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dropPreview(entry.id)}
|
||||
disabled={busy}
|
||||
className="rounded-md border border-[var(--director-sequencer-border)] px-2 py-1 text-[10px] text-[var(--director-dock-fg)]/80 hover:bg-[var(--director-control-hover)] disabled:opacity-40"
|
||||
>
|
||||
放弃
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirmPreview(entry.id)}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1 rounded-md bg-[var(--director-accent)] px-2 py-1 text-[10px] font-medium text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)] disabled:opacity-40"
|
||||
>
|
||||
确认执行({entry.plan.commands.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 border-t border-[var(--director-sequencer-border)] p-2.5">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
disabled={!ready || busy}
|
||||
placeholder={placeholder}
|
||||
className="min-w-0 flex-1 rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] px-2 py-1.5 text-xs text-[var(--director-dock-fg)] placeholder:text-[var(--director-dock-fg)]/40 focus:border-[var(--director-accent)] focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
aria-label={modelValue ? "发送" : "无可用文本模型"}
|
||||
title={modelValue ? "发送" : "请先选择文本模型"}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-md bg-[var(--director-accent)] text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)] disabled:opacity-40"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Send className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
531
web/src/lib/plugins/builtin/editor/editor-asset-ingest.tsx
Normal file
531
web/src/lib/plugins/builtin/editor/editor-asset-ingest.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
// 资产导入(editor-shell 预设插件贡献 asset-ingest 插槽,M3.4)。
|
||||
// 展示项目资产库,按来源区分「本地上传」与「项目素材」;点击资产 → makeClipFromAsset
|
||||
// → dispatch addClip 加入时间线(添加到匹配 kind 的轨道末尾)。资产是
|
||||
// "仅时间线作用域"直连媒体(nodeId=asset:<id>)。导入链路:uploadResourceFile →
|
||||
// linkProjectAsset(后端按资源元数据合成资产记录)→ refreshAssets。
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Boxes, ChevronDown, ChevronRight, Clapperboard, Film, FolderOpen, HardDrive, Image as ImageIcon, Loader2, Music2, Plus } from "lucide-react";
|
||||
|
||||
import { useEditorHostContext, useEditorStoreContext } from "@/components/editor/editor-context";
|
||||
import { defaultAssetCategoryForKind } from "@/lib/asset-category";
|
||||
import { probeMediaDurationMs } from "@/lib/media-metadata";
|
||||
import { makeClipFromAsset } from "@/lib/timeline/asset-ingest";
|
||||
import { DEFAULT_AUDIO_TRACK_ID, DEFAULT_SUBTITLE_TRACK_ID, DEFAULT_VIDEO_TRACK_ID } from "@/lib/timeline/timeline-tracks";
|
||||
import { linkProjectAsset } from "@/services/api/projects";
|
||||
import { uploadResourceFile, type ResourceUploadMeta } from "@/services/api/resources";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import type { ProjectAsset } from "@/services/api/projects";
|
||||
import type { TimelineProject } from "@/types/timeline";
|
||||
|
||||
|
||||
const MEDIA_ACCEPT = "video/*,audio/*,image/*";
|
||||
/** 库内重复判定用的归一键(文件名+媒体类型)。 */
|
||||
function assetDedupeKey(title: string | undefined, mediaType: string): string {
|
||||
return `${(title || "").trim().toLowerCase()}|${mediaType}`;
|
||||
}
|
||||
/** 毫秒 → m:ss 时长文案。 */
|
||||
function formatDurationMs(ms: number | undefined): string {
|
||||
if (!ms || ms <= 0) return "";
|
||||
const total = Math.round(ms / 1000);
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
||||
}
|
||||
const IMAGE_RE = /\.(png|jpe?g|webp|gif|avif)$/i;
|
||||
const AUDIO_RE = /\.(mp3|wav|m4a|ogg|flac|aac)$/i;
|
||||
const MEDIA_RE = /\.(mp4|mov|webm|mkv|m4v|avi)$/i;
|
||||
|
||||
/** 本地上传标记(assetFromUploadedResource 写入 payload.data.source)。 */
|
||||
const SOURCE_UPLOADED = "uploaded";
|
||||
|
||||
type AssetFilter = "all" | "project" | "uploaded";
|
||||
|
||||
const FILTER_TABS: { id: AssetFilter; label: string }[] = [
|
||||
{ id: "all", label: "全部" },
|
||||
{ id: "project", label: "项目素材" },
|
||||
{ id: "uploaded", label: "本地上传" },
|
||||
];
|
||||
|
||||
/** 按 MIME 与扩展名推断媒体 kind;非媒体文件返回 null。 */
|
||||
function kindFromFile(file: File): "image" | "video" | "audio" | null {
|
||||
if (file.type.startsWith("video/")) return "video";
|
||||
if (file.type.startsWith("audio/")) return "audio";
|
||||
if (file.type.startsWith("image/")) return "image";
|
||||
if (IMAGE_RE.test(file.name)) return "image";
|
||||
if (AUDIO_RE.test(file.name)) return "audio";
|
||||
if (MEDIA_RE.test(file.name)) return "video";
|
||||
return null;
|
||||
}
|
||||
|
||||
function trackIdForKind(kind: string, project: TimelineProject): string {
|
||||
const track = project.tracks.find((t) => t.kind === kind);
|
||||
if (track) return track.id;
|
||||
if (kind === "video" || kind === "image") return project.tracks.find((t) => t.kind === "video")?.id ?? DEFAULT_VIDEO_TRACK_ID;
|
||||
if (kind === "audio") return project.tracks.find((t) => t.kind === "audio")?.id ?? DEFAULT_AUDIO_TRACK_ID;
|
||||
return DEFAULT_SUBTITLE_TRACK_ID;
|
||||
}
|
||||
|
||||
function trackEndMs(project: TimelineProject, trackId: string): number {
|
||||
return project.clips.filter((c) => c.trackId === trackId).reduce((max, c) => Math.max(max, c.startMs + c.durationMs), 0);
|
||||
}
|
||||
|
||||
function AssetIcon({ mediaType }: { mediaType: string }) {
|
||||
const cls = "size-4";
|
||||
if (mediaType === "video") return <Film className={cls} />;
|
||||
if (mediaType === "audio") return <Music2 className={cls} />;
|
||||
if (mediaType === "image") return <ImageIcon className={cls} />;
|
||||
return <Clapperboard className={cls} />;
|
||||
}
|
||||
|
||||
/** 列表行小缩略图:图片媒体显示真实画面;视频/音频无法在 <img> 解码,
|
||||
* 统一渲染类型色块+图标占位(不再出现破图),视频缩略图由展开卡片提供。 */
|
||||
function AssetThumb({ asset }: { asset: ProjectAsset }) {
|
||||
const isImage = asset.mediaType === "image";
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isImage || !asset.storageKey) return;
|
||||
let alive = true;
|
||||
resolveMediaUrl(asset.storageKey)
|
||||
.then((resolved) => alive && setUrl(resolved ?? null))
|
||||
.catch(() => alive && setUrl(null));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [asset.storageKey, isImage]);
|
||||
if (url) {
|
||||
return (
|
||||
<div className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-md bg-[var(--director-control-hover)]">
|
||||
<img src={url} alt="" className="size-full object-cover" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const kind =
|
||||
asset.mediaType === "video"
|
||||
? { icon: <Film className="size-4" />, tone: "text-[var(--director-accent)]", tile: "bg-[var(--director-accent)]/15" }
|
||||
: asset.mediaType === "audio"
|
||||
? { icon: <Music2 className="size-4" />, tone: "text-[var(--director-dock-fg)]/70", tile: "bg-[var(--director-control-hover)]" }
|
||||
: { icon: <ImageIcon className="size-4" />, tone: "text-[var(--director-dock-fg)]/60", tile: "bg-[var(--director-control-hover)]" };
|
||||
return (
|
||||
<div className={`grid size-9 shrink-0 place-items-center overflow-hidden rounded-md ${kind.tile}`}>
|
||||
<span className={kind.tone}>{kind.icon}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 尝试用 <video> 抓取视频首帧作为封面 dataURL;失败返回 null(保持图标占位)。 */
|
||||
function useVideoPoster(url: string | null): string | null {
|
||||
const [poster, setPoster] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setPoster(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = "metadata";
|
||||
video.src = url;
|
||||
const seekToHead = () => {
|
||||
if (!alive || video.readyState < 1) return;
|
||||
try {
|
||||
video.currentTime = Math.min(0.05, Number.isFinite(video.duration) ? video.duration : 0.05);
|
||||
} catch {
|
||||
/* 忽略跨域/编解码异常 */
|
||||
}
|
||||
};
|
||||
const grabFrame = () => {
|
||||
if (!alive || video.readyState < 2 || video.videoWidth === 0) return;
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL("image/jpeg", 0.7);
|
||||
if (dataUrl.length > 100) setPoster(dataUrl);
|
||||
} catch {
|
||||
/* canvas 跨域污染等:放弃自动封面 */
|
||||
}
|
||||
};
|
||||
video.addEventListener("loadeddata", seekToHead);
|
||||
video.addEventListener("seeked", grabFrame);
|
||||
return () => {
|
||||
alive = false;
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
};
|
||||
}, [url]);
|
||||
return poster;
|
||||
}
|
||||
|
||||
/** 展开卡片预览:图片直接展示;视频给出自动首帧封面 + 原生播放器;音频为说明。 */
|
||||
function MediaPreview({ asset }: { asset: ProjectAsset }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!asset.storageKey) return;
|
||||
let alive = true;
|
||||
resolveMediaUrl(asset.storageKey)
|
||||
.then((resolved) => alive && setUrl(resolved ?? null))
|
||||
.catch(() => alive && setUrl(null));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [asset.storageKey]);
|
||||
const poster = useVideoPoster(asset.mediaType === "video" ? url : null);
|
||||
if (asset.mediaType === "image") {
|
||||
return url ? <img src={url} alt="" className="h-28 w-full rounded-md object-cover" /> : <div className="h-28 animate-pulse rounded-md bg-[var(--director-control-hover)]" />;
|
||||
}
|
||||
if (asset.mediaType === "video") {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-md bg-black">
|
||||
<video
|
||||
src={url ?? undefined}
|
||||
poster={poster ?? undefined}
|
||||
preload="metadata"
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-video w-full"
|
||||
/>
|
||||
{!url ? <div className="aspect-video w-full animate-pulse bg-[var(--director-control-hover)]" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-16 items-center justify-center gap-2 rounded-md bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/60">
|
||||
<Music2 className="size-4" />
|
||||
<span className="text-[10px]">音频素材 · 点击下方按钮加入时间线</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: string }) {
|
||||
const uploaded = source === SOURCE_UPLOADED;
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-1 py-px text-[9px] leading-none ${
|
||||
uploaded
|
||||
? "bg-[var(--director-control-hover)] text-[var(--director-dock-fg-strong)]"
|
||||
: "bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/70"
|
||||
}`}
|
||||
>
|
||||
{uploaded ? "本地上传" : "项目素材"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorAssetIngest() {
|
||||
const { projectId, assets, refreshAssets } = useEditorHostContext();
|
||||
const { project, dispatch } = useEditorStoreContext();
|
||||
const [added, setAdded] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [importNote, setImportNote] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!importNote) return;
|
||||
const timer = window.setTimeout(() => setImportNote(null), 3200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [importNote]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const [filter, setFilter] = useState<AssetFilter>("all");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [openGroups, setOpenGroups] = useState<{ uploaded: boolean; project: boolean }>({ uploaded: true, project: true });
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastAddKey = useRef<string | null>(null);
|
||||
const lastAddAt = useRef(0);
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const importFiles = async (fileList: FileList | File[]) => {
|
||||
const media = Array.from(fileList)
|
||||
.map((file) => ({ file, kind: kindFromFile(file) }))
|
||||
.filter((x): x is { file: File; kind: "image" | "video" | "audio" } => x.kind !== null);
|
||||
if (media.length === 0) {
|
||||
setImportError("仅支持视频、音频与图片文件");
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
setImportNote(null);
|
||||
let okCount = 0;
|
||||
let firstErrorMsg: string | null = null;
|
||||
const failedNames: string[] = [];
|
||||
try {
|
||||
// 去重:同名同类型视为同一素材。库内已有或本批内重复的跳过,避免
|
||||
// 再次上传同一大文件生成重复 asset(历史上重复导入会把素材堆积成多条)。
|
||||
const seen = new Set<string>();
|
||||
const toImport = media.filter(({ file, kind }) => {
|
||||
const key = assetDedupeKey(file.name, kind);
|
||||
const known = assets.some((a) => assetDedupeKey(a.title, a.mediaType) === key);
|
||||
const duplicated = seen.has(key);
|
||||
seen.add(key);
|
||||
return !known && !duplicated;
|
||||
});
|
||||
const skipped = media.length - toImport.length;
|
||||
// 逐文件导入:单文件失败不中断整批,汇总失败数提示。
|
||||
for (const { file, kind } of toImport) {
|
||||
try {
|
||||
// 上传前探测真实时长(视频/音频),随 meta 入库供时间线片段使用。
|
||||
const durationMs = await probeMediaDurationMs(file);
|
||||
const resource = await uploadResourceFile(file, kind, durationMs !== undefined ? { durationMs } : undefined);
|
||||
if (resource.status === "failed") {
|
||||
failedNames.push(file.name);
|
||||
if (!firstErrorMsg) firstErrorMsg = resource.error ? `「${file.name}」${resource.error}` : null;
|
||||
continue;
|
||||
}
|
||||
let linked = false;
|
||||
try {
|
||||
await linkProjectAsset(projectId, { assetId: resource.id, category: defaultAssetCategoryForKind(kind), title: file.name });
|
||||
linked = true;
|
||||
} catch {
|
||||
// 链接偶发失败(网络抖动/后端竞态)时重试一次,避免资源已入库却未挂到项目下。
|
||||
linked = await linkProjectAsset(projectId, { assetId: resource.id, category: defaultAssetCategoryForKind(kind), title: file.name })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
if (!linked) {
|
||||
// 两次挂载都失败(asset 可能已创建但未挂上项目):不删除,提示重试——重试走幂等路径可补挂。
|
||||
failedNames.push(file.name);
|
||||
if (!firstErrorMsg) firstErrorMsg = `「${file.name}」已上传但挂载到项目失败,请重试`;
|
||||
continue;
|
||||
}
|
||||
okCount += 1;
|
||||
} catch (err) {
|
||||
failedNames.push(file.name);
|
||||
const detail = extractApiMessage(err);
|
||||
if (!firstErrorMsg) firstErrorMsg = detail ? `「${file.name}」${detail}` : `「${file.name}」导入失败`;
|
||||
}
|
||||
}
|
||||
if (okCount > 0) {
|
||||
await refreshAssets();
|
||||
setImportNote(skipped > 0 ? `已导入 ${okCount} 个,跳过 ${skipped} 个重复文件` : `已导入 ${okCount} 个媒体`);
|
||||
} else if (skipped > 0 && failedNames.length === 0) {
|
||||
setImportNote(`媒体库中已有同名素材,跳过 ${skipped} 个重复文件`);
|
||||
}
|
||||
if (firstErrorMsg) {
|
||||
setImportError(firstErrorMsg);
|
||||
} else if (failedNames.length > 0) {
|
||||
const failedText = failedNames.length === 1 ? `「${failedNames[0]}」` : `${failedNames.length} 个文件`;
|
||||
setImportError(`${failedText}导入失败,请重试`);
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 双击“添加到时间线”会连续 dispatch 两次,产生两个同一素材的相邻片段;用时间戳守卫拦截 1.5s 内的重复添加。
|
||||
const addToTimeline = (asset: ProjectAsset) => {
|
||||
const now = Date.now();
|
||||
if (lastAddKey.current === asset.id && now - lastAddAt.current < 1500) return;
|
||||
lastAddKey.current = asset.id;
|
||||
lastAddAt.current = now;
|
||||
const kind = asset.mediaType === "video" ? "video" : asset.mediaType === "audio" ? "audio" : asset.mediaType === "image" ? "image" : null;
|
||||
if (!kind) return;
|
||||
const trackId = trackIdForKind(kind, project);
|
||||
const startMs = trackEndMs(project, trackId);
|
||||
const clip = makeClipFromAsset(asset, {
|
||||
trackId,
|
||||
startMs,
|
||||
defaultDurationMs: kind === "image" ? 3_000 : 5_000,
|
||||
});
|
||||
if (!clip) return;
|
||||
dispatch({ op: "addClip", payload: { clip } });
|
||||
setAdded(asset.id);
|
||||
setTimeout(() => setAdded(null), 1200);
|
||||
};
|
||||
|
||||
const uploadedAssets = assets.filter((a) => a.source === SOURCE_UPLOADED);
|
||||
const projectAssets = assets.filter((a) => a.source !== SOURCE_UPLOADED);
|
||||
const groups: { id: "uploaded" | "project"; label: string; icon: typeof HardDrive; items: ProjectAsset[] }[] = [
|
||||
{ id: "uploaded", label: "本地上传", icon: HardDrive, items: uploadedAssets },
|
||||
{ id: "project", label: "项目素材", icon: Boxes, items: projectAssets },
|
||||
];
|
||||
const visibleGroups = groups.filter((g) => filter === "all" || g.id === filter);
|
||||
const totalCount = assets.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full min-h-0 flex-col bg-[var(--director-sequencer-surface)] ${dragOver ? "ring-2 ring-inset ring-[var(--director-accent)]" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
void importFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{/* 标题行:媒体库 + 导入按钮 */}
|
||||
<div className="flex items-center gap-1.5 p-2 pb-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={MEDIA_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) void importFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-[var(--director-dock-fg-strong)]">媒体库</span>
|
||||
<span className="rounded-full bg-[var(--director-control-hover)] px-1.5 text-[9px] tabular-nums text-[var(--director-dock-fg)]/70">{totalCount}</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={importing}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex h-7 items-center gap-1.5 rounded-md border border-dashed border-[var(--director-sequencer-border)] px-2.5 text-[11px] font-medium text-[var(--director-dock-fg-strong)] transition-colors hover:border-[var(--director-accent)] hover:bg-[var(--director-control-hover)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{importing ? <Loader2 className="size-3.5 animate-spin" /> : <FolderOpen className="size-3.5" />}
|
||||
{importing ? "导入中…" : "导入媒体"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 来源筛选:全部 / 项目素材 / 本地上传 */}
|
||||
<div className="flex items-center gap-0.5 px-2 pb-1">
|
||||
{FILTER_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
aria-pressed={filter === tab.id}
|
||||
onClick={() => setFilter(tab.id)}
|
||||
className={`h-6 rounded-md px-2 text-[10px] transition-colors ${
|
||||
filter === tab.id
|
||||
? "bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]"
|
||||
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{importError ? <p className="px-2 pb-1 text-[10px] text-[var(--director-danger)]">{importError}</p> : null}
|
||||
{importNote ? <p className="px-2 pb-1 text-[10px] text-[var(--director-success)]">{importNote}</p> : null}
|
||||
|
||||
<div className="director-scroll min-h-0 flex-1 overflow-y-auto p-1.5">
|
||||
{assets.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<p className="text-xs text-[var(--director-dock-fg)]/60">项目暂无资产</p>
|
||||
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">点击上方导入媒体,或将文件拖入此区域</p>
|
||||
</div>
|
||||
) : visibleGroups.every((g) => g.items.length === 0) ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<p className="text-xs text-[var(--director-dock-fg)]/60">
|
||||
{filter === "uploaded" ? "暂无本地上传的媒体" : filter === "project" ? "暂无项目素材" : "项目暂无资产"}
|
||||
</p>
|
||||
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">点击上方导入媒体,或将文件拖入此区域</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{visibleGroups.map((group) => {
|
||||
if (group.items.length === 0) return null;
|
||||
const open = openGroups[group.id];
|
||||
return (
|
||||
<div key={group.id} className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpenGroups((s) => ({ ...s, [group.id]: !open }))}
|
||||
className="group flex h-7 w-full items-center gap-1.5 rounded-md px-1 text-left transition-colors hover:bg-[var(--director-control-hover)]"
|
||||
>
|
||||
<span
|
||||
className={`grid size-4 shrink-0 place-items-center rounded-[5px] transition-colors ${
|
||||
open ? "bg-[var(--director-accent)]/15 text-[var(--director-accent)]" : "bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/55 group-hover:text-[var(--director-dock-fg-strong)]"
|
||||
}`}
|
||||
>
|
||||
<ChevronRight className={`size-3 transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`} />
|
||||
</span>
|
||||
<group.icon className={`size-3.5 shrink-0 transition-colors ${open ? "text-[var(--director-accent)]" : "text-[var(--director-dock-fg)]/60"}`} />
|
||||
<span className="truncate text-[11px] font-medium text-[var(--director-dock-fg-strong)]">{group.label}</span>
|
||||
<span className="ml-auto rounded-full bg-[var(--director-control-hover)] px-1.5 text-[9px] tabular-nums text-[var(--director-dock-fg)]/70">{group.items.length}</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="flex flex-col">
|
||||
{group.items.map((asset) => {
|
||||
const expanded = expandedId === asset.id;
|
||||
const source = asset.source || "";
|
||||
return (
|
||||
<li key={asset.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpandedId(expanded ? null : asset.id)}
|
||||
title={asset.title || asset.storageKey}
|
||||
className={`group flex w-full items-center gap-2 rounded-md px-1 py-[3px] text-left transition-colors ${
|
||||
added === asset.id ? "bg-[var(--director-accent)]/15" : "hover:bg-[var(--director-control-hover)]"
|
||||
}`}
|
||||
>
|
||||
<AssetThumb asset={asset} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[11px] text-[var(--director-dock-fg-strong)]">{asset.title || asset.storageKey}</span>
|
||||
<span className="mt-0.5 flex items-center gap-1 text-[9px] text-[var(--director-dock-fg)]/60">
|
||||
<span className="uppercase">{asset.mediaType}</span>
|
||||
{asset.durationMs ? <span className="tabular-nums opacity-80">{formatDurationMs(asset.durationMs)}</span> : null}
|
||||
<SourceBadge source={source} />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`grid size-5 shrink-0 place-items-center rounded-[5px] transition-colors ${
|
||||
expanded ? "bg-[var(--director-accent)]/15 text-[var(--director-accent)]" : "text-[var(--director-dock-fg)]/40 group-hover:text-[var(--director-dock-fg)]/80"
|
||||
}`}
|
||||
>
|
||||
<ChevronDown className={`size-3.5 transition-transform duration-150 ease-out ${expanded ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div className="mx-1 mb-1 ml-10 rounded-md bg-[var(--director-control-hover)] p-2">
|
||||
|
||||
<MediaPreview asset={asset} />
|
||||
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 text-[10px]">
|
||||
<dt className="text-[var(--director-dock-fg)]/60">媒体类型</dt>
|
||||
<dd className="truncate text-right capitalize text-[var(--director-dock-fg-strong)]">{asset.mediaType}</dd>
|
||||
<dt className="text-[var(--director-dock-fg)]/60">分类</dt>
|
||||
<dd className="truncate text-right text-[var(--director-dock-fg-strong)]">{asset.category}</dd>
|
||||
<dt className="text-[var(--director-dock-fg)]/60">来源</dt>
|
||||
<dd className="text-right text-[var(--director-dock-fg-strong)]">{source === SOURCE_UPLOADED ? "本地上传" : "项目素材"}</dd>
|
||||
{asset.durationMs ? (
|
||||
<>
|
||||
<dt className="text-[var(--director-dock-fg)]/60">时长</dt>
|
||||
<dd className="text-right tabular-nums text-[var(--director-dock-fg-strong)]">{formatDurationMs(asset.durationMs)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
{asset.previewText ? <p className="mt-1 line-clamp-2 text-[10px] leading-relaxed text-[var(--director-dock-fg)]/60">{asset.previewText}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addToTimeline(asset)}
|
||||
className="mt-2 flex h-7 w-full items-center justify-center gap-1 rounded-md bg-[var(--director-accent)] text-[11px] font-medium text-[var(--director-on-accent)] transition-colors hover:bg-[var(--director-accent-hover)]"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
添加到时间线
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 从 Axios 错误中提取后端业务信息({ code, data, msg } 的 msg),无则返回 null。 */
|
||||
function extractApiMessage(err: unknown): string | null {
|
||||
if (typeof err !== "object" || err === null) return null;
|
||||
const anyErr = err as { response?: { data?: { msg?: string } } };
|
||||
const msg = anyErr.response?.data?.msg;
|
||||
if (typeof msg === "string" && msg.trim()) return msg.trim();
|
||||
return null;
|
||||
}
|
||||
232
web/src/lib/plugins/builtin/editor/editor-export.tsx
Normal file
232
web/src/lib/plugins/builtin/editor/editor-export.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
// 导出(editor-shell 预设插件贡献 export-renderer 插槽,M3.7)。
|
||||
// M4.2:默认提交后端渲染任务(POST /timeline/renders,ffmpeg 服务端合成,
|
||||
// 产物落资源存储,ResultJSON 返回 resourceId 供内嵌预览/下载);
|
||||
// ffmpeg.wasm 本地导出保留为离线/降级路径。
|
||||
// §3.1 引用契约:缺失 nodeId 源(节点已删除)按计划跳过,成片不因悬空引用失败。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Download, Loader2, PackageOpen, Server } from "lucide-react";
|
||||
|
||||
import { useEditorHostContext, useEditorStoreContext } from "@/components/editor/editor-context";
|
||||
import { buildTimelineRenderPlan, type TimelineRenderSource } from "@/lib/timeline/timeline-to-ffmpeg";
|
||||
import { exportTimelineToMp4, type TimelineExportProgress } from "@/lib/timeline/timeline-export";
|
||||
import { resourceFileUrl } from "@/services/api/resources";
|
||||
import { waitForGenerationTask } from "@/services/api/task-center";
|
||||
import { createTimelineRenderTask, type TimelineRenderResult } from "@/services/api/timeline-tasks";
|
||||
import type { TimelineProject } from "@/types/timeline";
|
||||
|
||||
type ExportState = {
|
||||
phase: "idle" | "running" | "done" | "error";
|
||||
mode: "remote" | "local" | null;
|
||||
percent: number;
|
||||
detail: string;
|
||||
result: TimelineRenderResult | null;
|
||||
};
|
||||
|
||||
/** 从时间线 clip 收集渲染源(按 nodeId 关联;directMedia 提供本地媒体定位)。 */
|
||||
function collectRenderSources(project: TimelineProject): TimelineRenderSource[] {
|
||||
const seen = new Set<string>();
|
||||
const sources: TimelineRenderSource[] = [];
|
||||
for (const clip of project.clips) {
|
||||
if (clip.kind !== "video" && clip.kind !== "image") continue;
|
||||
const direct = clip.directMedia;
|
||||
if (!direct) continue;
|
||||
if (seen.has(clip.nodeId)) continue;
|
||||
seen.add(clip.nodeId);
|
||||
sources.push({
|
||||
nodeId: clip.nodeId,
|
||||
fileName: `input-${sources.length}.mp4`,
|
||||
durationMs: clip.durationMs,
|
||||
storageKey: direct.storageKey,
|
||||
url: direct.url,
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function EditorExport() {
|
||||
const { project } = useEditorStoreContext();
|
||||
const { projectId } = useEditorHostContext();
|
||||
const [state, setState] = useState<ExportState>({ phase: "idle", mode: null, percent: 0, detail: "", result: null });
|
||||
|
||||
const sources = useMemo(() => (project ? collectRenderSources(project) : []), [project]);
|
||||
const plan = useMemo(() => (project ? buildTimelineRenderPlan(project, sources) : null), [project, sources]);
|
||||
|
||||
// 主路径:提交后端渲染任务并轮询(服务端任务上限 60 分钟,前端多留余量)。
|
||||
const renderRemote = async () => {
|
||||
if (!project || sources.length === 0 || state.phase === "running") return;
|
||||
setState({ phase: "running", mode: "remote", percent: 0, detail: "提交渲染任务…", result: null });
|
||||
try {
|
||||
const created = await createTimelineRenderTask({ projectId, timeline: project });
|
||||
const done = await waitForGenerationTask(created.id, {
|
||||
timeoutMs: 62 * 60 * 1000,
|
||||
intervalMs: 3000,
|
||||
onTaskUpdate: (task) =>
|
||||
setState({
|
||||
phase: "running",
|
||||
mode: "remote",
|
||||
percent: task.progress ?? 0,
|
||||
detail: task.stage
|
||||
? `${task.stage}${task.progress ? ` · ${task.progress}%` : ""}`
|
||||
: "渲染中…",
|
||||
result: null,
|
||||
}),
|
||||
});
|
||||
const parsed = JSON.parse(done.resultJson ?? "{}") as TimelineRenderResult;
|
||||
if (!parsed.resourceId) throw new Error("渲染任务未返回产物");
|
||||
setState({
|
||||
phase: "done",
|
||||
mode: "remote",
|
||||
percent: 100,
|
||||
detail: `渲染完成:${parsed.fileName ?? "timeline.mp4"}`,
|
||||
result: parsed,
|
||||
});
|
||||
} catch (error) {
|
||||
setState({
|
||||
phase: "error",
|
||||
mode: "remote",
|
||||
percent: 0,
|
||||
detail: error instanceof Error ? error.message : "渲染失败",
|
||||
result: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 降级路径:ffmpeg.wasm 浏览器本地合成(无后端/离线时可用)。
|
||||
const exportLocalMp4 = async () => {
|
||||
if (!project || sources.length === 0 || state.phase === "running") return;
|
||||
setState({ phase: "running", mode: "local", percent: 0, detail: "准备导出", result: null });
|
||||
try {
|
||||
const blob = await exportTimelineToMp4(project, sources, {
|
||||
onProgress: (p: TimelineExportProgress) =>
|
||||
setState({ phase: "running", mode: "local", percent: p.percent, detail: p.detail, result: null }),
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "timeline-export.mp4";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setState({ phase: "done", mode: "local", percent: 100, detail: "导出完成,已开始下载", result: null });
|
||||
} catch (error) {
|
||||
setState({
|
||||
phase: "error",
|
||||
mode: "local",
|
||||
percent: 0,
|
||||
detail: error instanceof Error ? error.message : "导出失败",
|
||||
result: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--director-sequencer-surface)]">
|
||||
<div className="director-scroll min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="mb-3 rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">渲染计划</span>
|
||||
<span className="text-[10px] text-[var(--director-dock-fg)]/60">{plan ? `${plan.steps.length} 步` : "—"}</span>
|
||||
</div>
|
||||
{plan && plan.steps.length > 0 ? (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{plan.steps.slice(0, 8).map((step, index) => (
|
||||
<li key={index} className="flex items-center gap-2 text-[11px] text-[var(--director-dock-fg)]/70">
|
||||
<span className="shrink-0 rounded bg-[var(--director-dock-active-surface)] px-1 py-0.5 font-mono text-[9px] uppercase text-[var(--director-dock-fg)]/80">
|
||||
{step.kind}
|
||||
</span>
|
||||
<span className="truncate">{step.description}</span>
|
||||
</li>
|
||||
))}
|
||||
{plan.steps.length > 8 && (
|
||||
<li className="pt-0.5 text-[10px] text-[var(--director-dock-fg)]/50">…共 {plan.steps.length} 步</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] text-[var(--director-dock-fg)]/55">时间线没有可渲染的视频片段。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex items-center justify-between rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] px-2.5 py-2 text-[11px] text-[var(--director-dock-fg)]/70">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<PackageOpen className="size-3.5 text-[var(--director-dock-fg)]/60" />
|
||||
渲染源
|
||||
</span>
|
||||
<span className="tabular-nums text-[var(--director-dock-fg)]/80">{sources.length} 个</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={renderRemote}
|
||||
disabled={sources.length === 0 || state.phase === "running"}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-[var(--director-accent)] px-2 py-1.5 text-xs font-medium text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)] disabled:opacity-40"
|
||||
>
|
||||
{state.phase === "running" && state.mode === "remote" ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Server className="size-3.5" />
|
||||
)}
|
||||
{state.phase === "running" && state.mode === "remote" ? "渲染中…" : "渲染成片(服务端)"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportLocalMp4}
|
||||
disabled={sources.length === 0 || state.phase === "running"}
|
||||
className="mt-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-[var(--director-sequencer-border)] px-2 py-1.5 text-xs text-[var(--director-dock-fg)]/80 hover:bg-[var(--director-control-hover)] disabled:opacity-40"
|
||||
>
|
||||
{state.phase === "running" && state.mode === "local" ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-3.5" />
|
||||
)}
|
||||
{state.phase === "running" && state.mode === "local" ? "导出中…" : "本地导出(ffmpeg.wasm)"}
|
||||
</button>
|
||||
|
||||
{state.phase === "running" && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 flex justify-between text-[10px] text-[var(--director-dock-fg)]/70">
|
||||
<span>{state.detail}</span>
|
||||
<span className="tabular-nums">{state.percent}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--director-control-hover)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--director-accent)] transition-all"
|
||||
style={{ width: `${state.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === "done" && state.result && state.mode === "remote" && (
|
||||
<div className="mt-2 overflow-hidden rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)]">
|
||||
<video src={resourceFileUrl(state.result.resourceId)} controls className="aspect-video w-full bg-black" />
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5 text-[11px] text-[var(--director-dock-fg)]/80">
|
||||
<span className="truncate">{state.result.fileName ?? "timeline.mp4"}</span>
|
||||
<a
|
||||
href={resourceFileUrl(state.result.resourceId)}
|
||||
download={state.result.fileName ?? "timeline.mp4"}
|
||||
className="flex shrink-0 items-center gap-1 text-[var(--director-accent)] hover:underline"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
下载
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{state.phase === "done" && (!state.result || state.mode !== "remote") && (
|
||||
<p className="mt-2 text-[11px] text-[var(--director-success)]">{state.detail}</p>
|
||||
)}
|
||||
{state.phase === "error" && <p className="mt-2 text-[11px] text-[var(--director-danger)]">{state.detail}</p>}
|
||||
|
||||
<p className="mt-3 text-[11px] leading-relaxed text-[var(--director-dock-fg)]/55">
|
||||
默认提交服务端渲染任务(异步,产物可直接预览/下载);本地 ffmpeg.wasm 导出保留为离线兜底。
|
||||
</p>
|
||||
{sources.length === 0 && (
|
||||
<p className="mt-1 text-[11px] text-[var(--director-dock-fg)]/55">悬空引用片段(节点已删除)按计划跳过,不影响其余片段导出。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
web/src/lib/plugins/builtin/editor/editor-inspector.tsx
Normal file
198
web/src/lib/plugins/builtin/editor/editor-inspector.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
// 片段检查器(editor-shell 预设插件贡献 inspector 插槽,M3.3)。
|
||||
// 读取选中片段(store.selectedClipId),属性编辑经 setClipProperty 命令入队
|
||||
// (可撤销、可回放、进黄金文件语义);无选中时显示项目概览。
|
||||
// 视觉参照 Concat inspector:分组小标题 + 紧凑控件 + sunken 滑块,token 随主题联动。
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useEditorStoreContext } from "@/components/editor/editor-context";
|
||||
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
|
||||
import type { TimelineClip } from "@/types/timeline";
|
||||
|
||||
export function EditorInspector() {
|
||||
const { project, selectedClipId, dispatch, selectClip } = useEditorStoreContext();
|
||||
const clip = project?.clips.find((c) => c.id === selectedClipId) ?? null;
|
||||
|
||||
if (!clip) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--director-sequencer-surface)]">
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<p className="text-xs text-[var(--director-dock-fg)]/60">未选中片段</p>
|
||||
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">在时间线中点击片段后,可在此编辑属性</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--director-sequencer-surface)]">
|
||||
<div className="director-scroll min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||
<SectionTitle>片段</SectionTitle>
|
||||
<ClipSummary clip={clip} />
|
||||
<SectionTitle>基本</SectionTitle>
|
||||
<PropertyField
|
||||
label="名称"
|
||||
value={clip.title ?? clip.nodeId ?? clip.id}
|
||||
onChange={(value) => dispatch({ op: "setClipProperty", payload: { id: clip.id, patch: { title: value } } })}
|
||||
/>
|
||||
{clip.kind === "subtitle" && (
|
||||
<PropertyField
|
||||
label="字幕文本"
|
||||
value={clip.text ?? ""}
|
||||
onChange={(value) => dispatch({ op: "setClipProperty", payload: { id: clip.id, patch: { text: value } } })}
|
||||
/>
|
||||
)}
|
||||
{clip.kind === "audio" && (
|
||||
<PropertyField
|
||||
label="音量"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={clip.volume ?? 1}
|
||||
formatValue={(v) => `${Math.round(v * 100)}%`}
|
||||
onChange={(value) => dispatch({ op: "setClipProperty", payload: { id: clip.id, patch: { volume: Number(value) } } })}
|
||||
/>
|
||||
)}
|
||||
{(clip.kind === "video" || clip.kind === "audio") && (
|
||||
<>
|
||||
<SectionTitle>转场</SectionTitle>
|
||||
<PropertyField
|
||||
label="淡入"
|
||||
type="range"
|
||||
min={0}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={clip.fadeInMs ?? 0}
|
||||
formatValue={(v) => `${(v / 1000).toFixed(1)}s`}
|
||||
onChange={(value) => dispatch({ op: "setClipProperty", payload: { id: clip.id, patch: { fadeInMs: Number(value) } } })}
|
||||
/>
|
||||
<PropertyField
|
||||
label="淡出"
|
||||
type="range"
|
||||
min={0}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={clip.fadeOutMs ?? 0}
|
||||
formatValue={(v) => `${(v / 1000).toFixed(1)}s`}
|
||||
onChange={(value) => dispatch({ op: "setClipProperty", payload: { id: clip.id, patch: { fadeOutMs: Number(value) } } })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-[var(--director-sequencer-border)] p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectClip(null)}
|
||||
className="w-full rounded-md border border-[var(--director-sequencer-border)] py-1.5 text-xs text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
|
||||
>
|
||||
清除选中
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="mb-2 mt-4 text-[10px] font-medium uppercase tracking-wider text-[var(--director-dock-fg)]/45 first:mt-0">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function ClipSummary({ clip }: { clip: TimelineClip }) {
|
||||
const kindLabel: Record<TimelineClip["kind"], string> = {
|
||||
video: "视频",
|
||||
audio: "音频",
|
||||
subtitle: "字幕",
|
||||
text: "文本",
|
||||
image: "图片",
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">{kindLabel[clip.kind] ?? clip.kind}</span>
|
||||
<span className="font-mono text-[10px] text-[var(--director-dock-fg)]/40">#{clip.id.slice(-6)}</span>
|
||||
</div>
|
||||
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<dt className="shrink-0 text-[var(--director-dock-fg)]/45">起点</dt>
|
||||
<dd className="tabular-nums text-[var(--director-dock-fg)]/80">{formatTimelineTime(clip.startMs)}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<dt className="shrink-0 text-[var(--director-dock-fg)]/45">时长</dt>
|
||||
<dd className="tabular-nums text-[var(--director-dock-fg)]/80">{formatTimelineTime(clip.durationMs)}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<dt className="shrink-0 text-[var(--director-dock-fg)]/45">源起点</dt>
|
||||
<dd className="tabular-nums text-[var(--director-dock-fg)]/80">{formatTimelineTime(clip.sourceStartMs ?? 0)}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<dt className="shrink-0 text-[var(--director-dock-fg)]/45">结束</dt>
|
||||
<dd className="tabular-nums text-[var(--director-dock-fg)]/80">{formatTimelineTime(clip.startMs + clip.durationMs)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
formatValue,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
type?: "text" | "range";
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
formatValue?: (value: number) => string;
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState<string>(String(value));
|
||||
useEffect(() => setLocalValue(String(value)), [value]);
|
||||
|
||||
const displayValue = type === "range" && formatValue ? formatValue(Number(localValue)) : localValue;
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11px] text-[var(--director-dock-fg)]/70">{label}</span>
|
||||
{type === "range" && (
|
||||
<span className="tabular-nums text-[10px] text-[var(--director-dock-fg)]/50">{displayValue}</span>
|
||||
)}
|
||||
</div>
|
||||
{type === "range" ? (
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={Number(localValue)}
|
||||
onChange={(e) => {
|
||||
setLocalValue(e.target.value);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
className="editor-slider"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (localValue !== String(value)) onChange(localValue);
|
||||
}}
|
||||
className="h-7 w-full rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-2 text-xs text-[var(--director-dock-fg-strong)] outline-none transition-colors focus:border-[var(--director-dock-fg-strong)]/40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
523
web/src/lib/plugins/builtin/editor/editor-preview-monitor.tsx
Normal file
523
web/src/lib/plugins/builtin/editor/editor-preview-monitor.tsx
Normal file
@@ -0,0 +1,523 @@
|
||||
// 预览监视器(editor-shell 预设插件贡献 preview-renderer 插槽,M3.2)。
|
||||
// 浏览器内近似预览:真实媒体帧(storageKey → resolveMediaUrl)+ 播放头 + 时间码。
|
||||
// 近似渲染与导出(M3.7 的 buildTimelineRenderPlan)共享同一条"片段→媒体"解析路径,
|
||||
// 但这里只做时序呈现,不承诺像素级预览——像素级交给导出任务(M4)。
|
||||
//
|
||||
// 播放模型:时间线 transport(store.transportMs)与监视器共享。
|
||||
// - 本地 playbackRef 以 rAF 逐帧推进(60fps 时间码),节流回写 store(~80ms),
|
||||
// 驱动时间线播放头;外部 scrub(时间线标尺)经 transportMs 回推本地时钟。
|
||||
// - <video> 元素从动于时钟:播放时 play()、暂停时 pause()、位置偏差 > 阈值才硬 seek,
|
||||
// 播放速率同步 playbackRate —— 画面与声音由浏览器管线真实推进(不再纯读时间码)。
|
||||
// - 静音跟随所在音频/视频轨道(muted),不再硬编码 muted(否则"有声音"永远不成立)。
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Film, Gauge, Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
|
||||
|
||||
import { useEditorStoreContext } from "@/components/editor/editor-context";
|
||||
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { playbackVariantUrl, refreshResource, resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { TimelineClip, TimelineProject } from "@/types/timeline";
|
||||
|
||||
// 本地播放时钟 → store.transportMs 的回写节流(毫秒)。太密会让时间线面板每帧重渲染。
|
||||
const STORE_PUSH_INTERVAL_MS = 80;
|
||||
// 外部 seek 与本地时钟差超过该值(毫秒)视为"拖动跳转":先暂停再跟随,避免播放中抢时钟。
|
||||
const SEEK_JUMP_MS = 500;
|
||||
// 视频元素位置校正阈值(秒):仅当偏差超过该值才硬 seek,避免逐帧写 currentTime。
|
||||
const VIDEO_SEEK_TOLERANCE_S = 0.35;
|
||||
|
||||
function getClipAtTime(project: TimelineProject, timeMs: number): TimelineClip | null {
|
||||
// 隐藏轨道(visible === false)上的片段不参与预览合成。
|
||||
const hiddenTrackIds = new Set(project.tracks.filter((t) => t.visible === false).map((t) => t.id));
|
||||
const visible = (clip: TimelineClip) => !hiddenTrackIds.has(clip.trackId);
|
||||
return (
|
||||
project.clips.find((clip) => visible(clip) && clip.kind === "video" && clip.startMs <= timeMs && timeMs < clip.startMs + clip.durationMs) ??
|
||||
project.clips.find((clip) => visible(clip) && clip.kind === "image" && clip.startMs <= timeMs && timeMs < clip.startMs + clip.durationMs) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function useClipMediaUrl(clip: TimelineClip | null): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setUrl(null);
|
||||
if (!clip) return;
|
||||
const direct = clip.directMedia;
|
||||
const source = direct?.dataUrl ?? direct?.url ?? direct?.storageKey;
|
||||
if (direct?.storageKey) {
|
||||
resolveMediaUrl(direct.storageKey, direct.url ?? "")
|
||||
.catch(() => null)
|
||||
.then((resolved) => {
|
||||
if (!cancelled && resolved) setUrl(resolved);
|
||||
});
|
||||
} else if (source) {
|
||||
setUrl(source);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clip?.id, clip?.nodeId, clip?.directMedia?.storageKey, clip?.directMedia?.url, clip?.directMedia?.dataUrl]);
|
||||
return url;
|
||||
}
|
||||
|
||||
/** clip 内部当前应播放的源时间(毫秒):transport 相对 clip 起点 + 源起点偏移。 */
|
||||
function clipSourceTimeMs(clip: TimelineClip, transportMs: number): number {
|
||||
return Math.max(0, transportMs - clip.startMs + (clip.sourceStartMs ?? 0));
|
||||
}
|
||||
|
||||
export function EditorPreviewMonitor() {
|
||||
const { project, transportMs, setTransportMs } = useEditorStoreContext();
|
||||
const durationMs = project?.durationMs ?? 0;
|
||||
|
||||
// 本地播放时钟(毫秒):逐帧刷新本组件;播放态节流回写 store 驱动时间线播放头。
|
||||
const [playbackMs, setPlaybackMs] = useState(0);
|
||||
const playbackRef = useRef(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const playingRef = useRef(false);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const speedRef = useRef(1);
|
||||
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastTickRef = useRef<number | null>(null);
|
||||
const lastStorePushRef = useRef(0);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const activeClip = project ? getClipAtTime(project, playbackMs) : null;
|
||||
const activeMediaUrl = useClipMediaUrl(activeClip);
|
||||
const activeTrack = project?.tracks.find((t) => t.id === activeClip?.trackId) ?? null;
|
||||
|
||||
// 播放回退:H.264 原件直接播放;H.265/HEVC 原件多数浏览器无法解码(黑屏),
|
||||
// <video> onError 后切换后端 playback 转码副本(variant=playback),副本就绪前轮询。
|
||||
const storageKey = activeClip?.directMedia?.storageKey;
|
||||
const mediaResourceId = storageKey ? resourceIdFromStorageKey(storageKey) : null;
|
||||
const [mediaTier, setMediaTier] = useState<"primary" | "variant">("primary");
|
||||
const [variantReadyTick, setVariantReadyTick] = useState(0);
|
||||
const [mediaErrorHint, setMediaErrorHint] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMediaTier("primary");
|
||||
setVariantReadyTick(0);
|
||||
setMediaErrorHint(null);
|
||||
}, [activeClip?.id, storageKey, activeMediaUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaTier !== "variant" || !mediaResourceId) return;
|
||||
let cancelled = false;
|
||||
let failures = 0;
|
||||
let polls = 0;
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await refreshResource(mediaResourceId);
|
||||
if (cancelled) return;
|
||||
if (res.playbackStatus === "ready") {
|
||||
setMediaErrorHint(null);
|
||||
setVariantReadyTick((n) => n + 1);
|
||||
window.clearInterval(timer);
|
||||
} else if (res.playbackStatus === "failed") {
|
||||
setMediaErrorHint("兼容副本生成失败,可下载原片后用本地播放器观看。");
|
||||
window.clearInterval(timer);
|
||||
} else if (res.playbackStatus === "none") {
|
||||
// 后端判定无需转码(远端存储/无 ffmpeg/编码不可处理):无副本可等,
|
||||
// 停轮询回到原件原生播放,避免"正在生成"永挂。
|
||||
setMediaTier("primary");
|
||||
setMediaErrorHint(null);
|
||||
window.clearInterval(timer);
|
||||
} else if ((polls += 1) >= 120) {
|
||||
// processing 上限保护(约 5 分钟):转码异常卡死时不再无限轮询。
|
||||
setMediaErrorHint("兼容版本生成超时,可下载原片后用本地播放器观看。");
|
||||
window.clearInterval(timer);
|
||||
} else {
|
||||
setMediaErrorHint("视频编码此浏览器暂不支持,正在生成兼容版本(H.264)…");
|
||||
}
|
||||
failures = 0;
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
failures += 1;
|
||||
if (failures >= 4) {
|
||||
window.clearInterval(timer);
|
||||
setMediaErrorHint("兼容版本生成服务暂不可达,请稍后重新打开预览重试。");
|
||||
}
|
||||
}
|
||||
}, 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [mediaTier, mediaResourceId]);
|
||||
|
||||
const handleMediaError = () => {
|
||||
// 图片/无资源:无播放副本可切,仅提示。
|
||||
if (activeClip?.kind !== "video" || !mediaResourceId) {
|
||||
setMediaErrorHint("该媒体无法解码,可下载原片后用本地播放器观看。");
|
||||
return;
|
||||
}
|
||||
if (mediaTier === "primary") {
|
||||
// 原件解码失败(大概率 H.265):切后端 playback 副本。
|
||||
setMediaTier("variant");
|
||||
setMediaErrorHint("视频编码此浏览器暂不支持,正在生成兼容版本(H.264)…");
|
||||
} else {
|
||||
// 副本尚未就绪时后端回退原件仍会失败;轮询 effect 会在就绪后重载。
|
||||
setMediaErrorHint("视频编码此浏览器暂不支持,正在生成兼容版本(H.264)…");
|
||||
}
|
||||
};
|
||||
|
||||
const isVideoClip = activeClip?.kind === "video";
|
||||
const videoSrc = isVideoClip && mediaResourceId && mediaTier === "variant" ? playbackVariantUrl(mediaResourceId) : isVideoClip ? activeMediaUrl : null;
|
||||
const imageSrc = activeClip?.kind === "image" ? activeMediaUrl : null;
|
||||
const videoMuted = activeTrack?.muted === true;
|
||||
|
||||
const syncPlayback = useCallback(
|
||||
(ms: number) => {
|
||||
const t = Math.max(0, Math.min(durationMs, Math.round(ms)));
|
||||
playbackRef.current = t;
|
||||
setPlaybackMs(t);
|
||||
setTransportMs(t);
|
||||
},
|
||||
[durationMs, setTransportMs],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
playingRef.current = false;
|
||||
setPlaying(false);
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
lastTickRef.current = null;
|
||||
lastStorePushRef.current = 0;
|
||||
const v = videoRef.current;
|
||||
if (v && !v.paused) v.pause();
|
||||
setTransportMs(playbackRef.current);
|
||||
}, [setTransportMs]);
|
||||
|
||||
// 外部 transport 变化(时间线标尺 scrub / 跳转):回推本地时钟;
|
||||
// 播放中大幅跳动视为拖动跳转,先暂停避免与本地时钟互相抢写。
|
||||
useEffect(() => {
|
||||
if (transportMs === playbackRef.current) return;
|
||||
if (playingRef.current) {
|
||||
// 播放中收到的 transport 写入只可能是自身节流回写或外部 scrub;
|
||||
// 大幅跳动视为拖动/跳转:暂停并跟随。小幅忽略,避免本地时钟被旧回写值拉回(回跳)。
|
||||
if (Math.abs(transportMs - playbackRef.current) > SEEK_JUMP_MS) {
|
||||
stop();
|
||||
playbackRef.current = transportMs;
|
||||
setPlaybackMs(transportMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
playbackRef.current = transportMs;
|
||||
setPlaybackMs(transportMs);
|
||||
// stop 已是最新引用;transportMs 为唯一驱动源
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [transportMs]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!project || durationMs <= 0) return;
|
||||
if (playingRef.current) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
// 停在末尾再点播放:从头开始。
|
||||
if (playbackRef.current >= durationMs - 50) {
|
||||
playbackRef.current = 0;
|
||||
setPlaybackMs(0);
|
||||
setTransportMs(0);
|
||||
}
|
||||
playingRef.current = true;
|
||||
lastTickRef.current = null;
|
||||
setPlaying(true);
|
||||
const tick = (now: number) => {
|
||||
if (lastTickRef.current === null) lastTickRef.current = now;
|
||||
const delta = now - lastTickRef.current;
|
||||
lastTickRef.current = now;
|
||||
const next = Math.min(durationMs, Math.max(0, playbackRef.current + delta * speedRef.current));
|
||||
playbackRef.current = next;
|
||||
setPlaybackMs(next);
|
||||
if (now - lastStorePushRef.current >= STORE_PUSH_INTERVAL_MS) {
|
||||
lastStorePushRef.current = now;
|
||||
setTransportMs(next);
|
||||
}
|
||||
if (next >= durationMs) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}, [durationMs, project, stop, setTransportMs]);
|
||||
|
||||
// 项目切换/时长变化时重置播放
|
||||
useEffect(() => {
|
||||
syncPlayback(0);
|
||||
stop();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project, durationMs]);
|
||||
|
||||
useEffect(() => () => stop(), [stop]);
|
||||
|
||||
const stepBy = (deltaMs: number) => {
|
||||
if (!project || durationMs <= 0) return;
|
||||
syncPlayback(playbackRef.current + deltaMs);
|
||||
};
|
||||
|
||||
const changeSpeed = (next: number) => {
|
||||
speedRef.current = next;
|
||||
setSpeed(next);
|
||||
};
|
||||
|
||||
// —— <video> 从动驱动 ——
|
||||
|
||||
const applyVideoPosition = (v: HTMLVideoElement) => {
|
||||
if (!activeClip || activeClip.kind !== "video") return;
|
||||
const targetS = clipSourceTimeMs(activeClip, playbackRef.current) / 1000;
|
||||
const maxS = Number.isFinite(v.duration) && v.duration > 0 ? v.duration : targetS;
|
||||
const t = Math.min(targetS, maxS);
|
||||
if (Math.abs(v.currentTime - t) > VIDEO_SEEK_TOLERANCE_S) v.currentTime = t;
|
||||
};
|
||||
|
||||
// 时钟推进/外部 seek:校正视频位置(播放同速时偏差小,几乎不触发硬 seek)。
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || !isVideoClip) return;
|
||||
if (v.readyState < HTMLMediaElement.HAVE_METADATA) return;
|
||||
applyVideoPosition(v);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playbackMs, mediaTier, variantReadyTick, activeClip?.id]);
|
||||
|
||||
// 播放/暂停:真实驱动浏览器管线(画面 + 声音)。
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || !isVideoClip) return;
|
||||
if (playing) {
|
||||
const p = v.play();
|
||||
if (p) p.catch(() => {
|
||||
// 浏览器 autoplay/格式限制:onError 已接管提示与回退。
|
||||
});
|
||||
} else if (!v.paused) {
|
||||
v.pause();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playing, mediaTier, variantReadyTick, activeClip?.id, videoSrc]);
|
||||
|
||||
// 变速:同步视频播放速率,保持音画与时钟一致。
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (v && isVideoClip) v.playbackRate = speed;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [speed, mediaTier, variantReadyTick, activeClip?.id]);
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
applyVideoPosition(v);
|
||||
if (playingRef.current) {
|
||||
const p = v.play();
|
||||
if (p) p.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// 源素材比片段短:video 提前播完 → 把时钟对齐到片段结尾,让后续片段/结束自然衔接。
|
||||
const handleVideoEnded = () => {
|
||||
if (!activeClip || activeClip.kind !== "video") return;
|
||||
const clipEndMs = activeClip.startMs + activeClip.durationMs;
|
||||
if (clipEndMs >= durationMs - 50) {
|
||||
stop();
|
||||
syncPlayback(durationMs);
|
||||
} else {
|
||||
syncPlayback(clipEndMs);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放条随监视器面板宽度自适应:窄面板逐级隐藏次要信息(先“/ 总时长”,再时间码与速度文字/箭头),
|
||||
// 极窄时让整条可横向滚动兜底;走带按钮始终固定尺寸,不缩小、不重叠。阈值即对应内容的最小显示宽度。
|
||||
const transportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [transportWidth, setTransportWidth] = useState(Number.POSITIVE_INFINITY);
|
||||
useLayoutEffect(() => {
|
||||
const el = transportRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setTransportWidth(Math.round(el.getBoundingClientRect().width));
|
||||
update();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const compact = transportWidth < 340;
|
||||
const hideTotal = !compact && transportWidth < 440;
|
||||
const allowScroll = transportWidth < 240;
|
||||
|
||||
const showEmpty = !activeClip || (!videoSrc && !imageSrc);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-[var(--director-sequencer-surface)]">
|
||||
{/* 预览画面:恒黑舞台,占满控制条以上剩余空间(Concat 监视器风格) */}
|
||||
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black p-6">
|
||||
{showEmpty ? (
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="grid size-14 place-items-center rounded-xl bg-white/10">
|
||||
<Film className="size-6 text-white/60" />
|
||||
</div>
|
||||
<div className="max-w-xs text-xs leading-relaxed text-white/70">
|
||||
{project && project.clips.length > 0
|
||||
? activeClip
|
||||
? "当前时间点片段无媒体源(占位/字幕/音频)"
|
||||
: "播放头位于空白处"
|
||||
: "时间线暂无片段,导入素材后在此预览"}
|
||||
</div>
|
||||
</div>
|
||||
) : isVideoClip && videoSrc ? (
|
||||
<video
|
||||
key={`v:${activeClip!.id}:${mediaTier}:${variantReadyTick}`}
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
muted={videoMuted}
|
||||
playsInline
|
||||
preload="auto"
|
||||
className="max-h-full max-w-full rounded-md object-contain shadow-lg"
|
||||
onError={handleMediaError}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={handleVideoEnded}
|
||||
/>
|
||||
) : imageSrc ? (
|
||||
<img
|
||||
key={`i:${activeClip!.id}`}
|
||||
src={imageSrc}
|
||||
alt=""
|
||||
className="max-h-full max-w-full rounded-md object-contain shadow-lg"
|
||||
onError={handleMediaError}
|
||||
/>
|
||||
) : null}
|
||||
{mediaErrorHint && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center gap-2 bg-black/70 px-4 py-3 backdrop-blur">
|
||||
<p className="text-xs leading-relaxed text-white/90">{mediaErrorHint}</p>
|
||||
{mediaResourceId && (
|
||||
<a
|
||||
href={resourceFileUrl(mediaResourceId)}
|
||||
download
|
||||
className="text-[11px] underline decoration-white/40 underline-offset-2 text-white/70 hover:text-white"
|
||||
>
|
||||
下载原片用本地播放器观看
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeClip && (
|
||||
<div className="absolute left-4 top-4 rounded-md bg-black/60 px-2 py-1 text-[11px] text-white/90 backdrop-blur">
|
||||
{activeClip.nodeId || activeClip.id} · {activeClip.kind}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 播放控制条:画面下方的独立一行(Concat 布局),不悬浮叠加、无进度条;
|
||||
播放/暂停用反白块 + 图标形态(Play/Pause)表达状态,图标保持中性(无 accent 蓝)。
|
||||
宽度自适应见上方 transportWidth 逻辑。 */}
|
||||
<div
|
||||
ref={transportRef}
|
||||
className={`grid h-11 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 border-t border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3 ${
|
||||
allowScroll ? "overflow-x-auto [scrollbar-width:thin]" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate text-xs tabular-nums text-[var(--director-dock-fg)]">
|
||||
{!compact && formatTimelineTime(playbackMs)}
|
||||
{!compact && !hideTotal && (
|
||||
<span className="opacity-60"> / {formatTimelineTime(durationMs)}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="回到开头"
|
||||
title="回到开头"
|
||||
onClick={() => syncPlayback(0)}
|
||||
disabled={!project || durationMs <= 0}
|
||||
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<SkipBack className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="后退 1 秒"
|
||||
title="后退 1 秒"
|
||||
onClick={() => stepBy(-1000)}
|
||||
disabled={!project || durationMs <= 0}
|
||||
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<StepBack className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={playing ? "暂停" : "播放"}
|
||||
onClick={toggle}
|
||||
disabled={durationMs <= 0}
|
||||
className={`grid size-7 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40 ${
|
||||
playing
|
||||
? "bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]"
|
||||
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
|
||||
}`}
|
||||
>
|
||||
{playing ? <Pause className="size-4" /> : <Play className="size-4 translate-x-px" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="前进 1 秒"
|
||||
title="前进 1 秒"
|
||||
onClick={() => stepBy(1000)}
|
||||
disabled={!project || durationMs <= 0}
|
||||
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<StepForward className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="跳到结尾"
|
||||
title="跳到结尾"
|
||||
onClick={() => syncPlayback(durationMs)}
|
||||
disabled={!project || durationMs <= 0}
|
||||
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<SkipForward className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative justify-self-end">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="播放速度"
|
||||
title="播放速度"
|
||||
onClick={() => setSpeedMenuOpen((v) => !v)}
|
||||
className="flex h-7 items-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
|
||||
>
|
||||
<Gauge className="size-3.5" />
|
||||
{!compact && <span className="tabular-nums">{speed}x</span>}
|
||||
{!compact && (
|
||||
<ChevronDown className={`size-3 transition-transform ${speedMenuOpen ? "rotate-180" : ""}`} />
|
||||
)}
|
||||
</button>
|
||||
{speedMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setSpeedMenuOpen(false)} />
|
||||
<div className="absolute bottom-full right-0 z-50 mb-1 w-24 overflow-hidden rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] py-1 shadow-xl">
|
||||
{[0.5, 1, 1.5, 2].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
changeSpeed(s);
|
||||
setSpeedMenuOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center justify-between px-2.5 py-1.5 text-[11px] transition-colors hover:bg-[var(--director-control-hover)] ${
|
||||
s === speed ? "text-[var(--director-dock-fg-strong)]" : "text-[var(--director-dock-fg)]"
|
||||
}`}
|
||||
>
|
||||
<span>{s}x</span>
|
||||
{s === speed && <span className="opacity-60">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
web/src/lib/plugins/builtin/editor/editor-shell.tsx
Normal file
98
web/src/lib/plugins/builtin/editor/editor-shell.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { registerPlugin } from "@/lib/plugins/plugin-registry";
|
||||
import type { PluginManifestV2, RegisteredPlugin } from "@/lib/plugins/plugin-types";
|
||||
import { registerEditorSlot } from "@/lib/plugins/editor-slot-registry";
|
||||
import { EditorTimelinePanel } from "./editor-timeline-panel";
|
||||
import { EditorPreviewMonitor } from "./editor-preview-monitor";
|
||||
import { EditorSubtitleTools } from "./editor-subtitle-tools";
|
||||
import { EditorInspector } from "./editor-inspector";
|
||||
import { EditorAssetIngest } from "./editor-asset-ingest";
|
||||
import { EditorTranscription } from "./editor-transcription";
|
||||
import { EditorExport } from "./editor-export";
|
||||
import { EditorAiAssistant } from "./editor-ai-assistant";
|
||||
|
||||
const manifest: PluginManifestV2 = {
|
||||
apiVersion: "yingce.plugin/v2",
|
||||
id: "editor-shell",
|
||||
name: "剪辑工作台(壳)",
|
||||
version: "0.1.0",
|
||||
description: "编辑器预设插件垂直切片:注册时间线面板插槽,验证 v2 插件插槽注册→渲染链路。",
|
||||
author: "影策团队",
|
||||
surfaces: ["fullscreen"],
|
||||
permissions: ["timeline.read", "timeline.command", "export.run"],
|
||||
trusted: true,
|
||||
runtime: { backend: "trusted-backend", web: "declarative" },
|
||||
contributes: {
|
||||
editorSlots: [
|
||||
{ slot: "timeline-panel", priority: 0 },
|
||||
{ slot: "preview-renderer", priority: 0 },
|
||||
{ slot: "inspector", priority: 0 },
|
||||
{ slot: "asset-ingest", priority: 0 },
|
||||
{ slot: "subtitle-tool", priority: 0 },
|
||||
{ slot: "transcription-provider", priority: 0 },
|
||||
{ slot: "export-renderer", priority: 0 },
|
||||
{ slot: "ai-assistant", priority: 0 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const editorShellPlugin: RegisteredPlugin = {
|
||||
manifest,
|
||||
editorSlots: manifest.contributes.editorSlots ?? [],
|
||||
};
|
||||
|
||||
registerPlugin(editorShellPlugin);
|
||||
|
||||
// M2.4:插槽渲染函数 = 真实时间线面板(命令状态机 + 手势)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "timeline-panel",
|
||||
render: () => <EditorTimelinePanel />,
|
||||
});
|
||||
|
||||
// M3.2:插槽渲染函数 = 预览监视器(浏览器内近似预览 + 播放头)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "preview-renderer",
|
||||
render: () => <EditorPreviewMonitor />,
|
||||
});
|
||||
// M3.3:插槽渲染函数 = 片段检查器(属性编辑入队可撤销)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "inspector",
|
||||
render: () => <EditorInspector />,
|
||||
});
|
||||
|
||||
// M3.4:插槽渲染函数 = 素材库(点击资产 → addClip 入轨)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "asset-ingest",
|
||||
render: () => <EditorAssetIngest />,
|
||||
});
|
||||
|
||||
// M3.5:插槽渲染函数 = 字幕工具(SRT 导入导出 + 从节点重建字幕快照)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "subtitle-tool",
|
||||
render: () => <EditorSubtitleTools />,
|
||||
});
|
||||
|
||||
// M3.6:转写(mock ASR → 字幕轨道,M4 接后端任务客户端)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "transcription-provider",
|
||||
render: () => <EditorTranscription />,
|
||||
});
|
||||
|
||||
// M3.7:导出(渲染计划 + ffmpeg.wasm 降级,M4 接后端任务)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "export-renderer",
|
||||
render: () => <EditorExport />,
|
||||
});
|
||||
|
||||
// M3.8:AI 助手占位(M6 补实现 ai-command-schema + timeline-summary)。
|
||||
registerEditorSlot({
|
||||
pluginId: manifest.id,
|
||||
slot: "ai-assistant",
|
||||
render: () => <EditorAiAssistant />,
|
||||
});
|
||||
143
web/src/lib/plugins/builtin/editor/editor-subtitle-tools.tsx
Normal file
143
web/src/lib/plugins/builtin/editor/editor-subtitle-tools.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
// 字幕工具(editor-shell 预设插件贡献 subtitle-tool 插槽,M3.5)。
|
||||
// SRT 导入导出(复用 srt-parser)+ 「从节点重建字幕片段」命令(§3.1 快照契约:
|
||||
// 节点 subtitleEntries 权威 → 重建时间线字幕快照,替换过期快照,单向显式同步)。
|
||||
// AI 高亮在 M6(editor-ai-assistant + ai.text 权限)接入,此处留占位。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useEditorStoreContext } from "@/components/editor/editor-context";
|
||||
import { parseSrt, serializeSrtEntries } from "@/lib/timeline/srt-parser";
|
||||
import { getSubtitleTracks } from "@/lib/timeline/timeline-tracks";
|
||||
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
|
||||
import type { SrtEntry, TimelineClip } from "@/types/timeline";
|
||||
|
||||
const DEFAULT_SRT_NODE_ID = "srt-import";
|
||||
|
||||
export function EditorSubtitleTools() {
|
||||
const { project, dispatch } = useEditorStoreContext();
|
||||
const [srtText, setSrtText] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const subtitleClips = useMemo(
|
||||
() => (project?.clips ?? []).filter((c) => c.kind === "subtitle").sort((a, b) => a.startMs - b.startMs),
|
||||
[project],
|
||||
);
|
||||
const subtitleTracks = useMemo(() => (project ? getSubtitleTracks(project.tracks) : []), [project]);
|
||||
|
||||
// SRT 导入:解析文本 → rebuildSubtitleClips 以 DEFAULT_SRT_NODE_ID 为权威节点重建
|
||||
// 字幕快照。确定性 id(nodeId:subtitle:index)保证重复导入原地替换,不堆积漂移。
|
||||
const importSrt = () => {
|
||||
const entries = parseSrt(srtText);
|
||||
if (entries.length === 0) {
|
||||
setMessage("未解析到有效 SRT 条目");
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
op: "rebuildSubtitleClips",
|
||||
payload: { nodeId: DEFAULT_SRT_NODE_ID, entries, trackId: subtitleTracks[0]?.id },
|
||||
});
|
||||
setMessage(`已从节点 ${DEFAULT_SRT_NODE_ID} 重建 ${entries.length} 条字幕快照`);
|
||||
};
|
||||
|
||||
// SRT 导出:当前时间线字幕快照 → 序列化 → 下载 .srt(往返一致)。
|
||||
const exportSrt = () => {
|
||||
if (subtitleClips.length === 0) {
|
||||
setMessage("时间线上没有字幕片段");
|
||||
return;
|
||||
}
|
||||
const entries: SrtEntry[] = subtitleClips.map((clip, idx) => ({
|
||||
index: clip.subtitleEntryIndex ?? idx + 1,
|
||||
startMs: clip.startMs,
|
||||
endMs: clip.startMs + clip.durationMs,
|
||||
text: clip.text ?? "",
|
||||
}));
|
||||
const content = serializeSrtEntries(entries);
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "subtitles.srt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setMessage(`已导出 ${entries.length} 条字幕`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--director-sequencer-surface)]">
|
||||
<div className="director-scroll min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="mb-3 rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">时间线字幕</span>
|
||||
<span className="text-[10px] text-[var(--director-dock-fg)]/60">{subtitleClips.length} 条</span>
|
||||
</div>
|
||||
{subtitleClips.length === 0 ? (
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-[var(--director-dock-fg)]/55">暂无字幕快照。粘贴 SRT 导入后,字幕会重建到时间线。</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{subtitleClips.slice(0, 6).map((clip) => (
|
||||
<SubtitleRow key={clip.id} clip={clip} />
|
||||
))}
|
||||
{subtitleClips.length > 6 && (
|
||||
<li className="pt-1 text-[10px] text-[var(--director-dock-fg)]/50">…共 {subtitleClips.length} 条</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="mb-3 block">
|
||||
<span className="mb-1 block text-[11px] text-[var(--director-dock-fg)]/70">SRT 文本(导入)</span>
|
||||
<textarea
|
||||
value={srtText}
|
||||
onChange={(e) => setSrtText(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={"1\n00:00:00,500 --> 00:00:02,500\n你好,影策"}
|
||||
className="w-full resize-y rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] px-2 py-1.5 font-mono text-[11px] leading-relaxed text-[var(--director-dock-fg-strong)] outline-none focus:border-[var(--workspace-accent)]/60"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={importSrt}
|
||||
disabled={!srtText.trim()}
|
||||
className="flex-1 rounded-md bg-[var(--director-accent)] px-2 py-1.5 text-xs font-medium text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)] disabled:opacity-40"
|
||||
>
|
||||
导入(重建字幕)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportSrt}
|
||||
disabled={subtitleClips.length === 0}
|
||||
className="flex-1 rounded-md border border-[var(--director-sequencer-border)] px-2 py-1.5 text-xs text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:opacity-40"
|
||||
>
|
||||
导出 SRT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">AI 高亮</span>
|
||||
<span className="rounded-full bg-[var(--director-dock-active-surface)] px-1.5 py-0.5 text-[10px] text-[var(--director-dock-fg)]/80">M6</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-[var(--director-dock-fg)]/55">
|
||||
AI 字幕高亮与关键词标注将在 M6(ai-assistant 插件 + ai.text 权限)接入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && <p className="mt-2 text-[11px] text-[var(--director-dock-fg)]/70">{message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function SubtitleRow({ clip }: { clip: TimelineClip }) {
|
||||
return (
|
||||
<li className="flex items-baseline gap-2 rounded border border-[var(--director-sequencer-border)]/70 bg-[var(--director-control-hover)]/60 px-1.5 py-1">
|
||||
<span className="shrink-0 tabular-nums text-[10px] text-[var(--director-dock-fg)]/60">
|
||||
{formatTimelineTime(clip.startMs)} – {formatTimelineTime(clip.startMs + clip.durationMs)}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-[var(--director-dock-fg)]/80">{clip.text ?? ""}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
790
web/src/lib/plugins/builtin/editor/editor-timeline-panel.tsx
Normal file
790
web/src/lib/plugins/builtin/editor/editor-timeline-panel.tsx
Normal file
@@ -0,0 +1,790 @@
|
||||
// 时间线面板(editor-shell 预设插件贡献,M2.4 手势 echo 最小件)。
|
||||
// 渲染链路:store.project(当前命令状态)→ 轨道/片段绝对定位 → 拖拽手势
|
||||
// (moveClip / trimClip)经 previewGesture 逐帧预览、commitGesture 一次性入历史。
|
||||
// 手势数学与渲染同源(同一 pxPerMs),保证拖拽所见即所得。
|
||||
// 撤销/重做与保存状态移入宿主顶栏(Concat 主菜单区),本面板保留缩放与片段统计。
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eye, EyeOff, Film, Magnet, Maximize, MousePointer2, Music2, Plus, Scissors, Slice, Subtitles, Trash2, Volume2, VolumeX, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
import { useEditorStoreContext } from "@/components/editor/editor-context";
|
||||
import {
|
||||
formatTimelineTime,
|
||||
getFitTimelineZoom,
|
||||
getRulerTickStep,
|
||||
getTimelinePxPerMs,
|
||||
getTimelineVisualEndMs,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} from "@/lib/timeline/timeline-view";
|
||||
import { getAudioTracks, getSubtitleTracks, getVisualTracks } from "@/lib/timeline/timeline-tracks";
|
||||
import { TRACK_KIND_BASE_LABELS } from "@/lib/timeline/editor-commands";
|
||||
import { computeSnap } from "@/lib/timeline/timeline-snap";
|
||||
import type { TimelineClip, TimelineProject, TimelineTrack, TimelineTrackKind } from "@/types/timeline";
|
||||
|
||||
const MIN_VISUAL_END_MS = 1_000;
|
||||
const SNAP_MS = 10;
|
||||
const TRIM_HANDLE_PX = 8;
|
||||
// 轨道标签列宽度(w-48):轨道徽标 + 轨道名 + 可见/静音/移除按钮。
|
||||
// 时间线内容宽度必须补上该列,否则片段区 flex-1 只到
|
||||
// trackWidth - 192,末端片段被 overflow-hidden 裁掉且无法滚动到达。
|
||||
const LABEL_COLUMN_PX = 192;
|
||||
// 剃刀/播放头分割两侧的最小保留时长(毫秒),避免切出空片段。
|
||||
const MIN_SPLIT_MS = 100;
|
||||
|
||||
// 不可移除轨道的原因文案(title 悬停与点击提示共用,单一来源)。
|
||||
const trackRemoveBlockedReason = (kind: TimelineTrack["kind"]) => `${TRACK_KIND_BASE_LABELS[kind]}轨道至少保留一条,无法移除`;
|
||||
|
||||
// 工具条按钮颜色角色(唯一来源,各按钮内不得重复字面量颜色):
|
||||
// 常态 = 次级前景 + hover 弱背景;选中/按下 = 反白块 + 强前景(中性,无 accent 蓝图标)。
|
||||
const TOOL_FG_IDLE = "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]";
|
||||
const TOOL_FG_ACTIVE = "bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]";
|
||||
|
||||
type GestureMode = "move" | "trim-start" | "trim-end" | null;
|
||||
|
||||
// 与时间线相关的场景:playheadMs 是标尺交互的临时坐标,随缩放同步换算。
|
||||
type TimelineTool = "select" | "razor";
|
||||
|
||||
type GestureState = {
|
||||
mode: Exclude<GestureMode, null>;
|
||||
pointerId: number;
|
||||
startClientX: number;
|
||||
originStartMs: number;
|
||||
originDurationMs: number;
|
||||
originSourceStartMs: number;
|
||||
};
|
||||
|
||||
|
||||
export function EditorTimelinePanel() {
|
||||
const { project, dispatch, previewGesture, commitGesture, cancelGesture, selectedClipId, selectClip, transportMs, setTransportMs } =
|
||||
useEditorStoreContext();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [activeTool, setActiveTool] = useState<TimelineTool>("select");
|
||||
// 播放头与监视器共享同一 transport(store):拖动标尺即时驱动监视器 seek,播放时监视器回写推进。
|
||||
const playheadMs = transportMs;
|
||||
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
// 点击不可移除轨道 X 时的原因提示(2.2s 后自动消失)。
|
||||
const [removeBlockedHint, setRemoveBlockedHint] = useState<{ id: number; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
setViewportWidth(el.clientWidth);
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width;
|
||||
if (width) setViewportWidth(width);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!removeBlockedHint) return;
|
||||
const timer = window.setTimeout(() => setRemoveBlockedHint(null), 2200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [removeBlockedHint]);
|
||||
|
||||
if (!project) return <div className="flex h-full items-center justify-center text-sm text-[var(--director-dock-fg)]">时间线未加载</div>;
|
||||
|
||||
const visualEndMs = Math.max(MIN_VISUAL_END_MS, getTimelineVisualEndMs(project.clips));
|
||||
// 像素/毫秒由缩放级别独立决定(不随时长漂移):拖动片段时轨道随内容等比扩展,
|
||||
// 手势映射与渲染同源,末片段向右拖不再被旧实现(trackWidth 随时长阶梯跳变)钉住。
|
||||
const pxPerMs = getTimelinePxPerMs(zoomLevel);
|
||||
const trackWidth = Math.max(viewportWidth, Math.ceil(visualEndMs * pxPerMs));
|
||||
// 内容宽度 = 逻辑轨道宽度 + 标签列;标签列 sticky 固定后,滚动到最右端时
|
||||
// 末端片段仍完整可见(flex-1 片段区 = 内容宽度 - 标签列 ≥ 轨道宽度)。
|
||||
const contentWidth = Math.max(viewportWidth, trackWidth + LABEL_COLUMN_PX);
|
||||
|
||||
const visualTracks = getVisualTracks(project.tracks);
|
||||
const audioTracks = getAudioTracks(project.tracks);
|
||||
const subtitleTracks = getSubtitleTracks(project.tracks);
|
||||
const clipAtPlayhead =
|
||||
project.clips.find(
|
||||
(clip) =>
|
||||
clip.kind !== "subtitle" &&
|
||||
clip.startMs + MIN_SPLIT_MS <= playheadMs &&
|
||||
playheadMs <= clip.startMs + clip.durationMs - MIN_SPLIT_MS,
|
||||
) ?? null;
|
||||
const selectedClip = project.clips.find((clip) => clip.id === selectedClipId) ?? null;
|
||||
|
||||
const handleSplitAtPlayhead = () => {
|
||||
if (!clipAtPlayhead) return;
|
||||
dispatch({ op: "splitClip", payload: { id: clipAtPlayhead.id, splitAtMs: playheadMs } });
|
||||
};
|
||||
const handleDeleteSelected = () => {
|
||||
if (!selectedClip) return;
|
||||
dispatch({ op: "removeClip", payload: { id: selectedClip.id } });
|
||||
selectClip(null);
|
||||
};
|
||||
const handleScrub = (ms: number) => {
|
||||
setTransportMs(Math.max(0, Math.min(visualEndMs, Math.round(ms))));
|
||||
};
|
||||
const handleFitWidth = () => {
|
||||
const availViewport = Math.max(320, viewportWidth - LABEL_COLUMN_PX);
|
||||
setZoomLevel(getFitTimelineZoom(visualEndMs, availViewport));
|
||||
};
|
||||
const handleAddTrack = (kind: TimelineTrackKind) => {
|
||||
setAddMenuOpen(false);
|
||||
dispatch({ op: "addTrack", payload: { kind } });
|
||||
};
|
||||
const handleRemoveTrack = (trackId: string) => {
|
||||
dispatch({ op: "removeTrack", payload: { trackId } });
|
||||
// 选中片段若落在被删轨道上,需同步清空,否则留下悬挂选中态。
|
||||
if (selectedClipId) {
|
||||
const clip = project.clips.find((c) => c.id === selectedClipId);
|
||||
if (clip && clip.trackId === trackId) selectClip(null);
|
||||
}
|
||||
};
|
||||
const handleToggleTrackFlag = (trackId: string, flag: "visible" | "muted", value: boolean) => {
|
||||
dispatch({ op: "setTrackFlag", payload: { trackId, flag, value } });
|
||||
};
|
||||
|
||||
const handleRemoveTrackBlocked = (kind: TimelineTrack["kind"]) => {
|
||||
setRemoveBlockedHint({ id: Date.now(), text: trackRemoveBlockedReason(kind) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col bg-[var(--director-sequencer-surface)]">
|
||||
{/* 工具条:左侧编辑工具,右侧缩放/时码(撤销/重做在宿主顶栏) */}
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3">
|
||||
{/* 工具模式:选择 / 剃刀 */}
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-[var(--director-control-hover)] p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="选择工具"
|
||||
aria-pressed={activeTool === "select"}
|
||||
title="选择工具"
|
||||
onClick={() => setActiveTool("select")}
|
||||
className={`grid size-6 place-items-center rounded-[6px] transition-colors ${
|
||||
activeTool === "select" ? TOOL_FG_ACTIVE : TOOL_FG_IDLE
|
||||
}`}
|
||||
>
|
||||
<MousePointer2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="剃刀工具"
|
||||
aria-pressed={activeTool === "razor"}
|
||||
title="剃刀工具:点击片段在光标处分割"
|
||||
onClick={() => setActiveTool((tool) => (tool === "razor" ? "select" : "razor"))}
|
||||
className={`grid size-6 place-items-center rounded-[6px] transition-colors ${
|
||||
activeTool === "razor" ? TOOL_FG_ACTIVE : TOOL_FG_IDLE
|
||||
}`}
|
||||
>
|
||||
<Slice className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-1 h-5 w-px bg-[var(--director-sequencer-border)]" />
|
||||
{/* 在播放头分割当前片段 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="在播放头分割"
|
||||
disabled={!clipAtPlayhead}
|
||||
title="在播放头分割片段"
|
||||
onClick={handleSplitAtPlayhead}
|
||||
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
<Scissors className="size-4" />
|
||||
</button>
|
||||
{/* 删除选中片段 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除选中片段"
|
||||
disabled={!selectedClip}
|
||||
title="删除选中片段"
|
||||
onClick={handleDeleteSelected}
|
||||
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
<div className="mx-1 h-5 w-px bg-[var(--director-sequencer-border)]" />
|
||||
{/* 吸附开关 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="吸附"
|
||||
aria-pressed={snapEnabled}
|
||||
title="吸附(拖动与时间刻度对齐)"
|
||||
onClick={() => setSnapEnabled((v) => !v)}
|
||||
className={`grid size-7 place-items-center rounded-md transition-colors ${
|
||||
snapEnabled ? TOOL_FG_ACTIVE : TOOL_FG_IDLE
|
||||
}`}
|
||||
>
|
||||
<Magnet className="size-4" />
|
||||
</button>
|
||||
{/* 新增轨道 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新增轨道"
|
||||
aria-expanded={addMenuOpen}
|
||||
title="新增轨道"
|
||||
onClick={() => setAddMenuOpen((v) => !v)}
|
||||
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE}`}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
{addMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setAddMenuOpen(false)} />
|
||||
<div className="absolute left-0 top-8 z-50 min-w-32 overflow-hidden rounded-lg border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] py-1 shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddTrack("video")}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs ${TOOL_FG_IDLE}`}
|
||||
>
|
||||
<Film className="size-3.5" />
|
||||
视频轨道
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddTrack("audio")}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs ${TOOL_FG_IDLE}`}
|
||||
>
|
||||
<Music2 className="size-3.5" />
|
||||
音频轨道
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddTrack("subtitle")}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs ${TOOL_FG_IDLE}`}
|
||||
>
|
||||
<Subtitles className="size-3.5" />
|
||||
字幕轨道
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{/* 播放头时码 */}
|
||||
<span className="w-16 text-right text-xs tabular-nums text-[var(--director-dock-fg)]">{formatTimelineTime(playheadMs)}</span>
|
||||
<span className="text-xs tabular-nums text-[var(--director-dock-fg)]">片段 {project.clips.length}</span>
|
||||
<div className="mx-1 h-5 w-px bg-[var(--director-sequencer-border)]" />
|
||||
{/* 适应宽度 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="适应宽度"
|
||||
title="适应宽度"
|
||||
onClick={handleFitWidth}
|
||||
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE}`}
|
||||
>
|
||||
<Maximize className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="缩小时间线"
|
||||
title="缩小时间线"
|
||||
onClick={() => setZoomLevel((z) => zoomOut(z))}
|
||||
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE}`}
|
||||
>
|
||||
<ZoomOut className="size-4" />
|
||||
</button>
|
||||
<span className="w-10 text-center text-xs tabular-nums text-[var(--director-dock-fg)]">{Math.round(zoomLevel * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="放大时间线"
|
||||
title="放大时间线"
|
||||
onClick={() => setZoomLevel((z) => zoomIn(z))}
|
||||
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE}`}
|
||||
>
|
||||
<ZoomIn className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 时间线主体 */}
|
||||
<div ref={containerRef} className="director-scroll min-h-0 flex-1 overflow-auto bg-[var(--director-sequencer-surface)]" data-canvas-no-zoom>
|
||||
{project.clips.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-[var(--director-dock-fg)]">
|
||||
<Scissors className="size-8" />
|
||||
<p className="text-sm">时间线暂无片段,从素材库拖入或后续接入画布节点</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative min-w-full" style={{ width: contentWidth }}>
|
||||
<TimelineRuler
|
||||
pxPerMs={pxPerMs}
|
||||
endMs={visualEndMs}
|
||||
playheadMs={playheadMs}
|
||||
snapEnabled={snapEnabled}
|
||||
onScrub={handleScrub}
|
||||
/>
|
||||
{[
|
||||
{ label: "视觉轨道", tracks: visualTracks },
|
||||
{ label: "音频轨道", tracks: audioTracks },
|
||||
{ label: "字幕轨道", tracks: subtitleTracks },
|
||||
]
|
||||
.filter((group) => group.tracks.length > 0)
|
||||
.map((group) => (
|
||||
<div key={group.label}>
|
||||
{group.tracks.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
project={project}
|
||||
pxPerMs={pxPerMs}
|
||||
onGesture={previewGesture}
|
||||
onCommit={commitGesture}
|
||||
onCancel={cancelGesture}
|
||||
selectedClipId={selectedClipId}
|
||||
razorActive={activeTool === "razor"}
|
||||
snapEnabled={snapEnabled}
|
||||
playheadMs={playheadMs}
|
||||
onSelectClip={selectClip}
|
||||
|
||||
removable={project.tracks.filter((t) => t.kind === track.kind).length > 1}
|
||||
onRemoveTrack={handleRemoveTrack}
|
||||
onRemoveBlocked={handleRemoveTrackBlocked}
|
||||
onToggleTrackFlag={handleToggleTrackFlag}
|
||||
onSplitClip={(clipId, splitAtMs) =>
|
||||
dispatch({ op: "splitClip", payload: { id: clipId, splitAtMs } })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 播放头:贯穿标尺与全部轨道,与标尺拖动同源(视觉/音频/字幕均覆盖) */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 z-[14] w-px bg-[var(--director-danger)]"
|
||||
style={{ left: LABEL_COLUMN_PX + playheadMs * pxPerMs }}
|
||||
>
|
||||
<div className="absolute -left-[5px] top-0 size-2.5 rotate-45 rounded-[2px] bg-[var(--director-danger)]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 点击不可移除轨道 X 时的原因提示(短暂居中浮现,不拦截交互) */}
|
||||
{removeBlockedHint && (
|
||||
<div
|
||||
key={removeBlockedHint.id}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="pointer-events-none absolute left-1/2 top-12 z-50 -translate-x-1/2 whitespace-nowrap rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3 py-1.5 text-xs text-[var(--director-dock-fg-strong)] shadow-lg"
|
||||
>
|
||||
{removeBlockedHint.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineRuler({
|
||||
pxPerMs,
|
||||
endMs,
|
||||
playheadMs,
|
||||
snapEnabled,
|
||||
onScrub,
|
||||
}: {
|
||||
pxPerMs: number;
|
||||
endMs: number;
|
||||
playheadMs: number;
|
||||
snapEnabled: boolean;
|
||||
onScrub: (ms: number) => void;
|
||||
}) {
|
||||
const step = getRulerTickStep(pxPerMs);
|
||||
const ticks: number[] = [];
|
||||
for (let t = 0; t <= endMs; t += step) ticks.push(t);
|
||||
const areaRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragPointerRef = useRef<number | null>(null);
|
||||
const moveTo = (clientX: number) => {
|
||||
const rect = areaRef.current!.getBoundingClientRect();
|
||||
let ms = (clientX - rect.left) / pxPerMs;
|
||||
if (snapEnabled) ms = Math.round(ms / SNAP_MS) * SNAP_MS;
|
||||
onScrub(ms);
|
||||
};
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex h-6 shrink-0 w-full items-end border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)]">
|
||||
{/* 左列占位与 TrackRow 轨道标签列(w-48)同宽,sticky 跟随横向滚动,保证 0ms 刻度与片段区起点对齐 */}
|
||||
<div className="sticky left-0 z-10 w-48 shrink-0 self-stretch border-r border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)]">
|
||||
{/* 当前播放头时码,跟随拖动实时刷新 */}
|
||||
<span className="absolute bottom-1 right-2 text-[10px] tabular-nums text-[var(--director-danger)]">{formatTimelineTime(playheadMs)}</span>
|
||||
</div>
|
||||
<div
|
||||
ref={areaRef}
|
||||
role="slider"
|
||||
aria-label="播放头"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.round(endMs)}
|
||||
aria-valuenow={Math.round(playheadMs)}
|
||||
aria-orientation="horizontal"
|
||||
className="relative h-full flex-1 cursor-col-resize select-none touch-none"
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
dragPointerRef.current = e.pointerId;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
moveTo(e.clientX);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (dragPointerRef.current === e.pointerId) moveTo(e.clientX);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
dragPointerRef.current = null;
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
dragPointerRef.current = null;
|
||||
}}
|
||||
>
|
||||
{ticks.map((t) => (
|
||||
<div key={t} className="absolute bottom-0" style={{ left: t * pxPerMs - 1 }}>
|
||||
<div className="h-2 w-px bg-[var(--director-sequencer-muted)]" />
|
||||
<span className="absolute bottom-2.5 left-1 whitespace-nowrap text-[10px] tabular-nums text-[var(--director-dock-fg)]">{formatTimelineTime(t)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackRow({
|
||||
track,
|
||||
project,
|
||||
pxPerMs,
|
||||
onGesture,
|
||||
onCommit,
|
||||
onCancel,
|
||||
selectedClipId,
|
||||
onSelectClip,
|
||||
razorActive,
|
||||
snapEnabled,
|
||||
playheadMs,
|
||||
onSplitClip,
|
||||
removable,
|
||||
onRemoveTrack,
|
||||
onRemoveBlocked,
|
||||
onToggleTrackFlag,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
project: TimelineProject;
|
||||
pxPerMs: number;
|
||||
onGesture: (cmd: { op: string; payload: unknown }) => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
selectedClipId: string | null;
|
||||
onSelectClip: (id: string | null) => void;
|
||||
razorActive: boolean;
|
||||
snapEnabled: boolean;
|
||||
playheadMs: number;
|
||||
onSplitClip: (clipId: string, splitAtMs: number) => void;
|
||||
removable: boolean;
|
||||
onRemoveTrack: (trackId: string) => void;
|
||||
onRemoveBlocked: (kind: TimelineTrack["kind"]) => void;
|
||||
onToggleTrackFlag: (trackId: string, flag: "visible" | "muted", value: boolean) => void;
|
||||
}) {
|
||||
const clips = project.clips.filter((c) => c.trackId === track.id);
|
||||
return (
|
||||
<div className="group flex h-16 border-b border-[var(--director-sequencer-border)]">
|
||||
<div className="sticky left-0 z-10 flex w-48 shrink-0 items-center gap-1 border-r border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-2">
|
||||
<TrackBadge kind={track.kind} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={`truncate text-xs font-medium ${
|
||||
track.visible === false
|
||||
? "text-[var(--director-dock-fg)]"
|
||||
: "text-[var(--director-dock-fg-strong)]"
|
||||
}`}
|
||||
>
|
||||
{track.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--director-dock-fg)]">{clips.length} 个片段</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={track.visible === false ? "显示轨道" : "隐藏轨道"}
|
||||
title={track.visible === false ? "显示轨道" : "隐藏轨道"}
|
||||
aria-pressed={track.visible === false}
|
||||
onClick={() => onToggleTrackFlag(track.id, "visible", track.visible === false)}
|
||||
className={`grid size-6 shrink-0 place-items-center rounded-md transition-colors ${
|
||||
track.visible === false
|
||||
? "bg-[var(--director-control-hover)] text-[var(--director-dock-fg-strong)]"
|
||||
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
|
||||
}`}
|
||||
>
|
||||
{track.visible === false ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={track.muted ? "取消静音" : "静音轨道"}
|
||||
title={track.muted ? "取消静音" : "静音轨道"}
|
||||
aria-pressed={track.muted === true}
|
||||
onClick={() => onToggleTrackFlag(track.id, "muted", !track.muted)}
|
||||
className={`grid size-6 shrink-0 place-items-center rounded-md transition-colors ${
|
||||
track.muted === true
|
||||
? "bg-[var(--director-control-hover)] text-[var(--director-dock-fg-strong)]"
|
||||
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
|
||||
}`}
|
||||
>
|
||||
{track.muted ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除轨道 ${track.label}`}
|
||||
title={removable ? "移除轨道" : trackRemoveBlockedReason(track.kind)}
|
||||
onClick={() => (removable ? onRemoveTrack(track.id) : onRemoveBlocked(track.kind))}
|
||||
className={`grid size-6 shrink-0 place-items-center rounded-md transition-opacity ${
|
||||
removable
|
||||
? "text-[var(--director-dock-fg)] opacity-0 hover:bg-[var(--director-control-hover)] hover:text-[var(--director-danger)] focus-visible:opacity-100 group-hover:opacity-100"
|
||||
: "cursor-not-allowed text-[var(--director-dock-fg)] opacity-40 hover:bg-[var(--director-control-hover)] hover:opacity-100 focus-visible:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`relative flex-1 overflow-hidden bg-[var(--director-sequencer-grid)] transition-opacity ${
|
||||
razorActive ? "cursor-crosshair" : ""
|
||||
} ${track.visible === false ? "opacity-40" : ""}`}
|
||||
onPointerDown={(e) => {
|
||||
if (razorActive) return;
|
||||
if (e.target === e.currentTarget) onSelectClip(null);
|
||||
}}
|
||||
>
|
||||
{clips.map((clip) => (
|
||||
<ClipItem
|
||||
key={clip.id}
|
||||
clip={clip}
|
||||
pxPerMs={pxPerMs}
|
||||
onGesture={onGesture}
|
||||
onCommit={onCommit}
|
||||
onCancel={onCancel}
|
||||
selected={selectedClipId === clip.id}
|
||||
onSelectClip={onSelectClip}
|
||||
razorActive={razorActive}
|
||||
snapEnabled={snapEnabled}
|
||||
playheadMs={playheadMs}
|
||||
clips={project.clips}
|
||||
onSplitClip={onSplitClip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackBadge({ kind }: { kind: TimelineTrack["kind"] }) {
|
||||
const cls =
|
||||
kind === "video" || kind === "image"
|
||||
? "text-[var(--director-dock-fg)]"
|
||||
: kind === "audio"
|
||||
? "text-[var(--director-success)]"
|
||||
: "text-[var(--director-warning)]";
|
||||
return (
|
||||
<div className={`grid size-6 shrink-0 place-items-center rounded-md bg-[var(--director-dock-active-surface)] ${cls}`} title={kind}>
|
||||
{kind === "subtitle" ? <Subtitles className="size-3.5" /> : kind === "audio" ? <Music2 className="size-3.5" /> : <Film className="size-3.5" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClipItem({
|
||||
clip,
|
||||
pxPerMs,
|
||||
onGesture,
|
||||
onCommit,
|
||||
onCancel,
|
||||
selected,
|
||||
onSelectClip,
|
||||
razorActive,
|
||||
snapEnabled,
|
||||
playheadMs,
|
||||
clips,
|
||||
onSplitClip,
|
||||
}: {
|
||||
clip: TimelineClip;
|
||||
pxPerMs: number;
|
||||
onGesture: (cmd: { op: string; payload: unknown }) => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
selected: boolean;
|
||||
onSelectClip: (id: string | null) => void;
|
||||
razorActive: boolean;
|
||||
snapEnabled: boolean;
|
||||
playheadMs: number;
|
||||
clips: TimelineClip[];
|
||||
onSplitClip: (clipId: string, splitAtMs: number) => void;
|
||||
}) {
|
||||
const gestureRef = useRef<GestureState | null>(null);
|
||||
|
||||
const beginGesture = (e: React.PointerEvent, mode: Exclude<GestureMode, null>) => {
|
||||
if (gestureRef.current) return;
|
||||
onSelectClip(clip.id);
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
gestureRef.current = {
|
||||
mode,
|
||||
pointerId: e.pointerId,
|
||||
startClientX: e.clientX,
|
||||
originStartMs: clip.startMs,
|
||||
originDurationMs: clip.durationMs,
|
||||
originSourceStartMs: clip.sourceStartMs ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
const g = gestureRef.current;
|
||||
if (!g || e.pointerId !== g.pointerId) return;
|
||||
const deltaMs = (e.clientX - g.startClientX) / pxPerMs;
|
||||
|
||||
if (g.mode === "move") {
|
||||
const candidateMs = Math.max(0, g.originStartMs + deltaMs);
|
||||
// 移动吸附:候选左边缘吸附到其他片段边缘/播放头;右边缘吸附则通过“目标边缘-自身时长”换算回起始点,取偏移更小者
|
||||
const leftSnap = computeSnap({
|
||||
candidateMs,
|
||||
playheadMs,
|
||||
clips,
|
||||
excludeClipId: clip.id,
|
||||
pxPerMs,
|
||||
thresholdPx: 8,
|
||||
enabled: snapEnabled,
|
||||
});
|
||||
const rightSnap = computeSnap({
|
||||
candidateMs: candidateMs + clip.durationMs,
|
||||
playheadMs,
|
||||
clips,
|
||||
excludeClipId: clip.id,
|
||||
pxPerMs,
|
||||
thresholdPx: 8,
|
||||
enabled: snapEnabled,
|
||||
});
|
||||
// computeSnap 未命中时返回 { snappedMs: candidateMs, targets: [] }:
|
||||
// 只有 targets 非空才算“命中”。若某侧未命中而其距离为 0(候选值不变),
|
||||
// 直接与另一侧命中距离比较会把真正命中的一侧丢弃(0 永远最小)。
|
||||
const leftHit = leftSnap.targets.length > 0;
|
||||
const rightHit = rightSnap.targets.length > 0;
|
||||
let snappedMs = candidateMs;
|
||||
if (leftHit && rightHit) {
|
||||
const rightAltStartMs = rightSnap.snappedMs - clip.durationMs;
|
||||
snappedMs =
|
||||
Math.abs(rightAltStartMs - candidateMs) < Math.abs(leftSnap.snappedMs - candidateMs)
|
||||
? Math.max(0, rightAltStartMs)
|
||||
: leftSnap.snappedMs;
|
||||
} else if (leftHit) {
|
||||
snappedMs = leftSnap.snappedMs;
|
||||
} else if (rightHit) {
|
||||
snappedMs = Math.max(0, rightSnap.snappedMs - clip.durationMs);
|
||||
}
|
||||
onGesture({ op: "moveClip", payload: { id: clip.id, startMs: snappedMs } });
|
||||
} else if (g.mode === "trim-end") {
|
||||
const sourceDuration = clip.sourceDurationMs ?? 0;
|
||||
const newRightEdge = g.originStartMs + g.originDurationMs + deltaMs;
|
||||
const { snappedMs } = computeSnap({
|
||||
candidateMs: newRightEdge,
|
||||
playheadMs,
|
||||
clips,
|
||||
excludeClipId: clip.id,
|
||||
pxPerMs,
|
||||
thresholdPx: 8,
|
||||
enabled: snapEnabled,
|
||||
});
|
||||
let newDurationMs = Math.max(SNAP_MS, snappedMs - g.originStartMs);
|
||||
if (sourceDuration > 0) newDurationMs = Math.min(newDurationMs, sourceDuration - g.originSourceStartMs);
|
||||
onGesture({ op: "trimClip", payload: { id: clip.id, durationMs: newDurationMs } });
|
||||
} else {
|
||||
// trim-start:右端保持不动,起始点前移,源起点同步前移
|
||||
const sourceDuration = clip.sourceDurationMs ?? 0;
|
||||
const rightEdge = g.originStartMs + g.originDurationMs;
|
||||
const candidateMs = Math.max(0, Math.min(g.originStartMs + deltaMs, rightEdge - SNAP_MS));
|
||||
const { snappedMs } = computeSnap({
|
||||
candidateMs,
|
||||
playheadMs,
|
||||
clips,
|
||||
excludeClipId: clip.id,
|
||||
pxPerMs,
|
||||
thresholdPx: 8,
|
||||
enabled: snapEnabled,
|
||||
});
|
||||
const newStartMs = Math.max(0, Math.min(snappedMs, rightEdge - SNAP_MS));
|
||||
let newSourceStartMs = g.originSourceStartMs + (newStartMs - g.originStartMs);
|
||||
if (sourceDuration > 0) newSourceStartMs = Math.min(newSourceStartMs, sourceDuration - SNAP_MS);
|
||||
onGesture({
|
||||
op: "trimClip",
|
||||
payload: { id: clip.id, startMs: newStartMs, durationMs: rightEdge - newStartMs, sourceStartMs: Math.max(0, newSourceStartMs) },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const endGesture = (e: React.PointerEvent) => {
|
||||
const g = gestureRef.current;
|
||||
if (!g || e.pointerId !== g.pointerId) return;
|
||||
gestureRef.current = null;
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// pointer capture 可能已被隐式释放
|
||||
}
|
||||
onCommit();
|
||||
};
|
||||
|
||||
const isSubtitle = clip.kind === "subtitle";
|
||||
const isAudio = clip.kind === "audio";
|
||||
const left = clip.startMs * pxPerMs;
|
||||
const width = clip.durationMs * pxPerMs;
|
||||
const label = clip.text || clip.nodeId || clip.id;
|
||||
const playheadInside = playheadMs >= clip.startMs && playheadMs <= clip.startMs + clip.durationMs;
|
||||
|
||||
const razorSplitAt = (e: React.PointerEvent) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const atMs = clip.startMs + (e.clientX - rect.left) / pxPerMs;
|
||||
const splitMs = Math.round(atMs);
|
||||
if (splitMs - clip.startMs < MIN_SPLIT_MS || clip.startMs + clip.durationMs - splitMs < MIN_SPLIT_MS) return;
|
||||
onSplitClip(clip.id, splitMs);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group absolute top-1.5 bottom-1.5 select-none rounded-md border text-xs shadow-sm ${
|
||||
selected
|
||||
? "border-[var(--director-accent)] ring-1 ring-[var(--director-accent)]/60 bg-[var(--director-accent)]/25 text-[var(--director-dock-fg-strong)]"
|
||||
: isSubtitle
|
||||
? "border-[var(--director-warning)]/40 bg-[var(--director-warning)]/15 text-[var(--director-warning)]"
|
||||
: isAudio
|
||||
? "border-[var(--director-success)]/40 bg-[var(--director-success)]/15 text-[var(--director-success)]"
|
||||
: "border-[var(--director-sequencer-border)] bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]"
|
||||
}`}
|
||||
style={{ left, width, touchAction: "none" }}
|
||||
title={`${label} · ${formatTimelineTime(clip.startMs)} +${formatTimelineTime(clip.durationMs)}`}
|
||||
onPointerDown={(e) => {
|
||||
// 剃刀模式(字幕除外):单击即在光标处分割;否则开始移动手势
|
||||
if (razorActive && !isSubtitle) {
|
||||
razorSplitAt(e);
|
||||
return;
|
||||
}
|
||||
beginGesture(e, "move");
|
||||
}}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endGesture}
|
||||
onPointerCancel={endGesture}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 cursor-ew-resize rounded-l-md"
|
||||
style={{ width: TRIM_HANDLE_PX }}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
beginGesture(e, "trim-start");
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none flex h-full items-center gap-1 px-2">
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
{/* 播放头经过片段时的对齐标线(razor/对齐视觉辅助) */}
|
||||
{playheadInside && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-[var(--director-danger)]/60"
|
||||
style={{ left: (playheadMs - clip.startMs) * pxPerMs }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 cursor-ew-resize rounded-r-md"
|
||||
style={{ width: TRIM_HANDLE_PX }}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
beginGesture(e, "trim-end");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
web/src/lib/plugins/builtin/editor/editor-transcription.tsx
Normal file
165
web/src/lib/plugins/builtin/editor/editor-transcription.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
// 字幕转写(editor-shell 预设插件贡献 transcription-provider 插槽,M3.6)。
|
||||
// M4 后端转写任务(whisper.cpp 本地执行):创建 POST /timeline/transcriptions
|
||||
// → waitForGenerationTask 轮询进度 → 结果 segments 按 ADR-0004 协议以
|
||||
// SrtEntry[] 落字幕轨道(rebuildSubtitleClips 重建快照),UI 契约与 M3.6 mock 一致。
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AudioLines, Loader2 } from "lucide-react";
|
||||
|
||||
import { useEditorHostContext, useEditorStoreContext } from "@/components/editor/editor-context";
|
||||
import { getSubtitleTracks } from "@/lib/timeline/timeline-tracks";
|
||||
import { waitForGenerationTask } from "@/services/api/task-center";
|
||||
import {
|
||||
createTimelineTranscriptionTask,
|
||||
type TimelineTranscriptionResult,
|
||||
type TimelineTranscriptionSegment,
|
||||
} from "@/services/api/timeline-tasks";
|
||||
import type { ProjectAsset } from "@/services/api/projects";
|
||||
import type { SrtEntry } from "@/types/timeline";
|
||||
|
||||
const WHISPER_WAIT_TIMEOUT_MS = 25 * 60 * 1000;
|
||||
|
||||
// 上传素材的底层对象以 resource:<id> 形式存储在资源表,取其中的资源 ID 提交转写。
|
||||
function resourceIdOfAsset(asset: ProjectAsset): string | null {
|
||||
const key = asset.storageKey ?? "";
|
||||
if (key.startsWith("resource:")) return key.slice("resource:".length);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EditorTranscription() {
|
||||
const { assets } = useEditorHostContext();
|
||||
const { project, dispatch } = useEditorStoreContext();
|
||||
// 音频与视频素材均可转写(视频静音会由后端明确报错)。
|
||||
const transcribableAssets = useMemo(
|
||||
() => assets.filter((a) => a.mediaType === "audio" || a.mediaType === "video"),
|
||||
[assets],
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progressLabel, setProgressLabel] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === null && transcribableAssets.length > 0) {
|
||||
const firstAudio = transcribableAssets.find((a) => a.mediaType === "audio");
|
||||
setSelectedId((firstAudio ?? transcribableAssets[0]).id);
|
||||
}
|
||||
}, [transcribableAssets, selectedId]);
|
||||
|
||||
if (!project) return null;
|
||||
const subtitleTrack = getSubtitleTracks(project.tracks)[0];
|
||||
const selected = transcribableAssets.find((a) => a.id === selectedId) ?? null;
|
||||
|
||||
const toSrtEntries = (segments: TimelineTranscriptionSegment[]): SrtEntry[] =>
|
||||
segments
|
||||
.map((seg, i) => ({
|
||||
index: i + 1,
|
||||
startMs: seg.startMs,
|
||||
endMs: seg.endMs,
|
||||
text: seg.text,
|
||||
}))
|
||||
.filter((entry) => entry.text.trim() !== "" && entry.endMs > entry.startMs);
|
||||
|
||||
const transcribe = async () => {
|
||||
if (!selected || running) return;
|
||||
const resourceId = resourceIdOfAsset(selected);
|
||||
if (!resourceId) {
|
||||
setFailed(true);
|
||||
setMessage("该素材没有可转写的媒体对象(缺少 storageKey)");
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setFailed(false);
|
||||
setMessage(null);
|
||||
setProgressLabel("排队中…");
|
||||
try {
|
||||
const created = await createTimelineTranscriptionTask({
|
||||
resourceId,
|
||||
});
|
||||
const done = await waitForGenerationTask(created.id, {
|
||||
timeoutMs: WHISPER_WAIT_TIMEOUT_MS,
|
||||
intervalMs: 2000,
|
||||
onTaskUpdate: (task) => {
|
||||
const stage = task.stage;
|
||||
const progress = task.progress ?? 0;
|
||||
if (stage) setProgressLabel(`${stage}${progress > 0 && progress < 100 ? ` ${progress}%` : ""}`);
|
||||
},
|
||||
});
|
||||
const parsed = JSON.parse(done.resultJson ?? "{}") as TimelineTranscriptionResult;
|
||||
const entries = toSrtEntries(parsed.segments ?? []);
|
||||
if (entries.length === 0) {
|
||||
setMessage("转写完成,但没有识别出可用字幕(语音内容为空?)");
|
||||
} else if (!subtitleTrack) {
|
||||
setMessage("项目没有字幕轨道,无法写入转写字幕");
|
||||
} else {
|
||||
dispatch({
|
||||
op: "rebuildSubtitleClips",
|
||||
payload: { nodeId: `transcription:${selected.id}`, entries, trackId: subtitleTrack.id },
|
||||
});
|
||||
const langHint = parsed.language ? `(${parsed.language})` : "";
|
||||
setMessage(`已写入 ${entries.length} 条转写字幕${langHint}`);
|
||||
}
|
||||
setProgressLabel(null);
|
||||
} catch (err) {
|
||||
setFailed(true);
|
||||
setMessage(err instanceof Error ? err.message : "转写失败,请稍后重试");
|
||||
setProgressLabel(null);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--director-sequencer-surface)]">
|
||||
<div className="director-scroll min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{transcribableAssets.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<AudioLines className="size-5 text-[var(--director-dock-fg)]/40" />
|
||||
<p className="text-xs text-[var(--director-dock-fg)]/60">项目暂无音视频素材</p>
|
||||
<p className="max-w-[200px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">素材库加入音频或视频后可转写自动字幕</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="mb-2 block">
|
||||
<span className="mb-1 block text-[11px] text-[var(--director-dock-fg)]/70">音视频素材</span>
|
||||
<select
|
||||
value={selectedId ?? ""}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
className="w-full rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] px-2 py-1.5 text-xs text-[var(--director-dock-fg-strong)] outline-none focus:border-[var(--workspace-accent)]/60 [&>option]:bg-[var(--director-sequencer-surface-raised)] [&>option]:text-[var(--director-dock-fg-strong)]"
|
||||
>
|
||||
{transcribableAssets.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.title || a.storageKey}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={transcribe}
|
||||
disabled={!selected || running}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-[var(--director-accent)] px-2 py-1.5 text-xs font-medium text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)] disabled:opacity-40"
|
||||
>
|
||||
{running && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{running ? "转写中…" : "转写自动字幕"}
|
||||
</button>
|
||||
|
||||
{running && progressLabel && (
|
||||
<p className="mt-2 text-[11px] text-[var(--director-dock-fg)]/60">{progressLabel}</p>
|
||||
)}
|
||||
{message && (
|
||||
<p className={`mt-2 text-[11px] ${failed ? "text-[var(--director-danger)]" : "text-[var(--director-dock-fg)]/70"}`}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">
|
||||
由本地 whisper.cpp 识别,音视频不出本机;识别结果写入字幕轨道,可再编辑。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
web/src/lib/plugins/builtin/editor/index.ts
Normal file
5
web/src/lib/plugins/builtin/editor/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// 编辑器预设插件集合入口(M3.8)。
|
||||
// editor-shell 单个预设插件贡献全部 8 种 editorSlots;此处聚合导出,
|
||||
// builtin/index.ts 的静态注册经 editor-shell 模块副作用完成。
|
||||
|
||||
export { editorShellPlugin } from "./editor-shell";
|
||||
@@ -3,3 +3,4 @@ import "./prompt-optimizer";
|
||||
import "./workflows";
|
||||
import "./portrait-clearance";
|
||||
import "./ai-art-critique";
|
||||
import "./editor/editor-shell";
|
||||
|
||||
93
web/src/lib/plugins/editor-slot-registry.ts
Normal file
93
web/src/lib/plugins/editor-slot-registry.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useReducer } from "react";
|
||||
|
||||
import type { EditorSlotKind } from "./plugin-types";
|
||||
|
||||
export type EditorSlotRenderer = (context: { pluginId: string }) => React.ReactNode;
|
||||
|
||||
export type EditorSlotRegistration = {
|
||||
id: number;
|
||||
pluginId: string;
|
||||
slot: EditorSlotKind;
|
||||
priority: number;
|
||||
order: number;
|
||||
render: EditorSlotRenderer;
|
||||
};
|
||||
|
||||
export type RegisterEditorSlotInput = {
|
||||
pluginId: string;
|
||||
slot: EditorSlotKind;
|
||||
priority?: number;
|
||||
render: EditorSlotRenderer;
|
||||
};
|
||||
|
||||
let nextOrder = 0;
|
||||
let nextId = 0;
|
||||
const registrations: EditorSlotRegistration[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emitChange(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** 订阅插槽注册表变化(插件启停时 UI 刷新)。返回取消订阅函数。 */
|
||||
export function subscribeEditorSlots(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** React hook:订阅插槽变化并返回当前插槽贡献。 */
|
||||
export function useEditorSlots(slot: EditorSlotKind): EditorSlotRegistration[] {
|
||||
const [, force] = useReducer((x: number) => x + 1, 0);
|
||||
useEffect(() => subscribeEditorSlots(force), []);
|
||||
return getEditorSlots(slot);
|
||||
}
|
||||
|
||||
/** 注册一个编辑器插槽贡献。同 pluginId + slot 重复注册会覆盖旧项(幂等,兼容 HMR 重载)。返回的卸载函数只卸载自己注册的实例。 */
|
||||
export function registerEditorSlot(input: RegisterEditorSlotInput): () => void {
|
||||
unregisterEditorSlot(input.pluginId, input.slot);
|
||||
const id = nextId++;
|
||||
registrations.push({
|
||||
id,
|
||||
pluginId: input.pluginId,
|
||||
slot: input.slot,
|
||||
priority: input.priority ?? 0,
|
||||
order: nextOrder++,
|
||||
render: input.render,
|
||||
});
|
||||
return () => {
|
||||
unregisterById(id);
|
||||
emitChange();
|
||||
};
|
||||
}
|
||||
|
||||
function unregisterById(id: number): void {
|
||||
const index = registrations.findIndex((r) => r.id === id);
|
||||
if (index >= 0) registrations.splice(index, 1);
|
||||
}
|
||||
|
||||
export function unregisterEditorSlot(pluginId: string, slot: EditorSlotKind): void {
|
||||
const index = registrations.findIndex((r) => r.pluginId === pluginId && r.slot === slot);
|
||||
if (index >= 0) {
|
||||
registrations.splice(index, 1);
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
/** 卸载某插件的全部编辑器插槽贡献(插件卸载时调用)。 */
|
||||
export function unregisterPluginSlots(pluginId: string): void {
|
||||
let changed = false;
|
||||
for (let i = registrations.length - 1; i >= 0; i--) {
|
||||
if (registrations[i].pluginId === pluginId) {
|
||||
registrations.splice(i, 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) emitChange();
|
||||
}
|
||||
|
||||
/** 取某插槽的全部贡献,按 priority 降序、注册序升序稳定排列。 */
|
||||
export function getEditorSlots(slot: EditorSlotKind): EditorSlotRegistration[] {
|
||||
return registrations
|
||||
.filter((r) => r.slot === slot)
|
||||
.sort((a, b) => b.priority - a.priority || a.order - b.order);
|
||||
}
|
||||
63
web/src/lib/plugins/plugin-permission-check.ts
Normal file
63
web/src/lib/plugins/plugin-permission-check.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// 编辑器插件执行期权限校验(M5,fail-closed)。
|
||||
// v2 插件贡献 editorSlots / 代发 timeline 命令时,其 manifest.permissions 必须覆盖
|
||||
// 对应能力域;缺失即拒绝渲染/执行。v1 插件不贡献 editorSlots,天然不受此域约束。
|
||||
// 本模块只做纯判定,接入点在插槽渲染(SlotStack)与宿主命令代发通道。
|
||||
|
||||
import { getRegisteredPlugin } from "@/lib/plugins/plugin-registry";
|
||||
import type { EditorPluginPermission, EditorSlotKind } from "@/lib/plugins/plugin-types";
|
||||
|
||||
/** 每个编辑器插槽所需的最小权限域(M5.2 表驱动:新插槽必须补表,否则 fail-closed 拒渲染)。 */
|
||||
export const EDITOR_SLOT_REQUIRED_PERMISSION: Record<EditorSlotKind, EditorPluginPermission> = {
|
||||
"timeline-panel": "timeline.command",
|
||||
"preview-renderer": "timeline.read",
|
||||
inspector: "timeline.command",
|
||||
"asset-ingest": "timeline.command",
|
||||
"subtitle-tool": "timeline.command",
|
||||
"transcription-provider": "timeline.command",
|
||||
"export-renderer": "export.run",
|
||||
"ai-assistant": "timeline.read",
|
||||
};
|
||||
|
||||
export type EditorSlotPermissionVerdict =
|
||||
| { allowed: true }
|
||||
| { allowed: false; reason: "plugin-not-registered" | "missing-permission"; missing: EditorPluginPermission | null };
|
||||
|
||||
/** fail-closed 权限判定:权限集缺失或未包含 required 一律拒绝。 */
|
||||
export function checkEditorPermission(permissions: ReadonlySet<string> | null, required: EditorPluginPermission): boolean {
|
||||
return permissions !== null && permissions.has(required);
|
||||
}
|
||||
|
||||
export function requiredPermissionForEditorSlot(slot: EditorSlotKind): EditorPluginPermission {
|
||||
return EDITOR_SLOT_REQUIRED_PERMISSION[slot];
|
||||
}
|
||||
|
||||
/** 查询插件的权限集(未注册返回 null)。 */
|
||||
export function pluginPermissions(pluginId: string): ReadonlySet<string> | null {
|
||||
const registered = getRegisteredPlugin(pluginId);
|
||||
if (!registered) return null;
|
||||
return new Set(registered.manifest.permissions ?? []);
|
||||
}
|
||||
|
||||
/** 判定某插件是否被允许渲染某编辑器插槽(fail-closed;v2 注册即被检,缺失权限拒渲染)。 */
|
||||
export function pluginMayRenderEditorSlot(pluginId: string, slot: EditorSlotKind): EditorSlotPermissionVerdict {
|
||||
const permissions = pluginPermissions(pluginId);
|
||||
if (permissions === null) {
|
||||
return { allowed: false, reason: "plugin-not-registered", missing: null };
|
||||
}
|
||||
const required = EDITOR_SLOT_REQUIRED_PERMISSION[slot];
|
||||
if (!permissions.has(required)) {
|
||||
return { allowed: false, reason: "missing-permission", missing: required };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/** 宿主命令代发通道的权限断言:插件未声明 permission 时抛错(M6 AI 命令、第三方代发复用)。 */
|
||||
export function assertEditorPermission(pluginId: string, permission: EditorPluginPermission): void {
|
||||
const permissions = pluginPermissions(pluginId);
|
||||
if (permissions === null) {
|
||||
throw new Error(`插件 ${pluginId} 未注册,无法代发 ${permission} 命令`);
|
||||
}
|
||||
if (!permissions.has(permission)) {
|
||||
throw new Error(`插件 ${pluginId} 缺少 ${permission} 权限,命令已被拒绝`);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,52 @@
|
||||
import type { PluginManifest, RegisteredPlugin } from "./plugin-types";
|
||||
import type { EditorSlotKind, PluginManifest, PluginManifestV2, RegisteredPlugin } from "./plugin-types";
|
||||
import { unregisterPluginSlots } from "./editor-slot-registry";
|
||||
import { registerPluginCanvasNodes, unregisterNodeDefinitions } from "@/lib/canvas/node-registry";
|
||||
|
||||
const registeredPlugins = new Map<string, RegisteredPlugin>();
|
||||
|
||||
function assertManifest(manifest: PluginManifest) {
|
||||
const EDITOR_SLOT_KINDS: EditorSlotKind[] = [
|
||||
"timeline-panel",
|
||||
"preview-renderer",
|
||||
"inspector",
|
||||
"asset-ingest",
|
||||
"subtitle-tool",
|
||||
"transcription-provider",
|
||||
"export-renderer",
|
||||
"ai-assistant",
|
||||
];
|
||||
|
||||
function assertManifest(manifest: PluginManifest | PluginManifestV2) {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.id)) throw new Error("插件 ID 必须使用 kebab-case");
|
||||
if (!manifest.name.trim() || !manifest.version.trim() || !manifest.apiVersion.trim()) throw new Error("插件清单缺少名称、版本或 API 版本");
|
||||
if (manifest.apiVersion !== "yingce.plugin/v1") throw new Error(`不支持的插件 API 版本:${manifest.apiVersion}`);
|
||||
const apiVersion: string = manifest.apiVersion;
|
||||
if (apiVersion !== "yingce.plugin/v1" && apiVersion !== "yingce.plugin/v2") {
|
||||
throw new Error(`不支持的插件 API 版本:${apiVersion}`);
|
||||
}
|
||||
if (new Set(manifest.permissions).size !== manifest.permissions.length) throw new Error("插件权限不能重复");
|
||||
if (!manifest.contributes || Object.values(manifest.contributes).every((value) => !value || value.length === 0)) throw new Error("插件至少需要声明一种贡献能力");
|
||||
if (manifest.apiVersion === "yingce.plugin/v2") assertManifestV2(manifest);
|
||||
else assertManifestV1Contributions(manifest.contributes);
|
||||
}
|
||||
|
||||
function assertManifestV1Contributions(contributes: PluginManifest["contributes"]) {
|
||||
if (!contributes || Object.values(contributes).every((value) => !value || value.length === 0)) {
|
||||
throw new Error("插件至少需要声明一种贡献能力");
|
||||
}
|
||||
}
|
||||
|
||||
function assertManifestV2(manifest: PluginManifestV2) {
|
||||
if (manifest.contributes.editorSlots?.length) {
|
||||
for (const slot of manifest.contributes.editorSlots) {
|
||||
if (!EDITOR_SLOT_KINDS.includes(slot.slot)) throw new Error(`未知的编辑器插槽类型:${slot.slot}`);
|
||||
if (slot.priority !== undefined && (typeof slot.priority !== "number" || Number.isNaN(slot.priority))) {
|
||||
throw new Error(`编辑器插槽 ${slot.slot} 的 priority 必须是数字`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 贡献检查:v1 贡献字段或 editorSlots 至少声明一项(editorSlots 是合法的 v2 独立贡献)。
|
||||
const { editorSlots, ...v1Contributions } = manifest.contributes;
|
||||
if (Object.values(v1Contributions).every((value) => !value || value.length === 0) && !editorSlots?.length) {
|
||||
throw new Error("插件至少需要声明一种贡献能力");
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPlugin(plugin: RegisteredPlugin) {
|
||||
@@ -17,6 +55,10 @@ export function registerPlugin(plugin: RegisteredPlugin) {
|
||||
if (existing && existing.manifest.version !== plugin.manifest.version) {
|
||||
throw new Error(`插件 ${plugin.manifest.id} 已注册其他版本`);
|
||||
}
|
||||
if (plugin.manifest.apiVersion === "yingce.plugin/v2") {
|
||||
// v2:编辑器插槽声明由注册器提取存储;UI 渲染函数由插件 activate() 阶段经 registerEditorSlot 提供。
|
||||
plugin.editorSlots = plugin.manifest.contributes.editorSlots ?? [];
|
||||
}
|
||||
if (plugin.manifest.contributes.canvasNodes?.length) {
|
||||
registerPluginCanvasNodes(plugin.manifest.id, plugin.manifest.contributes.canvasNodes);
|
||||
}
|
||||
@@ -25,6 +67,7 @@ export function registerPlugin(plugin: RegisteredPlugin) {
|
||||
|
||||
export function unregisterPlugin(pluginId: string) {
|
||||
unregisterNodeDefinitions(pluginId);
|
||||
unregisterPluginSlots(pluginId);
|
||||
registeredPlugins.delete(pluginId);
|
||||
}
|
||||
|
||||
@@ -36,6 +79,6 @@ export function listRegisteredPlugins() {
|
||||
return [...registeredPlugins.values()];
|
||||
}
|
||||
|
||||
export function listRegisteredManifests(): PluginManifest[] {
|
||||
export function listRegisteredManifests(): Array<PluginManifest | PluginManifestV2> {
|
||||
return listRegisteredPlugins().map(({ manifest }) => manifest);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
export const PLUGIN_API_VERSION = "yingce.plugin/v1" as const;
|
||||
export const PLUGIN_API_VERSION_V2 = "yingce.plugin/v2" as const;
|
||||
|
||||
export type EditorSlotKind =
|
||||
| "timeline-panel"
|
||||
| "preview-renderer"
|
||||
| "inspector"
|
||||
| "asset-ingest"
|
||||
| "subtitle-tool"
|
||||
| "transcription-provider"
|
||||
| "export-renderer"
|
||||
| "ai-assistant";
|
||||
|
||||
export type EditorSlotContribution = {
|
||||
slot: EditorSlotKind;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export type EditorPluginPermission = "timeline.read" | "timeline.command" | "export.run";
|
||||
|
||||
export type PluginContributionsV2 = PluginContributions & {
|
||||
editorSlots?: EditorSlotContribution[];
|
||||
};
|
||||
|
||||
// 注意:必须把 permissions 一并 Omit 掉再完全替换,否则数组交叉类型会被归约为 v1 的 PluginPermission[],v2 权限字面量无法赋值。
|
||||
export type PluginManifestV2 = Omit<PluginManifest, "apiVersion" | "contributes" | "permissions"> & {
|
||||
apiVersion: typeof PLUGIN_API_VERSION_V2;
|
||||
permissions: Array<PluginPermission | EditorPluginPermission>;
|
||||
contributes: PluginContributionsV2;
|
||||
};
|
||||
|
||||
export type PluginContributionKind = "provider" | "payment-provider" | "workflow" | "canvas-node" | "transform" | "command" | "asset-source" | "usage-observer" | "ai-capability" | "agent" | "import-export";
|
||||
export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source" | "settings" | "wallet";
|
||||
@@ -195,8 +224,8 @@ export type PluginHostServices = {
|
||||
};
|
||||
|
||||
export type PluginHostContext = {
|
||||
manifest: PluginManifest;
|
||||
permissions: ReadonlySet<PluginPermission>;
|
||||
manifest: PluginManifest | PluginManifestV2;
|
||||
permissions: ReadonlySet<PluginPermission | EditorPluginPermission>;
|
||||
storage: PluginStorage;
|
||||
config: Readonly<PluginInstallation["config"]>;
|
||||
services?: PluginHostServices;
|
||||
@@ -287,16 +316,20 @@ export type AssetSourceProvider = {
|
||||
};
|
||||
|
||||
export type RegisteredPlugin = {
|
||||
manifest: PluginManifest;
|
||||
/** v1 或 v2 插件清单;v2 清单结构为 v1 超集(含 editorSlots 声明)。 */
|
||||
manifest: PluginManifest | PluginManifestV2;
|
||||
source?: "bundled" | "uploaded" | string;
|
||||
activate?: (context: PluginHostContext) => Promise<void> | void;
|
||||
deactivate?: (context: PluginHostContext) => Promise<void> | void;
|
||||
createAssetSource?: (context: PluginHostContext) => AssetSourceProvider;
|
||||
createPromptOptimizer?: (context: PluginHostContext) => PromptOptimizerProvider;
|
||||
/** v2 插件由注册器从 manifest 提取的编辑器插槽声明(v1 插件无此字段)。 */
|
||||
editorSlots?: EditorSlotContribution[];
|
||||
/** v2 插件 UI 插槽的实际渲染函数由插件 activate() 阶段经 registerEditorSlot 提供。 */
|
||||
};
|
||||
|
||||
export type PluginInstallation = {
|
||||
manifest: PluginManifest;
|
||||
manifest: PluginManifest | PluginManifestV2;
|
||||
enabled: boolean;
|
||||
config: Record<string, string | number | boolean>;
|
||||
installedAt: string;
|
||||
|
||||
221
web/src/lib/timeline/ai-command-schema.ts
Normal file
221
web/src/lib/timeline/ai-command-schema.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// AI 编辑命令契约(ADR-0007 / Runbook M6.1):宿主校验 + LLM 提示词约束的双契约。
|
||||
// 本文件不复制 handler 约束——命令合法性硬守门在 M2.1 命令注册表(fail-closed),
|
||||
// 这里只做:(1) LLM 输出 JSON 的结构解析与批级预检(dry-run 走同一注册表,零重复),
|
||||
// (2) 生成喂给 LLM 的 op 契约(op 集合与注册表黄金同步,见测试)。
|
||||
|
||||
import { getEditorCommandRegistry, type EditCommand } from "@/lib/timeline/editor-commands";
|
||||
import type { TimelineProject } from "@/types/timeline";
|
||||
|
||||
/** LLM 输出 JSON 结构:reasoning 可选,commands 至少一条。 */
|
||||
export type AiCommandPlan = {
|
||||
reasoning?: string;
|
||||
commands: EditCommand[];
|
||||
};
|
||||
|
||||
export type AiCommandBatchVerdict =
|
||||
| { ok: true }
|
||||
| { ok: false; index: number; op: string; error: string };
|
||||
|
||||
export class AiCommandPlanError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AiCommandPlanError";
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 LLM 回复中提取首个 JSON 对象(容忍 markdown fence / 前后缀杂讯)。 */
|
||||
function extractFirstJsonObject(text: string): string {
|
||||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
const candidate = fenced ? fenced[1] : text;
|
||||
const start = candidate.indexOf("{");
|
||||
if (start < 0) throw new AiCommandPlanError("输出中未找到 JSON 对象(需要 { commands: [...] })");
|
||||
// 从首个 { 起逐字符数括号,找配对的收尾 }(字符串内括号不计)。
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = start; i < candidate.length; i += 1) {
|
||||
const ch = candidate[i];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === "\\") {
|
||||
escaped = true;
|
||||
} else if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
} else if (ch === "{") {
|
||||
depth += 1;
|
||||
} else if (ch === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return candidate.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new AiCommandPlanError("JSON 对象未闭合(缺少收尾 })");
|
||||
}
|
||||
|
||||
function assertPlanShape(raw: unknown): asserts raw is AiCommandPlan {
|
||||
if (typeof raw !== "object" || raw === null) throw new AiCommandPlanError("输出必须是 JSON 对象");
|
||||
const plan = raw as Record<string, unknown>;
|
||||
if (plan.reasoning !== undefined && typeof plan.reasoning !== "string") {
|
||||
throw new AiCommandPlanError("reasoning 必须是字符串");
|
||||
}
|
||||
if (!Array.isArray(plan.commands)) throw new AiCommandPlanError("缺少 commands 数组");
|
||||
if (plan.commands.length > AI_EDITING_MAX_COMMANDS) {
|
||||
throw new AiCommandPlanError(`commands 超过 ${AI_EDITING_MAX_COMMANDS} 条上限`);
|
||||
}
|
||||
// commands 为空数组 = 只读问答 / 无可用命令,合法终态
|
||||
plan.commands.forEach((cmd, i) => {
|
||||
if (typeof cmd !== "object" || cmd === null) throw new AiCommandPlanError(`commands[${i}] 必须是对象`);
|
||||
const c = cmd as Record<string, unknown>;
|
||||
if (typeof c.op !== "string" || c.op.length === 0) throw new AiCommandPlanError(`commands[${i}].op 必须是非空字符串`);
|
||||
if (typeof c.payload !== "object" || c.payload === null || Array.isArray(c.payload)) {
|
||||
throw new AiCommandPlanError(`commands[${i}].op=${c.op} 缺少对象 payload`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LLM 文本输出为 AiCommandPlan;结构非法时抛 AiCommandPlanError(供 UI 展示并回填模型)。
|
||||
* commands 为空数组表示「只读问答 / 无法用现有命令完成」,面板直接展示 reasoning 作为回答。
|
||||
*/
|
||||
export function parseAiCommandPlan(text: string): AiCommandPlan {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(extractFirstJsonObject(text));
|
||||
} catch (error) {
|
||||
if (error instanceof AiCommandPlanError) throw error;
|
||||
throw new AiCommandPlanError(`JSON 解析失败:${(error as Error).message}`);
|
||||
}
|
||||
assertPlanShape(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批级预检:把整批命令按序 dry-run 到当前时间线(immutable 纯函数,无副作用)。
|
||||
* 任何一条触发注册表 fail-closed(未知 op / 非法 payload / 引用不存在)即整批拒绝,
|
||||
* 返回首个出错命令的下标与消息——ADR-0007「任一非法→整批拒绝,错误回填 LLM 修正」。
|
||||
*/
|
||||
export function validateAiCommandBatch(project: TimelineProject, plan: AiCommandPlan): AiCommandBatchVerdict {
|
||||
const registry = getEditorCommandRegistry();
|
||||
let state = project;
|
||||
for (let i = 0; i < plan.commands.length; i += 1) {
|
||||
const cmd = plan.commands[i];
|
||||
try {
|
||||
state = registry.apply(state, cmd);
|
||||
} catch (error) {
|
||||
return { ok: false, index: i, op: cmd.op, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM 提示词契约(ADR-0007:schema 同时用于宿主校验与模型输出约束)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** AI 命令 schema 版本(Runbook M6.1):提示词契约/解析规则变更时递增,供 golden 与缓存失效对齐。 */
|
||||
export const AI_COMMAND_SCHEMA_VERSION = 1;
|
||||
|
||||
/** 单次 AI 编辑最多允许的命令条数(ADR-0007:防止模型一次输出失控编辑)。 */
|
||||
export const AI_EDITING_MAX_COMMANDS = 8;
|
||||
|
||||
/** 12 个黄金 op 的 LLM 可读 payload 契约;op 集合与注册表黄金同步(见测试)。 */
|
||||
export const AI_EDITING_OP_CATALOG: ReadonlyArray<{
|
||||
op: string;
|
||||
desc: string;
|
||||
payload: string;
|
||||
}> = [
|
||||
{
|
||||
op: "addClip",
|
||||
desc: "新增媒体/文本片段到已存在轨道",
|
||||
payload: "{ clip: { id(新唯一), kind: video|audio|image|text, trackId(必须存在), nodeId(源素材), startMs≥0, durationMs>0, title?, volume?, fadeInMs?, fadeOutMs? } }",
|
||||
},
|
||||
{
|
||||
op: "moveClip",
|
||||
desc: "移动片段(时间点/跨轨)",
|
||||
payload: "{ id, startMs≥0, trackId?(可选, 必须存在) }",
|
||||
},
|
||||
{
|
||||
op: "trimClip",
|
||||
desc: "裁剪片段(时间线位置/时长/源内起点)",
|
||||
payload: "{ id, startMs?(≥0), durationMs?(>0 且≤sourceDurationMs), sourceStartMs?(≥0, 与 durationMs 之和≤sourceDurationMs) }",
|
||||
},
|
||||
{
|
||||
op: "splitClip",
|
||||
desc: "在片段内部切一刀成两段",
|
||||
payload: "{ id, splitAtMs(严格大于 startMs 且小于 startMs+durationMs) }",
|
||||
},
|
||||
{
|
||||
op: "removeClip",
|
||||
desc: "删除片段",
|
||||
payload: "{ id }",
|
||||
},
|
||||
{
|
||||
op: "setClipProperty",
|
||||
desc: "修改片段属性(白名单)",
|
||||
payload: "{ id, patch: { title? | text? | volume? | fadeInMs? | fadeOutMs? | subtitleEntryIndex? }(至少一项) }",
|
||||
},
|
||||
{
|
||||
op: "addSubtitle",
|
||||
desc: "新增字幕片段",
|
||||
payload: "{ clip: { kind: subtitle, trackId(字幕轨), id/nodeId/startMs/durationMs/text } }",
|
||||
},
|
||||
{
|
||||
op: "removeSubtitle",
|
||||
desc: "删除字幕片段",
|
||||
payload: "{ id }",
|
||||
},
|
||||
{
|
||||
op: "rebuildSubtitleClips",
|
||||
desc: "按节点字幕条目重建字幕片段",
|
||||
payload: "{ nodeId, entries: [{ index≥0(唯一), startMs≥0, endMs>startMs, text }], trackId?(字幕轨) }",
|
||||
},
|
||||
{
|
||||
op: "addTrack",
|
||||
desc: "新增轨道(id/label 由系统生成,勿传)",
|
||||
payload: "{ kind: video|image|text|audio|subtitle }",
|
||||
},
|
||||
{
|
||||
op: "removeTrack",
|
||||
desc: "删除整条轨道及片段(每种轨道至少保留一条)",
|
||||
payload: "{ trackId }",
|
||||
},
|
||||
{
|
||||
op: "setTrackFlag",
|
||||
desc: "轨道开关",
|
||||
payload: "{ trackId, flag: visible|muted, value: boolean }",
|
||||
},
|
||||
];
|
||||
|
||||
/** LLM 输出结构说明(追加到系统提示词,约束其为纯 JSON)。 */
|
||||
export const AI_EDITING_OUTPUT_FORMAT = `输出要求:
|
||||
- 只输出一个 JSON 对象,不要任何解释文字或 markdown。
|
||||
- 结构:{ "reasoning": "一句话说明意图", "commands": [ { "op": "命令名", "payload": { ... } } ] }
|
||||
- commands:能执行时 1-8 条;用户只是提问或无法用现有命令完成时为空数组 [],此时 reasoning 直接回答用户。
|
||||
- payload 字段严格按命令契约;时间一律毫秒;文本用简体中文。
|
||||
- 所有 id(clip/track/nodeId)必须来自上面时间线摘要,禁止编造。`;
|
||||
|
||||
/** 示例(示意完整 JSON 输出,加入 system prompt)。 */
|
||||
const AI_EDITING_OUTPUT_EXAMPLE = `示例:
|
||||
{ "reasoning": "把第一个视频片段移到第 5 秒", "commands": [ { "op": "moveClip", "payload": { "id": "clip-a", "startMs": 5000 } } ] }`;
|
||||
|
||||
/** 组装 AI 编辑系统提示词:时间线摘要 + op 契约 + 输出约束(M6.3 面板喂给模型)。 */
|
||||
export function buildAiEditingSystemPrompt(timelineSummary: string): string {
|
||||
const catalogLines = AI_EDITING_OP_CATALOG.map(
|
||||
(entry) => `- ${entry.op}:${entry.desc}。payload:${entry.payload}`,
|
||||
).join("\n");
|
||||
return [
|
||||
"你是影策视频剪辑工作台的 AI 剪辑助手。根据用户指令对时间线输出受约束的编辑命令 JSON。",
|
||||
"时间线当前状态:",
|
||||
timelineSummary,
|
||||
"可用命令契约(与编辑器同源,非法命令会被整批拒绝):",
|
||||
catalogLines,
|
||||
AI_EDITING_OUTPUT_FORMAT,
|
||||
AI_EDITING_OUTPUT_EXAMPLE,
|
||||
"若用户指令无法用现有命令完成(如要转场、滤镜、模板),输出 commands: [] 并在 reasoning 说明缺什么能力。",
|
||||
].join("\n");
|
||||
}
|
||||
49
web/src/lib/timeline/asset-ingest.ts
Normal file
49
web/src/lib/timeline/asset-ingest.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// 资产 → 时间线片段 的纯转换(M3.4 asset-ingest)。
|
||||
// 资产是"仅时间线作用域"的直连媒体:nodeId 用 `asset:<id>` 前缀(不指向画布节点),
|
||||
// 预览/导出从 directMedia 解析媒体,不回画布查节点。
|
||||
|
||||
import type { ProjectAsset } from "@/services/api/projects";
|
||||
import type { TimelineClip, TimelineClipKind } from "@/types/timeline";
|
||||
|
||||
const MEDIA_TYPE_TO_KIND: Record<string, TimelineClipKind | undefined> = {
|
||||
video: "video",
|
||||
audio: "audio",
|
||||
image: "image",
|
||||
text: "text",
|
||||
};
|
||||
|
||||
export function assetToClipKind(mediaType: string): TimelineClipKind | null {
|
||||
return MEDIA_TYPE_TO_KIND[mediaType] ?? null;
|
||||
}
|
||||
|
||||
export type MakeClipFromAssetOptions = {
|
||||
/** 目标轨道 id(调用方已按 kind 选好轨道)。 */
|
||||
trackId: string;
|
||||
/** 时间线起点(毫秒,通常为该轨道当前末尾)。 */
|
||||
startMs: number;
|
||||
/** 默认时长(毫秒);资产无时长元数据时使用。 */
|
||||
defaultDurationMs?: number;
|
||||
/** 时间线内唯一 id(调用方负责去重,通常 `asset:<id>:<n>`)。 */
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export function makeClipFromAsset(asset: ProjectAsset, options: MakeClipFromAssetOptions): TimelineClip | null {
|
||||
const kind = assetToClipKind(asset.mediaType);
|
||||
if (!kind) return null;
|
||||
if (kind === "text") return null; // 文本资产不直接入轨(走字幕/文字工具)
|
||||
return {
|
||||
id: options.id ?? `asset:${asset.id}`,
|
||||
kind,
|
||||
nodeId: `asset:${asset.id}`,
|
||||
trackId: options.trackId,
|
||||
startMs: options.startMs,
|
||||
durationMs: asset.durationMs && asset.durationMs > 0 ? asset.durationMs : (options.defaultDurationMs ?? 3_000),
|
||||
title: asset.title,
|
||||
directMedia: {
|
||||
id: asset.id,
|
||||
kind: kind === "subtitle" ? "text" : kind,
|
||||
title: asset.title,
|
||||
storageKey: asset.storageKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
364
web/src/lib/timeline/editor-commands.ts
Normal file
364
web/src/lib/timeline/editor-commands.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
// 编辑器命令协议(ADR-0002):时间线唯一修改入口。
|
||||
// 命令 = 可序列化 { op, payload },handler 为纯函数(禁止依赖组件实例),
|
||||
// 未知 op 或非法 payload 一律抛错(fail-closed),保证可回放、可撤销、可黄金文件测试。
|
||||
// 插件可经宿主 API register 自定义 op,与内建命令共用同一注册表与校验纪律。
|
||||
|
||||
import type { SrtEntry, TimelineClip, TimelineProject, TimelineTrack, TimelineTrackKind } from "@/types/timeline";
|
||||
|
||||
export type EditCommand = { op: string; payload: unknown };
|
||||
export type CommandHandler = (state: TimelineProject, payload: unknown) => TimelineProject;
|
||||
|
||||
export type EditorCommandRegistry = {
|
||||
/** 注册命令 handler(宿主与插件共用;同名 op 覆盖,插件的注册顺序即优先级)。 */
|
||||
register: (op: string, handler: CommandHandler) => void;
|
||||
/** 应用命令:未知 op 抛错(fail-closed);返回新状态(不可变更新)。 */
|
||||
apply: (state: TimelineProject, cmd: EditCommand) => TimelineProject;
|
||||
knownOps: () => string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 黄金命令集合 payload 类型(首版一期能力,对应 Runbook M2.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AddClipPayload = { clip: TimelineClip };
|
||||
export type MoveClipPayload = { id: string; startMs: number; trackId?: string };
|
||||
export type TrimClipPayload = { id: string; startMs?: number; durationMs?: number; sourceStartMs?: number };
|
||||
export type SplitClipPayload = { id: string; splitAtMs: number };
|
||||
export type RemoveClipPayload = { id: string };
|
||||
export type SetClipPropertyPayload = {
|
||||
id: string;
|
||||
patch: Partial<Pick<TimelineClip, "title" | "text" | "volume" | "fadeInMs" | "fadeOutMs" | "subtitleEntryIndex">>;
|
||||
};
|
||||
export type AddSubtitlePayload = { clip: TimelineClip };
|
||||
export type RemoveSubtitlePayload = { id: string };
|
||||
/** 从节点权威 subtitleEntries 重建时间线条字幕 clip(§3.1 快照契约:单向显式同步,替换过期快照)。 */
|
||||
/** 新增空轨道(addTrack)。id/order/label 由 handler 按当前状态确定,保证 undo/redo 重放确定性。 */
|
||||
export type AddTrackPayload = { kind: TimelineTrackKind };
|
||||
|
||||
/** 移除整条轨道及其全部片段(removeTrack)。守卫:同 kind 至少保留一条轨道(防唯一字幕/视频轨被删光后编辑失锚)。 */
|
||||
export type RemoveTrackPayload = { trackId: string };
|
||||
|
||||
/** 轨道开关(setTrackFlag):可见性 / 静音。flag 白名单校验,fail-closed。 */
|
||||
export type SetTrackFlagPayload = { trackId: string; flag: "visible" | "muted"; value: boolean };
|
||||
export type RebuildSubtitleClipsPayload = {
|
||||
/** 画布节点 id(subtitleEntries 权威源)。 */
|
||||
nodeId: string;
|
||||
/** 权威字幕条目快照(按此重建,text 进入 clip 快照)。 */
|
||||
entries: SrtEntry[];
|
||||
/** 目标字幕轨道 id;缺省取第一个字幕轨道。 */
|
||||
trackId?: string;
|
||||
};
|
||||
|
||||
export const EDITOR_COMMAND_OPS = [
|
||||
"addClip",
|
||||
"moveClip",
|
||||
"trimClip",
|
||||
"splitClip",
|
||||
"removeClip",
|
||||
"setClipProperty",
|
||||
"addSubtitle",
|
||||
"removeSubtitle",
|
||||
"rebuildSubtitleClips",
|
||||
"addTrack",
|
||||
"removeTrack",
|
||||
"setTrackFlag",
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 校验辅助
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fail(op: string, message: string): never {
|
||||
throw new Error(`edit command "${op}" rejected: ${message}`);
|
||||
}
|
||||
|
||||
function assertClipShape(op: string, clip: unknown): asserts clip is TimelineClip {
|
||||
if (typeof clip !== "object" || clip === null) fail(op, "clip must be an object");
|
||||
const c = clip as TimelineClip;
|
||||
if (typeof c.id !== "string" || c.id.length === 0) fail(op, "clip.id must be a non-empty string");
|
||||
if (!["video", "audio", "subtitle", "text", "image"].includes(c.kind)) fail(op, `clip.kind must be a valid clip kind, got "${c.kind}"`);
|
||||
if (typeof c.trackId !== "string" || c.trackId.length === 0) fail(op, "clip.trackId must be a non-empty string");
|
||||
if (typeof c.nodeId !== "string" || c.nodeId.length === 0) fail(op, "clip.nodeId must be a non-empty string");
|
||||
if (typeof c.startMs !== "number" || !Number.isFinite(c.startMs) || c.startMs < 0) fail(op, "clip.startMs must be a non-negative finite number");
|
||||
if (typeof c.durationMs !== "number" || !Number.isFinite(c.durationMs) || c.durationMs <= 0) fail(op, "clip.durationMs must be a positive finite number");
|
||||
}
|
||||
|
||||
function findTrackOrThrow(op: string, state: TimelineProject, trackId: string) {
|
||||
const track = state.tracks.find((t) => t.id === trackId);
|
||||
if (!track) fail(op, `track "${trackId}" does not exist`);
|
||||
return track;
|
||||
}
|
||||
|
||||
function findClipOrThrow(op: string, state: TimelineProject, id: string): TimelineClip {
|
||||
const clip = state.clips.find((c) => c.id === id);
|
||||
if (!clip) fail(op, `clip "${id}" does not exist`);
|
||||
return clip;
|
||||
}
|
||||
|
||||
/** 时间线总时长 = 所有片段末端最大值(与 normalizeTimelineProject 同源)。 */
|
||||
function recomputeDurationMs(state: TimelineProject): number {
|
||||
return state.clips.reduce((max, clip) => Math.max(max, clip.startMs + clip.durationMs), 0);
|
||||
}
|
||||
|
||||
function withClips(state: TimelineProject, clips: TimelineClip[]): TimelineProject {
|
||||
return { ...state, clips, durationMs: recomputeDurationMs({ ...state, clips }) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 黄金命令集合 handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAddClip(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { clip } = payload as AddClipPayload;
|
||||
assertClipShape("addClip", clip);
|
||||
if (clip.kind === "subtitle") fail("addClip", 'subtitle clips must use op "addSubtitle"');
|
||||
findTrackOrThrow("addClip", state, clip.trackId);
|
||||
return withClips(state, [...state.clips, clip]);
|
||||
}
|
||||
|
||||
function handleMoveClip(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { id, startMs, trackId } = payload as MoveClipPayload;
|
||||
if (typeof startMs !== "number" || !Number.isFinite(startMs) || startMs < 0) fail("moveClip", "startMs must be a non-negative finite number");
|
||||
const clip = findClipOrThrow("moveClip", state, id);
|
||||
if (trackId !== undefined) findTrackOrThrow("moveClip", state, trackId);
|
||||
const moved = { ...clip, startMs, ...(trackId !== undefined ? { trackId } : {}) };
|
||||
return withClips(state, state.clips.map((c) => (c.id === id ? moved : c)));
|
||||
}
|
||||
|
||||
function handleTrimClip(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { id, startMs, durationMs, sourceStartMs } = payload as TrimClipPayload;
|
||||
const clip = findClipOrThrow("trimClip", state, id);
|
||||
|
||||
const nextStartMs = startMs ?? clip.startMs;
|
||||
const nextDurationMs = durationMs ?? clip.durationMs;
|
||||
const nextSourceStartMs = sourceStartMs ?? clip.sourceStartMs ?? 0;
|
||||
|
||||
if (!Number.isFinite(nextStartMs) || nextStartMs < 0) fail("trimClip", "startMs must be a non-negative finite number");
|
||||
if (!Number.isFinite(nextDurationMs) || nextDurationMs <= 0) fail("trimClip", "durationMs must be a positive finite number");
|
||||
if (!Number.isFinite(nextSourceStartMs) || nextSourceStartMs < 0) fail("trimClip", "sourceStartMs must be a non-negative finite number");
|
||||
|
||||
// 源时长已知时限制裁剪上限(UI 与命令层同一条规则,防止导出越界)。
|
||||
const sourceDuration = clip.sourceDurationMs ?? 0;
|
||||
if (sourceDuration > 0) {
|
||||
if (nextDurationMs > sourceDuration) fail("trimClip", `durationMs ${nextDurationMs} exceeds sourceDurationMs ${sourceDuration}`);
|
||||
if (nextSourceStartMs + nextDurationMs > sourceDuration) fail("trimClip", "sourceStartMs + durationMs exceeds sourceDurationMs");
|
||||
}
|
||||
|
||||
const trimmed: TimelineClip = {
|
||||
...clip,
|
||||
startMs: nextStartMs,
|
||||
durationMs: nextDurationMs,
|
||||
sourceStartMs: nextSourceStartMs,
|
||||
};
|
||||
return withClips(state, state.clips.map((c) => (c.id === id ? trimmed : c)));
|
||||
}
|
||||
|
||||
function handleSplitClip(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { id, splitAtMs } = payload as SplitClipPayload;
|
||||
if (typeof splitAtMs !== "number" || !Number.isFinite(splitAtMs)) fail("splitClip", "splitAtMs must be a finite number");
|
||||
const clip = findClipOrThrow("splitClip", state, id);
|
||||
|
||||
const clipEnd = clip.startMs + clip.durationMs;
|
||||
if (splitAtMs <= clip.startMs || splitAtMs >= clipEnd) {
|
||||
fail("splitClip", `splitAtMs ${splitAtMs} must be strictly inside clip [${clip.startMs}, ${clipEnd})`);
|
||||
}
|
||||
|
||||
const leftDuration = splitAtMs - clip.startMs;
|
||||
const rightDuration = clipEnd - splitAtMs;
|
||||
const left: TimelineClip = { ...clip, durationMs: leftDuration };
|
||||
const right: TimelineClip = {
|
||||
...clip,
|
||||
id: `${clip.id}:split:${splitAtMs}`,
|
||||
startMs: splitAtMs,
|
||||
durationMs: rightDuration,
|
||||
sourceStartMs: (clip.sourceStartMs ?? 0) + leftDuration,
|
||||
};
|
||||
|
||||
const index = state.clips.findIndex((c) => c.id === id);
|
||||
const clips = [...state.clips.slice(0, index), left, right, ...state.clips.slice(index + 1)];
|
||||
return withClips(state, clips);
|
||||
}
|
||||
|
||||
function handleRemoveClip(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { id } = payload as RemoveClipPayload;
|
||||
if (typeof id !== "string" || id.length === 0) fail("removeClip", "id must be a non-empty string");
|
||||
findClipOrThrow("removeClip", state, id);
|
||||
return withClips(state, state.clips.filter((c) => c.id !== id));
|
||||
}
|
||||
|
||||
/** setClipProperty 允许修改的属性白名单;结构字段(id/kind/nodeId/trackId/startMs/durationMs/...)由专门命令负责。 */
|
||||
const CLIP_PROPERTY_WHITELIST = new Set(["title", "text", "volume", "fadeInMs", "fadeOutMs", "subtitleEntryIndex"]);
|
||||
|
||||
function handleSetClipProperty(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { id, patch } = payload as SetClipPropertyPayload;
|
||||
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) fail("setClipProperty", "patch must be an object");
|
||||
const keys = Object.keys(patch);
|
||||
if (keys.length === 0) fail("setClipProperty", "patch must not be empty");
|
||||
for (const key of keys) {
|
||||
if (!CLIP_PROPERTY_WHITELIST.has(key)) fail("setClipProperty", `property "${key}" is not editable via setClipProperty (use the dedicated op)`);
|
||||
}
|
||||
findClipOrThrow("setClipProperty", state, id);
|
||||
|
||||
const updated = { ...findClipOrThrow("setClipProperty", state, id), ...patch };
|
||||
return withClips(state, state.clips.map((c) => (c.id === id ? updated : c)));
|
||||
}
|
||||
|
||||
function handleAddSubtitle(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { clip } = payload as AddSubtitlePayload;
|
||||
assertClipShape("addSubtitle", clip);
|
||||
if (clip.kind !== "subtitle") fail("addSubtitle", `clip.kind must be "subtitle", got "${clip.kind}"`);
|
||||
findTrackOrThrow("addSubtitle", state, clip.trackId);
|
||||
return withClips(state, [...state.clips, clip]);
|
||||
}
|
||||
|
||||
function handleRemoveSubtitle(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { id } = payload as RemoveSubtitlePayload;
|
||||
if (typeof id !== "string" || id.length === 0) fail("removeSubtitle", "id must be a non-empty string");
|
||||
const clip = findClipOrThrow("removeSubtitle", state, id);
|
||||
if (clip.kind !== "subtitle") fail("removeSubtitle", `clip "${id}" is not a subtitle clip`);
|
||||
return withClips(state, state.clips.filter((c) => c.id !== id));
|
||||
}
|
||||
|
||||
/** 轨道种类的基础标签;新增轨道 label = 基础标签 + 同种类现有轨道数 + 1。 */
|
||||
export const TRACK_KIND_BASE_LABELS: Record<TimelineTrackKind, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
text: "文本",
|
||||
audio: "音频",
|
||||
subtitle: "字幕",
|
||||
};
|
||||
|
||||
function handleAddTrack(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { kind } = payload as AddTrackPayload;
|
||||
if (!(kind in TRACK_KIND_BASE_LABELS)) {
|
||||
fail("addTrack", `track kind must be one of "video" | "image" | "text" | "audio" | "subtitle", got "${String(kind)}"`);
|
||||
}
|
||||
// id/order/label 都由当前状态确定:同一撤销/重放路径永远得到同一结果(确定性)。
|
||||
let index = state.tracks.filter((t) => t.kind === kind).length + 1;
|
||||
let id = `${kind}-${index}`;
|
||||
while (state.tracks.some((t) => t.id === id)) {
|
||||
index += 1;
|
||||
id = `${kind}-${index}`;
|
||||
}
|
||||
const order = state.tracks.reduce((max, t) => Math.max(max, t.order), -1) + 1;
|
||||
const track: TimelineTrack = { id, kind, label: `${TRACK_KIND_BASE_LABELS[kind]} ${index}`, order };
|
||||
return { ...state, tracks: [...state.tracks, track] };
|
||||
}
|
||||
|
||||
function handleRemoveTrack(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { trackId } = payload as RemoveTrackPayload;
|
||||
if (typeof trackId !== "string" || trackId.length === 0) fail("removeTrack", "trackId must be a non-empty string");
|
||||
const track = findTrackOrThrow("removeTrack", state, trackId);
|
||||
// 每类轨道至少保留一条:删除最后一条会让吸附目标、字幕重建等编辑操作失锚。
|
||||
const lastOfKind = state.tracks.filter((t) => t.kind === track.kind).length <= 1;
|
||||
if (lastOfKind) fail("removeTrack", `cannot remove the last "${track.kind}" track`);
|
||||
// 轨道上片段随轨道一并移除(moveClip/trimClip/splitClip 均已按 trackId 归属,无悬挂引用)。
|
||||
return {
|
||||
...state,
|
||||
tracks: state.tracks.filter((t) => t.id !== trackId),
|
||||
clips: state.clips.filter((c) => c.trackId !== trackId),
|
||||
};
|
||||
}
|
||||
|
||||
function handleSetTrackFlag(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { trackId, flag, value } = payload as SetTrackFlagPayload;
|
||||
if (typeof trackId !== "string" || trackId.length === 0) fail("setTrackFlag", "trackId must be a non-empty string");
|
||||
if (flag !== "visible" && flag !== "muted") fail("setTrackFlag", "flag must be \"visible\" or \"muted\"");
|
||||
if (typeof value !== "boolean") fail("setTrackFlag", "value must be a boolean");
|
||||
findTrackOrThrow("setTrackFlag", state, trackId);
|
||||
return {
|
||||
...state,
|
||||
tracks: state.tracks.map((t) => (t.id === trackId ? { ...t, [flag]: value } : t)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 校验 SrtEntry 数组:形状、区间、重复 index(fail-closed)。 */
|
||||
function assertSubtitleEntries(op: string, entries: unknown): asserts entries is SrtEntry[] {
|
||||
if (!Array.isArray(entries)) fail(op, "entries must be an array of SrtEntry");
|
||||
const seen = new Set<number>();
|
||||
for (const entry of entries) {
|
||||
if (typeof entry !== "object" || entry === null) fail(op, "entries must contain SrtEntry objects");
|
||||
const e = entry as SrtEntry;
|
||||
if (!Number.isInteger(e.index) || e.index < 0) fail(op, `entry index must be a non-negative integer, got ${e.index}`);
|
||||
if (seen.has(e.index)) fail(op, `duplicate entry index ${e.index}`);
|
||||
seen.add(e.index);
|
||||
if (!Number.isFinite(e.startMs) || e.startMs < 0) fail(op, `entry ${e.index} startMs must be a non-negative finite number`);
|
||||
if (!Number.isFinite(e.endMs) || e.endMs <= e.startMs) fail(op, `entry ${e.index} endMs must be greater than startMs`);
|
||||
if (typeof e.text !== "string") fail(op, `entry ${e.index} text must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从节点权威 subtitleEntries 重建字幕 clip(§3.1 快照契约:单向显式同步)。
|
||||
* 移除 nodeId 名下全部过期字幕快照,按 entries 重建;id 确定(nodeId:subtitle:index),
|
||||
* 重跑即原地替换,不产生漂移。缺省轨道取第一个字幕轨。
|
||||
*/
|
||||
function handleRebuildSubtitleClips(state: TimelineProject, payload: unknown): TimelineProject {
|
||||
const { nodeId, entries, trackId } = payload as RebuildSubtitleClipsPayload;
|
||||
if (typeof nodeId !== "string" || nodeId.length === 0) fail("rebuildSubtitleClips", "nodeId must be a non-empty string");
|
||||
assertSubtitleEntries("rebuildSubtitleClips", entries);
|
||||
|
||||
const explicitTrack = trackId !== undefined ? findTrackOrThrow("rebuildSubtitleClips", state, trackId) : null;
|
||||
if (explicitTrack && explicitTrack.kind !== "subtitle") fail("rebuildSubtitleClips", `track "${trackId}" is not a subtitle track`);
|
||||
const subtitleTrack = explicitTrack ?? state.tracks.find((t) => t.kind === "subtitle");
|
||||
if (!subtitleTrack) fail("rebuildSubtitleClips", "no subtitle track available");
|
||||
|
||||
const rebuilt: TimelineClip[] = entries.map((entry) => ({
|
||||
id: `${nodeId}:subtitle:${entry.index}`,
|
||||
kind: "subtitle" as const,
|
||||
nodeId,
|
||||
trackId: subtitleTrack.id,
|
||||
startMs: entry.startMs,
|
||||
durationMs: entry.endMs - entry.startMs,
|
||||
subtitleEntryIndex: entry.index,
|
||||
text: entry.text,
|
||||
}));
|
||||
const clips = [...state.clips.filter((c) => !(c.kind === "subtitle" && c.nodeId === nodeId)), ...rebuilt];
|
||||
return withClips(state, clips);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 注册表
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BUILTIN_HANDLERS: ReadonlyArray<readonly [string, CommandHandler]> = [
|
||||
["addClip", handleAddClip],
|
||||
["moveClip", handleMoveClip],
|
||||
["trimClip", handleTrimClip],
|
||||
["splitClip", handleSplitClip],
|
||||
["removeClip", handleRemoveClip],
|
||||
["setClipProperty", handleSetClipProperty],
|
||||
["addSubtitle", handleAddSubtitle],
|
||||
["removeSubtitle", handleRemoveSubtitle],
|
||||
["addTrack", handleAddTrack],
|
||||
["removeTrack", handleRemoveTrack],
|
||||
["setTrackFlag", handleSetTrackFlag],
|
||||
["rebuildSubtitleClips", handleRebuildSubtitleClips],
|
||||
];
|
||||
|
||||
/** 创建隔离的命令注册表(测试/黄金文件比对用独立实例)。 */
|
||||
export function createEditorCommandRegistry(): EditorCommandRegistry {
|
||||
const handlers = new Map<string, CommandHandler>(BUILTIN_HANDLERS);
|
||||
return {
|
||||
register: (op, handler) => {
|
||||
if (typeof op !== "string" || op.length === 0) throw new Error("edit command op must be a non-empty string");
|
||||
handlers.set(op, handler);
|
||||
},
|
||||
apply: (state, cmd) => {
|
||||
if (typeof cmd !== "object" || cmd === null || typeof cmd.op !== "string") {
|
||||
throw new Error("edit command must be { op, payload }");
|
||||
}
|
||||
const handler = handlers.get(cmd.op);
|
||||
if (!handler) throw new Error(`unknown edit command op "${cmd.op}"`);
|
||||
return handler(state, cmd.payload);
|
||||
},
|
||||
knownOps: () => [...handlers.keys()],
|
||||
};
|
||||
}
|
||||
|
||||
// 宿主核心协议单例:编辑器 store、插件经宿主 API 均注册/消费此实例。
|
||||
let sharedRegistry: EditorCommandRegistry | null = null;
|
||||
export function getEditorCommandRegistry(): EditorCommandRegistry {
|
||||
sharedRegistry ??= createEditorCommandRegistry();
|
||||
return sharedRegistry;
|
||||
}
|
||||
57
web/src/lib/timeline/editor-history.ts
Normal file
57
web/src/lib/timeline/editor-history.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// 时间线快照撤销(ADR-0002):有界 200 层 undo/redo 栈。
|
||||
// 纯函数式结构,便于 zustand 以 set(createEditorHistory(...)) 直接换新引用。
|
||||
// 快照依赖命令层不可变更新:历史中保存的是结构共享的全量对象,不做深拷贝。
|
||||
|
||||
import type { TimelineProject } from "@/types/timeline";
|
||||
|
||||
export const HISTORY_LIMIT = 200;
|
||||
|
||||
export type EditorHistory = {
|
||||
/** 栈底为最旧,栈顶(末尾)为最近一次历史。 */
|
||||
undoStack: TimelineProject[];
|
||||
redoStack: TimelineProject[];
|
||||
current: TimelineProject;
|
||||
};
|
||||
|
||||
export function createEditorHistory(initial: TimelineProject): EditorHistory {
|
||||
return { undoStack: [], redoStack: [], current: initial };
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交一次新状态:旧 current 入 undo 栈,清空 redo(新分支),
|
||||
* 超过 HISTORY_LIMIT 时丢弃最旧快照。
|
||||
*/
|
||||
export function pushEditorHistory(history: EditorHistory, next: TimelineProject): EditorHistory {
|
||||
const undoStack = [...history.undoStack, history.current];
|
||||
if (undoStack.length > HISTORY_LIMIT) undoStack.shift();
|
||||
return { undoStack, redoStack: [], current: next };
|
||||
}
|
||||
|
||||
/** 无可撤销时返回 null(调用方保持当前状态不变)。 */
|
||||
export function undoEditorHistory(history: EditorHistory): EditorHistory | null {
|
||||
const prev = history.undoStack.at(-1);
|
||||
if (prev === undefined) return null;
|
||||
return {
|
||||
undoStack: history.undoStack.slice(0, -1),
|
||||
redoStack: [...history.redoStack, history.current],
|
||||
current: prev,
|
||||
};
|
||||
}
|
||||
|
||||
export function redoEditorHistory(history: EditorHistory): EditorHistory | null {
|
||||
const next = history.redoStack.at(-1);
|
||||
if (next === undefined) return null;
|
||||
return {
|
||||
undoStack: [...history.undoStack, history.current],
|
||||
redoStack: history.redoStack.slice(0, -1),
|
||||
current: next,
|
||||
};
|
||||
}
|
||||
|
||||
export function canUndo(history: EditorHistory): boolean {
|
||||
return history.undoStack.length > 0;
|
||||
}
|
||||
|
||||
export function canRedo(history: EditorHistory): boolean {
|
||||
return history.redoStack.length > 0;
|
||||
}
|
||||
36
web/src/lib/timeline/mock-transcription.ts
Normal file
36
web/src/lib/timeline/mock-transcription.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Mock 转写(M3.6):M4 后端转写任务未就绪前,用确定性伪转写模拟 ASR 结果。
|
||||
// 同一 title + durationMs 永远生成相同的 SrtEntry[],便于测试与回归;
|
||||
// M4 接真实任务客户端后本模块移除,UI 契约(SrtEntry[] 落字幕轨道)不变。
|
||||
|
||||
import type { SrtEntry } from "@/types/timeline";
|
||||
|
||||
const MOCK_CHUNK_MS = 1800;
|
||||
const MOCK_GAP_MS = 200;
|
||||
|
||||
/** 从标题确定性生成一组模拟字幕条目(基于字符串 hash 稳定切分)。 */
|
||||
export function mockTranscriptionEntries(title: string, durationMs: number): SrtEntry[] {
|
||||
const text = title.trim() || "未命名素材";
|
||||
const chunks = splitStable(text, 6);
|
||||
const usable = Math.max(1, Math.floor(durationMs / (MOCK_CHUNK_MS + MOCK_GAP_MS)));
|
||||
const count = Math.min(chunks.length, usable);
|
||||
const step = durationMs / count;
|
||||
|
||||
const entries: SrtEntry[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const startMs = Math.round(i * step);
|
||||
const endMs = Math.min(durationMs, Math.round(startMs + MOCK_CHUNK_MS));
|
||||
if (endMs <= startMs) break;
|
||||
entries.push({ index: i, startMs, endMs, text: chunks[i % chunks.length] });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 稳定切分:按可打印字符逐段取满 maxLen,确定性无随机。 */
|
||||
function splitStable(text: string, maxLen: number): string[] {
|
||||
const chars = Array.from(text);
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < chars.length; i += maxLen) {
|
||||
chunks.push(chars.slice(i, i + maxLen).join(""));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
72
web/src/lib/timeline/timeline-summary.ts
Normal file
72
web/src/lib/timeline/timeline-summary.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// 时间线确定性摘要(Runbook M6.2):把 TimelineProject v2 压缩成喂给 LLM 的中文上下文。
|
||||
// 同一输入永远产出同一输出(无时间戳/无随机),供命令引用 id 与 AI 面板上下文注入。
|
||||
|
||||
import type { TimelineClip, TimelineProject, TimelineTrack } from "@/types/timeline";
|
||||
|
||||
/** 片段逐行列出的上限;超出后折叠为一行汇总,防止超长时间线撑爆模型上下文。 */
|
||||
export const SUMMARY_MAX_CLIP_LINES = 60;
|
||||
/** 标题/文本在摘要中的最大展示长度。 */
|
||||
export const SUMMARY_MAX_TEXT_CHARS = 24;
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
video: "视频",
|
||||
audio: "音频",
|
||||
subtitle: "字幕",
|
||||
text: "文本",
|
||||
image: "图片",
|
||||
};
|
||||
|
||||
export function formatMs(ms: number): string {
|
||||
const total = Math.max(0, Math.round(ms));
|
||||
const s = Math.floor(total / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
return `${m}:${String(rem).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function clipLine(clip: TimelineClip): string {
|
||||
const label = (clip.title || clip.text || "").trim().slice(0, SUMMARY_MAX_TEXT_CHARS);
|
||||
const source = clip.directMedia?.title?.slice(0, SUMMARY_MAX_TEXT_CHARS) ?? "";
|
||||
const text = label || source || clip.nodeId.slice(0, 12);
|
||||
return ` - clip ${clip.id} | ${clip.kind} | 轨道 ${clip.trackId} | ${clip.startMs}-${clip.startMs + clip.durationMs}ms | ${text}`;
|
||||
}
|
||||
|
||||
function trackLine(track: TimelineTrack): string {
|
||||
const flags = [
|
||||
track.visible === false ? "隐藏" : null,
|
||||
track.muted ? "静音" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("/");
|
||||
return ` - ${track.id} | ${KIND_LABELS[track.kind] ?? track.kind}轨 | ${track.label}${flags ? ` | ${flags}` : ""}`;
|
||||
}
|
||||
|
||||
/** 生成中文时间线摘要(确定性、纯文本)。 */
|
||||
export function summarizeTimeline(project: TimelineProject): string {
|
||||
const lines: string[] = [];
|
||||
const subtitleClips = project.clips.filter((c) => c.kind === "subtitle");
|
||||
const nodeIds = new Set(project.clips.map((c) => c.nodeId));
|
||||
const directCount = project.clips.filter((c) => c.directMedia).length;
|
||||
|
||||
lines.push(`时间线:总时长 ${formatMs(project.durationMs)},${project.tracks.length} 条轨道,${project.clips.length} 个片段(字幕 ${subtitleClips.length},直连素材 ${directCount},引用节点 ${nodeIds.size} 个)。`);
|
||||
lines.push(`轨道(顺序从上到下):`);
|
||||
for (const track of [...project.tracks].sort((a, b) => a.order - b.order)) {
|
||||
lines.push(trackLine(track));
|
||||
}
|
||||
lines.push(`片段:`);
|
||||
if (project.clips.length === 0) {
|
||||
lines.push(` (空时间线)`);
|
||||
} else {
|
||||
const shown = project.clips.slice(0, SUMMARY_MAX_CLIP_LINES);
|
||||
for (const clip of shown) {
|
||||
lines.push(clipLine(clip));
|
||||
}
|
||||
const hidden = project.clips.length - shown.length;
|
||||
if (hidden > 0) lines.push(` …其余 ${hidden} 个片段省略`);
|
||||
}
|
||||
if (subtitleClips.length > 0) {
|
||||
const totalChars = subtitleClips.reduce((sum, c) => sum + (c.text?.length ?? 0), 0);
|
||||
lines.push(`字幕共 ${totalChars} 字。`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// 时间线视图数学(移植自 lingji-cut 的 timeline-view.ts,按本项目类型精简)。
|
||||
// 提供缩放钳制、像素/毫秒换算、标尺刻度与时间格式化。
|
||||
|
||||
const BASE_TIMELINE_PX_PER_SECOND = 96;
|
||||
export const BASE_TIMELINE_PX_PER_SECOND = 96;
|
||||
const MIN_TIMELINE_TRACK_WIDTH = 960;
|
||||
const MIN_TIMELINE_ZOOM = 0.02;
|
||||
const MAX_TIMELINE_ZOOM = 4;
|
||||
@@ -21,6 +21,11 @@ export function getBaseTimelineWidth(durationMs: number): number {
|
||||
return Math.max(MIN_TIMELINE_TRACK_WIDTH, Math.ceil(Math.max(1_000, durationMs) / 1_000) * BASE_TIMELINE_PX_PER_SECOND);
|
||||
}
|
||||
|
||||
/** 缩放级别对应的像素/毫秒。时间线面板用它做独立的等比映射,拖动片段时轨道随内容扩展,比例保持恒定。 */
|
||||
export function getTimelinePxPerMs(zoomLevel: number): number {
|
||||
return (BASE_TIMELINE_PX_PER_SECOND * clampTimelineZoom(zoomLevel)) / 1_000;
|
||||
}
|
||||
|
||||
export function getNextTimelineZoom(zoomLevel: number, direction: ZoomDirection): number {
|
||||
const nextZoom = direction === "in" ? zoomLevel * TIMELINE_ZOOM_STEP : zoomLevel / TIMELINE_ZOOM_STEP;
|
||||
return clampTimelineZoom(nextZoom);
|
||||
|
||||
Reference in New Issue
Block a user