reelforge 0.5.5 → 0.6.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 +37 -22
- package/dist/commands/audio.js +73 -0
- package/dist/commands/content.js +50 -96
- package/dist/commands/create.js +124 -213
- package/dist/commands/pipelines.js +52 -34
- package/dist/commands/subtitles.js +40 -0
- package/dist/index.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,15 +79,14 @@ Run `rf <command> --help` for full details on any of these.
|
|
|
79
79
|
| `tts voices [--locale zh]` | List supported Edge TTS voices |
|
|
80
80
|
| `images generate -p <prompt> -m rx-image-flux` | Image generation via RelayX (rx-image-z / rx-image-flux / rx-image-qwen) |
|
|
81
81
|
|
|
82
|
-
### Content
|
|
82
|
+
### Content / audio / subtitle atomics
|
|
83
83
|
|
|
84
84
|
| command | what it does |
|
|
85
85
|
|---|---|
|
|
86
|
-
| `content
|
|
87
|
-
| `content
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
90
|
-
| `content asset-script --intent ... --assets <file>` | Asset-based scene script |
|
|
86
|
+
| `content scene-plan -t <topic>` | Single LLM call: title + master script + per-scene image prompts (replaces the old narration / split / image-prompts / title trio) |
|
|
87
|
+
| `content scene-plan --script <text-or-@file>` | Same, but the user supplies the script verbatim — LLM only segments and writes image prompts |
|
|
88
|
+
| `audio transcribe -f <file>` / `--url <url>` | RelayX paraformer-v2 ASR with word + segment timestamps |
|
|
89
|
+
| `subtitles split -t <text-or-@file>` | Deterministic tiered-punctuation subtitle line splitter (pure function, zero billing) |
|
|
91
90
|
|
|
92
91
|
### Composition
|
|
93
92
|
|
|
@@ -106,9 +105,11 @@ Run `rf <command> --help` for full details on any of these.
|
|
|
106
105
|
|
|
107
106
|
All `pipelines *` commands submit an **async task** and (by default) poll until it finishes with a live progress indicator on stderr. Use `--no-wait` to return immediately with a `task_id`, then `rf tasks wait <id>` later.
|
|
108
107
|
|
|
108
|
+
The standard pipeline is **audio-first**: scene-plan → one-shot TTS → ASR alignment → per-scene image generation → per-subtitle-line frame rendering → ffmpeg mux. One continuous master audio track; image cuts at scene boundaries; subtitle cuts at line boundaries.
|
|
109
|
+
|
|
109
110
|
| command | what it does |
|
|
110
111
|
|---|---|
|
|
111
|
-
| `pipelines standard -t <topic
|
|
112
|
+
| `pipelines standard -t <topic>` (or `--script <text>`) | Audio-first pipeline; `-d/--duration` and `-p/--pace` are the two main knobs |
|
|
112
113
|
|
|
113
114
|
### Resources
|
|
114
115
|
|
|
@@ -132,35 +133,49 @@ All `pipelines *` commands submit an **async task** and (by default) poll until
|
|
|
132
133
|
## Examples
|
|
133
134
|
|
|
134
135
|
```bash
|
|
135
|
-
# 1. One-click out a video (
|
|
136
|
+
# 1. One-click out a video (45s default, AI writes the script)
|
|
136
137
|
rf create "为什么我们还没找到外星文明?"
|
|
137
138
|
|
|
138
|
-
# 2.
|
|
139
|
+
# 2. Longer video with a slower visual rhythm
|
|
140
|
+
rf create "深夜便利店的灯光" -d 90 -p slow
|
|
141
|
+
|
|
142
|
+
# 3. Your own script — no narration-splitting on your side, the pipeline handles it
|
|
143
|
+
rf create --script @./my-script.txt
|
|
144
|
+
rf create --script "雨水缓缓滑落在玻璃窗上,像是无声的泪珠。"
|
|
145
|
+
|
|
146
|
+
# 4. Pick a built-in visual style preset
|
|
147
|
+
rf create "美食教程" --style photorealistic
|
|
148
|
+
|
|
149
|
+
# 5. Pipeline form with explicit output path
|
|
139
150
|
rf pipelines standard \
|
|
140
|
-
|
|
141
|
-
--
|
|
142
|
-
|
|
143
|
-
--tts-voice en-US-AriaNeural -o smoke.mp4
|
|
151
|
+
--script @./script.txt \
|
|
152
|
+
--frame-template 1080x1920/image_default.html \
|
|
153
|
+
-p normal -o smoke.mp4
|
|
144
154
|
|
|
145
|
-
#
|
|
155
|
+
# 6. Inspect existing tasks & redownload a finished video
|
|
146
156
|
rf tasks list --limit 5
|
|
147
157
|
rf history get <task-id> --download recovered.mp4
|
|
148
158
|
|
|
149
|
-
#
|
|
159
|
+
# 7. Atomics for stand-alone use
|
|
160
|
+
rf content scene-plan -t "雨天的玻璃窗" -d 45 --json | jq .scenes
|
|
161
|
+
rf audio transcribe -f narration.mp3 --json | jq '.words[:5]'
|
|
162
|
+
rf subtitles split -t @./narration.txt --min 10 --hard-max 24
|
|
163
|
+
|
|
164
|
+
# 8. JSON pipe for automation
|
|
150
165
|
rf llm presets --json | jq '.[].defaultModel'
|
|
151
166
|
|
|
152
|
-
#
|
|
167
|
+
# 9. Configure & test LLM (self-hosted)
|
|
153
168
|
rf config set llm.api_key rx-xxxxx # RelayX key (or your own provider key)
|
|
154
169
|
rf config set llm.base_url https://relayx.timor419.com/v1
|
|
155
170
|
rf config set llm.model anthropic/claude-4-7-sonnet
|
|
156
171
|
rf llm chat -p 'one-sentence summary of antifragile'
|
|
157
172
|
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
173
|
+
# 10. Use your own HTML template (no PR/release needed)
|
|
174
|
+
# Any --frame-template that points to a local .html file is read and sent
|
|
175
|
+
# inline. Declare size inside the file via
|
|
176
|
+
# <meta name="template:width" content="1080">
|
|
177
|
+
# <meta name="template:height" content="1920">
|
|
178
|
+
# or pass --frame-template-size 1080x1920.
|
|
164
179
|
rf templates show 1080x1920/image_default.html -o my-brand.html # copy a preset
|
|
165
180
|
# ...edit my-brand.html to suit your style...
|
|
166
181
|
rf templates preview ./my-brand.html --title "Hello" -o preview.png
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { uploadMultipart, post } from "../client.js";
|
|
4
|
+
import { print } from "../utils/output.js";
|
|
5
|
+
export function registerAudio(program) {
|
|
6
|
+
const audio = program
|
|
7
|
+
.command("audio")
|
|
8
|
+
.description("Audio atomics — transcription / forced alignment")
|
|
9
|
+
.helpOption("-h, --help", "show help");
|
|
10
|
+
audio
|
|
11
|
+
.command("transcribe")
|
|
12
|
+
.description("Transcribe an audio file to text + word-level timestamps (RelayX paraformer-v2)")
|
|
13
|
+
.helpOption("-h, --help", "show help")
|
|
14
|
+
.option("-f, --file <path>", "local audio file (mp3/wav/m4a). Use this OR --url.")
|
|
15
|
+
.option("-u, --url <url>", "remote audio URL — server downloads and transcribes.")
|
|
16
|
+
.option("-l, --language <code>", "language hint (e.g. zh, en). Optional — paraformer-v2 auto-detects.")
|
|
17
|
+
.option("-m, --model <id>", "override ASR model id (default alibaba/paraformer-v2)")
|
|
18
|
+
.option("-o, --output <file>", "write the full JSON response to this file as well as stdout")
|
|
19
|
+
.addHelpText("after", [
|
|
20
|
+
"",
|
|
21
|
+
"Examples:",
|
|
22
|
+
" rf audio transcribe -f ./narration.mp3",
|
|
23
|
+
" rf audio transcribe --url https://example.com/clip.mp3 --language zh",
|
|
24
|
+
" rf audio transcribe -f ./voice.wav --json | jq '.words[:5]'",
|
|
25
|
+
].join("\n"))
|
|
26
|
+
.action(async (opts) => {
|
|
27
|
+
if (!opts.file && !opts.url) {
|
|
28
|
+
throw new Error("either --file or --url is required");
|
|
29
|
+
}
|
|
30
|
+
if (opts.file && opts.url) {
|
|
31
|
+
throw new Error("--file and --url are mutually exclusive");
|
|
32
|
+
}
|
|
33
|
+
let r;
|
|
34
|
+
if (opts.file) {
|
|
35
|
+
const buf = await fs.readFile(opts.file);
|
|
36
|
+
const filename = path.basename(opts.file);
|
|
37
|
+
const ext = path.extname(filename).toLowerCase();
|
|
38
|
+
const mime = ext === ".wav" ? "audio/wav" :
|
|
39
|
+
ext === ".m4a" ? "audio/mp4" :
|
|
40
|
+
ext === ".flac" ? "audio/flac" :
|
|
41
|
+
ext === ".ogg" ? "audio/ogg" :
|
|
42
|
+
"audio/mpeg";
|
|
43
|
+
const fileBlob = new File([new Uint8Array(buf)], filename, { type: mime });
|
|
44
|
+
const fields = { file: fileBlob };
|
|
45
|
+
if (opts.language)
|
|
46
|
+
fields.language = opts.language;
|
|
47
|
+
if (opts.model)
|
|
48
|
+
fields.model = opts.model;
|
|
49
|
+
r = await uploadMultipart("/api/v1/audio/transcribe", fields);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const body = { audio_url: opts.url };
|
|
53
|
+
if (opts.language)
|
|
54
|
+
body.language = opts.language;
|
|
55
|
+
if (opts.model)
|
|
56
|
+
body.model = opts.model;
|
|
57
|
+
r = await post("/api/v1/audio/transcribe", body);
|
|
58
|
+
}
|
|
59
|
+
if (opts.output) {
|
|
60
|
+
await fs.writeFile(opts.output, JSON.stringify(r, null, 2), "utf-8");
|
|
61
|
+
}
|
|
62
|
+
print({
|
|
63
|
+
model: r.model,
|
|
64
|
+
language: r.language,
|
|
65
|
+
duration: r.duration,
|
|
66
|
+
text: r.text,
|
|
67
|
+
n_segments: r.segments.length,
|
|
68
|
+
n_words: r.words.length,
|
|
69
|
+
segments: r.segments,
|
|
70
|
+
words: r.words,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
package/dist/commands/content.js
CHANGED
|
@@ -4,109 +4,63 @@ import { print } from "../utils/output.js";
|
|
|
4
4
|
export function registerContent(program) {
|
|
5
5
|
const content = program
|
|
6
6
|
.command("content")
|
|
7
|
-
.description("
|
|
7
|
+
.description("Content atomics — scene planning (master script + image prompts in one call)")
|
|
8
8
|
.helpOption("-h, --help", "show help");
|
|
9
9
|
content
|
|
10
|
-
.command("
|
|
11
|
-
.description("Generate
|
|
10
|
+
.command("scene-plan")
|
|
11
|
+
.description("Generate a master script + per-scene image prompts (replaces narration/image-prompts/title)")
|
|
12
12
|
.helpOption("-h, --help", "show help")
|
|
13
|
-
.
|
|
14
|
-
.option("
|
|
15
|
-
.option("
|
|
16
|
-
.option("
|
|
17
|
-
.
|
|
18
|
-
.action(async (opts) => {
|
|
19
|
-
const r = await post("/api/v1/content/narration", {
|
|
20
|
-
topic: opts.topic,
|
|
21
|
-
n_scenes: opts.nScenes,
|
|
22
|
-
min_words: opts.minWords,
|
|
23
|
-
max_words: opts.maxWords,
|
|
24
|
-
});
|
|
25
|
-
print(r);
|
|
26
|
-
});
|
|
27
|
-
content
|
|
28
|
-
.command("split")
|
|
29
|
-
.description("Split a fixed script into narrations (no LLM cost)")
|
|
30
|
-
.helpOption("-h, --help", "show help")
|
|
31
|
-
.requiredOption("-s, --script <text>", "raw script text (use @file for a file)")
|
|
32
|
-
.option("-m, --mode <mode>", "paragraph | line | sentence", "paragraph")
|
|
33
|
-
.addHelpText("after", "\nExample:\n reelforge content split -s @script.txt -m sentence")
|
|
34
|
-
.action(async (opts) => {
|
|
35
|
-
let script = opts.script;
|
|
36
|
-
if (script.startsWith("@"))
|
|
37
|
-
script = await fs.readFile(script.slice(1), "utf-8");
|
|
38
|
-
const r = await post("/api/v1/content/narration/split", { script, mode: opts.mode });
|
|
39
|
-
print(r);
|
|
40
|
-
});
|
|
41
|
-
content
|
|
42
|
-
.command("image-prompts")
|
|
43
|
-
.description("Generate English image-generation prompts from narrations")
|
|
44
|
-
.helpOption("-h, --help", "show help")
|
|
45
|
-
.requiredOption("-i, --narrations <file>", "file with one narration per line (or @file)")
|
|
46
|
-
.option("--prefix <text>", "style prefix prepended to each prompt")
|
|
47
|
-
.option("--min-words <n>", "minimum words per prompt", parseInt, 30)
|
|
48
|
-
.option("--max-words <n>", "maximum words per prompt", parseInt, 60)
|
|
49
|
-
.addHelpText("after", "\nExample:\n reelforge content image-prompts -i narrations.txt --prefix 'cinematic'")
|
|
50
|
-
.action(async (opts) => {
|
|
51
|
-
let src = opts.narrations;
|
|
52
|
-
if (src.startsWith("@"))
|
|
53
|
-
src = src.slice(1);
|
|
54
|
-
const text = await fs.readFile(src, "utf-8");
|
|
55
|
-
const narrations = text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
56
|
-
const r = await post("/api/v1/content/image-prompts", {
|
|
57
|
-
narrations,
|
|
58
|
-
prompt_prefix: opts.prefix,
|
|
59
|
-
min_words: opts.minWords,
|
|
60
|
-
max_words: opts.maxWords,
|
|
61
|
-
});
|
|
62
|
-
print(r);
|
|
63
|
-
});
|
|
64
|
-
content
|
|
65
|
-
.command("title")
|
|
66
|
-
.description("Generate a short video title from content")
|
|
67
|
-
.helpOption("-h, --help", "show help")
|
|
68
|
-
.requiredOption("-c, --content <text>", "content to title (use @file)")
|
|
69
|
-
.option("--max-length <n>", "maximum characters", parseInt, 15)
|
|
70
|
-
.action(async (opts) => {
|
|
71
|
-
let body = opts.content;
|
|
72
|
-
if (body.startsWith("@"))
|
|
73
|
-
body = await fs.readFile(body.slice(1), "utf-8");
|
|
74
|
-
const r = await post("/api/v1/content/title", {
|
|
75
|
-
content: body,
|
|
76
|
-
max_length: opts.maxLength,
|
|
77
|
-
});
|
|
78
|
-
print(r);
|
|
79
|
-
});
|
|
80
|
-
content
|
|
81
|
-
.command("asset-script")
|
|
82
|
-
.description("Generate a scene script that assigns user-uploaded assets to scenes")
|
|
83
|
-
.helpOption("-h, --help", "show help")
|
|
84
|
-
.requiredOption("--intent <text>", "video intent / purpose")
|
|
85
|
-
.option("--title <text>", "optional video title")
|
|
86
|
-
.option("--duration <s>", "target duration in seconds", parseInt, 30)
|
|
87
|
-
.requiredOption("--assets <file>", "file with one asset per line, format: `path | description`")
|
|
13
|
+
.option("-t, --topic <text>", "video topic; AI writes the script (generate mode). Use @file for disk input.")
|
|
14
|
+
.option("--script <text>", "your own master script text (fixed mode). Use @file for disk input.")
|
|
15
|
+
.option("-d, --duration <sec>", "target video duration in seconds (generate mode; default 45)", (v) => parseInt(v, 10))
|
|
16
|
+
.option("-p, --pace <pace>", "visual rhythm hint: slow | normal | fast (default normal)")
|
|
17
|
+
.option("-m, --model <id>", "override LLM model")
|
|
88
18
|
.addHelpText("after", [
|
|
89
19
|
"",
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
20
|
+
"Two modes (exactly one required):",
|
|
21
|
+
" generate -t / --topic <text> LLM writes both script and image prompts",
|
|
22
|
+
" fixed --script @file or text LLM only segments + writes image prompts; text unchanged verbatim",
|
|
23
|
+
"",
|
|
24
|
+
"Examples:",
|
|
25
|
+
" rf content scene-plan -t '深夜便利店' -d 60 -p slow",
|
|
26
|
+
" rf content scene-plan --script @./my-script.txt -p fast",
|
|
27
|
+
" rf content scene-plan -t '雨天的玻璃窗' --json | jq .scenes",
|
|
93
28
|
].join("\n"))
|
|
94
29
|
.action(async (opts) => {
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
30
|
+
const hasTopic = typeof opts.topic === "string" && opts.topic.length > 0;
|
|
31
|
+
const hasScript = typeof opts.script === "string" && opts.script.length > 0;
|
|
32
|
+
if (!hasTopic && !hasScript) {
|
|
33
|
+
throw new Error("either --topic / -t or --script is required");
|
|
34
|
+
}
|
|
35
|
+
if (hasTopic && hasScript) {
|
|
36
|
+
throw new Error("--topic and --script are mutually exclusive");
|
|
37
|
+
}
|
|
38
|
+
if (opts.pace && !["slow", "normal", "fast"].includes(opts.pace)) {
|
|
39
|
+
throw new Error(`--pace must be one of slow|normal|fast (got: ${opts.pace})`);
|
|
40
|
+
}
|
|
41
|
+
let topic = opts.topic;
|
|
42
|
+
let script = opts.script;
|
|
43
|
+
if (topic?.startsWith("@"))
|
|
44
|
+
topic = (await fs.readFile(topic.slice(1), "utf-8")).trim();
|
|
45
|
+
if (script?.startsWith("@"))
|
|
46
|
+
script = (await fs.readFile(script.slice(1), "utf-8")).trim();
|
|
47
|
+
const body = {};
|
|
48
|
+
if (topic)
|
|
49
|
+
body.topic = topic;
|
|
50
|
+
if (script)
|
|
51
|
+
body.script = script;
|
|
52
|
+
if (opts.duration !== undefined)
|
|
53
|
+
body.duration = opts.duration;
|
|
54
|
+
if (opts.pace)
|
|
55
|
+
body.pace = opts.pace;
|
|
56
|
+
if (opts.model)
|
|
57
|
+
body.model = opts.model;
|
|
58
|
+
const r = await post("/api/v1/content/scene-plan", body);
|
|
59
|
+
print({
|
|
60
|
+
mode: r.mode,
|
|
61
|
+
title: r.title,
|
|
62
|
+
n_scenes: r.scenes.length,
|
|
63
|
+
scenes: r.scenes,
|
|
109
64
|
});
|
|
110
|
-
print(r);
|
|
111
65
|
});
|
|
112
66
|
}
|
package/dist/commands/create.js
CHANGED
|
@@ -8,17 +8,13 @@ import { downloadTo } from "../utils/download.js";
|
|
|
8
8
|
import { info, print, success, warn } from "../utils/output.js";
|
|
9
9
|
const LAST_CREATE_PATH = path.join(os.homedir(), ".reelforge", "last-create.json");
|
|
10
10
|
// ── Cost estimation (mirrors server src/lib/billing.ts) ──────────
|
|
11
|
-
const
|
|
12
|
-
const
|
|
11
|
+
const PLAN_UNITS = 1;
|
|
12
|
+
const TTS_UNITS = 1;
|
|
13
|
+
const ASR_UNITS = 1;
|
|
14
|
+
const IMAGE_UNITS = 3;
|
|
15
|
+
const CHARS_PER_SEC_ZH = 5;
|
|
16
|
+
const TARGET_SEC_PER_SCENE = 8;
|
|
13
17
|
function estimateUnits(body) {
|
|
14
|
-
const mode = body.mode || "generate";
|
|
15
|
-
const titleExplicit = !!body.title;
|
|
16
|
-
const N = body.n_scenes ?? 5;
|
|
17
|
-
// Template type resolution mirrors the server (src/lib/billing.ts):
|
|
18
|
-
// inline HTML → explicit body.frame_template_type
|
|
19
|
-
// → <meta name="template:type" content="..."> in the HTML
|
|
20
|
-
// → default "image"
|
|
21
|
-
// preset key → parsed from the filename prefix (static_/asset_/image_)
|
|
22
18
|
let tplType;
|
|
23
19
|
if (body.frame_template_inline) {
|
|
24
20
|
if (body.frame_template_type) {
|
|
@@ -31,7 +27,7 @@ function estimateUnits(body) {
|
|
|
31
27
|
}
|
|
32
28
|
}
|
|
33
29
|
else {
|
|
34
|
-
const tplKey = body.frame_template || "1080x1920/
|
|
30
|
+
const tplKey = body.frame_template || "1080x1920/image_default.html";
|
|
35
31
|
const tplBase = (tplKey.split("/").pop() || "").toLowerCase();
|
|
36
32
|
tplType = tplBase.startsWith("static_")
|
|
37
33
|
? "static"
|
|
@@ -39,22 +35,20 @@ function estimateUnits(body) {
|
|
|
39
35
|
? "asset"
|
|
40
36
|
: "image";
|
|
41
37
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
// Estimated scene count: from script length (fixed) or from duration (generate).
|
|
39
|
+
let estimatedScenes;
|
|
40
|
+
if (body.script) {
|
|
41
|
+
const estSec = body.script.length / CHARS_PER_SEC_ZH;
|
|
42
|
+
estimatedScenes = Math.max(2, Math.round(estSec / TARGET_SEC_PER_SCENE));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const dur = body.duration ?? 45;
|
|
46
|
+
estimatedScenes = Math.max(2, Math.round(dur / TARGET_SEC_PER_SCENE));
|
|
47
|
+
}
|
|
48
|
+
const imageUnits = tplType === "image" ? estimatedScenes * IMAGE_UNITS : 0;
|
|
49
|
+
return PLAN_UNITS + TTS_UNITS + ASR_UNITS + imageUnits;
|
|
49
50
|
}
|
|
50
51
|
// ── Helpers ─────────────────────────────────────────────────────
|
|
51
|
-
/**
|
|
52
|
-
* Distinguish a local HTML file path from a preset template key.
|
|
53
|
-
* Preset keys look like `"<size>/<file>.html"` (one slash, no dots/slashes
|
|
54
|
-
* outside that pattern). Anything starting with `./`, `../`, `/`, `~`, or
|
|
55
|
-
* containing a backslash, or that ends with `.html` and exists on disk, is
|
|
56
|
-
* treated as a local path.
|
|
57
|
-
*/
|
|
58
52
|
function looksLikeLocalHtmlPath(value) {
|
|
59
53
|
if (/^[.~]|^\//.test(value))
|
|
60
54
|
return true;
|
|
@@ -64,7 +58,8 @@ function looksLikeLocalHtmlPath(value) {
|
|
|
64
58
|
return true;
|
|
65
59
|
return false;
|
|
66
60
|
}
|
|
67
|
-
|
|
61
|
+
/** `@file` prefix → load file contents; raw text → return as-is. */
|
|
62
|
+
async function resolveTextOrFile(input) {
|
|
68
63
|
if (input.startsWith("@")) {
|
|
69
64
|
const file = input.slice(1);
|
|
70
65
|
return (await fs.readFile(file, "utf-8")).trim();
|
|
@@ -93,14 +88,6 @@ async function saveLastCreate(body) {
|
|
|
93
88
|
await fs.writeFile(LAST_CREATE_PATH, JSON.stringify(body, null, 2) + "\n", "utf-8");
|
|
94
89
|
}
|
|
95
90
|
// ── Filename derivation ─────────────────────────────────────────
|
|
96
|
-
//
|
|
97
|
-
// Cascade (highest → lowest):
|
|
98
|
-
// 1. result.title — server's actual video title (LLM or explicit)
|
|
99
|
-
// 2. body.title — user-supplied --title (pre-task fallback)
|
|
100
|
-
// 3. raw topic (mode=generate, length ≤ 60, no @-prefix)
|
|
101
|
-
// 4. @file stem — when text was loaded from @./script.txt
|
|
102
|
-
// 5. "reelforge" literal
|
|
103
|
-
// Always suffixed with "-<task_id[:8]>" to avoid collisions.
|
|
104
91
|
const FILENAME_MAX_CHARS = 40;
|
|
105
92
|
function sanitizeFilename(name) {
|
|
106
93
|
const cleaned = name
|
|
@@ -120,14 +107,8 @@ function computeDefaultFilename(args) {
|
|
|
120
107
|
if (args.resultTitle && args.resultTitle.trim()) {
|
|
121
108
|
base = sanitizeFilename(args.resultTitle);
|
|
122
109
|
}
|
|
123
|
-
else if (args.
|
|
124
|
-
base = sanitizeFilename(args.
|
|
125
|
-
}
|
|
126
|
-
else if (args.mode === "generate" &&
|
|
127
|
-
args.rawTextInput &&
|
|
128
|
-
!args.rawTextInput.startsWith("@") &&
|
|
129
|
-
Array.from(args.rawTextInput).length <= 60) {
|
|
130
|
-
base = sanitizeFilename(args.rawTextInput);
|
|
110
|
+
else if (args.topic && Array.from(args.topic).length <= 60) {
|
|
111
|
+
base = sanitizeFilename(args.topic);
|
|
131
112
|
}
|
|
132
113
|
else if (args.fileStemFromAt) {
|
|
133
114
|
base = sanitizeFilename(args.fileStemFromAt);
|
|
@@ -152,28 +133,31 @@ async function validateOutputPath(out) {
|
|
|
152
133
|
/** Camel-case CLI options → snake_case body, only including provided fields */
|
|
153
134
|
function optsToBody(opts) {
|
|
154
135
|
const out = {};
|
|
155
|
-
if (opts.
|
|
156
|
-
out.
|
|
157
|
-
if (opts.
|
|
158
|
-
out.
|
|
159
|
-
if (opts.
|
|
160
|
-
out.
|
|
161
|
-
if (opts.
|
|
162
|
-
out.
|
|
163
|
-
if (opts.
|
|
164
|
-
out.
|
|
165
|
-
if (opts.
|
|
166
|
-
out.
|
|
167
|
-
if (opts.
|
|
168
|
-
out.
|
|
136
|
+
if (opts.topic !== undefined)
|
|
137
|
+
out.topic = opts.topic;
|
|
138
|
+
if (opts.script !== undefined)
|
|
139
|
+
out.script = opts.script;
|
|
140
|
+
if (opts.duration !== undefined)
|
|
141
|
+
out.duration = opts.duration;
|
|
142
|
+
if (opts.pace !== undefined)
|
|
143
|
+
out.pace = opts.pace;
|
|
144
|
+
if (opts.llmModel !== undefined)
|
|
145
|
+
out.llm_model = opts.llmModel;
|
|
146
|
+
if (opts.ttsModel !== undefined)
|
|
147
|
+
out.tts_model = opts.ttsModel;
|
|
148
|
+
if (opts.asrModel !== undefined)
|
|
149
|
+
out.asr_model = opts.asrModel;
|
|
150
|
+
if (opts.imageModel !== undefined)
|
|
151
|
+
out.image_model = opts.imageModel;
|
|
152
|
+
if (opts.promptPrefix !== undefined)
|
|
153
|
+
out.prompt_prefix = opts.promptPrefix;
|
|
169
154
|
if (opts.voiceId !== undefined)
|
|
170
155
|
out.voice_id = opts.voiceId;
|
|
171
156
|
if (opts.ttsSpeed !== undefined)
|
|
172
157
|
out.tts_speed = opts.ttsSpeed;
|
|
173
|
-
if (opts.
|
|
174
|
-
out.
|
|
158
|
+
if (opts.videoFps !== undefined)
|
|
159
|
+
out.video_fps = opts.videoFps;
|
|
175
160
|
if (opts.frameTemplate !== undefined) {
|
|
176
|
-
// Local .html path → read and send as inline; preset key → send as-is.
|
|
177
161
|
if (looksLikeLocalHtmlPath(opts.frameTemplate)) {
|
|
178
162
|
const abs = path.resolve(opts.frameTemplate);
|
|
179
163
|
if (!fsSync.existsSync(abs)) {
|
|
@@ -189,35 +173,14 @@ function optsToBody(opts) {
|
|
|
189
173
|
out.frame_template_size = opts.frameTemplateSize;
|
|
190
174
|
if (opts.frameTemplateType !== undefined)
|
|
191
175
|
out.frame_template_type = opts.frameTemplateType;
|
|
192
|
-
if (opts.promptPrefix !== undefined)
|
|
193
|
-
out.prompt_prefix = opts.promptPrefix;
|
|
194
|
-
if (opts.bgm !== undefined)
|
|
195
|
-
out.bgm_path = opts.bgm;
|
|
196
|
-
if (opts.bgmVolume !== undefined)
|
|
197
|
-
out.bgm_volume = opts.bgmVolume;
|
|
198
|
-
if (opts.bgmMode !== undefined)
|
|
199
|
-
out.bgm_mode = opts.bgmMode;
|
|
200
|
-
if (opts.minNarrationWords !== undefined)
|
|
201
|
-
out.min_narration_words = opts.minNarrationWords;
|
|
202
|
-
if (opts.maxNarrationWords !== undefined)
|
|
203
|
-
out.max_narration_words = opts.maxNarrationWords;
|
|
204
|
-
if (opts.minImagePromptWords !== undefined)
|
|
205
|
-
out.min_image_prompt_words = opts.minImagePromptWords;
|
|
206
|
-
if (opts.maxImagePromptWords !== undefined)
|
|
207
|
-
out.max_image_prompt_words = opts.maxImagePromptWords;
|
|
208
|
-
if (opts.videoFps !== undefined)
|
|
209
|
-
out.video_fps = opts.videoFps;
|
|
210
176
|
if (opts.templateParams !== undefined)
|
|
211
177
|
out.template_params = opts.templateParams;
|
|
178
|
+
if (opts.subtitleMinChars !== undefined)
|
|
179
|
+
out.subtitle_min_chars = opts.subtitleMinChars;
|
|
180
|
+
if (opts.subtitleHardMax !== undefined)
|
|
181
|
+
out.subtitle_hard_max = opts.subtitleHardMax;
|
|
212
182
|
return out;
|
|
213
183
|
}
|
|
214
|
-
const DEFAULTS = {
|
|
215
|
-
mode: "generate",
|
|
216
|
-
n_scenes: 5,
|
|
217
|
-
frame_template: "1080x1920/image_default.html",
|
|
218
|
-
tts_voice: "zh-CN-YunjianNeural",
|
|
219
|
-
tts_speed: 1.2,
|
|
220
|
-
};
|
|
221
184
|
const STYLE_PRESETS = {
|
|
222
185
|
matchstick: {
|
|
223
186
|
prefix: "Minimalist black-and-white matchstick figure style illustration, clean lines, simple sketch style",
|
|
@@ -305,7 +268,6 @@ const STYLE_PRESETS = {
|
|
|
305
268
|
scene: "奢华品牌 / 复古优雅",
|
|
306
269
|
},
|
|
307
270
|
};
|
|
308
|
-
// CJK chars take 2 display columns in monospace terminals; pad accordingly.
|
|
309
271
|
function displayWidth(s) {
|
|
310
272
|
let w = 0;
|
|
311
273
|
for (const c of s)
|
|
@@ -331,36 +293,32 @@ function formatStylePresetsList() {
|
|
|
331
293
|
export function registerCreate(program) {
|
|
332
294
|
program
|
|
333
295
|
.command("create [topic]")
|
|
334
|
-
.description("One-click: topic → fully-generated MP4.
|
|
296
|
+
.description("One-click: topic (or your own script) → fully-generated MP4. Audio-first pipeline.")
|
|
335
297
|
.helpOption("-h, --help", "show help")
|
|
336
|
-
// --- Content ---
|
|
337
|
-
.option("-t, --
|
|
338
|
-
.option("--
|
|
339
|
-
.option("--
|
|
340
|
-
.option("-
|
|
341
|
-
.option("--split-mode <mode>", "paragraph | line | sentence (mode=fixed only)")
|
|
342
|
-
.option("--min-narration-words <N>", "narration min words per scene", (v) => parseInt(v, 10))
|
|
343
|
-
.option("--max-narration-words <N>", "narration max words per scene", (v) => parseInt(v, 10))
|
|
344
|
-
.option("--min-image-prompt-words <N>", "image prompt min words", (v) => parseInt(v, 10))
|
|
345
|
-
.option("--max-image-prompt-words <N>", "image prompt max words", (v) => parseInt(v, 10))
|
|
298
|
+
// --- Content (exactly one of --topic / --script) ---
|
|
299
|
+
.option("-t, --topic <text>", "video topic; AI writes the script (mode=generate). Prefix with @file to read from disk.")
|
|
300
|
+
.option("--script <text>", "your own master script text; AI just plans scenes + visuals (mode=fixed). Prefix with @file to read from disk.")
|
|
301
|
+
.option("-d, --duration <sec>", "target video duration in seconds (generate mode only; default 45). LLM aims for ~duration × 5 chars of narration.", (v) => parseInt(v, 10))
|
|
302
|
+
.option("-p, --pace <pace>", "visual rhythm hint passed to the LLM: slow | normal | fast (default normal). LLM still decides the actual scene count from semantic structure.")
|
|
346
303
|
// --- Visual ---
|
|
347
304
|
.option("--frame-template <keyOrPath>", "HTML frame template: preset key (e.g. 1080x1920/image_default.html) OR path to a local .html (auto-sent inline)")
|
|
348
305
|
.option("--frame-template-size <wxh>", "size for inline HTML when the file lacks <meta template:width|height>, e.g. 1080x1920")
|
|
349
|
-
.option("--frame-template-type <type>", "inline template type: image (default) | static | asset. Controls whether AI image generation runs per
|
|
306
|
+
.option("--frame-template-type <type>", "inline template type: image (default) | static | asset. Controls whether AI image generation runs per scene.")
|
|
350
307
|
.option("--image-model <id>", "RelayX image model (rx-image-z | rx-image-flux | rx-image-qwen)")
|
|
351
308
|
.option("--prompt-prefix <text>", "raw style prefix prepended to every image prompt (overrides --style)")
|
|
352
|
-
.option("--style <preset>", "image style preset — shortcut for --prompt-prefix; see 'Style presets' below
|
|
309
|
+
.option("--style <preset>", "image style preset — shortcut for --prompt-prefix; see 'Style presets' below")
|
|
353
310
|
// --- Audio (TTS) ---
|
|
354
|
-
.option("--
|
|
355
|
-
.option("--tts-speed <n>", "speech speed 0.5..2", parseFloat)
|
|
356
|
-
|
|
357
|
-
.option("--
|
|
358
|
-
|
|
359
|
-
.option("--
|
|
360
|
-
|
|
361
|
-
.option("--
|
|
311
|
+
.option("--voice-id <id>", "RelayX TTS voice id (default 专业解说); see `rf tts voices`")
|
|
312
|
+
.option("--tts-speed <n>", "speech speed 0.5..2 (default 1.0)", parseFloat)
|
|
313
|
+
// --- Service overrides ---
|
|
314
|
+
.option("--llm-model <id>", "override the LLM model used for scene-plan")
|
|
315
|
+
.option("--tts-model <id>", "override the TTS model (default vox/index-tts-2)")
|
|
316
|
+
.option("--asr-model <id>", "override the ASR model (default alibaba/paraformer-v2)")
|
|
317
|
+
// --- Subtitle splitter knobs (advanced) ---
|
|
318
|
+
.option("--subtitle-min-chars <N>", "subtitle line min chars (default 10)", (v) => parseInt(v, 10))
|
|
319
|
+
.option("--subtitle-hard-max <N>", "subtitle line absolute max chars (default 24)", (v) => parseInt(v, 10))
|
|
362
320
|
// --- Output / extra ---
|
|
363
|
-
.option("--video-fps <n>", "output video fps", (v) => parseInt(v, 10))
|
|
321
|
+
.option("--video-fps <n>", "output video fps (default 30)", (v) => parseInt(v, 10))
|
|
364
322
|
.option("--template-params <json>", "extra template placeholders as JSON string", (v) => {
|
|
365
323
|
try {
|
|
366
324
|
return JSON.parse(v);
|
|
@@ -374,110 +332,68 @@ export function registerCreate(program) {
|
|
|
374
332
|
.option("--redo", "replay last successful create from ~/.reelforge/last-create.json")
|
|
375
333
|
.option("--dry-run", "print the final request body + estimated units; do NOT submit")
|
|
376
334
|
.option("--no-wait", "submit and return task_id immediately (do not poll)")
|
|
377
|
-
.option("-o, --output <file>", "save the final video to this exact path (must include filename, e.g. ./out/space.mp4).
|
|
378
|
-
.option("--no-download", "do not save the video locally — just print
|
|
335
|
+
.option("-o, --output <file>", "save the final video to this exact path (must include filename, e.g. ./out/space.mp4).")
|
|
336
|
+
.option("--no-download", "do not save the video locally — just print JSON with video_url")
|
|
379
337
|
.option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
|
|
380
338
|
.option("--timeout-ms <ms>", "max wait time before aborting (default unlimited)", (v) => parseInt(v, 10))
|
|
381
339
|
.addHelpText("after", [
|
|
382
340
|
"",
|
|
383
|
-
"
|
|
384
|
-
"
|
|
385
|
-
"
|
|
341
|
+
"Two content modes (one is required):",
|
|
342
|
+
" generate AI writes the script. --topic / -t <text> + optional --duration -d",
|
|
343
|
+
" fixed You supply the script. --script <text-or-@file>",
|
|
386
344
|
"",
|
|
387
|
-
"
|
|
388
|
-
"
|
|
389
|
-
"
|
|
390
|
-
"
|
|
391
|
-
"
|
|
392
|
-
"
|
|
393
|
-
"
|
|
345
|
+
"Pace (visual rhythm hint to the LLM):",
|
|
346
|
+
" slow fewer scenes, glued to semantic boundaries",
|
|
347
|
+
" normal balance semantic edges with visual variety (default)",
|
|
348
|
+
" fast split long semantic chunks into multiple shots for variety",
|
|
349
|
+
"",
|
|
350
|
+
"Defaults:",
|
|
351
|
+
" duration=45s · pace=normal · frame-template=1080x1920/image_default.html · tts-speed=1.0",
|
|
394
352
|
"",
|
|
395
353
|
"Style presets (--style <preset>) — quick shortcut for --prompt-prefix:",
|
|
396
354
|
formatStylePresetsList(),
|
|
397
355
|
" · Pass --prompt-prefix to override (raw string always wins).",
|
|
398
|
-
" · Omit both to use the server's configured default style.",
|
|
356
|
+
" · Omit both to use the server's configured default style (if any).",
|
|
399
357
|
"",
|
|
400
358
|
"Output behavior:",
|
|
401
|
-
" No flag
|
|
402
|
-
" -o <path>
|
|
359
|
+
" No flag → saves to ./<title>-<task_id>.mp4 in current directory, prints the path",
|
|
360
|
+
" -o <path> → saves to that exact path (must include filename)",
|
|
403
361
|
" --no-download → skips local save, just prints JSON result with video_url",
|
|
404
362
|
" (when stdout is piped, --no-download is implied automatically)",
|
|
405
363
|
"",
|
|
406
|
-
"
|
|
407
|
-
"
|
|
408
|
-
" reelforge tts voices --locale zh # Edge TTS voice ids",
|
|
409
|
-
" reelforge bgm list # built-in BGM files",
|
|
410
|
-
"",
|
|
411
|
-
"Examples (`rf` is a short alias for `reelforge`):",
|
|
412
|
-
" # Minimum — saves to ./<title>-<short_id>.mp4 in cwd",
|
|
364
|
+
"Examples (`rf` is the short alias):",
|
|
365
|
+
" # Minimum — AI writes a 45s script",
|
|
413
366
|
' rf create "为什么我们还没找到外星文明?"',
|
|
414
367
|
"",
|
|
415
|
-
" #
|
|
416
|
-
' rf create "..." -
|
|
417
|
-
"",
|
|
418
|
-
" # Long script from a file, fixed mode (no LLM scriptwriting)",
|
|
419
|
-
" rf create @./script.txt --mode fixed --split-mode paragraph",
|
|
368
|
+
" # 60-second video with slow visual pace",
|
|
369
|
+
' rf create "..." -d 60 -p slow',
|
|
420
370
|
"",
|
|
421
|
-
" #
|
|
422
|
-
|
|
371
|
+
" # Your own script, you decide the wording",
|
|
372
|
+
" rf create --script @./script.txt",
|
|
373
|
+
' rf create --script "整段文案文本..."',
|
|
423
374
|
"",
|
|
424
|
-
" #
|
|
425
|
-
|
|
426
|
-
"",
|
|
427
|
-
" # Change voice + speed",
|
|
428
|
-
' rf create "..." --tts-voice zh-CN-XiaoxiaoNeural --tts-speed 1.0',
|
|
375
|
+
" # Custom HTML template (auto-detected when --frame-template is a local path)",
|
|
376
|
+
" rf create '...' --frame-template ./my-brand.html",
|
|
429
377
|
"",
|
|
430
378
|
" # Pick a built-in style preset",
|
|
431
379
|
' rf create "..." --style cinematic',
|
|
432
|
-
' rf create "美食教程" --style photorealistic',
|
|
433
|
-
"",
|
|
434
|
-
" # Free-form style — write your own prefix from scratch",
|
|
435
|
-
' rf create "..." --prompt-prefix "Studio Ghibli, pastel, dreamy"',
|
|
436
380
|
"",
|
|
437
|
-
" #
|
|
438
|
-
" rf create '...' --frame-template ./my-brand.html",
|
|
439
|
-
" # ↳ default type=image (best-practice; AI image generated per scene).",
|
|
440
|
-
" # ↳ pure-text template? declare `--frame-template-type static`",
|
|
441
|
-
" # OR add `<meta name=\"template:type\" content=\"static\">` inside the HTML.",
|
|
442
|
-
"",
|
|
443
|
-
" # Full recipe in one file",
|
|
381
|
+
" # Recipe + replay last",
|
|
444
382
|
" rf create --recipe ./space.recipe.json",
|
|
445
|
-
"",
|
|
446
|
-
"
|
|
447
|
-
' rf create --recipe ./space.recipe.json --text "新主题" -n 8',
|
|
448
|
-
"",
|
|
449
|
-
" # Replay last successful create",
|
|
450
|
-
" rf create --redo",
|
|
451
|
-
"",
|
|
452
|
-
" # Replay last but tweak one knob",
|
|
453
|
-
" rf create --redo --tts-speed 1.0",
|
|
383
|
+
" rf create --redo # replay last successful create",
|
|
384
|
+
" rf create --redo -p fast # replay with one knob tweaked",
|
|
454
385
|
"",
|
|
455
386
|
" # See exactly what would be sent (no submission)",
|
|
456
|
-
' rf create "..." -
|
|
387
|
+
' rf create "..." -d 60 --dry-run',
|
|
457
388
|
"",
|
|
458
|
-
" # Pipe-friendly
|
|
389
|
+
" # Pipe-friendly",
|
|
459
390
|
' rf create "..." --no-download --json | jq -r .video_url',
|
|
460
|
-
"",
|
|
461
|
-
"Recipe file format (every field is optional; all keys match the REST API body):",
|
|
462
|
-
" {",
|
|
463
|
-
' "text": "为什么我们还没找到外星文明?",',
|
|
464
|
-
' "n_scenes": 7,',
|
|
465
|
-
' "frame_template": "1080x1920/image_default.html",',
|
|
466
|
-
' "image_model": "rx-image-flux",',
|
|
467
|
-
' "prompt_prefix": "Minimalist matchstick figure style",',
|
|
468
|
-
' "tts_voice": "zh-CN-YunjianNeural",',
|
|
469
|
-
' "tts_speed": 1.2,',
|
|
470
|
-
' "bgm_path": "bgm/Echoes.mp3",',
|
|
471
|
-
' "bgm_volume": 0.2',
|
|
472
|
-
" }",
|
|
473
391
|
].join("\n"))
|
|
474
392
|
.action(async (topicArg, opts) => {
|
|
475
|
-
// Validate -o early so we fail before submitting a paid task
|
|
476
393
|
if (opts.output) {
|
|
477
394
|
await validateOutputPath(opts.output);
|
|
478
395
|
}
|
|
479
|
-
// Expand --style preset to --prompt-prefix unless
|
|
480
|
-
// --prompt-prefix is also given (the raw string always wins).
|
|
396
|
+
// Expand --style preset to --prompt-prefix unless --prompt-prefix is given.
|
|
481
397
|
if (opts.style) {
|
|
482
398
|
const preset = STYLE_PRESETS[opts.style];
|
|
483
399
|
if (!preset) {
|
|
@@ -487,6 +403,9 @@ export function registerCreate(program) {
|
|
|
487
403
|
opts.promptPrefix = preset.prefix;
|
|
488
404
|
}
|
|
489
405
|
}
|
|
406
|
+
if (opts.pace && !["slow", "normal", "fast"].includes(opts.pace)) {
|
|
407
|
+
throw new Error(`--pace must be one of slow|normal|fast (got: ${opts.pace})`);
|
|
408
|
+
}
|
|
490
409
|
// 1. Layer defaults: --redo → --recipe → CLI opts → positional topic
|
|
491
410
|
let body = {};
|
|
492
411
|
if (opts.redo) {
|
|
@@ -502,51 +421,49 @@ export function registerCreate(program) {
|
|
|
502
421
|
body = { ...body, ...recipe };
|
|
503
422
|
info(`Loaded recipe from ${opts.recipe}`);
|
|
504
423
|
}
|
|
505
|
-
// CLI options layer
|
|
506
424
|
const fromOpts = optsToBody(opts);
|
|
507
425
|
body = { ...body, ...fromOpts };
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
const
|
|
511
|
-
const fileStemFromAt =
|
|
512
|
-
? path.parse(
|
|
513
|
-
|
|
514
|
-
// Positional topic wins for `text` (with @file support)
|
|
426
|
+
// Positional arg always wins for `topic`.
|
|
427
|
+
// Resolve @file prefix on whichever of topic/script is set.
|
|
428
|
+
const rawTopicInput = topicArg ?? (typeof body.topic === "string" ? body.topic : undefined);
|
|
429
|
+
const fileStemFromAt = rawTopicInput?.startsWith("@") ? path.parse(rawTopicInput.slice(1)).name :
|
|
430
|
+
body.script?.startsWith("@") ? path.parse(body.script.slice(1)).name :
|
|
431
|
+
undefined;
|
|
515
432
|
if (topicArg) {
|
|
516
|
-
body.
|
|
433
|
+
body.topic = await resolveTextOrFile(topicArg);
|
|
434
|
+
}
|
|
435
|
+
else if (typeof body.topic === "string") {
|
|
436
|
+
body.topic = await resolveTextOrFile(body.topic);
|
|
437
|
+
}
|
|
438
|
+
if (typeof body.script === "string") {
|
|
439
|
+
body.script = await resolveTextOrFile(body.script);
|
|
517
440
|
}
|
|
518
|
-
|
|
519
|
-
|
|
441
|
+
// Validate content mode
|
|
442
|
+
const hasTopic = typeof body.topic === "string" && body.topic.trim().length > 0;
|
|
443
|
+
const hasScript = typeof body.script === "string" && body.script.trim().length > 0;
|
|
444
|
+
if (!hasTopic && !hasScript) {
|
|
445
|
+
throw new Error("either --topic (or positional arg) or --script is required.");
|
|
520
446
|
}
|
|
521
|
-
if (
|
|
522
|
-
throw new Error("
|
|
447
|
+
if (hasTopic && hasScript) {
|
|
448
|
+
throw new Error("--topic and --script are mutually exclusive (pick one mode).");
|
|
523
449
|
}
|
|
524
|
-
//
|
|
525
|
-
const finalBody = {
|
|
526
|
-
...DEFAULTS,
|
|
527
|
-
...body,
|
|
528
|
-
text: body.text,
|
|
529
|
-
};
|
|
530
|
-
// When the user supplied inline HTML, the DEFAULTS' `frame_template`
|
|
531
|
-
// key is irrelevant — drop it so the server-side request body stays
|
|
532
|
-
// clean and the dry-run output isn't misleading.
|
|
450
|
+
// 3. Final body — drop empty / null fields
|
|
451
|
+
const finalBody = { ...body };
|
|
533
452
|
if (finalBody.frame_template_inline && finalBody.frame_template) {
|
|
534
453
|
delete finalBody.frame_template;
|
|
535
454
|
}
|
|
536
|
-
//
|
|
455
|
+
// 4. Estimate cost
|
|
537
456
|
const estimate = estimateUnits(finalBody);
|
|
538
|
-
// 4. Dry-run: print & exit
|
|
539
457
|
if (opts.dryRun) {
|
|
540
458
|
info("--- DRY RUN ---");
|
|
541
459
|
info("Final request body:");
|
|
542
460
|
print(finalBody);
|
|
543
|
-
info(`Estimated cost: ${estimate} units`);
|
|
461
|
+
info(`Estimated cost: ≈ ${estimate} units`);
|
|
544
462
|
info("(use without --dry-run to actually submit)");
|
|
545
463
|
return;
|
|
546
464
|
}
|
|
547
465
|
info(`Submitting create task (≈ ${estimate} units)...`);
|
|
548
466
|
const submitted = await post("/api/v1/pipelines/standard", finalBody);
|
|
549
|
-
// 5. Save as last (post-submit, before wait — so even cancelled tasks can be replayed)
|
|
550
467
|
await saveLastCreate(finalBody).catch((e) => {
|
|
551
468
|
warn(`Could not save last-create.json: ${e.message}`);
|
|
552
469
|
});
|
|
@@ -563,11 +480,6 @@ export function registerCreate(program) {
|
|
|
563
480
|
throw new Error(t.error || `Task ended with status ${t.status}`);
|
|
564
481
|
}
|
|
565
482
|
const result = t.result;
|
|
566
|
-
// Decide where (or whether) to save locally.
|
|
567
|
-
// -o → that exact path
|
|
568
|
-
// --no-download → skip
|
|
569
|
-
// stdout piped → skip (clig.dev: don't dump binary-touching side effects into a script)
|
|
570
|
-
// otherwise → auto-named in cwd
|
|
571
483
|
if (result?.video_url) {
|
|
572
484
|
const stdoutIsPipe = !process.stdout.isTTY;
|
|
573
485
|
const skipDownload = !!opts.noDownload || (stdoutIsPipe && !opts.output);
|
|
@@ -576,11 +488,10 @@ export function registerCreate(program) {
|
|
|
576
488
|
savedPath = opts.output;
|
|
577
489
|
}
|
|
578
490
|
else if (!skipDownload) {
|
|
491
|
+
const topicForFilename = hasTopic && finalBody.topic ? finalBody.topic : undefined;
|
|
579
492
|
savedPath = computeDefaultFilename({
|
|
580
493
|
resultTitle: result.title,
|
|
581
|
-
|
|
582
|
-
mode: finalBody.mode,
|
|
583
|
-
rawTextInput,
|
|
494
|
+
topic: topicForFilename,
|
|
584
495
|
fileStemFromAt,
|
|
585
496
|
taskId: t.id,
|
|
586
497
|
ext: "mp4",
|
|
@@ -36,56 +36,74 @@ export function registerPipelines(program) {
|
|
|
36
36
|
const pl = program
|
|
37
37
|
.command("pipelines")
|
|
38
38
|
.alias("pipeline")
|
|
39
|
-
.description("End-to-end video pipelines (standard)")
|
|
39
|
+
.description("End-to-end video pipelines (standard, audio-first)")
|
|
40
40
|
.helpOption("-h, --help", "show help");
|
|
41
41
|
// ---------- standard ----------
|
|
42
42
|
commonOptions(pl
|
|
43
43
|
.command("standard")
|
|
44
|
-
.description("
|
|
44
|
+
.description("Audio-first pipeline: topic|script → master TTS → ASR → scene/subtitle layers → final MP4")
|
|
45
45
|
.helpOption("-h, --help", "show help")
|
|
46
|
-
.
|
|
47
|
-
.option("--
|
|
48
|
-
.option("--
|
|
49
|
-
.option("-
|
|
50
|
-
.option("--
|
|
51
|
-
.option("--frame-template <keyOrPath>", "preset key (e.g. 1080x1920/static_default.html) OR path to a local .html file", "1080x1920/static_default.html")
|
|
46
|
+
.option("-t, --topic <text>", "video topic (mode=generate). Use @file to read from disk.")
|
|
47
|
+
.option("--script <text>", "your own master script text (mode=fixed). Use @file to read from disk.")
|
|
48
|
+
.option("-d, --duration <sec>", "target video duration in seconds (generate mode; default 45)", (v) => parseInt(v, 10))
|
|
49
|
+
.option("-p, --pace <pace>", "visual rhythm hint: slow | normal | fast (default normal)")
|
|
50
|
+
.option("--frame-template <keyOrPath>", "preset key (e.g. 1080x1920/image_default.html) OR path to a local .html file")
|
|
52
51
|
.option("--frame-template-size <wxh>", "size for inline HTML when the file lacks <meta template:width|height>")
|
|
53
|
-
.option("--frame-template-type <type>", "inline type: image (default) | static | asset
|
|
54
|
-
.option("--image-model <id>", "RelayX image model (rx-image-z | rx-image-flux | rx-image-qwen)
|
|
55
|
-
.option("--prompt-prefix <text>", "style prefix prepended to image
|
|
56
|
-
.option("--
|
|
57
|
-
.option("--tts-speed <n>", "speech speed (0.5..2)", parseFloat
|
|
58
|
-
.option("--
|
|
59
|
-
.option("--
|
|
52
|
+
.option("--frame-template-type <type>", "inline type: image (default) | static | asset")
|
|
53
|
+
.option("--image-model <id>", "RelayX image model (rx-image-z | rx-image-flux | rx-image-qwen)")
|
|
54
|
+
.option("--prompt-prefix <text>", "style prefix prepended to every image prompt")
|
|
55
|
+
.option("--voice-id <id>", "RelayX TTS voice id (default 专业解说); see `rf tts voices`")
|
|
56
|
+
.option("--tts-speed <n>", "speech speed (0.5..2; default 1.0)", parseFloat)
|
|
57
|
+
.option("--video-fps <n>", "output video fps (default 30)", (v) => parseInt(v, 10))
|
|
58
|
+
.option("--subtitle-min-chars <N>", "subtitle line min chars (default 10)", (v) => parseInt(v, 10))
|
|
59
|
+
.option("--subtitle-hard-max <N>", "subtitle line absolute max chars (default 24)", (v) => parseInt(v, 10))
|
|
60
60
|
.addHelpText("after", [
|
|
61
61
|
"",
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
62
|
+
"Two content modes (exactly one required):",
|
|
63
|
+
" generate AI writes the script. --topic / -t <text> + optional --duration -d",
|
|
64
|
+
" fixed You supply the script. --script <text-or-@file>",
|
|
65
|
+
"",
|
|
66
|
+
"Pace (LLM visual rhythm hint): slow | normal | fast",
|
|
66
67
|
"",
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
68
|
+
"Examples:",
|
|
69
|
+
" rf pipelines standard -t 'why we explore space' -d 60 -o space.mp4",
|
|
70
|
+
" rf pipelines standard --script @script.txt -p slow -o out.mp4",
|
|
71
|
+
" rf pipelines standard -t '宠物' --frame-template ./my-brand.html -o final.mp4",
|
|
70
72
|
].join("\n"))).action(async (opts) => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
const hasTopic = typeof opts.topic === "string" && opts.topic.length > 0;
|
|
74
|
+
const hasScript = typeof opts.script === "string" && opts.script.length > 0;
|
|
75
|
+
if (!hasTopic && !hasScript) {
|
|
76
|
+
throw new Error("either --topic / -t or --script is required");
|
|
77
|
+
}
|
|
78
|
+
if (hasTopic && hasScript) {
|
|
79
|
+
throw new Error("--topic and --script are mutually exclusive");
|
|
80
|
+
}
|
|
81
|
+
if (opts.pace && !["slow", "normal", "fast"].includes(opts.pace)) {
|
|
82
|
+
throw new Error(`--pace must be one of slow|normal|fast (got: ${opts.pace})`);
|
|
83
|
+
}
|
|
84
|
+
let topic = opts.topic;
|
|
85
|
+
let script = opts.script;
|
|
86
|
+
if (topic?.startsWith("@"))
|
|
87
|
+
topic = await fs.readFile(topic.slice(1), "utf-8");
|
|
88
|
+
if (script?.startsWith("@"))
|
|
89
|
+
script = await fs.readFile(script.slice(1), "utf-8");
|
|
90
|
+
const tpl = opts.frameTemplate
|
|
91
|
+
? resolveTemplateArg(opts.frameTemplate, opts.frameTemplateSize)
|
|
92
|
+
: {};
|
|
75
93
|
await submitAndMaybeWait("/api/v1/pipelines/standard", {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
split_mode: opts.splitMode,
|
|
94
|
+
topic,
|
|
95
|
+
script,
|
|
96
|
+
duration: opts.duration,
|
|
97
|
+
pace: opts.pace,
|
|
81
98
|
...tpl,
|
|
82
99
|
frame_template_type: opts.frameTemplateType,
|
|
83
100
|
image_model: opts.imageModel,
|
|
84
101
|
prompt_prefix: opts.promptPrefix,
|
|
85
|
-
|
|
102
|
+
voice_id: opts.voiceId,
|
|
86
103
|
tts_speed: opts.ttsSpeed,
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
video_fps: opts.videoFps,
|
|
105
|
+
subtitle_min_chars: opts.subtitleMinChars,
|
|
106
|
+
subtitle_hard_max: opts.subtitleHardMax,
|
|
89
107
|
}, { wait: opts.wait, output: opts.output, pollMs: opts.pollMs, timeoutMs: opts.timeoutMs });
|
|
90
108
|
});
|
|
91
109
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { post } from "../client.js";
|
|
3
|
+
import { print } from "../utils/output.js";
|
|
4
|
+
export function registerSubtitles(program) {
|
|
5
|
+
const sub = program
|
|
6
|
+
.command("subtitles")
|
|
7
|
+
.alias("subtitle")
|
|
8
|
+
.description("Subtitle atomics — deterministic line splitter (no LLM, no billing)")
|
|
9
|
+
.helpOption("-h, --help", "show help");
|
|
10
|
+
sub
|
|
11
|
+
.command("split")
|
|
12
|
+
.description("Split a chunk of text into subtitle-sized lines using tiered punctuation priority")
|
|
13
|
+
.helpOption("-h, --help", "show help")
|
|
14
|
+
.requiredOption("-t, --text <text>", "text to split. Use @file to read from disk.")
|
|
15
|
+
.option("--min <N>", "minimum line length in chars (default 10)", (v) => parseInt(v, 10))
|
|
16
|
+
.option("--hard-max <N>", "absolute maximum line length in chars (default 24)", (v) => parseInt(v, 10))
|
|
17
|
+
.addHelpText("after", [
|
|
18
|
+
"",
|
|
19
|
+
"Rule:",
|
|
20
|
+
" Within [min, hard-max], pick the highest-tier punctuation; same tier → latest position.",
|
|
21
|
+
" Tier 1 (。!?) > Tier 2 (;:) > Tier 3 (,、)",
|
|
22
|
+
" No punctuation in window → force-cut at hard-max.",
|
|
23
|
+
"",
|
|
24
|
+
"Examples:",
|
|
25
|
+
" rf subtitles split -t '雨水缓缓滑落在玻璃窗上,像是无声的泪珠。'",
|
|
26
|
+
" rf subtitles split -t @./narration.txt --min 8 --hard-max 20",
|
|
27
|
+
].join("\n"))
|
|
28
|
+
.action(async (opts) => {
|
|
29
|
+
let text = opts.text;
|
|
30
|
+
if (text.startsWith("@"))
|
|
31
|
+
text = (await fs.readFile(text.slice(1), "utf-8")).trim();
|
|
32
|
+
const body = { text };
|
|
33
|
+
if (opts.min !== undefined)
|
|
34
|
+
body.min_chars = opts.min;
|
|
35
|
+
if (opts.hardMax !== undefined)
|
|
36
|
+
body.hard_max = opts.hardMax;
|
|
37
|
+
const r = await post("/api/v1/subtitles/split", body);
|
|
38
|
+
print({ count: r.count, lines: r.lines });
|
|
39
|
+
});
|
|
40
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,8 @@ import { registerModels } from "./commands/models.js";
|
|
|
19
19
|
import { registerTts } from "./commands/tts.js";
|
|
20
20
|
import { registerImages } from "./commands/images.js";
|
|
21
21
|
import { registerContent } from "./commands/content.js";
|
|
22
|
+
import { registerAudio } from "./commands/audio.js";
|
|
23
|
+
import { registerSubtitles } from "./commands/subtitles.js";
|
|
22
24
|
import { registerTemplates } from "./commands/templates.js";
|
|
23
25
|
import { registerFrames } from "./commands/frames.js";
|
|
24
26
|
import { registerCompositions } from "./commands/compositions.js";
|
|
@@ -70,7 +72,7 @@ program.addHelpText("afterAll", [
|
|
|
70
72
|
" rf llm chat --prompt 'explain antifragile in 3 sentences'",
|
|
71
73
|
" rf tts edge --text 'hello world' --voice en-US-AriaNeural -o out.mp3",
|
|
72
74
|
" rf images generate --prompt 'a cat' --model rx-image-flux -o cat.png",
|
|
73
|
-
" rf pipelines standard
|
|
75
|
+
" rf pipelines standard -t 'why we explore space' -d 60",
|
|
74
76
|
" rf tasks list --status running",
|
|
75
77
|
" rf config get",
|
|
76
78
|
].join("\n"));
|
|
@@ -81,6 +83,8 @@ registerModels(program);
|
|
|
81
83
|
registerTts(program);
|
|
82
84
|
registerImages(program);
|
|
83
85
|
registerContent(program);
|
|
86
|
+
registerAudio(program);
|
|
87
|
+
registerSubtitles(program);
|
|
84
88
|
registerTemplates(program);
|
|
85
89
|
registerFrames(program);
|
|
86
90
|
registerCompositions(program);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reelforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "CLI for ReelForge Studio — AI video engine. Installs as both `reelforge` and the short alias `rf`. Every REST API exposed as a command, with --help on every level.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|