初始化提交: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:
commit
df56c2c137
1773 files changed
+205840
No files matched your search
+64
@@ -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`
|
||||
|
||||
各来源的版权与许可归原作者所有。
|
||||
+91
@@ -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` 保画质
|
||||
+57
@@ -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 接反(被压的接成了人声)→ 人声被压没了
|
||||
+50
@@ -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,没配就走网页人工下,或让用户提供直链/曲目。
|
||||
- 选曲是主观 + 授权决定:拿不准先问用户要「情绪 + 一个可用来源/链接」。
|
||||
+66
@@ -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
|
||||
+125
@@ -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` 且能播放,即通道打通,可正式批量出片。
|
||||
+15
@@ -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"
|
||||
+51
@@ -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
|
||||
+109
@@ -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()
|
||||
+112
@@ -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 驱动、相同输入相同输出
|
||||
+92
@@ -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 |
|
||||
|
||||
用户指定风格时以用户为准;混合场景可分场景切风格,但整条要有统一基因(同一套字体+强调色逻辑)。
|
||||
+35
@@ -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 门再谈。
|
||||
+221
@@ -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>
|
||||
+59
@@ -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);
|
||||
+45
@@ -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;
|
||||
+70
@@ -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 秒的人看的
|
||||
- **封面文案 ≠ 视频标题**:更短、更冲、更钩
|
||||
+87
@@ -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 后端(转写与重转写)。
|
||||
|
||||
## 核心原则
|
||||
|
||||
- **删前保后** > 一切子规则
|
||||
- **删字安全,删意思危险**:复核按风险投放,不铺满
|
||||
- **剪口播和成片分开**:先出干净素材,再谈包装
|
||||
- **一切可回看**:审核页让人一眼看到删哪、成片前时间轴对齐
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# 删前保后(最高优先级)
|
||||
|
||||
**此原则优先于所有子规则。任何删除操作都必须先过这一关。**
|
||||
|
||||
后说的通常更完整,所以:
|
||||
- 删除**前面的**(口误 / 残句 / 重复的第一次)
|
||||
- 保留**后面的**(完整句 / 重复的最后一次)
|
||||
|
||||
## 常见错误
|
||||
|
||||
```
|
||||
句子: "于是很把于是很容易把它理解成一种不够友好但很高效的界面"
|
||||
↑前半(错) ↑后半(对,更完整)
|
||||
|
||||
❌ 整句删除 → 把后面完整的也删了
|
||||
✓ 只删"于是很把"这几个字,保留"于是很容易把它理解成..."
|
||||
```
|
||||
|
||||
```
|
||||
相邻句:
|
||||
前句: "到底是什么原因导致了他会在AI的时代重新被选中"
|
||||
后句: "到底是什么原因导致了他在AI的时代又重新被选中了呢"
|
||||
|
||||
❌ 删后句(更长更完整的)
|
||||
✓ 删前句,保留后句
|
||||
```
|
||||
|
||||
## 适用范围
|
||||
|
||||
- **重复句**:删前句,保留后句(不是删较短的)
|
||||
- **句内重复**:只删句内前面重复的片段,不删整句
|
||||
- **残句**:删残句(前面的),保留后面重说的完整版
|
||||
- **重说纠正**:删前面说错的,保留后面纠正的
|
||||
|
||||
## 校验步骤
|
||||
|
||||
标记完所有删除项后,逐条检查:
|
||||
1. 被删的是不是**前面 / 较早**的版本?
|
||||
2. 保留的是不是**后面 / 更完整**的版本?
|
||||
3. 句内重复是不是**只删了重复片段**,没有误删整句?
|
||||
|
||||
## 例外
|
||||
|
||||
用户明确说"前面更顺"时,删后保前。除此之外,一律删前保后。
|
||||
+34
@@ -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 论证"这个删除是错的"),过了再进审核页。
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# 删除风险分层
|
||||
|
||||
> 🚨 一句话:删"字"安全,删"意思"危险。复核(verify)按风险投放,不要铺满。
|
||||
|
||||
复核(对抗式让 reviewer 反过来挑"这个删除是不是错的")很贵。要把它砸在**容易删错**的地方,低风险项直接交给末端的网页人工审核即可。
|
||||
|
||||
## 低风险 —— 免复核 / 轻复核
|
||||
|
||||
| 类型 | 为什么安全 | 例 |
|
||||
|------|-----------|----|
|
||||
| **静音** | 删的是停顿不是话,丢不了信息,最多丢节奏;审核页一眼可见 | ≥0.2s gap |
|
||||
| **逐字子集重复** | 删的是保留内容的逐字开头,删了等于没删,内容还在后面 | 删「我把同一句」保「我把同一句**话**」 |
|
||||
| **≤3字纯卡壳/语气词** | 太短,装不下任何独有意思 | 单字「从」「嗯」「ment」 |
|
||||
|
||||
## 高风险 —— 必须复核
|
||||
|
||||
| 类型 | 为什么危险 | 例 |
|
||||
|------|-----------|----|
|
||||
| **整句删除** | 一整句里可能藏着别处没有的话 | 残句 / 整句重说 |
|
||||
| **开头撞、结尾岔** | 开头相同被当重复,但后半语义分叉,删了真丢内容 | 「超出了**预算**」vs「超出了**路线规划能力**」 |
|
||||
| **长片段重复** | 越长越可能含独有内容 | 跨多字的句内重复 |
|
||||
|
||||
## 判定要点
|
||||
|
||||
1. 删的内容,是不是保留版的**逐字子集**?是 → 安全(机械可验,免动脑)。
|
||||
2. 是**整句**吗?整句后半是不是独有内容?后半独有 → 危险,必验"保留版是否真覆盖了它"。
|
||||
3. 开头像、**结尾是否分叉**?分叉 → 高危误删,必验。
|
||||
|
||||
## 为什么这样分
|
||||
|
||||
- **错删代价 > 漏删代价**:删错一句观众看得出来,漏删顶多啰嗦一点。
|
||||
- **末端有人工兜底**:低风险交审核页点一点就够,verify 全量铺开和人工重叠、浪费。
|
||||
|
||||
配合 [删前保后.md]:删前保后是判断**方向**,本规则是判断**值不值得复核**。
|
||||
+45
@@ -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"
|
||||
+70
@@ -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 味,分镜再准也白搭
|
||||
- **逼到秒**:不接受模糊描述,追问到每秒有安排
|
||||
- **留真人痕迹**:适度口头语、停顿、自我修正,比工整更可信
|
||||
+64
@@ -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。
|
||||
|
||||
## 核心原则
|
||||
|
||||
- **断句按呼吸,不按标点**
|
||||
- **专名先建表**:术语表统一,避免同一个词半集对半集错
|
||||
- **剪后重转写**:剪过的视频,字幕必须重新做
|
||||
+81
@@ -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% 爆款」)
|
||||
- 平台合规:去水印仅用于个人学习/二创参考
|
||||
+80
@@ -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)。
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "视频链接字幕提取"
|
||||
short_description: "五个平台链接一键提取字幕,完成后按用户选择下载原视频"
|
||||
default_prompt: "使用 $space-video-transcript 提取这个链接的字幕,完成后询问我是否下载原视频。"
|
||||
+44
@@ -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。
|
||||
+210
@@ -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
|
||||
+119
@@ -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("-.")
|
||||
+302
@@ -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
|
||||
+11
@@ -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" "$@"
|
||||
+235
@@ -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())
|
||||
+133
@@ -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()
|
||||
+51
@@ -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()
|
||||
+55
@@ -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()
|
||||
+144
@@ -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()
|
||||
+132
@@ -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()
|
||||
+61
@@ -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`
|
||||
|
||||
各来源的版权与许可归原作者所有。
|
||||
+72
@@ -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. 追加一条到用户的视频复盘记录(若有)
|
||||
|
||||
## 核心原则
|
||||
|
||||
- **单一职责**:本技能只做调度和复盘,不亲自剪、不亲自写;具体活交子技能
|
||||
- **落盘交接**:每步产出写成文件,可追溯、可回退
|
||||
- **平台先行**:动手前先定比例和平台,避免竖屏横屏返工
|
||||
Reference in new issue
Block a user