素材、切片工程与投稿
工作流程为 录播 → 素材 → 切片 → 投稿:
- 素材:从录播中截取的一段视频文件,只提供简单预览。生成时不压制弹幕,但会把对应时间段的弹幕截取保存为素材旁的弹幕文件,录播被清理后弹幕仍然可用。
- 切片:一个简易剪辑工程,由一个或多个素材按顺序组成,在工程中完成裁剪、分割、排序、字幕和弹幕压制,可导出为文件。
- 投稿:独立的功能页,把素材、切片或任意视频文件发布到 B 站,并保留草稿和投稿记录。
数据模型
素材(videos 表)
为了兼容 AI 助手、MCP 与 Webhook,素材沿用 videos 表与 VideoRow。迁移 18 新增:
| 字段 | 说明 |
|---|---|
live_id | 来源录播,导入的视频为空 |
source | 来源信息 JSON:{"ranges":[{"start","end"}],"local_offset":0},时间相对录播播放列表起点 |
duration | 精确时长(秒),旧数据为 0,读取时按需用原生媒体探测补齐 |
素材的旁路文件与视频文件同名:
| 文件 | 说明 |
|---|---|
<stem>.jpg | 缩略图 |
<stem>.opus | 波形用音频样本 |
<stem>.srt | AI 识别字幕缓存(素材时间) |
<stem>.danmu.json | 截取的弹幕,ts 为相对素材起点的毫秒 |
<stem>.peaks.json | 时间轴波形峰值缓存 |
从录播生成素材时,默认每个选区生成一个素材(直接复制视频流,不重新编码);merge 时按选区顺序拼接成一个素材(重新编码),弹幕按拼接后的时间轴截取,内嵌弹幕在拼接后压制。两种方式都可选同时创建包含这些素材的切片工程。
切片(projects 表)
| 字段 | 说明 |
|---|---|
title / note | 标题与备注 |
room_id / platform | 所属直播间,用于素材库筛选和投稿配置 |
cover | 切片封面,相对输出目录(projects/<id>/cover.jpg),为空时使用第一个素材的缩略图;也是投稿的默认封面 |
timeline | 时间轴 JSON,见下文 |
profile | 旧数据迁移来的投稿信息 JSON,为该切片新建投稿时作为初始内容 |
status / bvid | 最近一次投稿的状态与 BV 号,由投稿成功时回写 |
工程文件统一放在 <output>/projects/<id>/,删除工程时一并删除。
时间轴
{
"version": 1,
"clips": [{ "id": "c1", "material_id": 12, "start": 3.2, "end": 40.0 }],
"subtitles": [{ "id": "s1", "clip_id": "c1", "start": 5.0, "end": 7.5, "text": "..." }],
"tracks": [{ "id": "t1", "kind": "sticker", "items": [{ "id": "o1", "clip_id": "c1", "start": 4.0, "duration": 3.0, "kind": "text", "element": {} }] }],
"audio_tracks": [],
"subtitle_style": { "fontName": "Arial", "...": "..." },
"danmu": false
}时间轴只有四种轨道,从上到下:
| 轨道 | 数据 | 说明 |
|---|---|---|
| 字幕 | subtitles | 永远在最上层,只有一条 |
| 视频 / 贴图 | tracks(kind 为 video / sticker) | 可以有多条,按数组顺序从下往上叠加,两种轨道可以任意交错;视频轨道放其他素材的画面(画中画),贴图轨道放文字与贴纸 |
| 主视频 | clips | 视频轨道中的一条,永远在所有画面的最下层 |
| 音频 | audio_tracks | 永远在最下层,可以有多条 |
clips为主视频轨道,按顺序首尾相接播放(磁吸主轨),start/end是素材内的入点与出点。片段在工程中的起点由前面片段的时长累加得到。- 字幕锚定在片段上,时间是素材时间。片段被移动、裁剪时字幕自动跟随画面;超出片段可见范围的部分被隐藏,重新拉长片段后会恢复。分割片段时,跨越分割点的字幕会复制到两侧。
- 其他轨道上的内容同样以起点锚定在主轨片段上,跟随片段移动,长度可以越过后面的片段,也可以越过主轨的结尾:工程时长到最后一个结束的内容为止(
timelineDuration/Timeline::duration,主轨自身的长度是mainDuration/Timeline::main_duration),起点仍须落在主轨上。预览在主轨之后显示黑底,导出时在片段后补一段黑屏静音(RenderClip::blank)。同一条轨道上的内容不重叠,编辑导致重叠时被抬到新的同类轨道上。 - 内容可以拖到同类的其他轨道上;拖到另一类轨道上时,在它上方新建一条同类轨道。主轨片段向上拖到视频轨道上即成为画中画(后面的片段自动前移,锚定在它上面的字幕、贴图、音频随它一起移动),画中画拖到主轨上即成为片段,插入到落点处。主轨至少保留一个片段。
- 旧时间轴的
tracks没有kind,一条轨道上可能同时有视频和文字、贴纸,读取时拆成一条视频轨道和其上方的一条贴图轨道,叠加顺序不变。 danmu是工程级设置:为true时,导出和投稿生成视频时会把各片段可见范围内的素材弹幕映射到工程时间后压制。
预览与导出
- 预览:和常规剪辑软件一样以声音为主时钟。工程里所有声音(片段原声、画中画的声音、音频轨)都由 Web Audio 播放(
src/lib/preview-audio.ts):后端按固定的 10 秒网格把文件的声音切成 48 kHz 双声道 16 位 FLAC 音频块(GET /preview_audio?file=<相对输出目录的路径>&chunk=<序号>,src-tauri/src/preview_audio.rs),切好后缓存在<cache>/preview_audio/,文件变化后重新切,3 天未再切的会被清理。每块的采样与整段解码完全对齐,相邻块逐采样衔接:位置从声音流的起始时间(如 MP3 编码器留下的前导)算起;FLV 转封装的录播把 AAC 帧的时间戳取整到毫秒,按 1024 采样的帧网格校正;第一块不经跳转从头解码,以保留 AAC 的前导帧。音频块使用固定的 4096 采样帧,否则 FFmpeg 可能写出块大小不一的 FLAC,WebKit(Apple 的解码器)无法解码;前端提前几秒加载并在 AudioContext 的时间上逐块首尾相接地排程,音量、淡入淡出用 GainNode 实现,与导出一致。所有<video>都静音,只负责画面:主轨两个<video>交替播放、在切点处切换,画中画各自一个,都跟随主时钟(VideoFollower),小偏差靠略微调快或调慢追上,偏差超过 1 秒才跳转,并跳到跳转完成时时钟所在的位置;跟不上时只会丢帧,不影响声音。开始播放或跳转后,先等当前位置的音频块和画面加载好再启动时钟。播放器对外暴露一个以工程时间为准的虚拟媒体时钟,原生 Skia 字幕预览直接绑定该时钟。预览依赖普通的 HTTP 服务,桌面端(静态文件服务)与 Docker 网页端一致。 - 生成视频:后端把时间轴编译为结构化原生媒体计划和共享滤镜图:每个片段以独立的定位和时长作为输入,统一缩放到 1920×1080、统一帧率与音频格式后
concat,再通过原生渲染器叠加字幕(前端生成的 ASS,与预览完全一致)和弹幕(文字及表情图片),经过任务队列执行,支持进度与取消。先写入目标旁的<名称>.part.mp4,完成后再替换目标文件,失败不会破坏已有文件。 - 导出:
export_project把时间轴生成到用户在保存对话框中选择的文件;Web 端不传路径,生成到<cache>/exports/<id>/export.mp4后由浏览器下载。工程不记录导出文件,每次导出都按当前时间轴重新生成。 - 只有一个完整素材、没有字幕和弹幕的工程(
needs_render为false)不需要生成:导出时复制素材文件,投稿时直接使用素材文件。
投稿(uploads 表)
迁移 19 新增,每条记录是一次投稿:未发布时是草稿,发布后作为历史保留。
| 字段 | 说明 |
|---|---|
source / source_id | 来源:material(素材 id)、project(切片 id)或 file(source_id 为 0) |
file | file 来源的视频绝对路径,投稿时直接读取,不复制 |
room_id | 来源所属直播间,用于沿用同一直播间上次的投稿信息 |
uid | 投稿账号 |
cover | 自定义封面,相对输出目录(uploads/<id>/ 下,每次保存写入新文件),为空时使用来源的封面;视频文件在新建时截取一帧作为封面 |
profile | 投稿信息 JSON(B 站 Profile),编辑时自动保存 |
danmu | 素材投稿是否压制弹幕(迁移 21);切片的弹幕设置保存在切片自己的时间轴中,投稿页修改的就是它 |
status | draft / exporting / posting / posted / failed;应用重启时进行中的记录标记为失败 |
message / bvid | 失败原因 / 投稿成功的 BV 号 |
post_upload 校验后立即返回,投稿在后台进行(不占用任务队列,以免阻塞导出),任务类型为 upload:
- 按来源确定视频文件:素材使用素材文件,开启弹幕且素材保存了弹幕时,按只含整个素材的时间轴生成压制弹幕的视频(任务类型
render_material_danmu,同样缓存在uploads/<id>/);视频文件使用file;切片按当前时间轴以<event_id>-export为任务 id 加入导出队列,生成到uploads/<id>/render_<hash>.mp4(字幕 ASS 由前端按切片时间轴生成后传入),等待完成后使用。哈希包含时间轴、字幕与弹幕设置,失败重试时时间轴未变就直接复用,投稿成功后删除 - 上传视频与封面并提交稿件,成功后回写记录的
bvid,并在素材或切片上记录 BV 号
投稿记录的文件统一放在 <output>/uploads/<id>/,删除记录时一并删除。AI 助手的 upload_procedure 投稿成功后也会写入一条素材投稿记录。
接口
| 命令 | 说明 |
|---|---|
generate_materials | 从录播选区生成素材(每个选区一个,或 merge 合并为一个),可选同时创建工程 |
get_material_usage / get_material_peaks / get_material_danmu | 素材被哪些工程使用 / 波形峰值 / 截取的弹幕 |
get_projects / get_project / create_project / delete_project | 工程增删查 |
update_project_info / update_project_timeline | 工程修改:标题与备注可分别保存;保存时间轴时附带编辑器上次见到的已保存时间轴,后端据此合并期间自己的修改(弹幕设置、删除素材移除的片段、识别回填的字幕),编辑窗口收到变更时按同样规则合并 |
update_project_danmu | 切换工程的弹幕设置(编辑窗口与投稿页共用) |
export_project | 把工程导出到指定文件(Web 端导出到缓存供下载),返回导出任务 |
get_uploads / get_upload / create_upload / delete_upload | 投稿记录增删查,列表附带来源名称、是否需要生成视频与封面 |
update_upload / update_upload_cover | 保存草稿的账号与投稿信息 / 封面 |
update_upload_danmu | 切换压制弹幕:素材写入投稿记录,切片直接修改切片的时间轴 |
post_upload | 在后台投稿,切片先生成视频 |
open_project / open_clip | 打开工程编辑窗口 / 素材预览窗口 |
clip_range 仍然可用(AI 助手与 MCP 使用),它把所有选区合并为一个素材,弹幕与 generate_materials 一样默认保存在素材旁,danmu 为 true 时压制进素材。MCP 还提供 generate_materials、切片的查询与主轨编辑,以及投稿草稿的创建与修改;MCP 只保存草稿,不调用 post_upload,草稿由用户在投稿页发布。
旧数据迁移
迁移 18 为已有的每条 videos 记录标记 needs_project,启动时为它们各创建一个只含该素材的工程:标题、备注、投稿状态与 BV 号迁到工程,素材的 .srt 字幕导入为工程字幕;已投稿的同时生成一条投稿记录。迁移 19 为当时已有 BV 号的工程生成投稿记录;迁移 20 删除工程的 output / output_hash 列,之前导出到 projects/<id>/ 的文件保留,随工程一起删除。