reelforge 1.21.0 → 1.23.0

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.
@@ -0,0 +1,154 @@
1
+ import fs from "node:fs/promises";
2
+ import fsSync from "node:fs";
3
+ import path from "node:path";
4
+ import { post } from "../client.js";
5
+ import { waitForTask } from "../utils/task-waiter.js";
6
+ import { downloadTo } from "../utils/download.js";
7
+ import { formatUsd, humanDuration, info, isJson, print, success } from "../utils/output.js";
8
+ const VIDEO_EXT_TO_MIME = {
9
+ ".mp4": "video/mp4",
10
+ ".mov": "video/quicktime",
11
+ ".webm": "video/webm",
12
+ ".mkv": "video/x-matroska",
13
+ ".m4v": "video/mp4",
14
+ };
15
+ const MAX_BYTES = 55 * 1024 * 1024;
16
+ function fmtSize(bytes) {
17
+ if (!bytes)
18
+ return "0";
19
+ if (bytes < 1024)
20
+ return `${bytes} B`;
21
+ if (bytes < 1024 * 1024)
22
+ return `${(bytes / 1024).toFixed(1)} KB`;
23
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
24
+ }
25
+ export function registerDub(program) {
26
+ program
27
+ .command("dub <video>")
28
+ .description("⚠️ EXPERIMENTAL · 一个原始视频 → 自动反推内容 + 写解说 + 熊小二配音 + 字幕,输出成片")
29
+ .helpOption("-h, --help", "show help")
30
+ .option("-o, --output <file>", "输出 MP4 路径 (默认: <视频名>-dub.mp4, 放在当前目录)")
31
+ .option("--voice <id>", "配音音色 id (index-tts-2 音色名, 默认 熊小二)", "熊小二")
32
+ .option("--tts-model <id>", "TTS 模型 (默认 vox/index-tts-2)", "vox/index-tts-2")
33
+ .option("--game-volume <n>", "原视频自带声音的音量 0..1 (默认 0.6; 设 0 = 完全静音原声, 只留配音)", (v) => parseFloat(v), 0.6)
34
+ .option("--no-subtitles", "不烧录字幕 (默认烧录)")
35
+ .option("--subtitle-style <style>", "字幕样式: plate | stroke | cinema (默认 plate)", "plate")
36
+ .option("--theme <id>", "可选主题 id (影响字幕底板 / 配色风格)")
37
+ .option("--vision-model <id>", "反推用的视觉模型 (默认 qwen/qwen3-vl-flash)")
38
+ .option("--script-model <id>", "写解说脚本用的 LLM (默认服务端配置模型)")
39
+ .option("--preview-only", "只生成 preview, 不渲染最终 MP4 (更快更省, 用于试效果)")
40
+ .option("--poll-ms <ms>", "轮询间隔", (v) => parseInt(v, 10), 1500)
41
+ .option("--timeout-ms <ms>", "最长等待时间", (v) => parseInt(v, 10))
42
+ .addHelpText("after", [
43
+ "",
44
+ "它做了什么 (全部在服务端完成):",
45
+ " 1. 反推视频内容 — 详细、带时间段的分镜 + 屏幕文字",
46
+ " 2. 根据视频时长写一段贴合画面的解说脚本 (卡住时长, 画面演到哪讲到哪)",
47
+ " 3. 用熊小二音色合成配音 (可 --voice 指定其它音色)",
48
+ " 4. 把配音叠到原视频上, 原视频自带声音压到 60% 垫底 (可 --game-volume 调)",
49
+ " 5. 烧录字幕 (复用 compose 的字幕样式 + 切分逻辑; --no-subtitles 关闭)",
50
+ "",
51
+ "认证:",
52
+ " 需要 API key — `rf login <key>` 或 $REELFORGE_API_KEY 或 --api-key <key>。",
53
+ " 没有 key 服务端会拒绝 (401)。",
54
+ "",
55
+ "支持的输入扩展名: .mp4 .mov .webm .mkv .m4v (单文件 ≤ 55MB)",
56
+ "",
57
+ "可用音色: `rf tts voices` (熊小二 / 激情熊大 / 专业解说 等 149 个)",
58
+ "",
59
+ "示例:",
60
+ " # 最简单: 一个视频进, 配音+字幕成片出 (默认熊小二, 原声 60%)",
61
+ " rf dub ./clip.mp4",
62
+ "",
63
+ " # 指定输出路径 + 换个音色 + 原声调小到 30%",
64
+ " rf dub ./clip.mp4 -o ./out.mp4 --voice 激情熊大 --game-volume 0.3",
65
+ "",
66
+ " # 不要字幕",
67
+ " rf dub ./clip.mp4 --no-subtitles",
68
+ "",
69
+ " # 批量 (PowerShell): 对一个目录里所有 mp4 串行处理",
70
+ " Get-ChildItem *.mp4 | ForEach-Object { rf dub $_.FullName }",
71
+ "",
72
+ " # 先看效果再决定 (preview, 不渲染 MP4)",
73
+ " rf dub ./clip.mp4 --preview-only",
74
+ ].join("\n"))
75
+ .action(async (videoArg, opts) => {
76
+ const abs = path.resolve(videoArg);
77
+ if (!fsSync.existsSync(abs))
78
+ throw new Error(`video not found: ${abs}`);
79
+ const ext = path.extname(abs).toLowerCase();
80
+ const mime = VIDEO_EXT_TO_MIME[ext];
81
+ if (!mime) {
82
+ throw new Error(`unsupported video extension "${ext}" (supported: ${Object.keys(VIDEO_EXT_TO_MIME).join(" / ")})`);
83
+ }
84
+ const gameVolume = opts.gameVolume ?? 0.6;
85
+ if (!Number.isFinite(gameVolume) || gameVolume < 0 || gameVolume > 1) {
86
+ throw new Error(`--game-volume must be between 0 and 1 (got ${opts.gameVolume})`);
87
+ }
88
+ const subtitleStyle = (opts.subtitleStyle || "plate").toLowerCase();
89
+ if (!["plate", "stroke", "cinema"].includes(subtitleStyle)) {
90
+ throw new Error(`--subtitle-style must be plate | stroke | cinema (got ${opts.subtitleStyle})`);
91
+ }
92
+ const buf = await fs.readFile(abs);
93
+ if (buf.byteLength > MAX_BYTES) {
94
+ throw new Error(`video is ${fmtSize(buf.byteLength)} — exceeds the ${fmtSize(MAX_BYTES)} upload cap. ` +
95
+ `Compress / trim it first (e.g. ffmpeg -crf 28).`);
96
+ }
97
+ info(`Uploading ${path.basename(abs)} (${fmtSize(buf.byteLength)})...`);
98
+ const body = {
99
+ video: `data:${mime};base64,${buf.toString("base64")}`,
100
+ voice: opts.voice || "熊小二",
101
+ tts_model: opts.ttsModel || "vox/index-tts-2",
102
+ game_audio_volume: gameVolume,
103
+ subtitles: opts.subtitles !== false,
104
+ subtitle_style: subtitleStyle,
105
+ };
106
+ if (opts.theme)
107
+ body.theme = opts.theme;
108
+ if (opts.visionModel)
109
+ body.vision_model = opts.visionModel;
110
+ if (opts.scriptModel)
111
+ body.script_model = opts.scriptModel;
112
+ if (opts.previewOnly)
113
+ body.preview_only = true;
114
+ const submitted = await post("/api/v1/dub", body);
115
+ info(`Submitted task: ${submitted.task_id} — 反推 + 写脚本 + TTS + 渲染, 请稍候...`);
116
+ const t = await waitForTask(submitted.task_id, { pollMs: opts.pollMs, timeoutMs: opts.timeoutMs });
117
+ if (t.status !== "completed") {
118
+ throw new Error(t.error || `Task ended with status ${t.status}`);
119
+ }
120
+ const result = t.result;
121
+ if (result?.script?.length) {
122
+ info(`配音脚本 (${result.voice || opts.voice}):`);
123
+ for (const line of result.script)
124
+ info(` ${line}`);
125
+ }
126
+ if (result?.render_pending) {
127
+ info(`MP4 render skipped (--preview-only). Run \`rf render ${t.id}\` to finalize.`);
128
+ }
129
+ else if (result?.video_url) {
130
+ const stdoutIsPipe = !process.stdout.isTTY;
131
+ const skipDownload = stdoutIsPipe && !opts.output;
132
+ let savedPath;
133
+ if (opts.output) {
134
+ savedPath = path.resolve(opts.output);
135
+ }
136
+ else if (!skipDownload) {
137
+ const stem = path.parse(abs).name;
138
+ savedPath = path.resolve(`${stem}-dub.mp4`);
139
+ }
140
+ if (savedPath) {
141
+ await downloadTo(result.video_url, savedPath);
142
+ success(`Saved → ${savedPath}`);
143
+ }
144
+ }
145
+ if (result?.generation_ms != null) {
146
+ const playback = result.duration != null ? `, video ${result.duration.toFixed(1)}s` : "";
147
+ info(`generated in ${humanDuration(result.generation_ms)}${playback}`);
148
+ }
149
+ if (result?.price_usd != null)
150
+ info(`charge: ${formatUsd(result.price_usd)}`);
151
+ if (isJson())
152
+ print({ task_id: t.id, status: t.status, ...result });
153
+ });
154
+ }
@@ -9,6 +9,11 @@ const VERTICALS = [
9
9
  label: "宠物第一视角搞笑 vlog",
10
10
  blurb: "用户拍的猫狗图 / 视频 + 一句话 → 宠物第一人称搞笑竖屏短视频",
11
11
  },
12
+ {
13
+ id: "general",
14
+ label: "通用素材→视频(兜底)",
15
+ blurb: "没有更专门赛道时的通用 SOP:做饭 / 旅行 / 日常 vlog / 开箱等临时场景都走这里",
16
+ },
12
17
  ];
13
18
  function loadPlaybook(id) {
14
19
  const candidates = [
@@ -38,9 +43,12 @@ function indexText() {
38
43
  lines.push(` ${" ".repeat(8)} ${v.blurb}`);
39
44
  }
40
45
  lines.push("");
46
+ lines.push("挑选规则:有贴合的垂类就用垂类;没有 → 用 general 兜底。");
47
+ lines.push("");
41
48
  lines.push("用法:");
42
- lines.push(" rf vlog pet 打印「宠物赛道」完整剧本(SOP)");
43
- lines.push(" rf vlog pet | less 分页浏览");
49
+ lines.push(" rf vlog <id> 打印某赛道完整剧本(SOP),如 `rf vlog pet`");
50
+ lines.push(" rf vlog general 通用 SOP(临时 / 没专门赛道的场景)");
51
+ lines.push(" rf vlog <id> | less 分页浏览");
44
52
  return lines.join("\n");
45
53
  }
46
54
  export function registerVlog(program) {
@@ -0,0 +1,129 @@
1
+ # rf vlog · 通用素材→视频剧本(general)
2
+
3
+ > 给**上层 agent**(你,正在帮用户做视频的 Claude Code / Cursor 等)读的工作指南。
4
+ > 这是**兜底剧本**:用户拿一批自己的图/视频想做条短视频,但没有更专门的赛道时,走这里。
5
+ > 有更贴合的垂类(如 `rf vlog pet`)就优先用垂类;没有 → 用本剧本。临时场景(做饭、
6
+ > 旅行、日常 vlog、开箱…)都适用。
7
+ >
8
+ > **角色边界(先记死)**
9
+ > - 引擎只给你两样**你做不到**的能力:`rf assets describe`(给视频「眼睛」,你看不到视频帧)、`rf compose`(渲染 + 配音 + 字幕对齐)。
10
+ > - **写旁白 / 脚本是你的活,不是引擎的。** 本剧本只给你「怎么写」的最佳实践,绝不替你写一个字。
11
+ > - 字段 / 参数的精确用法一律以 `rf compose -h`、`rf cover -h` 为准。
12
+
13
+ ---
14
+
15
+ ## Step 0 · 动手前先确认输入(别让用户从 0 摸索)
16
+
17
+ | 输入 | 默认 | 要问吗 |
18
+ |---|---|---|
19
+ | 素材目录 | — | 必给(让用户指一个文件夹) |
20
+ | **故事方向 / 想表达啥** | — | **灵魂问 1** |
21
+ | **调性 + 旁白视角** | — | **灵魂问 1 的一部分**:恶搞/温馨/科普/沉浸?旁观叙述 / 第一人称 / 主人 vlog? |
22
+ | **音色** | 不固定 | **灵魂问 2**:按调性挑 2-3 个候选给用户选(查法见下) |
23
+ | 封面 | 自动取一张高光素材 | 用户想要精致封面时,把模板预览图发他挑 |
24
+ | 字幕风格 | stroke(短视频爆款描边) | 默认,不问 |
25
+ | BGM / 分辨率 | 默认 BGM / 1080×1920 | 默认,不问 |
26
+
27
+ ---
28
+
29
+ ## Step 1 · 看素材(必做,这是你「看到」素材的唯一方式)
30
+
31
+ ```bash
32
+ rf assets describe ./素材目录/ -o assets.json
33
+ ```
34
+
35
+ 返回每个素材的客观描述(图片 ~30 字 / 视频 ~80 字含动作与氛围)+ 视频时长。
36
+ **这是事实地基。后面写旁白,只能基于这里描述出来的真实动作。**
37
+
38
+ > ⚠️ 视频 describe 有 body 体积上限(~10MB 就会 400)。源视频偏大时,先用 ffmpeg 压个低清短代理
39
+ > (如 `-vf scale=480:-2,fps=12 -crf 30`,几百 KB)再 describe;代理只用来"看懂内容",不进成片。
40
+
41
+ ## Step 2 · 你来写旁白 + 给用户过故事板
42
+
43
+ 引擎不替你写。按《写作最佳实践》自己写,然后用**人话故事板**(不是 JSON)给用户确认。
44
+
45
+ ## Step 3 · 渲染
46
+
47
+ ```bash
48
+ rf compose spec.json -o ./final.mp4
49
+ ```
50
+
51
+ ---
52
+
53
+ ## ★ 写作最佳实践
54
+
55
+ ### 🚫 反虚构铁律(最重要,违反 = 废片)
56
+ **旁白只能解说画面里 `describe` 出来的真实动作。**
57
+ - ✅ 可以编:情绪、内心戏、动机、夸张比喻。
58
+ - ❌ 不准编:画面没发生的物理事件(画面"够不着"就不能写"够到了")。
59
+ - 不确定画面里发生没发生 → 当作没发生,别写。
60
+
61
+ ### 句子写法
62
+ - 写**完整句子、句末带标点**(。!?)。每句 10~30 字。
63
+ - **别为字幕节奏手动拆短**——字幕切分是引擎的活(compose 内部按真实配音时间对齐)。
64
+ - 每条旁白是一个独立句子,放进对应 scene 的 `narration` 数组里。
65
+
66
+ ---
67
+
68
+ ## ★ 素材 & 画幅最佳实践(决定"短视频观感")
69
+
70
+ 短视频节奏踩过的坑,默认这么做:
71
+
72
+ - **优先用视频片段,尽量少用静图。** 静图即便加了 Ken Burns,整体节奏也会显得拖沓,不合现代短视频快节奏。能用视频就别用图。
73
+ - **竖拍照片用 full-bleed 铺满**(`fit:"cover"` + `focal` 保主体),别让它缩在中间——铺满才有跟视频齐平的沉浸感。
74
+ - **横屏视频**:要么用 `layout:"blur-bg"`(不裁画面,但竖图会被装在中间显小);要么先用 ffmpeg 把横屏烤成自带模糊垫底的 9:16 片,再整片走 `layout:"full"`——想让图和视频**统一全屏**就用后者。
75
+ - **照片朝向**:手机竖拍照片常带旋转标记,直接喂可能横躺。喂前先摆正、抽帧确认:`ffmpeg -noautorotate -i in.jpg -vf "transpose=1" up.jpg`。
76
+ - 不设 `theme` → 引擎**不会压暗**你的素材(保持原始亮度);只有显式选 theme 才会按主题调色压暗。
77
+
78
+ ---
79
+
80
+ ## 音色怎么选
81
+
82
+ 按调性挑,不固定某一个。查全部音色:
83
+
84
+ ```bash
85
+ rf tts voices --provider relayx --model vox/index-tts-2
86
+ ```
87
+
88
+ 按风格筛,例如 `... | grep 解说`。**别让用户从一长串里盲选**——你挑 2-3 个候选报给他,他点一个。
89
+ 选定后填进 spec 的 `audio.tts_voice`。
90
+
91
+ ## 封面怎么选
92
+
93
+ compose 的 `cover.image` 只收**一张图**。两种做法:
94
+
95
+ - **省事**:挑一张高光素材当封面 → `"cover": { "image": "高光帧.jpg" }`。
96
+ - **套模板**:`rf cover templates`(每个模板带 preview_url)→ **把预览裸链接发给用户看图挑** → 渲染:
97
+ `rf cover -i 高光帧.jpg -t "标题" --template <模板id> -o cover.png` → 填 `"cover": { "image": "cover.png" }`。
98
+ (`--subtitle` / `--badge` / `--param` 见 `rf cover -h`。)
99
+
100
+ ---
101
+
102
+ ## compose.v3 spec 模板
103
+
104
+ ```json
105
+ {
106
+ "experimental": "compose.v3",
107
+ "output": { "width": 1080, "height": 1920, "fps": 30, "layout": "full" },
108
+ "audio": { "tts_model": "vox/index-tts-2", "tts_voice": "<选定音色>", "bgm_volume": 0.12 },
109
+ "subtitle_style": "stroke",
110
+ "cover": { "image": "封面.jpg" },
111
+ "scenes": [
112
+ { "video": "clip1.mp4", "muted": true, "narration": ["句1。", "句2。"] },
113
+ { "image": "photo1.jpg", "fit": "cover", "focal": "center", "motion": "zoom-in", "narration": ["句1。"] }
114
+ ]
115
+ }
116
+ ```
117
+
118
+ - spec 里**没有任何秒数**;时间由引擎量真实配音派生。
119
+ - 旁白尽量 ≤ 视频时长,省掉片头黑卡;真比视频长时引擎会自动前置标题卡。
120
+ - `layout` 选择见上面《素材 & 画幅最佳实践》。
121
+
122
+ ---
123
+
124
+ ## 提交前自查
125
+ - [ ] 跑过 `rf assets describe`?
126
+ - [ ] 旁白每一句都对得上画面真实动作?(过一遍反虚构铁律)
127
+ - [ ] 给用户看的是**人话故事板**,不是裸 JSON?
128
+ - [ ] 照片朝向已摆正、竖图 full-bleed?横屏视频处理好画幅?
129
+ - [ ] 没设 theme(素材保持原始亮度)?
@@ -138,6 +138,11 @@ rf cover -i 高光帧.jpg -t "橘子大战鱼缸" --template <模板id> -o cover
138
138
  - spec 里**没有任何秒数**;时间由引擎量真实配音派生。
139
139
  - 旁白尽量 ≤ 视频时长,省掉片头黑卡;真比视频长时引擎会自动前置一张标题卡。
140
140
 
141
+ ### 素材 & 画幅(短视频节奏)
142
+ - **优先视频片段,尽量少用静图**:静图即便加 Ken Burns 也会拖慢节奏,不合短视频快感。
143
+ - 竖拍照片用 `fit:"cover"` 铺满(别缩在中间显小);横屏视频要么 `layout:"blur-bg"`,要么先烤成 9:16 再走 `layout:"full"` 统一全屏。
144
+ - 完整的通用画幅 / 朝向 / 音色 / 封面最佳实践见 `rf vlog general`。
145
+
141
146
  ### ⚠️ 照片朝向
142
147
  手机竖拍照片常带旋转标记,直接喂可能是横躺的。喂前先摆正、抽帧确认方向:
143
148
 
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import { registerExtract } from "./commands/extract.js";
34
34
  import { registerMedia } from "./commands/media.js";
35
35
  import { registerScript } from "./commands/script.js";
36
36
  import { registerCompose } from "./commands/compose.js";
37
+ import { registerDub } from "./commands/dub.js";
37
38
  import { registerVlog } from "./commands/vlog.js";
38
39
  import { error as logError } from "./utils/output.js";
39
40
  import { ApiCallError } from "./client.js";
@@ -109,12 +110,14 @@ program.addHelpText("after", [
109
110
  " rf fetch '<抖音分享链接>' -t 下载抖音 MP4 + ASR 转写",
110
111
  " rf cover -i scene.png -t '标题' -o cover.png 单图渲染封面",
111
112
  " rf assets describe ./photo.jpg 图片 / 视频 → 一句话描述",
113
+ " rf dub ./clip.mp4 一个视频 → 自动解说配音+字幕成片",
112
114
  " rf history list 看之前生成的视频",
113
115
  " rf tasks list --status running 看进行中的任务",
114
116
  "",
115
- "做 vlog?(用户拍的素材 → 视频,如宠物号)先看赛道剧本:",
116
- " rf vlog 列出可用赛道(宠物等)",
117
- " rf vlog pet 打印「宠物第一视角搞笑」完整 SOP",
117
+ "做 vlog?(用户拍的素材 → 视频)先看赛道剧本:",
118
+ " rf vlog 列出可用赛道",
119
+ " rf vlog pet 宠物第一视角搞笑 SOP",
120
+ " rf vlog general 通用 SOP(没专门赛道就用它兜底)",
118
121
  " → 拿到 SOP 后照着走:rf assets describe(看素材) → 写旁白 → rf compose(渲染)",
119
122
  ].join("\n"));
120
123
  registerAuth(program);
@@ -145,6 +148,7 @@ registerExtract(program);
145
148
  registerMedia(program);
146
149
  registerScript(program);
147
150
  registerCompose(program);
151
+ registerDub(program);
148
152
  registerVlog(program);
149
153
  async function main() {
150
154
  if (process.argv.length <= 2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelforge",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "description": "AI 视频生成 CLI。一句话主题或自己的脚本 → 自动出抖音/TikTok/视频号竖屏 MP4。安装即用:`reelforge` 或短别名 `rf`。",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",