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:
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