reelforge 1.24.3 → 1.25.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/README.md +4 -291
- package/package.json +4 -41
- package/bin/reelforge.js +0 -8
- package/dist/client.js +0 -120
- package/dist/commands/assets-workflow-text.js +0 -22
- package/dist/commands/assets.js +0 -243
- package/dist/commands/audio.js +0 -91
- package/dist/commands/auth.js +0 -170
- package/dist/commands/bgm.js +0 -59
- package/dist/commands/clip.js +0 -139
- package/dist/commands/compose.js +0 -298
- package/dist/commands/compositions.js +0 -148
- package/dist/commands/config.js +0 -62
- package/dist/commands/content.js +0 -66
- package/dist/commands/cover.js +0 -421
- package/dist/commands/create.js +0 -638
- package/dist/commands/dub.js +0 -220
- package/dist/commands/extract.js +0 -115
- package/dist/commands/fetch.js +0 -129
- package/dist/commands/files.js +0 -70
- package/dist/commands/health.js +0 -12
- package/dist/commands/history.js +0 -44
- package/dist/commands/images.js +0 -107
- package/dist/commands/llm.js +0 -67
- package/dist/commands/media.js +0 -136
- package/dist/commands/models.js +0 -36
- package/dist/commands/pipelines.js +0 -142
- package/dist/commands/platform.js +0 -218
- package/dist/commands/regen.js +0 -134
- package/dist/commands/render.js +0 -82
- package/dist/commands/script.js +0 -128
- package/dist/commands/styles.js +0 -113
- package/dist/commands/subtitles.js +0 -246
- package/dist/commands/tasks.js +0 -59
- package/dist/commands/tts.js +0 -134
- package/dist/commands/vlog.js +0 -91
- package/dist/data/vlog/general.md +0 -129
- package/dist/data/vlog/pet.md +0 -160
- package/dist/index.js +0 -183
- package/dist/utils/config-file.js +0 -37
- package/dist/utils/download.js +0 -13
- package/dist/utils/file-upload.js +0 -102
- package/dist/utils/oss-upload.js +0 -44
- package/dist/utils/output.js +0 -91
- package/dist/utils/task-waiter.js +0 -40
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { getApiKey, getServer, post } from "../client.js";
|
|
4
|
-
import { info, print, success } from "../utils/output.js";
|
|
5
|
-
export function registerSubtitles(program) {
|
|
6
|
-
const sub = program
|
|
7
|
-
.command("subtitles")
|
|
8
|
-
.alias("subtitle")
|
|
9
|
-
.description("字幕原子能力: split (确定性 token-atomic 分段, 单条/批量) + translate (双语第二行) + ledger (历史切分质量数据)")
|
|
10
|
-
.helpOption("-h, --help", "show help");
|
|
11
|
-
sub
|
|
12
|
-
.command("split")
|
|
13
|
-
.description("把文本切成字幕分段 (分段). 单条 (--text) 或批量分镜列表 (--scenes). 与线上视频 pipeline 同一个切分器。")
|
|
14
|
-
.helpOption("-h, --help", "show help")
|
|
15
|
-
.option("-t, --text <text>", "单条: 要切分的文本. 用 @file 从磁盘读。")
|
|
16
|
-
.option("-S, --scenes <input>", "批量: 分镜列表. JSON 字符串数组 / [{text}] / storyboard.json ({scenes:[{text}]}). 用 @file。")
|
|
17
|
-
.option("--bilingual", "双语模式: min/max 缺省时用 6/14 (而非单语 10/28), 与线上双语视频一致")
|
|
18
|
-
.option("--min <N>", "最小分段字数 (缺省: 单语 10 / 双语 6)", (v) => parseInt(v, 10))
|
|
19
|
-
.option("--max <N>", "最大分段字数 (缺省: 单语 28 / 双语 14 ≈ 2/1 视觉行)", (v) => parseInt(v, 10))
|
|
20
|
-
.addHelpText("after", [
|
|
21
|
-
"",
|
|
22
|
-
"Segment vs line:",
|
|
23
|
-
" 一个 segment 是一条上屏字幕单元 (一张 PNG / 一个时间窗), 不是视觉行。",
|
|
24
|
-
" 视觉换行是模板的事 (默认 clamp 2 行)。",
|
|
25
|
-
"",
|
|
26
|
-
"Examples:",
|
|
27
|
-
" # 单条",
|
|
28
|
-
" rf subtitles split -t '雨水缓缓滑落在玻璃窗上,像是无声的泪珠。'",
|
|
29
|
-
"",
|
|
30
|
-
" # 批量: 一个分镜列表 → 二级 list (每个分镜一组字幕分段)",
|
|
31
|
-
" rf subtitles split --scenes '[\"第一镜旁白\",\"第二镜旁白\"]' --json",
|
|
32
|
-
"",
|
|
33
|
-
" # 直接喂真实 storyboard.json (取 scenes[].text), 双语参数验证线上切分",
|
|
34
|
-
" rf subtitles split --scenes @storyboard.json --bilingual --json",
|
|
35
|
-
].join("\n"))
|
|
36
|
-
.action(async (opts) => {
|
|
37
|
-
const body = {};
|
|
38
|
-
if (opts.min !== undefined)
|
|
39
|
-
body.min_chars = opts.min;
|
|
40
|
-
if (opts.max !== undefined)
|
|
41
|
-
body.max_chars = opts.max;
|
|
42
|
-
if (opts.bilingual)
|
|
43
|
-
body.bilingual = true;
|
|
44
|
-
if (opts.scenes) {
|
|
45
|
-
body.scenes = await parseScenesInput(await readInput(opts.scenes));
|
|
46
|
-
const r = await post("/api/v1/subtitles/split", body);
|
|
47
|
-
print({
|
|
48
|
-
scene_count: r.scene_count,
|
|
49
|
-
scenes: r.scenes.map((s, i) => ({
|
|
50
|
-
scene: i,
|
|
51
|
-
count: s.count,
|
|
52
|
-
segments: s.segments.map((g) => g.text),
|
|
53
|
-
})),
|
|
54
|
-
});
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
if (!opts.text)
|
|
58
|
-
throw new Error("提供 --text (单条) 或 --scenes (批量) 其一");
|
|
59
|
-
let text = opts.text;
|
|
60
|
-
if (text.startsWith("@"))
|
|
61
|
-
text = (await fs.readFile(text.slice(1), "utf-8")).trim();
|
|
62
|
-
body.text = text;
|
|
63
|
-
const r = await post("/api/v1/subtitles/split", body);
|
|
64
|
-
print({ count: r.count, segments: r.segments });
|
|
65
|
-
});
|
|
66
|
-
sub
|
|
67
|
-
.command("translate")
|
|
68
|
-
.description("字幕分段翻译 (双语字幕第二行). 接受一个分段数组 + 目标语言, 返回一一对应的译文。")
|
|
69
|
-
.helpOption("-h, --help", "show help")
|
|
70
|
-
.option("-s, --segments <input>", "字幕分段数组. 支持: @file (JSON 数组 或每行一段) / JSON 字符串 / 多段用 \\n 分隔")
|
|
71
|
-
.option("--from-split <input>", "直接接 rf subtitles split 的 JSON 输出 (file 或 stdin) — 自动提取 segments 字段")
|
|
72
|
-
.option("-l, --lang <code>", "目标语言, 如 en / ja / English / 日本語 (必填)")
|
|
73
|
-
.option("--master <text>", "完整旁白脚本 (上下文, 帮助术语一致). @file 读文件。可选, 缺省用 segments 拼接")
|
|
74
|
-
.option("--llm-model <id>", "覆盖默认 LLM 模型")
|
|
75
|
-
.addHelpText("after", [
|
|
76
|
-
"",
|
|
77
|
-
"Input 三种形式:",
|
|
78
|
-
" --segments @file.json 分段 JSON 数组",
|
|
79
|
-
" --segments @lines.txt 每行一段",
|
|
80
|
-
" --from-split @split.json rf subtitles split 的输出直接喂进来",
|
|
81
|
-
"",
|
|
82
|
-
"Examples:",
|
|
83
|
-
" # 直接给分段数组",
|
|
84
|
-
" rf subtitles translate -s '[\"第一段\",\"第二段\"]' -l en",
|
|
85
|
-
"",
|
|
86
|
-
" # 链式: split → translate (agent 工作流)",
|
|
87
|
-
" rf subtitles split -t @./script.txt --json > /tmp/segs.json",
|
|
88
|
-
" rf subtitles translate --from-split @/tmp/segs.json -l en",
|
|
89
|
-
"",
|
|
90
|
-
" # 加完整 master 让术语一致",
|
|
91
|
-
" rf subtitles translate --from-split @/tmp/segs.json -l ja --master @/tmp/script.txt",
|
|
92
|
-
"",
|
|
93
|
-
"成本估算: 单次 LLM 调用 (~$0.001-0.003 取决于段数 + 模型)",
|
|
94
|
-
].join("\n"))
|
|
95
|
-
.action(async (opts) => {
|
|
96
|
-
if (!opts.lang)
|
|
97
|
-
throw new Error("--lang is required (e.g. en, ja, English)");
|
|
98
|
-
let segments;
|
|
99
|
-
if (opts.fromSplit) {
|
|
100
|
-
const raw = await readInput(opts.fromSplit);
|
|
101
|
-
const parsed = JSON.parse(raw);
|
|
102
|
-
const arr = Array.isArray(parsed?.segments) ? parsed.segments : parsed;
|
|
103
|
-
if (!Array.isArray(arr)) {
|
|
104
|
-
throw new Error("--from-split: expected an object with `segments` array or a bare array");
|
|
105
|
-
}
|
|
106
|
-
segments = arr.map((s) => typeof s === "string" ? s : (s?.text ?? "")).filter((s) => s.length > 0);
|
|
107
|
-
}
|
|
108
|
-
else if (opts.segments) {
|
|
109
|
-
const raw = await readInput(opts.segments);
|
|
110
|
-
segments = parseSegmentsInput(raw);
|
|
111
|
-
}
|
|
112
|
-
else {
|
|
113
|
-
throw new Error("--segments or --from-split is required");
|
|
114
|
-
}
|
|
115
|
-
if (segments.length === 0)
|
|
116
|
-
throw new Error("no segments to translate");
|
|
117
|
-
let master;
|
|
118
|
-
if (opts.master) {
|
|
119
|
-
master = await readInput(opts.master);
|
|
120
|
-
}
|
|
121
|
-
const body = {
|
|
122
|
-
segments,
|
|
123
|
-
target_lang: opts.lang,
|
|
124
|
-
};
|
|
125
|
-
if (master)
|
|
126
|
-
body.master_script = master;
|
|
127
|
-
if (opts.llmModel)
|
|
128
|
-
body.llm_model = opts.llmModel;
|
|
129
|
-
const r = await post("/api/v1/subtitles/translate", body);
|
|
130
|
-
print({
|
|
131
|
-
count: r.translations.length,
|
|
132
|
-
translations: r.translations.map((t, i) => ({
|
|
133
|
-
id: i + 1,
|
|
134
|
-
src: segments[i],
|
|
135
|
-
translated: t,
|
|
136
|
-
})),
|
|
137
|
-
cost_usd: r.cost_usd,
|
|
138
|
-
duration_ms: r.duration_ms,
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
const ledger = sub
|
|
142
|
-
.command("ledger")
|
|
143
|
-
.description("Production subtitle ledger (每段字幕一行 JSONL,跨任务汇总)")
|
|
144
|
-
.helpOption("-h, --help", "show help");
|
|
145
|
-
ledger
|
|
146
|
-
.command("dump")
|
|
147
|
-
.description("从服务器下载字幕 ledger 到本地 JSONL 用于离线分析(每段一行,含切分质量评分)")
|
|
148
|
-
.helpOption("-h, --help", "show help")
|
|
149
|
-
.option("--since <iso>", "只拉 ts >= 这个时间的记录(ISO 8601,如 2026-06-01 或 2026-06-01T00:00:00Z)")
|
|
150
|
-
.option("--until <iso>", "只拉 ts <= 这个时间的记录")
|
|
151
|
-
.option("--task-id <id>", "只拉某一个 task 的记录")
|
|
152
|
-
.option("--limit <n>", "最多拉多少条(默认 10000, 上限 100000)", (v) => parseInt(v, 10))
|
|
153
|
-
.option("-o, --output <file>", "输出文件路径(默认 ./subtitle-ledger.jsonl)", "./subtitle-ledger.jsonl")
|
|
154
|
-
.addHelpText("after", [
|
|
155
|
-
"",
|
|
156
|
-
"字段:",
|
|
157
|
-
" ts / task_id / scene_idx / seg_idx 数据点定位",
|
|
158
|
-
" input_text 该 scene 切分前完整文本",
|
|
159
|
-
" display_text / text_secondary 实际上字幕的文字 + 译文",
|
|
160
|
-
" length / start_sec / end_sec 出现时长",
|
|
161
|
-
" cut_reason llm | tail | fallback-*",
|
|
162
|
-
" split_score (0-100) 该 scene LLM 切分评分",
|
|
163
|
-
" split_attempts / split_source 重试次数 / llm or fallback",
|
|
164
|
-
" splitter_version / llm_model 生成这条记录的版本组合",
|
|
165
|
-
" subtitle_translate_to en / null / 等",
|
|
166
|
-
"",
|
|
167
|
-
"Examples:",
|
|
168
|
-
" # 全量(上限 10000)",
|
|
169
|
-
" rf subtitles ledger dump -o ./ledger.jsonl",
|
|
170
|
-
"",
|
|
171
|
-
" # 只看最近一周",
|
|
172
|
-
" rf subtitles ledger dump --since 2026-06-01 -o ./recent.jsonl",
|
|
173
|
-
"",
|
|
174
|
-
" # 单个 task 复盘",
|
|
175
|
-
" rf subtitles ledger dump --task-id 0eebc40f -o ./one-task.jsonl",
|
|
176
|
-
"",
|
|
177
|
-
"拉完后用 jq / awk / 自己写脚本分析。常见查询:",
|
|
178
|
-
" jq 'select(.split_score < 80)' ledger.jsonl # 低质量 case",
|
|
179
|
-
" jq 'select(.length > 25)' ledger.jsonl # 偏长 segment",
|
|
180
|
-
" jq -s 'group_by(.task_id) | length' ledger.jsonl # task 数",
|
|
181
|
-
].join("\n"))
|
|
182
|
-
.action(async (opts) => {
|
|
183
|
-
const server = getServer().replace(/\/+$/, "");
|
|
184
|
-
const apiKey = getApiKey();
|
|
185
|
-
const qs = new URLSearchParams({ format: "jsonl" });
|
|
186
|
-
if (opts.since)
|
|
187
|
-
qs.set("since", opts.since);
|
|
188
|
-
if (opts.until)
|
|
189
|
-
qs.set("until", opts.until);
|
|
190
|
-
if (opts.taskId)
|
|
191
|
-
qs.set("task_id", opts.taskId);
|
|
192
|
-
if (opts.limit)
|
|
193
|
-
qs.set("limit", String(opts.limit));
|
|
194
|
-
const headers = {};
|
|
195
|
-
if (apiKey)
|
|
196
|
-
headers["authorization"] = `Bearer ${apiKey}`;
|
|
197
|
-
info(`Fetching ledger from ${server}/api/v1/subtitles/ledger?${qs.toString()}`);
|
|
198
|
-
const resp = await fetch(`${server}/api/v1/subtitles/ledger?${qs.toString()}`, { headers });
|
|
199
|
-
if (!resp.ok) {
|
|
200
|
-
const text = await resp.text().catch(() => "");
|
|
201
|
-
throw new Error(`Ledger fetch failed: HTTP ${resp.status}\n${text.slice(0, 500)}`);
|
|
202
|
-
}
|
|
203
|
-
const text = await resp.text();
|
|
204
|
-
const count = parseInt(resp.headers.get("x-ledger-count") || "0", 10);
|
|
205
|
-
const totalBytes = parseInt(resp.headers.get("x-ledger-bytes") || "0", 10);
|
|
206
|
-
const outAbs = path.resolve(opts.output);
|
|
207
|
-
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
|
208
|
-
await fs.writeFile(outAbs, text);
|
|
209
|
-
success(`Saved ${count} entries → ${outAbs} ` +
|
|
210
|
-
`(${(text.length / 1024).toFixed(1)} KB; server ledger size: ${(totalBytes / 1024).toFixed(1)} KB)`);
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
async function readInput(input) {
|
|
214
|
-
if (input.startsWith("@")) {
|
|
215
|
-
return (await fs.readFile(input.slice(1), "utf-8")).trim();
|
|
216
|
-
}
|
|
217
|
-
return input;
|
|
218
|
-
}
|
|
219
|
-
async function parseScenesInput(raw) {
|
|
220
|
-
let parsed;
|
|
221
|
-
try {
|
|
222
|
-
parsed = JSON.parse(raw);
|
|
223
|
-
}
|
|
224
|
-
catch {
|
|
225
|
-
throw new Error("--scenes 必须是 JSON (数组 或 含 scenes 数组的对象, 如 storyboard.json)");
|
|
226
|
-
}
|
|
227
|
-
const arr = Array.isArray(parsed)
|
|
228
|
-
? parsed
|
|
229
|
-
: Array.isArray(parsed?.scenes)
|
|
230
|
-
? parsed.scenes
|
|
231
|
-
: null;
|
|
232
|
-
if (!arr)
|
|
233
|
-
throw new Error("--scenes: 期望 JSON 数组, 或含 `scenes` 数组的对象 (storyboard.json)");
|
|
234
|
-
return arr.map((s) => ({ text: typeof s === "string" ? s : (s?.text ?? "") }));
|
|
235
|
-
}
|
|
236
|
-
function parseSegmentsInput(raw) {
|
|
237
|
-
const trimmed = raw.trim();
|
|
238
|
-
if (trimmed.startsWith("[")) {
|
|
239
|
-
const parsed = JSON.parse(trimmed);
|
|
240
|
-
if (!Array.isArray(parsed))
|
|
241
|
-
throw new Error("--segments JSON must be an array");
|
|
242
|
-
return parsed.filter((s) => typeof s === "string" && s.length > 0);
|
|
243
|
-
}
|
|
244
|
-
const unescaped = trimmed.replace(/\\n/g, "\n");
|
|
245
|
-
return unescaped.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
246
|
-
}
|
package/dist/commands/tasks.js
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { del, get } from "../client.js";
|
|
2
|
-
import { waitForTask } from "../utils/task-waiter.js";
|
|
3
|
-
import { print, table } from "../utils/output.js";
|
|
4
|
-
export function registerTasks(program) {
|
|
5
|
-
const tasks = program
|
|
6
|
-
.command("tasks")
|
|
7
|
-
.alias("task")
|
|
8
|
-
.description("任务队列管理: 查看 / 等待 / 取消")
|
|
9
|
-
.helpOption("-h, --help", "show help");
|
|
10
|
-
tasks
|
|
11
|
-
.command("list")
|
|
12
|
-
.description("List recent tasks")
|
|
13
|
-
.helpOption("-h, --help", "show help")
|
|
14
|
-
.option("--status <s>", "filter by status: pending | running | completed | failed | cancelled")
|
|
15
|
-
.option("--limit <n>", "max number of tasks", parseInt)
|
|
16
|
-
.action(async (opts) => {
|
|
17
|
-
const qs = new URLSearchParams();
|
|
18
|
-
if (opts.status)
|
|
19
|
-
qs.set("status", opts.status);
|
|
20
|
-
if (opts.limit)
|
|
21
|
-
qs.set("limit", String(opts.limit));
|
|
22
|
-
const r = await get(`/api/v1/tasks${qs.toString() ? `?${qs}` : ""}`);
|
|
23
|
-
table(r.tasks.map((t) => ({
|
|
24
|
-
id: t.id.slice(0, 8),
|
|
25
|
-
kind: t.kind,
|
|
26
|
-
status: t.status,
|
|
27
|
-
progress: `${(t.progress * 100).toFixed(0)}%`,
|
|
28
|
-
created_at: t.created_at,
|
|
29
|
-
})));
|
|
30
|
-
});
|
|
31
|
-
tasks
|
|
32
|
-
.command("get <id>")
|
|
33
|
-
.description("Show full task detail (status, progress, events, result)")
|
|
34
|
-
.helpOption("-h, --help", "show help")
|
|
35
|
-
.action(async (id) => {
|
|
36
|
-
const r = await get(`/api/v1/tasks/${id}`);
|
|
37
|
-
print(r);
|
|
38
|
-
});
|
|
39
|
-
tasks
|
|
40
|
-
.command("wait <id>")
|
|
41
|
-
.description("Poll a task until it reaches a terminal state (live progress on stderr)")
|
|
42
|
-
.helpOption("-h, --help", "show help")
|
|
43
|
-
.option("--poll-ms <ms>", "poll interval", parseInt, 1500)
|
|
44
|
-
.option("--timeout-ms <ms>", "max wait time", parseInt)
|
|
45
|
-
.action(async (id, opts) => {
|
|
46
|
-
const t = await waitForTask(id, { pollMs: opts.pollMs, timeoutMs: opts.timeoutMs });
|
|
47
|
-
print({ id: t.id, status: t.status, error: t.error, result: t.result });
|
|
48
|
-
if (t.status !== "completed")
|
|
49
|
-
process.exit(1);
|
|
50
|
-
});
|
|
51
|
-
tasks
|
|
52
|
-
.command("cancel <id>")
|
|
53
|
-
.description("Cancel a running task")
|
|
54
|
-
.helpOption("-h, --help", "show help")
|
|
55
|
-
.action(async (id) => {
|
|
56
|
-
const r = await del(`/api/v1/tasks/${id}`);
|
|
57
|
-
print(r);
|
|
58
|
-
});
|
|
59
|
-
}
|
package/dist/commands/tts.js
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import { get, post } from "../client.js";
|
|
2
|
-
import { downloadTo } from "../utils/download.js";
|
|
3
|
-
import { print, table, success } from "../utils/output.js";
|
|
4
|
-
const DEFAULT_RELAYX_TTS_MODEL = "vox/index-tts-2";
|
|
5
|
-
export function registerTts(program) {
|
|
6
|
-
const tts = program
|
|
7
|
-
.command("tts")
|
|
8
|
-
.description("语音合成 (TTS): 本地 (免费) / 云端")
|
|
9
|
-
.helpOption("-h, --help", "显示帮助");
|
|
10
|
-
tts
|
|
11
|
-
.command("edge")
|
|
12
|
-
.description("用本地引擎合成语音 (免费, 不联外网)")
|
|
13
|
-
.helpOption("-h, --help", "显示帮助")
|
|
14
|
-
.requiredOption("-t, --text <text>", "要合成的文本")
|
|
15
|
-
.option("--voice <id>", "音色 id (例: zh-CN-YunjianNeural / en-US-AriaNeural)", "zh-CN-YunjianNeural")
|
|
16
|
-
.option("--speed <n>", "语速倍率 (0.5..2)", parseFloat, 1.2)
|
|
17
|
-
.option("--rate <rate>", "原生 rate 字符串, 如 '+30%' (覆盖 --speed)")
|
|
18
|
-
.option("-o, --output <file>", "保存音频到指定路径")
|
|
19
|
-
.addHelpText("after", [
|
|
20
|
-
"",
|
|
21
|
-
"示例:",
|
|
22
|
-
" rf tts edge -t '你好' --voice zh-CN-XiaoxiaoNeural -o hello.mp3",
|
|
23
|
-
" rf tts edge -t 'hello' --voice en-US-AriaNeural --speed 1.4 -o out.mp3",
|
|
24
|
-
"",
|
|
25
|
-
"查看可用 voices: `rf tts voices` (按 locale 过滤: `--locale zh`)",
|
|
26
|
-
].join("\n"))
|
|
27
|
-
.action(async (opts) => {
|
|
28
|
-
const body = { text: opts.text };
|
|
29
|
-
if (opts.voice)
|
|
30
|
-
body.voice = opts.voice;
|
|
31
|
-
if (opts.speed !== undefined)
|
|
32
|
-
body.speed = opts.speed;
|
|
33
|
-
if (opts.rate)
|
|
34
|
-
body.rate = opts.rate;
|
|
35
|
-
const r = await post("/api/v1/tts/edge", body);
|
|
36
|
-
if (opts.output) {
|
|
37
|
-
await downloadTo(r.url, opts.output);
|
|
38
|
-
success(`Saved → ${opts.output} (${r.size_bytes} bytes, voice=${r.voice}, rate=${r.rate})`);
|
|
39
|
-
}
|
|
40
|
-
print({ ok: r.ok, voice: r.voice, rate: r.rate, size_bytes: r.size_bytes, file_path: r.file_path, url: r.url, downloaded_to: opts.output || null });
|
|
41
|
-
});
|
|
42
|
-
tts
|
|
43
|
-
.command("relayx")
|
|
44
|
-
.description("用云端 TTS 合成语音 (149 个内置音色)")
|
|
45
|
-
.helpOption("-h, --help", "显示帮助")
|
|
46
|
-
.requiredOption("-t, --text <text>", "要合成的文本")
|
|
47
|
-
.option("-m, --model <id>", "TTS 模型 id (默认云端模型)")
|
|
48
|
-
.option("--voice <id>", "音色 id (默认: 专业解说)")
|
|
49
|
-
.option("--speed <n>", "语速倍率 (0.5..2)", parseFloat)
|
|
50
|
-
.option("-o, --output <file>", "保存音频到指定路径")
|
|
51
|
-
.addHelpText("after", [
|
|
52
|
-
"",
|
|
53
|
-
"示例:",
|
|
54
|
-
" rf tts relayx -t '你好世界' --voice '专业解说' -o hello.mp3",
|
|
55
|
-
" rf tts relayx -t '...' --voice '熊小二' --speed 1.1 -o out.mp3",
|
|
56
|
-
"",
|
|
57
|
-
"查看可用 voices: `rf tts voices` (默认这 149 个) /",
|
|
58
|
-
" `rf tts voices --model <其他 SKU>`",
|
|
59
|
-
].join("\n"))
|
|
60
|
-
.action(async (opts) => {
|
|
61
|
-
const body = { text: opts.text };
|
|
62
|
-
if (opts.model)
|
|
63
|
-
body.model = opts.model;
|
|
64
|
-
if (opts.voice)
|
|
65
|
-
body.voice = opts.voice;
|
|
66
|
-
if (opts.speed !== undefined)
|
|
67
|
-
body.speed = opts.speed;
|
|
68
|
-
const r = await post("/api/v1/tts/relayx", body);
|
|
69
|
-
if (opts.output) {
|
|
70
|
-
await downloadTo(r.url, opts.output);
|
|
71
|
-
success(`Saved → ${opts.output} (${r.size_bytes} bytes, model=${r.model}, voice=${r.voice})`);
|
|
72
|
-
}
|
|
73
|
-
print(r);
|
|
74
|
-
});
|
|
75
|
-
tts
|
|
76
|
-
.command("voices")
|
|
77
|
-
.description("列出可用音色 (本地 + 云端, 共 ~170+)")
|
|
78
|
-
.helpOption("-h, --help", "显示帮助")
|
|
79
|
-
.option("--provider <p>", "edge | relayx | all (默认: all,即两者都列)", "all")
|
|
80
|
-
.option("--model <id>", "云端模型 id (默认云端模型; 例: <SKU>)")
|
|
81
|
-
.option("--locale <prefix>", "本地引擎才有: 按 locale 前缀过滤, 如 zh / en-US")
|
|
82
|
-
.option("--refresh", "云端才有: 跳过 5 分钟服务端缓存")
|
|
83
|
-
.addHelpText("after", [
|
|
84
|
-
"",
|
|
85
|
-
"示例:",
|
|
86
|
-
" rf tts voices # 本地 + 云端,合计 ~174",
|
|
87
|
-
" rf tts voices --provider edge # 只看本地",
|
|
88
|
-
" rf tts voices --provider edge --locale zh # 本地中文音色",
|
|
89
|
-
" rf tts voices --provider relayx # 只看云端 (149)",
|
|
90
|
-
" rf tts voices --model <SKU> # 换个云端 SKU",
|
|
91
|
-
"",
|
|
92
|
-
"默认 --provider=all 同时列两边,本地用 locale 区分语种,云端用 voice id 选择。",
|
|
93
|
-
].join("\n"))
|
|
94
|
-
.action(async (opts) => {
|
|
95
|
-
const provider = (opts.provider || "all").toLowerCase();
|
|
96
|
-
const model = opts.model || DEFAULT_RELAYX_TTS_MODEL;
|
|
97
|
-
async function fetchEdge() {
|
|
98
|
-
const r = await get("/api/v1/tts/voices?provider=edge");
|
|
99
|
-
let voices = r.voices;
|
|
100
|
-
if (opts.locale)
|
|
101
|
-
voices = voices.filter((v) => v.locale.startsWith(opts.locale));
|
|
102
|
-
return voices.map((v) => ({ provider: "edge", model: "edge", id: v.id, label: v.label, locale: v.locale, gender: v.gender, featured: "" }));
|
|
103
|
-
}
|
|
104
|
-
async function fetchRelayx() {
|
|
105
|
-
const qs = new URLSearchParams({ provider: "relayx", model });
|
|
106
|
-
if (opts.refresh)
|
|
107
|
-
qs.set("refresh", "1");
|
|
108
|
-
const r = await get(`/api/v1/tts/voices?${qs.toString()}`);
|
|
109
|
-
return r.voices.map((v) => ({
|
|
110
|
-
provider: "relayx",
|
|
111
|
-
model,
|
|
112
|
-
id: v.id,
|
|
113
|
-
label: v.label,
|
|
114
|
-
locale: "",
|
|
115
|
-
gender: "",
|
|
116
|
-
featured: v.featured ? "★" : "",
|
|
117
|
-
}));
|
|
118
|
-
}
|
|
119
|
-
if (provider === "edge") {
|
|
120
|
-
table(await fetchEdge());
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (provider === "relayx") {
|
|
124
|
-
table(await fetchRelayx());
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
if (provider === "all") {
|
|
128
|
-
const [edgeVoices, relayxVoices] = await Promise.all([fetchEdge(), fetchRelayx()]);
|
|
129
|
-
table([...edgeVoices, ...relayxVoices]);
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
throw new Error(`未知的 --provider: ${provider} (应为 edge / relayx / all)`);
|
|
133
|
-
});
|
|
134
|
-
}
|
package/dist/commands/vlog.js
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
-
const __dirname = path.dirname(__filename);
|
|
6
|
-
const VERTICALS = [
|
|
7
|
-
{
|
|
8
|
-
id: "pet",
|
|
9
|
-
label: "宠物第一视角搞笑 vlog",
|
|
10
|
-
blurb: "用户拍的猫狗图 / 视频 + 一句话 → 宠物第一人称搞笑竖屏短视频",
|
|
11
|
-
},
|
|
12
|
-
{
|
|
13
|
-
id: "general",
|
|
14
|
-
label: "通用素材→视频(兜底)",
|
|
15
|
-
blurb: "没有更专门赛道时的通用 SOP:做饭 / 旅行 / 日常 vlog / 开箱等临时场景都走这里",
|
|
16
|
-
},
|
|
17
|
-
];
|
|
18
|
-
function loadPlaybook(id) {
|
|
19
|
-
const candidates = [
|
|
20
|
-
path.resolve(__dirname, "../data/vlog", `${id}.md`),
|
|
21
|
-
path.resolve(__dirname, "../../../docs/vlog", `${id}.md`),
|
|
22
|
-
];
|
|
23
|
-
for (const p of candidates) {
|
|
24
|
-
try {
|
|
25
|
-
return fs.readFileSync(p, "utf8");
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
function indexText() {
|
|
33
|
-
const lines = [
|
|
34
|
-
"rf vlog — 用素材做视频的「赛道剧本」(给 agent 读的 use-case SOP)",
|
|
35
|
-
"",
|
|
36
|
-
"底层能力是 `rf assets describe`(看素材) + `rf compose`(渲染);本命名空间只给",
|
|
37
|
-
"「怎么把一条赛道落地」的引导。写旁白是 agent 的活,引擎不替写。",
|
|
38
|
-
"",
|
|
39
|
-
"可用赛道:",
|
|
40
|
-
];
|
|
41
|
-
for (const v of VERTICALS) {
|
|
42
|
-
lines.push(` ${v.id.padEnd(8)} ${v.label}`);
|
|
43
|
-
lines.push(` ${" ".repeat(8)} ${v.blurb}`);
|
|
44
|
-
}
|
|
45
|
-
lines.push("");
|
|
46
|
-
lines.push("挑选规则:有贴合的垂类就用垂类;没有 → 用 general 兜底。");
|
|
47
|
-
lines.push("");
|
|
48
|
-
lines.push("用法:");
|
|
49
|
-
lines.push(" rf vlog <id> 打印某赛道完整剧本(SOP),如 `rf vlog pet`");
|
|
50
|
-
lines.push(" rf vlog general 通用 SOP(临时 / 没专门赛道的场景)");
|
|
51
|
-
lines.push(" rf vlog <id> | less 分页浏览");
|
|
52
|
-
return lines.join("\n");
|
|
53
|
-
}
|
|
54
|
-
export function registerVlog(program) {
|
|
55
|
-
const cmd = program
|
|
56
|
-
.command("vlog")
|
|
57
|
-
.description("素材→视频 垂类剧本 (给 agent 读的 use-case SOP;底层用 rf assets describe + rf compose)")
|
|
58
|
-
.helpOption("-h, --help", "show help")
|
|
59
|
-
.action(() => {
|
|
60
|
-
process.stdout.write(indexText() + "\n");
|
|
61
|
-
});
|
|
62
|
-
for (const v of VERTICALS) {
|
|
63
|
-
cmd
|
|
64
|
-
.command(v.id)
|
|
65
|
-
.description(`打印「${v.label}」完整剧本 (SOP) 到 stdout`)
|
|
66
|
-
.helpOption("-h, --help", "show help")
|
|
67
|
-
.addHelpText("after", ["", `不带 -h 直接运行 \`rf vlog ${v.id}\` 打印完整剧本。`, "适合 agent 启动时一次性读入。"].join("\n"))
|
|
68
|
-
.action(() => {
|
|
69
|
-
const text = loadPlaybook(v.id);
|
|
70
|
-
if (text) {
|
|
71
|
-
process.stdout.write(text);
|
|
72
|
-
if (!text.endsWith("\n"))
|
|
73
|
-
process.stdout.write("\n");
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
process.stdout.write([
|
|
77
|
-
`# rf vlog · ${v.label}`,
|
|
78
|
-
"",
|
|
79
|
-
"(本机未找到完整剧本文件;可能是旧版安装。)",
|
|
80
|
-
"核心流程:",
|
|
81
|
-
" 1. rf assets describe ./素材目录/ -o assets.json # 让 agent 看懂素材",
|
|
82
|
-
" 2. agent 基于真实画面写第一人称旁白(不要虚构画面没发生的动作)",
|
|
83
|
-
" 3. 写 compose.v3 spec,音色查 `rf tts voices --provider relayx --model vox/index-tts-2`",
|
|
84
|
-
" 4. rf compose spec.json -o ./final.mp4",
|
|
85
|
-
"",
|
|
86
|
-
"spec 字段见 `rf compose -h`,封面模板见 `rf cover templates`。",
|
|
87
|
-
"",
|
|
88
|
-
].join("\n"));
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
}
|
|
@@ -1,129 +0,0 @@
|
|
|
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(素材保持原始亮度)?
|