reelforge 0.3.1 → 0.3.2

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
@@ -14,37 +14,43 @@ Or use directly without install:
14
14
  npx reelforge <command>
15
15
  ```
16
16
 
17
- After install, two binaries are on your `PATH` — `reelforge` and the short alias `rf`. Both behave identically:
17
+ After install, two binaries are on your `PATH` — `reelforge` and the short alias `rf`. Both behave identically; the docs use `rf` from here on.
18
18
 
19
19
  ```bash
20
20
  rf --version # same as `reelforge --version`
21
- rf create "..." -o out.mp4
22
21
  ```
23
22
 
24
23
  ## Quick start
25
24
 
26
- `reelforge` ships pointing at the hosted instance (`https://reelforge.timor419.com`). Get an activation code from the admin, log in once, and call:
25
+ The CLI ships pointing at the hosted instance (`https://reelforge.timor419.com`). Log in once, then call:
27
26
 
28
27
  ```bash
29
28
  npm install -g reelforge
30
- reelforge login # opens browser, picks up token automatically
31
- reelforge whoami # balance + api_keys
32
- reelforge create "为什么我们还没找到外星文明?" -o space.mp4
33
- # or the short alias:
34
- rf create "为什么我们还没找到外星文明?" -o space.mp4
29
+ rf login # opens browser; headless? rf login <api_key>
30
+ rf whoami # balance + api_keys
31
+ rf create "为什么我们还没找到外星文明?" # auto-saves to ./<title>-<id>.mp4 in cwd
35
32
  ```
36
33
 
37
34
  That's the whole story — no server to run.
38
35
 
36
+ ### Output behavior
37
+
38
+ | invocation | result |
39
+ |---|---|
40
+ | `rf create "..."` | Saves to `./<sanitized-title>-<task_id_short>.mp4`, prints the path |
41
+ | `rf create "..." -o ./videos/space.mp4` | Saves to that exact path (must include filename, not just a directory) |
42
+ | `rf create "..." --no-download` | Skips local save, prints JSON result with `video_url` |
43
+ | `rf create "..." \| jq .video_url` | When stdout is piped, download is skipped automatically |
44
+
39
45
  ### Self-hosting
40
46
 
41
47
  If you want to run your own ReelForge Studio (own LLM / RunningHub keys, your own pricing) clone the upstream repo, `pnpm dev`, then point the CLI at it:
42
48
 
43
49
  ```bash
44
- reelforge --server http://localhost:8501 health
50
+ rf --server http://localhost:8501 health
45
51
  # or persist:
46
52
  export REELFORGE_SERVER=http://localhost:8501
47
- # or via `reelforge login <key> --server http://localhost:8501`
53
+ # or via `rf login <key> --server http://localhost:8501`
48
54
  ```
49
55
 
50
56
  ## Global options
@@ -60,7 +66,7 @@ export REELFORGE_SERVER=http://localhost:8501
60
66
 
61
67
  ## Command map
62
68
 
63
- Run `reelforge <command> --help` for full details on any of these.
69
+ Run `rf <command> --help` for full details on any of these.
64
70
 
65
71
  ### Core capabilities
66
72
 
@@ -100,7 +106,7 @@ Run `reelforge <command> --help` for full details on any of these.
100
106
 
101
107
  ### End-to-end pipelines
102
108
 
103
- 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 `reelforge tasks wait <id>` later.
109
+ 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.
104
110
 
105
111
  | command | what it does |
106
112
  |---|---|
@@ -133,25 +139,28 @@ All `pipelines *` commands submit an **async task** and (by default) poll until
133
139
  ## Examples
134
140
 
135
141
  ```bash
136
- # 1. End-to-end "text video" with zero external APIs (Edge TTS + static template)
137
- reelforge pipelines standard \
142
+ # 1. One-click out a video (auto-saves to ./<title>-<id>.mp4 in cwd)
143
+ rf create "为什么我们还没找到外星文明?"
144
+
145
+ # 2. Same, but with a fixed script and explicit output path
146
+ rf pipelines standard \
138
147
  -t "Hello world. This is scene one.\n\nThis is scene two." \
139
148
  --mode fixed --title "Smoke Test" \
140
149
  --frame-template 1080x1920/static_default.html \
141
150
  --tts-voice en-US-AriaNeural -o smoke.mp4
142
151
 
143
- # 2. Inspect existing tasks & redownload a finished video
144
- reelforge tasks list --limit 5
145
- reelforge history get <task-id> --download recovered.mp4
152
+ # 3. Inspect existing tasks & redownload a finished video
153
+ rf tasks list --limit 5
154
+ rf history get <task-id> --download recovered.mp4
146
155
 
147
- # 3. JSON pipe for automation
148
- reelforge workflows list --kind image --json | jq '.workflows[].key'
156
+ # 4. JSON pipe for automation
157
+ rf workflows list --kind image --json | jq '.workflows[].key'
149
158
 
150
- # 4. Configure & test LLM
151
- reelforge config set llm.api_key sk-xxxxx
152
- reelforge config set llm.base_url https://dashscope.aliyuncs.com/compatible-mode/v1
153
- reelforge config set llm.model qwen-plus
154
- reelforge llm chat -p 'one-sentence summary of antifragile'
159
+ # 5. Configure & test LLM (self-hosted)
160
+ rf config set llm.api_key sk-xxxxx
161
+ rf config set llm.base_url https://dashscope.aliyuncs.com/compatible-mode/v1
162
+ rf config set llm.model qwen-plus
163
+ rf llm chat -p 'one-sentence summary of antifragile'
155
164
  ```
156
165
 
157
166
  ## Tip — getting unstuck
@@ -159,23 +168,10 @@ reelforge llm chat -p 'one-sentence summary of antifragile'
159
168
  Every level has `--help`:
160
169
 
161
170
  ```bash
162
- reelforge --help # top-level overview
163
- reelforge pipelines --help # list of pipelines
164
- reelforge pipelines standard --help # full option reference
165
- reelforge tts edge --help # one specific command
166
- ```
167
-
168
- ## Development & publishing
169
-
170
- ```bash
171
- cd cli
172
- npm install
173
- npm run build
174
- npm link # makes `reelforge` available globally for testing
175
- reelforge health # try it out
176
-
177
- # Publish to npm
178
- npm publish --access public
171
+ rf --help # top-level overview
172
+ rf pipelines --help # list of pipelines
173
+ rf pipelines standard --help # full option reference
174
+ rf tts edge --help # one specific command
179
175
  ```
180
176
 
181
177
  ## License
@@ -82,6 +82,63 @@ async function saveLastCreate(body) {
82
82
  await fs.mkdir(path.dirname(LAST_CREATE_PATH), { recursive: true });
83
83
  await fs.writeFile(LAST_CREATE_PATH, JSON.stringify(body, null, 2) + "\n", "utf-8");
84
84
  }
85
+ // ── Filename derivation ─────────────────────────────────────────
86
+ //
87
+ // Cascade (highest → lowest):
88
+ // 1. result.title — server's actual video title (LLM or explicit)
89
+ // 2. body.title — user-supplied --title (pre-task fallback)
90
+ // 3. raw topic (mode=generate, length ≤ 60, no @-prefix)
91
+ // 4. @file stem — when text was loaded from @./script.txt
92
+ // 5. "reelforge" literal
93
+ // Always suffixed with "-<task_id[:8]>" to avoid collisions.
94
+ const FILENAME_MAX_CHARS = 40;
95
+ function sanitizeFilename(name) {
96
+ const cleaned = name
97
+ .replace(/[\/\\:*?"<>|\r\n\t]+/g, "-")
98
+ .replace(/\s+/g, " ")
99
+ .trim()
100
+ .replace(/^[-.\s]+|[-.\s]+$/g, "");
101
+ const chars = Array.from(cleaned);
102
+ if (chars.length <= FILENAME_MAX_CHARS)
103
+ return cleaned;
104
+ return chars.slice(0, FILENAME_MAX_CHARS).join("").replace(/[-.\s]+$/g, "");
105
+ }
106
+ function computeDefaultFilename(args) {
107
+ const ext = args.ext || "mp4";
108
+ const shortId = args.taskId.slice(0, 8);
109
+ let base;
110
+ if (args.resultTitle && args.resultTitle.trim()) {
111
+ base = sanitizeFilename(args.resultTitle);
112
+ }
113
+ else if (args.bodyTitle && args.bodyTitle.trim()) {
114
+ base = sanitizeFilename(args.bodyTitle);
115
+ }
116
+ else if (args.mode === "generate" &&
117
+ args.rawTextInput &&
118
+ !args.rawTextInput.startsWith("@") &&
119
+ Array.from(args.rawTextInput).length <= 60) {
120
+ base = sanitizeFilename(args.rawTextInput);
121
+ }
122
+ else if (args.fileStemFromAt) {
123
+ base = sanitizeFilename(args.fileStemFromAt);
124
+ }
125
+ return `${base || "reelforge"}-${shortId}.${ext}`;
126
+ }
127
+ async function validateOutputPath(out) {
128
+ if (out.endsWith("/") || out.endsWith("\\")) {
129
+ throw new Error(`-o must include a filename (got directory-only path: ${out}). Example: -o ./videos/space.mp4`);
130
+ }
131
+ try {
132
+ const stat = await fs.stat(out);
133
+ if (stat.isDirectory()) {
134
+ throw new Error(`-o must include a filename (path exists as a directory: ${out}). Example: -o ${out.replace(/[\\/]+$/, "")}/space.mp4`);
135
+ }
136
+ }
137
+ catch (e) {
138
+ if (e.code !== "ENOENT")
139
+ throw e;
140
+ }
141
+ }
85
142
  /** Camel-case CLI options → snake_case body, only including provided fields */
86
143
  function optsToBody(opts) {
87
144
  const out = {};
@@ -186,7 +243,8 @@ export function registerCreate(program) {
186
243
  .option("--redo", "replay last successful create from ~/.reelforge/last-create.json")
187
244
  .option("--dry-run", "print the final request body + estimated units; do NOT submit")
188
245
  .option("--no-wait", "submit and return task_id immediately (do not poll)")
189
- .option("-o, --output <file>", "save the final video to this path (only when waiting)")
246
+ .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.")
247
+ .option("--no-download", "do not save the video locally — just print the JSON result with video_url")
190
248
  .option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
191
249
  .option("--timeout-ms <ms>", "max wait time before aborting (default unlimited)", (v) => parseInt(v, 10))
192
250
  .addHelpText("after", [
@@ -200,9 +258,15 @@ export function registerCreate(program) {
200
258
  " Visual : --frame-template --media-workflow --prompt-prefix",
201
259
  " TTS : --tts-voice --tts-speed --tts-inference-mode --tts-workflow --voice-id --ref-audio",
202
260
  " BGM : --bgm --bgm-volume --bgm-mode",
203
- " Output : --video-fps --template-params -o --no-wait --poll-ms --timeout-ms",
261
+ " Output : --video-fps --template-params -o --no-download --no-wait --poll-ms --timeout-ms",
204
262
  " Workflow: --recipe --redo --dry-run",
205
263
  "",
264
+ "Output behavior:",
265
+ " No flag → saves to ./<title>-<task_id>.mp4 in current directory, prints the path",
266
+ " -o <path> → saves to that exact path (must include filename, not just a directory)",
267
+ " --no-download → skips local save, just prints JSON result with video_url",
268
+ " (when stdout is piped, --no-download is implied automatically)",
269
+ "",
206
270
  "Explore available resources (separate commands):",
207
271
  " reelforge templates list # all HTML templates",
208
272
  " reelforge workflows list --kind image # all AI image workflows",
@@ -210,41 +274,47 @@ export function registerCreate(program) {
210
274
  " reelforge tts voices --locale zh # Edge TTS voice ids",
211
275
  " reelforge bgm list # built-in BGM files",
212
276
  "",
213
- "Examples:",
214
- " # Minimum — defaults out 5 scenes, image background, Yunjian voice",
215
- ' reelforge create "为什么我们还没找到外星文明?"',
277
+ "Examples (`rf` is a short alias for `reelforge`):",
278
+ " # Minimum — saves to ./<title>-<short_id>.mp4 in cwd",
279
+ ' rf create "为什么我们还没找到外星文明?"',
280
+ "",
281
+ " # Pick the exact output path",
282
+ ' rf create "..." -o ./videos/space.mp4',
216
283
  "",
217
284
  " # Long script from a file, fixed mode (no LLM scriptwriting)",
218
- " reelforge create @./script.txt --mode fixed --split-mode paragraph",
285
+ " rf create @./script.txt --mode fixed --split-mode paragraph",
219
286
  "",
220
287
  " # Landscape (1920x1080)",
221
- ' reelforge create "..." --frame-template 1920x1080/image_default.html',
288
+ ' rf create "..." --frame-template 1920x1080/image_default.html',
222
289
  "",
223
290
  " # AI-generated video background instead of still image",
224
- ' reelforge create "..." \\',
291
+ ' rf create "..." \\',
225
292
  " --frame-template 1080x1920/video_default.html \\",
226
293
  " --media-workflow runninghub/video_wan2.2.json",
227
294
  "",
228
295
  " # Add BGM",
229
- ' reelforge create "..." --bgm bgm/Echoes.mp3 --bgm-volume 0.3 --bgm-mode loop',
296
+ ' rf create "..." --bgm bgm/Echoes.mp3 --bgm-volume 0.3 --bgm-mode loop',
230
297
  "",
231
298
  " # Change voice + speed",
232
- ' reelforge create "..." --tts-voice zh-CN-XiaoxiaoNeural --tts-speed 1.0',
299
+ ' rf create "..." --tts-voice zh-CN-XiaoxiaoNeural --tts-speed 1.0',
233
300
  "",
234
301
  " # Full recipe in one file",
235
- " reelforge create --recipe ./space.recipe.json",
302
+ " rf create --recipe ./space.recipe.json",
236
303
  "",
237
304
  " # Override a field on top of a recipe",
238
- ' reelforge create --recipe ./space.recipe.json --text "新主题" -n 8',
305
+ ' rf create --recipe ./space.recipe.json --text "新主题" -n 8',
239
306
  "",
240
307
  " # Replay last successful create",
241
- " reelforge create --redo",
308
+ " rf create --redo",
242
309
  "",
243
310
  " # Replay last but tweak one knob",
244
- " reelforge create --redo --tts-speed 1.0",
311
+ " rf create --redo --tts-speed 1.0",
245
312
  "",
246
313
  " # See exactly what would be sent (no submission)",
247
- ' reelforge create "..." -n 7 --bgm bgm/Echoes.mp3 --dry-run',
314
+ ' rf create "..." -n 7 --bgm bgm/Echoes.mp3 --dry-run',
315
+ "",
316
+ " # Pipe-friendly: skip local download, take video_url for downstream",
317
+ ' rf create "..." --no-download --json | jq -r .video_url',
248
318
  "",
249
319
  "Recipe file format (every field is optional; all keys match the REST API body):",
250
320
  " {",
@@ -260,6 +330,10 @@ export function registerCreate(program) {
260
330
  " }",
261
331
  ].join("\n"))
262
332
  .action(async (topicArg, opts) => {
333
+ // Validate -o early so we fail before submitting a paid task
334
+ if (opts.output) {
335
+ await validateOutputPath(opts.output);
336
+ }
263
337
  // 1. Layer defaults: --redo → --recipe → CLI opts → positional topic
264
338
  let body = {};
265
339
  if (opts.redo) {
@@ -278,6 +352,12 @@ export function registerCreate(program) {
278
352
  // CLI options layer
279
353
  const fromOpts = optsToBody(opts);
280
354
  body = { ...body, ...fromOpts };
355
+ // Capture the raw text input (with potential @-prefix) for filename derivation.
356
+ // After `resolveText` we lose the @path → file stem mapping.
357
+ const rawTextInput = topicArg ?? (typeof body.text === "string" ? body.text : undefined);
358
+ const fileStemFromAt = rawTextInput?.startsWith("@")
359
+ ? path.parse(rawTextInput.slice(1)).name
360
+ : undefined;
281
361
  // Positional topic wins for `text` (with @file support)
282
362
  if (topicArg) {
283
363
  body.text = await resolveText(topicArg);
@@ -324,9 +404,33 @@ export function registerCreate(program) {
324
404
  throw new Error(t.error || `Task ended with status ${t.status}`);
325
405
  }
326
406
  const result = t.result;
327
- if (opts.output && result?.video_url) {
328
- await downloadTo(result.video_url, opts.output);
329
- success(`Saved${opts.output}`);
407
+ // Decide where (or whether) to save locally.
408
+ // -o that exact path
409
+ // --no-downloadskip
410
+ // stdout piped → skip (clig.dev: don't dump binary-touching side effects into a script)
411
+ // otherwise → auto-named in cwd
412
+ if (result?.video_url) {
413
+ const stdoutIsPipe = !process.stdout.isTTY;
414
+ const skipDownload = !!opts.noDownload || (stdoutIsPipe && !opts.output);
415
+ let savedPath;
416
+ if (opts.output) {
417
+ savedPath = opts.output;
418
+ }
419
+ else if (!skipDownload) {
420
+ savedPath = computeDefaultFilename({
421
+ resultTitle: result.title,
422
+ bodyTitle: finalBody.title,
423
+ mode: finalBody.mode,
424
+ rawTextInput,
425
+ fileStemFromAt,
426
+ taskId: t.id,
427
+ ext: "mp4",
428
+ });
429
+ }
430
+ if (savedPath) {
431
+ await downloadTo(result.video_url, savedPath);
432
+ success(`Saved → ${savedPath}`);
433
+ }
330
434
  }
331
435
  print({ task_id: t.id, status: t.status, ...result });
332
436
  });
package/dist/index.js CHANGED
@@ -66,19 +66,14 @@ program
66
66
  program.addHelpText("afterAll", [
67
67
  "",
68
68
  "Examples (tip: `rf` works wherever you see `reelforge`):",
69
- " reelforge create '为什么我们还没有找到外星文明?' -o space.mp4",
70
- " rf create '为什么我们还没有找到外星文明?' -o space.mp4",
71
- " reelforge llm chat --prompt 'explain antifragile in 3 sentences'",
72
- " reelforge tts edge --text 'hello world' --voice en-US-AriaNeural -o out.mp3",
73
- " reelforge images generate --prompt 'a cat' --workflow selfhost/image_flux.json -o cat.png",
74
- " reelforge pipelines standard --text 'why we explore space' --tts-voice zh-CN-YunjianNeural",
75
- " reelforge tasks list --status running",
76
- " reelforge config get",
77
- "",
78
- "Setup before publishing to npm:",
79
- " 1) cd cli && npm install && npm run build",
80
- " 2) npm link (test locally as `reelforge`)",
81
- " 3) npm publish --access public",
69
+ " rf create '为什么我们还没有找到外星文明?' # auto-saves to ./<title>-<id>.mp4 in cwd",
70
+ " rf create '...' -o ./videos/space.mp4 # pick the exact path",
71
+ " rf llm chat --prompt 'explain antifragile in 3 sentences'",
72
+ " rf tts edge --text 'hello world' --voice en-US-AriaNeural -o out.mp3",
73
+ " rf images generate --prompt 'a cat' --workflow selfhost/image_flux.json -o cat.png",
74
+ " rf pipelines standard --text 'why we explore space' --tts-voice zh-CN-YunjianNeural",
75
+ " rf tasks list --status running",
76
+ " rf config get",
82
77
  ].join("\n"));
83
78
  registerAuth(program);
84
79
  registerCreate(program);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reelforge",
3
- "version": "0.3.1",
4
- "description": "CLI for ReelForge Studio — AI video engine. Every REST API exposed as a command, with --help on every level.",
3
+ "version": "0.3.2",
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",
7
7
  "bin": {