seereelcli 0.1.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/bin/reelyai.js ADDED
@@ -0,0 +1,1334 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const DEFAULT_BASE_URL =
9
+ process.env.SEEREEL_AGENT_BASE_URL ||
10
+ process.env.REELYAI_AGENT_BASE_URL ||
11
+ process.env.CINEMA_AGENT_BASE_URL ||
12
+ "https://seereel.studio";
13
+
14
+ const CONFIG_DIR = process.env.SEEREEL_CLI_HOME || process.env.REELYAI_CLI_HOME || path.join(os.homedir(), ".seereel");
15
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
16
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
+ const REELYAI_CLI_SKILL = path.join(PACKAGE_ROOT, "skills", "reelyai-cli", "SKILL.md");
18
+ const DEFAULT_POLL_INTERVAL_MS = 3000;
19
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
20
+
21
+ const HELP = `
22
+ SeeReel CLI
23
+
24
+ Create visible SeeReel canvas workflows from natural language, then let a human
25
+ review or take over in the web app.
26
+
27
+ Usage:
28
+ seereelcli workflow "a 60s cyberpunk short about ..." [options]
29
+ seereelcli node <get|update-prompt|generate|poll|tailframe|review|repair> --id <nodeId> [options]
30
+ seereelcli publish-storyboards --session <sessionId|latest>
31
+ seereelcli final-review --session <sessionId|latest> [--repair]
32
+ seereelcli render --session <sessionId> [options]
33
+ seereelcli stitch --session <sessionId> [options]
34
+ seereelcli download --session <sessionId|latest> --output ./final.mp4
35
+ seereelcli handoff --session <sessionId|latest> [--open]
36
+ seereelcli skill <install|print|path> [options]
37
+ seereelcli status [options]
38
+ seereelcli configure [options]
39
+ seereelcli open --session <sessionId> [options]
40
+
41
+ Aliases:
42
+ workflow: new, create, plan
43
+
44
+ Global options:
45
+ --base-url <url> SeeReel server. Default: env or https://seereel.studio
46
+ --access-token <token> Shared deployment token, also read from SEEREEL_ACCESS_TOKEN
47
+ --agent-plan-token <token> Browser-scoped Agent Plan key for model generation
48
+ --json Print machine-readable JSON
49
+ --jsonl Print newline-delimited progress events; final result is a complete event
50
+ --progress Print human-readable progress events to stderr
51
+
52
+ workflow options:
53
+ --title <title> Session title
54
+ --duration <sec> Target duration. Default: 60
55
+ --shots <count> Shot count. Default: ceil(duration / 15)
56
+ --style <text> Visual style
57
+ --language <zh|en> Session UI/script language. Default: zh
58
+ --no-script Skip /script/generate
59
+ --no-storyboard Skip /storyboard
60
+ --render Continue into shot generation after storyboard
61
+ --stitch Stitch after render, or after storyboard if shots are ready
62
+ --stitch-partial Stitch ready shots even when some shots failed or were skipped
63
+ --open Open the created session in the browser
64
+
65
+ render options:
66
+ --session <sessionId|latest> Session to render. Default: latest
67
+ --mode <missing|all> Workflow plan mode. Default: missing
68
+ --max-parallel-shots <n> Max parallel independent shots. Default: 1
69
+ --stitch Stitch after shots are ready
70
+ --stitch-partial Stitch ready shots even when some shots failed or were skipped
71
+ --repair-policy <none|safe-retry>
72
+ Retry policy failures with a safer prompt. Default: none
73
+ --max-attempts <n> Max attempts per shot when --repair-policy safe-retry is set. Default: 1
74
+
75
+ status options:
76
+ --session <sessionId|latest> Show one session. Use with --deep for shot/render details
77
+ --deep Include shots, renders, errors, stitch state, and download URL
78
+
79
+ download options:
80
+ --session <sessionId|latest> Session to download. Default: latest
81
+ --output <path> Local output file. Default: ./seereel-<sessionId>.mp4
82
+
83
+ handoff options:
84
+ --session <sessionId|latest> Session to transfer to the current browser user. Default: latest
85
+ --open Open the one-time handoff link in the browser
86
+
87
+ node options:
88
+ --id <nodeId> Shot, asset, or session id. Also accepts --shot / --asset
89
+ --prompt <text> New prompt for update-prompt
90
+ --title <text> Optional shot title for update-prompt
91
+ --duration <sec> Optional shot duration for update-prompt
92
+ --wait Poll after starting shot generation
93
+ --publish-tos Publish a shot tailframe to TOS for Seedance references
94
+ --canvas-node Save tailframe as a session-scoped visible canvas node
95
+ --frame-count <n> VLM review frame count
96
+ --model <model> Asset image model for asset generate
97
+
98
+ skill options:
99
+ --agent <all|codex,claude,cursor,agents>
100
+ Install bundled reelyai-cli skill. Default: all
101
+
102
+ Examples:
103
+ npm install -g seereelcli
104
+ seereelcli skill install --agent all
105
+ seereelcli configure --base-url https://seereel.studio --access-token "$SEEREEL_ACCESS_TOKEN"
106
+ seereelcli workflow "一个失眠导演在午夜便利店遇见未来的自己" --duration 60 --style "neo-noir, rain"
107
+ seereelcli node update-prompt --id shot_xxx --prompt "new Seedance prompt"
108
+ seereelcli node tailframe --id shot_xxx --publish-tos --canvas-node
109
+ seereelcli node review --id shot_xxx --frame-count 8
110
+ seereelcli render --session latest --stitch --progress
111
+ seereelcli status --session latest --deep --json
112
+ seereelcli download --session latest --output ./final.mp4
113
+ seereelcli handoff --session latest --open
114
+ `;
115
+
116
+ class CliError extends Error {
117
+ constructor(message, code = 1) {
118
+ super(message);
119
+ this.name = "CliError";
120
+ this.code = code;
121
+ }
122
+ }
123
+
124
+ function createReporter(options) {
125
+ const jsonl = Boolean(options.jsonl);
126
+ const progress = Boolean(options.progress);
127
+ return {
128
+ event(event, payload = {}) {
129
+ if (!jsonl && !progress) return;
130
+ const line = { event, at: new Date().toISOString(), ...payload };
131
+ if (jsonl) console.log(JSON.stringify(line));
132
+ if (progress) console.error(formatProgressLine(line));
133
+ },
134
+ complete(result) {
135
+ if (jsonl) this.event("complete", { result });
136
+ }
137
+ };
138
+ }
139
+
140
+ function formatProgressLine(event) {
141
+ const parts = [event.event];
142
+ if (event.sessionId) parts.push(`session=${event.sessionId}`);
143
+ if (event.shotId) parts.push(`shot=${event.shotId}`);
144
+ if (event.index !== undefined) parts.push(`index=${event.index}`);
145
+ if (event.status) parts.push(`status=${event.status}`);
146
+ if (event.taskId) parts.push(`task=${event.taskId}`);
147
+ if (event.attempt !== undefined) parts.push(`attempt=${event.attempt}`);
148
+ if (event.reason) parts.push(`reason=${event.reason}`);
149
+ return `[reelyai] ${parts.join(" ")}`;
150
+ }
151
+
152
+ function parseArgs(argv) {
153
+ const args = [...argv];
154
+ const command = args[0]?.startsWith("-") ? "help" : args.shift() || "help";
155
+ const positionals = [];
156
+ const options = {};
157
+
158
+ for (let i = 0; i < args.length; i += 1) {
159
+ const arg = args[i];
160
+ if (arg === "--") {
161
+ positionals.push(...args.slice(i + 1));
162
+ break;
163
+ }
164
+ if (!arg.startsWith("--")) {
165
+ positionals.push(arg);
166
+ continue;
167
+ }
168
+ if (arg.startsWith("--no-")) {
169
+ options[toCamel(arg.slice(5))] = false;
170
+ continue;
171
+ }
172
+ const eq = arg.indexOf("=");
173
+ if (eq >= 0) {
174
+ options[toCamel(arg.slice(2, eq))] = arg.slice(eq + 1);
175
+ continue;
176
+ }
177
+ const key = toCamel(arg.slice(2));
178
+ const next = args[i + 1];
179
+ if (next && !next.startsWith("--")) {
180
+ options[key] = next;
181
+ i += 1;
182
+ } else {
183
+ options[key] = true;
184
+ }
185
+ }
186
+ return { command, positionals, options };
187
+ }
188
+
189
+ function toCamel(value) {
190
+ return value.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
191
+ }
192
+
193
+ async function readConfig() {
194
+ try {
195
+ return JSON.parse(await readFile(CONFIG_FILE, "utf8"));
196
+ } catch (error) {
197
+ if (error?.code === "ENOENT") return {};
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ async function writeConfig(config) {
203
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
204
+ await writeFile(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
205
+ }
206
+
207
+ function resolveRuntime(config, options) {
208
+ const baseUrl = normalizeBaseUrl(
209
+ String(options.baseUrl || process.env.SEEREEL_AGENT_BASE_URL || process.env.REELYAI_AGENT_BASE_URL || process.env.CINEMA_AGENT_BASE_URL || config.baseUrl || DEFAULT_BASE_URL)
210
+ );
211
+ return {
212
+ baseUrl,
213
+ accessToken:
214
+ stringOption(options.accessToken) ||
215
+ process.env.SEEREEL_ACCESS_TOKEN ||
216
+ process.env.REELYAI_ACCESS_TOKEN ||
217
+ process.env.SEEREEL_CLI_ACCESS_TOKEN ||
218
+ process.env.REELYAI_CLI_ACCESS_TOKEN ||
219
+ config.accessToken ||
220
+ "",
221
+ agentPlanToken:
222
+ stringOption(options.agentPlanToken) ||
223
+ process.env.SEEREEL_AGENT_PLAN_TOKEN ||
224
+ process.env.REELYAI_AGENT_PLAN_TOKEN ||
225
+ process.env.ARK_AGENT_PLAN_KEY ||
226
+ config.agentPlanToken ||
227
+ "",
228
+ cookies: config.cookies || {}
229
+ };
230
+ }
231
+
232
+ function stringOption(value) {
233
+ return typeof value === "string" && value.trim() ? value.trim() : "";
234
+ }
235
+
236
+ function normalizeBaseUrl(value) {
237
+ const url = value.trim();
238
+ if (!/^https?:\/\//i.test(url)) throw new CliError(`Invalid --base-url: ${value}`);
239
+ return url.replace(/\/+$/, "");
240
+ }
241
+
242
+ function originFor(baseUrl) {
243
+ return new URL(baseUrl).origin;
244
+ }
245
+
246
+ function cookieHeader(runtime) {
247
+ const jar = runtime.cookies?.[originFor(runtime.baseUrl)] || {};
248
+ return Object.entries(jar)
249
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
250
+ .join("; ");
251
+ }
252
+
253
+ function rememberCookies(runtime, headers) {
254
+ const setCookies =
255
+ typeof headers.getSetCookie === "function"
256
+ ? headers.getSetCookie()
257
+ : splitSetCookie(headers.get("set-cookie") || "");
258
+ if (!setCookies.length) return false;
259
+
260
+ const origin = originFor(runtime.baseUrl);
261
+ runtime.cookies ||= {};
262
+ runtime.cookies[origin] ||= {};
263
+ for (const line of setCookies) {
264
+ const [pair] = line.split(";");
265
+ const index = pair.indexOf("=");
266
+ if (index <= 0) continue;
267
+ const name = pair.slice(0, index).trim();
268
+ const value = decodeURIComponent(pair.slice(index + 1).trim());
269
+ if (value) runtime.cookies[origin][name] = value;
270
+ else delete runtime.cookies[origin][name];
271
+ }
272
+ return true;
273
+ }
274
+
275
+ function splitSetCookie(value) {
276
+ if (!value) return [];
277
+ return value.split(/,(?=[^;,]+=)/).map((item) => item.trim()).filter(Boolean);
278
+ }
279
+
280
+ async function api(runtime, route, init = {}) {
281
+ const headers = {
282
+ Accept: "application/json",
283
+ ...(init.body ? { "Content-Type": "application/json" } : {}),
284
+ ...(runtime.accessToken ? { "x-seereel-access": runtime.accessToken, "x-reelyai-access": runtime.accessToken } : {}),
285
+ ...(cookieHeader(runtime) ? { Cookie: cookieHeader(runtime) } : {}),
286
+ ...(init.headers || {})
287
+ };
288
+ const res = await fetch(`${runtime.baseUrl}${route}`, { ...init, headers });
289
+ const changedCookies = rememberCookies(runtime, res.headers);
290
+ if (changedCookies) {
291
+ const config = await readConfig();
292
+ config.cookies = runtime.cookies;
293
+ await writeConfig(config);
294
+ }
295
+
296
+ const text = await res.text();
297
+ let body = text;
298
+ try {
299
+ body = text ? JSON.parse(text) : undefined;
300
+ } catch {
301
+ // Keep plain-text body.
302
+ }
303
+ if (!res.ok) {
304
+ const message = body?.error || body?.message || text || `${res.status} ${res.statusText}`;
305
+ if (body?.code === "access_token_required") {
306
+ throw new CliError(`${message}. Pass --access-token or set SEEREEL_ACCESS_TOKEN.`);
307
+ }
308
+ throw new CliError(`${route} failed: ${message}`);
309
+ }
310
+ return body;
311
+ }
312
+
313
+ async function downloadToFile(runtime, route, outputPath) {
314
+ const headers = {
315
+ ...(runtime.accessToken ? { "x-seereel-access": runtime.accessToken, "x-reelyai-access": runtime.accessToken } : {}),
316
+ ...(cookieHeader(runtime) ? { Cookie: cookieHeader(runtime) } : {})
317
+ };
318
+ const res = await fetch(`${runtime.baseUrl}${route}`, { headers });
319
+ const changedCookies = rememberCookies(runtime, res.headers);
320
+ if (changedCookies) {
321
+ const config = await readConfig();
322
+ config.cookies = runtime.cookies;
323
+ await writeConfig(config);
324
+ }
325
+ if (!res.ok) {
326
+ const text = await res.text();
327
+ throw new CliError(`${route} download failed: ${text || `${res.status} ${res.statusText}`}`);
328
+ }
329
+ const bytes = Buffer.from(await res.arrayBuffer());
330
+ await mkdir(path.dirname(outputPath), { recursive: true });
331
+ await writeFile(outputPath, bytes);
332
+ return { bytes: bytes.length };
333
+ }
334
+
335
+ async function ensureAgentPlan(runtime, options = {}) {
336
+ const status = await api(runtime, "/api/credentials/agent-plan");
337
+ if (status?.configured) return status;
338
+ if (runtime.agentPlanToken) {
339
+ const configured = await api(runtime, "/api/credentials/agent-plan", {
340
+ method: "POST",
341
+ body: JSON.stringify({ apiKey: runtime.agentPlanToken })
342
+ });
343
+ options.reporter?.event("agent_plan_configured", { fingerprint: configured?.fingerprint });
344
+ return configured;
345
+ }
346
+ if (options.required) {
347
+ throw new CliError(
348
+ "Agent Plan token is not configured for this CLI/browser scope. Run `seereelcli configure --agent-plan-token \"<AGENT_PLAN_API_KEY>\"`, or set SEEREEL_AGENT_PLAN_TOKEN / ARK_AGENT_PLAN_KEY before render/review commands."
349
+ );
350
+ }
351
+ return status;
352
+ }
353
+
354
+ function sessionUrl(baseUrl, sessionId) {
355
+ return `${baseUrl}/#/s/${encodeURIComponent(sessionId)}`;
356
+ }
357
+
358
+ function downloadUrl(baseUrl, sessionId) {
359
+ return `${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}/download`;
360
+ }
361
+
362
+ async function createSessionHandoff(runtime, sessionId) {
363
+ return api(runtime, `/api/sessions/${encodeURIComponent(sessionId)}/handoff`, { method: "POST" });
364
+ }
365
+
366
+ function clampInt(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
367
+ const n = Number.parseInt(String(value ?? ""), 10);
368
+ if (!Number.isFinite(n)) return fallback;
369
+ return Math.min(max, Math.max(min, n));
370
+ }
371
+
372
+ function inferTitle(prompt, explicit) {
373
+ if (explicit) return explicit;
374
+ const compact = prompt.replace(/\s+/g, " ").trim();
375
+ return compact.slice(0, 28) || "SeeReel Workflow";
376
+ }
377
+
378
+ async function readPrompt(positionals) {
379
+ const prompt = positionals.join(" ").trim();
380
+ if (prompt) return prompt;
381
+ if (!process.stdin.isTTY) {
382
+ const chunks = [];
383
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
384
+ return Buffer.concat(chunks).toString("utf8").trim();
385
+ }
386
+ throw new CliError("Missing natural-language prompt. Example: seereelcli workflow \"a 60s short...\"");
387
+ }
388
+
389
+ function summarizeShots(shots = []) {
390
+ return shots
391
+ .slice()
392
+ .sort((a, b) => (a.index || 0) - (b.index || 0))
393
+ .map((shot) => ({
394
+ index: shot.index,
395
+ id: shot.id,
396
+ title: shot.title,
397
+ durationSec: shot.durationSec,
398
+ status: shot.status,
399
+ videoUrl: shot.videoUrl
400
+ }));
401
+ }
402
+
403
+ function summarizeAsset(asset) {
404
+ if (!asset) return undefined;
405
+ return {
406
+ id: asset.id,
407
+ name: asset.name,
408
+ type: asset.type,
409
+ mediaKind: asset.mediaKind,
410
+ mediaUrl: asset.mediaUrl,
411
+ imageUrl: asset.imageUrl,
412
+ ownerSessionId: asset.ownerSessionId,
413
+ ownerShotId: asset.ownerShotId,
414
+ imageReviewStatus: asset.imageReviewStatus
415
+ };
416
+ }
417
+
418
+ function summarizeShot(shot) {
419
+ if (!shot) return undefined;
420
+ return {
421
+ id: shot.id,
422
+ sessionId: shot.sessionId,
423
+ index: shot.index,
424
+ title: shot.title,
425
+ durationSec: shot.durationSec,
426
+ status: shot.status,
427
+ videoUrl: shot.videoUrl,
428
+ firstFrameAssetId: shot.firstFrameAssetId,
429
+ lastFrameAssetId: shot.lastFrameAssetId,
430
+ usePreviousShotClip: shot.usePreviousShotClip,
431
+ referenceVideoAssetId: shot.referenceVideoAssetId,
432
+ videoReviewStatus: shot.videoReviewStatus,
433
+ rawPrompt: shot.rawPrompt,
434
+ prompt: shot.prompt
435
+ };
436
+ }
437
+
438
+ function summarizeRender(render) {
439
+ if (!render) return undefined;
440
+ return {
441
+ id: render.id,
442
+ status: render.status,
443
+ seedancePhase: render.seedancePhase,
444
+ generationTaskId: render.generationTaskId,
445
+ taskAgeSec: ageSec(render.generationStartedAt),
446
+ durationSec: render.durationSec,
447
+ videoUrl: render.videoUrl,
448
+ remoteVideoUrl: render.remoteVideoUrl,
449
+ error: render.error,
450
+ model: render.model,
451
+ createdAt: render.createdAt,
452
+ generationStartedAt: render.generationStartedAt,
453
+ videoGeneratedAt: render.videoGeneratedAt
454
+ };
455
+ }
456
+
457
+ function summarizeDeepShot(shot) {
458
+ const selectedRender = Array.isArray(shot.renders) ? shot.renders[0] : undefined;
459
+ return {
460
+ ...summarizeShot(shot),
461
+ seedancePhase: shot.seedancePhase,
462
+ generationTaskId: shot.generationTaskId || selectedRender?.generationTaskId,
463
+ taskAgeSec: ageSec(shot.generationStartedAt || selectedRender?.generationStartedAt),
464
+ error: shot.error || selectedRender?.error,
465
+ selectedRenderId: selectedRender?.id,
466
+ renderCount: Array.isArray(shot.renders) ? shot.renders.length : 0,
467
+ renders: (shot.renders || []).map(summarizeRender)
468
+ };
469
+ }
470
+
471
+ function summarizeDeepSession(runtime, session, state) {
472
+ const shots = (state.shots || [])
473
+ .filter((shot) => shot.sessionId === session.id)
474
+ .sort((a, b) => (a.index || 0) - (b.index || 0));
475
+ const readyShots = shots.filter((shot) => Boolean(shot.videoUrl));
476
+ const failedShots = shots.filter((shot) => shot.status === "error" || shot.status === "cancelled" || !shot.videoUrl);
477
+ return {
478
+ id: session.id,
479
+ title: session.title,
480
+ webUrl: sessionUrl(runtime.baseUrl, session.id),
481
+ finalVideoUrl: session.finalVideoUrl,
482
+ downloadUrl: session.finalVideoUrl ? downloadUrl(runtime.baseUrl, session.id) : undefined,
483
+ stitchStatus: session.stitchStatus,
484
+ stitchProgress: session.stitchProgress,
485
+ stitchError: session.stitchError,
486
+ stitchShotIds: session.stitchShotIds,
487
+ finalVideoReviewStatus: session.finalVideoReviewStatus,
488
+ finalVideoReview: session.finalVideoReview,
489
+ readyShotCount: readyShots.length,
490
+ failedShotCount: failedShots.length,
491
+ skippedShots: summarizeShots(failedShots),
492
+ stitchJobs: session.stitchJobs || [],
493
+ shots: shots.map(summarizeDeepShot)
494
+ };
495
+ }
496
+
497
+ function ageSec(value) {
498
+ if (!value) return undefined;
499
+ const time = Date.parse(value);
500
+ if (!Number.isFinite(time)) return undefined;
501
+ return Math.max(0, Math.round((Date.now() - time) / 1000));
502
+ }
503
+
504
+ async function commandWorkflow(runtime, positionals, options) {
505
+ await api(runtime, "/api/healthz");
506
+ const agentPlan = await ensureAgentPlan(runtime, { required: Boolean(options.render), reporter: options.reporter });
507
+ const prompt = await readPrompt(positionals);
508
+ const duration = clampInt(options.duration, 60, { min: 1, max: 3600 });
509
+ const shotCount = clampInt(options.shots, Math.ceil(duration / 15), { min: 1, max: 120 });
510
+ const title = inferTitle(prompt, stringOption(options.title));
511
+ const style = stringOption(options.style) || "cinematic short drama, coherent multi-shot continuity";
512
+ const language = options.language === "en" ? "en" : "zh";
513
+
514
+ const session = await api(runtime, "/api/sessions", {
515
+ method: "POST",
516
+ body: JSON.stringify({
517
+ title,
518
+ logline: prompt,
519
+ style,
520
+ language,
521
+ targetDurationSec: duration,
522
+ shotCount
523
+ })
524
+ });
525
+ options.reporter?.event("session_created", { sessionId: session.id, title });
526
+
527
+ let currentSession = session;
528
+ let storyboard;
529
+ if (options.script !== false) {
530
+ options.reporter?.event("script_generating", { sessionId: session.id });
531
+ currentSession = await api(runtime, `/api/sessions/${session.id}/script/generate`, { method: "POST" });
532
+ options.reporter?.event("script_ready", { sessionId: session.id });
533
+ }
534
+ if (options.storyboard !== false) {
535
+ options.reporter?.event("storyboard_generating", { sessionId: session.id });
536
+ storyboard = await api(runtime, `/api/sessions/${session.id}/storyboard`, { method: "POST" });
537
+ currentSession = storyboard.session || currentSession;
538
+ options.reporter?.event("storyboard_ready", { sessionId: session.id, shotCount: (storyboard?.shots || currentSession.shots || []).length });
539
+ }
540
+
541
+ let renderResult;
542
+ if (options.render) {
543
+ renderResult = await renderSession(runtime, session.id, {
544
+ mode: "missing",
545
+ maxParallelShots: clampInt(options.maxParallelShots, 1, { min: 1, max: 8 }),
546
+ stitch: Boolean(options.stitch),
547
+ stitchPartial: Boolean(options.stitchPartial),
548
+ repairPolicy: normalizeRepairPolicy(options.repairPolicy),
549
+ maxAttempts: clampInt(options.maxAttempts, 1, { min: 1, max: 5 }),
550
+ timeoutMs: clampInt(options.timeoutMs, DEFAULT_TIMEOUT_MS, { min: 10_000 }),
551
+ pollIntervalMs: clampInt(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS, { min: 1000 }),
552
+ reporter: options.reporter
553
+ });
554
+ } else if (options.stitch) {
555
+ renderResult = { stitched: await stitchSession(runtime, session.id, options) };
556
+ }
557
+
558
+ const handoff = await createSessionHandoff(runtime, session.id);
559
+ const result = {
560
+ baseUrl: runtime.baseUrl,
561
+ sessionId: session.id,
562
+ title: currentSession.title || title,
563
+ webUrl: sessionUrl(runtime.baseUrl, session.id),
564
+ webUrlVisibleInBrowser: false,
565
+ handoffUrl: handoff.handoffUrl,
566
+ handoffExpiresAt: handoff.handoffExpiresAt,
567
+ agentPlan,
568
+ story: currentSession.story,
569
+ shots: summarizeShots(storyboard?.shots || currentSession.shots || session.shots || []),
570
+ render: renderResult
571
+ };
572
+
573
+ if (options.open) openUrl(result.handoffUrl || result.webUrl);
574
+ return result;
575
+ }
576
+
577
+ async function commandRender(runtime, options) {
578
+ await api(runtime, "/api/healthz");
579
+ await ensureAgentPlan(runtime, { required: true, reporter: options.reporter });
580
+ const sessionId = await resolveSessionId(runtime, options.session || "latest");
581
+ return renderSession(runtime, sessionId, {
582
+ mode: options.mode === "all" ? "all" : "missing",
583
+ maxParallelShots: clampInt(options.maxParallelShots, 1, { min: 1, max: 8 }),
584
+ stitch: Boolean(options.stitch),
585
+ stitchPartial: Boolean(options.stitchPartial),
586
+ repairPolicy: normalizeRepairPolicy(options.repairPolicy),
587
+ maxAttempts: clampInt(options.maxAttempts, 1, { min: 1, max: 5 }),
588
+ timeoutMs: clampInt(options.timeoutMs, DEFAULT_TIMEOUT_MS, { min: 10_000 }),
589
+ pollIntervalMs: clampInt(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS, { min: 1000 }),
590
+ reporter: options.reporter
591
+ });
592
+ }
593
+
594
+ async function commandNode(runtime, positionals, options, forcedKind) {
595
+ await api(runtime, "/api/healthz");
596
+ const action = positionals[0] || "get";
597
+ const id = resolveNodeArg(positionals, options);
598
+ const node = await resolveNode(runtime, id, forcedKind);
599
+
600
+ if (action === "get" || action === "show") {
601
+ return nodeResult(runtime, node, { action });
602
+ }
603
+
604
+ if (action === "update-prompt" || action === "prompt" || action === "update") {
605
+ const prompt = stringOption(options.prompt) || (positionals.length > 2 ? positionals.slice(2).join(" ").trim() : "");
606
+ if (!prompt) throw new CliError("Missing --prompt for node update-prompt.");
607
+ if (node.kind === "shot") {
608
+ const patch = { rawPrompt: prompt, prompt };
609
+ if (options.title) patch.title = String(options.title);
610
+ if (options.duration) patch.durationSec = clampInt(options.duration, node.value.durationSec || 15, { min: 1, max: 15 });
611
+ const updated = await api(runtime, `/api/shots/${node.id}`, { method: "PATCH", body: JSON.stringify(patch) });
612
+ return nodeResult(runtime, { kind: "shot", id: updated.id, value: updated }, { action: "update-prompt" });
613
+ }
614
+ if (node.kind === "asset") {
615
+ const updated = await api(runtime, `/api/assets/${node.id}`, {
616
+ method: "PATCH",
617
+ body: JSON.stringify({ prompt, description: stringOption(options.description) || node.value.description })
618
+ });
619
+ return nodeResult(runtime, { kind: "asset", id: updated.id, value: updated }, { action: "update-prompt" });
620
+ }
621
+ throw new CliError("update-prompt supports shot and asset nodes.");
622
+ }
623
+
624
+ if (action === "generate") {
625
+ await ensureAgentPlan(runtime, { required: true, reporter: options.reporter });
626
+ if (node.kind === "shot") {
627
+ options.reporter?.event("shot_submitted", { shotId: node.id, index: node.value.index, attempt: 1 });
628
+ const generated = await api(runtime, `/api/shots/${node.id}/generate`, { method: "POST" });
629
+ reportShotTask(options.reporter, "task_id", generated);
630
+ if (!options.wait) return nodeResult(runtime, { kind: "shot", id: generated.id, value: generated }, { action: "generate" });
631
+ const rendered = await waitForShot(runtime, node.id, {
632
+ timeoutMs: clampInt(options.timeoutMs, DEFAULT_TIMEOUT_MS, { min: 10_000 }),
633
+ pollIntervalMs: clampInt(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS, { min: 1000 }),
634
+ reporter: options.reporter
635
+ });
636
+ return { action: "generate", kind: "shot", webUrl: sessionUrl(runtime.baseUrl, rendered.sessionId || node.value.sessionId), shot: summarizeShot(rendered) };
637
+ }
638
+ if (node.kind === "asset") {
639
+ const generated = await api(runtime, `/api/assets/${node.id}/generate`, {
640
+ method: "POST",
641
+ body: JSON.stringify({
642
+ model: stringOption(options.model) || undefined,
643
+ visionReview: options.visionReview === true ? true : undefined,
644
+ maxReviewAttempts: options.maxReviewAttempts ? clampInt(options.maxReviewAttempts, 1, { min: 1, max: 5 }) : undefined
645
+ })
646
+ });
647
+ return nodeResult(runtime, { kind: "asset", id: generated.id, value: generated }, { action: "generate" });
648
+ }
649
+ throw new CliError("generate supports shot and asset nodes.");
650
+ }
651
+
652
+ if (action === "poll") {
653
+ if (node.kind !== "shot") throw new CliError("poll only supports shot nodes.");
654
+ const shot = await api(runtime, `/api/shots/${node.id}/poll`, { method: "POST" });
655
+ return nodeResult(runtime, { kind: "shot", id: shot.id, value: shot }, { action: "poll" });
656
+ }
657
+
658
+ if (action === "tailframe") {
659
+ if (node.kind !== "shot") throw new CliError("tailframe only supports shot nodes with rendered video.");
660
+ const result = await api(runtime, `/api/shots/${node.id}/tailframe`, {
661
+ method: "POST",
662
+ body: JSON.stringify({
663
+ publishToTos: Boolean(options.publishTos),
664
+ canvasNode: Boolean(options.canvasNode)
665
+ })
666
+ });
667
+ return {
668
+ action: "tailframe",
669
+ kind: "shot",
670
+ shotId: node.id,
671
+ webUrl: sessionUrl(runtime.baseUrl, node.value.sessionId),
672
+ asset: summarizeAsset(result.asset)
673
+ };
674
+ }
675
+
676
+ if (action === "review" || action === "vlm") {
677
+ await ensureAgentPlan(runtime, { required: true, reporter: options.reporter });
678
+ if (node.kind === "shot") {
679
+ const reviewed = await api(runtime, `/api/shots/${node.id}/review`, {
680
+ method: "POST",
681
+ body: JSON.stringify({ frameCount: clampInt(options.frameCount, 8, { min: 1, max: 32 }) })
682
+ });
683
+ return nodeResult(runtime, { kind: "shot", id: reviewed.id, value: reviewed }, { action: "review" });
684
+ }
685
+ if (node.kind === "asset") {
686
+ const reviewed = await api(runtime, `/api/assets/${node.id}/review`, { method: "POST" });
687
+ return nodeResult(runtime, { kind: "asset", id: reviewed.id, value: reviewed }, { action: "review" });
688
+ }
689
+ if (node.kind === "session") {
690
+ return commandFinalReview(runtime, { ...options, session: node.id });
691
+ }
692
+ throw new CliError("review supports shot, asset, and session nodes.");
693
+ }
694
+
695
+ if (action === "repair" || action === "repair-prompts") {
696
+ await ensureAgentPlan(runtime, { required: true, reporter: options.reporter });
697
+ if (node.kind === "shot") {
698
+ const repaired = await api(runtime, `/api/shots/${node.id}/review/repair-prompts`, { method: "POST" });
699
+ return nodeResult(runtime, { kind: "shot", id: repaired.id, value: repaired }, { action: "repair" });
700
+ }
701
+ if (node.kind === "session") {
702
+ return commandFinalReview(runtime, { ...options, session: node.id, repair: true });
703
+ }
704
+ throw new CliError("repair supports shot and session nodes.");
705
+ }
706
+
707
+ throw new CliError(`Unknown node action: ${action}`);
708
+ }
709
+
710
+ async function commandPublishStoryboards(runtime, options) {
711
+ await api(runtime, "/api/healthz");
712
+ const sessionId = await resolveSessionId(runtime, options.session || "latest");
713
+ const result = await api(runtime, `/api/sessions/${sessionId}/storyboards/publish-tos`, { method: "POST" });
714
+ return {
715
+ action: "publish-storyboards",
716
+ sessionId,
717
+ webUrl: sessionUrl(runtime.baseUrl, sessionId),
718
+ assets: (result.assets || []).map(summarizeAsset),
719
+ shots: summarizeShots(result.session?.shots || [])
720
+ };
721
+ }
722
+
723
+ async function commandFinalReview(runtime, options) {
724
+ await api(runtime, "/api/healthz");
725
+ await ensureAgentPlan(runtime, { required: true, reporter: options.reporter });
726
+ const sessionId = await resolveSessionId(runtime, options.session || "latest");
727
+ const route = options.repair ? `/api/sessions/${sessionId}/final-review/repair-prompts` : `/api/sessions/${sessionId}/final-review`;
728
+ const result = await api(runtime, route, {
729
+ method: "POST",
730
+ body: JSON.stringify({
731
+ jobId: stringOption(options.jobId) || undefined,
732
+ frameCount: options.frameCount ? clampInt(options.frameCount, 10, { min: 1, max: 32 }) : undefined
733
+ })
734
+ });
735
+ return {
736
+ action: options.repair ? "final-review-repair" : "final-review",
737
+ sessionId,
738
+ webUrl: sessionUrl(runtime.baseUrl, sessionId),
739
+ session: {
740
+ id: result.id,
741
+ title: result.title,
742
+ finalVideoReviewStatus: result.finalVideoReviewStatus,
743
+ finalVideoReview: result.finalVideoReview,
744
+ finalVideoReviewRepairPlan: result.finalVideoReviewRepairPlan
745
+ }
746
+ };
747
+ }
748
+
749
+ async function renderSession(runtime, sessionId, options) {
750
+ options.reporter?.event("render_plan_requested", { sessionId, mode: options.mode });
751
+ const plan = await api(runtime, `/api/sessions/${sessionId}/workflow/plan`, {
752
+ method: "POST",
753
+ body: JSON.stringify({ mode: options.mode, maxParallelShots: options.maxParallelShots })
754
+ });
755
+ options.reporter?.event("render_plan_ready", { sessionId, summary: plan.summary, layerCount: (plan.layers || []).length });
756
+ const layerResults = [];
757
+ for (let layerIndex = 0; layerIndex < (plan.layers || []).length; layerIndex += 1) {
758
+ const layer = plan.layers[layerIndex];
759
+ options.reporter?.event("render_layer_started", { sessionId, layerIndex, shotCount: layer.length });
760
+ const rendered = await Promise.all(layer.map((item) => renderShot(runtime, item, options)));
761
+ layerResults.push(rendered);
762
+ options.reporter?.event("render_layer_finished", { sessionId, layerIndex });
763
+ }
764
+ const state = await api(runtime, "/api/state");
765
+ const shots = state.shots?.filter((shot) => shot.sessionId === sessionId) || [];
766
+ const failed = shots.filter((shot) => shot.status === "error" || shot.status === "cancelled" || !shot.videoUrl);
767
+ const ready = shots.filter((shot) => shot.videoUrl);
768
+ let stitched;
769
+ if (options.stitch && (failed.length === 0 || options.stitchPartial) && ready.length > 0) {
770
+ stitched = await stitchSession(runtime, sessionId, options);
771
+ } else if (options.stitch && failed.length > 0) {
772
+ options.reporter?.event("stitch_skipped", {
773
+ sessionId,
774
+ reason: "failed_or_missing_shots",
775
+ skippedShotIds: failed.map((shot) => shot.id)
776
+ });
777
+ }
778
+ return {
779
+ baseUrl: runtime.baseUrl,
780
+ sessionId,
781
+ webUrl: sessionUrl(runtime.baseUrl, sessionId),
782
+ planSummary: plan.summary,
783
+ layers: layerResults,
784
+ shots: summarizeShots(shots),
785
+ failed: summarizeShots(failed),
786
+ skippedShots: summarizeShots(failed),
787
+ stitched
788
+ };
789
+ }
790
+
791
+ async function renderShot(runtime, item, options) {
792
+ const shotId = item.shotId || item.id;
793
+ if (!shotId) throw new CliError(`Workflow item is missing shot id: ${JSON.stringify(item)}`);
794
+ const maxAttempts = options.repairPolicy === "safe-retry" ? Math.max(1, options.maxAttempts || 1) : 1;
795
+ let lastResult;
796
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
797
+ if (item.index > 1 && item.action !== "skip") {
798
+ await api(runtime, `/api/shots/${shotId}`, {
799
+ method: "PATCH",
800
+ body: JSON.stringify({ usePreviousShotClip: true, previousShotClipSec: 2 })
801
+ });
802
+ }
803
+ options.reporter?.event("shot_submitted", { shotId, index: item.index, attempt });
804
+ const submitted = await api(runtime, `/api/shots/${shotId}/generate`, { method: "POST" });
805
+ reportShotTask(options.reporter, "task_id", submitted, { attempt });
806
+ let rendered;
807
+ try {
808
+ rendered = await waitForShot(runtime, shotId, options);
809
+ } catch (error) {
810
+ if (attempt >= maxAttempts || options.repairPolicy !== "safe-retry") throw error;
811
+ const state = await api(runtime, "/api/state");
812
+ const current = (state.shots || []).find((shot) => shot.id === shotId) || submitted;
813
+ const patch = buildSafeRetryPatch(current);
814
+ options.reporter?.event("retrying", {
815
+ shotId,
816
+ index: current.index || item.index,
817
+ attempt: attempt + 1,
818
+ reason: error instanceof Error ? error.message : String(error),
819
+ durationSec: patch.durationSec
820
+ });
821
+ await api(runtime, `/api/shots/${shotId}/cancel`, { method: "POST" }).catch(() => undefined);
822
+ await api(runtime, `/api/shots/${shotId}`, { method: "PATCH", body: JSON.stringify(patch) });
823
+ continue;
824
+ }
825
+ lastResult = rendered;
826
+ if (rendered.status !== "error" && rendered.status !== "cancelled") return rendered;
827
+ if (attempt >= maxAttempts || !isSafeRetryableShotError(rendered.error)) return rendered;
828
+ const patch = buildSafeRetryPatch(rendered);
829
+ options.reporter?.event("retrying", {
830
+ shotId,
831
+ index: rendered.index,
832
+ attempt: attempt + 1,
833
+ reason: rendered.error,
834
+ durationSec: patch.durationSec
835
+ });
836
+ await api(runtime, `/api/shots/${shotId}/cancel`, { method: "POST" }).catch(() => undefined);
837
+ await api(runtime, `/api/shots/${shotId}`, {
838
+ method: "PATCH",
839
+ body: JSON.stringify(patch)
840
+ });
841
+ }
842
+ return lastResult;
843
+ }
844
+
845
+ async function waitForShot(runtime, shotId, options) {
846
+ const started = Date.now();
847
+ let snapshot;
848
+ while (Date.now() - started < options.timeoutMs) {
849
+ snapshot = await api(runtime, `/api/shots/${shotId}/poll`, { method: "POST" });
850
+ options.reporter?.event("poll_status", {
851
+ shotId,
852
+ index: snapshot.index,
853
+ status: snapshot.status,
854
+ phase: snapshot.seedancePhase,
855
+ taskId: snapshot.generationTaskId || latestRender(snapshot)?.generationTaskId,
856
+ elapsedSec: Math.round((Date.now() - started) / 1000)
857
+ });
858
+ if (["ready", "error", "cancelled"].includes(snapshot.status)) {
859
+ return {
860
+ id: snapshot.id,
861
+ sessionId: snapshot.sessionId,
862
+ index: snapshot.index,
863
+ title: snapshot.title,
864
+ durationSec: snapshot.durationSec,
865
+ status: snapshot.status,
866
+ videoUrl: snapshot.videoUrl,
867
+ error: snapshot.error || latestRender(snapshot)?.error,
868
+ rawPrompt: snapshot.rawPrompt,
869
+ prompt: snapshot.prompt
870
+ };
871
+ }
872
+ await sleep(options.pollIntervalMs);
873
+ }
874
+ await api(runtime, `/api/shots/${shotId}/cancel`, { method: "POST" }).catch(() => undefined);
875
+ throw new CliError(`Timed out waiting for shot ${shotId}`);
876
+ }
877
+
878
+ function latestRender(shot) {
879
+ return Array.isArray(shot?.renders) ? shot.renders[0] : undefined;
880
+ }
881
+
882
+ function reportShotTask(reporter, event, shot, extra = {}) {
883
+ const render = latestRender(shot);
884
+ const taskId = shot?.generationTaskId || render?.generationTaskId;
885
+ if (!taskId) return;
886
+ reporter?.event(event, {
887
+ shotId: shot.id,
888
+ index: shot.index,
889
+ taskId,
890
+ status: shot.status,
891
+ ...extra
892
+ });
893
+ }
894
+
895
+ function normalizeRepairPolicy(value) {
896
+ const raw = stringOption(value) || "none";
897
+ if (raw === "none" || raw === "safe-retry") return raw;
898
+ throw new CliError(`Unknown --repair-policy: ${raw}. Expected none or safe-retry.`);
899
+ }
900
+
901
+ function isSafeRetryableShotError(error) {
902
+ const text = String(error || "");
903
+ return /SensitiveContent|PolicyViolation|content.*policy|sensitive|risk|violation|审核|敏感|安全|策略/i.test(text);
904
+ }
905
+
906
+ function buildSafeRetryPatch(shot) {
907
+ const original = shot.rawPrompt || shot.prompt || "";
908
+ const prompt = safeRetryPrompt(original);
909
+ const currentDuration = Number(shot.durationSec) || 15;
910
+ return {
911
+ rawPrompt: prompt,
912
+ prompt,
913
+ durationSec: Math.max(3, Math.min(8, currentDuration > 8 ? 8 : currentDuration))
914
+ };
915
+ }
916
+
917
+ function safeRetryPrompt(prompt) {
918
+ const replacements = [
919
+ [/台北\s*101|taipei\s*101/gi, "一座虚构现代城市地标高楼"],
920
+ [/纽约|new\s*york|东京|tokyo|巴黎|paris|北京|beijing|上海|shanghai|台北|taipei/gi, "虚构现代城市"],
921
+ [/apple|iphone|tesla|bytedance|tiktok|douyin|volcengine|openai|google|microsoft|meta|nvidia/gi, "虚构科技品牌"],
922
+ [/苹果|特斯拉|字节跳动|抖音|火山引擎|谷歌|微软|英伟达/gi, "虚构科技品牌"],
923
+ [/logo|商标|品牌标识/gi, "无可识别品牌标识"]
924
+ ];
925
+ let next = prompt;
926
+ for (const [pattern, replacement] of replacements) next = next.replace(pattern, replacement);
927
+ const safety = "安全重试约束:使用虚构地点、虚构建筑和虚构品牌;不要出现真实品牌、真实地名、可识别商标、政治符号、血腥暴力、危险行为或敏感标识;保持电影感和原镜头意图。";
928
+ return next.includes("安全重试约束") ? next : `${next.trim()}\n${safety}`;
929
+ }
930
+
931
+ function resolveNodeArg(positionals, options) {
932
+ const id = stringOption(options.id) || stringOption(options.shot) || stringOption(options.asset) || stringOption(options.session) || positionals[1];
933
+ if (!id || id === true) throw new CliError("Missing node id. Use --id shot_xxx / --id asset_xxx / --id ses_xxx.");
934
+ return String(id);
935
+ }
936
+
937
+ async function resolveNode(runtime, id, forcedKind) {
938
+ const state = await api(runtime, "/api/state");
939
+ if (forcedKind === "shot" || (!forcedKind && id.startsWith("shot_"))) {
940
+ const shot = (state.shots || []).find((item) => item.id === id);
941
+ if (!shot) throw new CliError(`Shot not found: ${id}`);
942
+ return { kind: "shot", id, value: shot };
943
+ }
944
+ if (forcedKind === "asset" || (!forcedKind && id.startsWith("asset_"))) {
945
+ const asset = (state.assets || []).find((item) => item.id === id);
946
+ if (!asset) throw new CliError(`Asset not found: ${id}`);
947
+ return { kind: "asset", id, value: asset };
948
+ }
949
+ if (forcedKind === "session" || (!forcedKind && id.startsWith("ses_"))) {
950
+ const session = (state.sessions || []).find((item) => item.id === id);
951
+ if (!session) throw new CliError(`Session not found: ${id}`);
952
+ return { kind: "session", id, value: session };
953
+ }
954
+ const shot = (state.shots || []).find((item) => item.id === id);
955
+ if (shot) return { kind: "shot", id, value: shot };
956
+ const asset = (state.assets || []).find((item) => item.id === id);
957
+ if (asset) return { kind: "asset", id, value: asset };
958
+ const session = (state.sessions || []).find((item) => item.id === id);
959
+ if (session) return { kind: "session", id, value: session };
960
+ throw new CliError(`Node not found: ${id}`);
961
+ }
962
+
963
+ function nodeResult(runtime, node, extra = {}) {
964
+ const sessionId = node.kind === "shot" ? node.value.sessionId : node.kind === "asset" ? node.value.ownerSessionId : node.value.id;
965
+ return {
966
+ ...extra,
967
+ kind: node.kind,
968
+ id: node.id,
969
+ webUrl: sessionId ? sessionUrl(runtime.baseUrl, sessionId) : undefined,
970
+ shot: node.kind === "shot" ? summarizeShot(node.value) : undefined,
971
+ asset: node.kind === "asset" ? summarizeAsset(node.value) : undefined,
972
+ session: node.kind === "session" ? {
973
+ id: node.value.id,
974
+ title: node.value.title,
975
+ finalVideoUrl: node.value.finalVideoUrl,
976
+ stitchStatus: node.value.stitchStatus,
977
+ finalVideoReviewStatus: node.value.finalVideoReviewStatus
978
+ } : undefined
979
+ };
980
+ }
981
+
982
+ async function stitchSession(runtime, sessionId, options) {
983
+ const beforeState = await api(runtime, "/api/state");
984
+ const sessionShots = (beforeState.shots || []).filter((shot) => shot.sessionId === sessionId);
985
+ const skippedShots = sessionShots.filter((shot) => !shot.videoUrl);
986
+ options.reporter?.event("stitch_started", {
987
+ sessionId,
988
+ readyShotCount: sessionShots.length - skippedShots.length,
989
+ skippedShotIds: skippedShots.map((shot) => shot.id)
990
+ });
991
+ let session = await api(runtime, `/api/sessions/${sessionId}/stitch`, {
992
+ method: "POST",
993
+ body: JSON.stringify({ force: Boolean(options.force) })
994
+ });
995
+ const started = Date.now();
996
+ const timeoutMs = clampInt(options.timeoutMs, DEFAULT_TIMEOUT_MS, { min: 10_000 });
997
+ const pollIntervalMs = clampInt(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS, { min: 1000 });
998
+ while (session.stitchStatus === "running" && Date.now() - started < timeoutMs) {
999
+ await sleep(pollIntervalMs);
1000
+ session = await api(runtime, `/api/sessions/${sessionId}/stitch/poll`, { method: "POST" });
1001
+ options.reporter?.event("stitch_poll", {
1002
+ sessionId,
1003
+ status: session.stitchStatus,
1004
+ progress: session.stitchProgress
1005
+ });
1006
+ }
1007
+ options.reporter?.event("stitch_ready", {
1008
+ sessionId,
1009
+ status: session.stitchStatus,
1010
+ finalVideoUrl: session.finalVideoUrl,
1011
+ downloadUrl: session.finalVideoUrl ? downloadUrl(runtime.baseUrl, sessionId) : undefined
1012
+ });
1013
+ return {
1014
+ sessionId,
1015
+ status: session.stitchStatus,
1016
+ progress: session.stitchProgress,
1017
+ error: session.stitchError,
1018
+ finalVideoUrl: session.finalVideoUrl,
1019
+ webUrl: sessionUrl(runtime.baseUrl, sessionId),
1020
+ downloadUrl: session.finalVideoUrl ? downloadUrl(runtime.baseUrl, sessionId) : undefined,
1021
+ skippedShots: summarizeShots(skippedShots)
1022
+ };
1023
+ }
1024
+
1025
+ async function resolveSessionId(runtime, value) {
1026
+ if (value && value !== true && value !== "latest") return String(value);
1027
+ const state = await api(runtime, "/api/state");
1028
+ const latest = state.sessions?.[0];
1029
+ if (!latest) throw new CliError("No sessions found.");
1030
+ return latest.id;
1031
+ }
1032
+
1033
+ async function commandStatus(runtime, options) {
1034
+ const health = await api(runtime, "/api/healthz");
1035
+ const agentPlan = await ensureAgentPlan(runtime, { reporter: options.reporter });
1036
+ const state = await api(runtime, "/api/state");
1037
+ const limit = clampInt(options.limit, 10, { min: 1, max: 100 });
1038
+ const sessions = (state.sessions || []).slice(0, limit);
1039
+ const sessionId = options.deep || options.session ? await resolveSessionIdFromState(runtime, state, options.session || "latest") : undefined;
1040
+ const session = sessionId ? (state.sessions || []).find((item) => item.id === sessionId) : undefined;
1041
+ return {
1042
+ action: "status",
1043
+ baseUrl: runtime.baseUrl,
1044
+ health,
1045
+ agentPlan,
1046
+ deep: Boolean(options.deep),
1047
+ session: session && options.deep ? summarizeDeepSession(runtime, session, state) : undefined,
1048
+ sessions: sessions.map((session) => ({
1049
+ id: session.id,
1050
+ title: session.title,
1051
+ webUrl: sessionUrl(runtime.baseUrl, session.id),
1052
+ finalVideoUrl: session.finalVideoUrl,
1053
+ downloadUrl: session.finalVideoUrl ? downloadUrl(runtime.baseUrl, session.id) : undefined,
1054
+ stitchStatus: session.stitchStatus,
1055
+ readyShotCount: (state.shots || []).filter((shot) => shot.sessionId === session.id && shot.videoUrl).length,
1056
+ failedShotCount: (state.shots || []).filter((shot) => shot.sessionId === session.id && (shot.status === "error" || shot.status === "cancelled")).length
1057
+ }))
1058
+ };
1059
+ }
1060
+
1061
+ async function commandDownload(runtime, options) {
1062
+ await api(runtime, "/api/healthz");
1063
+ const sessionId = await resolveSessionId(runtime, options.session || "latest");
1064
+ const output = path.resolve(stringOption(options.output) || `seereel-${sessionId}.mp4`);
1065
+ const result = await downloadToFile(runtime, `/api/sessions/${sessionId}/download`, output);
1066
+ return {
1067
+ action: "download",
1068
+ sessionId,
1069
+ output,
1070
+ bytes: result.bytes,
1071
+ webUrl: sessionUrl(runtime.baseUrl, sessionId)
1072
+ };
1073
+ }
1074
+
1075
+ async function resolveSessionIdFromState(runtime, state, value) {
1076
+ if (value && value !== true && value !== "latest") return String(value);
1077
+ const latest = state.sessions?.[0];
1078
+ if (!latest) return undefined;
1079
+ return latest.id;
1080
+ }
1081
+
1082
+ async function commandConfigure(config, options) {
1083
+ const next = { ...config };
1084
+ const baseUrl = flagValue(options, "baseUrl");
1085
+ const accessToken = flagValue(options, "accessToken");
1086
+ const agentPlanToken = flagValue(options, "agentPlanToken");
1087
+ if (baseUrl) next.baseUrl = normalizeBaseUrl(baseUrl);
1088
+ if (accessToken) next.accessToken = accessToken;
1089
+ if (agentPlanToken) next.agentPlanToken = agentPlanToken;
1090
+ if (options.clearAccessToken) delete next.accessToken;
1091
+ if (options.clearAgentPlanToken) delete next.agentPlanToken;
1092
+ if (options.clearCookies) delete next.cookies;
1093
+
1094
+ const changed =
1095
+ baseUrl ||
1096
+ accessToken ||
1097
+ agentPlanToken ||
1098
+ options.clearAccessToken ||
1099
+ options.clearAgentPlanToken ||
1100
+ options.clearCookies;
1101
+ if (changed) await writeConfig(next);
1102
+
1103
+ return {
1104
+ configFile: CONFIG_FILE,
1105
+ baseUrl: next.baseUrl || DEFAULT_BASE_URL,
1106
+ accessTokenConfigured: Boolean(next.accessToken),
1107
+ agentPlanTokenConfigured: Boolean(next.agentPlanToken),
1108
+ cookieOrigins: Object.keys(next.cookies || {})
1109
+ };
1110
+ }
1111
+
1112
+ async function commandSkill(positionals, options) {
1113
+ const action = positionals[0] || "install";
1114
+ const skillText = await readFile(REELYAI_CLI_SKILL, "utf8");
1115
+ if (action === "print" || action === "show") {
1116
+ return { action: "skill-print", rawText: skillText, skillPath: REELYAI_CLI_SKILL };
1117
+ }
1118
+ if (action === "path") {
1119
+ return { action: "skill-path", skillPath: REELYAI_CLI_SKILL };
1120
+ }
1121
+ if (action !== "install") throw new CliError(`Unknown skill action: ${action}`);
1122
+
1123
+ const targets = resolveSkillTargets(options.agent);
1124
+ const installed = [];
1125
+ for (const target of targets) {
1126
+ const dir = path.join(os.homedir(), target.root, "skills", "reelyai-cli");
1127
+ await mkdir(dir, { recursive: true });
1128
+ const file = path.join(dir, "SKILL.md");
1129
+ await writeFile(file, skillText);
1130
+ installed.push({ agent: target.name, file });
1131
+ }
1132
+ return { action: "skill-install", skillPath: REELYAI_CLI_SKILL, installed };
1133
+ }
1134
+
1135
+ function resolveSkillTargets(value) {
1136
+ const all = [
1137
+ { name: "codex", root: ".codex" },
1138
+ { name: "claude", root: ".claude" },
1139
+ { name: "cursor", root: ".cursor" },
1140
+ { name: "agents", root: ".agents" }
1141
+ ];
1142
+ const raw = stringOption(value) || "all";
1143
+ if (raw === "all") return all;
1144
+ const names = raw.split(",").map((item) => item.trim()).filter(Boolean);
1145
+ const selected = all.filter((target) => names.includes(target.name));
1146
+ const missing = names.filter((name) => !all.some((target) => target.name === name));
1147
+ if (missing.length) throw new CliError(`Unknown --agent target: ${missing.join(", ")}`);
1148
+ return selected;
1149
+ }
1150
+
1151
+ function flagValue(options, key) {
1152
+ const value = options[key];
1153
+ if (value === undefined || value === false) return "";
1154
+ if (value === true) throw new CliError(`--${key.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)} requires a value`);
1155
+ return String(value).trim();
1156
+ }
1157
+
1158
+ async function commandOpen(runtime, options) {
1159
+ const sessionId = await resolveSessionId(runtime, options.session || "latest");
1160
+ const webUrl = sessionUrl(runtime.baseUrl, sessionId);
1161
+ openUrl(webUrl);
1162
+ return { webUrl };
1163
+ }
1164
+
1165
+ async function commandHandoff(runtime, options) {
1166
+ await api(runtime, "/api/healthz");
1167
+ const sessionId = await resolveSessionId(runtime, options.session || "latest");
1168
+ const handoff = await createSessionHandoff(runtime, sessionId);
1169
+ const result = {
1170
+ action: "handoff",
1171
+ sessionId,
1172
+ webUrl: sessionUrl(runtime.baseUrl, sessionId),
1173
+ webUrlVisibleInBrowser: false,
1174
+ handoffUrl: handoff.handoffUrl,
1175
+ handoffExpiresAt: handoff.handoffExpiresAt
1176
+ };
1177
+ if (options.open) openUrl(result.handoffUrl);
1178
+ return result;
1179
+ }
1180
+
1181
+ function openUrl(url) {
1182
+ const command =
1183
+ process.platform === "darwin" ? "open" :
1184
+ process.platform === "win32" ? "cmd" :
1185
+ "xdg-open";
1186
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
1187
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
1188
+ child.unref();
1189
+ }
1190
+
1191
+ function sleep(ms) {
1192
+ return new Promise((resolve) => setTimeout(resolve, ms));
1193
+ }
1194
+
1195
+ function printHuman(result, command) {
1196
+ if (result?.rawText) {
1197
+ console.log(result.rawText);
1198
+ return;
1199
+ }
1200
+ if (command === "configure") {
1201
+ console.log(`Config: ${result.configFile}`);
1202
+ console.log(`Base URL: ${result.baseUrl}`);
1203
+ console.log(`Access token: ${result.accessTokenConfigured ? "configured" : "not configured"}`);
1204
+ console.log(`Agent Plan token: ${result.agentPlanTokenConfigured ? "configured" : "not configured"}`);
1205
+ if (result.cookieOrigins.length) console.log(`Cookie origins: ${result.cookieOrigins.join(", ")}`);
1206
+ return;
1207
+ }
1208
+ if (command === "status") {
1209
+ console.log(`SeeReel: ${result.baseUrl}`);
1210
+ console.log(`Health: ${result.health?.ok ? "ok" : "unknown"}`);
1211
+ console.log(`Agent Plan: ${result.agentPlan?.configured ? `configured (${result.agentPlan.fingerprint})` : "not configured"}`);
1212
+ if (result.session) {
1213
+ console.log(`Session: ${result.session.title || result.session.id} [${result.session.id}]`);
1214
+ console.log(`Web: ${result.session.webUrl}`);
1215
+ console.log(`Shots: ${result.session.readyShotCount} ready / ${result.session.failedShotCount} failed-or-missing`);
1216
+ console.log(`Stitch: ${result.session.stitchStatus || "idle"}${result.session.stitchProgress ? ` (${result.session.stitchProgress})` : ""}`);
1217
+ if (result.session.downloadUrl) console.log(`Download: ${result.session.downloadUrl}`);
1218
+ for (const shot of result.session.shots || []) {
1219
+ const age = shot.taskAgeSec !== undefined ? ` age=${shot.taskAgeSec}s` : "";
1220
+ const task = shot.generationTaskId ? ` task=${shot.generationTaskId}` : "";
1221
+ const error = shot.error ? ` error=${shot.error}` : "";
1222
+ console.log(` ${shot.index}. ${shot.title || shot.id} - ${shot.status || "draft"}${task}${age}${error}`);
1223
+ }
1224
+ return;
1225
+ }
1226
+ for (const session of result.sessions) {
1227
+ console.log(`- ${session.title || session.id} [${session.id}] ${session.webUrl}`);
1228
+ }
1229
+ return;
1230
+ }
1231
+ if (result?.kind || result?.action) {
1232
+ console.log(`Action: ${result.action || command}`);
1233
+ if (result.skillPath) console.log(`Skill: ${result.skillPath}`);
1234
+ if (Array.isArray(result.installed)) {
1235
+ for (const item of result.installed) console.log(`Installed ${item.agent}: ${item.file}`);
1236
+ }
1237
+ if (result.kind) console.log(`Node: ${result.kind} ${result.id || ""}`.trim());
1238
+ if (result.webUrl) console.log(`Web: ${result.webUrl}`);
1239
+ if (result.webUrlVisibleInBrowser === false) console.log("Web visible in normal browser: no (use Handoff)");
1240
+ if (result.handoffUrl) console.log(`Handoff: ${result.handoffUrl}`);
1241
+ if (result.handoffExpiresAt) console.log(`Handoff expires: ${result.handoffExpiresAt}`);
1242
+ if (result.output) console.log(`Output: ${result.output}${result.bytes ? ` (${result.bytes} bytes)` : ""}`);
1243
+ if (result.shot) {
1244
+ console.log(`Shot: ${result.shot.index || ""} ${result.shot.title || result.shot.id} - ${result.shot.status || "unknown"}`.trim());
1245
+ if (result.shot.videoUrl) console.log(`Video: ${result.shot.videoUrl}`);
1246
+ if (result.shot.videoReviewStatus) console.log(`VLM: ${result.shot.videoReviewStatus}`);
1247
+ }
1248
+ if (result.asset) {
1249
+ console.log(`Asset: ${result.asset.name || result.asset.id} - ${result.asset.mediaKind || "unknown"}`);
1250
+ if (result.asset.mediaUrl) console.log(`Media: ${result.asset.mediaUrl}`);
1251
+ if (result.asset.imageReviewStatus) console.log(`VLM: ${result.asset.imageReviewStatus}`);
1252
+ }
1253
+ if (result.session) {
1254
+ console.log(`Session: ${result.session.title || result.session.id}`);
1255
+ if (result.session.finalVideoReviewStatus) console.log(`Final VLM: ${result.session.finalVideoReviewStatus}`);
1256
+ }
1257
+ if (Array.isArray(result.assets) && result.assets.length) console.log(`Assets: ${result.assets.length}`);
1258
+ return;
1259
+ }
1260
+ console.log(`Session: ${result.title || result.sessionId} [${result.sessionId}]`);
1261
+ console.log(`Web: ${result.webUrl}`);
1262
+ if (result.webUrlVisibleInBrowser === false) console.log("Web visible in normal browser: no (use Handoff)");
1263
+ if (result.handoffUrl) console.log(`Handoff: ${result.handoffUrl}`);
1264
+ if (result.handoffExpiresAt) console.log(`Handoff expires: ${result.handoffExpiresAt}`);
1265
+ if (result.story?.premise) console.log(`Premise: ${result.story.premise}`);
1266
+ if (result.planSummary) console.log(`Plan: ${result.planSummary}`);
1267
+ if (Array.isArray(result.shots) && result.shots.length) {
1268
+ console.log("Shots:");
1269
+ for (const shot of result.shots) {
1270
+ console.log(` ${shot.index}. ${shot.title || shot.id} - ${shot.status || "draft"}${shot.durationSec ? ` - ${shot.durationSec}s` : ""}`);
1271
+ }
1272
+ }
1273
+ if (result.stitched?.downloadUrl) console.log(`Download: ${result.stitched.downloadUrl}`);
1274
+ if (result.render?.stitched?.downloadUrl) console.log(`Download: ${result.render.stitched.downloadUrl}`);
1275
+ if (result.failed?.length) console.log(`Failed shots: ${result.failed.map((shot) => shot.id).join(", ")}`);
1276
+ }
1277
+
1278
+ async function main() {
1279
+ const { command: rawCommand, positionals, options } = parseArgs(process.argv.slice(2));
1280
+ const command = rawCommand === "new" || rawCommand === "create" || rawCommand === "plan" ? "workflow" : rawCommand;
1281
+ if (options.help || command === "help" || command === "--help" || command === "-h") {
1282
+ console.log(HELP.trim());
1283
+ return;
1284
+ }
1285
+
1286
+ const config = await readConfig();
1287
+ const runtime = resolveRuntime(config, options);
1288
+ options.reporter = createReporter(options);
1289
+ let result;
1290
+ if (command === "configure" || command === "config") {
1291
+ result = await commandConfigure(config, options);
1292
+ } else if (command === "workflow") {
1293
+ result = await commandWorkflow(runtime, positionals, options);
1294
+ } else if (command === "node") {
1295
+ result = await commandNode(runtime, positionals, options);
1296
+ } else if (command === "shot") {
1297
+ result = await commandNode(runtime, positionals, options, "shot");
1298
+ } else if (command === "asset") {
1299
+ result = await commandNode(runtime, positionals, options, "asset");
1300
+ } else if (command === "session") {
1301
+ result = await commandNode(runtime, positionals, options, "session");
1302
+ } else if (command === "publish-storyboards") {
1303
+ result = await commandPublishStoryboards(runtime, options);
1304
+ } else if (command === "final-review") {
1305
+ result = await commandFinalReview(runtime, options);
1306
+ } else if (command === "render") {
1307
+ result = await commandRender(runtime, options);
1308
+ } else if (command === "stitch") {
1309
+ const sessionId = await resolveSessionId(runtime, options.session || "latest");
1310
+ result = await stitchSession(runtime, sessionId, options);
1311
+ } else if (command === "skill") {
1312
+ result = await commandSkill(positionals, options);
1313
+ } else if (command === "status") {
1314
+ result = await commandStatus(runtime, options);
1315
+ } else if (command === "download") {
1316
+ result = await commandDownload(runtime, options);
1317
+ } else if (command === "handoff") {
1318
+ result = await commandHandoff(runtime, options);
1319
+ } else if (command === "open") {
1320
+ result = await commandOpen(runtime, options);
1321
+ } else {
1322
+ throw new CliError(`Unknown command: ${rawCommand}\n\n${HELP.trim()}`);
1323
+ }
1324
+
1325
+ if (options.jsonl) options.reporter.complete(result);
1326
+ else if (options.json) console.log(JSON.stringify(result, null, 2));
1327
+ else printHuman(result, command);
1328
+ }
1329
+
1330
+ main().catch((error) => {
1331
+ const message = error instanceof Error ? error.message : String(error);
1332
+ console.error(`reelyai: ${message}`);
1333
+ process.exit(error instanceof CliError ? error.code : 1);
1334
+ });