reelforge 1.18.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.
Files changed (39) hide show
  1. package/README.md +295 -0
  2. package/bin/reelforge.js +8 -0
  3. package/dist/client.js +120 -0
  4. package/dist/commands/assets-workflow-text.js +22 -0
  5. package/dist/commands/assets.js +231 -0
  6. package/dist/commands/audio.js +73 -0
  7. package/dist/commands/auth.js +170 -0
  8. package/dist/commands/bgm.js +45 -0
  9. package/dist/commands/compose.js +293 -0
  10. package/dist/commands/compositions.js +143 -0
  11. package/dist/commands/config.js +62 -0
  12. package/dist/commands/content.js +66 -0
  13. package/dist/commands/cover.js +397 -0
  14. package/dist/commands/create.js +629 -0
  15. package/dist/commands/extract.js +102 -0
  16. package/dist/commands/fetch.js +129 -0
  17. package/dist/commands/files.js +56 -0
  18. package/dist/commands/health.js +12 -0
  19. package/dist/commands/history.js +44 -0
  20. package/dist/commands/images.js +88 -0
  21. package/dist/commands/llm.js +67 -0
  22. package/dist/commands/media.js +128 -0
  23. package/dist/commands/models.js +36 -0
  24. package/dist/commands/pipelines.js +142 -0
  25. package/dist/commands/platform.js +218 -0
  26. package/dist/commands/regen.js +134 -0
  27. package/dist/commands/render.js +82 -0
  28. package/dist/commands/script.js +128 -0
  29. package/dist/commands/styles.js +113 -0
  30. package/dist/commands/subtitles.js +246 -0
  31. package/dist/commands/tasks.js +59 -0
  32. package/dist/commands/tts.js +134 -0
  33. package/dist/index.js +173 -0
  34. package/dist/utils/config-file.js +37 -0
  35. package/dist/utils/download.js +13 -0
  36. package/dist/utils/file-upload.js +59 -0
  37. package/dist/utils/output.js +91 -0
  38. package/dist/utils/task-waiter.js +40 -0
  39. package/package.json +44 -0
@@ -0,0 +1,142 @@
1
+ import fs from "node:fs/promises";
2
+ import { post } from "../client.js";
3
+ import { waitForTask } from "../utils/task-waiter.js";
4
+ import { downloadTo } from "../utils/download.js";
5
+ import { print, success, warn } from "../utils/output.js";
6
+ async function submitAndMaybeWait(endpoint, body, common) {
7
+ const { task_id, status } = await post(endpoint, body);
8
+ if (common.wait === false) {
9
+ print({ task_id, status });
10
+ return;
11
+ }
12
+ const t = await waitForTask(task_id, {
13
+ pollMs: common.pollMs ?? 1500,
14
+ timeoutMs: common.timeoutMs,
15
+ });
16
+ if (t.status !== "completed") {
17
+ throw new Error(t.error || `Task ended with status ${t.status}`);
18
+ }
19
+ const result = t.result;
20
+ if (common.output && result?.video_url) {
21
+ await downloadTo(result.video_url, common.output);
22
+ success(`Saved → ${common.output}`);
23
+ }
24
+ print({ task_id: t.id, status: t.status, ...result });
25
+ }
26
+ function commonOptions(cmd) {
27
+ return cmd
28
+ .option("--no-wait", "submit and return task_id immediately (do not poll)")
29
+ .option("-o, --output <file>", "save the final video to this path (only when waiting)")
30
+ .option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
31
+ .option("--timeout-ms <ms>", "max wait time before aborting (default unlimited)", (v) => parseInt(v, 10));
32
+ }
33
+ export function registerPipelines(program) {
34
+ const pl = program
35
+ .command("pipelines")
36
+ .alias("pipeline")
37
+ .description("端到端视频流水线 (与 rf create 等价, 暴露更多底层参数)")
38
+ .helpOption("-h, --help", "show help");
39
+ commonOptions(pl
40
+ .command("standard")
41
+ .description("Audio-first pipeline: topic|script → master TTS → ASR → scene/subtitle layers → final MP4")
42
+ .helpOption("-h, --help", "show help")
43
+ .option("-t, --topic <text>", "video topic (mode=generate). Use @file to read from disk.")
44
+ .option("--script <text>", "your own master script text (mode=fixed). Use @file to read from disk.")
45
+ .option("--title <text>", "hard-override video title; LLM will NOT auto-summarize. Useful for compliance-sensitive content. Use @file to read from disk. Pass --title '' (empty) to explicitly suppress title rendering. Omit to keep LLM auto-title.")
46
+ .option("-d, --duration <sec>", "target video duration in seconds (generate mode; default 45)", (v) => parseInt(v, 10))
47
+ .option("-p, --pace <pace>", "visual rhythm hint: slow | normal | fast (default normal)")
48
+ .option("--motion <preset>", "per-scene image animation: off | lite (default) | max")
49
+ .option("--layout <preset>", "image layout: full (default) | blur-bg | letterbox. See below.")
50
+ .option("--layout-matte-color <css>", "letterbox matte color (CSS). Ignored unless --layout letterbox. Default: black.")
51
+ .option("--subtitle-style <preset>", "subtitle visual style: plate (default) | stroke | cinema")
52
+ .option("--preview-only", "只生成可预览的网页版,不出 MP4(快很多)。后续用 `rf render <task-id>` 再出 MP4。")
53
+ .option("--image-model <id>", "image model (rx-image-z | rx-image-flux | rx-image-qwen | rx-image-qwen-edit)")
54
+ .option("--prompt-prefix <text>", "style prefix prepended to every image prompt")
55
+ .option("--character-ref <urlOrPath>", "main character ref for cross-scene identity lock")
56
+ .option("--voice-id <id>", "TTS voice id (default 专业解说); see `rf tts voices`")
57
+ .option("--tts-speed <n>", "speech speed (0.5..2; default 1.0). Only honored by some TTS backends; the default cloud model ignores it.", parseFloat)
58
+ .option("--video-fps <n>", "output video fps (default 24 — cinema-standard, ~20% faster render vs 30; pass 30 if you want smoother motion)", (v) => parseInt(v, 10))
59
+ .option("--segment-min-chars <N>", "subtitle SEGMENT (分段) min chars (default 10). Segment = one on-screen subtitle unit, not a visual line.", (v) => parseInt(v, 10))
60
+ .option("--segment-max-chars <N>", "subtitle SEGMENT (分段) max chars (default 28 ≈ 2 visual lines at 820px safe width). HTML clamps to 2 lines + ellipsis.", (v) => parseInt(v, 10))
61
+ .addHelpText("after", [
62
+ "",
63
+ "Two content modes (exactly one required):",
64
+ " generate AI writes the script. --topic / -t <text> + optional --duration -d",
65
+ " fixed You supply the script. --script <text-or-@file>",
66
+ "",
67
+ "Pace (LLM visual rhythm hint): slow | normal | fast",
68
+ "Motion (per-scene animation): off | lite | max",
69
+ "Subtitle style: plate | stroke | cinema",
70
+ "",
71
+ "Layout — how the AI image sits in the 1080×1920 canvas:",
72
+ " full image fills the whole canvas (default; punchy, 9:16-native content).",
73
+ " blur-bg image at 1080×1080 centered + same image scaled/blurred as background",
74
+ " (小红书 / 抖音 style; best for charts, screenshots, non-9:16 source).",
75
+ " letterbox image at 1080×1080 centered + solid matte top/bottom (cinematic).",
76
+ " tweak with --layout-matte-color (default 'black').",
77
+ " · Image is generated at the actual on-screen size, so you don't pay for cropped pixels.",
78
+ "",
79
+ "Examples:",
80
+ " rf pipelines standard -t 'why we explore space' -d 60 -o space.mp4",
81
+ " rf pipelines standard --script @script.txt -p slow --motion max -o out.mp4",
82
+ " rf pipelines standard -t '财经日报' --layout blur-bg --subtitle-style plate -o out.mp4",
83
+ " rf pipelines standard -t '纪录片' --layout letterbox --motion max -o film.mp4",
84
+ "",
85
+ "Tip: `rf create` is a more ergonomic wrapper around the same endpoint.",
86
+ ].join("\n"))).action(async (opts) => {
87
+ const hasTopic = typeof opts.topic === "string" && opts.topic.length > 0;
88
+ const hasScript = typeof opts.script === "string" && opts.script.length > 0;
89
+ if (!hasTopic && !hasScript) {
90
+ throw new Error("either --topic / -t or --script is required");
91
+ }
92
+ if (hasTopic && hasScript) {
93
+ throw new Error("--topic and --script are mutually exclusive");
94
+ }
95
+ if (opts.pace && !["slow", "normal", "fast"].includes(opts.pace)) {
96
+ throw new Error(`--pace must be one of slow|normal|fast (got: ${opts.pace})`);
97
+ }
98
+ if (opts.motion && !["off", "lite", "max"].includes(opts.motion)) {
99
+ throw new Error(`--motion must be one of off|lite|max (got: ${opts.motion})`);
100
+ }
101
+ if (opts.layout && !["full", "blur-bg", "letterbox"].includes(opts.layout)) {
102
+ throw new Error(`--layout must be one of full|blur-bg|letterbox (got: ${opts.layout})`);
103
+ }
104
+ if (opts.subtitleStyle && !["plate", "stroke", "cinema"].includes(opts.subtitleStyle)) {
105
+ throw new Error(`--subtitle-style must be one of plate|stroke|cinema (got: ${opts.subtitleStyle})`);
106
+ }
107
+ if (opts.ttsSpeed !== undefined &&
108
+ (!opts.ttsModel || opts.ttsModel.startsWith("vox/"))) {
109
+ const m = opts.ttsModel || "default";
110
+ warn(`--tts-speed=${opts.ttsSpeed} will be ignored by TTS model "${m}". Speed control is only honored by Edge-TTS-backed models.`);
111
+ }
112
+ let topic = opts.topic;
113
+ let script = opts.script;
114
+ let title = opts.title;
115
+ if (topic?.startsWith("@"))
116
+ topic = await fs.readFile(topic.slice(1), "utf-8");
117
+ if (script?.startsWith("@"))
118
+ script = await fs.readFile(script.slice(1), "utf-8");
119
+ if (title?.startsWith("@"))
120
+ title = await fs.readFile(title.slice(1), "utf-8");
121
+ await submitAndMaybeWait("/api/v1/pipelines/standard", {
122
+ topic,
123
+ script,
124
+ title,
125
+ duration: opts.duration,
126
+ pace: opts.pace,
127
+ motion: opts.motion,
128
+ layout: opts.layout,
129
+ layout_matte_color: opts.layoutMatteColor,
130
+ subtitle_style: opts.subtitleStyle,
131
+ image_model: opts.imageModel,
132
+ prompt_prefix: opts.promptPrefix,
133
+ character_ref: opts.characterRef,
134
+ voice_id: opts.voiceId,
135
+ tts_speed: opts.ttsSpeed,
136
+ video_fps: opts.videoFps,
137
+ segment_min_chars: opts.segmentMinChars,
138
+ segment_max_chars: opts.segmentMaxChars,
139
+ preview_only: opts.previewOnly,
140
+ }, { wait: opts.wait, output: opts.output, pollMs: opts.pollMs, timeoutMs: opts.timeoutMs });
141
+ });
142
+ }
@@ -0,0 +1,218 @@
1
+ import kleur from "kleur";
2
+ import { isJson, print, table } from "../utils/output.js";
3
+ const CANVAS_W = 1080;
4
+ const CANVAS_H = 1920;
5
+ const IMAGE_SIDE = 1080;
6
+ const SLACK = CANVAS_H - IMAGE_SIDE;
7
+ const PLATFORMS = {
8
+ douyin: {
9
+ label: "Douyin (抖音)",
10
+ top: 250,
11
+ bottom: 400,
12
+ left: 150,
13
+ right: 250,
14
+ centerMax: 820,
15
+ reason: "Cover-crop on tall phones cuts ~96-180px per side; status bar ~250px; bottom caption + progress + buttons ~400px; right action-button column ~150px from the visible edge.",
16
+ recommendedAnchor: 0.40,
17
+ anchorVariants: [
18
+ { value: 0.50, case: "短描述 / 无评论弹幕(默认)" },
19
+ { value: 0.40, case: "长描述,留更多底部 matte(推荐)" },
20
+ { value: 0.30, case: "很长描述 / 评论弹幕活跃" },
21
+ ],
22
+ },
23
+ tiktok: {
24
+ label: "TikTok",
25
+ top: 220,
26
+ bottom: 380,
27
+ left: 150,
28
+ right: 240,
29
+ centerMax: 820,
30
+ reason: "Same cover-crop behaviour as Douyin; slightly smaller top tab area and bottom caption.",
31
+ recommendedAnchor: 0.42,
32
+ anchorVariants: [
33
+ { value: 0.50, case: "short caption(default)" },
34
+ { value: 0.42, case: "long caption / hashtags(recommended)" },
35
+ { value: 0.30, case: "verbose caption + many hashtags" },
36
+ ],
37
+ },
38
+ wechat: {
39
+ label: "WeChat Channels (视频号)",
40
+ top: 200,
41
+ bottom: 350,
42
+ left: 120,
43
+ right: 120,
44
+ centerMax: 880,
45
+ reason: "Cover-crop similar; right-side action buttons less obtrusive than 抖音/TikTok, so right padding can be smaller.",
46
+ recommendedAnchor: 0.45,
47
+ anchorVariants: [
48
+ { value: 0.50, case: "短描述(默认)" },
49
+ { value: 0.45, case: "长描述(推荐)" },
50
+ { value: 0.30, case: "评论弹幕活跃" },
51
+ ],
52
+ },
53
+ };
54
+ const ALIASES = {
55
+ 抖音: "douyin",
56
+ dy: "douyin",
57
+ tt: "tiktok",
58
+ 视频号: "wechat",
59
+ weixin: "wechat",
60
+ wx: "wechat",
61
+ };
62
+ function resolveKey(input) {
63
+ const lower = input.toLowerCase();
64
+ if (PLATFORMS[lower])
65
+ return lower;
66
+ if (ALIASES[input])
67
+ return ALIASES[input];
68
+ if (ALIASES[lower])
69
+ return ALIASES[lower];
70
+ return null;
71
+ }
72
+ function anchorRange(z) {
73
+ const minAnchor = z.top / SLACK;
74
+ const maxAnchor = (SLACK - z.bottom) / SLACK;
75
+ return [Math.max(0, minAnchor), Math.min(1, maxAnchor)];
76
+ }
77
+ function imageYRange(anchor) {
78
+ const top = Math.round(anchor * SLACK);
79
+ const bottom = top + IMAGE_SIDE;
80
+ return { top, bottom, bottomMatte: CANVAS_H - bottom };
81
+ }
82
+ function printOverview() {
83
+ process.stdout.write(kleur.bold("Reference safe zones for short-video platforms (1080×1920)\n") +
84
+ kleur.dim("Numbers cover ~90% of mainstream phones — verify on your own target devices.\n\n"));
85
+ const rows = Object.entries(PLATFORMS).map(([key, z]) => {
86
+ const [lo, hi] = anchorRange(z);
87
+ return {
88
+ platform: key,
89
+ label: z.label,
90
+ top: z.top,
91
+ bottom: z.bottom,
92
+ left: z.left,
93
+ right: z.right,
94
+ "anchor-range": `[${lo.toFixed(2)}, ${hi.toFixed(2)}]`,
95
+ recommended: z.recommendedAnchor.toFixed(2),
96
+ };
97
+ });
98
+ table(rows);
99
+ process.stdout.write("\n" +
100
+ kleur.dim("Detail + anchor variants: ") +
101
+ kleur.cyan("rf platform <name>") +
102
+ "\n" +
103
+ kleur.dim("Aliases: 抖音=douyin · 视频号=wechat · tt=tiktok\n"));
104
+ }
105
+ function printDetail(key, z) {
106
+ const [lo, hi] = anchorRange(z);
107
+ const lines = [];
108
+ lines.push(kleur.bold(`${z.label} — safe zones for 1080×1920 vertical video.`));
109
+ lines.push("");
110
+ lines.push(kleur.dim("Why: ") + z.reason);
111
+ lines.push("");
112
+ lines.push(kleur.bold("Safe zones (padding from canvas edge, pixels):"));
113
+ lines.push(` top ${String(z.top).padStart(4)} (status bar / Dynamic Island)`);
114
+ lines.push(` bottom ${String(z.bottom).padStart(4)} (description + progress + buttons)`);
115
+ lines.push(` left ${String(z.left).padStart(4)} (cover-crop buffer)`);
116
+ lines.push(` right ${String(z.right).padStart(4)} (cover-crop + right action column)`);
117
+ lines.push(` center-max width ${String(z.centerMax).padStart(4)} (max safe horizontal width for centred elements)`);
118
+ lines.push("");
119
+ lines.push(kleur.bold("For blur-bg / letterbox layouts (--media-anchor-y):"));
120
+ lines.push(` Safe range: ${kleur.cyan(`[${lo.toFixed(2)}, ${hi.toFixed(2)}]`)}` +
121
+ kleur.dim(` (image top ≥ ${z.top}, image bottom ≤ ${CANVAS_H - z.bottom})`));
122
+ lines.push("");
123
+ lines.push(" Variants:");
124
+ for (const v of z.anchorVariants) {
125
+ const r = imageYRange(v.value);
126
+ const inRange = v.value >= lo && v.value <= hi;
127
+ const tag = inRange ? kleur.green("✓") : kleur.red("⚠");
128
+ const recommended = v.value === z.recommendedAnchor ? kleur.cyan(" (recommended)") : "";
129
+ lines.push(` ${tag} ${kleur.bold(v.value.toFixed(2))} ${v.case}${recommended}`);
130
+ lines.push(kleur.dim(` image y=${r.top}..${r.bottom}, bottom matte ${r.bottomMatte}px`));
131
+ }
132
+ lines.push("");
133
+ const outsideVariants = z.anchorVariants.filter((v) => v.value < lo || v.value > hi);
134
+ if (outsideVariants.length) {
135
+ lines.push(kleur.yellow("⚠ Caveats:"));
136
+ for (const v of outsideVariants) {
137
+ if (v.value < lo) {
138
+ lines.push(kleur.dim(` anchor=${v.value.toFixed(2)} pushes image top to y=${Math.round(v.value * SLACK)}, below ${z.label} safe top (${z.top}px) — may be clipped by Dynamic Island / status bar on some devices.`));
139
+ }
140
+ else {
141
+ lines.push(kleur.dim(` anchor=${v.value.toFixed(2)} pushes image bottom to y=${Math.round(v.value * SLACK) + IMAGE_SIDE}, beyond ${z.label} safe bottom (${CANVAS_H - z.bottom}) — content may be covered by platform UI.`));
142
+ }
143
+ }
144
+ lines.push("");
145
+ }
146
+ lines.push(kleur.bold("Quick start:"));
147
+ lines.push(" " +
148
+ kleur.cyan(`rf create "..." --layout blur-bg --media-anchor-y ${z.recommendedAnchor.toFixed(2)}`));
149
+ lines.push("");
150
+ lines.push(kleur.dim(`Note: 'full' layout fills the canvas and ignores --media-anchor-y. For platforms with heavy bottom UI like ${z.label}, prefer blur-bg or letterbox so you have a matte band to lift the image into.`));
151
+ process.stdout.write(lines.join("\n") + "\n");
152
+ }
153
+ export function registerPlatform(program) {
154
+ program
155
+ .command("platform [name]")
156
+ .description("查询竖屏平台 (抖音 / TikTok / 视频号) 的安全区参数与推荐 --media-anchor-y 值")
157
+ .helpOption("-h, --help", "show help")
158
+ .addHelpText("after", [
159
+ "",
160
+ "Why this exists:",
161
+ " Different short-video apps overlay their own UI (status bar, caption,",
162
+ " progress bar, action buttons) on top of your video. The",
163
+ " --media-anchor-y knob lifts the central 1080×1080 image up or down",
164
+ " inside the 1920-high canvas to keep important content out of those",
165
+ " UI zones. This command tells you what anchor value to dial for each",
166
+ " platform.",
167
+ "",
168
+ "Examples:",
169
+ " rf platform # overview table for all platforms",
170
+ " rf platform 抖音 # detail (alias of `douyin`)",
171
+ " rf platform douyin",
172
+ " rf platform tiktok",
173
+ " rf platform wechat # 视频号",
174
+ " rf platform douyin --json # machine-readable",
175
+ ].join("\n"))
176
+ .action((name) => {
177
+ if (isJson()) {
178
+ if (name) {
179
+ const key = resolveKey(name);
180
+ if (!key) {
181
+ print({ ok: false, error: `unknown platform: ${name}`, known: Object.keys(PLATFORMS) });
182
+ process.exit(1);
183
+ }
184
+ const z = PLATFORMS[key];
185
+ const [lo, hi] = anchorRange(z);
186
+ print({
187
+ ok: true,
188
+ platform: key,
189
+ ...z,
190
+ anchorRange: { min: lo, max: hi },
191
+ canvas: { width: CANVAS_W, height: CANVAS_H, imageSide: IMAGE_SIDE },
192
+ });
193
+ }
194
+ else {
195
+ const out = {};
196
+ for (const [k, z] of Object.entries(PLATFORMS)) {
197
+ const [lo, hi] = anchorRange(z);
198
+ out[k] = { ...z, anchorRange: { min: lo, max: hi } };
199
+ }
200
+ print({ ok: true, canvas: { width: CANVAS_W, height: CANVAS_H, imageSide: IMAGE_SIDE }, platforms: out });
201
+ }
202
+ return;
203
+ }
204
+ if (name) {
205
+ const key = resolveKey(name);
206
+ if (!key) {
207
+ process.stderr.write(kleur.red(`✗ unknown platform: ${name}\n`) +
208
+ ` known: ${Object.keys(PLATFORMS).join(", ")}\n` +
209
+ ` aliases: ${Object.keys(ALIASES).join(", ")}\n`);
210
+ process.exit(1);
211
+ }
212
+ printDetail(key, PLATFORMS[key]);
213
+ }
214
+ else {
215
+ printOverview();
216
+ }
217
+ });
218
+ }
@@ -0,0 +1,134 @@
1
+ import { post, get, getServer } from "../client.js";
2
+ import { waitForTask } from "../utils/task-waiter.js";
3
+ import { downloadTo } from "../utils/download.js";
4
+ import { info, print, success, warn } from "../utils/output.js";
5
+ const VALID_KINDS = [
6
+ "image",
7
+ "chart-bar",
8
+ "chart-line",
9
+ "chart-donut",
10
+ "kpi-grid",
11
+ "stat-counter",
12
+ "comparison-vs",
13
+ "tweet-card",
14
+ "ios-messages",
15
+ "notification",
16
+ "quote-card",
17
+ "terminal",
18
+ "code-editor",
19
+ "kinetic-text",
20
+ "bullet-list",
21
+ ];
22
+ export function registerRegen(program) {
23
+ program
24
+ .command("regen")
25
+ .description("重生成已有任务里某个 scene 的画面 (音频和时间轴保持不变)")
26
+ .helpOption("-h, --help", "show help")
27
+ .argument("<task-id>", "task id from `rf create` or `rf create --preview-only`")
28
+ .option("--scene <n>", "1-based scene index to regenerate (REQUIRED)", (v) => parseInt(v, 10))
29
+ .option("--kind <kind>", `force a specific visual kind (one of: ${VALID_KINDS.join(", ")})`, (v) => {
30
+ if (!VALID_KINDS.includes(v)) {
31
+ throw new Error(`--kind must be one of: ${VALID_KINDS.join(", ")} (got "${v}")`);
32
+ }
33
+ return v;
34
+ })
35
+ .option("--hint <text>", "natural-language guidance to steer the regen (e.g. \"数字要大一点 / 用左右两栏不是上下\")")
36
+ .option("-o, --output <file>", "save the re-rendered MP4 to this exact path (completed-task path only)")
37
+ .option("--no-download", "skip downloading the re-rendered MP4 (completed-task path only)")
38
+ .option("--no-wait", "submit and return job id immediately (do not poll)")
39
+ .option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
40
+ .option("--timeout-ms <ms>", "max wait time (default: unlimited)", (v) => parseInt(v, 10))
41
+ .addHelpText("after", [
42
+ "",
43
+ "What happens:",
44
+ " • LLM picks a fresh { kind, spec } for the target scene only.",
45
+ " • Image scenes also re-call image gen (~5-15s, ~$0.003).",
46
+ " • Composition is rebuilt — opens in /preview/<task-id> after refresh.",
47
+ " • If the task was already MP4-rendered, MP4 is re-rendered too.",
48
+ "",
49
+ "Behavior matrix:",
50
+ " preview-only task → composition only, ~5-30s, no MP4 in response",
51
+ " completed task → composition + MP4, ~45-90s, MP4 downloaded",
52
+ "",
53
+ "Examples:",
54
+ " rf regen 2026-05-28T12-00-00-000Z_abcd --scene 3",
55
+ " rf regen 2026-05-28T12-00-00-000Z_abcd --scene 3 --kind kinetic-text",
56
+ " rf regen 2026-05-28T12-00-00-000Z_abcd --scene 5 --kind chart-bar -o out.mp4",
57
+ ].join("\n"))
58
+ .action(async (taskId, opts) => {
59
+ if (opts.scene === undefined) {
60
+ throw new Error("--scene <n> is required (1-based scene index)");
61
+ }
62
+ if (!Number.isFinite(opts.scene) || opts.scene < 1) {
63
+ throw new Error("--scene must be a positive integer (1-based)");
64
+ }
65
+ info(`Regenerating scene ${opts.scene} on task ${taskId}` +
66
+ (opts.kind ? ` (kind=${opts.kind})` : " (free pick)") +
67
+ "…");
68
+ const body = { scene_index: opts.scene };
69
+ if (opts.kind)
70
+ body.kind = opts.kind;
71
+ if (opts.hint)
72
+ body.hint = opts.hint;
73
+ const submitted = await post(`/api/v1/pipelines/standard/${encodeURIComponent(taskId)}/regenerate-scene`, body);
74
+ if (opts.wait === false) {
75
+ print({ task_id: submitted.task_id, status: submitted.status });
76
+ return;
77
+ }
78
+ info(`Regen job: ${submitted.task_id} — waiting…`);
79
+ const t = await waitForTask(submitted.task_id, {
80
+ pollMs: opts.pollMs,
81
+ timeoutMs: opts.timeoutMs,
82
+ });
83
+ if (t.status !== "completed") {
84
+ throw new Error(t.error || `Regen ended with status ${t.status}`);
85
+ }
86
+ const result = t.result;
87
+ const newKind = result?.new_kind ?? "(unknown)";
88
+ const rerendered = !!result?.rerendered_mp4;
89
+ success(`Scene ${opts.scene} → kind=${newKind}, ` +
90
+ (rerendered ? "MP4 re-rendered" : "composition only"));
91
+ if (result?.price_usd !== undefined) {
92
+ info(`Cost: $${result.price_usd.toFixed(4)}`);
93
+ }
94
+ const serverBase = getServer().replace(/\/+$/, "");
95
+ info(`预览页 (刷新即可看到新画面): ${serverBase}/preview/${taskId}`);
96
+ if (rerendered && result?.video_url) {
97
+ const stdoutIsPipe = !process.stdout.isTTY;
98
+ const skipDownload = opts.download === false || (stdoutIsPipe && !opts.output);
99
+ let savedPath = opts.output;
100
+ if (!savedPath && !skipDownload) {
101
+ let title;
102
+ try {
103
+ const list = await get("/api/v1/history?limit=200");
104
+ title = list.items?.find((it) => it.task_id === taskId)?.title;
105
+ }
106
+ catch {
107
+ }
108
+ const safeTitle = (title || "video")
109
+ .replace(/[\\/:*?"<>|]/g, "_")
110
+ .slice(0, 80);
111
+ savedPath = `./${safeTitle}-${taskId}.mp4`;
112
+ }
113
+ if (savedPath) {
114
+ await downloadTo(result.video_url, savedPath);
115
+ success(`MP4 saved → ${savedPath}`);
116
+ }
117
+ }
118
+ else if (!rerendered) {
119
+ warn("Task is in preview state — no MP4 produced. Run `rf render " +
120
+ taskId +
121
+ "` when you're happy with the preview.");
122
+ }
123
+ print({
124
+ task_id: taskId,
125
+ scene_index: opts.scene,
126
+ new_kind: newKind,
127
+ rerendered_mp4: rerendered,
128
+ video_url: result?.video_url ?? null,
129
+ file_size: result?.file_size ?? null,
130
+ price_usd: result?.price_usd ?? null,
131
+ preview_urls: result?.preview_urls,
132
+ });
133
+ });
134
+ }
@@ -0,0 +1,82 @@
1
+ import { post, get, getServer } from "../client.js";
2
+ import { waitForTask } from "../utils/task-waiter.js";
3
+ import { downloadTo } from "../utils/download.js";
4
+ import { humanDuration, info, print, success } from "../utils/output.js";
5
+ export function registerRender(program) {
6
+ program
7
+ .command("render")
8
+ .description("把先前已生成预览的任务最终渲染成 MP4")
9
+ .helpOption("-h, --help", "show help")
10
+ .argument("<task-id>", "task id from `rf create --preview-only`")
11
+ .option("-o, --output <file>", "save the MP4 to this exact path (must include filename, e.g. ./out/x.mp4)")
12
+ .option("--no-download", "do not save the video locally — just print JSON with video_url")
13
+ .option("--fps <n>", "override FPS for this render (default: storyboard.config.videoFps)", (v) => parseInt(v, 10))
14
+ .option("--no-wait", "submit and return task_id immediately (do not poll)")
15
+ .option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
16
+ .option("--timeout-ms <ms>", "max wait time before aborting (default unlimited)", (v) => parseInt(v, 10))
17
+ .addHelpText("after", [
18
+ "",
19
+ "Two-step workflow:",
20
+ ' 1. rf create "topic" --preview-only # builds composition, opens browser preview',
21
+ " 2. rf render <task-id> # produces the MP4",
22
+ "",
23
+ "Notes:",
24
+ " • The task must already exist and have a preview-able composition",
25
+ " built (via `rf create --preview-only`).",
26
+ " • This re-renders from the existing composition only — it does NOT",
27
+ " re-run scene-plan / TTS / ASR / image generation.",
28
+ " • If you've already rendered once, calling render again will overwrite.",
29
+ "",
30
+ "Examples:",
31
+ " rf render 2026-05-28T12-00-00-000Z_abcd",
32
+ " rf render 2026-05-28T12-00-00-000Z_abcd -o ./final/space.mp4",
33
+ " rf render 2026-05-28T12-00-00-000Z_abcd --fps 60",
34
+ ].join("\n"))
35
+ .action(async (taskId, opts) => {
36
+ info(`Submitting render for ${taskId}…`);
37
+ const body = {};
38
+ if (opts.fps !== undefined)
39
+ body.fps = opts.fps;
40
+ const submitted = await post(`/api/v1/tasks/${encodeURIComponent(taskId)}/render`, body);
41
+ if (opts.wait === false) {
42
+ print({ task_id: submitted.task_id, status: submitted.status });
43
+ return;
44
+ }
45
+ info(`Render task: ${submitted.task_id} — waiting for completion…`);
46
+ const t = await waitForTask(submitted.task_id, {
47
+ pollMs: opts.pollMs,
48
+ timeoutMs: opts.timeoutMs,
49
+ });
50
+ if (t.status !== "completed") {
51
+ throw new Error(t.error || `Render ended with status ${t.status}`);
52
+ }
53
+ const result = t.result;
54
+ if (result?.video_url) {
55
+ const stdoutIsPipe = !process.stdout.isTTY;
56
+ const skipDownload = !!opts.noDownload || (stdoutIsPipe && !opts.output);
57
+ let savedPath = opts.output;
58
+ if (!savedPath && !skipDownload) {
59
+ let title;
60
+ try {
61
+ const list = await get("/api/v1/history?limit=200");
62
+ title = list.items?.find((it) => it.task_id === taskId)?.title;
63
+ }
64
+ catch { }
65
+ const safeTitle = (title || "video").replace(/[\\/:*?"<>|]/g, "_").slice(0, 80);
66
+ savedPath = `./${safeTitle}-${taskId}.mp4`;
67
+ }
68
+ if (savedPath) {
69
+ await downloadTo(result.video_url, savedPath);
70
+ success(`Saved → ${savedPath}`);
71
+ }
72
+ }
73
+ if (result?.file_size) {
74
+ info(`MP4 size: ${(result.file_size / 1024 / 1024).toFixed(1)} MB`);
75
+ }
76
+ void humanDuration;
77
+ const serverBase = getServer().replace(/\/+$/, "");
78
+ info(`预览页 (全功能): ${serverBase}/preview/${taskId}`);
79
+ info(`纯播放器: ${serverBase}/preview/${taskId}/player`);
80
+ print({ task_id: taskId, ...result });
81
+ });
82
+ }