reelforge 0.7.0 → 0.8.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.
@@ -15,27 +15,8 @@ const IMAGE_UNITS = 3;
15
15
  const CHARS_PER_SEC_ZH = 5;
16
16
  const TARGET_SEC_PER_SCENE = 8;
17
17
  function estimateUnits(body) {
18
- let tplType;
19
- if (body.frame_template_inline) {
20
- if (body.frame_template_type) {
21
- tplType = body.frame_template_type;
22
- }
23
- else {
24
- const m = body.frame_template_inline.match(/<meta[^>]+name=["']template:type["'][^>]+content=["']([a-z]+)["']/i);
25
- const v = m?.[1].toLowerCase();
26
- tplType = v === "static" || v === "asset" || v === "image" ? v : "image";
27
- }
28
- }
29
- else {
30
- const tplKey = body.frame_template || "1080x1920/image_default.html";
31
- const tplBase = (tplKey.split("/").pop() || "").toLowerCase();
32
- tplType = tplBase.startsWith("static_")
33
- ? "static"
34
- : tplBase.startsWith("asset_")
35
- ? "asset"
36
- : "image";
37
- }
38
- // Estimated scene count: from script length (fixed) or from duration (generate).
18
+ // The standard pipeline now always generates one AI image per scene (the
19
+ // old static_/asset_ template-type carveouts are gone).
39
20
  let estimatedScenes;
40
21
  if (body.script) {
41
22
  const estSec = body.script.length / CHARS_PER_SEC_ZH;
@@ -45,19 +26,9 @@ function estimateUnits(body) {
45
26
  const dur = body.duration ?? 45;
46
27
  estimatedScenes = Math.max(2, Math.round(dur / TARGET_SEC_PER_SCENE));
47
28
  }
48
- const imageUnits = tplType === "image" ? estimatedScenes * IMAGE_UNITS : 0;
49
- return PLAN_UNITS + TTS_UNITS + ASR_UNITS + imageUnits;
29
+ return PLAN_UNITS + TTS_UNITS + ASR_UNITS + estimatedScenes * IMAGE_UNITS;
50
30
  }
51
31
  // ── Helpers ─────────────────────────────────────────────────────
52
- function looksLikeLocalHtmlPath(value) {
53
- if (/^[.~]|^\//.test(value))
54
- return true;
55
- if (value.includes("\\"))
56
- return true;
57
- if (value.endsWith(".html") && fsSync.existsSync(value))
58
- return true;
59
- return false;
60
- }
61
32
  /** `@file` prefix → load file contents; raw text → return as-is. */
62
33
  async function resolveTextOrFile(input) {
63
34
  if (input.startsWith("@")) {
@@ -184,32 +155,16 @@ function optsToBody(opts) {
184
155
  out.prompt_prefix = opts.promptPrefix;
185
156
  if (opts.characterRef !== undefined)
186
157
  out.character_ref = opts.characterRef;
187
- if (opts.styleRef !== undefined)
188
- out.style_ref = opts.styleRef;
189
158
  if (opts.voiceId !== undefined)
190
159
  out.voice_id = opts.voiceId;
191
160
  if (opts.ttsSpeed !== undefined)
192
161
  out.tts_speed = opts.ttsSpeed;
193
162
  if (opts.videoFps !== undefined)
194
163
  out.video_fps = opts.videoFps;
195
- if (opts.frameTemplate !== undefined) {
196
- if (looksLikeLocalHtmlPath(opts.frameTemplate)) {
197
- const abs = path.resolve(opts.frameTemplate);
198
- if (!fsSync.existsSync(abs)) {
199
- throw new Error(`--frame-template: local file not found: ${abs}`);
200
- }
201
- out.frame_template_inline = fsSync.readFileSync(abs, "utf-8");
202
- }
203
- else {
204
- out.frame_template = opts.frameTemplate;
205
- }
206
- }
207
- if (opts.frameTemplateSize !== undefined)
208
- out.frame_template_size = opts.frameTemplateSize;
209
- if (opts.frameTemplateType !== undefined)
210
- out.frame_template_type = opts.frameTemplateType;
211
- if (opts.templateParams !== undefined)
212
- out.template_params = opts.templateParams;
164
+ if (opts.motion !== undefined)
165
+ out.motion = opts.motion;
166
+ if (opts.subtitleStyle !== undefined)
167
+ out.subtitle_style = opts.subtitleStyle;
213
168
  if (opts.subtitleMinChars !== undefined)
214
169
  out.subtitle_min_chars = opts.subtitleMinChars;
215
170
  if (opts.subtitleHardMax !== undefined)
@@ -336,14 +291,12 @@ export function registerCreate(program) {
336
291
  .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))
337
292
  .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.")
338
293
  // --- Visual ---
339
- .option("--frame-template <keyOrPath>", "HTML frame template: preset key (e.g. 1080x1920/image_default.html) OR path to a local .html (auto-sent inline)")
340
- .option("--frame-template-size <wxh>", "size for inline HTML when the file lacks <meta template:width|height>, e.g. 1080x1920")
341
- .option("--frame-template-type <type>", "inline template type: image (default) | static | asset. Controls whether AI image generation runs per scene.")
342
- .option("--image-model <id>", "RelayX image model (rx-image-z | rx-image-flux | rx-image-qwen | rx-image-qwen-edit). Auto-switches to rx-image-qwen-edit when --character-ref or --style-ref is set.")
294
+ .option("--image-model <id>", "RelayX image model (rx-image-z | rx-image-flux | rx-image-qwen | rx-image-qwen-edit). Auto-switches to rx-image-qwen-edit when --character-ref is set.")
343
295
  .option("--prompt-prefix <text>", "raw style prefix prepended to every image prompt (overrides --style)")
344
296
  .option("--style <preset>", "image style preset — shortcut for --prompt-prefix; see 'Style presets' below")
345
297
  .option("--character-ref <urlOrPath>", "reference image of the main character — locks identity across scenes. URL, data: URI, or local png/jpg/webp path (auto-encoded). Auto-enables rx-image-qwen-edit.")
346
- .option("--style-ref <urlOrPath>", "reference image of the overall visual style — locks palette/composition/mood across scenes. Same input formats as --character-ref.")
298
+ .option("--motion <preset>", "per-scene image animation intensity. See 'Motion presets' below. Default: lite.")
299
+ .option("--subtitle-style <preset>", "subtitle visual style. See 'Subtitle styles' below. Default: plate.")
347
300
  // --- Audio (TTS) ---
348
301
  .option("--voice-id <id>", "RelayX TTS voice id (default 专业解说); see `rf tts voices`")
349
302
  .option("--tts-speed <n>", "speech speed 0.5..2 (default 1.0)", parseFloat)
@@ -356,14 +309,6 @@ export function registerCreate(program) {
356
309
  .option("--subtitle-hard-max <N>", "subtitle line absolute max chars (default 24)", (v) => parseInt(v, 10))
357
310
  // --- Output / extra ---
358
311
  .option("--video-fps <n>", "output video fps (default 30)", (v) => parseInt(v, 10))
359
- .option("--template-params <json>", "extra template placeholders as JSON string", (v) => {
360
- try {
361
- return JSON.parse(v);
362
- }
363
- catch {
364
- throw new Error(`--template-params: invalid JSON: ${v}`);
365
- }
366
- })
367
312
  // --- Runtime / workflow ---
368
313
  .option("--recipe <file>", "load defaults from a JSON recipe file (CLI flags still override)")
369
314
  .option("--redo", "replay last successful create from ~/.reelforge/last-create.json")
@@ -385,9 +330,21 @@ export function registerCreate(program) {
385
330
  " fast split long semantic chunks into multiple shots for variety",
386
331
  "",
387
332
  "Defaults:",
388
- " duration=45s · pace=normal · frame-template=1080x1920/image_default.html · tts-speed=1.0",
333
+ " duration=45s · pace=normal · motion=lite · subtitle-style=plate · tts-speed=1.0",
334
+ "",
335
+ "Motion presets (--motion <preset>) — per-scene image animation intensity:",
336
+ " off no motion, hard cuts between scenes — PPT / slideshow mode",
337
+ " lite 6% zoom + 0.3s crossfade, 4 sub-anims (default; safe + tasteful)",
338
+ " max 20% zoom/pan + 0.5s crossfade, 10 sub-anims (cinematic, more dramatic)",
339
+ " · sub-animations are Fisher-Yates shuffled per task_id, so every video",
340
+ " cycles a different order — no two videos feel identical.",
389
341
  "",
390
- "Style presets (--style <preset>) — quick shortcut for --prompt-prefix:",
342
+ "Subtitle styles (--subtitle-style <preset>):",
343
+ " plate semi-transparent black plate + white text (CapCut default; safest readability)",
344
+ " stroke bold white text with black stroke + shadow, no plate (抖音网红风)",
345
+ " cinema bottom black gradient backdrop + lighter text (film / documentary look)",
346
+ "",
347
+ "Image style presets (--style <preset>) — quick shortcut for --prompt-prefix:",
391
348
  formatStylePresetsList(),
392
349
  " · Pass --prompt-prefix to override (raw string always wins).",
393
350
  " · Omit both to use the server's configured default style (if any).",
@@ -409,16 +366,17 @@ export function registerCreate(program) {
409
366
  " rf create --script @./script.txt",
410
367
  ' rf create --script "整段文案文本..."',
411
368
  "",
412
- " # Custom HTML template (auto-detected when --frame-template is a local path)",
413
- " rf create '...' --frame-template ./my-brand.html",
414
- "",
415
- " # Pick a built-in style preset",
369
+ " # Pick a built-in image style preset",
416
370
  ' rf create "..." --style cinematic',
417
371
  "",
418
372
  " # Cross-scene character consistency (auto-switches image model)",
419
373
  ' rf create "主角小女孩的一天" --character-ref ./hero.png',
420
- " rf create '...' --character-ref ./hero.png --style-ref ./mood.jpg",
421
- ' rf create "..." --style-ref https://example.com/style.png',
374
+ ' rf create "..." --character-ref https://example.com/hero.png',
375
+ "",
376
+ " # Motion + subtitle style combos",
377
+ ' rf create "..." --motion max --subtitle-style stroke # 抖音网红风',
378
+ ' rf create "..." --motion lite --subtitle-style cinema # 文艺纪录片',
379
+ ' rf create "..." --motion off # PPT 模式(旧行为)',
422
380
  "",
423
381
  " # Recipe + replay last",
424
382
  " rf create --recipe ./space.recipe.json",
@@ -448,6 +406,12 @@ export function registerCreate(program) {
448
406
  if (opts.pace && !["slow", "normal", "fast"].includes(opts.pace)) {
449
407
  throw new Error(`--pace must be one of slow|normal|fast (got: ${opts.pace})`);
450
408
  }
409
+ if (opts.motion && !["off", "lite", "max"].includes(opts.motion)) {
410
+ throw new Error(`--motion must be one of off|lite|max (got: ${opts.motion})`);
411
+ }
412
+ if (opts.subtitleStyle && !["plate", "stroke", "cinema"].includes(opts.subtitleStyle)) {
413
+ throw new Error(`--subtitle-style must be one of plate|stroke|cinema (got: ${opts.subtitleStyle})`);
414
+ }
451
415
  // 1. Layer defaults: --redo → --recipe → CLI opts → positional topic
452
416
  let body = {};
453
417
  if (opts.redo) {
@@ -480,19 +444,14 @@ export function registerCreate(program) {
480
444
  if (typeof body.script === "string") {
481
445
  body.script = await resolveTextOrFile(body.script);
482
446
  }
483
- // Resolve refs: local file paths → data: URIs (RelayX accepts both
484
- // https:// and data: in image_urls). Done after layering so a recipe
485
- // can carry refs by path too.
447
+ // Resolve character ref: local file path → data: URI (RelayX accepts
448
+ // both https:// and data: in image_urls). Done after layering so a
449
+ // recipe can carry the ref by path too.
486
450
  const resolvedChar = await resolveRefImage(body.character_ref, "--character-ref");
487
- const resolvedStyle = await resolveRefImage(body.style_ref, "--style-ref");
488
451
  if (resolvedChar !== undefined)
489
452
  body.character_ref = resolvedChar;
490
453
  else
491
454
  delete body.character_ref;
492
- if (resolvedStyle !== undefined)
493
- body.style_ref = resolvedStyle;
494
- else
495
- delete body.style_ref;
496
455
  // Validate content mode
497
456
  const hasTopic = typeof body.topic === "string" && body.topic.trim().length > 0;
498
457
  const hasScript = typeof body.script === "string" && body.script.trim().length > 0;
@@ -502,11 +461,8 @@ export function registerCreate(program) {
502
461
  if (hasTopic && hasScript) {
503
462
  throw new Error("--topic and --script are mutually exclusive (pick one mode).");
504
463
  }
505
- // 3. Final body — drop empty / null fields
464
+ // 3. Final body
506
465
  const finalBody = { ...body };
507
- if (finalBody.frame_template_inline && finalBody.frame_template) {
508
- delete finalBody.frame_template;
509
- }
510
466
  // 4. Estimate cost
511
467
  const estimate = estimateUnits(finalBody);
512
468
  if (opts.dryRun) {
@@ -1,6 +1,35 @@
1
+ import fs from "node:fs/promises";
2
+ import fsSync from "node:fs";
3
+ import path from "node:path";
1
4
  import { post } from "../client.js";
2
5
  import { downloadTo } from "../utils/download.js";
3
6
  import { print, success } from "../utils/output.js";
7
+ /**
8
+ * Reference image resolver. Accepts an https:// URL, a data: URI, or a local
9
+ * file path. Local files are read + base64-encoded into a data: URI so the
10
+ * server can forward them inside the JSON body — no file-hosting step needed.
11
+ */
12
+ async function resolveImageRef(input) {
13
+ const t = input.trim();
14
+ if (!t)
15
+ throw new Error("--image: empty value");
16
+ if (/^https?:\/\//i.test(t) || t.startsWith("data:"))
17
+ return t;
18
+ const abs = path.resolve(t);
19
+ if (!fsSync.existsSync(abs)) {
20
+ throw new Error(`--image: local file not found: ${abs}`);
21
+ }
22
+ const ext = path.extname(abs).toLowerCase();
23
+ const mime = ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
24
+ ext === ".webp" ? "image/webp" :
25
+ ext === ".png" ? "image/png" :
26
+ null;
27
+ if (!mime) {
28
+ throw new Error(`--image: unsupported extension ${ext} (use png/jpg/jpeg/webp)`);
29
+ }
30
+ const buf = await fs.readFile(abs);
31
+ return `data:${mime};base64,${buf.toString("base64")}`;
32
+ }
4
33
  export function registerImages(program) {
5
34
  const images = program
6
35
  .command("images")
@@ -8,19 +37,30 @@ export function registerImages(program) {
8
37
  .helpOption("-h, --help", "show help");
9
38
  images
10
39
  .command("generate")
11
- .description("Generate an image via RelayX")
40
+ .description("Generate an image via RelayX (text-to-image or ref-image, depending on model + --image)")
12
41
  .helpOption("-h, --help", "show help")
13
- .requiredOption("-p, --prompt <text>", "text prompt")
14
- .option("-m, --model <id>", "RelayX image model id (rx-image-z | rx-image-flux | rx-image-qwen)")
42
+ .requiredOption("-p, --prompt <text>", "text prompt. With --image, reference each ref via 「图 N」/「Image N」.")
43
+ .option("-m, --model <id>", "RelayX image model id (rx-image-z | rx-image-flux | rx-image-qwen | rx-image-qwen-edit)")
15
44
  .option("--width <n>", "image width", parseInt)
16
45
  .option("--height <n>", "image height", parseInt)
46
+ .option("-i, --image <urlOrPath>", "reference image, repeatable up to 3 times. URL / data: URI / local png/jpg/webp path (auto-base64'd). Index N-1 maps to 「图 N」in the prompt. Only honored by edit-capable SKUs (rx-image-qwen-edit).", (val, prev) => [...prev, val], [])
17
47
  .option("-o, --output <file>", "download first image to this local path")
18
48
  .option("--all-output <dir>", "download ALL generated images into this directory")
19
49
  .addHelpText("after", [
20
50
  "",
21
51
  "Examples:",
52
+ " # plain text-to-image",
22
53
  " reelforge images generate -p 'a cat' -m rx-image-flux --width 1024 --height 1024 -o cat.png",
23
- " reelforge images generate -p 'cyberpunk city, neon, 9:16' --width 1080 --height 1920 -o city.png",
54
+ "",
55
+ " # ref-image edit (Qwen-Image-Edit), 1 ref local file",
56
+ " reelforge images generate -m rx-image-qwen-edit \\",
57
+ " -p '保持图1人物,把背景改成雪山黄昏' \\",
58
+ " -i ./hero.png -o out.png",
59
+ "",
60
+ " # ref-image edit, 2 refs (URL + local), 「图1」+「图2」",
61
+ " reelforge images generate -m rx-image-qwen-edit \\",
62
+ " -p '让图1的人物穿上图2的衣服' \\",
63
+ " -i https://example.com/person.jpg -i ./outfit.png -o composite.png",
24
64
  ].join("\n"))
25
65
  .action(async (opts) => {
26
66
  const body = { prompt: opts.prompt };
@@ -30,6 +70,12 @@ export function registerImages(program) {
30
70
  body.width = opts.width;
31
71
  if (opts.height !== undefined)
32
72
  body.height = opts.height;
73
+ if (opts.image && Array.isArray(opts.image) && opts.image.length > 0) {
74
+ if (opts.image.length > 3) {
75
+ throw new Error(`--image accepts at most 3 refs (got ${opts.image.length})`);
76
+ }
77
+ body.image_urls = await Promise.all(opts.image.map(resolveImageRef));
78
+ }
33
79
  const r = await post("/api/v1/images/generate", body);
34
80
  if (opts.output && r.images?.[0]) {
35
81
  await downloadTo(r.images[0], opts.output);
@@ -1,6 +1,4 @@
1
1
  import fs from "node:fs/promises";
2
- import fsSync from "node:fs";
3
- import path from "node:path";
4
2
  import { post } from "../client.js";
5
3
  import { waitForTask } from "../utils/task-waiter.js";
6
4
  import { downloadTo } from "../utils/download.js";
@@ -47,11 +45,11 @@ export function registerPipelines(program) {
47
45
  .option("--script <text>", "your own master script text (mode=fixed). Use @file to read from disk.")
48
46
  .option("-d, --duration <sec>", "target video duration in seconds (generate mode; default 45)", (v) => parseInt(v, 10))
49
47
  .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")
51
- .option("--frame-template-size <wxh>", "size for inline HTML when the file lacks <meta template:width|height>")
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)")
48
+ .option("--motion <preset>", "per-scene image animation: off | lite (default) | max")
49
+ .option("--subtitle-style <preset>", "subtitle visual style: plate (default) | stroke | cinema")
50
+ .option("--image-model <id>", "RelayX image model (rx-image-z | rx-image-flux | rx-image-qwen | rx-image-qwen-edit)")
54
51
  .option("--prompt-prefix <text>", "style prefix prepended to every image prompt")
52
+ .option("--character-ref <urlOrPath>", "main character ref for cross-scene identity lock")
55
53
  .option("--voice-id <id>", "RelayX TTS voice id (default 专业解说); see `rf tts voices`")
56
54
  .option("--tts-speed <n>", "speech speed (0.5..2; default 1.0)", parseFloat)
57
55
  .option("--video-fps <n>", "output video fps (default 30)", (v) => parseInt(v, 10))
@@ -64,11 +62,15 @@ export function registerPipelines(program) {
64
62
  " fixed You supply the script. --script <text-or-@file>",
65
63
  "",
66
64
  "Pace (LLM visual rhythm hint): slow | normal | fast",
65
+ "Motion (per-scene animation): off | lite | max",
66
+ "Subtitle style: plate | stroke | cinema",
67
67
  "",
68
68
  "Examples:",
69
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
+ " rf pipelines standard --script @script.txt -p slow --motion max -o out.mp4",
71
+ " rf pipelines standard -t '宠物' --motion lite --subtitle-style cinema -o final.mp4",
72
+ "",
73
+ "Tip: `rf create` is a more ergonomic wrapper around the same endpoint.",
72
74
  ].join("\n"))).action(async (opts) => {
73
75
  const hasTopic = typeof opts.topic === "string" && opts.topic.length > 0;
74
76
  const hasScript = typeof opts.script === "string" && opts.script.length > 0;
@@ -81,24 +83,28 @@ export function registerPipelines(program) {
81
83
  if (opts.pace && !["slow", "normal", "fast"].includes(opts.pace)) {
82
84
  throw new Error(`--pace must be one of slow|normal|fast (got: ${opts.pace})`);
83
85
  }
86
+ if (opts.motion && !["off", "lite", "max"].includes(opts.motion)) {
87
+ throw new Error(`--motion must be one of off|lite|max (got: ${opts.motion})`);
88
+ }
89
+ if (opts.subtitleStyle && !["plate", "stroke", "cinema"].includes(opts.subtitleStyle)) {
90
+ throw new Error(`--subtitle-style must be one of plate|stroke|cinema (got: ${opts.subtitleStyle})`);
91
+ }
84
92
  let topic = opts.topic;
85
93
  let script = opts.script;
86
94
  if (topic?.startsWith("@"))
87
95
  topic = await fs.readFile(topic.slice(1), "utf-8");
88
96
  if (script?.startsWith("@"))
89
97
  script = await fs.readFile(script.slice(1), "utf-8");
90
- const tpl = opts.frameTemplate
91
- ? resolveTemplateArg(opts.frameTemplate, opts.frameTemplateSize)
92
- : {};
93
98
  await submitAndMaybeWait("/api/v1/pipelines/standard", {
94
99
  topic,
95
100
  script,
96
101
  duration: opts.duration,
97
102
  pace: opts.pace,
98
- ...tpl,
99
- frame_template_type: opts.frameTemplateType,
103
+ motion: opts.motion,
104
+ subtitle_style: opts.subtitleStyle,
100
105
  image_model: opts.imageModel,
101
106
  prompt_prefix: opts.promptPrefix,
107
+ character_ref: opts.characterRef,
102
108
  voice_id: opts.voiceId,
103
109
  tts_speed: opts.ttsSpeed,
104
110
  video_fps: opts.videoFps,
@@ -107,14 +113,3 @@ export function registerPipelines(program) {
107
113
  }, { wait: opts.wait, output: opts.output, pollMs: opts.pollMs, timeoutMs: opts.timeoutMs });
108
114
  });
109
115
  }
110
- function resolveTemplateArg(value, size) {
111
- if (/^[.~]|^\//.test(value) || value.includes("\\") || (value.endsWith(".html") && fsSync.existsSync(value))) {
112
- const abs = path.resolve(value);
113
- if (!fsSync.existsSync(abs)) {
114
- throw new Error(`Local template not found: ${abs}`);
115
- }
116
- const html = fsSync.readFileSync(abs, "utf-8");
117
- return size ? { frame_template_inline: html, frame_template_size: size } : { frame_template_inline: html };
118
- }
119
- return { frame_template: value };
120
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelforge",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",