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:
爱笑的毛毛虫
2026-09-04 14:03:33 +08:00
committed by GitHub
parent 33c589fd8e
commit 05d1bce876
106 changed files with 10857 additions and 119 deletions

View 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");
}

View 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,
},
};
}

View 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;
}

View 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;
}

View 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;
}

View 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");
}

View File

@@ -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);