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,73 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { uploadMultipart, post } from "../client.js";
4
+ import { print } from "../utils/output.js";
5
+ export function registerAudio(program) {
6
+ const audio = program
7
+ .command("audio")
8
+ .description("音频原子能力: 转写 (音频 → 文字 + 词级时间戳)")
9
+ .helpOption("-h, --help", "show help");
10
+ audio
11
+ .command("transcribe")
12
+ .description("Transcribe an audio file to text + word-level timestamps")
13
+ .helpOption("-h, --help", "show help")
14
+ .option("-f, --file <path>", "local audio file (mp3/wav/m4a). Use this OR --url.")
15
+ .option("-u, --url <url>", "remote audio URL — server downloads and transcribes.")
16
+ .option("-l, --language <code>", "language hint (e.g. zh, en). Optional — auto-detected.")
17
+ .option("-m, --model <id>", "override the ASR model id (defaults to the server's)")
18
+ .option("-o, --output <file>", "write the full JSON response to this file as well as stdout")
19
+ .addHelpText("after", [
20
+ "",
21
+ "Examples:",
22
+ " rf audio transcribe -f ./narration.mp3",
23
+ " rf audio transcribe --url https://example.com/clip.mp3 --language zh",
24
+ " rf audio transcribe -f ./voice.wav --json | jq '.words[:5]'",
25
+ ].join("\n"))
26
+ .action(async (opts) => {
27
+ if (!opts.file && !opts.url) {
28
+ throw new Error("either --file or --url is required");
29
+ }
30
+ if (opts.file && opts.url) {
31
+ throw new Error("--file and --url are mutually exclusive");
32
+ }
33
+ let r;
34
+ if (opts.file) {
35
+ const buf = await fs.readFile(opts.file);
36
+ const filename = path.basename(opts.file);
37
+ const ext = path.extname(filename).toLowerCase();
38
+ const mime = ext === ".wav" ? "audio/wav" :
39
+ ext === ".m4a" ? "audio/mp4" :
40
+ ext === ".flac" ? "audio/flac" :
41
+ ext === ".ogg" ? "audio/ogg" :
42
+ "audio/mpeg";
43
+ const fileBlob = new File([new Uint8Array(buf)], filename, { type: mime });
44
+ const fields = { file: fileBlob };
45
+ if (opts.language)
46
+ fields.language = opts.language;
47
+ if (opts.model)
48
+ fields.model = opts.model;
49
+ r = await uploadMultipart("/api/v1/audio/transcribe", fields);
50
+ }
51
+ else {
52
+ const body = { audio_url: opts.url };
53
+ if (opts.language)
54
+ body.language = opts.language;
55
+ if (opts.model)
56
+ body.model = opts.model;
57
+ r = await post("/api/v1/audio/transcribe", body);
58
+ }
59
+ if (opts.output) {
60
+ await fs.writeFile(opts.output, JSON.stringify(r, null, 2), "utf-8");
61
+ }
62
+ print({
63
+ model: r.model,
64
+ language: r.language,
65
+ duration: r.duration,
66
+ text: r.text,
67
+ n_segments: r.segments.length,
68
+ n_words: r.words.length,
69
+ segments: r.segments,
70
+ words: r.words,
71
+ });
72
+ });
73
+ }
@@ -0,0 +1,170 @@
1
+ import http from "node:http";
2
+ import crypto from "node:crypto";
3
+ import os from "node:os";
4
+ import { spawn } from "node:child_process";
5
+ import { get, setApiKey, setServer, getServer } from "../client.js";
6
+ import { loadConfig, saveConfig, deleteConfig, getConfigPath } from "../utils/config-file.js";
7
+ import { info, success, print, table, formatUsd } from "../utils/output.js";
8
+ function openBrowser(url) {
9
+ const cmd = process.platform === "darwin"
10
+ ? "open"
11
+ : process.platform === "win32"
12
+ ? "rundll32"
13
+ : "xdg-open";
14
+ const args = process.platform === "win32"
15
+ ? ["url.dll,FileProtocolHandler", url]
16
+ : [url];
17
+ try {
18
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ async function browserOAuthFlow(serverUrl) {
26
+ const state = crypto.randomBytes(16).toString("hex");
27
+ const hostname = (os.hostname() || "device").slice(0, 64);
28
+ return new Promise((resolve, reject) => {
29
+ let timer = null;
30
+ const server = http.createServer((req, res) => {
31
+ const url = new URL(req.url || "/", "http://127.0.0.1");
32
+ if (url.pathname !== "/cb") {
33
+ res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
34
+ return;
35
+ }
36
+ const gotState = url.searchParams.get("state");
37
+ const token = url.searchParams.get("token");
38
+ const errCode = url.searchParams.get("error");
39
+ const finish = (statusCode, title, body, outcome) => {
40
+ res.writeHead(statusCode, {
41
+ "Content-Type": "text/html; charset=utf-8",
42
+ Connection: "close",
43
+ }).end(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>ReelForge CLI</title><style>body{font-family:-apple-system,system-ui,"Segoe UI",sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#fafaf7;color:#222}div{text-align:center;max-width:24rem;padding:1.5rem}h1{font-size:1.5rem;margin:0 0 .5rem;font-weight:600}p{color:#666;font-size:.875rem;line-height:1.5}</style></head><body><div><h1>${title}</h1><p>${body}</p></div><script>setTimeout(()=>window.close?.(),2000)</script></body></html>`);
44
+ if (timer)
45
+ clearTimeout(timer);
46
+ server.close();
47
+ server.closeAllConnections?.();
48
+ if (outcome.ok)
49
+ resolve(outcome.token);
50
+ else
51
+ reject(outcome.err);
52
+ };
53
+ if (gotState !== state) {
54
+ finish(400, "授权失败", "state 不匹配,可能是 CSRF。请重新跑 reelforge login。", {
55
+ ok: false,
56
+ err: new Error("state mismatch"),
57
+ });
58
+ return;
59
+ }
60
+ if (errCode || !token) {
61
+ finish(400, "授权被取消", "可以关闭此页面回到 CLI。", {
62
+ ok: false,
63
+ err: new Error(errCode || "no token returned"),
64
+ });
65
+ return;
66
+ }
67
+ finish(200, "✓ 授权成功", "可以关闭此页面,CLI 已自动接收 key。", {
68
+ ok: true,
69
+ token,
70
+ });
71
+ });
72
+ server.listen(0, "127.0.0.1", () => {
73
+ const addr = server.address();
74
+ const port = typeof addr === "object" && addr ? addr.port : 0;
75
+ const callback = `http://127.0.0.1:${port}/cb`;
76
+ const authUrl = new URL("/cli-auth", serverUrl);
77
+ authUrl.searchParams.set("callback", callback);
78
+ authUrl.searchParams.set("state", state);
79
+ authUrl.searchParams.set("hostname", hostname);
80
+ info(`Listening on ${callback}`);
81
+ info("正在打开浏览器完成授权...");
82
+ const opened = openBrowser(authUrl.toString());
83
+ if (!opened) {
84
+ info("(打开浏览器失败,请手动访问以下 URL):");
85
+ info(authUrl.toString());
86
+ }
87
+ else {
88
+ info(`(如果浏览器没自动弹出,请手动访问: ${authUrl.toString()})`);
89
+ }
90
+ });
91
+ timer = setTimeout(() => {
92
+ server.close();
93
+ reject(new Error("授权超时(5 分钟)。请重新跑 reelforge login"));
94
+ }, 5 * 60 * 1000);
95
+ });
96
+ }
97
+ export function registerAuth(program) {
98
+ program
99
+ .command("login [api_key]")
100
+ .description("登录: 不带参数自动开浏览器 OAuth; 带 api_key 直接保存")
101
+ .helpOption("-h, --help", "show help")
102
+ .option("--server <url>", "also persist a custom server URL")
103
+ .addHelpText("after", [
104
+ "",
105
+ "Examples:",
106
+ " reelforge login # browser OAuth (recommended)",
107
+ " reelforge login JK1234567890ABCDEF # manual paste (SSH / headless)",
108
+ " reelforge login --server http://nas:8501 # OAuth against a self-hosted server",
109
+ "",
110
+ "The token is verified against /api/v1/me before being saved.",
111
+ ].join("\n"))
112
+ .action(async (apiKey, opts) => {
113
+ if (opts.server)
114
+ setServer(opts.server);
115
+ let token;
116
+ if (apiKey) {
117
+ token = apiKey;
118
+ }
119
+ else {
120
+ info(`Starting browser OAuth against ${getServer()}`);
121
+ token = await browserOAuthFlow(getServer());
122
+ }
123
+ setApiKey(token);
124
+ const me = await get("/api/v1/me");
125
+ const existing = await loadConfig();
126
+ const saved = await saveConfig({
127
+ ...existing,
128
+ api_key: token,
129
+ ...(opts.server ? { server: opts.server } : {}),
130
+ });
131
+ success(`Saved → ${saved}`);
132
+ info(`Account: ${me.account.account_number}${me.account.label ? ` · ${me.account.label}` : ""}`);
133
+ info(`Balance: ${formatUsd(me.account.balance / 1_000_000)}`);
134
+ info(`Server: ${getServer()}`);
135
+ });
136
+ program
137
+ .command("logout")
138
+ .description("登出: 删除本地保存的 API key 和 server")
139
+ .helpOption("-h, --help", "show help")
140
+ .action(async () => {
141
+ const path = getConfigPath();
142
+ const deleted = await deleteConfig();
143
+ if (deleted) {
144
+ success(`Deleted ${path}`);
145
+ }
146
+ else {
147
+ info(`No config to delete (${path} not found)`);
148
+ }
149
+ });
150
+ program
151
+ .command("whoami")
152
+ .description("查看当前账户: 余额 / API key 列表 / 调用统计")
153
+ .helpOption("-h, --help", "show help")
154
+ .action(async () => {
155
+ const me = await get("/api/v1/me");
156
+ print({
157
+ account: me.account,
158
+ });
159
+ if (me.api_keys.length) {
160
+ info(`API keys (${me.api_keys.length}):`);
161
+ table(me.api_keys.map((k) => ({
162
+ id: k.id,
163
+ api_key: k.api_key,
164
+ label: k.label ?? "",
165
+ status: k.status,
166
+ last_used_at: k.last_used_at ?? "—",
167
+ })));
168
+ }
169
+ });
170
+ }
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { del, get, uploadMultipart } from "../client.js";
4
+ import { print, table, success, bytes } from "../utils/output.js";
5
+ export function registerBgm(program) {
6
+ const bgm = program
7
+ .command("bgm")
8
+ .description("BGM 库管理 (内置 + 用户上传)")
9
+ .helpOption("-h, --help", "show help");
10
+ bgm
11
+ .command("list")
12
+ .description("List all BGM files (default + user-uploaded)")
13
+ .helpOption("-h, --help", "show help")
14
+ .action(async () => {
15
+ const r = await get("/api/v1/bgm");
16
+ table(r.bgm.map((b) => ({
17
+ name: b.name,
18
+ size: bytes(b.size_bytes),
19
+ source: b.source,
20
+ path: b.relative_path,
21
+ })));
22
+ });
23
+ bgm
24
+ .command("upload <file>")
25
+ .description("Upload a new BGM file (saved under data/bgm/)")
26
+ .helpOption("-h, --help", "show help")
27
+ .action(async (file) => {
28
+ const abs = path.resolve(file);
29
+ const buf = fs.readFileSync(abs);
30
+ const name = path.basename(abs);
31
+ const fileObj = new File([new Uint8Array(buf)], name, { type: "audio/mpeg" });
32
+ const r = await uploadMultipart("/api/v1/bgm", { file: fileObj });
33
+ success(`Uploaded ${name}`);
34
+ print(r);
35
+ });
36
+ bgm
37
+ .command("delete <name>")
38
+ .alias("rm")
39
+ .description("Delete a user-uploaded BGM (default repo BGM is protected)")
40
+ .helpOption("-h, --help", "show help")
41
+ .action(async (name) => {
42
+ const r = await del(`/api/v1/bgm/${encodeURIComponent(name)}`);
43
+ print(r);
44
+ });
45
+ }
@@ -0,0 +1,293 @@
1
+ import fs from "node:fs/promises";
2
+ import fsSync from "node:fs";
3
+ import path from "node:path";
4
+ import { post, getServer } from "../client.js";
5
+ import { waitForTask } from "../utils/task-waiter.js";
6
+ import { downloadTo } from "../utils/download.js";
7
+ import { formatUsd, humanDuration, info, print, success, warn } from "../utils/output.js";
8
+ const EXT_TO_MIME = {
9
+ ".mp3": "audio/mpeg",
10
+ ".wav": "audio/wav",
11
+ ".m4a": "audio/mp4",
12
+ ".ogg": "audio/ogg",
13
+ ".png": "image/png",
14
+ ".jpg": "image/jpeg",
15
+ ".jpeg": "image/jpeg",
16
+ ".webp": "image/webp",
17
+ ".mp4": "video/mp4",
18
+ ".mov": "video/quicktime",
19
+ ".webm": "video/webm",
20
+ };
21
+ function fmtSize(bytes) {
22
+ if (!bytes)
23
+ return "0";
24
+ if (bytes < 1024)
25
+ return `${bytes} B`;
26
+ if (bytes < 1024 * 1024)
27
+ return `${(bytes / 1024).toFixed(1)} KB`;
28
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
29
+ }
30
+ function listSpecPaths(spec) {
31
+ const paths = new Set();
32
+ if (spec.audio.narration)
33
+ paths.add(spec.audio.narration);
34
+ if (spec.audio.bgm)
35
+ paths.add(spec.audio.bgm);
36
+ for (const s of spec.scenes) {
37
+ if (s.image)
38
+ paths.add(s.image);
39
+ if (s.video)
40
+ paths.add(s.video);
41
+ }
42
+ if (spec.cover)
43
+ paths.add(spec.cover.image);
44
+ if (spec.brand?.logo)
45
+ paths.add(spec.brand.logo);
46
+ return [...paths];
47
+ }
48
+ async function readAsDataUri(specPath, baseDir) {
49
+ const abs = path.isAbsolute(specPath) ? specPath : path.resolve(baseDir, specPath);
50
+ if (!fsSync.existsSync(abs)) {
51
+ throw new Error(`compose: spec references "${specPath}" but file not found at ${abs}`);
52
+ }
53
+ const ext = path.extname(abs).toLowerCase();
54
+ const mime = EXT_TO_MIME[ext];
55
+ if (!mime) {
56
+ throw new Error(`compose: unsupported file extension "${ext}" (path: ${abs}). Supported: ` +
57
+ Object.keys(EXT_TO_MIME).join(" / "));
58
+ }
59
+ const buf = await fs.readFile(abs);
60
+ return { dataUri: `data:${mime};base64,${buf.toString("base64")}`, bytes: buf.byteLength };
61
+ }
62
+ const EXAMPLE_SPEC_HINT = `{
63
+ "experimental": "compose.v2",
64
+ "audio": { "narration": "./narration.mp3" },
65
+ "scenes": [
66
+ { "start_sec": 0, "end_sec": 5, "image": "./s1.png", "motion": "zoom-in" },
67
+ { "start_sec": 5, "end_sec": 11, "video": "./clip.mp4", "muted": true }
68
+ ],
69
+ "subtitles": [
70
+ { "start_sec": 0, "end_sec": 5, "text": "为什么咖啡因让人精神" },
71
+ { "start_sec": 5, "end_sec": 11, "text": "原来它跟睡眠物质是同一把钥匙" }
72
+ ],
73
+ "title": "咖啡因的真相"
74
+ }`;
75
+ const EXAMPLE_SPEC_V3_HINT = `{
76
+ "experimental": "compose.v3",
77
+ "audio": { "tts_voice": "熊小二" },
78
+ "scenes": [
79
+ {
80
+ "video": "./s1.mp4", "muted": true,
81
+ "narration": ["今天热得不行", "我跑厕所睡觉"],
82
+ "intro_title": "热到躲厕所"
83
+ },
84
+ {
85
+ "image": "./splash.jpg",
86
+ "narration": ["主人你找拖鞋去吧"]
87
+ }
88
+ ],
89
+ "subtitle_style": "stroke",
90
+ "title": "贼喵被抓现行"
91
+ }`;
92
+ export function registerCompose(program) {
93
+ program
94
+ .command("compose <spec>")
95
+ .description("⚠️ EXPERIMENTAL · 从 JSON spec 渲染 MP4 — 调用方精确指定每个 scene 的图/视频 / 时间 / 字幕")
96
+ .helpOption("-h, --help", "show help")
97
+ .option("-o, --output <file>", "output MP4 path. Default: <spec-basename>.mp4 in cwd")
98
+ .option("--preview-only", "build the composition and return preview URLs; skip MP4 render (faster + cheaper iteration)")
99
+ .option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
100
+ .option("--timeout-ms <ms>", "max wait time before aborting", (v) => parseInt(v, 10))
101
+ .option("--watch", "监听 spec.json,每次保存让 server 重建 HF preview(自动 --preview-only,不渲 MP4),打印一个 preview URL — 任何设备浏览器打开即可看效果。Ctrl-C 退出")
102
+ .option("--watch-debounce-ms <ms>", "watch 模式下文件变化的 debounce 时长", (v) => parseInt(v, 10), 400)
103
+ .addHelpText("after", [
104
+ "",
105
+ "⚠️ Schema versioning",
106
+ " Two schemas coexist; the server picks one via spec.experimental.",
107
+ " compose.v2 — you write every start_sec/end_sec yourself.",
108
+ " compose.v3 — audio-first: server runs TTS+ASR and derives times.",
109
+ " Both are EXPERIMENTAL; pin your CLI (e.g. reelforge@1.16.x) if you",
110
+ " build an agent against them.",
111
+ "",
112
+ "compose.v2 minimal example (manual timing):",
113
+ EXAMPLE_SPEC_HINT,
114
+ "",
115
+ "compose.v3 minimal example (audio-first, recommended for short videos):",
116
+ EXAMPLE_SPEC_V3_HINT,
117
+ "",
118
+ "Path resolution:",
119
+ " File paths in the spec are resolved RELATIVE to the spec.json's",
120
+ " directory. Absolute paths are also accepted. The CLI reads each",
121
+ " file and base64-encodes it into the request body — no separate",
122
+ " upload step needed.",
123
+ "",
124
+ "Supported file types:",
125
+ " audio: .mp3 .wav .m4a .ogg",
126
+ " image: .png .jpg .jpeg .webp",
127
+ " video: .mp4 .mov .webm",
128
+ "",
129
+ "Scene visuals:",
130
+ " Each scene has exactly one of `image` or `video`. Video scenes",
131
+ " play their source clip; if the clip is shorter than the scene",
132
+ " window, the last frame freezes (Ken-Burns continues, so the held",
133
+ " shot doesn't look frozen). Use `muted: true` to silence the clip's",
134
+ " own audio — default lets it mix with the narration.",
135
+ "",
136
+ "Size limits:",
137
+ " total decoded payload ≤ 55 MB (server cap). Stays in the safe-upload",
138
+ " envelope. Typical 30s spec (5 scenes + narration) is ~5-10 MB.",
139
+ "",
140
+ "Examples:",
141
+ " # Render directly to a file",
142
+ " rf compose ./my-video-spec.json -o ./output.mp4",
143
+ "",
144
+ " # Preview only — prints preview URL, no MP4 yet (~2 min, ~$0.05)",
145
+ " rf compose ./my-video-spec.json --preview-only",
146
+ "",
147
+ " # Watch mode — 编辑 spec → 保存 → server 重建 preview → CLI 打印 URL,手机/浏览器点开看效果",
148
+ " rf compose ./my-video-spec.json --watch",
149
+ "",
150
+ " # JSON output (parse from agent)",
151
+ " rf compose ./my-video-spec.json --json",
152
+ "",
153
+ "Full schema reference + worked examples (cognitive science / mixed",
154
+ "image+video / dual-language financial / minimal) live at:",
155
+ " docs/compose-spec.md in the ReelForge repo",
156
+ "",
157
+ "Use-case workflows (compose is the generic primitive; use-case SOPs",
158
+ "live under their own namespace):",
159
+ " rf assets workflow 用户素材 → 视频 工作流",
160
+ "",
161
+ "(`rf` works wherever you see `reelforge`)",
162
+ ].join("\n"))
163
+ .action(async (specPathArg, opts) => {
164
+ const specPath = path.resolve(specPathArg);
165
+ if (!fsSync.existsSync(specPath)) {
166
+ throw new Error(`spec not found: ${specPath}`);
167
+ }
168
+ await renderComposeOnce(specPath, opts);
169
+ if (opts.watch) {
170
+ await runWatchLoop(specPath, opts);
171
+ }
172
+ });
173
+ }
174
+ async function renderComposeOnce(specPath, opts) {
175
+ let spec;
176
+ try {
177
+ const raw = await fs.readFile(specPath, "utf-8");
178
+ spec = JSON.parse(raw);
179
+ }
180
+ catch (e) {
181
+ throw new Error(`failed to read / parse spec ${specPath}: ${e instanceof Error ? e.message : String(e)}`);
182
+ }
183
+ if (spec.experimental !== "compose.v2" && spec.experimental !== "compose.v3") {
184
+ throw new Error(`spec.experimental must be "compose.v2" or "compose.v3" (got: ${JSON.stringify(spec.experimental)}). ` +
185
+ `See docs/compose-spec.md or docs/assets-to-video-workflow.md.`);
186
+ }
187
+ const baseDir = path.dirname(specPath);
188
+ info(`Reading spec from ${specPath}...`);
189
+ const specPaths = listSpecPaths(spec);
190
+ const files = {};
191
+ let totalBytes = 0;
192
+ for (const p of specPaths) {
193
+ const r = await readAsDataUri(p, baseDir);
194
+ files[p] = r.dataUri;
195
+ totalBytes += r.bytes;
196
+ }
197
+ info(`Bundling ${specPaths.length} files (${fmtSize(totalBytes)} total)...`);
198
+ const body = { spec, files };
199
+ if (opts.previewOnly)
200
+ body.preview_only = true;
201
+ const submitted = await post("/api/v1/compose", body);
202
+ info(`Submitted task: ${submitted.task_id}`);
203
+ const t = await waitForTask(submitted.task_id, {
204
+ pollMs: opts.pollMs,
205
+ timeoutMs: opts.timeoutMs,
206
+ });
207
+ if (t.status !== "completed") {
208
+ throw new Error(t.error || `Task ended with status ${t.status}`);
209
+ }
210
+ const result = t.result;
211
+ const serverBase = getServer().replace(/\/+$/, "");
212
+ const previewPageAbs = result?.preview_urls?.page ? serverBase + result.preview_urls.page : undefined;
213
+ const previewPlayerAbs = result?.preview_urls?.player ? serverBase + result.preview_urls.player : undefined;
214
+ if (previewPageAbs)
215
+ info(`Preview page: ${previewPageAbs}`);
216
+ if (previewPlayerAbs)
217
+ info(`Player only: ${previewPlayerAbs}`);
218
+ if (result?.render_pending) {
219
+ info(`MP4 render skipped (--preview-only). Run \`rf render ${t.id}\` to finalize.`);
220
+ }
221
+ else if (result?.video_url) {
222
+ const stdoutIsPipe = !process.stdout.isTTY;
223
+ const skipDownload = stdoutIsPipe && !opts.output;
224
+ let savedPath;
225
+ if (opts.output) {
226
+ savedPath = opts.output;
227
+ }
228
+ else if (!skipDownload) {
229
+ const stem = path.parse(specPath).name;
230
+ savedPath = path.resolve(`${stem}-${t.id.slice(0, 8)}.mp4`);
231
+ }
232
+ if (savedPath) {
233
+ await downloadTo(result.video_url, savedPath);
234
+ success(`Saved → ${savedPath}`);
235
+ }
236
+ }
237
+ if (result?.generation_ms != null) {
238
+ const gen = humanDuration(result.generation_ms);
239
+ const playback = result.duration != null ? `, video ${result.duration.toFixed(1)}s` : "";
240
+ info(`generated in ${gen}${playback}`);
241
+ }
242
+ if (result?.price_usd != null)
243
+ info(`charge: ${formatUsd(result.price_usd)}`);
244
+ print({ task_id: t.id, status: t.status, ...result });
245
+ }
246
+ async function runWatchLoop(specPath, opts) {
247
+ const debounceMs = opts.watchDebounceMs ?? 400;
248
+ const watchOpts = { ...opts, previewOnly: true };
249
+ if (opts.output) {
250
+ warn(` --output is ignored in --watch mode (preview-only — no MP4 is produced)`);
251
+ }
252
+ info(`👀 watching ${specPath} — preview iteration mode`);
253
+ info(` server: ${getServer()}`);
254
+ info(` each save → server rebuilds preview (NO MP4 render) → prints URL`);
255
+ info(` open the URL on any device (phone / laptop) to scrub HF player`);
256
+ info(` Ctrl-C to stop`);
257
+ let running = false;
258
+ let queued = false;
259
+ let debounceTimer = null;
260
+ const rerender = async () => {
261
+ if (running) {
262
+ queued = true;
263
+ return;
264
+ }
265
+ running = true;
266
+ try {
267
+ const t0 = Date.now();
268
+ info(`\n— rebuild —`);
269
+ await renderComposeOnce(specPath, watchOpts);
270
+ info(`✓ rebuilt in ${humanDuration(Date.now() - t0)}; refresh / open the URL above`);
271
+ }
272
+ catch (e) {
273
+ warn(`✗ rebuild failed: ${e instanceof Error ? e.message : String(e)}`);
274
+ }
275
+ finally {
276
+ running = false;
277
+ if (queued) {
278
+ queued = false;
279
+ setTimeout(rerender, 50);
280
+ }
281
+ }
282
+ };
283
+ fsSync.watch(specPath, () => {
284
+ if (debounceTimer)
285
+ clearTimeout(debounceTimer);
286
+ debounceTimer = setTimeout(rerender, debounceMs);
287
+ });
288
+ process.on("SIGINT", () => {
289
+ info("\n👋 watch stopped");
290
+ process.exit(0);
291
+ });
292
+ await new Promise(() => { });
293
+ }