@optima-chat/gen-cli 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/commands/task.d.ts.map +1 -1
  2. package/dist/commands/task.js +12 -3
  3. package/dist/commands/task.js.map +1 -1
  4. package/dist/commands/video.d.ts.map +1 -1
  5. package/dist/commands/video.js +26 -2
  6. package/dist/commands/video.js.map +1 -1
  7. package/package.json +1 -2
  8. package/.claude/skills/digital-human/SKILL.md +0 -309
  9. package/.claude/skills/digital-human/references/avatar-catalog.md +0 -47
  10. package/.claude/skills/digital-human/references/edit.md +0 -219
  11. package/.claude/skills/digital-human/references/generate.md +0 -378
  12. package/.claude/skills/digital-human/references/manage.md +0 -137
  13. package/.claude/skills/digital-human/references/train.md +0 -276
  14. package/.claude/skills/gen/SKILL.md +0 -366
  15. package/.claude/skills/motion-control/SKILL.md +0 -68
  16. package/.claude/skills/multigrid-poster/SKILL.md +0 -194
  17. package/.claude/skills/multigrid-poster/layouts/2x2.json +0 -34
  18. package/.claude/skills/multigrid-poster/layouts/3x3.json +0 -43
  19. package/.claude/skills/multigrid-poster/scripts/compose.py +0 -116
  20. package/.claude/skills/multigrid-poster/scripts/placeholder.png +0 -0
  21. package/.claude/skills/multigrid-poster/shared/fonts/MaShanZheng-Regular.ttf +0 -0
  22. package/.claude/skills/video-compose/SKILL.md +0 -144
  23. package/.claude/skills/video-compose/scripts/video_compose.py +0 -290
  24. package/.claude/skills/video-edit/SKILL.md +0 -332
  25. package/.claude/skills/video-gen/SKILL.md +0 -662
  26. package/.claude/skills/video-gen/references/cinematic-language.md +0 -158
  27. package/.claude/skills/video-gen/references/confirm-card.md +0 -49
  28. package/.claude/skills/video-gen/references/prompt-craft.md +0 -72
  29. package/.claude/skills/video-gen/templates/INDEX.md +0 -78
  30. package/.claude/skills/video-gen/templates/before-after-beauty.md +0 -183
  31. package/.claude/skills/video-gen/templates/drama-fmcg.md +0 -183
  32. package/.claude/skills/video-gen/templates/kol-reaction-food.md +0 -193
  33. package/.claude/skills/video-gen/templates/multi-point-apparel.md +0 -185
  34. package/.claude/skills/video-gen/templates/pain-solution-home.md +0 -184
  35. package/.claude/skills/video-gen/templates/pdp-360-showcase.md +0 -189
  36. package/.claude/skills/video-gen/templates/pdp-feature-highlight.md +0 -182
  37. package/.claude/skills/video-gen/templates/scene-digital.md +0 -183
  38. package/.claude/skills/video-translate/SKILL.md +0 -547
@@ -1,43 +0,0 @@
1
- {
2
- "_comment": "通用 3×3 网格布局 - 适合 9 张 cell (商品清单 / 多角度展示)",
3
- "canvas_size": [1242, 1660],
4
- "cells": {
5
- "_comment": "9 格 414×420。顶部 200px 标题区,cells y=200..1460,底部 200px caption 区。3×420 + 200×2 = 1660 = canvas_h ✓",
6
- "positions": [
7
- [0, 200], [414, 200], [828, 200],
8
- [0, 620], [414, 620], [828, 620],
9
- [0, 1040], [414, 1040], [828, 1040]
10
- ],
11
- "sizes": [
12
- [414, 420], [414, 420], [414, 420],
13
- [414, 420], [414, 420], [414, 420],
14
- [414, 420], [414, 420], [414, 420]
15
- ]
16
- },
17
- "text_zones": {
18
- "title": {
19
- "_comment": "顶部白边 2 行标题(y < 200 区间)",
20
- "font": "shared/fonts/MaShanZheng-Regular.ttf",
21
- "size": 78,
22
- "color": "#FFB940",
23
- "stroke_w": 6,
24
- "stroke_color": "#D63D3D",
25
- "lines": [
26
- {"position": [621, 60], "anchor": "mm"},
27
- {"position": [621, 150], "anchor": "mm"}
28
- ]
29
- },
30
- "caption": {
31
- "_comment": "底部白边 2 行 caption(cells 结束于 y=1460,留 200px)",
32
- "font": "shared/fonts/MaShanZheng-Regular.ttf",
33
- "size": 60,
34
- "color": "#FFB940",
35
- "stroke_w": 5,
36
- "stroke_color": "#D63D3D",
37
- "lines": [
38
- {"position": [621, 1530], "anchor": "mm"},
39
- {"position": [621, 1610], "anchor": "mm"}
40
- ]
41
- }
42
- }
43
- }
@@ -1,116 +0,0 @@
1
- #!/usr/bin/env python3
2
- """multigrid-poster compose — Pillow 版渲染器
3
-
4
- 输入:layout.json + N 张 cell 图片 + 标题文字 + caption 文字
5
- 输出:1242×1660 PNG(小红书封面标准)
6
-
7
- 用法:
8
- python compose.py \
9
- --layout <SKILL_DIR>/layouts/2x2.json \
10
- --cells cell_0.png cell_1.png cell_2.png cell_3.png \
11
- --title-line "26岁一个人创业" \
12
- --title-line "跨境电商月入10w+" \
13
- --caption-line "只需要一部手机就可以完成!" \
14
- --caption-line "跨境人的必备app推荐" \
15
- --output cover.png
16
-
17
- 依赖:Pillow (容器自带,无需额外安装)
18
- 字体:从 SKILL_DIR/shared/fonts/ 加载(layout.json 里指定)
19
- """
20
- import argparse
21
- import json
22
- import sys
23
- from pathlib import Path
24
- from PIL import Image, ImageDraw, ImageFont
25
-
26
-
27
- def compose(layout_path: Path, cell_paths: list[Path],
28
- title_lines: list[str], caption_lines: list[str],
29
- output_path: Path) -> Path:
30
- """根据 layout.json 渲染海报。
31
-
32
- layout.json 字段:
33
- canvas_size [w, h]
34
- cells.positions [[x,y], ...] cell 左上角
35
- cells.sizes [[w,h], ...] cell 尺寸
36
- text_zones.title {lines: [...], size, color, stroke_w, stroke_color, font}
37
- text_zones.caption {同上}
38
- """
39
- layout = json.loads(layout_path.read_text(encoding="utf-8"))
40
- # 约定:layout 必须放在 <skill>/layouts/ 下,字体路径相对 <skill>/ 解析
41
- skill_dir = layout_path.parent.parent
42
-
43
- # 文本行数 vs layout 配置行数:行多了 zip 会静默截断,显式 fail-fast
44
- # 错误信息用英文 — sys.exit 走 stderr,Windows GBK locale 下中文会乱码
45
- for zone_key, lines in [("title", title_lines), ("caption", caption_lines)]:
46
- zone = layout.get("text_zones", {}).get(zone_key)
47
- if not zone:
48
- continue
49
- max_lines = len(zone["lines"])
50
- if len(lines) > max_lines:
51
- sys.exit(f"too many {zone_key} lines: got {len(lines)}, layout supports {max_lines}")
52
-
53
- # Canvas
54
- canvas = Image.new("RGB", tuple(layout["canvas_size"]), "white")
55
-
56
- # Cells
57
- cell_count = len(layout["cells"]["positions"])
58
- if len(cell_paths) != cell_count:
59
- sys.exit(f"cell count mismatch: layout expects {cell_count}, got {len(cell_paths)}")
60
- for cp, pos, size in zip(cell_paths,
61
- layout["cells"]["positions"],
62
- layout["cells"]["sizes"]):
63
- img = Image.open(cp).convert("RGB").resize(tuple(size), Image.LANCZOS)
64
- canvas.paste(img, tuple(pos))
65
-
66
- draw = ImageDraw.Draw(canvas)
67
-
68
- # Text zones (title + caption 通用绘制)
69
- for zone_key, lines in [("title", title_lines), ("caption", caption_lines)]:
70
- if zone_key not in layout["text_zones"]:
71
- continue
72
- zone = layout["text_zones"][zone_key]
73
- font_path = skill_dir / zone["font"]
74
- font = ImageFont.truetype(str(font_path), zone["size"])
75
- for text, line_cfg in zip(lines, zone["lines"]):
76
- if not text:
77
- continue
78
- draw.text(
79
- tuple(line_cfg["position"]), text,
80
- fill=zone["color"], font=font,
81
- stroke_width=zone.get("stroke_w", 0),
82
- stroke_fill=zone.get("stroke_color", zone["color"]),
83
- anchor=line_cfg.get("anchor", "la"),
84
- )
85
-
86
- canvas.save(output_path, optimize=True)
87
- return output_path
88
-
89
-
90
- def main():
91
- ap = argparse.ArgumentParser(description=__doc__,
92
- formatter_class=argparse.RawDescriptionHelpFormatter)
93
- ap.add_argument("--layout", required=True, type=Path,
94
- help="layout.json 路径(如 SKILL_DIR/layouts/2x2.json)")
95
- ap.add_argument("--cells", required=True, nargs="+", type=Path,
96
- help="N 张 cell 图片路径,顺序对应 layout.cells.positions")
97
- ap.add_argument("--title-line", action="append", default=[],
98
- help="标题行(可重复)")
99
- ap.add_argument("--caption-line", action="append", default=[],
100
- help="底部 caption 行(可重复)")
101
- ap.add_argument("--output", required=True, type=Path,
102
- help="输出 PNG 路径")
103
- args = ap.parse_args()
104
-
105
- out = compose(
106
- layout_path=args.layout,
107
- cell_paths=args.cells,
108
- title_lines=args.title_line,
109
- caption_lines=args.caption_line,
110
- output_path=args.output,
111
- )
112
- print(f"saved {out} ({out.stat().st_size // 1024} KB)")
113
-
114
-
115
- if __name__ == "__main__":
116
- main()
@@ -1,144 +0,0 @@
1
- ---
2
- name: video-compose
3
- description: "把【多个素材片段 + 一段口播文案】自动合成一条可发布短视频——自动配音、按文案语义选片拼接、烧字幕、加 BGM。用户只需拍素材/用 AI 生成片段 + 写文案,剪辑配音字幕全交给 AI。
4
-
5
- 必备前提:用户有 ≥1 个视频片段文件 + 一段口播文案(或愿意现写)。
6
-
7
- 触发:用户给出多个视频片段 + 文案,并说'拼个视频'/'把这堆素材剪一下'/'给这段文案配视频'/'做条口播带画面的视频'/'素材+文案做成片'。
8
-
9
- 不触发:只给 1 个口播 talking-head 视频要剪(用 video-edit);只给产品图要生成画面(用 video-gen);用户要【保留片段原声】拼接(用 video-edit,本 skill 会丢弃原声)。"
10
- version: 1.0.0
11
- owner_repo: Optima-Chat/optima-gen
12
- ---
13
-
14
- # video-compose — 素材 + 文案 → 成片
15
-
16
- 用户给一堆片段 + 一段口播稿,你交付配好音、配好画面、带字幕和 BGM 的竖版成片。
17
-
18
- > ⚠ **语义前提:片段被当作纯画面 b-roll,原声一律丢弃**,成片音频 = AI 配音 + BGM。若用户给的是带人声的口播片段、想**保留原声**拼接,那是 video-edit 的活——开工前跟用户确认一句"片段原声会去掉、全程用 AI 配音",避免出片后才发现声音没了。
19
-
20
- **关键:选片这一步由你(Claude)亲自看帧判断**——脚本把每个 clip 抽 3 帧,你用 Read 看图,按文案语义写 `proposal.json`。不需要任何外部 vision API。
21
-
22
- ## 工具
23
-
24
- `python3 $CLAUDE_SKILL_DIR/scripts/video_compose.py <frames|build> <proj-dir>`
25
- - 依赖(容器自带):`python3`、`ffmpeg`、`gen` CLI。
26
- - 情感配音走 `gen tts --provider minimax`(key + 计费在后端 optima-generation,skill 不碰密钥)。
27
-
28
- ## 工作目录
29
-
30
- ```
31
- <proj>/inputs/clips/*.mp4 素材(任意命名,按文件名排序得 clip id)
32
- <proj>/inputs/script.txt 口播稿,每行一句 = 一个 segment
33
- <proj>/work/ 中间产物(frames/ proposal.json subs.ass 等)
34
- <proj>/final.mp4 成片
35
- ```
36
-
37
- ## Step 0:指令清单读回(≥ 2 个动作时必跑)
38
-
39
- 用户一条消息里给多个要求(如"竖版 + 不要 BGM + 字幕大一点 + 压到 20 秒")时,**先拆成原子清单读回、等确认再动手**,不要边读边做。单一动作("拼个视频")跳过。
40
- (理由同 video-edit:多指令直接执行易漏,漏了要等成片出来才发现,全流程重做。)
41
-
42
- ## 主流程
43
-
44
- ### 1. 建工程 + 落素材和文案
45
- - 建 `<proj>/inputs/clips/` 和 `<proj>/inputs/script.txt`
46
- - 把用户的片段拷进 clips/(命名随意,建议 `01.mp4 02.mp4 …` 便于引用)
47
- - 文案写进 script.txt,**每行一句**。用户没给文案就先帮他写(看素材定主题),写完**先给用户确认文案**再继续。
48
-
49
- ### 2. 抽帧
50
- ```
51
- python .../video_compose.py frames <proj>
52
- ```
53
- 产出 `work/frames/<clipid>_<tag>.jpg`(每片自适应抽 3~6 帧,约每 5s 一帧)+ `work/clips_manifest.json`。**manifest 里每帧带 `t`(秒)= 该子镜头在素材中的时间点**——写 proposal 时用它指定 `src_start`。
54
-
55
- ### 3. 看帧 + 写 proposal.json(**你的核心判断**)
56
- - 用 **Read 逐张看** `work/frames/` 里的帧,在心里给每个 clip 一句话描述(人物/动作/场景/有无烧死字幕)。
57
- - 按 script 每句的语义,给它挑最贴合的 clip,写出 `work/proposal.json`:
58
-
59
- ```json
60
- {
61
- "voice": "Chinese (Mandarin)_Warm_Girl",
62
- "bgm_mood": "warm",
63
- "assignments": [
64
- { "segment_idx": 0, "text": "第一句原文", "clip": "01", "src_start": 5.2, "emotion": "sad", "speed": 0.92, "rationale": "为什么选它 + 为什么这个子镜头 + 为什么这个情绪" }
65
- ]
66
- }
67
- ```
68
-
69
- - **voice**:voice_id。**只用 `voice-samples/CATALOG.md` 里 7 个实测可用的音色**(标签 ↔ voice_id),别填没验证过的(错的 voice_id 会 `UPSTREAM_UNKNOWN: voice id not exist` 失败)。默认 `Chinese (Mandarin)_Warm_Girl`(温暖少女)。**具体音色让用户试听后定,见 §4。**
70
- - **clip**:素材 id(manifest 里的 `id`)
71
- - **src_start**:从该素材的**哪一秒**开始切(= 你选中那个子镜头帧的 `t`,看 manifest)。**这是避免重复镜头的关键**:同一 clip 给多句复用时,每句填**不同的 `t`**(如倒水特写 t=11.5、碰杯 t=20.7),脚本以该时刻为中心切,画面就不会重复。不填则脚本按复用顺序自动均匀错开(也不会重复,但选的子镜头不一定贴文案,所以建议显式填)。
72
- - **emotion**:每句独立,九选一 `happy/sad/angry/fearful/disgusted/surprised/calm/fluent/whisper`,按文案情绪配
73
- - **speed**:0.5–2.0。**这是抖音/TikTok/小红书短视频工具,默认就要快**——不写 speed 脚本按 `DEFAULT_SPEED=1.35` 配音(≈TikTok 口播节奏);想更冲可显式写 1.5;**只有明确要治愈/抒情慢节奏才写 ≤1.0**(如 0.9)。别让成片听起来拖沓。
74
- - **bgm_mood / bgm**:见 §BGM(用户没指定就按文案情绪填 `bgm_mood`)
75
-
76
- 选片规则:
77
- - 首句优先用能建立场景/正面的镜头
78
- - 时序上有视觉故事线就尊重它
79
- - **clip 可复用,但复用时必须给不同的 `src_start`(选不同子镜头),否则画面重复**;也尽量别连续两句用同一 clip
80
- - **每个 clip 通常有多个子镜头(manifest 多帧)——优先把不同子镜头分给不同句子,让素材都出镜,而不是反复用片头**
81
- - 素材数 < 句子数时复用并提示用户素材偏少
82
- - **若某 clip 帧里有烧死的字幕/水印**(见 §坑),尽量不选它承载关键句;非用不可则提示用户
83
-
84
- 情绪配法:按文案的情感弧线给每句配 emotion,不要全句一个调。例:怅然开场 `sad` → 转折欣喜 `happy` → 高潮 `surprised` → 舒缓 `calm` → 暖心收尾 `happy`。
85
-
86
- 节奏(平台风格):这是短视频,默认**快节奏**(像抖音/TikTok 口播)。文案**短句、强钩子、句子别太长**(一句太长配音就拖、画面也切得慢);语速默认 1.35,想更冲可更快。除非用户明确要慢节奏治愈片,否则别配出"念课文"那种拖沓感。
87
-
88
- ### 4. 试听音色 / BGM + 看选片方案 → 用户拍板
89
- **音色和 BGM 是创作决策,让用户自己听、自己定,不要替用户默认死。** 出片前:
90
-
91
- > **素材路径**:BGM 库和音色样音随 `gen-cli` 一起发布(不在 skill 目录)。先解析一次:
92
- > ```bash
93
- > ASSETS=$(python3 $CLAUDE_SKILL_DIR/scripts/video_compose.py assets-dir)
94
- > ```
95
- > 下面用 `$ASSETS/voice-samples`、`$ASSETS/bgm-library/<mood>`。
96
-
97
- 1. **音色试听**:把 `$ASSETS/voice-samples/*.mp3`(7 个音色样音,标签↔voice_id 见同目录 `CATALOG.md`)拷到 `<proj>/previews/`,把可播放的文件链接列给用户,让用户**听完选一个**。
98
- 2. **BGM 试听**:按文案情绪先推荐一类(治愈→`warm`、种草→`upbeat`…),把该类(用户想多听就多拷几类)`$ASSETS/bgm-library/<mood>/*.mp3` 拷到 `<proj>/previews/` 给用户试听;用户可换类、指定某首,或**自己上传一首**(放 `inputs/bgm/`,优先级最高)。
99
- 3. **选片方案**:把 assignments 摘要(每句哪个 clip + 理由)一并给用户过目。
100
- 4. **给推荐默认**(音色=贴文案的一个、BGM=按情绪一类),用户想省事一句「就用默认」即可直接出片;想换就听了再定。
101
-
102
- 用户拍板后:选定的 voice_id 写进 `proposal.voice`、BGM 写进 `bgm`(自带路径) 或 `bgm_mood`(情绪库),再:
103
- ```
104
- python3 $CLAUDE_SKILL_DIR/scripts/video_compose.py build <proj>
105
- ```
106
- 脚本做:逐句情感配音(带缓存,未改的句子不重跑)→ 每句按 `src_start` 用 `-ss` **精切对应子镜头**到该句时长(竖版 crop,clip 比该句短则**慢放填满、不 loop**;**同素材复用强制时间窗不重叠,防重复镜头**)→ 拼接 → 烧字幕 → BGM sidechain ducking(有 BGM 才加)→ `final.mp4`。
107
-
108
- ### 5. 交付汇报
109
- 报成片路径 + 时长 + 用了几个 clip + 配音字数。**不要提任何模型/服务名**,配音统一说"AI 配音"。
110
-
111
- ## BGM(不锁死;用户没指定就按文案情绪自动配)
112
- 来源优先级:
113
- 1. `proposal["bgm"]` 显式路径(用户明确指定某首)
114
- 2. `inputs/bgm/` 用户上传的音频
115
- 3. `proposal["bgm_mood"]` → 从情绪库 `bgm-library/<mood>/` **随机挑一首**(用户没指定 BGM 时,**你按文案整体情绪填这个字段**)
116
- 4. 都没有 → 仅人声
117
-
118
- - 有 BGM 时自动 sidechain ducking(人声起时压低 BGM)
119
- - **优先让用户试听后选**(见 §4 步骤 2);用户说「你定/随便」时才按文案情绪自动填 `bgm_mood`,**绝不留空**。情绪 → 目录:`warm`(治愈/温暖) `upbeat`(欢快) `sad`(伤感) `calm`(舒缓) `energetic`(高能/卖货) `dramatic`(戏剧/科技)
120
- - 用户明确要某首 / 要换 → 用优先级 1、2
121
- - ⚠ 库里只能放**可商用授权**的曲子(公开发布视频有版权要求);某情绪目录为空时该句跳过 BGM 并提示
122
-
123
- ## 用户改方案怎么办
124
- - 换某句的画面:改 `proposal.json` 那条的 `clip`(换素材)或 `src_start`(同素材换子镜头),重跑 `build`
125
- - 嫌某两句画面重复:给它们填不同的 `src_start`(看 manifest 的帧 `t`),重跑 `build`
126
- - 换情绪/音色:改 `emotion`/`voice`,重跑 `build`
127
- - 换/加 BGM:丢文件进 `inputs/bgm/` 或改 `bgm` 路径,重跑 `build`
128
- **配音有缓存**(engine/voice/emotion/speed/text 没变就复用),所以单纯换 BGM/字幕**不重新花钱跑配音**,秒出。`proposal.json` 就是反复调的抓手。
129
-
130
- ## 坑(实跑踩过)
131
-
132
- | 坑 | 处理 |
133
- |---|---|
134
- | **用户素材自带烧死字幕/水印** | 你看帧时若发现某片已有硬字幕(如原片烧了英文 caption),新字幕叠上去会重影。承载关键句时避开该片,或提示用户该片有原生字幕 |
135
- | **clip 是横版** | 脚本默认 center-crop 到竖版会裁掉两侧;横版素材多时提示用户成片是竖版裁切 |
136
- | **素材总时长 < 文案配音时长** | 不再 loop(loop=重复):单片比某句短→慢放填满;某片被多句复用但时长不够 distinct 画面→`[mix] ⚠` 告警。**要彻底不重复**:素材总时长应 ≥ 配音总时长,且每片别被复用超过它能切出的 distinct 段数;不够就提示用户补素材或精简文案 |
137
- | **字幕样式/字体** | 默认白字黑边底部。字体走 env `VIDEO_COMPOSE_FONT`(默认 `Noto Sans CJK SC`)。prod shell 镜像(optima-ai-shell 根 `Dockerfile`,Ubuntu 22.04)已装 `fonts-noto-cjk`(提供 `Noto Sans CJK SC`)+ Source Han Sans SC,CJK 字体确认可用。`build` 前 `_preflight_font()` 会 `fc-match` 兜底,缺字体 fail-loud 不静默渲染豆腐块。换镜像/换 env 字体时按此 fc-match 验证 |
138
- | **gen tts 报错** | 透传后端错误。`PROVIDER_INSUFFICIENT_CREDITS`=MiniMax 余额;`INVALID_INPUT`=emotion/voice 非法。配音失败不出片,不静默吞 |
139
-
140
- ## 关键参数(scripts/video_compose.py 顶部)
141
- - `W,H=1080,1920`(竖版)、`FPS=30`、`CRF=20`
142
- - 配音:`gen tts --provider minimax`,密钥/计费在后端,skill 不碰密钥
143
- - `VIDEO_COMPOSE_FONT`:字幕字体(容器需有对应 CJK 字体)
144
- - BGM 不锁死:见 §BGM;情绪库随 `gen-cli` 打包,脚本自动解析(`$ASSETS/bgm-library/<mood>/`,见 §4 素材路径)
@@ -1,290 +0,0 @@
1
- #!/usr/bin/env python3
2
- """video-compose — 素材片段 + 口播文案 → 成片(情感配音/选片/字幕/BGM 全自动)
3
-
4
- 容器版:TTS 走 `gen tts --provider minimax`(key + 计费在后端 optima-generation),
5
- 不直连 MiniMax、不在容器放密钥。依赖:python3 + ffmpeg + `gen` CLI(容器自带)。
6
-
7
- 两个命令,中间由 Claude 看帧写 proposal.json:
8
- python3 $CLAUDE_SKILL_DIR/scripts/video_compose.py frames <proj>
9
- # -> Claude Read 每帧,按 script.txt 写 <proj>/work/proposal.json
10
- python3 $CLAUDE_SKILL_DIR/scripts/video_compose.py build <proj>
11
-
12
- 工程目录:
13
- <proj>/inputs/clips/*.mp4 素材(任意命名,按文件名排序得 clip id)
14
- <proj>/inputs/script.txt 口播稿,每行一句 = 一个 segment
15
- <proj>/inputs/bgm/ (可选) 用户上传的 BGM
16
- <proj>/work/ 中间产物
17
- <proj>/final.mp4 成片
18
- """
19
- import json, subprocess, sys, os, random, shutil, hashlib
20
- from pathlib import Path
21
-
22
- # 脚本日志含大量中文:统一 stdout/stderr 为 UTF-8,避免非 UTF-8 locale 下一行 print
23
- # 抛 UnicodeEncodeError 连带杀掉整个 build(容器默认 UTF-8,此处是兜底)。
24
- try:
25
- sys.stdout.reconfigure(encoding="utf-8"); sys.stderr.reconfigure(encoding="utf-8")
26
- except Exception:
27
- pass
28
-
29
- W, H, FPS, CRF = 1080, 1920, 30, 20
30
- # 字幕字体:容器需有 CJK 字体;可用 env 覆盖。fc-match 验证见 SKILL §坑。
31
- SUB_FONT = os.environ.get("VIDEO_COMPOSE_FONT", "Noto Sans CJK SC")
32
- # 情绪 BGM 库 + 音色样音:随 @optima-chat/gen-cli npm 包一起发布(plugin 的
33
- # ensure-cli.sh 在 SessionStart 装它),不再放 skill 目录里——否则 5.3MB BGM 会把
34
- # plugin 的 thin tar 撑过上限(见 optima-gen #<this PR>)。解析顺序:
35
- # 1. VIDEO_COMPOSE_ASSETS 显式覆盖
36
- # 2. 已安装的 gen-cli 包内 assets/video-compose/
37
- # 3. skill 目录(optima-agent baked 老布局 / 本地开发的兜底)
38
- def _asset_root() -> Path:
39
- # 资产(bgm-library/voice-samples)随 @optima-chat/gen-cli 发布在 <gen-cli>/assets/video-compose。
40
- # gen-cli 在 prod 里可能:(a) 被 baked 进 optima-agent(PATH 上的 `gen` 是个薄 wrapper
41
- # `…/optima-agent/dist/bin/gen.js`,嵌套的 gen-cli 在 …/optima-agent/node_modules/@optima-chat/gen-cli),
42
- # (b) 由 plugin ensure-cli 装进 $CLAUDE_PLUGIN_DATA,(c) 直接是 gen-cli 自己的 bin。
43
- # 因 wrapper 深度不定,从 `gen` 的真实路径**逐层往上**找,每层试 <anc>/assets/video-compose
44
- # 和 <anc>/node_modules/@optima-chat/gen-cli/assets/video-compose,谁存在用谁。
45
- REL = Path("assets") / "video-compose"
46
- NESTED = Path("node_modules") / "@optima-chat" / "gen-cli" / REL
47
-
48
- env = os.environ.get("VIDEO_COMPOSE_ASSETS")
49
- if env and Path(env).exists():
50
- return Path(env)
51
-
52
- cpd = os.environ.get("CLAUDE_PLUGIN_DATA")
53
- if cpd:
54
- c = Path(cpd) / NESTED
55
- if c.exists():
56
- return c
57
-
58
- gen = shutil.which("gen")
59
- if gen:
60
- p = Path(os.path.realpath(gen))
61
- for anc in [p] + list(p.parents):
62
- for c in (anc / REL, anc / NESTED):
63
- if c.exists():
64
- return c
65
-
66
- # 兜底:skill 目录(optima-agent baked 老布局 / 本地开发;可能没有资产,_resolve_bgm 会优雅跳过)
67
- return Path(os.environ.get("CLAUDE_SKILL_DIR", Path(__file__).resolve().parent.parent))
68
-
69
- ASSET_ROOT = _asset_root()
70
- BGM_LIBRARY = ASSET_ROOT / "bgm-library"
71
- _AUDIO_EXT = (".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg")
72
- DEFAULT_VOICE = "Chinese (Mandarin)_Warm_Girl"
73
- # 默认明快语速:本工具是抖音/TikTok/小红书短视频出片,语速要快才像平台口播。
74
- # 1.35 ≈ TikTok 口播那种节奏(实测+用户拍板)。单句想放慢(治愈/抒情)在 proposal
75
- # 那句写更低的 speed 覆盖即可。
76
- DEFAULT_SPEED = 1.35
77
-
78
- def run(cmd):
79
- r = subprocess.run([str(c) for c in cmd], capture_output=True, text=True)
80
- if r.returncode != 0:
81
- print("CMD FAIL:", " ".join(str(c) for c in cmd)); print(r.stderr[-2000:]); sys.exit(1)
82
- return r
83
-
84
- def probe_dur(path):
85
- return float(run(["ffprobe","-v","error","-show_entries","format=duration","-of","csv=p=0",path]).stdout.strip())
86
-
87
- def list_clips(proj):
88
- return sorted((proj/"inputs"/"clips").glob("*.mp4"), key=lambda p: p.name)
89
-
90
- def read_segments(proj):
91
- txt=(proj/"inputs"/"script.txt").read_text(encoding="utf-8")
92
- return [ln.strip() for ln in txt.splitlines() if ln.strip()]
93
-
94
- # ---------- frames:抽帧给 Claude 看 ----------
95
- def cmd_frames(proj):
96
- """每个素材自适应抽 3~6 帧(约每 5s 一帧),manifest 记录每帧的**时间戳 t(秒)**。
97
- Claude 看帧后在 proposal 每句写 `src_start`= 选中那个子镜头帧的 t;同一素材被多句复用时
98
- 选**不同的 t**,build 据此精确切不同子镜头,避免重复镜头(见 build 的 _resolve_windows)。"""
99
- fdir=proj/"work"/"frames"; fdir.mkdir(parents=True, exist_ok=True)
100
- clips=list_clips(proj)
101
- manifest={"clips":[], "segments":read_segments(proj)}
102
- for p in clips:
103
- cid=p.stem; dur=probe_dur(p); frames=[]
104
- n=max(3, min(6, int(dur//5)+1)) # 自适应帧数:短片 3 帧,长片至多 6 帧
105
- for j in range(n):
106
- t=dur*(j+0.5)/n # 每帧落在等分泳道中心,代表一个子镜头
107
- tag=chr(ord('a')+j)
108
- out=fdir/f"{cid}_{tag}.jpg"
109
- run(["ffmpeg","-v","error","-ss",f"{t:.2f}","-i",p,"-frames:v","1","-q:v","3",out,"-y"])
110
- frames.append({"tag":tag,"t":round(t,2),"path":str(out)})
111
- manifest["clips"].append({"id":cid,"duration_s":round(dur,2),"frames":frames})
112
- (proj/"work"/"clips_manifest.json").write_text(json.dumps(manifest,ensure_ascii=False,indent=2),encoding="utf-8")
113
- nframes=sum(len(c["frames"]) for c in manifest["clips"])
114
- print(f"[frames] {len(clips)} clips / {nframes} 帧(含时间戳 t)-> {fdir}")
115
- print(f"[frames] segments: {len(manifest['segments'])} 句;下一步 Claude 看帧写 proposal.json")
116
- print(f"[frames] 提示:每句 assignment 写 src_start=选中帧的 t;同素材复用请选不同 t(防重复镜头)")
117
-
118
- # ---------- TTS:gen tts --provider minimax(key/计费在后端)----------
119
- def gen_tts(text, voice, emotion, speed, out):
120
- cmd=["gen","tts",text,"--provider","minimax","--voice",voice,"-o",str(out)]
121
- if emotion: cmd+=["--emotion",emotion]
122
- if speed is not None: cmd+=["--speed",str(speed)]
123
- r=subprocess.run(cmd, capture_output=True, text=True)
124
- if r.returncode!=0 or not Path(out).exists():
125
- print("TTS FAIL:", " ".join(cmd)); print((r.stderr or r.stdout)[-1500:]); sys.exit(1)
126
-
127
- def _ass_time(t):
128
- h=int(t//3600); m=int((t%3600)//60); s=t%60
129
- return f"{h:d}:{m:02d}:{s:05.2f}"
130
-
131
- def _preflight_font():
132
- """字体预检(fail-loud):容器若无对应 CJK 字体,subtitles filter 会把中文渲染成豆腐块
133
- 且 ffmpeg 不报错(静默翻车)。这里用 fc-match 提前拦截。本地无 fc-match(如 Windows)则跳过。
134
- 放在 TTS 之前,未命中直接退出,不浪费配音扣费。"""
135
- if not shutil.which("fc-match"):
136
- return # 非 fontconfig 环境(如本地 Windows),跳过;容器有 fc-match
137
- try:
138
- # 显式 utf-8 + errors=replace:避免非 utf-8 locale(如 Windows gbk) 解码 fc-match 输出崩溃
139
- r=subprocess.run(["fc-match","-f","%{family}",SUB_FONT],
140
- capture_output=True,encoding="utf-8",errors="replace",timeout=10)
141
- except Exception:
142
- return # 检查器自身跑不了 → 不阻塞(best-effort 预检)
143
- got=(r.stdout or "").strip()
144
- if r.returncode!=0 or not got:
145
- return # 拿不到结果 → 不阻塞
146
- norm=lambda s: s.lower().replace(" ","")
147
- # fontconfig 命中已装字体时 family 原样返回;未装则回退(如 DejaVu)→ 与请求名不符 → 硬失败
148
- if norm(SUB_FONT) not in norm(got):
149
- print(f"ERR 字幕字体 '{SUB_FONT}' 未命中(fc-match 回退到 '{got}')——中文字幕会渲染成豆腐块。")
150
- print(f" 解决其一:容器装该 CJK 字体 / 设 VIDEO_COMPOSE_FONT 指向已装 CJK 字体(fc-match 报告的)/ 在 skill bundle 字体。")
151
- sys.exit(1)
152
-
153
- def _audio_in(d):
154
- d=Path(d)
155
- return sorted([p for p in d.iterdir() if p.suffix.lower() in _AUDIO_EXT]) if d.is_dir() else []
156
-
157
- def _resolve_bgm(proj, prop):
158
- """BGM 不锁死:proposal.bgm 路径 > inputs/bgm/ 上传 > bgm_mood 情绪库(确定性挑) > 无。
159
- 情绪库选曲按 proposal 内容做确定性 seed —— 同一项目重跑选同一首(稳定),不同项目才变化。"""
160
- if prop.get("bgm"): return prop["bgm"]
161
- up=_audio_in(proj/"inputs"/"bgm")
162
- if up: return str(up[0])
163
- mood=prop.get("bgm_mood")
164
- if mood:
165
- lib=_audio_in(BGM_LIBRARY/mood)
166
- if lib:
167
- sig=mood+"|"+"|".join(a.get("text","") for a in prop.get("assignments",[]))
168
- seed=int(hashlib.md5(sig.encode("utf-8")).hexdigest(),16)
169
- return str(random.Random(seed).choice(lib))
170
- print(f"[bgm] 情绪 '{mood}' 库内无曲({BGM_LIBRARY/mood}),跳过 BGM")
171
- return None
172
-
173
- # ---------- 镜头时间窗:同素材复用不重复 ----------
174
- def _has_overlap(intervals, eps=0.05):
175
- """intervals: [(start,end), ...];排序后判断是否有相邻区间重叠。"""
176
- s=sorted(intervals)
177
- return any(b0 < a1-eps for (a0,a1),(b0,b1) in zip(s, s[1:]))
178
-
179
- def _resolve_windows(segs, clips_dir):
180
- """算每句的切片起点,保证**同一素材被多句复用时时间窗不重叠**(消灭重复镜头)。
181
- 优先级:assignment.src_start(显式,以该时刻为子镜头中心) > 同素材内自动均匀错开(泳道)。
182
- 显式窗口若仍重叠 → 整组回退为均匀错开并提示。返回 starts[i]=第 i 句的切片起点(秒)。"""
183
- by_clip={}
184
- for i,s in enumerate(segs):
185
- by_clip.setdefault(s["clip"],[]).append(i)
186
- starts=[0.0]*len(segs)
187
- for clip,idxs in by_clip.items():
188
- cdur=probe_dur(clips_dir/f"{clip}.mp4"); k=len(idxs)
189
- spans=[min(segs[i]["dur"], cdur) for i in idxs]
190
- if k>1 and sum(spans) > cdur+0.1: # 该片被复用所需的不同画面总时长 > 它本身时长
191
- print(f"[mix] 警告: 素材 {clip} 仅 {cdur:.1f}s,被 {k} 句复用共需 {sum(spans):.1f}s 不同画面——"
192
- f"时长不够,可能仍有重复。建议多给素材,或减少该片复用。")
193
- def lane(order): # 第 order 条均匀落在第 order 个泳道中心
194
- c=(order+0.5)*cdur/k; sp=spans[order]
195
- return max(0.0, min(c-sp/2, max(0.0, cdur-sp)))
196
- st=[]
197
- for order,i in enumerate(idxs):
198
- ss=segs[i].get("src_start"); sp=spans[order]
199
- st.append(max(0.0, min(float(ss)-sp/2, max(0.0, cdur-sp))) if ss is not None else lane(order))
200
- if k>1 and _has_overlap([(st[o], st[o]+spans[o]) for o in range(k)]):
201
- st=[lane(o) for o in range(k)]
202
- print(f"[mix] 素材 {clip} 被 {k} 句复用且窗口重叠/未指定 → 自动均匀错开,避免重复镜头")
203
- for order,i in enumerate(idxs): starts[i]=round(st[order],3)
204
- return starts
205
-
206
- # ---------- build:proposal.json -> final.mp4 ----------
207
- def cmd_build(proj):
208
- work=proj/"work"; clips_dir=proj/"inputs"/"clips"
209
- prop=json.loads((work/"proposal.json").read_text(encoding="utf-8"))
210
- voice=prop.get("voice", DEFAULT_VOICE)
211
- asg=prop["assignments"]
212
- avail={p.stem for p in list_clips(proj)}
213
- for a in asg:
214
- if a["clip"] not in avail:
215
- print(f"ERR seg{a['segment_idx']} clip '{a['clip']}' 不存在。可用: {sorted(avail)}"); sys.exit(1)
216
-
217
- _preflight_font() # 字体不行就早退,别先花钱配音
218
-
219
- # 1) 逐句情感配音(带缓存:engine/voice/emotion/speed/text 未变则复用,不重复扣费)
220
- segs=[]; t0=0.0
221
- for a in asg:
222
- i=a["segment_idx"]; mp3=work/f"vo_{i:02d}.mp3"; keyf=work/f"vo_{i:02d}.key"
223
- spd=a.get("speed") if a.get("speed") is not None else DEFAULT_SPEED # 默认明快,适配短视频平台
224
- ck=f"minimax|{a.get('voice',voice)}|{a.get('emotion')}|{spd}|{a['text']}"
225
- if not (mp3.exists() and keyf.exists() and keyf.read_text(encoding="utf-8")==ck):
226
- gen_tts(a["text"], a.get("voice",voice), a.get("emotion"), spd, mp3)
227
- keyf.write_text(ck,encoding="utf-8")
228
- d=probe_dur(mp3)
229
- segs.append({**a,"audio":mp3,"start":t0,"end":t0+d,"dur":d}); t0+=d
230
- total=t0; print(f"[voiceover] {len(segs)} 句 / {total:.2f}s")
231
-
232
- # 2) 配音轨
233
- (work/"vo_list.txt").write_text("".join(f"file '{s['audio'].as_posix()}'\n" for s in segs),encoding="utf-8")
234
- voiceover=work/"voiceover.m4a"
235
- run(["ffmpeg","-y","-f","concat","-safe","0","-i",work/"vo_list.txt","-c:a","aac","-b:a","192k",voiceover])
236
-
237
- # 3) 切片对齐(竖版 crop;按 _resolve_windows 用 -ss 精切不同子镜头,防重复)
238
- # 素材比该句短时:慢放填满(setpts),**不 loop**——loop 会在一句内重复画面。
239
- starts=_resolve_windows(segs, clips_dir)
240
- seg_mp4s=[]
241
- base_vf=f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H}"
242
- for k,s in enumerate(segs):
243
- src=clips_dir/f"{s['clip']}.mp4"; dur=s["dur"]; st=starts[k]; cdur=probe_dur(src)
244
- out=work/f"seg_{s['segment_idx']:02d}.mp4"
245
- if cdur < dur-0.05: # 整片比该句短 → 慢放至该句时长,无重复
246
- vf=f"{base_vf},setpts=PTS*{dur/cdur:.4f},fps={FPS},setsar=1"; ss=[]
247
- else:
248
- vf=f"{base_vf},fps={FPS},setsar=1"; ss=["-ss",f"{st:.3f}"]
249
- run(["ffmpeg","-y",*ss,"-i",src,"-t",f"{dur:.3f}","-an","-vf",vf,
250
- "-c:v","libx264","-crf",CRF,"-pix_fmt","yuv420p",out])
251
- seg_mp4s.append(out)
252
- (work/"v_list.txt").write_text("".join(f"file '{p.as_posix()}'\n" for p in seg_mp4s),encoding="utf-8")
253
- silent=work/"video_silent.mp4"
254
- run(["ffmpeg","-y","-f","concat","-safe","0","-i",work/"v_list.txt","-c","copy",silent])
255
-
256
- # 4) 字幕 ASS(Format 与 Dialogue 字段数必须一致)
257
- head=("[Script Info]\nScriptType: v4.00+\nPlayResX: %d\nPlayResY: %d\n\n"
258
- "[V4+ Styles]\nFormat: Name,Fontname,Fontsize,PrimaryColour,OutlineColour,BackColour,Bold,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding\n"
259
- "Style: D,%s,58,&H00FFFFFF,&H00000000,&H64000000,1,3,1,2,40,40,180,1\n\n"
260
- "[Events]\nFormat: Layer,Start,End,Style,Text\n") % (W,H,SUB_FONT)
261
- body="\n".join(f"Dialogue: 0,{_ass_time(s['start'])},{_ass_time(s['end'])},D,{s['text']}" for s in segs)
262
- ass=work/"subs.ass"; ass.write_text(head+body+"\n",encoding="utf-8")
263
- ass_esc=ass.as_posix().replace(":","\\:")
264
-
265
- # 5) BGM ducking + 烧字幕 -> final
266
- bgm=_resolve_bgm(proj, prop); final=proj/"final.mp4"
267
- if bgm and Path(bgm).exists():
268
- print(f"[bgm] {bgm}")
269
- fc=("[2:a]aloop=loop=-1:size=2e9,volume=0.25[bg];[1:a]asplit=2[vo][sc];"
270
- "[bg][sc]sidechaincompress=threshold=0.02:ratio=8:attack=5:release=300[bgd];"
271
- "[vo][bgd]amix=inputs=2:duration=first:normalize=0[aout]")
272
- run(["ffmpeg","-y","-i",silent,"-i",voiceover,"-i",bgm,
273
- "-filter_complex",fc+f";[0:v]subtitles='{ass_esc}'[v]",
274
- "-map","[v]","-map","[aout]","-t",f"{total:.3f}",
275
- "-c:v","libx264","-crf",CRF,"-pix_fmt","yuv420p","-c:a","aac","-b:a","192k","-shortest",final])
276
- else:
277
- print("[bgm] none(用户未提供且 proposal 未设 bgm_mood,仅人声)")
278
- run(["ffmpeg","-y","-i",silent,"-i",voiceover,"-vf",f"subtitles='{ass_esc}'",
279
- "-map","0:v","-map","1:a","-c:v","libx264","-crf",CRF,"-pix_fmt","yuv420p","-c:a","aac",final])
280
- print(f"[done] {final} ({probe_dur(final):.2f}s)")
281
-
282
- if __name__=="__main__":
283
- # `assets-dir` prints the resolved BGM/voice asset root (so SKILL.md resolves it
284
- # the same way this script does). No project arg needed.
285
- if len(sys.argv)>=2 and sys.argv[1]=="assets-dir":
286
- print(ASSET_ROOT); sys.exit(0)
287
- if len(sys.argv)<3:
288
- print("usage: video_compose.py [frames|build|assets-dir] <proj-dir>"); sys.exit(1)
289
- cmd, proj = sys.argv[1], Path(sys.argv[2]).resolve()
290
- {"frames":cmd_frames,"build":cmd_build}[cmd](proj)