初始化提交:contentm_agent 工作区全量快照

内容分四块:
1、产品规划产出 —— MCN 短视频整合营销工作台的①段五份(1a 需求/1b 竞品/1c 画像/1d 策略/1e 场景)、②段两份(2a 功能/2b 布局)、③段界面(DESIGN.md 契约与令牌表 + mcn-workbench.html 原型 + 实测/会诊/审查三份 + 23 张闸门截图)。
2、开源竞品调研 —— 5 个内容工作台项目的取证原始件与 1b 系列分析文档。
3、参考资料 —— 竞品视频抽帧 1145 张 + 2 个源视频 + 功能点截图。
4、机制侧 —— 协作脚本与状态台账、工作区记忆日志、抽帧/OCR 脚本。

.gitignore 只排运行时日志、脚本备份副本与一次性探针输出,其余按原样入库。
This commit is contained in:
WorkBuddy committed 2026-10-08 08:13:02 +08:00
commit df56c2c137
1773 files changed
+205840

No files matched your search

@@ -0,0 +1,64 @@
# space-video · 视频创作 Skill 矩阵
一套面向自媒体视频创作的 `space-` 技能矩阵,把一条视频从「参考 → 选题 → 脚本 → 剪辑 → B-roll → 字幕 → 封面」拆成单一职责、可单用也可串联的技能。
## 技能清单
| 技能 | 职责 |
|---|---|
| **space-video** | 总控/导演:判断走哪步、路由子技能、成片后复盘 |
| **space-video-topic** | 选题前端:下载去水印 · 逐字稿 · 选题/钩子 · 标题 · 正文 |
| **space-video-transcript** | 五个平台链接转 Markdown/SRT;字幕完成后询问是否下载原视频/音频 |
| **space-video-script** | 脚本:去 AI 味口播稿 + 精确到秒分镜 |
| **space-video-edit** | 剪辑:剪口播(删前保后 + 风险分层)+ 口播成片 |
| **space-video-broll** | B-roll:HTML→确定性 MP4 的动画/motion 画面(代码渲染) |
| **space-video-broll-sketch** | B-roll:文章转手绘图解,调视频模型(Seedance/libtv)逐帧生成 |
| **space-video-subtitle** | 字幕:转写 · 断句 · AI 校对 · SRT/ASS · 烧录 |
| **space-video-audio** | 配音配乐:TTS 旁白 · 找 BGM 下载 · 压音混音 · 响度归一 |
| **space-video-cover** | 封面:家族气质 + 平台比例 + 高点击缩略图 |
## 串联用法
```
发送 YouTube / 小宇宙 / B站 / 抖音 / 小红书链接 → space-video-transcript 先提字幕 → 再询问是否下载源文件
说「从头帮我做一条视频」→ space-video 接管,逐环节调子技能,文件落盘交接
```
各技能也可单独触发,见每个 SKILL.md 的触发词。
---
## 致谢与引用来源
本矩阵在设计与方法论上参考、借鉴了以下开源项目,并对其能力做了简化、拆解与重组:
**Skill 架构与视频创作方法论**
- [Pluviobyte/rnskill](https://github.com/Pluviobyte/rnskill) — Skill 工作流串联模式、视频创作技能分类
- [chengfeng / AI产品自由 · chengfeng-videocut-skills](https://github.com/Agentchengfeng/chengfeng-videocut-skills)(收录于 rnskill)— 剪口播「删前保后」「删除风险分层」方法论、口播成片分镜/时间轴流程,是 `space-video-edit` 的主要参考
**视频下载与文案提取**
- [yzfly/douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) — 抖音无水印下载、SenseVoice 语音转文案思路,参考进 `space-video-topic` / `space-video-subtitle`
**配音 / TTS**
- rnskill `tts-skill`(IndexTTS2 本地克隆音色路由方法论)— 参考进 `space-video-audio` 的 TTS 分档策略
**脚本/分镜规格**
- [feicaiclub/video-spec-builder](https://github.com/feicaiclub/video-spec-builder) — 「把模糊想法逼成精确到秒的分镜脚本」理念,参考进 `space-video-script`
**B-roll 渲染**
- [heygen-com/hyperframes](https://github.com/heygen-com/hyperframes) — HTML→确定性 MP4、可寻址动画机制,是 `space-video-broll` 的技术路线基础
**视频模型(生成式 B-roll)**
- 火山引擎 **Seedance 2.0**(火山方舟 Ark API,`doubao-seedance-2-0-*`)— `space-video-broll-sketch` 的 Seedance 通道
- **libtv Agent**(复用本地 `libtv-skill` 的 agent-im OpenAPI)— `space-video-broll-sketch` 的 libtv 通道
**编排复用的本地技能**(矩阵不重复造轮子,直接路由)
- 去 AI 味:`Humanizer-zh`|口播转换:`podcast-script-generator`
- B-roll 渲染:`hyperframes` / `hyperframes-cli` / `hyperframes-media` / `remotion-video`
- 动画适配器:`gsap` / `animejs` / `css-animations` / `waapi` / `three` / `lottie`
- 标题:`baokuan-title-generator`|选题:`topic-generator` / `topic-collector`
- 逐字稿:`youtube-transcript-cn` / `content-digest`
- 封面出图:`image-studio` / `space-article-cover-and-batch-illustration`
各来源的版权与许可归原作者所有。
@@ -0,0 +1,91 @@
---
name: space-video-audio
description: 视频配音与配乐。三件事——(1) 配音:把口播稿用 TTS 生成旁白音频;(2) 配乐:根据视频内容/节奏匹配合适的 BGM,从免版权曲库找到并下载;(3) 混音:把 BGM + 旁白 + 视频用 ffmpeg 合成,自动压音(BGM 在人声下自动变小)、响度归一、淡入淡出、对齐视频长度。当用户说「配音」「配乐」「加 BGM」「背景音乐」「找音乐」「给视频配旁白」「TTS 口播」「混音」「audio」时触发。
---
# space-video-audio · 配音与配乐
给视频补上声音:**旁白(TTS) + 背景音乐(BGM) + 混音**。三块可单用,也能一次做完。
## 一、配音(TTS 旁白)
把口播稿变成旁白音频。按可用后端分档,**优先质量高的**:
| 档 | 后端 | 说明 |
|---|---|---|
| 高 | IndexTTS2 / 克隆音色 | 有本地模型/配置时用,最像真人、可定制音色 |
| **中(默认)** | `edge-tts` | 免费、自然,中文音色好。`uvx edge-tts` 直接跑 |
| 兜底 | macOS `say` | 无网络/无依赖时用,机械感强 |
edge-tts 生成(推荐中文音色 `zh-CN-XiaoxiaoNeural` / `zh-CN-YunxiNeural`):
```bash
uvx edge-tts --voice zh-CN-YunxiNeural --rate=+8% \
--text "$(cat 02_脚本/口播.txt)" --write-media 05_音频/voice.mp3
```
- 长稿按句/段分段生成再拼接,避免一句错整条重来
- 生成后走 `space-video-subtitle` 用**旁白音频**重转写做字幕对齐(TTS 段时长只是规划元数据,不能当字幕时间轴)
- 语速:口播偏快更精神,中文 `+8%~+15%` 常用
## 二、配乐(找 BGM 并下载)
### 选曲:先定情绪,再找曲
根据视频内容和节奏定 BGM 的情绪与 BPM:
| 视频类型 | BGM 方向 |
|---|---|
| 干货/教程 | 轻快 lo-fi / 简约电子,低存在感,不抢话 |
| 产品/科技 | 干净的合成器 pad / 节奏感电子 |
| 生活/小红书 | 明亮 ukulele / 轻流行 / 暖钢琴 |
| 观点/严肃 | 极简钢琴 / 环境氛围 |
| 快节奏盘点 | 有鼓点的 upbeat,卡点用 |
### 免版权来源(务必看授权)
见 `references/music-sources.md`。核心几个:
- **Pixabay Music**:免费、多数免署名(需网页/API)
- **YouTube Audio Library**:免费,部分需署名(需登录)
- **Free Music Archive / ccMixter**:CC 授权,多数需署名
- **Incompetech**(Kevin MacLeod):CC-BY,需署名
### 下载
- 有直链的 → `curl -L -o 05_音频/bgm.mp3 "<直链>"`
- YouTube/B站 的免版权曲目 → `yt-dlp -x --audio-format mp3 -o 05_音频/bgm.mp3 "<URL>"`
**红线**:只用有权使用的曲目;需署名的记下来放视频简介;不搬运受版权保护的流行音乐;不确定授权就不用。选曲/授权是用户的决定,拿不准先问。
## 三、混音(合成到视频)
用 `scripts/mix.sh`,一条命令把 BGM(+旁白) 压进视频:
```bash
bash scripts/mix.sh --video in.mp4 --bgm bgm.mp3 [--voice voice.mp3] \
--out out.mp4 [--bgm-db -20] [--fade 1.0] [--duck]
```
管线做的事(见 `references/mixing-recipes.md` 全套 ffmpeg):
1. **对齐长度**:BGM 太短自动循环,太长裁切到视频时长
2. **响度归一**:`loudnorm` 把整体拉到 -14 LUFS 左右(平台友好)
3. **压音 ducking**:有旁白时用 `sidechaincompress`,人声一响 BGM 自动下沉,人声停 BGM 回来
4. **淡入淡出**:首尾 `afade`,别硬切
5. **BGM 音量**:无旁白约 -12~-16dB,有旁白垫底约 -20~-26dB
6. **封装**:`-c:v copy` 不重编码画面,只替换/混合音轨
## 交接
- 上游:`space-video-script` 的口播稿(→旁白)、`space-video-edit` 的成片、`space-video-broll` 的 B-roll
- 下游:带声音的成片 → 发布,或回 `space-video-edit` 做最终封装
## 依赖
`ffmpeg`(需带 loudnorm/sidechaincompress,本机已支持)、`yt-dlp`、`uvx`(跑 edge-tts);克隆音色档需 IndexTTS2 环境。
## 核心原则
- **BGM 垫底不抢话**:有旁白必开 ducking,BGM 只做氛围
- **响度归一**:别让视频忽大忽小,统一到 -14 LUFS
- **授权优先**:只用免版权/有权曲目,需署名的记下来
- **不重编码画面**:混音只动音轨,`-c:v copy` 保画质
@@ -0,0 +1,57 @@
# 混音 ffmpeg 配方
`scripts/mix.sh` 是封装版,这里是底层配方,特殊需求时手改。
## 目标响度
- 成片整体:**-14 LUFS**(YouTube/多数平台参考值),真峰 -1 dB
- 人声(前景):先 -16 LUFS 归一,再进混音
- BGM 垫底:无人声 -12~-16 dB;有人声 -20~-26 dB
## 1. BGM 循环 + 裁切到视频长度
```bash
# -stream_loop -1 无限循环,-t 裁到 DUR
ffmpeg -stream_loop -1 -i bgm.mp3 -t $DUR -c:a pcm_s16le bgm_fit.wav
```
## 2. 淡入淡出
```
afade=t=in:st=0:d=1.0, afade=t=out:st=$(DUR-1.0):d=1.0
```
## 3. 压音 ducking(人声一响 BGM 下沉)
```
[bgm][voice]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=300[bgducked]
```
- `threshold` 越低越容易触发下沉;`ratio` 越大压得越狠
- `release` 300ms:人声停多久后 BGM 回来,太短会「抽气」
- 注意:sidechain 的 key 是**人声**,被压的是 **BGM**
## 4. 合并
```
[voice][bgducked]amix=inputs=2:normalize=0
```
`normalize=0` 关键——否则 amix 会把两轨都压半、忽大忽小。
## 5. 纯 B-roll(无前景音)只铺 BGM
```bash
ffmpeg -i broll.mp4 -stream_loop -1 -i bgm.mp3 \
-filter_complex "[1:a]afade=t=in:st=0:d=1,afade=t=out:st=$FOUT:d=1,volume=-14dB,loudnorm=I=-14:TP=-1[a]" \
-map 0:v -map "[a]" -t $DUR -c:v copy -c:a aac -b:a 192k out.mp4
```
## 6. 只换/加音轨,不动画面
`-c:v copy` 全程保留,混音只重编码音频(aac 192k)。画质零损失、速度快。
## 常见坑
- amix 不加 `normalize=0` → 音量忽大忽小
- BGM 比视频短又没循环 → 后半段静音
- 没做 loudnorm → 换平台后忽大忽小
- ducking 的 sidechain 接反(被压的接成了人声)→ 人声被压没了
@@ -0,0 +1,50 @@
# 免版权 BGM 来源与授权
**只用有权使用的曲目。** 需署名的,把「曲名 + 作者 + 来源链接」记到视频简介。拿不准授权,就不用。
## 常用来源
| 来源 | 授权 | 是否需署名 | 获取方式 |
|---|---|---|---|
| **Pixabay Music** | Pixabay License | 多数否 | 网页下载 / API(需 key) |
| **YouTube Audio Library** | 各曲不同 | 部分需 | 需登录 YouTube 后台下载 |
| **Free Music Archive** | CC 各版本 | 多数需 | 网页有直链 |
| **ccMixter (dig.ccmixter.org)** | CC 各版本 | 多数需 | 网页有直链 |
| **Incompetech(Kevin MacLeod)** | CC-BY 4.0 | 需 | 网页直链 mp3 |
| **Uppbeat / Bensound** | 各家协议 | 免费档多需 | 网页下载 |
## 下载方式
```bash
# 有直链的曲目
curl -L -o 05_音频/bgm.mp3 "<mp3 直链>"
# 曲库放在 YouTube/B站 上的免版权曲目
yt-dlp -x --audio-format mp3 --audio-quality 0 -o "05_音频/bgm.%(ext)s" "<URL>"
```
### Pixabay 免登录取直链(实测可用)
Pixabay 下载按钮需登录,但**播放用的就是完整曲目**,可绕开登录拿直链:
1. 浏览器开 `pixabay.com/music/search/<vibe>/`(如 upbeat / happy / lo-fi)
2. 点某曲**播放键**(不是下载键),让它加载
3. 从播放器 `<audio>` 元素读 `currentSrc`,是形如 `https://cdn.pixabay.com/audio/YYYY/MM/DD/audio_<hash>.mp3` 的干净直链
4. `curl -L -o bgm.mp3 "<直链>"`
Pixabay License:免费商用、无需署名。下完把曲名/作者/链接记进选曲记录。
## 选曲清单(记录用)
每次选定 BGM,落一条:
```
曲名 / 作者 / 来源链接 / 授权类型 / 是否需署名 / 用在哪条视频
```
## 注意
- **不搬运受版权保护的流行音乐**(哪怕只做 BGM),平台会打版权、限流或静音。
- YouTube Audio Library 需要登录导出,属用户操作,不要代登录。
- Pixabay/多数曲库的 API 需要 key,没配就走网页人工下,或让用户提供直链/曲目。
- 选曲是主观 + 授权决定:拿不准先问用户要「情绪 + 一个可用来源/链接」。
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# 把 BGM(+可选旁白) 混进视频:循环/裁切对齐 → 响度归一 → 压音ducking → 淡入淡出。
# 用法:
# bash mix.sh --video in.mp4 --bgm bgm.mp3 [--voice voice.mp3] --out out.mp4 \
# [--bgm-db -18] [--fade 1.0] [--duck]
# 说明:
# 前景音 = --voice(有则用) 或 视频自带音轨(有则用);BGM 垫在其下。
# --duck: 前景一响 BGM 自动下沉(sidechaincompress)。无前景音时忽略。
set -euo pipefail
VIDEO="" BGM="" VOICE="" OUT="" BGM_DB="" FADE="1.0" DUCK=0
while [ $# -gt 0 ]; do case "$1" in
--video) VIDEO="$2";shift 2;; --bgm) BGM="$2";shift 2;;
--voice) VOICE="$2";shift 2;; --out) OUT="$2";shift 2;;
--bgm-db) BGM_DB="$2";shift 2;; --fade) FADE="$2";shift 2;;
--duck) DUCK=1;shift;; *) echo "未知参数 $1";exit 1;; esac; done
: "${VIDEO:?需要 --video}"; : "${BGM:?需要 --bgm}"; : "${OUT:?需要 --out}"
DUR=$(ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 "$VIDEO")
FOUT=$(echo "$DUR - $FADE" | bc -l)
# 视频是否自带音轨
HAS_VA=$(ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 "$VIDEO" | head -1)
# 前景音来源
FG_INPUT=""; FG_IDX=""
if [ -n "$VOICE" ]; then FG_INPUT="-i $VOICE"; fi
# 默认 BGM 音量:有前景音垫底更低
if [ -z "$BGM_DB" ]; then
if [ -n "$VOICE" ] || [ -n "$HAS_VA" ]; then BGM_DB="-24"; else BGM_DB="-14"; fi
fi
# 组装输入: 0=video 1=bgm(循环) [2=voice]
INPUTS=(-i "$VIDEO" -stream_loop -1 -i "$BGM")
[ -n "$VOICE" ] && INPUTS+=(-i "$VOICE")
# BGM 处理链
BG="[1:a]afade=t=in:st=0:d=$FADE,afade=t=out:st=$FOUT:d=$FADE,volume=${BGM_DB}dB[bg]"
# 前景音 label
FG=""; FGLABEL=""
if [ -n "$VOICE" ]; then
FG="[2:a]loudnorm=I=-16:TP=-1.5:LRA=11[fg]"; FGLABEL="[fg]"
elif [ -n "$HAS_VA" ]; then
FG="[0:a]loudnorm=I=-16:TP=-1.5:LRA=11[fg]"; FGLABEL="[fg]"
fi
if [ -n "$FGLABEL" ]; then
if [ "$DUCK" = "1" ]; then
FILTER="$BG;$FG;${FGLABEL}[bg]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=300[bgd];${FGLABEL}[bgd]amix=inputs=2:normalize=0,loudnorm=I=-14:TP=-1.0[aout]"
else
FILTER="$BG;$FG;${FGLABEL}[bg]amix=inputs=2:normalize=0,loudnorm=I=-14:TP=-1.0[aout]"
fi
else
# 无前景音(如纯 B-roll):只铺 BGM
FILTER="$BG;[bg]loudnorm=I=-14:TP=-1.0[aout]"
fi
echo "视频 ${DUR}s | BGM ${BGM_DB}dB | duck=$DUCK | 前景音=$([ -n "$FGLABEL" ] && echo 有 || echo 无)"
ffmpeg -y "${INPUTS[@]}" \
-filter_complex "$FILTER" \
-map 0:v -map "[aout]" -t "$DUR" \
-c:v copy -c:a aac -b:a 192k -shortest "$OUT" 2>/dev/null
echo "✅ $OUT"
ffprobe -v error -show_entries format=duration:stream=codec_type -of default=nk=1 "$OUT" 2>/dev/null | tr '\n' ' '; echo
@@ -0,0 +1,125 @@
---
name: space-video-broll-sketch
description: 文章转手绘图解 B-roll——输入公众号文章(正文或链接),优先抽取原文配图,把文章逻辑做成一支手绘线稿风格的动态图解视频,并调用真实视频模型(火山引擎 Seedance 2.0 或 libtv Agent)逐帧生成「手绘生长」动效,拼成成片。白底、黑色单线图标、单一蓝色强调、细曲线箭头,默认 4:3,垫在口播下方。当用户说「文章转图解视频」「手绘图解 B-roll」「白板动图」「把这篇文章做成动态图解」「Seedance 出 B-roll」时触发。
---
# space-video-broll-sketch · 文章转手绘图解 B-roll(调视频模型出片)
你是做「知识类内容动态图解」的设计师兼动效导演。用户给一篇文章,你把它的逻辑做成一支**手绘线稿风格的动态图解视频**,并**调用真实视频模型逐帧生成后拼成成片**。
**核心比喻:像放一份手绘白板 PPT。** 每帧是一张干净的手绘逻辑图,元素一个一个被「画」出来,箭头一条条延伸。垫在口播下方,把抽象逻辑变得看得见。
---
## 一、视觉风格(每一帧必须遵守)
**白底手绘线稿**:纯白/极浅米白底,无底纹无渐变无网格。所有图标/图形/箭头是**黑色单线线稿**,粗细均匀、转角圆润、带手绘不规则感,**内部不填充**,允许轻微手抖。
**唯一彩色 = 一处蓝色**:每帧**只允许一处蓝色**,其余全黑白。蓝色是**实心填充**(正蓝/克莱因蓝,高饱和),标记本帧核心主体,通常在视觉中心、是箭头的起点或终点。除它之外禁止任何其他颜色。
**箭头**:细的**手绘曲线箭头**,略带弧度,不用直角折线、不用粗箭头,把中心蓝色主体和四周黑色图标连成放射或流向。
**文字**:标题=黑色粗体无衬线放顶部,一句完整观点;注释=贴图标旁、字号小字重轻带手写感、5–12 字;中文为主英文更小放下方;一帧文字总量 ≤ 40 字。
**版式**:大量留白,宁空不挤,一帧只讲一件事。三种布局——放射式(中心蓝主体+四周 3–5 图标+发散箭头,默认首选)、流程式(左到右 3–4 图标依次连)、对比式(左右两组+中间竖线/VS)。
## 二、动效规范(把一张静态手绘图一笔笔画出来)
- **手绘生长 draw-on**:线稿图标描边生长入场,像被笔现场画出;箭头**沿路径生长**;文字逐字打出或整体淡入;蓝色主体由浅到深填充或轻微缩放弹入。
- **入场顺序 = 演示感来源**:标题先出 → 中心蓝主体 → 第一条箭头生长→它指向的图标画出→注释淡入 → 第二条… 单元素入场 0.4–0.6s,间隔 ~0.3s。**一帧全部画完后静止停留 ≥ 2s。**
- **禁止**:镜头晃动/旋转/翻转;粒子/光效/闪烁/发光;元素持续运动(画完就停);花哨转场(帧间只硬切或极短淡入淡出)。
## 三、工作流程(严格按序,不跳步)
### 第 1 步 · 获取原文和配图
- 给**链接**:抓正文 + **把全部配图抽出来编号列出**。
- 给**正文**:直接进第 2 步。
- **配图是最重要的素材,优先用,别急着自己画。**
### 第 2 步 · 先看配图,再拆信息点(顺序不能反)
先过配图(作者已筛选过),逐张判断:
| 原图是什么 | 怎么处理 |
|---|---|
| 逻辑图/流程图/结构图 | **最高优先级**。重绘成手绘线稿,保留逻辑关系和文字,核心主体改蓝实心,其余黑线稿,加逐元素生长 |
| 产品/界面截图 | 画手绘设备外框,界面简化成线稿,关键区蓝色高亮,依次点亮。不直接贴原图 |
| 数据图表 | 重绘手绘图表,数字滚动递增,关键柱/数字用蓝 |
| 实拍/人物照 | 转手绘线稿示意,或丢掉另画 |
| 纯装饰/表情包 | 丢掉 |
再通读全文补齐配图没覆盖的信息点。**总帧数 6–10,尽量多来自原文配图**(有 5 张有效配图,至少 5 帧来自它们)。
### 第 3 步 · 判定每帧图解类型
放射图(默认首选)/流程图/对比图/结构图/数据图/结论卡。**信息点按原文叙述顺序排列,不重组逻辑**(口播按原文讲,画面要跟上)。
### 第 4 步 · 输出分镜表(先出表,等用户确认再进第 5 步)
| 帧号 | 图解类型 | 来自原文第几张图 | 画面描述 | 蓝色元素是哪个 | 入场顺序 | 标题文字 | 注释文字 | 口播解说词 | 时长 |
|---|---|---|---|---|---|---|---|---|---|
- 画面描述要**具体到可执行**(有哪些图标、怎么排、箭头怎么连、每处写什么字),禁止「科技感画面」这种空描述。
- 蓝色元素**明确指定且只有一个**;入场顺序按 1/2/3 写清;时长 5–8s(动效跑完还要停)。
### 第 5 步 · 生成画面(用户确认分镜后)—— 见第四节「真正出片」
## 四、真正出片:调用视频模型
每一帧走两小步:**① 生成手绘线稿静帧 → ② 用视频模型做「手绘生长」动效**,最后拼接。
### ① 生成每帧的手绘线稿静帧
按分镜表的画面描述 + 第一节风格,用图像模型出**这一帧动效跑完后的最终静态图**(白底、黑线稿、一处蓝、留白)。走用户的 `image-studio` 或 `frontend-design`(HTML 画好截图亦可)。命名 `frames/f01.png…`。
### ② 用视频模型把静帧变成「手绘生长」动效
两条通道任选(需对应 key):
**通道 A · 火山引擎 Seedance 2.0**(`scripts/seedance.py`)
```bash
export ARK_API_KEY=xxx # 火山方舟 API Key
python3 scripts/seedance.py \
--image frames/f01.png \
--prompt "白底手绘白板动画:画面中的黑色线稿图标和曲线箭头,像被一支看不见的笔按顺序一笔一笔画出来(draw-on 生长),箭头沿路径延伸;中心蓝色实心主体轻微弹入;画完后完全静止。镜头固定不动,无粒子无光效无转场。" \
--ratio 4:3 --duration 6 --resolution 720p --out clips/f01.mp4
```
**通道 B · libtv Agent**(`scripts/libtv_i2v.sh`,复用 libtv-skill)
```bash
export LIBTV_ACCESS_KEY=xxx
bash scripts/libtv_i2v.sh --image frames/f01.png \
--prompt "白底手绘白板:黑色线稿与曲线箭头逐笔画出,中心蓝色主体弹入,画完静止,镜头不动,无光效无转场" \
--out clips/f01.mp4
```
对每一帧重复。动效 prompt 要点:**逐笔 draw-on、箭头沿路径生长、蓝主体弹入、画完静止、镜头固定、无粒子无光效无转场**。
### ③ 拼接 + 配乐
```bash
bash scripts/concat.sh broll.mp4 clips/f01.mp4 clips/f02.mp4 ... # 硬切拼接
# 配无人声氛围乐(压低):走 space-video-audio
bash ~/.claude/skills/space-video-audio/scripts/mix.sh --video broll.mp4 --bgm bgm.mp3 --out broll_final.mp4 --bgm-db -22
```
## 五、安全区(垫在口播下方)
画面**下方 1/4 保持干净**(留给口播人像和字幕),图解主体放上方 3/4,标题固定顶部,正下方不放关键信息。
## 六、默认参数
画幅 **4:3** | 总时长 40–60s | 白底 | 黑色单线手绘 | 蓝色实心每帧一处 | 中文为主 | 无人声氛围乐压低 | 硬切转场。
## 七、依赖与配置
- `python3`、`ffmpeg`、`curl`
- 视频模型二选一:`ARK_API_KEY`(Seedance) 或 `LIBTV_ACCESS_KEY`(libtv,且已装 `libtv-skill`)
- 图像模型:`image-studio` / `frontend-design`
- Seedance 模型 id 默认 `doubao-seedance-2-0-260128`,可用 `ARK_SEEDANCE_MODEL` 覆盖
## 八、输出前自查(任一不过就重出那帧)
1. 背景纯白?无多余底纹渐变? 2. **本帧只有一处蓝**、其余全黑线稿? 3. 图标只有轮廓无填充?没混进照片/3D/渐变? 4. 箭头是手绘曲线非直角折线? 5. 元素**一个一个画出**而非一次全出? 6. 每帧画完静止 ≥ 2s? 7. 一帧文字 ≤ 40 字、留白够? 8. 下方 1/4 干净? 9. 原文有效配图都用上了?
## 调通自测
配好 key 后,先用单帧跑通再批量:
```bash
# Seedance:一句话文生视频测连通
python3 scripts/seedance.py --prompt "白底,一个黑色线稿灯泡被一笔笔画出来,画完静止" --duration 5 --out /tmp/test.mp4
# 或 libtv:
bash scripts/libtv_i2v.sh --prompt "白底,一个黑色线稿灯泡逐笔画出,画完静止" --out /tmp/test.mp4
```
出到 `/tmp/test.mp4` 且能播放,即通道打通,可正式批量出片。
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# 把各帧片段按顺序硬切拼成一条 B-roll。
# 用法: bash concat.sh out.mp4 frame01.mp4 frame02.mp4 ...
set -euo pipefail
OUT="${1:?需要输出路径}"; shift
[ $# -ge 1 ] || { echo "至少给一个片段"; exit 1; }
TMP=$(mktemp)
for f in "$@"; do echo "file '$(python3 -c "import os,sys;print(os.path.abspath(sys.argv[1]))" "$f")'" >> "$TMP"; done
# 统一重编码,保证不同来源片段能无缝拼接(硬切)
ffmpeg -y -f concat -safe 0 -i "$TMP" \
-c:v libx264 -preset veryfast -crf 20 -pix_fmt yuv420p -r 30 \
-c:a aac -b:a 128k "$OUT" 2>/dev/null
rm -f "$TMP"
echo "✅ $OUT"
ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 "$OUT" | xargs printf "总时长 %.1fs\n"
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# libtv Agent 图生视频一站式:上传首帧图 → 发"图生视频"消息 → 轮询 → 下载 MP4。
# 复用全局 libtv-skill 的脚本。
# 用法:
# export LIBTV_ACCESS_KEY=xxx
# bash libtv_i2v.sh --image frame01.png --prompt "手绘线稿逐笔画出,元素依次生长,白底,镜头静止" --out clip.mp4
set -euo pipefail
LIBTV_DIR="$HOME/.claude/skills/libtv-skill/scripts"
IMAGE="" PROMPT="" OUT="" SESSION=""
while [ $# -gt 0 ]; do case "$1" in
--image) IMAGE="$2";shift 2;; --prompt) PROMPT="$2";shift 2;;
--out) OUT="$2";shift 2;; --session) SESSION="$2";shift 2;;
*) echo "未知参数 $1";exit 1;; esac; done
: "${PROMPT:?需要 --prompt}"; : "${OUT:?需要 --out}"
: "${LIBTV_ACCESS_KEY:?需要 export LIBTV_ACCESS_KEY}"
[ -f "$LIBTV_DIR/create_session.py" ] || { echo "找不到 libtv-skill,先安装它"; exit 1; }
MSG="$PROMPT"
if [ -n "$IMAGE" ]; then
echo "上传首帧图 $IMAGE ..."
IMG_URL=$(python3 "$LIBTV_DIR/upload_file.py" "$IMAGE" | python3 -c 'import sys,json;print(json.load(sys.stdin)["url"])')
echo " OSS: $IMG_URL"
MSG="参考这张图做图生视频。图片:$IMG_URL 。要求:$PROMPT"
fi
echo "发送生视频消息 ..."
if [ -n "$SESSION" ]; then
python3 "$LIBTV_DIR/create_session.py" "$MSG" --session-id "$SESSION" >/tmp/libtv_cs.json
else
python3 "$LIBTV_DIR/create_session.py" "$MSG" >/tmp/libtv_cs.json
SESSION=$(python3 -c 'import json;print(json.load(open("/tmp/libtv_cs.json"))["sessionId"])')
fi
echo " sessionId: $SESSION"
echo "轮询结果(最多 ~10 分钟)..."
SEQ=0
for i in $(seq 1 120); do
sleep 5
python3 "$LIBTV_DIR/query_session.py" "$SESSION" --after-seq "$SEQ" >/tmp/libtv_q.json 2>/dev/null || continue
# 从消息里找 mp4 URL
VURL=$(grep -oE 'https?://[^"[:space:]]+\.mp4' /tmp/libtv_q.json | head -1 || true)
if [ -n "$VURL" ]; then
echo " 拿到视频: $VURL"
curl -sL -o "$OUT" "$VURL"
echo "✅ $OUT"
exit 0
fi
printf " ...等待中 (%d)\r" "$i"
done
echo "轮询超时,去项目画布看:https://www.liblib.tv/canvas"; exit 1
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""火山引擎 Seedance 2.0 视频生成(Ark 异步任务)。仅用标准库。
一次性:建任务 → 轮询 → 下载 MP4。
用法:
export ARK_API_KEY=xxx
# 文生视频
python3 seedance.py --prompt "白底手绘线稿逐笔画出,元素依次生长" \
--ratio 4:3 --duration 5 --resolution 720p --out clip.mp4
# 图生视频(首帧):--image 可为本地路径或 URL
python3 seedance.py --prompt "手绘生长动效,镜头静止" \
--image frame01.png --ratio 4:3 --duration 5 --out clip.mp4
环境变量:
ARK_API_KEY 必填
ARK_BASE 可选,默认 https://ark.cn-beijing.volces.com/api/v3
ARK_SEEDANCE_MODEL 可选,默认 doubao-seedance-2-0-260128
"""
import argparse, base64, json, mimetypes, os, sys, time, urllib.request, urllib.error
BASE = os.environ.get("ARK_BASE", "https://ark.cn-beijing.volces.com/api/v3").rstrip("/")
MODEL = os.environ.get("ARK_SEEDANCE_MODEL", "doubao-seedance-2-0-260128")
KEY = os.environ.get("ARK_API_KEY")
def _req(method, url, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + (KEY or ""))
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
sys.exit("HTTP %s: %s" % (e.code, e.read().decode()[:500]))
def _image_field(image):
"""本地路径 → base64 data URL;URL 原样。"""
if image.startswith("http://") or image.startswith("https://"):
return image
mime = mimetypes.guess_type(image)[0] or "image/png"
with open(image, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return "data:%s;base64,%s" % (mime, b64)
def create_task(prompt, image, ratio, duration, resolution, watermark):
# Seedance 用文本内联参数控制画幅/时长/分辨率
text = "%s --ratio %s --dur %s --rs %s --wm %s" % (
prompt, ratio, duration, resolution, "true" if watermark else "false")
content = [{"type": "text", "text": text}]
if image:
content.append({"type": "image_url",
"image_url": {"url": _image_field(image)},
"role": "first_frame"})
out = _req("POST", BASE + "/contents/generations/tasks",
{"model": MODEL, "content": content})
tid = out.get("id") or out.get("task_id")
if not tid:
sys.exit("未拿到任务 id:" + json.dumps(out)[:400])
return tid
def poll(tid, timeout=600):
t0 = time.time()
while time.time() - t0 < timeout:
out = _req("GET", BASE + "/contents/generations/tasks/" + tid)
st = out.get("status", "")
if st in ("succeeded", "success"):
c = out.get("content") or {}
url = c.get("video_url") or out.get("video_url")
if not url:
sys.exit("成功但无 video_url:" + json.dumps(out)[:400])
return url
if st in ("failed", "error", "cancelled"):
sys.exit("任务失败:" + json.dumps(out)[:400])
print(" ...%s (%ds)" % (st or "pending", int(time.time() - t0)))
time.sleep(5)
sys.exit("轮询超时")
def download(url, out):
urllib.request.urlretrieve(url, out)
print("✅ %s" % out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--prompt", required=True)
ap.add_argument("--image", help="首帧图:本地路径或 URL")
ap.add_argument("--ratio", default="4:3")
ap.add_argument("--duration", default="5")
ap.add_argument("--resolution", default="720p", choices=["480p", "720p", "1080p"])
ap.add_argument("--watermark", action="store_true")
ap.add_argument("--out", required=True)
a = ap.parse_args()
if not KEY:
sys.exit("缺 ARK_API_KEY 环境变量")
print("模型 %s | %s | %ss | %s" % (MODEL, a.ratio, a.duration, a.resolution))
tid = create_task(a.prompt, a.image, a.ratio, a.duration, a.resolution, a.watermark)
print("任务 %s,轮询中…" % tid)
url = poll(tid)
download(url, a.out)
if __name__ == "__main__":
main()
@@ -0,0 +1,112 @@
---
name: space-video-broll
description: 动效导演 + B-roll 生成器。从一段文字/脚本/视频,一次性产出 30 秒以上的动效 B-roll 视频(HTML→确定性 MP4)。不做 PPT 式翻页,从「视觉隐喻 + 运动」出发;内置多种设计风格(暗色 SaaS 魔术、黑白打字机、暖色编辑、极简白、蓝图网格);产出一律不用 emoji,用排版/几何/SVG 线性图标。当用户说「做 B-roll」「做个动效视频」「motion 开场」「文字动画」「概念动画」「HTML 转视频」「这段配个画面」「30秒 broll」时触发。
---
# space-video-broll · 动效导演
从一段文字/脚本/视频,产出一条**能当视频看、不像 PPT** 的动效 B-roll(默认 30 秒以上,一次成片)。
## 核心信条:从运动出发,不从页面出发
不要从「一页一页卡片」开始。先给内容找一个**能动起来的视觉隐喻**:
> 河流 / 网络 / 分支树 / 轨道 / 流水线 / 蜂群 / 坍缩 / 压缩 / 扫描 / 交接 / 增长循环 / 地图路径 / 堆栈 / 波形 / 时钟 / 透镜 / 机器
文字是锚点,不是主角。一个 beat 如果要靠读一大段字才成立,就把它改成运动、结构、符号或图形。
## 输入:文字 或 视频
- **文字/脚本**:直接抽 beats。
- **视频/逐字稿**:先读内容(`space-video-topic` 的逐字稿),抽出讲了哪几件事 → beats。
- 缺参数不追问,除非答案会改变产出路线。默认竖屏 720×896 或按 `space-video-edit` 配置,默认 30–40 秒。
## 工作流
### 1. 运动论点(一句话)
写死这一句再往下:
```
这条视频靠「<视觉隐喻>」从<起始状态>变成<结束状态>,来证明<核心主张>。
```
找不到能动的隐喻,就停下来先发明一个,别开始排场景。
### 2. Beat 图(连续时间轴,不是页)
排一条连续时间轴,每个 beat 写清:
| 字段 | 说明 |
|---|---|
| `time` | 起止秒 |
| `job` | 钩子 / 揭示 / 对比 / 机制 / 结果 / 证明 / 收尾 |
| `mover` | 这个 beat 里承载运动的主对象 |
| `change` | 屏幕上**物理上**发生了什么变化 |
| `camera` | 推/拉/摇/视差/环绕/裁切/稳定 |
| `text` | 标题/标签/计数/字幕/无 |
| `asset` | 代码SVG/生成图/截图/图标/无 |
### 3. Anti-PPT 质量门(硬性)
- **≥80% 的 beat 必须有「淡入/滑入」以外的可见状态变化**(生长、聚合、坍缩、路径绘制、替换、变形、点亮)。
- 不满足就退回重排——宁可少几个 beat,也不要一堆卡片轮流淡入。
- 主对象在它最显眼的那帧要**足够大**,不能像远处的小道具。
### 4. 选风格(见 `references/design-styles.md`)
内置风格,按内容/平台选一个,或用户指定:
| 风格 | 适合 | 关键词 |
|---|---|---|
| `dark-saas` | AI/产品/SaaS 演示 | 黑色星场、底部紫光、大字动态排版、青→品红 CTA |
| `bw-typewriter` | 观点/开场/悬念 | 纯黑白、大字逐字出现、打字节奏 |
| `warm-editorial` | 小红书/生活/自媒体 | 暖粉底、圆角、红色强调 |
| `minimal-light` | 教程/干货/极简 | 白底、细线、单一强调色 |
| `blueprint-grid` | 技术/架构/流程 | 深色网格、节点连线、扫描感 |
### 5. 无 Emoji 铁律
**产出一律不用 emoji。** 需要图标时用:几何形状、数字、SVG 线性图标(自己画 path)、纯文字 chip。emoji 在渲染里字重/对齐不可控,且廉价。
### 6. 渲染(一次成 30 秒+ 单片)
用自带渲染器把**一个多场景 HTML** 渲成一条完整 MP4:
```bash
node scripts/render.mjs <composition.html> <out.mp4> [fps=30]
```
- HTML 用 `scripts/timeline.js` 组织多个场景:每个场景声明 `start/dur` 和 `update(lt, p)`,`seek(t)` 自动分发。
- HTML 暴露 `window.DURATION`(秒) 和 `window.seek(t)`(纯函数、不依赖时钟,保证确定性)。
- 30 秒 = 900 帧,渲染放后台跑。示例见 `scripts/example-composition.html`。
## 时长预设(参考 rn 系列)
| 预设 | 时长 | beat 数 |
|---|---|---|
| `sting` | 8–12s | 3–4 |
| `standard` | 30–40s | 6–8 |
| `extended` | 50–70s | 9–12 |
默认 `standard`。
## 交接
- 上游:`space-video-script` 的 beats/画面需求,或用户一段文字/一条视频
- 下游:`04_broll/<名字>.mp4` → `space-video-edit` 合成,或直接单发
## 编排(复用已有能力)
- 有 hyperframes CLI → 走 `hyperframes` / `hyperframes-cli` / `hyperframes-media`
- 已有 Remotion 工程 → 走 `remotion-video` / `remotion-to-hyperframes`
- 复杂动画写法 → 走 `gsap` / `animejs` / `css-animations` / `waapi` / `three` / `lottie`
- 参考片复刻 + QC → 见 `references/replica-and-qc.md`
## 核心原则
- **运动优先**:先找会动的隐喻,再排场景
- **Anti-PPT**:≥80% beat 有真实状态变化,否则重排
- **无 emoji**:SVG 线性图标 / 几何 / 数字 / 文字
- **一次成片**:默认 30 秒+ 连续多场景,不是零散小片
- **确定性**:seek 驱动、相同输入相同输出
@@ -0,0 +1,92 @@
# 内置设计风格库
每种风格给一套可直接用的设计 token(背景/强调色/字体/圆角/运动感)。选一个,贯穿整条 B-roll。**全部不用 emoji。**
无 emoji 的图标方案(通用):
- 数字、几何形状(圆/方/环/三角)
- SVG 线性图标:自己画 `<path>`,1.5–2px stroke,`stroke-linecap:round`
- 纯文字 chip / 标签
- 抽象 UI 卡片(圆角矩形 + 线条占位)
---
## 1. dark-saas —— 暗色 SaaS 魔术
```css
--bg: radial-gradient(120% 90% at 50% 100%, #241a3d 0%, #0c0912 55%);
--stage: #0c0912; /* 黑色空间舞台 + 细星点颗粒 */
--glow: #7c4dff; /* 底部紫色地平线光 */
--accent-a: #22e3d6; /* 青 */
--accent-b: #ff3fa4; /* 品红,CTA 用青→品红渐变 */
--text: #ffffff; /* 大字动态排版,白 */
--muted: #9a93b5;
--font: "PingFang SC","Inter",sans-serif;
--radius: 20px;
```
硬规则:黑色星场 + 底部弱紫光;大白字当叙事节拍;至少一个青→品红 CTA 触发可见变化;主对象(prompt 卡/CTA/UI 板/环)在最显眼帧要大;大转场用速度模糊/缩放冲刺/白色速度擦除;UI 物件像从黑暗中被「召唤」出来。忌:持续的水平霓虹线背景、无解释的全屏纯色闪。
## 2. bw-typewriter —— 黑白打字机
```css
--bg: #000000;
--text: #ffffff;
--accent: #ffffff; /* 纯黑白,强调靠字重/尺寸 */
--font: "PingFang SC","SF Mono",monospace;
--radius: 0;
```
大白字**逐字出现**,打字节奏(每字 40–70ms);可做词替换/对比词;结尾定格一句或引到下一场景。可选打字 click 音效(后期加)。极简,不加装饰。
## 3. warm-editorial —— 暖色编辑(小红书/自媒体)
```css
--bg: radial-gradient(120% 90% at 50% 12%, #fff3f4 0%, #ffe3e8 50%, #ffd0dd 100%);
--accent: #ff2442; /* 小红书红 */
--text: #22252a;
--muted: #7a7370;
--card: #ffffff;
--font: "PingFang SC","Heiti SC",sans-serif;
--radius: 22px;
--shadow: 0 12px 34px rgba(255,36,66,.18);
```
圆角卡片、留白足、红色只点睛;数字大、成品感强;亲和不炫技。
## 4. minimal-light —— 极简白(教程/干货)
```css
--bg: #f7f7f5;
--accent: #111111; /* 单一强调色,默认黑,可换品牌色 */
--text: #1a1a1a;
--muted: #8a8a86;
--line: #e2e2dd;
--font: "PingFang SC","Inter",sans-serif;
--radius: 14px;
```
细线(1px)、大量留白、克制;靠版式和位移讲逻辑,不靠颜色。
## 5. blueprint-grid —— 蓝图网格(技术/架构/流程)
```css
--bg: #0b1220;
--grid: rgba(90,140,255,.10); /* 背景网格线 */
--accent: #5b9dff;
--text: #e8f0ff;
--muted: #6b7a99;
--node: #12203a;
--font: "PingFang SC","JetBrains Mono",monospace;
--radius: 10px;
```
深色网格底、节点+正交连线、扫描/绘制感;连线**画出来**(path draw),节点依次点亮。
---
## 选风格速查
| 内容 | 建议风格 |
|---|---|
| AI 工具 / 产品演示 | dark-saas |
| 观点 / 悬念开场 | bw-typewriter |
| 小红书 / 生活 / 个人 IP | warm-editorial |
| 教程 / 方法论 / 干货 | minimal-light |
| 架构 / 流程 / 技术拆解 | blueprint-grid |
用户指定风格时以用户为准;混合场景可分场景切风格,但整条要有统一基因(同一套字体+强调色逻辑)。
@@ -0,0 +1,35 @@
# 参考片复刻 + 成片 QC
当用户给一条**参考视频**要「复刻同款动效」时,走这套。目标是**原创改编**:保留视觉语法、布局逻辑、节奏和交互语言,替换掉源片的文案、素材、口播、画面。
## 版权边界(先守住)
- 只处理用户有权使用的公开链接或本地文件。
- 不绕过私密账号、DRM、付费墙、已删除内容。
- 源视频/截图/逐字稿放私有工作目录,**不打包进成片或 Skill**。
- 复刻的是运动结构、布局、节奏、交互语言;文案和素材要原创或通用。
## 复刻流程
1. **锁定研究区间**:起止时间、要复刻的效果、成片比例、是预览还是成品。画布/帧率默认跟参考。
2. **获取并核验参考**:`yt-dlp` 下载 → `ffprobe` 核验(可播放、时长非零、已知宽高帧率)。HTTP 200 不等于拿到了。
3. **生成视觉证据**:对区间抽联络图/关键帧。记五层证据:
- 视觉 token:背景/描边/配色/字体/阴影/纹理
- 几何:安全边距/锚点/卡片尺寸/连线走向/层级
- 运动基元:pop / slide / path-draw / swap / push / blur / zoom
- 顺序:谁先入、谁等待、谁替换了前一个状态
- 时序:入场时长 / 停留 / 重叠 / 转场时长 / 收尾定格
- 写 `frame.md`(视觉契约) 和 `STORYBOARD.md`(带时间戳的顺序) 再动画。
4. **先搭布局再做动画**:每个场景先构到最显眼状态,确认卡片/标签/连线不动时也放得下;再按阅读顺序动画:框→区块标签→第一焦点→下一端点→端点间连线→支撑卡片逐个。
## 成片 QC(交付前)
用 `ffprobe` 核验成片,别只信渲染没报错:
- [ ] 时长、分辨率、帧率符合预期
- [ ] 抽 5–8 个关键帧目检:无穿帮、无空帧、文字不出血、安全区 OK
- [ ] Anti-PPT 复查:≥80% beat 有真实状态变化
- [ ] 无 emoji;图标为 SVG/几何/文字
- [ ] 首帧/尾帧干净(可做封面/可接下一镜)
> 不承诺像素级/帧级对齐。要精确对齐参考,先过 QC 门再谈。
@@ -0,0 +1,221 @@
<!doctype html><html><head><meta charset="utf-8"><style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:720px;height:896px;overflow:hidden;font-family:"PingFang SC","Heiti SC",sans-serif}
#stage{width:720px;height:896px;position:relative;
background:radial-gradient(120% 90% at 50% 12%,#fff3f4 0%,#ffe3e8 50%,#ffd0dd 100%)}
.scene{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
.red{color:#ff2442}.ink{color:#22252a}.mut{color:#7a7370}
/* S1 */
#s1 .num{font-size:300px;font-weight:900;color:#ff2442;line-height:1;letter-spacing:-8px;text-shadow:0 20px 60px rgba(255,36,66,.28)}
#s1 .sub{font-size:56px;font-weight:800;color:#22252a;margin-top:6px}
#s1 .tag{font-size:30px;font-weight:700;color:#ff2442;background:#fff;padding:12px 30px;border-radius:100px;margin-top:32px;box-shadow:0 12px 34px rgba(255,36,66,.18)}
.dot{position:absolute;border-radius:50%;background:#ff2442;opacity:.10}
/* S2 convergence */
#s2 .chip{position:absolute;background:#fff;color:#22252a;font-size:26px;font-weight:700;padding:12px 22px;border-radius:100px;box-shadow:0 8px 22px rgba(255,36,66,.12)}
#s2 .pain{font-size:64px;font-weight:900;color:#22252a;z-index:5}
#s2 .pain b{color:#ff2442}
/* S3 hub */
#s3 svg{position:absolute;inset:0}
#s3 .hub{position:absolute;left:50%;top:44%;transform:translate(-50%,-50%);width:150px;height:150px;border-radius:34px;background:linear-gradient(135deg,#ff2442,#ff5a6e);display:flex;align-items:center;justify-content:center;color:#fff;font-size:34px;font-weight:900;box-shadow:0 18px 44px rgba(255,36,66,.35);z-index:4;text-align:center;line-height:1.15}
#s3 .cap{position:absolute;bottom:150px;font-size:40px;font-weight:800;color:#22252a}
#s3 .cap b{color:#ff2442}
.spoke{position:absolute;width:74px;height:74px;border-radius:20px;background:#fff;box-shadow:0 8px 20px rgba(255,36,66,.14);z-index:3}
/* S4 grid */
#s4{padding:70px 44px;justify-content:flex-start}
#s4 h2{font-size:44px;font-weight:900;color:#22252a;margin:14px 0 30px}
#s4 h2 b{color:#ff2442}
#s4 .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;width:100%}
#s4 .card{background:#fff;border-radius:22px;padding:22px 10px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 10px 26px rgba(255,36,66,.12)}
#s4 .card .t{font-size:26px;font-weight:800;color:#22252a}
#s4 .ic{width:46px;height:46px}
/* S5 pipeline */
#s5{padding:70px 0}
#s5 h2{font-size:44px;font-weight:900;color:#22252a}
#s5 h2 b{color:#ff2442}
#s5 .sub{font-size:24px;color:#7a7370;margin:8px 0 30px}
#s5 .flow{position:relative;width:460px;flex:1}
#s5 .lbg{position:absolute;left:52px;top:8px;width:6px;height:100%;background:#ffd0d6;border-radius:6px}
#s5 .line{position:absolute;left:52px;top:8px;width:6px;background:#ff2442;border-radius:6px}
#s5 .step{position:relative;display:flex;align-items:center;gap:22px;height:16.6%}
#s5 .node{width:52px;height:52px;border-radius:50%;background:#fff;border:5px solid #ffd0d6;margin-left:27px;z-index:2}
#s5 .lab{font-size:34px;font-weight:800;color:#c3b7b3}
/* S6 CTA */
#s6 .l1{font-size:46px;font-weight:900;color:#22252a;text-align:center;line-height:1.3}
#s6 .l1 b{color:#ff2442}
#s6 .btn{position:relative;margin-top:40px;font-size:34px;font-weight:800;color:#fff;padding:22px 56px;border-radius:100px;overflow:hidden;background:#ffd0d6;box-shadow:0 14px 40px rgba(255,36,66,.28)}
#s6 .fill{position:absolute;inset:0;background:linear-gradient(100deg,#ff2442,#ff5a6e);transform-origin:left}
#s6 .btxt{position:relative;z-index:2}
</style></head><body>
<div id="stage">
<!-- S1 opener -->
<div class="scene" id="s1">
<div class="dot" style="width:180px;height:180px;left:-40px;top:80px"></div>
<div class="dot" style="width:120px;height:120px;right:-20px;top:660px"></div>
<div class="num">10</div><div class="sub">个 Skill</div>
<div class="tag">搞定小红书起号</div>
</div>
<!-- S2 scattered → converge -->
<div class="scene" id="s2">
<div class="pain"><b>工具</b>太散了</div>
<div class="chip" data-hx="-240" data-hy="-260">标题</div>
<div class="chip" data-hx="230" data-hy="-210">选题</div>
<div class="chip" data-hx="-270" data-hy="120">正文</div>
<div class="chip" data-hx="250" data-hy="180">配图</div>
<div class="chip" data-hx="-160" data-hy="290">定位</div>
<div class="chip" data-hx="180" data-hy="-320">复盘</div>
<div class="chip" data-hx="20" data-hy="330">封面</div>
</div>
<!-- S3 hub + spokes -->
<div class="scene" id="s3">
<svg id="s3svg" width="720" height="896"></svg>
<div class="hub">路由<br>总控</div>
<div class="cap"><b>1 个总控</b>,帮你选</div>
</div>
<!-- S4 nine skills -->
<div class="scene" id="s4">
<h2>再加 <b>9</b> 个功能 Skill</h2>
<div class="grid" id="grid"></div>
</div>
<!-- S5 pipeline -->
<div class="scene" id="s5">
<h2>还能<b>串起来</b></h2><div class="sub">从定位到发布,一条龙</div>
<div class="flow">
<div class="lbg"></div><div class="line" id="line" style="height:0%"></div>
<div class="step"><div class="node"></div><div class="lab">定位</div></div>
<div class="step"><div class="node"></div><div class="lab">选题</div></div>
<div class="step"><div class="node"></div><div class="lab">标题</div></div>
<div class="step"><div class="node"></div><div class="lab">正文</div></div>
<div class="step"><div class="node"></div><div class="lab">封面</div></div>
<div class="step"><div class="node"></div><div class="lab">发布</div></div>
</div>
</div>
<!-- S6 CTA -->
<div class="scene" id="s6">
<div class="l1">复制仓库地址<br>发给你的 <b>Agent</b></div>
<div class="btn"><div class="fill" id="fill"></div><div class="btxt">一键安装 10 个 Skill</div></div>
</div>
</div>
<script src="timeline.js"></script>
<script>
// 9 个 SVG 线性图标(无 emoji),stroke 用红色
const R='#ff2442';
const ICONS={
定位:`<circle cx="23" cy="23" r="16"/><circle cx="23" cy="23" r="8"/><circle cx="23" cy="23" r="2" fill="${R}"/>`,
选题:`<circle cx="19" cy="19" r="13"/><line x1="29" y1="29" x2="39" y2="39"/>`,
标题:`<path d="M10 34 L30 14 L36 20 L16 40 L8 42 Z"/><line x1="26" y1="18" x2="32" y2="24"/>`,
正文:`<rect x="11" y="8" width="24" height="30" rx="3"/><line x1="16" y1="17" x2="30" y2="17"/><line x1="16" y1="23" x2="30" y2="23"/><line x1="16" y1="29" x2="25" y2="29"/>`,
视觉:`<circle cx="23" cy="23" r="15"/><circle cx="18" cy="18" r="2.5" fill="${R}"/><circle cx="29" cy="19" r="2.5" fill="${R}"/><path d="M23 38 a12 12 0 0 0 12 -12"/>`,
图文:`<rect x="9" y="11" width="28" height="24" rx="3"/><circle cx="17" cy="19" r="3"/><path d="M12 33 L21 24 L28 30 L33 26 L34 33 Z" fill="${R}" stroke="none"/>`,
体检:`<path d="M8 23 h9 l3 -8 l5 16 l3 -8 h9"/>`,
复盘:`<line x1="10" y1="38" x2="38" y2="38"/><rect x="13" y="24" width="6" height="14"/><rect x="24" y="16" width="6" height="22"/><rect x="35" y="28" width="0.1" height="10"/>`,
发布:`<path d="M23 8 L23 34"/><path d="M13 18 L23 8 L33 18"/><line x1="12" y1="40" x2="34" y2="40"/>`
};
const NAMES=['定位','选题','标题','正文','视觉','图文','体检','复盘','发布'];
const grid=document.getElementById('grid');
NAMES.forEach(n=>{
const d=document.createElement('div');d.className='card';
d.innerHTML=`<svg class="ic" viewBox="0 0 46 46" fill="none" stroke="${R}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">${ICONS[n]}</svg><div class="t">${n}</div>`;
grid.appendChild(d);
});
const $=(s,r=document)=>r.querySelector(s), $$=(s,r=document)=>[...r.querySelectorAll(s)];
const T=Timeline();
// S1 0-4.5 —— 数字回弹 + 副标题 + 标签
T.scene('#s1',0,4.5,(lt)=>{
const num=$('#s1 .num'),sub=$('#s1 .sub'),tag=$('#s1 .tag'),dots=$$('#s1 .dot');
const p=eob(seg(lt,.1,.7));num.style.transform=`scale(${.3+.7*p})`;num.style.opacity=clamp(seg(lt,.1,.3));
const q=eoc(seg(lt,.7,.5));sub.style.opacity=q;sub.style.transform=`translateY(${20*(1-q)}px)`;
const r=eoc(seg(lt,1.1,.5));tag.style.opacity=r;tag.style.transform=`translateY(${24*(1-r)}px)`;
dots.forEach((d,i)=>d.style.transform=`translateY(${Math.sin(lt*1.2+i)*14}px)`);
const out=eic(seg(lt,4.0,.5));$('#s1').style.opacity=1-out; // 淡出交接
});
// S2 4.5-10 —— 散乱 chip 聚合到中心,"太散了"闪现后消散
T.scene('#s2',4.5,5.5,(lt)=>{
const chips=$$('#s2 .chip'),pain=$('#s2 .pain');
const conv=eio(seg(lt,1.6,2.2)); // 0 散 → 1 聚
chips.forEach(c=>{
const hx=+c.dataset.hx,hy=+c.dataset.hy;
const x=lerp(hx,0,conv), y=lerp(hy,0,conv);
const s=lerp(1,.2,conv), o=lerp(1,0,clamp((conv-.6)/.4));
c.style.transform=`translate(${x}px,${y}px) scale(${s})`;c.style.opacity=clamp(seg(lt,.1,.4))*(o);
});
const pin=eob(seg(lt,1.7,.5)),pout=eic(seg(lt,3.4,.6));
pain.style.opacity=clamp(pin-pout);pain.style.transform=`scale(${.7+.3*pin})`;
$('#s2').style.opacity=1-eic(seg(lt,5.0,.5));
});
// S3 10-15.5 —— 中心 hub 出现,向 9 个 spoke 画连线
const cx=360,cy=394,Rr=250;
const spokes=[];
const s3svg=$('#s3svg');
for(let i=0;i<9;i++){
const a=(-90+i*40)*Math.PI/180;
const x=cx+Math.cos(a)*Rr,y=cy+Math.sin(a)*Rr;
const ln=document.createElementNS('http://www.w3.org/2000/svg','line');
ln.setAttribute('x1',cx);ln.setAttribute('y1',cy);ln.setAttribute('x2',x);ln.setAttribute('y2',y);
ln.setAttribute('stroke','#ff2442');ln.setAttribute('stroke-width','3');ln.setAttribute('opacity','.5');
s3svg.appendChild(ln);
const sp=document.createElement('div');sp.className='spoke';sp.style.left=(x-37)+'px';sp.style.top=(y-37)+'px';
$('#s3').appendChild(sp);spokes.push({ln,sp,x,y});
}
T.scene('#s3',10,5.5,(lt)=>{
const hub=$('#s3 .hub'),cap=$('#s3 .cap');
const hp=eob(seg(lt,.1,.6));hub.style.transform=`translate(-50%,-50%) scale(${.4+.6*hp})`;hub.style.opacity=clamp(seg(lt,.1,.3));
spokes.forEach((s,i)=>{
const p=eoc(seg(lt,.7+i*.28,.5)); // 连线依次生长
s.ln.setAttribute('x2',lerp(cx,s.x,p));s.ln.setAttribute('y2',lerp(cy,s.y,p));
s.ln.setAttribute('opacity',.5*clamp(p*2));
const q=eob(seg(lt,.9+i*.28,.45));s.sp.style.transform=`scale(${clamp(q)})`;s.sp.style.opacity=clamp(q);
});
const c=eoc(seg(lt,1.0,.6));cap.style.opacity=c;cap.style.transform=`translateY(${18*(1-c)}px)`;
$('#s3').style.opacity=1-eic(seg(lt,5.0,.5));
});
// S4 15.5-25 —— 9 卡片错峰缩放入场 + 图标
T.scene('#s4',15.5,9.5,(lt)=>{
const h=$('#s4 h2'),cards=$$('#s4 .card');
const hp=eoc(seg(lt,.1,.5));h.style.opacity=hp;h.style.transform=`translateY(${16*(1-hp)}px)`;
cards.forEach((c,i)=>{
const p=eob(seg(lt,.6+i*.28,.5));
c.style.opacity=clamp(seg(lt,.6+i*.28,.3));
c.style.transform=`translateY(${30*(1-clamp(p))}px) scale(${.85+.15*clamp(p)})`;
});
$('#s4').style.opacity=1-eic(seg(lt,9.0,.5));
});
// S5 25-31 —— 连线生长 + 节点依次点亮
T.scene('#s5',25,6,(lt)=>{
const h=$('#s5 h2'),sub=$('#s5 .sub'),line=$('#line'),nodes=$$('#s5 .node'),labs=$$('#s5 .lab');
[h,sub].forEach((el,i)=>{const p=eoc(seg(lt,.1+i*.12,.5));el.style.opacity=p;el.style.transform=`translateY(${12*(1-p)}px)`;});
const lp=eoc(seg(lt,.5,4.2));line.style.height=(lp*100)+'%';
nodes.forEach((nd,i)=>{
const on=eoc(seg(lt,.7+i*.62,.4));const hot=on>.5;
nd.style.borderColor=hot?'#ff2442':'#ffd0d6';nd.style.background=hot?'#ff2442':'#fff';
nd.style.transform=`scale(${1+.2*on*(1-Math.abs(seg(lt,.7+i*.62,.4)*2-1))})`;
labs[i].style.color=hot?'#22252a':'#c3b7b3';
});
$('#s5').style.opacity=1-eic(seg(lt,5.5,.5));
});
// S6 31-35 —— CTA 文案 + 按钮填充扫过
T.scene('#s6',31,4,(lt)=>{
const l1=$('#s6 .l1'),btn=$('#s6 .btn'),fill=$('#fill');
const p=eoc(seg(lt,.2,.6));l1.style.opacity=p;l1.style.transform=`translateY(${18*(1-p)}px)`;
const bp=eob(seg(lt,.9,.5));btn.style.opacity=clamp(seg(lt,.9,.3));btn.style.transform=`scale(${.8+.2*clamp(bp)})`;
const f=eio(seg(lt,1.5,.8));fill.style.transform=`scaleX(${f})`;
const pulse=1+.03*Math.sin(clamp(seg(lt,2.4,1.6))*Math.PI*4);btn.style.transform=`scale(${(.8+.2*clamp(bp))*pulse})`;
});
T.build();
window.seek=T.seek;
window.seek(0);
</script></body></html>
@@ -0,0 +1,59 @@
// 确定性 B-roll 渲染器:HTML(seek 驱动) → 逐帧截图 → ffmpeg → MP4
// 用法: node render.mjs <html> <out.mp4> [fps=30]
// HTML 约定: 暴露 window.DURATION(秒) 和 window.seek(t)(把画面置于第 t 秒的状态)
import puppeteer from "puppeteer-core";
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync, existsSync } from "node:fs";
import path from "node:path";
const [htmlArg, outArg, fpsArg] = process.argv.slice(2);
const fps = Number(fpsArg || 30);
const html = path.resolve(htmlArg);
const out = path.resolve(outArg);
const W = 720, H = 896, SCALE = 2;
// 自动探测缓存的 chrome-headless-shell(puppeteer 下载的)
import { globSync } from "node:fs";
const CHROME = process.env.CHROME_PATH
|| globSync(process.env.HOME + "/.cache/puppeteer/chrome-headless-shell/*/*/chrome-headless-shell")[0]
|| globSync(process.env.HOME + "/.cache/puppeteer/chrome/*/*/*/Contents/MacOS/Google Chrome for Testing")[0];
if (!CHROME) { console.error("找不到缓存的 chrome,设 CHROME_PATH 环境变量指定"); process.exit(1); }
const framesDir = path.resolve("frames");
if (existsSync(framesDir)) rmSync(framesDir, { recursive: true });
mkdirSync(framesDir, { recursive: true });
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: true,
args: ["--no-sandbox", "--force-color-profile=srgb", "--hide-scrollbars"],
});
const page = await browser.newPage();
await page.setViewport({ width: W, height: H, deviceScaleFactor: SCALE });
await page.goto("file://" + html, { waitUntil: "networkidle0" });
// 等字体就绪,确定性
await page.evaluate(async () => { if (document.fonts) await document.fonts.ready; });
const duration = await page.evaluate(() => window.DURATION || 3);
const total = Math.round(duration * fps);
process.stdout.write(`渲染 ${htmlArg} ${duration}s @${fps}fps = ${total} 帧\n`);
for (let i = 0; i < total; i++) {
const t = i / fps;
await page.evaluate((t) => window.seek(t), t);
const f = path.join(framesDir, `f_${String(i).padStart(5, "0")}.png`);
await page.screenshot({ path: f });
if (i % 15 === 0) process.stdout.write(` ${i}/${total}\r`);
}
await browser.close();
// 逐帧 → MP4(缩回 720x896,yuv420p 通用兼容)
execFileSync("ffmpeg", [
"-y", "-framerate", String(fps),
"-i", path.join(framesDir, "f_%05d.png"),
"-vf", `scale=${W}:${H}:flags=lanczos`,
"-c:v", "libx264", "-preset", "slow", "-crf", "18",
"-pix_fmt", "yuv420p", out,
], { stdio: "inherit" });
rmSync(framesDir, { recursive: true });
console.log("\n✅ " + out);
@@ -0,0 +1,45 @@
/* 多场景确定性时间轴助手(seek 驱动,不依赖时钟)
用法:
const T = Timeline();
T.scene('#s1', 0, 4, (lt, p) => { ... }); // 选择器/元素, 起始秒, 时长, 更新函数
T.scene('#s2', 4, 5, (lt, p) => { ... });
T.build(); // 设置 window.DURATION
window.seek = T.seek; // 暴露给渲染器
缓动/工具挂在 window:clamp seg lerp eoc eob eic eio typed
*/
window.clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
window.lerp = (a, b, p) => a + (b - a) * p;
window.seg = (t, s, d) => clamp((t - s) / d); // 局部进度 0..1
window.eoc = (x) => 1 - Math.pow(1 - x, 3); // easeOutCubic
window.eic = (x) => x * x * x; // easeInCubic
window.eio = (x) => x < .5 ? 4*x*x*x : 1 - Math.pow(-2*x+2,3)/2; // easeInOutCubic
window.eob = (x) => { const c = 2.70158; return 1 + c*Math.pow(x-1,3) + 1.70158*Math.pow(x-1,2); }; // back out
// 打字:返回应显示的字符数
window.typed = (full, lt, cps = 18) => full.slice(0, Math.round(clamp(lt * cps, 0, full.length)));
function Timeline() {
const scenes = [];
const api = {
scene(sel, start, dur, update) {
const el = typeof sel === 'string' ? document.querySelector(sel) : sel;
scenes.push({ el, start, dur, update, end: start + dur });
return api;
},
build() {
window.DURATION = Math.max(...scenes.map(s => s.end), 0);
return api;
},
seek(t) {
for (const s of scenes) {
const active = t >= s.start && t < s.end + 0.001;
if (s.el) s.el.style.display = active ? '' : 'none';
if (active) {
const lt = t - s.start;
s.update(lt, clamp(lt / s.dur), s.el);
}
}
},
};
return api;
}
window.Timeline = Timeline;
@@ -0,0 +1,70 @@
---
name: space-video-cover
description: 视频封面/缩略图设计。为口播视频、B站/视频号/小红书/YouTube 生成高点击封面,保持同一账号的「家族气质」(统一版式基因,但每条重新设计不套死模板)。当用户说「做封面」「视频缩略图」「首图」「B站封面」「小红书封面」「cover」「起个封面」时触发。输入是标题/主题/一张截图,输出对应平台比例的封面图。
---
# space-video-cover · 视频封面
封面决定点击率。这个技能出的封面要满足两点:**这一条足够抓人**,且**放进主页一排能看出是同一个账号**。
## 家族气质(核心理念)
不套死模板,但守住一套「基因」——让你的封面一眼可辨认:
- 固定的:主色/辅色、主字体、标题位置区、账号标识位
- 每条变的:具体排版、配图、强调色、信息层级
**每次重新设计,保持家族气质。** 别让十条封面长成一个模子,也别十条各说各话。
## 平台比例
| 平台 | 比例 | 尺寸建议 |
|---|---|---|
| 视频号 / 抖音 竖屏 | 3:4 或 9:16 | 1080×1440 / 1080×1920 |
| B站 横屏 | 16:10 | 1280×800 |
| 小红书 | 3:4 | 1080×1440 |
| YouTube | 16:9 | 1280×720 |
| 公众号首图 | 2.35:1 | 1175×500 |
先问平台,定比例,再设计。
## 设计流程
```text
① 定信息层级:主标题(最大) + 副信息(次) + 视觉锚点(人/物/截图)
② 定钩子文案:封面标题 ≠ 视频标题,更短更冲,3-6 字爆点 + 补充
③ 排版:家族版式基因 + 本条强调色
④ 出图:审提示词 → 生成 → 检查可读性(缩略图大小下还能看清主标题吗)
```
### 可读性铁律
- 主标题在**手机列表缩略图尺寸**下要能一眼读清(缩到 200px 宽测试)
- 文字和背景对比足够(深底浅字/浅底深字 + 描边)
- 别塞太多字,一个封面一个核心点
## 封面类型
| 类型 | 适合 |
|---|---|
| 大字报(黑白/高对比文字为主) | 观点型、悬念型 |
| 人物出镜 + 文字 | 口播、个人 IP |
| 截图 + 高亮标注 | 实测、教程 |
| 拼贴/editorial | 盘点、对比 |
## 编排(复用已有能力)
- 出图与批量配图 → 走用户的 `image-studio`
- 文章/封面家族与批量插图 → 走用户的 `space-article-cover-and-batch-illustration`
- 小红书图文封面 → 走用户的 `xiaohongshu-content-tools` / 相关封面技能
本技能负责定「家族气质 + 信息层级 + 平台比例 + 钩子文案」,具体出图路由到上述技能。
## 交接
- 上游:`space-video-topic` 的标题/主题,或成片里的一帧截图
- 下游:`06_封面/cover.png` → 发布
## 核心原则
- **家族气质**:可辨认 > 每条炫技
- **缩略图可读**:封面是给拇指划过 0.5 秒的人看的
- **封面文案 ≠ 视频标题**:更短、更冲、更钩
@@ -0,0 +1,87 @@
---
name: space-video-edit
description: 口播视频剪辑,两阶段——(1) 剪口播:转写→识别口误/语气词/静音/重复/卡顿/残句→生成审核页→用户确认后剪出干净视频→重转写并 AI 校对字幕;(2) 成片:把剪后视频+字幕+素材串成分镜、时间轴预览、导出最终 MP4。当用户说「剪这条口播」「删掉口误」「处理录屏素材」「口播成片」「做时间轴」「导出竖屏视频」「videocut」时触发。
---
# space-video-edit · 口播剪辑与成片
口播剪辑分两步,别混在一起:**先剪出一条干净的(剪口播),再包装成成片(成片)。**
## 阶段一:剪口播(出干净素材)
目标:把一条原始录屏,剪成没有口误、卡顿、废话的 `source_cut.mp4` + 校对过的 `subtitles.srt`。
### 流程
```text
原视频 → ①转录(带时间词级) → ②识别删除候选 → ③生成审核网页
→ 用户确认删哪里 → ④ffmpeg 剪出新视频
→ ⑤基于剪后视频重新转写 → ⑥AI 校对字幕 → 输出
```
字幕**必须基于剪后视频重新转写再校对**,不能沿用原视频字幕,也不能拿转写初稿当最终字幕。
### 识别哪些该删(检测器)
按 `references/删除检测规则.md` 逐类扫描:静音段、语气词、连续语气词、卡顿词、逐字重复、残句、重说纠正。
### 怎么删(两条铁律)
1. **删前保后**:后说的通常更完整 → 删前面的口误/残句/重复第一次,保后面完整的。详见 `references/删前保后.md`。
2. **风险分层复核**:「删字」安全、「删意思」危险。复核只砸在高风险处(整句删除、开头撞结尾岔、长重复),低风险交末端人工审核页兜底。详见 `references/删除风险分层.md`。
### 剪切执行
标记确认后,用 `scripts/cut_by_ranges.sh` 按「保留区间」无损拼接(避免逐段删的误差累积):
```bash
bash scripts/cut_by_ranges.sh 原视频.mp4 keep_ranges.json source_cut.mp4
```
### 产出
```text
output/YYYY-MM-DD_视频名/
├── 剪口播/1_转录/{audio.mp3, words.json}
├── 剪口播/2_分析/{readable.txt, 口误分析.md, auto_selected.json}
├── 剪口播/3_审核/review.html
├── source_cut.mp4
└── subtitles.srt # 剪后重转写 + AI 校对
```
## 阶段二:口播成片
把 `source_cut.mp4 + subtitles.srt + 素材` 做成完整成片。**只解决一件事:出成片。** 比例和动画风格读用户配置。
```text
输入(剪后视频+字幕+素材)
→ ① 分镜稿:概念分段 + 每段类型判定 + 画面来源(原视频/截图/HTML/B-roll)
→ ② 时间线预览:画面与口播逐秒对齐(HTML 预览页)
→ ③ 合成:按配置比例导出 MP4
→ ④ 验收:ffprobe 检查时长/分辨率 + 抽关键帧核对
```
### 用户配置
```json
{ "aspectRatio": "3:4", "animationStyle": "极简深色" }
```
每次先读配置,决定成片比例和动画风格。B-roll/动画画面本身交 `space-video-broll` 生成,本阶段只负责**对齐和合成**。
## 交接
- 上游:`space-video-script` 的 `script.md`(分镜依据)、录制的原视频
- 平行:B-roll 找 `space-video-broll`,字幕精修找 `space-video-subtitle`
- 下游:成片 → `space-video-cover` 出封面
## 依赖
`ffmpeg` / `ffprobe`、`node`(跑审核页/预览页)、一个 ASR 后端(转写与重转写)。
## 核心原则
- **删前保后** > 一切子规则
- **删字安全,删意思危险**:复核按风险投放,不铺满
- **剪口播和成片分开**:先出干净素材,再谈包装
- **一切可回看**:审核页让人一眼看到删哪、成片前时间轴对齐
@@ -0,0 +1,44 @@
# 删前保后(最高优先级)
**此原则优先于所有子规则。任何删除操作都必须先过这一关。**
后说的通常更完整,所以:
- 删除**前面的**(口误 / 残句 / 重复的第一次)
- 保留**后面的**(完整句 / 重复的最后一次)
## 常见错误
```
句子: "于是很把于是很容易把它理解成一种不够友好但很高效的界面"
↑前半(错) ↑后半(对,更完整)
❌ 整句删除 → 把后面完整的也删了
✓ 只删"于是很把"这几个字,保留"于是很容易把它理解成..."
```
```
相邻句:
前句: "到底是什么原因导致了他会在AI的时代重新被选中"
后句: "到底是什么原因导致了他在AI的时代又重新被选中了呢"
❌ 删后句(更长更完整的)
✓ 删前句,保留后句
```
## 适用范围
- **重复句**:删前句,保留后句(不是删较短的)
- **句内重复**:只删句内前面重复的片段,不删整句
- **残句**:删残句(前面的),保留后面重说的完整版
- **重说纠正**:删前面说错的,保留后面纠正的
## 校验步骤
标记完所有删除项后,逐条检查:
1. 被删的是不是**前面 / 较早**的版本?
2. 保留的是不是**后面 / 更完整**的版本?
3. 句内重复是不是**只删了重复片段**,没有误删整句?
## 例外
用户明确说"前面更顺"时,删后保前。除此之外,一律删前保后。
@@ -0,0 +1,34 @@
# 删除检测规则(口播口误识别)
在带时间的词级转写(words.json)上,逐类扫描删除候选。每类给出 `start/end` 时间和理由,汇总成 `auto_selected.json`,再由「删前保后 + 风险分层」裁决。
## 1. 静音段
- 相邻词之间 gap ≥ 0.2s 视为可压缩静音。
- 删停顿不删话,低风险。保留必要的呼吸/节奏停顿(段落之间的停顿别删光)。
## 2. 语气词 / 填充词
- 「嗯、啊、呃、这个、那个、就是说、然后就」等无信息填充。
- 单个 ≤3 字,低风险。
## 3. 连续语气词
- 多个语气词连在一起(「嗯…那个…就是」),整串删,保留后面第一个实词。
## 4. 卡顿词 / 结巴
- 同一字/音的即时重复(「我我我」「这这个」),删前面重复,保留最后一次。
- 中英混说的结巴(「ment…ment…」)同理。
## 5. 逐字重复(句内)
- 后半是前半的逐字延续(「我把同一句 / 我把同一句话」)→ 只删前面重复片段,不删整句。低风险。
## 6. 残句
- 说了一半没说完就重起(「于是很把… / 于是很容易把它理解成…」)→ 删残句(前面的),保留后面完整版。**整句级,高风险,必复核。**
## 7. 重说纠正 / 整句重复
- 一整句说完又重说一遍(可能改了措辞)→ 删前句,保留后句。
- ⚠️ 「开头撞、结尾岔」陷阱:开头相同别急着当重复,检查后半是否语义分叉;分叉则两句都要留。**高风险,必复核。**
## 汇总
每个候选记录:`{start, end, type, text, risk: low|high, keep_ref?}`。
- `risk=low`:进审核页由人工点选即可。
- `risk=high`:先跑一轮对抗复核(让 reviewer 论证"这个删除是错的"),过了再进审核页。
@@ -0,0 +1,34 @@
# 删除风险分层
> 🚨 一句话:删"字"安全,删"意思"危险。复核(verify)按风险投放,不要铺满。
复核(对抗式让 reviewer 反过来挑"这个删除是不是错的")很贵。要把它砸在**容易删错**的地方,低风险项直接交给末端的网页人工审核即可。
## 低风险 —— 免复核 / 轻复核
| 类型 | 为什么安全 | 例 |
|------|-----------|----|
| **静音** | 删的是停顿不是话,丢不了信息,最多丢节奏;审核页一眼可见 | ≥0.2s gap |
| **逐字子集重复** | 删的是保留内容的逐字开头,删了等于没删,内容还在后面 | 删「我把同一句」保「我把同一句**话**」 |
| **≤3字纯卡壳/语气词** | 太短,装不下任何独有意思 | 单字「从」「嗯」「ment」 |
## 高风险 —— 必须复核
| 类型 | 为什么危险 | 例 |
|------|-----------|----|
| **整句删除** | 一整句里可能藏着别处没有的话 | 残句 / 整句重说 |
| **开头撞、结尾岔** | 开头相同被当重复,但后半语义分叉,删了真丢内容 | 「超出了**预算**」vs「超出了**路线规划能力**」 |
| **长片段重复** | 越长越可能含独有内容 | 跨多字的句内重复 |
## 判定要点
1. 删的内容,是不是保留版的**逐字子集**?是 → 安全(机械可验,免动脑)。
2. 是**整句**吗?整句后半是不是独有内容?后半独有 → 危险,必验"保留版是否真覆盖了它"。
3. 开头像、**结尾是否分叉**?分叉 → 高危误删,必验。
## 为什么这样分
- **错删代价 > 漏删代价**:删错一句观众看得出来,漏删顶多啰嗦一点。
- **末端有人工兜底**:低风险交审核页点一点就够,verify 全量铺开和人工重叠、浪费。
配合 [删前保后.md]:删前保后是判断**方向**,本规则是判断**值不值得复核**。
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# 按「保留区间」无损拼接口播视频,避免逐段删导致的误差累积。
#
# 用法:
# bash cut_by_ranges.sh <输入.mp4> <keep_ranges.json> <输出.mp4>
#
# keep_ranges.json 格式(单位秒,只列要保留的区间):
# [ {"start": 0.0, "end": 12.3}, {"start": 13.1, "end": 40.7} ]
#
# 依赖: ffmpeg, jq
set -euo pipefail
IN="${1:?需要输入视频}"
RANGES="${2:?需要 keep_ranges.json}"
OUT="${3:?需要输出路径}"
command -v ffmpeg >/dev/null || { echo "缺 ffmpeg"; exit 1; }
command -v jq >/dev/null || { echo "缺 jq"; exit 1; }
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
LIST="$TMP/concat.txt"
: > "$LIST"
n=$(jq 'length' "$RANGES")
echo "保留区间数: $n"
for i in $(seq 0 $((n-1))); do
s=$(jq -r ".[$i].start" "$RANGES")
e=$(jq -r ".[$i].end" "$RANGES")
seg="$TMP/seg_$i.mp4"
# 精确剪切: 放在 -i 后做精确到帧的裁切并重编码,保证拼接点无花屏
ffmpeg -y -i "$IN" -ss "$s" -to "$e" \
-c:v libx264 -preset veryfast -crf 18 -c:a aac -avoid_negative_ts make_zero \
"$seg" 2>/dev/null
echo "file '$seg'" >> "$LIST"
printf " 段 %2d: %s → %s\n" "$i" "$s" "$e"
done
ffmpeg -y -f concat -safe 0 -i "$LIST" -c copy "$OUT" 2>/dev/null
echo "✅ 输出: $OUT"
ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 "$OUT" \
| xargs printf "成片时长: %.2fs\n"
@@ -0,0 +1,70 @@
---
name: space-video-script
description: 视频口播脚本撰写,两大特色——(1) 去 AI 味,把书面/AI 腔改成能念出口的真人口语;(2) 把模糊想法逼成一份精确到秒的分镜脚本(每个镜头讲什么、怎么呈现、停多久、怎么切)。当用户说「写口播脚本」「这段太 AI 了改成人话」「口播稿」「分镜脚本」「精确到秒」「script」「改成能念的」时触发。输入可以是选题正文、一段草稿,或一句模糊描述。
---
# space-video-script · 去 AI 味口播脚本
把「想讲的东西」变成「能对着镜头念、且精确到秒的脚本」。分两层活:**先去 AI 味(能念),再定分镜(精确)。**
## 第一层:去 AI 味(能不能念出口)
口播稿和文章不是一回事。文章能读,口播要能**念**。AI 写的稿子念出来会「假」,症状固定:
| AI 腔症状 | 改法 |
|---|---|
| 长句套从句,一口气念不完 | 拆短句,一句一个意思 |
| 「不仅…而且…」「首先其次最后」书面连接词 | 换口语过渡:「然后」「你看」「问题来了」 |
| 排比三连、工整对仗 | 打破工整,留一点松弛和口头语 |
| 破折号、括号补充 | 念不出来的标点一律改成口语插入 |
| 「值得注意的是」「综上」「这意味着」等套话 | 直接删或换大白话 |
| 形容词堆砌代替具体 | 换成具体的数字、例子、动作 |
**判断标准只有一个:读出声。** 卡壳、喘不上气、听着像念稿的地方,就是要改的地方。
若用户有自己的「AI 腔黑名单 / 审稿标准」,先读进来,按它过一遍。
## 第二层:分镜(精确到秒)
不允许停在「我想做个 3 分钟的产品视频」这种模糊描述。通过追问逐步逼到位,直到每一秒都有安排。
### 追问清单(模糊 → 精确)
1. 时长目标?平台?比例(竖屏 3:4 / 横屏 16:9)?
2. 开场前 3 秒是什么钩子?
3. 分几个段落?每段核心一句话是什么?
4. 每段画面来源:真人口播 / 录屏 / B-roll 动画 / 截图?
5. 视觉主题:配色、字体、运动风格?
### 输出:`script.md`(逐镜头,精确到秒)
```markdown
# 视频脚本 · <标题>
平台: 视频号竖屏 3:4 | 目标时长: 90s | 风格: 深色/极简
| 时间 | 口播(去AI味后的台词) | 画面 | 呈现方式 | 转场 |
|------|----------------------|------|---------|------|
| 0:00-0:03 | 「我这半天,几乎没碰键盘。」 | 真人口播 | 特写+气泡出镜 | 硬切 |
| 0:03-0:10 | … | 录屏 | 屏幕放大高亮 | 叠化 |
| 0:10-0:18 | … | B-roll | motion 文字动画 | 推入 |
```
每行必须写清:**这几秒讲什么、画面是什么、怎么呈现、怎么切到下一镜。** 字幕文本、文字动画点、B-roll 需求都在这里标注,供下游 `space-video-broll` / `space-video-subtitle` 直接取用。
## 交接
- 上游:`space-video-topic` 的 `正文.md` 或用户草稿
- 下游:`script.md` 里标了 B-roll 需求 → `space-video-broll`;标了字幕文本 → `space-video-subtitle`;口播台词 → 录制/剪辑
## 编排(复用已有能力)
- 去 AI 味的深度改写 → 走用户的 `Humanizer-zh`
- 口播腔转换、长文转口播 → 走用户的 `podcast-script-generator`
本技能负责把「去 AI 味」和「精确分镜」合成一份能直接开拍的 `script.md`。
## 核心原则
- **先能念,再精确**:去不掉 AI 味,分镜再准也白搭
- **逼到秒**:不接受模糊描述,追问到每秒有安排
- **留真人痕迹**:适度口头语、停顿、自我修正,比工整更可信
@@ -0,0 +1,64 @@
---
name: space-video-subtitle
description: 视频字幕全流程——语音转写、口播式断句、AI 校对纠错(专有名词/同音字/标点)、生成 SRT/ASS,并可选烧录进视频。当用户说「上字幕」「转写这条视频」「生成 srt」「字幕校对」「断句」「烧录字幕」「硬字幕」「subtitle」时触发。输入是视频或音频,输出是校对过的字幕文件,可选带字幕的成片。
---
# space-video-subtitle · 字幕
字幕不是「转写完就行」。口播字幕要**断句舒服、专名正确、和画面对齐**。分四步。
## 流程
```text
视频/音频 → ① 转写(带时间) → ② 口播式断句 → ③ AI 校对 → ④ 输出 SRT/ASS →(可选)烧录
```
### ① 转写
- 抽 16k 单声道 wav → ASR(SenseVoice / whisper / Volc 任一可用后端)
- 产出带时间的词级或句级结果
### ② 口播式断句
字幕断句和标点不一样,按**呼吸和语义**断,不按书面标点:
- 一屏字幕 ≤ 一句能一眼读完(中文一般 ≤15 字/行,竖屏更短)
- 在自然停顿处断,不把一个词组切两屏
- 语气词/口头语可保留(更真实),也可按用户偏好清掉
### ③ AI 校对(最关键)
ASR 初稿必错,重点纠三类:
- **专有名词**:人名、产品名、英文缩写(Qoder、HyperFrames、MCP…)——建一个术语表喂进去统一
- **同音字**:中文 ASR 高发(「在/再」「的/得」「做/作」)
- **标点与数字**:口播里的「百分之八十七」→「87%」按需规整
若有剪辑(`space-video-edit`),字幕**基于剪后视频重新转写再校对**,不能沿用原视频字幕。
### ④ 输出
- `subtitles.srt`:通用
- `subtitles.ass`:需要样式(字体、描边、位置、逐字卡拉OK)时用
- 命名和成片对应
## 烧录(可选)
```bash
# 软字幕(可关) —— 封装进容器
ffmpeg -i in.mp4 -i subtitles.srt -c copy -c:s mov_text out.mp4
# 硬字幕(烧进画面) —— 带样式
ffmpeg -i in.mp4 -vf "subtitles=subtitles.ass" -c:a copy out_hard.mp4
```
竖屏注意字幕安全区(别被平台 UI 挡住),一般压在画面下方 1/5 以上。
## 交接
- 上游:`space-video-edit` 的 `source_cut.mp4`,或任意成片/音频
- 下游:带字幕的成片 → `space-video-cover` 或直接发布
## 依赖
`ffmpeg`、一个 ASR 后端;烧录 ASS 需 ffmpeg 带 libass。
## 核心原则
- **断句按呼吸,不按标点**
- **专名先建表**:术语表统一,避免同一个词半集对半集错
- **剪后重转写**:剪过的视频,字幕必须重新做
@@ -0,0 +1,81 @@
---
name: space-video-topic
description: 视频选题与文案前端。做四件事——(1) 下载抖音/YouTube/B站参考视频并去水印、(2) 提取逐字稿、(3) 生成视频选题与钩子、(4) 撰写标题和口播正文/文案。当用户说「帮我想视频选题」「这条爆款拆一下」「下载这个视频」「提取文案/逐字稿」「起视频标题」「写口播正文」「video 选题」时触发。是整条视频流水线的入口。
---
# space-video-topic · 选题与文案
一条视频动手前,先解决四件事:**参考哪条、讲什么、叫什么、说什么**。
## 四个子能力(按需单用,也可连跑)
### A. 下载参考视频(去水印)
支持抖音 / YouTube / B站链接。
- **抖音**:yt-dlp 借用浏览器登录态直下(见下方实测命令)。
- **YouTube / B站**:优先取字幕转逐字稿;无字幕再走音频转写。
- 工具链:`yt-dlp`(用 `uvx yt-dlp@latest` 保证最新,抖音提取器更新快)、`ffmpeg` 抽音频。
- 产出:`00_参考/原视频.mp4`、`00_参考/逐字稿.txt`
```bash
# YouTube/B站
yt-dlp -f "bv*+ba/b" -o "00_参考/%(title).60s.%(ext)s" "<URL>"
# 抖音(实测可用):短链会自动跳转到 www.douyin.com/video/<id>
uvx yt-dlp@latest --cookies-from-browser chrome \
-o "00_参考/%(title).60s.%(ext)s" "<抖音短链或 video URL>"
# 抽音频供 ASR
ffmpeg -i "00_参考/原视频.mp4" -vn -ar 16000 -ac 1 "00_参考/audio.wav"
```
**抖音踩坑记(重要)**:
- 抖音要求「活跃浏览器会话」,光有 cookie 不够,报 `Fresh cookies (not necessarily logged in) are needed`。
- 解法:先在 Chrome 里**打开一次 douyin.com**(哪怕弹登录墙也行,访问动作会刷新 `__ac` 签名 token),再跑 yt-dlp `--cookies-from-browser chrome` 即可下到 720p。
- 若仍被拦:需要用户在 Chrome 里**登录抖音**(扫码),登录属用户操作,不要代做。
- 旧版 yt-dlp(>90 天)抖音提取器会失效返回空 JSON,务必用 `@latest`。
逐字稿走语音识别(SenseVoice / Volc / whisper 任一可用后端)。搜索抖音(非下载)没有免费路径,需 TikHub/AgentKey 这类 API。
### B. 提取逐字稿
从参考视频或用户自己的录屏里,拿到**带时间的逐字稿**,供后续选题拆解、洗稿、剪辑对齐用。
- 有官方字幕 → 直接转逐字稿
- 无字幕 → 音频转写(16k 单声道 wav → ASR)
- 产出:纯文本 `readable.txt` + 带时间 `words.json`
### C. 选题与钩子
不是「随便想个题」,是**从参考里拆出可复用的选题结构**:
1. **拆爆款**:这条为什么火——钩子是什么、痛点是什么、信息增量在哪、结尾留没留钩子
2. **选题公式**:套用「热点型 / 领域型 / 收藏型 / 经验型」判断这个题属于哪类,能不能持续做
3. **钩子设计**:前 3 秒的开场——冲突、反常识、直接抛结果、纠错式
4. 产出 `01_选题/选题.md`:选题方向 + 目标人群 + 核心钩子 + 一句话卖点
### D. 标题 + 正文/文案
- **标题**:一次给 5-8 个候选,标清适用平台(视频号/小红书/B站),逐个点评风险,给 A/B 建议。科技/AI 领域套爆款标题方法论。
- **正文/口播文案**:先出大纲讨论,再写正文。口语、短句、段尾金句。**这一步只出「内容」,不负责去 AI 味和精确分镜——那是 `space-video-script` 的活。**
- 产出:`01_选题/标题候选.md`、`01_选题/正文.md`
## 编排(复用已有能力)
- 五个平台链接的逐字稿/转写与按需下载 → 走 `space-video-transcript`
- 已有长文稿的内容消化 → 走 `content-digest`
- 标题批量生成与评分 → 走用户的 `baokuan-title-generator`
- 选题采集/生成 → 走用户的 `topic-generator` / `topic-collector`
本技能负责把它们串成「参考 → 选题 → 标题 → 正文」的视频前端,不重复造轮子。
## 依赖
`yt-dlp`、`ffmpeg`、`uv`(跑 Python ASR 时);抖音去水印需可用解析接口;ASR 需一个可用后端(SenseVoice / whisper / Volc)。
## 红线
- 参考视频只作创作参考与拆解,不整条搬运
- 不下结论式夸大(如「100% 爆款」)
- 平台合规:去水印仅用于个人学习/二创参考
@@ -0,0 +1,80 @@
---
name: space-video-transcript
description: 从 YouTube、小宇宙、B站、抖音和小红书链接提取字幕或逐字稿;优先读取平台字幕,无字幕时自动提取音频并调用可用 ASR,输出 Markdown 与 SRT。用户发送这些平台链接、要求提取字幕/文案/逐字稿/视频转文字,或在字幕完成后希望下载原视频时使用。字幕成功后必须询问用户是否下载原视频;小宇宙对应下载原音频。
---
# 视频链接字幕提取
收到支持的平台链接后立即提取字幕。先交付字幕,再询问是否下载源文件;不得在用户确认前下载完整视频。
## 定位 Skill
```bash
SVT_HOME="$(
for dir in \
"$HOME/.codex/skills/space-video-transcript" \
"$HOME/.agents/skills/space-video-transcript" \
"$HOME/.claude/skills/space-video-transcript" \
"$(pwd)/video-Skills/space-video-transcript" \
"$(pwd)/space-video-transcript"; do
[ -f "$dir/SKILL.md" ] && printf '%s\n' "$dir" && break
done
)"
```
若 `SVT_HOME` 为空,先让用户提供 Skill 路径。
## 工作流
1. 从用户消息中提取 URL;支持 YouTube、小宇宙单集、B站、抖音和小红书视频。
2. 首次使用或环境异常时运行体检:
```bash
bash "$SVT_HOME/scripts/run.sh" doctor
```
3. 立即提取字幕,不先询问是否下载:
```bash
bash "$SVT_HOME/scripts/run.sh" transcribe \
"<URL>" --output-dir "<输出目录>"
```
默认输出目录为当前目录下的 `video-transcripts/`。脚本优先获取平台已有字幕;没有字幕时自动抽取音频并按 `Groq → 本地 Whisper → agent-reach` 的顺序选择 ASR。
4. 读取脚本最后一行 `SPACE_VIDEO_TRANSCRIPT_RESULT=...`:
- 向用户展示字幕文件路径、来源平台、采用的平台字幕或 ASR。
- 用户要求查看全文时,读取 Markdown 文件并展示;长稿可先给摘要和路径。
- 然后必须询问:`字幕已提取。是否同时下载原视频?(小宇宙将下载原音频)`
5. 只有用户明确回答“是/下载”后才运行:
```bash
bash "$SVT_HOME/scripts/run.sh" download \
"<同一 URL>" --output-dir "<输出目录>/media"
```
若平台要求登录态,先说明原因,再让用户自行在浏览器登录;得到允许后可加 `--cookies-from-browser chrome`。不要索取或回显 Cookie。
## 输出
- `*_transcript.md`:带来源、语言和时间戳的可读稿。
- `*_subtitles.srt`:存在可靠时间轴时生成。
- `media/`:仅在用户确认后保存原视频;小宇宙保存原音频。
## 语言与校对
- 中文字幕直接交付。
- 只有英文字幕且用户要求中文稿时,用当前 Agent 翻译,保留时间戳,不改事实。
- 专有名词明显错误时先列出低置信度修正,不静默改写原意。
- 成片字幕需要进一步做口播断句、ASS 样式或烧录时,交给 `space-video-subtitle`。
## 失败处理
- YouTube/B站无字幕:自动走 ASR,不要直接结束。
- 抖音/小红书抓取失败:提示用户先在 Chrome 打开或登录对应平台,再经允许使用浏览器登录态重试。
- 小宇宙只接受 `/episode/` 单集页;节目主页要求用户改发单集链接。
- 没有可用 ASR:说明可选方案——配置 `GROQ_API_KEY`、安装 `openai-whisper`,或配置 `agent-reach transcribe`。
- 不支持 DRM、付费或无权访问的内容,不绕过验证码、权限校验和平台限制。
平台差异和依赖说明见 [references/platforms.md](references/platforms.md)。
@@ -0,0 +1,4 @@
interface:
display_name: "视频链接字幕提取"
short_description: "五个平台链接一键提取字幕,完成后按用户选择下载原视频"
default_prompt: "使用 $space-video-transcript 提取这个链接的字幕,完成后询问我是否下载原视频。"
@@ -0,0 +1,44 @@
# 平台与依赖
## 路由
| 平台 | 字幕优先路径 | 无字幕路径 | 下载内容 |
|---|---|---|---|
| YouTube | `youtube-transcript-api`,再用 `yt-dlp` | 音频 → ASR | MP4 |
| B站 | `yt-dlp` 可用字幕 | 音频 → ASR | MP4 |
| 抖音 | 通常无公开字幕 | 音频 → ASR | MP4 |
| 小红书 | 通常无公开字幕 | 音频 → ASR | MP4 |
| 小宇宙 | 无平台字幕 | 单集音频 → ASR | M4A/MP3 |
## 基础依赖
- Python 3.10+
- 推荐安装 `uv`;启动脚本会用隔离环境加载 YouTube 字幕依赖,不污染系统 Python
- `ffmpeg` / `ffprobe`
- `yt-dlp`;平台变化频繁,建议保持最新版
没有 `uv` 时,YouTube 平台字幕可手动安装:
```bash
python3 -m pip install youtube-transcript-api
```
## ASR 后端
脚本的 `auto` 顺序:
1. 存在 `GROQ_API_KEY`:调用 Groq Whisper,适合现有小宇宙工作流。
2. 存在 `whisper` 命令:使用本地 OpenAI Whisper。
3. 存在 `agent-reach`:调用其 `transcribe` 路由。
可用 `--asr-backend groq|whisper|agent-reach` 强制指定。
## 浏览器登录态
抖音和小红书可能要求活跃浏览器会话。只有用户同意下载或重试后,才使用:
```bash
--cookies-from-browser chrome
```
此参数让 `yt-dlp` 在本机读取浏览器登录态。不要导出、打印或提交 Cookie。
@@ -0,0 +1,210 @@
"""ASR backend selection and transcription."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
from formats import Segment, clean_text
def media_duration(path: Path) -> float:
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
str(path),
],
capture_output=True,
text=True,
check=False,
)
try:
return float(result.stdout.strip())
except ValueError:
return 0.0
def available_backends() -> tuple[str, ...]:
found: list[str] = []
if os.environ.get("GROQ_API_KEY") and shutil.which("curl"):
found.append("groq")
if shutil.which("whisper"):
found.append("whisper")
if shutil.which("agent-reach"):
found.append("agent-reach")
return tuple(found)
def choose_backend(requested: str) -> str:
available = available_backends()
if requested != "auto":
if requested not in available:
raise RuntimeError(
f"ASR 后端 {requested} 当前不可用;可用后端:{', '.join(available) or '无'}"
)
return requested
if not available:
raise RuntimeError(
"没有可用 ASR。请配置 GROQ_API_KEY、安装 openai-whisper,或配置 agent-reach"
)
return available[0]
def split_audio(audio: Path, work_dir: Path, seconds: int = 600) -> tuple[Path, ...]:
pattern = str(work_dir / "chunk_%03d.mp3")
command = [
"ffmpeg",
"-y",
"-loglevel",
"error",
"-i",
str(audio),
"-vn",
"-ac",
"1",
"-ar",
"16000",
"-b:a",
"32k",
"-f",
"segment",
"-segment_time",
str(seconds),
pattern,
]
result = subprocess.run(
command, capture_output=True, text=True, check=False, timeout=1800
)
chunks = tuple(sorted(work_dir.glob("chunk_*.mp3")))
if result.returncode != 0 or not chunks:
raise RuntimeError(f"音频切片失败:{result.stderr[-500:]}")
return chunks
def _groq_chunk(path: Path) -> dict:
command = [
"curl",
"-fsS",
"--max-time",
"600",
"--retry",
"4",
"--retry-all-errors",
"--retry-delay",
"5",
"https://api.groq.com/openai/v1/audio/transcriptions",
"-H",
"@-",
"-F",
f"file=@{path}",
"-F",
"model=whisper-large-v3",
"-F",
"language=zh",
"-F",
"response_format=verbose_json",
]
result = subprocess.run(
command,
input=f"Authorization: Bearer {os.environ['GROQ_API_KEY']}\n",
capture_output=True,
text=True,
check=False,
timeout=660,
)
if result.returncode != 0:
raise RuntimeError(f"Groq 转录失败:{result.stderr[-500:]}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError("Groq 返回了无法解析的结果") from exc
def transcribe_groq(audio: Path, work_dir: Path) -> tuple[Segment, ...]:
output: list[Segment] = []
offset = 0.0
for chunk in split_audio(audio, work_dir):
payload = _groq_chunk(chunk)
raw_segments = payload.get("segments") or []
if raw_segments:
output.extend(
Segment(
start=offset + float(item.get("start", 0)),
end=offset + float(item.get("end", item.get("start", 0) + 1)),
text=clean_text(str(item.get("text", ""))),
)
for item in raw_segments
if clean_text(str(item.get("text", "")))
)
else:
text = clean_text(str(payload.get("text", "")))
if text:
output.append(Segment(offset, offset + media_duration(chunk), text))
offset += media_duration(chunk)
return tuple(output)
def transcribe_whisper(audio: Path, work_dir: Path) -> tuple[Segment, ...]:
command = [
"whisper",
str(audio),
"--model",
"small",
"--language",
"zh",
"--output_format",
"json",
"--output_dir",
str(work_dir),
]
result = subprocess.run(
command, capture_output=True, text=True, check=False, timeout=7200
)
json_files = tuple(work_dir.glob("*.json"))
if result.returncode != 0 or not json_files:
raise RuntimeError(f"本地 Whisper 转录失败:{result.stderr[-500:]}")
payload = json.loads(json_files[0].read_text(encoding="utf-8"))
return tuple(
Segment(float(item["start"]), float(item["end"]), clean_text(str(item["text"])))
for item in payload.get("segments", [])
if clean_text(str(item.get("text", "")))
)
def transcribe_agent_reach(audio: Path, work_dir: Path) -> tuple[Segment, ...]:
output = work_dir / "agent-reach-transcript.txt"
result = subprocess.run(
["agent-reach", "transcribe", str(audio), "-o", str(output)],
capture_output=True,
text=True,
check=False,
timeout=7200,
)
if result.returncode != 0 or not output.exists():
raise RuntimeError(f"agent-reach 转录失败:{result.stderr[-500:]}")
text = clean_text(output.read_text(encoding="utf-8", errors="replace"))
return (Segment(0, max(1.0, media_duration(audio)), text),) if text else ()
def transcribe(
audio: Path, work_dir: Path, requested: str = "auto"
) -> tuple[str, tuple[Segment, ...]]:
backend = choose_backend(requested)
handlers = {
"groq": transcribe_groq,
"whisper": transcribe_whisper,
"agent-reach": transcribe_agent_reach,
}
segments = handlers[backend](audio, work_dir)
if not segments:
raise RuntimeError(f"{backend} 没有返回可用文字")
return backend, segments
@@ -0,0 +1,119 @@
"""Subtitle parsing and rendering helpers."""
from __future__ import annotations
import re
from dataclasses import dataclass
from html import unescape
from pathlib import Path
@dataclass(frozen=True)
class Segment:
start: float
end: float
text: str
_TAG_RE = re.compile(r"<[^>]+>")
_TIMING_RE = re.compile(
r"(?P<start>\d{1,2}:\d{2}:\d{2}[,.]\d{3}|\d{1,2}:\d{2}[,.]\d{3})\s*-->\s*"
r"(?P<end>\d{1,2}:\d{2}:\d{2}[,.]\d{3}|\d{1,2}:\d{2}[,.]\d{3})"
)
def clean_text(value: str) -> str:
text = unescape(_TAG_RE.sub("", value)).replace("\u200b", "")
lines = [re.sub(r"\s+", " ", line).strip() for line in text.splitlines()]
unique: list[str] = []
for line in lines:
if line and (not unique or line != unique[-1]):
unique.append(line)
return " ".join(unique).strip()
def parse_clock(value: str) -> float:
parts = value.replace(",", ".").split(":")
if len(parts) == 2:
minutes, seconds = parts
return int(minutes) * 60 + float(seconds)
hours, minutes, seconds = parts
return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
def parse_subtitle_text(content: str) -> tuple[Segment, ...]:
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
segments: list[Segment] = []
lines = normalized.split("\n")
index = 0
while index < len(lines):
match = _TIMING_RE.search(lines[index])
if not match:
index += 1
continue
text_lines: list[str] = []
index += 1
while index < len(lines) and lines[index].strip():
text_lines.append(lines[index])
index += 1
text = clean_text("\n".join(text_lines))
if text:
candidate = Segment(
start=parse_clock(match.group("start")),
end=parse_clock(match.group("end")),
text=text,
)
if not segments or candidate.text != segments[-1].text:
segments.append(candidate)
index += 1
return tuple(segments)
def parse_subtitle_file(path: Path) -> tuple[Segment, ...]:
return parse_subtitle_text(path.read_text(encoding="utf-8", errors="replace"))
def clock(seconds: float, *, srt: bool = False) -> str:
millis = max(0, round(seconds * 1000))
hours, remainder = divmod(millis, 3_600_000)
minutes, remainder = divmod(remainder, 60_000)
secs, ms = divmod(remainder, 1000)
separator = "," if srt else "."
return f"{hours:02d}:{minutes:02d}:{secs:02d}{separator}{ms:03d}"
def render_srt(segments: tuple[Segment, ...]) -> str:
blocks = [
f"{index}\n{clock(item.start, srt=True)} --> {clock(item.end, srt=True)}\n{item.text}"
for index, item in enumerate(segments, start=1)
]
return "\n\n".join(blocks) + ("\n" if blocks else "")
def render_markdown(
*,
title: str,
url: str,
platform: str,
language: str,
method: str,
segments: tuple[Segment, ...],
) -> str:
header = [
f"# {title}",
"",
f"> 平台:{platform} ",
f"> 来源:{url} ",
f"> 语言:{language or 'unknown'} ",
f"> 提取方式:{method}",
"",
"## 逐字稿",
"",
]
body = [f"**[{clock(item.start)[:-4]}]** {item.text}" for item in segments]
return "\n".join(header + body).rstrip() + "\n"
def safe_stem(value: str, fallback: str = "transcript") -> str:
cleaned = re.sub(r"[\\/:*?\"<>|\s]+", "-", value).strip("-.")
return (cleaned[:80] or fallback).strip("-.")
@@ -0,0 +1,302 @@
"""Platform detection, metadata and source acquisition."""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from formats import Segment, clean_text
SUPPORTED = ("youtube", "xiaoyuzhou", "bilibili", "douyin", "xiaohongshu")
@dataclass(frozen=True)
class Episode:
title: str
audio_url: str
podcast: str = ""
def detect_platform(url: str) -> str:
parsed = urlparse(url)
host = parsed.netloc.lower()
path = parsed.path.lower()
if host in {"youtu.be", "www.youtu.be"} or host.endswith("youtube.com"):
return "youtube"
if host.endswith("xiaoyuzhoufm.com"):
return "xiaoyuzhou" if "/episode/" in path else "xiaoyuzhou-page"
if host.endswith("bilibili.com") or host == "b23.tv":
return "bilibili"
if host.endswith("douyin.com"):
return "douyin"
if host.endswith("xiaohongshu.com") or host == "xhslink.com":
return "xiaohongshu"
return "unsupported"
def validate_url(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("请输入完整的 http/https 链接")
platform = detect_platform(url)
if platform == "xiaoyuzhou-page":
raise ValueError("小宇宙节目主页不是单集,请提供包含 /episode/ 的单集链接")
if platform not in SUPPORTED:
raise ValueError("仅支持 YouTube、小宇宙、B站、抖音和小红书链接")
return platform
def youtube_id(url: str) -> str:
parsed = urlparse(url)
if parsed.netloc.lower().endswith("youtu.be"):
return parsed.path.strip("/").split("/")[0]
match = re.search(r"(?:[?&]v=|/embed/|/shorts/)([A-Za-z0-9_-]{11})", url)
if not match:
raise ValueError("无法从链接提取 YouTube 视频 ID")
return match.group(1)
def youtube_api_captions(url: str, languages: tuple[str, ...]) -> tuple[Segment, ...]:
try:
from youtube_transcript_api import YouTubeTranscriptApi
except ImportError:
return ()
try:
transcript = (
YouTubeTranscriptApi()
.list(youtube_id(url))
.find_transcript(list(languages))
)
fetched = transcript.fetch()
except Exception: # noqa: BLE001 - platform/API failures must fall through to yt-dlp/ASR
return ()
return tuple(
Segment(
float(item.start), float(item.start + item.duration), clean_text(item.text)
)
for item in fetched
if clean_text(item.text)
)
def _walk(value: Any):
if isinstance(value, dict):
yield value
for child in value.values():
yield from _walk(child)
elif isinstance(value, list):
for child in value:
yield from _walk(child)
def extract_episode_from_html(html: str) -> Episode:
title = "小宇宙播客"
podcast = ""
audio_url = ""
match = re.search(
r'<script[^>]+id=["\']__NEXT_DATA__["\'][^>]*>(.*?)</script>', html, re.DOTALL
)
if match:
try:
payload = json.loads(match.group(1))
for item in _walk(payload):
possible_audio = (
item.get("enclosureUrl")
or item.get("audioUrl")
or item.get("mediaUrl")
)
if isinstance(possible_audio, str) and possible_audio.startswith(
"http"
):
audio_url = possible_audio
title = str(item.get("title") or title)
podcast_value = item.get("podcast")
if isinstance(podcast_value, dict):
podcast = str(
podcast_value.get("title")
or podcast_value.get("name")
or ""
)
break
except json.JSONDecodeError:
pass
if not audio_url:
audio_match = re.search(
r'https://media\.xyzcdn\.net/[^"\'\\]+?\.(?:m4a|mp3)(?:\?[^"\'\\]*)?',
html,
re.IGNORECASE,
)
if audio_match:
audio_url = audio_match.group(0).replace("\\u0026", "&")
meta_title = re.search(
r'<meta[^>]+(?:property|name)=["\']og:title["\'][^>]+content=["\']([^"\']+)',
html,
re.IGNORECASE,
)
if meta_title:
title = clean_text(meta_title.group(1)) or title
if not audio_url:
raise RuntimeError("无法解析小宇宙音频直链,平台页面结构可能已变化")
return Episode(title=title, audio_url=audio_url, podcast=podcast)
def fetch_xiaoyuzhou_episode(url: str) -> Episode:
request = Request(
url, headers={"User-Agent": "Mozilla/5.0 space-video-transcript/1.0"}
)
with urlopen(request, timeout=30) as response:
html = response.read(10_000_000).decode("utf-8", errors="replace")
return extract_episode_from_html(html)
def ytdlp_command() -> str:
command = shutil.which("yt-dlp")
if not command:
raise RuntimeError("未找到 yt-dlp;请先安装:python3 -m pip install -U yt-dlp")
return command
def metadata(url: str) -> dict[str, Any]:
command = [
ytdlp_command(),
"--dump-single-json",
"--skip-download",
"--no-warnings",
url,
]
result = subprocess.run(
command, capture_output=True, text=True, check=False, timeout=120
)
if result.returncode != 0:
return {"title": "视频逐字稿", "language": "unknown"}
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return {"title": "视频逐字稿", "language": "unknown"}
return {
"title": data.get("title") or "视频逐字稿",
"language": data.get("language") or "unknown",
}
def cookie_args(browser: str | None) -> list[str]:
return ["--cookies-from-browser", browser] if browser else []
def failure_message(action: str, detail: str) -> str:
lowered = detail.lower()
if "not a bot" in lowered or "cookies-from-browser" in lowered:
return (
f"{action}失败:平台要求浏览器登录态。请先在 Chrome 打开并登录该平台,"
"得到用户允许后加 --cookies-from-browser chrome 重试"
)
return f"{action}失败:{detail[-600:]}"
def download_platform_subtitles(
url: str, work_dir: Path, languages: str, browser: str | None = None
) -> tuple[Path, ...]:
template = str(work_dir / "platform.%(ext)s")
command = [
ytdlp_command(),
"--skip-download",
"--write-subs",
"--write-auto-subs",
"--sub-langs",
languages,
"--convert-subs",
"srt",
"--no-warnings",
"-o",
template,
*cookie_args(browser),
url,
]
subprocess.run(command, capture_output=True, text=True, check=False, timeout=180)
candidates = sorted(work_dir.glob("platform*.srt")) + sorted(
work_dir.glob("platform*.vtt")
)
return tuple(dict.fromkeys(candidates))
def download_audio(url: str, work_dir: Path, browser: str | None = None) -> Path:
output = str(work_dir / "audio.%(ext)s")
command = [
ytdlp_command(),
"-x",
"--audio-format",
"mp3",
"--audio-quality",
"5",
"--no-playlist",
"--no-warnings",
"-o",
output,
*cookie_args(browser),
url,
]
result = subprocess.run(
command, capture_output=True, text=True, check=False, timeout=1800
)
matches = sorted(work_dir.glob("audio.*"))
if result.returncode != 0 or not matches:
detail = (result.stderr or result.stdout)[-600:]
raise RuntimeError(failure_message("音频提取", detail))
return matches[0]
def download_direct(url: str, output: Path) -> Path:
request = Request(
url, headers={"User-Agent": "Mozilla/5.0 space-video-transcript/1.0"}
)
with urlopen(request, timeout=60) as response, output.open("wb") as handle:
shutil.copyfileobj(response, handle)
return output
def download_media(
url: str, output_dir: Path, browser: str | None = None
) -> tuple[Path, ...]:
platform = validate_url(url)
output_dir.mkdir(parents=True, exist_ok=True)
if platform == "xiaoyuzhou":
episode = fetch_xiaoyuzhou_episode(url)
suffix = Path(urlparse(episode.audio_url).path).suffix or ".m4a"
return (
download_direct(
episode.audio_url, output_dir / f"xiaoyuzhou-audio{suffix}"
),
)
template = str(output_dir / "%(title).80s [%(id)s].%(ext)s")
command = [
ytdlp_command(),
"-f",
"bv*+ba/b",
"--merge-output-format",
"mp4",
"--no-playlist",
"--no-warnings",
"--print",
"after_move:filepath",
"-o",
template,
*cookie_args(browser),
url,
]
result = subprocess.run(
command, capture_output=True, text=True, check=False, timeout=3600
)
files = tuple(
Path(line.strip()) for line in result.stdout.splitlines() if line.strip()
)
if result.returncode != 0 or not files:
detail = (result.stderr or result.stdout)[-600:]
raise RuntimeError(failure_message("视频下载", detail))
return files
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$SCRIPT_DIR/space_video_transcript.py"
if command -v uv >/dev/null 2>&1; then
exec uv run "$SCRIPT" "$@"
fi
exec python3 "$SCRIPT" "$@"
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10,<3.14"
# dependencies = ["youtube-transcript-api>=1.2.3,<2"]
# ///
"""Unified transcript extraction for five video and podcast platforms."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
import tempfile
from pathlib import Path
from asr import available_backends, transcribe
from formats import parse_subtitle_file, render_markdown, render_srt, safe_stem
from platforms import (
download_audio,
download_direct,
download_media,
download_platform_subtitles,
fetch_xiaoyuzhou_episode,
metadata,
validate_url,
youtube_api_captions,
)
PLATFORM_NAMES = {
"youtube": "YouTube",
"xiaoyuzhou": "小宇宙",
"bilibili": "B站",
"douyin": "抖音",
"xiaohongshu": "小红书",
}
DEFAULT_LANGUAGES = (
"zh-Hans",
"zh-Hant",
"zh",
"zh-CN",
"zh-TW",
"en",
"en-US",
"en-GB",
)
def emit(payload: dict) -> None:
print("SPACE_VIDEO_TRANSCRIPT_RESULT=" + json.dumps(payload, ensure_ascii=False))
def write_outputs(
*,
output_dir: Path,
title: str,
url: str,
platform: str,
language: str,
method: str,
segments: tuple,
) -> tuple[Path, Path | None]:
output_dir.mkdir(parents=True, exist_ok=True)
stem = safe_stem(title)
markdown_path = output_dir / f"{stem}_transcript.md"
srt_path = output_dir / f"{stem}_subtitles.srt"
markdown_path.write_text(
render_markdown(
title=title,
url=url,
platform=PLATFORM_NAMES[platform],
language=language,
method=method,
segments=segments,
),
encoding="utf-8",
)
if segments:
srt_path.write_text(render_srt(segments), encoding="utf-8")
else:
srt_path = None
return markdown_path, srt_path
def transcribe_url(args: argparse.Namespace) -> int:
platform = validate_url(args.url)
output_dir = Path(args.output_dir).expanduser().resolve()
with tempfile.TemporaryDirectory(prefix="space-video-transcript-") as temp:
work_dir = Path(temp)
info = {"title": "视频逐字稿", "language": "unknown"}
episode = None
if platform == "xiaoyuzhou":
episode = fetch_xiaoyuzhou_episode(args.url)
info = {"title": episode.title, "language": "zh"}
else:
info = metadata(args.url)
segments = ()
method = ""
if not args.force_asr and platform == "youtube":
segments = youtube_api_captions(args.url, DEFAULT_LANGUAGES)
if segments:
method = "YouTube 平台字幕"
if not args.force_asr and not segments and platform in {"youtube", "bilibili"}:
files = download_platform_subtitles(
args.url,
work_dir,
args.subtitle_languages,
args.cookies_from_browser,
)
if files:
segments = parse_subtitle_file(files[0])
if segments:
method = "平台字幕(yt-dlp)"
if not segments:
if episode:
suffix = Path(episode.audio_url.split("?", 1)[0]).suffix or ".m4a"
audio = download_direct(episode.audio_url, work_dir / f"audio{suffix}")
else:
audio = download_audio(args.url, work_dir, args.cookies_from_browser)
backend, segments = transcribe(audio, work_dir, args.asr_backend)
method = f"ASR({backend})"
if not segments:
raise RuntimeError("没有提取到字幕内容")
markdown_path, srt_path = write_outputs(
output_dir=output_dir,
title=str(info["title"]),
url=args.url,
platform=platform,
language=str(info.get("language") or "unknown"),
method=method,
segments=segments,
)
emit(
{
"ok": True,
"action": "transcribe",
"platform": platform,
"method": method,
"title": info["title"],
"markdown": str(markdown_path),
"srt": str(srt_path) if srt_path else None,
"segment_count": len(segments),
"ask_download": True,
"download_kind": "audio" if platform == "xiaoyuzhou" else "video",
}
)
return 0
def download_url(args: argparse.Namespace) -> int:
platform = validate_url(args.url)
files = download_media(
args.url,
Path(args.output_dir).expanduser().resolve(),
args.cookies_from_browser,
)
emit(
{
"ok": True,
"action": "download",
"platform": platform,
"files": [str(path) for path in files],
}
)
return 0
def doctor() -> int:
checks = {
"python>=3.10": sys.version_info >= (3, 10),
"ffmpeg": bool(shutil.which("ffmpeg")),
"ffprobe": bool(shutil.which("ffprobe")),
"yt-dlp": bool(shutil.which("yt-dlp")),
"ASR": bool(available_backends()),
}
for name, ok in checks.items():
print(f"{'✓' if ok else '✗'} {name}")
print("可用 ASR:" + (", ".join(available_backends()) or "无"))
return 0 if all(checks.values()) else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
detect = subparsers.add_parser("detect", help="识别链接平台")
detect.add_argument("url")
extract = subparsers.add_parser("transcribe", help="提取字幕或逐字稿")
extract.add_argument("url")
extract.add_argument(
"--output-dir", default=os.path.join(os.getcwd(), "video-transcripts")
)
extract.add_argument("--subtitle-languages", default="zh.*,zh-Hans,zh-CN,zh,en.*")
extract.add_argument(
"--asr-backend",
choices=("auto", "groq", "whisper", "agent-reach"),
default="auto",
)
extract.add_argument("--force-asr", action="store_true")
extract.add_argument("--cookies-from-browser")
download = subparsers.add_parser("download", help="下载用户确认后的原视频或音频")
download.add_argument("url")
download.add_argument(
"--output-dir", default=os.path.join(os.getcwd(), "video-transcripts", "media")
)
download.add_argument("--cookies-from-browser")
subparsers.add_parser("doctor", help="检查依赖")
return parser
def main() -> int:
args = build_parser().parse_args()
try:
if args.command == "doctor":
return doctor()
if args.command == "detect":
platform = validate_url(args.url)
emit({"ok": True, "action": "detect", "platform": platform})
return 0
if args.command == "transcribe":
return transcribe_url(args)
return download_url(args)
except (RuntimeError, ValueError) as exc:
emit({"ok": False, "action": args.command, "error": str(exc)})
print(f"错误:{exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,133 @@
import json
import os
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import Mock, patch
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
import asr
from formats import Segment
class AsrTests(unittest.TestCase):
def test_available_and_choose_backends(self):
def located(name):
return (
f"/bin/{name}" if name in {"curl", "whisper", "agent-reach"} else None
)
with (
patch.dict(os.environ, {"GROQ_API_KEY": "secret"}),
patch("asr.shutil.which", side_effect=located),
):
self.assertEqual(
asr.available_backends(), ("groq", "whisper", "agent-reach")
)
self.assertEqual(asr.choose_backend("auto"), "groq")
self.assertEqual(asr.choose_backend("whisper"), "whisper")
with patch("asr.available_backends", return_value=()):
with self.assertRaisesRegex(RuntimeError, "没有可用 ASR"):
asr.choose_backend("auto")
with self.assertRaisesRegex(RuntimeError, "当前不可用"):
asr.choose_backend("groq")
@patch("asr.subprocess.run")
def test_media_duration(self, run):
run.return_value = Mock(stdout="12.5\n")
self.assertEqual(asr.media_duration(Path("a.mp3")), 12.5)
run.return_value = Mock(stdout="bad")
self.assertEqual(asr.media_duration(Path("a.mp3")), 0.0)
def test_split_audio(self):
with TemporaryDirectory() as temp:
root = Path(temp)
def fake_run(*_args, **_kwargs):
(root / "chunk_000.mp3").write_bytes(b"audio")
return Mock(returncode=0, stderr="")
with patch("asr.subprocess.run", side_effect=fake_run):
self.assertEqual(len(asr.split_audio(Path("source.mp3"), root)), 1)
def test_groq_transcription_with_segments_and_text_fallback(self):
with TemporaryDirectory() as temp:
root = Path(temp)
chunks = (root / "a.mp3", root / "b.mp3")
for chunk in chunks:
chunk.write_bytes(b"a")
payloads = [
{"segments": [{"start": 1, "end": 2, "text": "第一句"}]},
{"text": "第二句"},
]
with (
patch("asr.split_audio", return_value=chunks),
patch("asr._groq_chunk", side_effect=payloads),
patch("asr.media_duration", return_value=10),
):
self.assertEqual(
asr.transcribe_groq(Path("source.mp3"), root),
(Segment(1, 2, "第一句"), Segment(10, 20, "第二句")),
)
def test_groq_chunk_success_and_invalid_json(self):
good = Mock(returncode=0, stdout='{"text":"ok"}', stderr="")
with (
patch.dict(os.environ, {"GROQ_API_KEY": "secret"}),
patch("asr.subprocess.run", return_value=good),
):
self.assertEqual(asr._groq_chunk(Path("a.mp3"))["text"], "ok")
bad = Mock(returncode=0, stdout="not-json", stderr="")
with (
patch.dict(os.environ, {"GROQ_API_KEY": "secret"}),
patch("asr.subprocess.run", return_value=bad),
self.assertRaisesRegex(RuntimeError, "无法解析"),
):
asr._groq_chunk(Path("a.mp3"))
def test_whisper_and_agent_reach(self):
with TemporaryDirectory() as temp:
root = Path(temp)
def whisper_run(*_args, **_kwargs):
(root / "audio.json").write_text(
json.dumps({"segments": [{"start": 0, "end": 2, "text": "你好"}]}),
encoding="utf-8",
)
return Mock(returncode=0, stderr="")
with patch("asr.subprocess.run", side_effect=whisper_run):
self.assertEqual(
asr.transcribe_whisper(Path("audio.mp3"), root),
(Segment(0, 2, "你好"),),
)
def reach_run(*_args, **_kwargs):
(root / "agent-reach-transcript.txt").write_text(
"逐字稿", encoding="utf-8"
)
return Mock(returncode=0, stderr="")
with (
patch("asr.subprocess.run", side_effect=reach_run),
patch("asr.media_duration", return_value=5),
):
self.assertEqual(
asr.transcribe_agent_reach(Path("audio.mp3"), root),
(Segment(0, 5, "逐字稿"),),
)
def test_transcribe_dispatch(self):
expected = (Segment(0, 1, "ok"),)
with (
patch("asr.choose_backend", return_value="groq"),
patch("asr.transcribe_groq", return_value=expected),
):
self.assertEqual(asr.transcribe(Path("a"), Path("b")), ("groq", expected))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,51 @@
import json
import subprocess
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CLI = ROOT / "scripts" / "space_video_transcript.py"
class CliTests(unittest.TestCase):
def test_detect_end_to_end(self):
result = subprocess.run(
[
sys.executable,
str(CLI),
"detect",
"https://www.bilibili.com/video/BV123",
],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0)
marker = result.stdout.strip().split("=", 1)[1]
self.assertEqual(json.loads(marker)["platform"], "bilibili")
def test_invalid_url_end_to_end(self):
result = subprocess.run(
[sys.executable, str(CLI), "detect", "not-a-url"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn('"ok": false', result.stdout)
def test_help_end_to_end(self):
result = subprocess.run(
[sys.executable, str(CLI), "--help"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0)
self.assertIn("transcribe", result.stdout)
self.assertIn("download", result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,55 @@
import sys
import unittest
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from formats import Segment, parse_subtitle_text, render_markdown, render_srt, safe_stem
class FormatTests(unittest.TestCase):
def test_parse_srt_and_remove_duplicate_cues(self):
content = """1
00:00:01,000 --> 00:00:03,000
你好 <b>世界</b>
2
00:00:03,000 --> 00:00:04,000
你好 世界
3
00:00:04,000 --> 00:00:06,000
第二句
"""
self.assertEqual(
parse_subtitle_text(content),
(Segment(1.0, 3.0, "你好 世界"), Segment(4.0, 6.0, "第二句")),
)
def test_parse_vtt(self):
content = "WEBVTT\n\n00:01.000 --> 00:03.500\nhello\nworld\n"
self.assertEqual(
parse_subtitle_text(content), (Segment(1.0, 3.5, "hello world"),)
)
def test_render_outputs(self):
segments = (Segment(1.2, 2.5, "测试"),)
self.assertIn("00:00:01,200 --> 00:00:02,500", render_srt(segments))
markdown = render_markdown(
title="标题",
url="https://example.com",
platform="YouTube",
language="zh",
method="平台字幕",
segments=segments,
)
self.assertIn("# 标题", markdown)
self.assertIn("**[00:00:01]** 测试", markdown)
def test_safe_stem(self):
self.assertEqual(safe_stem("a/b: c?"), "a-b-c")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,144 @@
import json
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import Mock, patch
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
import platforms
from platforms import (
detect_platform,
extract_episode_from_html,
validate_url,
youtube_id,
)
class PlatformTests(unittest.TestCase):
def test_detect_supported_platforms(self):
cases = {
"https://youtu.be/dQw4w9WgXcQ": "youtube",
"https://www.xiaoyuzhoufm.com/episode/abc123": "xiaoyuzhou",
"https://www.bilibili.com/video/BV123": "bilibili",
"https://v.douyin.com/abc": "douyin",
"https://xhslink.com/a1b2": "xiaohongshu",
}
for url, expected in cases.items():
with self.subTest(url=url):
self.assertEqual(detect_platform(url), expected)
def test_reject_show_and_unknown_pages(self):
with self.assertRaisesRegex(ValueError, "单集"):
validate_url("https://www.xiaoyuzhoufm.com/podcast/abc")
with self.assertRaisesRegex(ValueError, "仅支持"):
validate_url("https://example.com/video")
def test_youtube_id(self):
self.assertEqual(youtube_id("https://youtu.be/dQw4w9WgXcQ"), "dQw4w9WgXcQ")
self.assertEqual(
youtube_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1"),
"dQw4w9WgXcQ",
)
with self.assertRaisesRegex(ValueError, "视频 ID"):
youtube_id("https://www.youtube.com/channel/example")
def test_extract_episode_next_data(self):
html = """<html><head><meta property="og:title" content="一期播客"></head>
<script id="__NEXT_DATA__" type="application/json">
{"props":{"episode":{"title":"内部标题","enclosureUrl":"https://media.xyzcdn.net/a.m4a","podcast":{"title":"节目名"}}}}
</script></html>"""
episode = extract_episode_from_html(html)
self.assertEqual(episode.title, "一期播客")
self.assertEqual(episode.audio_url, "https://media.xyzcdn.net/a.m4a")
self.assertEqual(episode.podcast, "节目名")
def test_extract_episode_regex_fallback(self):
html = '<meta property="og:title" content="标题"><p>https://media.xyzcdn.net/fallback.mp3</p>'
episode = extract_episode_from_html(html)
self.assertEqual(episode.title, "标题")
self.assertEqual(episode.audio_url, "https://media.xyzcdn.net/fallback.mp3")
with self.assertRaisesRegex(RuntimeError, "无法解析"):
extract_episode_from_html("<html></html>")
def test_ytdlp_command_and_cookie_args(self):
with patch("platforms.shutil.which", return_value="/bin/yt-dlp"):
self.assertEqual(platforms.ytdlp_command(), "/bin/yt-dlp")
with (
patch("platforms.shutil.which", return_value=None),
self.assertRaisesRegex(RuntimeError, "未找到 yt-dlp"),
):
platforms.ytdlp_command()
self.assertEqual(
platforms.cookie_args("chrome"), ["--cookies-from-browser", "chrome"]
)
self.assertEqual(platforms.cookie_args(None), [])
self.assertIn(
"--cookies-from-browser chrome",
platforms.failure_message(
"视频下载", "Sign in; use --cookies-from-browser"
),
)
def test_metadata_success_and_fallbacks(self):
good = Mock(
returncode=0, stdout=json.dumps({"title": "视频", "language": "zh"})
)
with (
patch("platforms.ytdlp_command", return_value="yt-dlp"),
patch("platforms.subprocess.run", return_value=good),
):
self.assertEqual(
platforms.metadata("https://youtu.be/dQw4w9WgXcQ")["title"], "视频"
)
failed = Mock(returncode=1, stdout="", stderr="error")
with (
patch("platforms.ytdlp_command", return_value="yt-dlp"),
patch("platforms.subprocess.run", return_value=failed),
):
self.assertEqual(
platforms.metadata("https://youtu.be/dQw4w9WgXcQ")["title"],
"视频逐字稿",
)
def test_subtitle_and_audio_download_helpers(self):
with TemporaryDirectory() as temp:
root = Path(temp)
def subtitle_run(*_args, **_kwargs):
(root / "platform.zh.srt").write_text(
"1\n00:00:00,000 --> 00:00:01,000\n你好\n", encoding="utf-8"
)
return Mock(returncode=0, stdout="", stderr="")
with (
patch("platforms.ytdlp_command", return_value="yt-dlp"),
patch("platforms.subprocess.run", side_effect=subtitle_run),
):
self.assertEqual(
len(
platforms.download_platform_subtitles(
"https://youtu.be/x", root, "zh"
)
),
1,
)
def audio_run(*_args, **_kwargs):
(root / "audio.mp3").write_bytes(b"audio")
return Mock(returncode=0, stdout="", stderr="")
with (
patch("platforms.ytdlp_command", return_value="yt-dlp"),
patch("platforms.subprocess.run", side_effect=audio_run),
):
self.assertEqual(
platforms.download_audio("https://v.douyin.com/x", root).name,
"audio.mp3",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,132 @@
import io
import json
import sys
import unittest
from argparse import Namespace
from contextlib import redirect_stdout
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
import space_video_transcript as cli
from formats import Segment
from platforms import Episode
def args(url, output_dir, **overrides):
values = {
"url": url,
"output_dir": output_dir,
"force_asr": False,
"subtitle_languages": "zh.*,en.*",
"cookies_from_browser": None,
"asr_backend": "auto",
}
values.update(overrides)
return Namespace(**values)
class WorkflowTests(unittest.TestCase):
def test_youtube_caption_workflow_and_download_prompt_marker(self):
with TemporaryDirectory() as temp:
segments = (Segment(0, 2, "平台字幕"),)
output = io.StringIO()
with (
patch(
"space_video_transcript.metadata",
return_value={"title": "测试视频", "language": "zh"},
),
patch(
"space_video_transcript.youtube_api_captions", return_value=segments
),
redirect_stdout(output),
):
self.assertEqual(
cli.transcribe_url(args("https://youtu.be/dQw4w9WgXcQ", temp)), 0
)
payload = json.loads(output.getvalue().strip().split("=", 1)[1])
self.assertTrue(payload["ask_download"])
self.assertEqual(payload["download_kind"], "video")
self.assertTrue(Path(payload["markdown"]).exists())
self.assertTrue(Path(payload["srt"]).exists())
def test_douyin_asr_fallback(self):
with TemporaryDirectory() as temp:
audio = Path(temp) / "audio.mp3"
audio.write_bytes(b"audio")
segments = (Segment(0, 2, "转写结果"),)
with (
patch(
"space_video_transcript.metadata",
return_value={"title": "抖音视频", "language": "zh"},
),
patch("space_video_transcript.download_audio", return_value=audio),
patch(
"space_video_transcript.transcribe",
return_value=("whisper", segments),
),
redirect_stdout(io.StringIO()),
):
self.assertEqual(
cli.transcribe_url(args("https://v.douyin.com/abc", temp)), 0
)
def test_xiaoyuzhou_asr_and_audio_marker(self):
with TemporaryDirectory() as temp:
episode = Episode("播客标题", "https://media.xyzcdn.net/a.m4a", "节目")
audio = Path(temp) / "audio.m4a"
audio.write_bytes(b"audio")
output = io.StringIO()
with (
patch(
"space_video_transcript.fetch_xiaoyuzhou_episode",
return_value=episode,
),
patch("space_video_transcript.download_direct", return_value=audio),
patch(
"space_video_transcript.transcribe",
return_value=("agent-reach", (Segment(0, 5, "播客内容"),)),
),
redirect_stdout(output),
):
cli.transcribe_url(
args("https://www.xiaoyuzhoufm.com/episode/abc", temp)
)
payload = json.loads(output.getvalue().strip().split("=", 1)[1])
self.assertEqual(payload["download_kind"], "audio")
def test_download_workflow(self):
with TemporaryDirectory() as temp:
media = Path(temp) / "video.mp4"
with (
patch("space_video_transcript.download_media", return_value=(media,)),
redirect_stdout(io.StringIO()),
):
self.assertEqual(
cli.download_url(
args("https://www.bilibili.com/video/BV123", temp)
),
0,
)
def test_doctor_and_main_detect(self):
with (
patch("space_video_transcript.shutil.which", return_value="/bin/tool"),
patch(
"space_video_transcript.available_backends", return_value=("whisper",)
),
redirect_stdout(io.StringIO()),
):
self.assertEqual(cli.doctor(), 0)
with (
patch.object(sys, "argv", ["tool", "detect", "https://xhslink.com/a"]),
redirect_stdout(io.StringIO()),
):
self.assertEqual(cli.main(), 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,61 @@
# space-video · 视频创作 Skill 矩阵
一套面向自媒体视频创作的 `space-` 技能矩阵,把一条视频从「参考 → 选题 → 脚本 → 剪辑 → B-roll → 字幕 → 封面」拆成单一职责、可单用也可串联的技能。
## 技能清单
| 技能 | 职责 |
|---|---|
| **space-video** | 总控/导演:判断走哪步、路由子技能、成片后复盘 |
| **space-video-topic** | 选题前端:下载去水印 · 逐字稿 · 选题/钩子 · 标题 · 正文 |
| **space-video-script** | 脚本:去 AI 味口播稿 + 精确到秒分镜 |
| **space-video-edit** | 剪辑:剪口播(删前保后 + 风险分层)+ 口播成片 |
| **space-video-broll** | B-roll:HTML→确定性 MP4 的动画/motion 画面(代码渲染) |
| **space-video-broll-sketch** | B-roll:文章转手绘图解,调视频模型(Seedance/libtv)逐帧生成 |
| **space-video-subtitle** | 字幕:转写 · 断句 · AI 校对 · SRT/ASS · 烧录 |
| **space-video-audio** | 配音配乐:TTS 旁白 · 找 BGM 下载 · 压音混音 · 响度归一 |
| **space-video-cover** | 封面:家族气质 + 平台比例 + 高点击缩略图 |
## 串联用法
```
说「从头帮我做一条视频」→ space-video 接管,逐环节调子技能,文件落盘交接
```
各技能也可单独触发,见每个 SKILL.md 的触发词。
---
## 致谢与引用来源
本矩阵在设计与方法论上参考、借鉴了以下开源项目,并对其能力做了简化、拆解与重组:
**Skill 架构与视频创作方法论**
- [Pluviobyte/rnskill](https://github.com/Pluviobyte/rnskill) — Skill 工作流串联模式、视频创作技能分类
- [chengfeng / AI产品自由 · chengfeng-videocut-skills](https://github.com/Agentchengfeng/chengfeng-videocut-skills)(收录于 rnskill)— 剪口播「删前保后」「删除风险分层」方法论、口播成片分镜/时间轴流程,是 `space-video-edit` 的主要参考
**视频下载与文案提取**
- [yzfly/douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) — 抖音无水印下载、SenseVoice 语音转文案思路,参考进 `space-video-topic` / `space-video-subtitle`
**配音 / TTS**
- rnskill `tts-skill`(IndexTTS2 本地克隆音色路由方法论)— 参考进 `space-video-audio` 的 TTS 分档策略
**脚本/分镜规格**
- [feicaiclub/video-spec-builder](https://github.com/feicaiclub/video-spec-builder) — 「把模糊想法逼成精确到秒的分镜脚本」理念,参考进 `space-video-script`
**B-roll 渲染**
- [heygen-com/hyperframes](https://github.com/heygen-com/hyperframes) — HTML→确定性 MP4、可寻址动画机制,是 `space-video-broll` 的技术路线基础
**视频模型(生成式 B-roll)**
- 火山引擎 **Seedance 2.0**(火山方舟 Ark API,`doubao-seedance-2-0-*`)— `space-video-broll-sketch` 的 Seedance 通道
- **libtv Agent**(复用本地 `libtv-skill` 的 agent-im OpenAPI)— `space-video-broll-sketch` 的 libtv 通道
**编排复用的本地技能**(矩阵不重复造轮子,直接路由)
- 去 AI 味:`Humanizer-zh`|口播转换:`podcast-script-generator`
- B-roll 渲染:`hyperframes` / `hyperframes-cli` / `hyperframes-media` / `remotion-video`
- 动画适配器:`gsap` / `animejs` / `css-animations` / `waapi` / `three` / `lottie`
- 标题:`baokuan-title-generator`|选题:`topic-generator` / `topic-collector`
- 逐字稿:`youtube-transcript-cn` / `content-digest`
- 封面出图:`image-studio` / `space-article-cover-and-batch-illustration`
各来源的版权与许可归原作者所有。
@@ -0,0 +1,72 @@
---
name: space-video
description: 视频创作总控/导演。把一条视频从「参考素材 → 选题 → 脚本 → 剪辑 → B-roll → 字幕 → 封面」串成一条流水线,判断当前该走哪一步、调用哪个 space-video-* 子技能,并在成片后做复盘。当用户说「做个视频」「我要拍条口播」「这个视频从头帮我做」「视频工作流」「video pipeline」,或不确定该用哪个视频子技能时触发。
---
# space-video · 视频创作导演
一条视频从想法到成片,中间有七个环节。这个技能是**总调度**:判断你现在在哪一步,把活派给对应的子技能,最后收口做复盘。
## 全流程地图
```text
参考/灵感 ──► ① 选题 space-video-topic 选题·标题·正文·下载参考·逐字稿
► ② 脚本 space-video-script 去 AI 味口播稿·精确到秒
► ③ 剪辑 space-video-edit 剪口播·删前保后·成片
► ④ B-roll space-video-broll 动效导演·HTML→30s+ MP4
► ⑤ 字幕 space-video-subtitle 转写·断句·AI 校对·烧录
► ⑥ 配音配乐 space-video-audio TTS 旁白·找 BGM·压音混音
► ⑦ 封面 space-video-cover 封面家族气质
成片 ──────► ⑧ 复盘(本技能收口)
```
## 路由规则
先判断用户手里有什么、想要什么,再决定入口:
| 用户的话 / 手里的东西 | 派给 |
|---|---|
| 「不知道拍什么」「帮我想选题」「这条爆款拆一下」「起标题」 | `space-video-topic` |
| 发送 YouTube/小宇宙/B站/抖音/小红书链接,要求「提取字幕/逐字稿/下载视频」 | `space-video-transcript` |
| 「稿子写一下」「口播脚本」「这段太 AI 了」「精确到秒的分镜」 | `space-video-script` |
| 「剪一下这条录屏」「口误删掉」「做成片」「时间轴」 | `space-video-edit` |
| 「加点动画」「B-roll」「做个 motion 开场」「HTML 转视频」 | `space-video-broll`(代码渲染,确定性、免费) |
| 「文章转图解视频」「手绘白板动图」「Seedance/libtv 出 B-roll」 | `space-video-broll-sketch`(调视频模型生成) |
| 对本地成片说「上字幕」「断句」「字幕校对」「烧录字幕」 | `space-video-subtitle` |
| 「配音」「配乐」「加 BGM」「找音乐」「TTS 旁白」「混音」 | `space-video-audio` |
| 「做封面」「缩略图」「首图」 | `space-video-cover` |
| 「从头帮我做一条」 | 从①开始,逐环节走 |
**不确定就先问一句**:这条视频是口播/录屏/混剪?发哪个平台(竖屏 3:4 / 横屏 16:9)?有没有参考?
## 一条龙时的交接约定
各环节用文件交接,不要把上一步的结果只留在对话里:
```text
项目目录/
├── 00_参考/ 下载的参考视频、逐字稿
├── 01_选题/ 选题.md、标题候选.md、正文.md
├── 02_脚本/ script.md(精确到秒)
├── 03_剪辑/ source_cut.mp4、cutlist.json
├── 04_broll/ *.mp4 B-roll 片段
├── 05_字幕/ subtitles.srt
└── 06_封面/ cover.png
```
每步产出落盘后,再进下一步。子技能之间不直接互相调用,统一由本技能编排。
## ⑦ 复盘(成片后收口)
成片发布后,帮用户沉淀:
1. **数据**:完播率、点赞/评论比、涨粉,哪个环节可能是瓶颈
2. **钩子回看**:前 3 秒留没留住人
3. **可复用资产**:这条里哪个 B-roll / 封面模板 / 选题公式下次能复用
4. 追加一条到用户的视频复盘记录(若有)
## 核心原则
- **单一职责**:本技能只做调度和复盘,不亲自剪、不亲自写;具体活交子技能
- **落盘交接**:每步产出写成文件,可追溯、可回退
- **平台先行**:动手前先定比例和平台,避免竖屏横屏返工