reelforge 0.3.1 → 0.4.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
@@ -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 = {};
@@ -140,6 +197,115 @@ const DEFAULTS = {
140
197
  tts_voice: "zh-CN-YunjianNeural",
141
198
  tts_speed: 1.2,
142
199
  };
200
+ const STYLE_PRESETS = {
201
+ matchstick: {
202
+ prefix: "Minimalist black-and-white matchstick figure style illustration, clean lines, simple sketch style",
203
+ label: "极简黑白火柴人",
204
+ scene: "知识科普 / 哲思 / 段子",
205
+ },
206
+ cinematic: {
207
+ prefix: "cinematic photography, soft natural lighting, shallow depth of field, 35mm film, color grading",
208
+ label: "电影感",
209
+ scene: "故事 / 旅行 / 通用百搭",
210
+ },
211
+ photorealistic: {
212
+ prefix: "photorealistic, professional studio lighting, sharp focus, high detail, 85mm portrait lens",
213
+ label: "写实棚拍",
214
+ scene: "产品 / 人像 / 美食",
215
+ },
216
+ documentary: {
217
+ prefix: "documentary photography, candid moment, natural available light, photojournalism, realistic colors",
218
+ label: "纪实新闻",
219
+ scene: "历史 / 人物 / 纪录",
220
+ },
221
+ flat: {
222
+ prefix: "flat vector illustration, modern editorial style, geometric shapes, limited color palette, clean composition",
223
+ label: "扁平商业插画",
224
+ scene: "财经 / 商业 / 数据科普",
225
+ },
226
+ anime: {
227
+ prefix: "anime illustration, cel-shaded, vibrant colors, detailed background, expressive characters, Makoto Shinkai aesthetic",
228
+ label: "日漫",
229
+ scene: "故事 / 二次元 / 年轻向",
230
+ },
231
+ comic: {
232
+ prefix: "comic book illustration, bold ink outlines, halftone shading, dynamic poses, Marvel style",
233
+ label: "美漫",
234
+ scene: "动作 / 英雄向 / 段子",
235
+ },
236
+ watercolor: {
237
+ prefix: "soft watercolor painting, gentle brush strokes, pastel color palette, dreamy atmosphere, hand-painted texture",
238
+ label: "水彩",
239
+ scene: "治愈 / 情感 / 小红书风",
240
+ },
241
+ "oil-painting": {
242
+ prefix: "oil painting, visible brushstrokes, rich saturated colors, classical composition, canvas texture",
243
+ label: "油画",
244
+ scene: "文艺 / 古典 / 艺术史",
245
+ },
246
+ ink: {
247
+ prefix: "traditional Chinese ink painting, sumi-e style, flowing brushwork, monochrome wash, minimalist composition with negative space",
248
+ label: "中国水墨",
249
+ scene: "中式叙事 / 古诗词 / 国风",
250
+ },
251
+ "3d-render": {
252
+ prefix: "3D render, octane render, smooth surfaces, soft global illumination, polished and clean",
253
+ label: "三维渲染",
254
+ scene: "科技 / 产品 / 未来 / 概念图",
255
+ },
256
+ claymation: {
257
+ prefix: "claymation style, handmade clay textures, soft diffused studio lighting, matte surfaces, pastel colors",
258
+ label: "黏土定格",
259
+ scene: "儿童 / 萌系 / 治愈段子",
260
+ },
261
+ "low-poly": {
262
+ prefix: "low poly 3D art, triangular faceted surfaces, gradient coloring, minimalist geometric, clean style",
263
+ label: "低多边形",
264
+ scene: "科技 / 概念 / 设计感",
265
+ },
266
+ pixel: {
267
+ prefix: "16-bit pixel art, retro game aesthetic, limited color palette, blocky shading, SNES style",
268
+ label: "像素",
269
+ scene: "游戏 / 怀旧 / 段子",
270
+ },
271
+ cyberpunk: {
272
+ prefix: "cyberpunk, neon-lit dystopian cityscape, rain-slicked streets, holographic signs, Blade Runner aesthetic",
273
+ label: "赛博朋克",
274
+ scene: "科技未来 / 都市夜景",
275
+ },
276
+ vaporwave: {
277
+ prefix: "vaporwave aesthetic, pink and purple gradient, retro 90s elements, glitch effects, dreamlike surreal",
278
+ label: "蒸汽波",
279
+ scene: "网络梗 / 怀旧 / 抽象审美",
280
+ },
281
+ "art-deco": {
282
+ prefix: "Art Deco style, geometric symmetric patterns, gold and black palette, 1920s glamour, luxury aesthetic",
283
+ label: "装饰艺术",
284
+ scene: "奢华品牌 / 复古优雅",
285
+ },
286
+ };
287
+ // CJK chars take 2 display columns in monospace terminals; pad accordingly.
288
+ function displayWidth(s) {
289
+ let w = 0;
290
+ for (const c of s)
291
+ w += c.charCodeAt(0) > 0x7f ? 2 : 1;
292
+ return w;
293
+ }
294
+ function padDisplay(s, width) {
295
+ const pad = Math.max(0, width - displayWidth(s));
296
+ return s + " ".repeat(pad);
297
+ }
298
+ function formatStylePresetsList() {
299
+ const keys = Object.keys(STYLE_PRESETS);
300
+ const keyW = Math.max(...keys.map((k) => k.length));
301
+ const labelW = Math.max(...keys.map((k) => displayWidth(STYLE_PRESETS[k].label)));
302
+ return keys
303
+ .map((k) => {
304
+ const p = STYLE_PRESETS[k];
305
+ return ` ${padDisplay(k, keyW)} ${padDisplay(p.label, labelW)} ${p.scene}`;
306
+ })
307
+ .join("\n");
308
+ }
143
309
  // ── Command registration ────────────────────────────────────────
144
310
  export function registerCreate(program) {
145
311
  program
@@ -159,7 +325,8 @@ export function registerCreate(program) {
159
325
  // --- Visual ---
160
326
  .option("--frame-template <key>", "HTML frame template, e.g. 1080x1920/image_default.html")
161
327
  .option("--media-workflow <key>", "AI image/video workflow, e.g. runninghub/image_flux.json")
162
- .option("--prompt-prefix <text>", "style prefix prepended to every image prompt")
328
+ .option("--prompt-prefix <text>", "raw style prefix prepended to every image prompt (overrides --style)")
329
+ .option("--style <preset>", "image style preset — shortcut for --prompt-prefix; see 'Style presets' below for the full list")
163
330
  // --- Audio (TTS) ---
164
331
  .option("--tts-voice <id>", "Edge TTS voice id, e.g. zh-CN-YunjianNeural / en-US-AriaNeural")
165
332
  .option("--tts-speed <n>", "speech speed 0.5..2", parseFloat)
@@ -186,7 +353,8 @@ export function registerCreate(program) {
186
353
  .option("--redo", "replay last successful create from ~/.reelforge/last-create.json")
187
354
  .option("--dry-run", "print the final request body + estimated units; do NOT submit")
188
355
  .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)")
356
+ .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.")
357
+ .option("--no-download", "do not save the video locally — just print the JSON result with video_url")
190
358
  .option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
191
359
  .option("--timeout-ms <ms>", "max wait time before aborting (default unlimited)", (v) => parseInt(v, 10))
192
360
  .addHelpText("after", [
@@ -197,12 +365,23 @@ export function registerCreate(program) {
197
365
  "",
198
366
  "Param groups:",
199
367
  " Content : --mode --title -n --split-mode --min/max-narration-words --min/max-image-prompt-words",
200
- " Visual : --frame-template --media-workflow --prompt-prefix",
368
+ " Visual : --frame-template --media-workflow --style --prompt-prefix",
201
369
  " TTS : --tts-voice --tts-speed --tts-inference-mode --tts-workflow --voice-id --ref-audio",
202
370
  " BGM : --bgm --bgm-volume --bgm-mode",
203
- " Output : --video-fps --template-params -o --no-wait --poll-ms --timeout-ms",
371
+ " Output : --video-fps --template-params -o --no-download --no-wait --poll-ms --timeout-ms",
204
372
  " Workflow: --recipe --redo --dry-run",
205
373
  "",
374
+ "Style presets (--style <preset>) — quick shortcut for --prompt-prefix:",
375
+ formatStylePresetsList(),
376
+ " · Pass --prompt-prefix to override (raw string always wins).",
377
+ " · Omit both to use the server's configured default style.",
378
+ "",
379
+ "Output behavior:",
380
+ " No flag → saves to ./<title>-<task_id>.mp4 in current directory, prints the path",
381
+ " -o <path> → saves to that exact path (must include filename, not just a directory)",
382
+ " --no-download → skips local save, just prints JSON result with video_url",
383
+ " (when stdout is piped, --no-download is implied automatically)",
384
+ "",
206
385
  "Explore available resources (separate commands):",
207
386
  " reelforge templates list # all HTML templates",
208
387
  " reelforge workflows list --kind image # all AI image workflows",
@@ -210,41 +389,54 @@ export function registerCreate(program) {
210
389
  " reelforge tts voices --locale zh # Edge TTS voice ids",
211
390
  " reelforge bgm list # built-in BGM files",
212
391
  "",
213
- "Examples:",
214
- " # Minimum — defaults out 5 scenes, image background, Yunjian voice",
215
- ' reelforge create "为什么我们还没找到外星文明?"',
392
+ "Examples (`rf` is a short alias for `reelforge`):",
393
+ " # Minimum — saves to ./<title>-<short_id>.mp4 in cwd",
394
+ ' rf create "为什么我们还没找到外星文明?"',
395
+ "",
396
+ " # Pick the exact output path",
397
+ ' rf create "..." -o ./videos/space.mp4',
216
398
  "",
217
399
  " # Long script from a file, fixed mode (no LLM scriptwriting)",
218
- " reelforge create @./script.txt --mode fixed --split-mode paragraph",
400
+ " rf create @./script.txt --mode fixed --split-mode paragraph",
219
401
  "",
220
402
  " # Landscape (1920x1080)",
221
- ' reelforge create "..." --frame-template 1920x1080/image_default.html',
403
+ ' rf create "..." --frame-template 1920x1080/image_default.html',
222
404
  "",
223
405
  " # AI-generated video background instead of still image",
224
- ' reelforge create "..." \\',
406
+ ' rf create "..." \\',
225
407
  " --frame-template 1080x1920/video_default.html \\",
226
408
  " --media-workflow runninghub/video_wan2.2.json",
227
409
  "",
228
410
  " # Add BGM",
229
- ' reelforge create "..." --bgm bgm/Echoes.mp3 --bgm-volume 0.3 --bgm-mode loop',
411
+ ' rf create "..." --bgm bgm/Echoes.mp3 --bgm-volume 0.3 --bgm-mode loop',
230
412
  "",
231
413
  " # Change voice + speed",
232
- ' reelforge create "..." --tts-voice zh-CN-XiaoxiaoNeural --tts-speed 1.0',
414
+ ' rf create "..." --tts-voice zh-CN-XiaoxiaoNeural --tts-speed 1.0',
415
+ "",
416
+ " # Pick a built-in style preset",
417
+ ' rf create "..." --style cinematic',
418
+ ' rf create "美食教程" --style photorealistic',
419
+ "",
420
+ " # Free-form style — write your own prefix from scratch",
421
+ ' rf create "..." --prompt-prefix "Studio Ghibli, pastel, dreamy"',
233
422
  "",
234
423
  " # Full recipe in one file",
235
- " reelforge create --recipe ./space.recipe.json",
424
+ " rf create --recipe ./space.recipe.json",
236
425
  "",
237
426
  " # Override a field on top of a recipe",
238
- ' reelforge create --recipe ./space.recipe.json --text "新主题" -n 8',
427
+ ' rf create --recipe ./space.recipe.json --text "新主题" -n 8',
239
428
  "",
240
429
  " # Replay last successful create",
241
- " reelforge create --redo",
430
+ " rf create --redo",
242
431
  "",
243
432
  " # Replay last but tweak one knob",
244
- " reelforge create --redo --tts-speed 1.0",
433
+ " rf create --redo --tts-speed 1.0",
245
434
  "",
246
435
  " # See exactly what would be sent (no submission)",
247
- ' reelforge create "..." -n 7 --bgm bgm/Echoes.mp3 --dry-run',
436
+ ' rf create "..." -n 7 --bgm bgm/Echoes.mp3 --dry-run',
437
+ "",
438
+ " # Pipe-friendly: skip local download, take video_url for downstream",
439
+ ' rf create "..." --no-download --json | jq -r .video_url',
248
440
  "",
249
441
  "Recipe file format (every field is optional; all keys match the REST API body):",
250
442
  " {",
@@ -260,6 +452,21 @@ export function registerCreate(program) {
260
452
  " }",
261
453
  ].join("\n"))
262
454
  .action(async (topicArg, opts) => {
455
+ // Validate -o early so we fail before submitting a paid task
456
+ if (opts.output) {
457
+ await validateOutputPath(opts.output);
458
+ }
459
+ // Expand --style preset to --prompt-prefix unless an explicit
460
+ // --prompt-prefix is also given (the raw string always wins).
461
+ if (opts.style) {
462
+ const preset = STYLE_PRESETS[opts.style];
463
+ if (!preset) {
464
+ throw new Error(`Unknown --style: ${opts.style}\nAvailable presets:\n${formatStylePresetsList()}`);
465
+ }
466
+ if (opts.promptPrefix === undefined) {
467
+ opts.promptPrefix = preset.prefix;
468
+ }
469
+ }
263
470
  // 1. Layer defaults: --redo → --recipe → CLI opts → positional topic
264
471
  let body = {};
265
472
  if (opts.redo) {
@@ -278,6 +485,12 @@ export function registerCreate(program) {
278
485
  // CLI options layer
279
486
  const fromOpts = optsToBody(opts);
280
487
  body = { ...body, ...fromOpts };
488
+ // Capture the raw text input (with potential @-prefix) for filename derivation.
489
+ // After `resolveText` we lose the @path → file stem mapping.
490
+ const rawTextInput = topicArg ?? (typeof body.text === "string" ? body.text : undefined);
491
+ const fileStemFromAt = rawTextInput?.startsWith("@")
492
+ ? path.parse(rawTextInput.slice(1)).name
493
+ : undefined;
281
494
  // Positional topic wins for `text` (with @file support)
282
495
  if (topicArg) {
283
496
  body.text = await resolveText(topicArg);
@@ -324,9 +537,33 @@ export function registerCreate(program) {
324
537
  throw new Error(t.error || `Task ended with status ${t.status}`);
325
538
  }
326
539
  const result = t.result;
327
- if (opts.output && result?.video_url) {
328
- await downloadTo(result.video_url, opts.output);
329
- success(`Saved${opts.output}`);
540
+ // Decide where (or whether) to save locally.
541
+ // -o that exact path
542
+ // --no-downloadskip
543
+ // stdout piped → skip (clig.dev: don't dump binary-touching side effects into a script)
544
+ // otherwise → auto-named in cwd
545
+ if (result?.video_url) {
546
+ const stdoutIsPipe = !process.stdout.isTTY;
547
+ const skipDownload = !!opts.noDownload || (stdoutIsPipe && !opts.output);
548
+ let savedPath;
549
+ if (opts.output) {
550
+ savedPath = opts.output;
551
+ }
552
+ else if (!skipDownload) {
553
+ savedPath = computeDefaultFilename({
554
+ resultTitle: result.title,
555
+ bodyTitle: finalBody.title,
556
+ mode: finalBody.mode,
557
+ rawTextInput,
558
+ fileStemFromAt,
559
+ taskId: t.id,
560
+ ext: "mp4",
561
+ });
562
+ }
563
+ if (savedPath) {
564
+ await downloadTo(result.video_url, savedPath);
565
+ success(`Saved → ${savedPath}`);
566
+ }
330
567
  }
331
568
  print({ task_id: t.id, status: t.status, ...result });
332
569
  });
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,52 +1,52 @@
1
- {
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.",
5
- "license": "Apache-2.0",
6
- "type": "module",
7
- "bin": {
8
- "reelforge": "./bin/reelforge.js",
9
- "rf": "./bin/reelforge.js"
10
- },
11
- "files": [
12
- "bin",
13
- "dist",
14
- "README.md"
15
- ],
16
- "engines": {
17
- "node": ">=18.17"
18
- },
19
- "scripts": {
20
- "build": "tsc -p tsconfig.json",
21
- "dev": "tsc -p tsconfig.json --watch",
22
- "typecheck": "tsc -p tsconfig.json --noEmit",
23
- "clean": "rimraf dist",
24
- "prepublishOnly": "npm run clean && npm run build"
25
- },
26
- "dependencies": {
27
- "commander": "^12.1.0",
28
- "kleur": "^4.1.5"
29
- },
30
- "devDependencies": {
31
- "@types/node": "^20.14.0",
32
- "rimraf": "^6.0.1",
33
- "typescript": "^5.5.0"
34
- },
35
- "keywords": [
36
- "reelforge",
37
- "ai-video",
38
- "comfyui",
39
- "runninghub",
40
- "tts",
41
- "edge-tts",
42
- "ffmpeg",
43
- "playwright",
44
- "cli"
45
- ],
46
- "repository": {
47
- "type": "git",
48
- "url": "https://github.com/puke3615/ReelForge.git",
49
- "directory": "cli"
50
- },
51
- "homepage": "https://github.com/puke3615/ReelForge"
52
- }
1
+ {
2
+ "name": "reelforge",
3
+ "version": "0.4.0",
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
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "reelforge": "./bin/reelforge.js",
9
+ "rf": "./bin/reelforge.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.17"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "dev": "tsc -p tsconfig.json --watch",
22
+ "typecheck": "tsc -p tsconfig.json --noEmit",
23
+ "clean": "rimraf dist",
24
+ "prepublishOnly": "npm run clean && npm run build"
25
+ },
26
+ "dependencies": {
27
+ "commander": "^12.1.0",
28
+ "kleur": "^4.1.5"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^20.14.0",
32
+ "rimraf": "^6.0.1",
33
+ "typescript": "^5.5.0"
34
+ },
35
+ "keywords": [
36
+ "reelforge",
37
+ "ai-video",
38
+ "comfyui",
39
+ "runninghub",
40
+ "tts",
41
+ "edge-tts",
42
+ "ffmpeg",
43
+ "playwright",
44
+ "cli"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/puke3615/ReelForge.git",
49
+ "directory": "cli"
50
+ },
51
+ "homepage": "https://github.com/puke3615/ReelForge"
52
+ }