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 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 generation
82
+ ### Content / audio / subtitle atomics
83
83
 
84
84
  | command | what it does |
85
85
  |---|---|
86
- | `content narration -t <topic>` | Generate N narration sentences from a topic |
87
- | `content split -s <script>` | Split a fixed script into narrations |
88
- | `content image-prompts -i <file>` | English image prompts from narration list |
89
- | `content title -c <content>` | Generate a short video title |
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\|script>` | Topic / script narration frames final MP4 |
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 (auto-saves to ./<title>-<id>.mp4 in cwd)
136
+ # 1. One-click out a video (45s default, AI writes the script)
136
137
  rf create "为什么我们还没找到外星文明?"
137
138
 
138
- # 2. Same, but with a fixed script and explicit output path
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
- -t "Hello world. This is scene one.\n\nThis is scene two." \
141
- --mode fixed --title "Smoke Test" \
142
- --frame-template 1080x1920/static_default.html \
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
- # 3. Inspect existing tasks & redownload a finished video
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
- # 4. JSON pipe for automation
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
- # 5. Configure & test LLM (self-hosted)
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
- # 6. Use your own HTML template (no PR/release needed)
159
- # Any of -t / --frame-template that points to a local .html file is read and
160
- # sent inline. Declare size inside the file via
161
- # <meta name="template:width" content="1080">
162
- # <meta name="template:height" content="1920">
163
- # or pass --size 1080x1920 on the CLI.
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
+ }
@@ -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("LLM-based content generators (script, image prompts, titles, asset scripts)")
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("narration")
11
- .description("Generate N narration sentences from a topic")
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
- .requiredOption("-t, --topic <text>", "the video topic")
14
- .option("-n, --n-scenes <n>", "number of scenes", parseInt, 5)
15
- .option("--min-words <n>", "minimum words per narration", parseInt, 5)
16
- .option("--max-words <n>", "maximum words per narration", parseInt, 20)
17
- .addHelpText("after", "\nExample:\n reelforge content narration -t 'why we explore space' -n 5")
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
- "Example assets.txt:",
91
- " data/uploads/cat.jpg | A fluffy cat",
92
- " data/uploads/dog.jpg | A happy dog wagging tail",
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 raw = await fs.readFile(opts.assets, "utf-8");
96
- const assets = raw
97
- .split(/\r?\n/)
98
- .map((s) => s.trim())
99
- .filter(Boolean)
100
- .map((line) => {
101
- const [p, d] = line.split("|").map((s) => s.trim());
102
- return { path: p, description: d || "" };
103
- });
104
- const r = await post("/api/v1/content/asset-script", {
105
- intent: opts.intent,
106
- title: opts.title,
107
- duration: opts.duration,
108
- assets,
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
  }
@@ -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 IMAGE_UNITS = 3; // matches ATOMIC_UNITS["images.generate"] in src/lib/billing.ts
12
- const TTS_RELAYX_UNITS = 1; // matches ATOMIC_UNITS["tts.relayx"]
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/static_default.html";
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
- const mediaPerFrame = tplType === "image" ? IMAGE_UNITS : 0;
43
- const ttsMode = body.tts_inference_mode || "edge";
44
- const ttsPerFrame = ttsMode === "relayx" ? TTS_RELAYX_UNITS : 0;
45
- const narrations = mode === "generate" ? 1 : 0;
46
- const title = titleExplicit ? 0 : 1;
47
- const imagePrompts = tplType === "static" ? 0 : 1;
48
- return narrations + title + imagePrompts + N * (ttsPerFrame + mediaPerFrame);
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
- async function resolveText(input) {
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.bodyTitle && args.bodyTitle.trim()) {
124
- base = sanitizeFilename(args.bodyTitle);
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.text !== undefined)
156
- out.text = opts.text;
157
- if (opts.mode !== undefined)
158
- out.mode = opts.mode;
159
- if (opts.title !== undefined)
160
- out.title = opts.title;
161
- if (opts.nScenes !== undefined)
162
- out.n_scenes = opts.nScenes;
163
- if (opts.splitMode !== undefined)
164
- out.split_mode = opts.splitMode;
165
- if (opts.ttsInferenceMode !== undefined)
166
- out.tts_inference_mode = opts.ttsInferenceMode;
167
- if (opts.ttsVoice !== undefined)
168
- out.tts_voice = opts.ttsVoice;
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.imageModel !== undefined)
174
- out.image_model = opts.imageModel;
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. 23 tunable params + recipe files.")
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, --text <text>", "topic (mode=generate) or fixed script (mode=fixed). Prefix with @ to read from a file (e.g. @script.txt).")
338
- .option("--mode <mode>", "generate | fixed (default: generate)")
339
- .option("--title <text>", "explicit video title (default: LLM-generated from topic)")
340
- .option("-n, --n-scenes <N>", "number of scenes", (v) => parseInt(v, 10))
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 frame. Can also be set via <meta name=\"template:type\" content=\"...\"> in the HTML.")
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 for the full list")
309
+ .option("--style <preset>", "image style preset — shortcut for --prompt-prefix; see 'Style presets' below")
353
310
  // --- Audio (TTS) ---
354
- .option("--tts-voice <id>", "TTS voice id; for edge use e.g. zh-CN-YunjianNeural / en-US-AriaNeural; for relayx use vox voice ids (default: 专业解说)")
355
- .option("--tts-speed <n>", "speech speed 0.5..2", parseFloat)
356
- .option("--tts-inference-mode <mode>", "edge (default, local Microsoft Edge TTS) | relayx (vox/index-tts-2 via RelayX)")
357
- .option("--voice-id <id>", "alias of --tts-voice (legacy compat)")
358
- // --- Audio (BGM) ---
359
- .option("--bgm <path>", "background music file path (server-side relative to bgm/)")
360
- .option("--bgm-volume <n>", "BGM volume 0..1", parseFloat)
361
- .option("--bgm-mode <mode>", "loop | once")
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). Default: auto-named file in current directory.")
378
- .option("--no-download", "do not save the video locally — just print the JSON result with video_url")
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
- "Defaults match the /create web page:",
384
- " mode=generate · n-scenes=5 · frame-template=1080x1920/image_default.html",
385
- " tts-voice=zh-CN-YunjianNeural · tts-speed=1.2",
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
- "Param groups:",
388
- " Content : --mode --title -n --split-mode --min/max-narration-words --min/max-image-prompt-words",
389
- " Visual : --frame-template --image-model --style --prompt-prefix",
390
- " TTS : --tts-voice --tts-speed --tts-inference-mode --voice-id",
391
- " BGM : --bgm --bgm-volume --bgm-mode",
392
- " Output : --video-fps --template-params -o --no-download --no-wait --poll-ms --timeout-ms",
393
- " Workflow: --recipe --redo --dry-run",
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 → saves to ./<title>-<task_id>.mp4 in current directory, prints the path",
402
- " -o <path> → saves to that exact path (must include filename, not just a directory)",
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
- "Explore available resources (separate commands):",
407
- " reelforge templates list # all HTML templates",
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
- " # Pick the exact output path",
416
- ' rf create "..." -o ./videos/space.mp4',
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
- " # Landscape (1920x1080)",
422
- ' rf create "..." --frame-template 1920x1080/image_default.html',
371
+ " # Your own script, you decide the wording",
372
+ " rf create --script @./script.txt",
373
+ ' rf create --script "整段文案文本..."',
423
374
  "",
424
- " # Add BGM",
425
- ' rf create "..." --bgm bgm/Echoes.mp3 --bgm-volume 0.3 --bgm-mode loop',
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
- " # Custom HTML template (auto-detected when --frame-template is a local path)",
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
- " # Override a field on top of a recipe",
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 "..." -n 7 --bgm bgm/Echoes.mp3 --dry-run',
387
+ ' rf create "..." -d 60 --dry-run',
457
388
  "",
458
- " # Pipe-friendly: skip local download, take video_url for downstream",
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 an explicit
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
- // Capture the raw text input (with potential @-prefix) for filename derivation.
509
- // After `resolveText` we lose the @path file stem mapping.
510
- const rawTextInput = topicArg ?? (typeof body.text === "string" ? body.text : undefined);
511
- const fileStemFromAt = rawTextInput?.startsWith("@")
512
- ? path.parse(rawTextInput.slice(1)).name
513
- : undefined;
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.text = await resolveText(topicArg);
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
- else if (typeof body.text === "string") {
519
- body.text = await resolveText(body.text);
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 (!body.text) {
522
- throw new Error("text is required pass it as the positional arg, or via --text / --recipe / --redo.");
447
+ if (hasTopic && hasScript) {
448
+ throw new Error("--topic and --script are mutually exclusive (pick one mode).");
523
449
  }
524
- // 2. Apply defaults for fields still unset
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
- // 3. Estimate cost
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
- bodyTitle: finalBody.title,
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("Topic / script → narrationframes → final MP4")
44
+ .description("Audio-first pipeline: topic|script → master TTS ASRscene/subtitle layers → final MP4")
45
45
  .helpOption("-h, --help", "show help")
46
- .requiredOption("-t, --text <text>", "topic OR fixed script (use @file)")
47
- .option("--mode <mode>", "generate | fixed", "generate")
48
- .option("--title <text>", "explicit video title (skip LLM title gen)")
49
- .option("-n, --n-scenes <n>", "number of scenes (mode=generate)", parseInt, 5)
50
- .option("--split-mode <mode>", "paragraph | line | sentence (mode=fixed)", "paragraph")
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. Or set <meta name=\"template:type\"> in the HTML.")
54
- .option("--image-model <id>", "RelayX image model (rx-image-z | rx-image-flux | rx-image-qwen) — only when template requires AI images")
55
- .option("--prompt-prefix <text>", "style prefix prepended to image prompts")
56
- .option("--tts-voice <id>", "Edge TTS voice", "zh-CN-YunjianNeural")
57
- .option("--tts-speed <n>", "speech speed (0.5..2)", parseFloat, 1.2)
58
- .option("--bgm <path>", "BGM file path")
59
- .option("--bgm-volume <n>", "BGM volume", parseFloat, 0.2)
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
- "Examples:",
63
- " reelforge pipelines standard -t 'why we explore space' -n 5 -o space.mp4",
64
- " reelforge pipelines standard -t @script.txt --mode fixed --split-mode paragraph --title 'My Show' -o out.mp4",
65
- " reelforge pipelines standard -t '宠物' --frame-template 1080x1920/image_default.html --image-model rx-image-flux --prompt-prefix 'cinematic'",
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
- " Custom HTML template (sent inline; no upload needed):",
68
- " reelforge pipelines standard -t '宠物' --frame-template ./my-brand.html -o final.mp4",
69
- " (declare size via <meta name=\"template:width|height\"> or pass --frame-template-size 1080x1920)",
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
- let text = opts.text;
72
- if (text.startsWith("@"))
73
- text = await fs.readFile(text.slice(1), "utf-8");
74
- const tpl = resolveTemplateArg(opts.frameTemplate, opts.frameTemplateSize);
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
- text,
77
- mode: opts.mode,
78
- title: opts.title,
79
- n_scenes: opts.nScenes,
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
- tts_voice: opts.ttsVoice,
102
+ voice_id: opts.voiceId,
86
103
  tts_speed: opts.ttsSpeed,
87
- bgm_path: opts.bgm,
88
- bgm_volume: opts.bgmVolume,
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 --text 'why we explore space' --tts-voice zh-CN-YunjianNeural",
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.5.5",
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",