reelforge 1.22.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.
- package/dist/commands/dub.js +154 -0
- package/dist/index.js +3 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
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,6 +110,7 @@ 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
|
"",
|
|
@@ -146,6 +148,7 @@ registerExtract(program);
|
|
|
146
148
|
registerMedia(program);
|
|
147
149
|
registerScript(program);
|
|
148
150
|
registerCompose(program);
|
|
151
|
+
registerDub(program);
|
|
149
152
|
registerVlog(program);
|
|
150
153
|
async function main() {
|
|
151
154
|
if (process.argv.length <= 2) {
|