Files
ttstd-infinite-canvas/docs/plans/m4-transcription-render-design.md
爱笑的毛毛虫 7ec51732f7 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 + 测试 + 构建通过,
  浏览器冒烟待真实文本渠道)。
2026-09-04 14:03:33 +08:00

146 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M4 转写与渲染任务设计(增量)
> 状态:M4.1–M4.3 已实现并提交(执行侧 e9d1b8ad、创建 API bf26ebb1、前端字幕回写 fd5e8139、
> 渲染任务 3f16d13b)。本文按实现回写;任何出入以 `backend/internal/service/` 现行代码为准。
> 参考锚点:`task_worker.go`、`backend/internal/repository/repository.go`、
> `backend/internal/handler/routes.go`(各小节内另行标注实现文件)。
## 1. 现状边界(调研结论)
现有模型生成链路对"任务"的约定:
- `CreateTask`(`task_creation.go`):draining 检查 → prompt/type 校验 → **模型路由**
`resolveTaskModelSelection`(绑定 logical model / revision / route / channel model / billing)→
额度与活跃任务配额 → 落库 `queued`。`text_replay` 是唯一旁路(前端自管、不排队)。
- worker(`task_worker.go`):`ClaimNextTask` **不过滤 task type**——任何 `queued` 都会被模型
worker 领取;`processClaimedTask` 随后做二次路由、billing `MarkRunning`、provider 执行、
`handleSuccess`(billing settle + session/message 落库)。
- 结论(实现,修正原草案):M4 任务**进入同一公共队列**,由唯一 worker 池领取
(`ClaimNextTask` 不过滤类型),但在 `processClaimedTask` 顶部按类型分叉到独立执行器,
**不经计费与模型渠道路由**(`beginTaskRouteAttempt` / `MarkBillingRunning` /
`routeExecutor` / `handleSuccess` 全部跳过);终态分别走 `SaveTaskCompletion`(成功)/
`UpdateTaskTerminalState`(失败)。
## 2. 架构决策
1. **新任务类型常量**(放 `backend/internal/model`,与 `TaskStatus*` 并列):
- `TaskTypeTimelineTranscription = "timeline_transcription"`
- `TaskTypeTimelineRender = "timeline_render"`
两者都不经过 `validateTaskType` 白名单(创建走独立入口)。
2. **创建走独立 API 与 service 方法**(不经过 `POST /api/tasks` 的模型路由/计费入口):
- `POST /api/timeline/transcriptions`(M4.1,`CreateTimelineTranscriptionTask`)
- `POST /api/timeline/renders`(M4.2,`CreateTimelineRenderTask`)
- 两路由**注册在 `RegisterTaskRoutes` 内**(挂载于 `/api` 组),复用任务路由组的
鉴权/限流骨架(限流键 `timeline-ts:<userID>` 等),非独立路由组。
- 复用现有任务基础设施:同一 `tasks` 表、同一 `TaskStatus` 状态机、租约/取消原语;
无 billing/session/message 关联(`BillingOrderID` 为空,收尾走旁路终态)。
- 查询与取消**复用现有端点**:`GET /api/tasks/:id`、`POST /api/tasks/:id/cancel`
(service 的 `Task()` / `CancelTask()` 均与 task type 无关,已核对)。
3. **单一 worker 池,领取不过滤**(实现修正原草案):`ClaimNextTask` **不加类型过滤**
——worker 是 queued 任务的唯一领取方,若按类型排除会使两类新任务无人领取;改为在
`processClaimedTask` 顶部按 `task.Type` 分叉(见决策 4)。任务类型常量从 `model` 包引入。
4. **`processClaimedTask` 顶部按类型分叉**:在 cancelled 复查之后、stage 覆盖/路由之前,
`timeline_transcription` → `processTimelineTranscription(task, ctx)`(`task_timeline.go`)、
`timeline_render` → `processTimelineRender(task, ctx)`(`task_render.go`),复用外层已建立的
租约续期、`registerActiveTask`(取消即时生效)与超时 context 框架。超时
`taskExecutionTimeoutWithPolicy` 分叉:转写 **20 分钟**、渲染 **60 分钟**(草案 30 分钟已改)。
5. **旁路终态**:转写/渲染成功不用 `handleSuccess`,`ResultJSON` 落盘后
`repo.SaveTaskCompletion(task, running, nil, nil, nil)`(清租约由框架现有 defer 完成);
失败走 `repo.UpdateTaskTerminalState(failed, stage, message, now)` 记可读错误。日志沿用 `s.log`。
## 3. M4.1 转写任务规格
### 3.1 输入(`POST /api/timeline/transcriptions` body,路由注册于 `RegisterTaskRoutes`)
```jsonc
{
"resourceId": "string", // 必填,音频/视频资源;校验归属且存在
"language": "zh", // 可选,默认空 = whisper 自动检测
"projectId": "string" // 可选,记录到任务归属字段(ProjectID)
}
```
校验顺序(`CreateTimelineTranscriptionTask`):`IsDraining`(503) → `RequireFeature`
(timelineTranscription,默认开)→ `Resource()` 归属读取(失败提示"无法读取待转写媒体,
可能已被删除")→ `isTranscribableMime`(失败提示"仅支持音视频文件转写")→
`RuntimePolicy` → 配额入队(`createTaskWithinStorageQuota`,无 billing order;
`repository.ErrActiveTaskLimit` → 可读 400)→ 落库 `queued`
(Stage="等待队列调度"、Progress=5、Provider=local、Model=whisper.cpp,
`InputJSON = {"resourceId","language"}`)。
### 3.2 输出 JSON 形状(`ResultJSON`,成功时)
```jsonc
{
"segments": [
{ "startMs": 0, "endMs": 3200, "text": "字幕文本…" }
],
"srt": "1\n00:00:00,000 --> 00:00:03,200\n字幕文本…",
"language": "zh"
}
```
毫秒为整型(`timelineTranscriptionSegment{StartMs,EndMs,Text}`)。前端(M4.3)把
`segments` 映射为 `SrtEntry[]`(index = i+1)后直接 `rebuildSubtitleClips` 写入字幕轨道,
不做二次解析;`srt` 供导出。
### 3.3 状态机
与现有 Task 完全一致:`queued → running(claim) → succeeded | failed | cancelled`;
stage/progress 由 `processTimelineTranscription` 内更新:
`"等待转写服务"`(progress 15) → `"正在转写…"`(40) → `"整理字幕…"`(80) → succeeded(100)。
取消:外层 `registerActiveTask` 的 cancel ctx 令 provider HTTP 调用中断(需在请求中透传
`ctx`)。
### 3.4 转写执行实现(`transcription_whisper.go`,未引入独立 Provider 包)
- `whisperClient`(`newWhisperClient`):baseURL 取自环境变量 `CANVAS_WHISPER_BASE_URL`
(§3.6 已决:独立 env,非模型渠道)。
- `transcribe(ctx, wavPath, language)`:本地 ffmpeg 预处理为 16k 单声道 PCM wav
(`prepareWhisperWav`),POST `{baseURL}/inference`,multipart `file` 字段,
`response_format=verbose_json`;ctx 随外层任务 context 透传(取消即时生效)。
- `decodeWhisperVerboseJSON` → `timelineTranscriptionResult{Segments,SRT,Language}`;
`formatSRTTimestamp` / `buildTimelineSRT` 生成 SRT(对齐 `HH:MM:SS,mmm`)。
### 3.5 任务执行(`task_timeline.go` 内 `processTimelineTranscription`)
1. 解密 `InputJSON` → 校验 `resourceId`;`Resource()` 读取并做 user scope 归属校验。
2. `isTranscribableMime`(audio/video)前置校验;ffmpeg 预处理 16k 单声道 PCM wav。
3. 转写(stage/progress:等待转写服务 15 → 正在转写… 40 → 整理字幕… 80 → 完成 100)。
4. `ResultJSON` 落盘(3.2 形状)→ `repo.SaveTaskCompletion`;失败走
`repo.UpdateTaskTerminalState` 记可读错误(未配置 `CANVAS_WHISPER_BASE_URL` 时
明确失败并提示设置)。
### 3.6 已决项
- baseURL:**环境变量 `CANVAS_WHISPER_BASE_URL`**(whisper.cpp HTTP,语音数据不出本机);
`.env.example` 已补充。
- 语言:`language` 直传 whisper `language=`,空 = 自动检测。
- feature gate:`FeatureTimelineTranscription`(availability JSON
`timelineTranscriptionEnabled`,默认开;未开时 `RequireFeature` → Forbidden
"字幕转写暂未开放")。
- ffmpeg 路径:`CANVAS_FFMPEG_PATH`(可空,空则 PATH 查找;转写/渲染/播放副本共用)。
## 4. M4.2 渲染任务(已实现:`task_render.go` + `timeline_render_plan.go` + `task_timeline.go` 创建侧)
- `POST /api/timeline/renders`(注册于 `RegisterTaskRoutes`):body =
`{projectId, timeline}`;timeline 为 v2 快照(tracks/clips 平铺,`renderProject`),片段经
`directMedia.storageKey = "resource:"+<id>` 引用后端资源。
- 创建(`CreateTimelineRenderTask`):draining(503) → `buildRenderPlan` 校验 ≥1 个可渲染
媒体片段(否则 400 "时间线没有可渲染的媒体片段")→ 配额入队(Provider=local、
Model=ffmpeg、Stage="等待队列调度"、Progress=5)。
- 执行(`processTimelineRender`,60 分钟超时):`buildRenderPlan` 展开合成脚本 → 本地
ffmpeg(`CANVAS_FFMPEG_PATH` 或 PATH 查找)→ 产物写资源存储(`storeResource` 路径)→
`ResultJSON` = `{"resourceId","fileName","size","durationMs","subtitleSrt"?}`
(`subtitleSrt`:时间线含字幕轨时携带导出的 SRT 文本,可空)。
- 终态旁路(同 §2 决策 5);无 billing/session/message 关联。
## 5. 数据库/文档同步
- 无 schema 变更(复用 tasks 表)。任务 type 以字符串存储,无须迁移。
- `auth.go` 的 API 前缀白名单已加 `timeline`(防 NoRoute 短代理把未匹配的
`/api/timeline/*` 当渠道请求)。
- 同步更新 `docs/content/docs/progress/pending-test.mdx`(gitignore,仅本地)与
Runbook M4 章节的 API 表格(端点/入参/出参),以及权限文档中的 feature gate。
本文 §2–§4 已按实现回写,仍以代码为准。