visualfries 0.1.10115 → 0.3.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 (139) hide show
  1. package/README.md +122 -0
  2. package/bin/visualfries.js +1570 -0
  3. package/dist/DIContainer.d.ts +2 -1
  4. package/dist/DIContainer.js +16 -5
  5. package/dist/SceneBuilder.svelte.d.ts +70 -1
  6. package/dist/SceneBuilder.svelte.js +96 -29
  7. package/dist/agent/audioMixer.d.ts +34 -0
  8. package/dist/agent/audioMixer.js +308 -0
  9. package/dist/agent/broll.d.ts +35 -0
  10. package/dist/agent/broll.js +226 -0
  11. package/dist/agent/capabilities.d.ts +34 -0
  12. package/dist/agent/capabilities.js +246 -0
  13. package/dist/agent/captionScene.d.ts +23 -0
  14. package/dist/agent/captionScene.js +204 -0
  15. package/dist/agent/catalog.d.ts +24 -0
  16. package/dist/agent/catalog.js +67 -0
  17. package/dist/agent/cueFiles.d.ts +54 -0
  18. package/dist/agent/cueFiles.js +219 -0
  19. package/dist/agent/cuePresets.d.ts +7 -0
  20. package/dist/agent/cuePresets.js +50 -0
  21. package/dist/agent/deterministicMedia.d.ts +61 -0
  22. package/dist/agent/deterministicMedia.js +303 -0
  23. package/dist/agent/index.d.ts +18 -0
  24. package/dist/agent/index.js +18 -0
  25. package/dist/agent/inspectScene.d.ts +36 -0
  26. package/dist/agent/inspectScene.js +139 -0
  27. package/dist/agent/localRender.d.ts +82 -0
  28. package/dist/agent/localRender.js +702 -0
  29. package/dist/agent/overlays.d.ts +40 -0
  30. package/dist/agent/overlays.js +325 -0
  31. package/dist/agent/productionPlan.d.ts +131 -0
  32. package/dist/agent/productionPlan.js +322 -0
  33. package/dist/agent/renderPlan.d.ts +27 -0
  34. package/dist/agent/renderPlan.js +68 -0
  35. package/dist/agent/renderRanges.d.ts +24 -0
  36. package/dist/agent/renderRanges.js +73 -0
  37. package/dist/agent/runtimeSupport.d.ts +19 -0
  38. package/dist/agent/runtimeSupport.js +192 -0
  39. package/dist/agent/streamingEncoder.d.ts +31 -0
  40. package/dist/agent/streamingEncoder.js +208 -0
  41. package/dist/agent/transcripts.d.ts +29 -0
  42. package/dist/agent/transcripts.js +146 -0
  43. package/dist/agent/transitions.d.ts +20 -0
  44. package/dist/agent/transitions.js +169 -0
  45. package/dist/animations/PixiAnimationTarget.d.ts +29 -0
  46. package/dist/animations/PixiAnimationTarget.js +63 -0
  47. package/dist/animations/builders/LineHighlighterAnimationBuilder.js +20 -3
  48. package/dist/animations/builders/WordHighlighterAnimationBuilder.js +15 -2
  49. package/dist/browser/browserExport.d.ts +73 -0
  50. package/dist/browser/browserExport.js +180 -0
  51. package/dist/browser/glTransitions.d.ts +37 -0
  52. package/dist/browser/glTransitions.js +261 -0
  53. package/dist/browser/index.d.ts +3 -0
  54. package/dist/browser/index.js +3 -0
  55. package/dist/browser/mediaDecode.d.ts +31 -0
  56. package/dist/browser/mediaDecode.js +152 -0
  57. package/dist/builders/PixiComponentBuilder.d.ts +6 -0
  58. package/dist/builders/PixiComponentBuilder.js +12 -0
  59. package/dist/builders/_ComponentState.svelte.d.ts +7 -5
  60. package/dist/builders/_ComponentState.svelte.js +29 -15
  61. package/dist/builders/html/StyleBuilder.js +9 -8
  62. package/dist/builders/html/TextComponentHtmlBuilder.js +4 -2
  63. package/dist/builders/html/processors/AppearanceStyleProcessor.js +1 -1
  64. package/dist/builders/html/processors/TextAppearanceStyleProcessor.js +7 -0
  65. package/dist/builders/html/processors/TextEffectsStyleProcessor.js +32 -13
  66. package/dist/commands/ReplaceSourceOnTimeCommand.js +9 -1
  67. package/dist/commands/SeekCommand.js +19 -6
  68. package/dist/components/AnimatedGIF.d.ts +3 -0
  69. package/dist/components/AnimatedGIF.js +28 -1
  70. package/dist/components/Component.svelte.d.ts +5 -5
  71. package/dist/components/Component.svelte.js +29 -13
  72. package/dist/components/ComponentContext.svelte.js +68 -23
  73. package/dist/components/hooks/AnimationHook.js +1 -1
  74. package/dist/components/hooks/CanvasFillHook.d.ts +12 -0
  75. package/dist/components/hooks/CanvasFillHook.js +75 -0
  76. package/dist/components/hooks/DeterministicMediaFrameHook.js +3 -0
  77. package/dist/components/hooks/HtmlTextHook.js +11 -3
  78. package/dist/components/hooks/HtmlToCanvasHook.js +8 -25
  79. package/dist/components/hooks/MediaHook.js +172 -47
  80. package/dist/components/hooks/MediaSeekingHook.js +138 -88
  81. package/dist/components/hooks/PixiGifHook.js +4 -4
  82. package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.d.ts +3 -0
  83. package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.js +85 -8
  84. package/dist/components/hooks/PixiVideoTextureHook.js +10 -4
  85. package/dist/components/hooks/PixiVisualTransformHook.d.ts +16 -0
  86. package/dist/components/hooks/PixiVisualTransformHook.js +102 -0
  87. package/dist/components/hooks/SubtitlesHook.js +48 -24
  88. package/dist/components/hooks/VerifyMediaHook.js +2 -2
  89. package/dist/directors/ComponentDirector.d.ts +1 -0
  90. package/dist/directors/ComponentDirector.js +41 -8
  91. package/dist/factories/SceneBuilderFactory.d.ts +3 -0
  92. package/dist/factories/SceneBuilderFactory.js +6 -0
  93. package/dist/fonts/fontDiscovery.js +12 -1
  94. package/dist/layers/Layer.svelte.d.ts +54 -0
  95. package/dist/layers/Layer.svelte.js +2 -25
  96. package/dist/managers/AppManager.svelte.d.ts +12 -0
  97. package/dist/managers/AppManager.svelte.js +180 -8
  98. package/dist/managers/ComponentsManager.svelte.d.ts +7 -7
  99. package/dist/managers/ComponentsManager.svelte.js +29 -15
  100. package/dist/managers/DeterministicMediaManager.d.ts +6 -0
  101. package/dist/managers/DeterministicMediaManager.js +57 -3
  102. package/dist/managers/EventManager.js +3 -1
  103. package/dist/managers/LayersManager.svelte.d.ts +54 -0
  104. package/dist/managers/RenderManager.d.ts +5 -0
  105. package/dist/managers/RenderManager.js +67 -21
  106. package/dist/managers/StateManager.svelte.d.ts +56 -0
  107. package/dist/managers/StateManager.svelte.js +60 -58
  108. package/dist/managers/SubtitlesManager.svelte.js +1 -3
  109. package/dist/managers/TimeManager.svelte.d.ts +4 -0
  110. package/dist/managers/TimeManager.svelte.js +23 -7
  111. package/dist/schemas/runtime/deterministic.d.ts +3 -0
  112. package/dist/schemas/runtime/deterministic.js +5 -1
  113. package/dist/schemas/runtime/types.d.ts +36 -15
  114. package/dist/schemas/scene/components.d.ts +126 -0
  115. package/dist/schemas/scene/components.js +11 -0
  116. package/dist/schemas/scene/core.d.ts +109 -10
  117. package/dist/schemas/scene/core.js +1 -1
  118. package/dist/utils/mediaWindow.d.ts +13 -0
  119. package/dist/utils/mediaWindow.js +25 -0
  120. package/dist/utils/svgGenerator.js +1 -1
  121. package/dist/utils/timelineWindow.d.ts +2 -0
  122. package/dist/utils/timelineWindow.js +7 -0
  123. package/dist/utils/utils.js +2 -4
  124. package/docs/AGENT_PATTERNS.md +243 -0
  125. package/docs/AGENT_WORKFLOW.md +364 -0
  126. package/docs/AUTHORING_BEST_PRACTICES.md +188 -0
  127. package/docs/AUTHORING_SYSTEM_AUDIT.md +98 -0
  128. package/docs/BROWSER_EXPORT_SPIKE.md +55 -0
  129. package/docs/DETERMINISTIC_RENDERER.md +83 -0
  130. package/docs/NEXT_RELEASE.md +28 -0
  131. package/docs/PROPOSED_VISUALFRIES_SKILL.md +68 -0
  132. package/docs/examples/agent-cues.json +49 -0
  133. package/docs/examples/agent-transcript.json +23 -0
  134. package/docs/examples/agent-transcript.srt +7 -0
  135. package/docs/examples/agent-transcript.vtt +7 -0
  136. package/docs/examples/image-animation.json +87 -0
  137. package/docs/examples/native-text-metric-badge.json +109 -0
  138. package/package.json +133 -100
  139. package/skills/visualfries/SKILL.md +328 -0
@@ -0,0 +1,1570 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { pathToFileURL, fileURLToPath } from 'node:url';
8
+ import { spawn } from 'node:child_process';
9
+ import { createHash } from 'node:crypto';
10
+ import {
11
+ applyAgentCueFile,
12
+ createCaptionScene,
13
+ createAgentCuePreset,
14
+ compileProductionPlan,
15
+ getAgentCatalog,
16
+ inspectScene,
17
+ mergeAgentCueFiles,
18
+ parseSubtitleText,
19
+ renderSceneLocally,
20
+ resolveAgentRenderPlan,
21
+ validateAgentCueFile
22
+ } from '../dist/agent/index.js';
23
+ import { SceneShape } from '../dist/schemas/scene/index.js';
24
+
25
+ const packageRequire = createRequire(import.meta.url);
26
+ const { version: VERSION } = packageRequire('../package.json');
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = path.dirname(__filename);
29
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
30
+
31
+ function help() {
32
+ console.log(`visualfries ${VERSION}
33
+
34
+ Usage:
35
+ visualfries validate <scene.json> [--strict-runtime-support] [--json]
36
+ visualfries inspect <scene.json> [--strict-runtime-support] [--json] [--screenshots --output <dir>]
37
+ visualfries explain <scene.json> --component <id> [--frame <n>] [--json]
38
+ visualfries parity <scene.json> --output <dir> [--frames 0,12,30] [--strict] [--json]
39
+ visualfries init <dir> [--video <video> --transcript <file>] [options]
40
+ visualfries caption-scene --video <video> --transcript <file> --output <scene.json> [options]
41
+ visualfries preset-cues --duration <seconds> --output <cues.json> [options]
42
+ visualfries validate-cues <cues.json> [--duration <seconds>] [--json]
43
+ visualfries apply-cues <scene.json> --cues <cues.json> --output <scene.json> [options]
44
+ visualfries compose --video <video> --transcript <file> --output <out.mp4|frames-dir> [options]
45
+ visualfries produce <production-plan.json> --output <out.mp4> [options]
46
+ visualfries qa <scene.json> --output <dir> [options]
47
+ visualfries render <scene.json> --output <out.mp4|frames-dir> [options]
48
+ visualfries catalog [--component <TYPE>] [--capabilities] [--json]
49
+ visualfries doctor [--json]
50
+
51
+ caption-scene options:
52
+ --preset <name> reels-center | reels-lower | podcast-clean | hidden-engine-center
53
+ --language <code> Default: en
54
+ --asset-id <id> Default: main-video
55
+ --duration <seconds> Defaults to transcript end time
56
+ --width <px> Default: 1080
57
+ --height <px> Default: 1920
58
+ --fps <number> Default: 30
59
+
60
+ init options:
61
+ --video <video> Optional input video path or URL
62
+ --transcript <file> Optional transcript JSON, SRT, or VTT path
63
+ --preset <name> Caption preset when video+transcript are provided
64
+ --cue-preset <name> Cue preset for generated cues.json. Default: hidden-engine-dynamic
65
+ --width <px> Default: 1080
66
+ --height <px> Default: 1920
67
+ --fps <number> Default: 30
68
+
69
+ apply-cues options:
70
+ --cues <json> Cue file with overlays, broll, and/or transitions arrays
71
+ --output <path> Output scene JSON path
72
+ --in-place Write back to the input scene path
73
+
74
+ preset-cues options:
75
+ --preset <name> hidden-engine-dynamic | captioned-clean
76
+ --duration <seconds> Required
77
+ --output <path> Output cue JSON path
78
+
79
+ compose options:
80
+ --video <video> Input video path or URL
81
+ --transcript <file> Transcript JSON, SRT, or VTT path
82
+ --output <path> MP4 output path, or frames directory with --frames-only
83
+ --scene-output <path> Optional scene JSON output path
84
+ --cues <json> Optional cue file to apply
85
+ --cue-preset <name> Optional starter cue preset to apply before --cues
86
+ --qa-output <dir> Optional sampled screenshot QA directory
87
+ --skip-duplicates Reuse identical deterministic frames on the final render
88
+
89
+ produce options:
90
+ --output <path> Final MP4 output path
91
+ --scene-output <path> Compiled editable scene JSON (default: <plan>.scene.json)
92
+ --qa-output <dir> Exact QA frames and acceptance.json output directory
93
+ --generated-assets <dir>
94
+ Generated freeze-frame assets (default: beside the plan)
95
+
96
+ render options:
97
+ --output <path> MP4 output path, or frames directory with --frames-only
98
+ --render-mode <mode> final | preview. Default: final
99
+ --engine <engine> auto | browser-preview | deterministic-local. Default: auto
100
+ --allow-browser-media-final
101
+ Force legacy browser media output for VIDEO/GIF final renders.
102
+ Use only for local experiments; it is not deterministic.
103
+ --frames-only Render PNG frame sequence without ffmpeg encode
104
+ --from-frame <n> Default: 0
105
+ --to-frame <n> Default: ceil(duration * fps)
106
+ --skip-duplicates Reuse identical deterministic frames when VisualFries marks them clean
107
+ --stream-encode Pipe frames directly into ffmpeg instead of writing a temporary sequence
108
+ --crf <number> H.264 CRF for MP4 output. Default: 18
109
+ --preset <name> ffmpeg x264 preset for MP4 output. Default: veryfast
110
+ --fps <number> Defaults to scene settings fps
111
+ --image-format <fmt> png | jpg. Default: png
112
+ --quality <number> Image quality for jpg. Default: 0.92
113
+ --keep-frames Keep temporary frames after MP4 encode
114
+ --audio <path|none> Override audio input. Defaults to local scene audio mix.
115
+
116
+ qa options:
117
+ --output <dir> QA directory. Writes inspect.json and frames/
118
+ --samples <n> Number of sampled frames. Default: 3
119
+ --image-format <fmt> png | jpg. Default: png
120
+
121
+ inspect screenshot options:
122
+ --screenshots Render sampled QA frames into --output
123
+ --output <dir> Screenshot directory. Default: <scene>.qa
124
+ --samples <n> Number of sampled frames. Default: 3
125
+ --image-format <fmt> png | jpg. Default: png
126
+
127
+ doctor checks:
128
+ ffmpeg, Vite, Svelte Vite plugin, Playwright, Chromium executable, temp dir
129
+
130
+ Notes:
131
+ This CLI is the agent-facing scene contract. It creates and validates VisualFries
132
+ scene JSON. Browser rendering is preview/QA for VIDEO/GIF scenes. Final VIDEO/GIF
133
+ output must go through the local deterministic renderer.
134
+ `);
135
+ }
136
+
137
+ function readFlag(args, name, fallback = undefined) {
138
+ const index = args.indexOf(name);
139
+ if (index === -1) return fallback;
140
+ return args[index + 1] ?? fallback;
141
+ }
142
+
143
+ function hasFlag(args, name) {
144
+ return args.includes(name);
145
+ }
146
+
147
+ function numberFlag(args, name, fallback = undefined) {
148
+ const raw = readFlag(args, name);
149
+ if (raw === undefined) return fallback;
150
+ const value = Number(raw);
151
+ if (!Number.isFinite(value)) {
152
+ throw new Error(`${name} must be a number.`);
153
+ }
154
+ return value;
155
+ }
156
+
157
+ function intFlag(args, name, fallback = undefined) {
158
+ const value = numberFlag(args, name, fallback);
159
+ if (value === undefined) return value;
160
+ return Math.trunc(value);
161
+ }
162
+
163
+ async function readJson(filePath) {
164
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
165
+ }
166
+
167
+ async function readTranscript(filePath) {
168
+ const ext = path.extname(filePath).toLowerCase();
169
+ if (ext === '.srt' || ext === '.vtt') {
170
+ return parseSubtitleText(await fs.readFile(filePath, 'utf8'), ext === '.vtt' ? 'vtt' : 'srt');
171
+ }
172
+ return readJson(filePath);
173
+ }
174
+
175
+ function normalizeMediaUrl(value) {
176
+ if (/^[a-z]+:\/\//i.test(value) || value.startsWith('file://')) return value;
177
+ return pathToFileURL(path.resolve(value)).toString();
178
+ }
179
+
180
+ async function writeJson(filePath, data) {
181
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
182
+ await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
183
+ }
184
+
185
+ async function importFromOptionalPaths(moduleName, envName) {
186
+ try {
187
+ return await import(moduleName);
188
+ } catch (initialError) {
189
+ const extraPaths = [process.env[envName], process.env.VISUALFRIES_NODE_MODULES].filter(Boolean);
190
+ for (const moduleDir of extraPaths) {
191
+ try {
192
+ const require = createRequire(import.meta.url);
193
+ const resolved = require.resolve(moduleName, { paths: [moduleDir] });
194
+ return await import(pathToFileURL(resolved).toString());
195
+ } catch {
196
+ // Try the next configured module path.
197
+ }
198
+ }
199
+
200
+ const hint =
201
+ moduleName === 'playwright'
202
+ ? 'Install playwright or set VISUALFRIES_PLAYWRIGHT_MODULES to a node_modules directory that contains it.'
203
+ : `Install ${moduleName} or set ${envName} to a node_modules directory that contains it.`;
204
+ throw new Error(`${moduleName} is required for rendering. ${hint}\n${initialError.message}`);
205
+ }
206
+ }
207
+
208
+ async function probeCommand(command, args = ['-version']) {
209
+ return await new Promise((resolve) => {
210
+ let stdout = '';
211
+ let stderr = '';
212
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
213
+ child.stdout.on('data', (chunk) => {
214
+ stdout += String(chunk);
215
+ });
216
+ child.stderr.on('data', (chunk) => {
217
+ stderr += String(chunk);
218
+ });
219
+ child.on('error', (error) => {
220
+ resolve({ ok: false, error: error.message });
221
+ });
222
+ child.on('close', (code) => {
223
+ resolve({
224
+ ok: code === 0,
225
+ code,
226
+ version: `${stdout}\n${stderr}`.split(/\r?\n/).find(Boolean)
227
+ });
228
+ });
229
+ });
230
+ }
231
+
232
+ function findBrowserExecutable() {
233
+ const candidates = [
234
+ process.env.VISUALFRIES_CHROMIUM_PATH,
235
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
236
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
237
+ path.join(
238
+ os.homedir(),
239
+ '.cache/puppeteer/chrome-headless-shell/mac_arm-131.0.6778.204/chrome-headless-shell-mac-arm64/chrome-headless-shell'
240
+ )
241
+ ].filter(Boolean);
242
+ return candidates.find((candidate) => existsSync(candidate));
243
+ }
244
+
245
+ function normalizeImageFormat(value) {
246
+ if (!['png', 'jpg', 'jpeg'].includes(value)) {
247
+ throw new Error('--image-format must be png, jpg, or jpeg.');
248
+ }
249
+ return value === 'jpeg' ? 'jpg' : value;
250
+ }
251
+
252
+ function frameRange(fromFrame, toFrame) {
253
+ const frames = [];
254
+ for (let frame = fromFrame; frame < toFrame; frame += 1) frames.push(frame);
255
+ return frames;
256
+ }
257
+
258
+ function sampledFrames({ duration, fps, samples }) {
259
+ const totalFrames = Math.max(1, Math.ceil(duration * fps));
260
+ const count = Math.max(1, Math.trunc(samples));
261
+ if (count === 1) return [0];
262
+ const lastFrame = Math.max(0, totalFrames - 1);
263
+ return Array.from({ length: count }, (_, index) =>
264
+ Math.round((lastFrame * index) / Math.max(1, count - 1))
265
+ );
266
+ }
267
+
268
+ function renderEncodingOptionsFromArgs(args, scene) {
269
+ return {
270
+ fps: numberFlag(args, '--fps', scene.settings.fps ?? 30),
271
+ imageFormat: normalizeImageFormat(readFlag(args, '--image-format', 'png')),
272
+ imageQuality: numberFlag(args, '--quality', 0.92)
273
+ };
274
+ }
275
+
276
+ function renderPlanOptionsFromArgs(args) {
277
+ const mode = readFlag(args, '--render-mode', 'final');
278
+ const engine = readFlag(args, '--engine', 'auto');
279
+ if (!['final', 'preview'].includes(mode)) {
280
+ throw new Error('--render-mode must be final or preview.');
281
+ }
282
+ if (!['auto', 'browser-preview', 'deterministic-local'].includes(engine)) {
283
+ throw new Error('--engine must be auto, browser-preview, or deterministic-local.');
284
+ }
285
+ return {
286
+ mode,
287
+ engine,
288
+ allowBrowserMediaFinal: hasFlag(args, '--allow-browser-media-final')
289
+ };
290
+ }
291
+
292
+ function assertBrowserRenderAllowed(scene, args) {
293
+ const plan = resolveAgentRenderPlan(scene, renderPlanOptionsFromArgs(args));
294
+ if (plan.blockers.length > 0) {
295
+ throw new Error(
296
+ [
297
+ 'VisualFries render blocked by render plan.',
298
+ `mode=${plan.mode}`,
299
+ `engine=${plan.engine}`,
300
+ `requiresDeterministicMedia=${plan.requiresDeterministicMedia}`,
301
+ ...plan.media.map(
302
+ (item) =>
303
+ `media ${item.type} ${item.componentId ?? 'unknown'}${item.source ? ` -> ${item.source}` : ''}: ${item.reason}`
304
+ ),
305
+ ...plan.blockers.map((blocker) => `blocker: ${blocker}`),
306
+ 'For quick QA, rerun with --render-mode preview. For a one-off legacy output, add --allow-browser-media-final.'
307
+ ].join('\n')
308
+ );
309
+ }
310
+
311
+ for (const warning of plan.warnings) {
312
+ console.warn(`WARN ${warning}`);
313
+ }
314
+
315
+ return plan;
316
+ }
317
+
318
+ function fullRenderOptionsFromArgs(args, scene, output) {
319
+ const { fps, imageFormat, imageQuality } = renderEncodingOptionsFromArgs(args, scene);
320
+ const renderPlan = resolveAgentRenderPlan(scene, renderPlanOptionsFromArgs(args));
321
+ const fromFrame = intFlag(args, '--from-frame', 0);
322
+ const toFrame = intFlag(args, '--to-frame', Math.ceil(scene.settings.duration * fps));
323
+ const framesOnly = hasFlag(args, '--frames-only');
324
+
325
+ return {
326
+ fromFrame,
327
+ toFrame,
328
+ renderOptions: {
329
+ scene,
330
+ output,
331
+ framesOnly,
332
+ frameIndices: frameRange(fromFrame, toFrame),
333
+ fps,
334
+ imageFormat,
335
+ imageQuality,
336
+ audioOverride: readFlag(args, '--audio'),
337
+ keepFrames: hasFlag(args, '--keep-frames') || framesOnly,
338
+ skipDuplicates: hasFlag(args, '--skip-duplicates'),
339
+ streamEncode: hasFlag(args, '--stream-encode'),
340
+ crf: intFlag(args, '--crf', 18),
341
+ preset: readFlag(args, '--preset', 'veryfast'),
342
+ renderPlan
343
+ }
344
+ };
345
+ }
346
+
347
+ async function renderSampledSceneFrames({ args, scene, output }) {
348
+ const { fps, imageFormat, imageQuality } = renderEncodingOptionsFromArgs(args, scene);
349
+ return await renderSceneWithBrowser({
350
+ scene,
351
+ output,
352
+ framesOnly: true,
353
+ frameIndices: sampledFrames({
354
+ duration: scene.settings.duration,
355
+ fps,
356
+ samples: intFlag(args, '--samples', 3)
357
+ }),
358
+ fps,
359
+ imageFormat,
360
+ imageQuality,
361
+ audioOverride: 'none',
362
+ keepFrames: true,
363
+ skipDuplicates: false
364
+ });
365
+ }
366
+
367
+ async function renderSceneWithBrowser({
368
+ scene,
369
+ output,
370
+ framesOnly,
371
+ frameIndices,
372
+ fps,
373
+ imageFormat,
374
+ imageQuality,
375
+ audioOverride,
376
+ keepFrames,
377
+ skipDuplicates = false,
378
+ streamEncode = false,
379
+ crf = 18,
380
+ preset = 'veryfast',
381
+ renderPlan
382
+ }) {
383
+ return await renderSceneLocally({
384
+ scene,
385
+ output,
386
+ framesOnly,
387
+ frameIndices,
388
+ fps,
389
+ imageFormat,
390
+ imageQuality,
391
+ audioOverride,
392
+ keepFrames,
393
+ skipDuplicates,
394
+ streamEncode,
395
+ crf,
396
+ preset,
397
+ renderPlan,
398
+ packageRoot: PACKAGE_ROOT,
399
+ modulePaths: {
400
+ vite: process.env.VISUALFRIES_VITE_MODULES,
401
+ svelteVitePlugin: process.env.VISUALFRIES_SVELTE_VITE_MODULES,
402
+ playwright: process.env.VISUALFRIES_PLAYWRIGHT_MODULES,
403
+ nodeModules: process.env.VISUALFRIES_NODE_MODULES
404
+ }
405
+ });
406
+ }
407
+
408
+ async function validateCommand(args) {
409
+ const filePath = args[0];
410
+ if (!filePath) throw new Error('Usage: visualfries validate <scene.json>');
411
+ const scene = await readJson(filePath);
412
+ const report = inspectScene(scene, {
413
+ strictRuntimeSupport: hasFlag(args, '--strict-runtime-support')
414
+ });
415
+ const output = {
416
+ ok: report.valid,
417
+ file: filePath,
418
+ schemaValid: report.schemaValid,
419
+ runtimeSupported: report.runtimeSupported,
420
+ issues: report.issues,
421
+ summary: report.summary
422
+ };
423
+ if (hasFlag(args, '--json')) {
424
+ console.log(JSON.stringify(output, null, 2));
425
+ } else if (report.valid) {
426
+ console.log(`OK ${filePath}`);
427
+ for (const issue of report.issues) {
428
+ console.log(`${issue.level.toUpperCase()} ${issue.type}: ${issue.message}`);
429
+ }
430
+ } else {
431
+ for (const issue of report.issues) {
432
+ console.error(`${issue.level.toUpperCase()} ${issue.type}: ${issue.message}`);
433
+ }
434
+ }
435
+ if (!report.valid) process.exitCode = 1;
436
+ }
437
+
438
+ async function initCommand(args) {
439
+ const targetDir = args[0];
440
+ if (!targetDir)
441
+ throw new Error('Usage: visualfries init <dir> [--video <video> --transcript <file>]');
442
+ const root = path.resolve(targetDir);
443
+ const assetsDir = path.join(root, 'assets');
444
+ const qaFramesDir = path.join(root, 'qa', 'frames');
445
+ await fs.mkdir(assetsDir, { recursive: true });
446
+ await fs.mkdir(qaFramesDir, { recursive: true });
447
+
448
+ const video = readFlag(args, '--video');
449
+ const transcriptPath = readFlag(args, '--transcript');
450
+ let scene;
451
+ if (video && transcriptPath) {
452
+ scene = createCaptionScene({
453
+ id: readFlag(args, '--id', 'agent-video-scene'),
454
+ video: {
455
+ url: normalizeMediaUrl(video),
456
+ assetId: readFlag(args, '--asset-id', 'main-video'),
457
+ duration: numberFlag(args, '--duration')
458
+ },
459
+ transcript: await readTranscript(transcriptPath),
460
+ preset: readFlag(args, '--preset', 'hidden-engine-center'),
461
+ language: readFlag(args, '--language', 'en'),
462
+ width: numberFlag(args, '--width', 1080),
463
+ height: numberFlag(args, '--height', 1920),
464
+ fps: numberFlag(args, '--fps', 30),
465
+ duration: numberFlag(args, '--duration')
466
+ });
467
+ } else {
468
+ scene = SceneShape.parse({
469
+ id: readFlag(args, '--id', 'agent-video-scene'),
470
+ name: 'Agent Video Scene',
471
+ settings: {
472
+ width: numberFlag(args, '--width', 1080),
473
+ height: numberFlag(args, '--height', 1920),
474
+ duration: numberFlag(args, '--duration', 5),
475
+ fps: numberFlag(args, '--fps', 30),
476
+ backgroundColor: '#000000'
477
+ },
478
+ assets: [],
479
+ layers: []
480
+ });
481
+ }
482
+
483
+ const scenePath = path.join(root, 'scene.json');
484
+ const cuesPath = path.join(root, 'cues.json');
485
+ const cuePreset = readFlag(args, '--cue-preset', 'hidden-engine-dynamic');
486
+ const cues = createAgentCuePreset({
487
+ preset: cuePreset,
488
+ duration: scene.settings.duration
489
+ });
490
+ await writeJson(scenePath, scene);
491
+ await writeJson(cuesPath, cues);
492
+ await writeJson(path.join(root, 'qa', 'inspect.json'), inspectScene(scene));
493
+ await writeJson(path.join(root, 'transcript.example.json'), {
494
+ segments: [
495
+ {
496
+ text: 'Replace this transcript with timed words or segments.',
497
+ start: 0,
498
+ end: 2,
499
+ words: [
500
+ { text: 'Replace', start: 0, end: 0.3 },
501
+ { text: 'this', start: 0.3, end: 0.5 },
502
+ { text: 'transcript', start: 0.5, end: 1.1 }
503
+ ]
504
+ }
505
+ ]
506
+ });
507
+ await fs.writeFile(
508
+ path.join(root, 'notes.md'),
509
+ `# VisualFries Agent Video
510
+
511
+ ## Commands
512
+
513
+ \`\`\`bash
514
+ visualfries doctor --json
515
+ visualfries compose --video ./input.mp4 --transcript ./captions.srt --cue-preset hidden-engine-dynamic --cues ./cues.json --scene-output ./scene.json --qa-output ./qa/frames --output ./out.mp4
516
+ \`\`\`
517
+
518
+ ## Debug Commands
519
+
520
+ \`\`\`bash
521
+ visualfries validate ./scene.json
522
+ visualfries qa ./scene.json --output ./qa
523
+ visualfries apply-cues ./scene.json --cues ./cues.json --output ./scene.with-cues.json
524
+ visualfries render ./scene.with-cues.json --output ./out.mp4
525
+ \`\`\`
526
+
527
+ ## Agent Notes
528
+
529
+ - Keep source assets in ./assets.
530
+ - Put sampled QA frames in ./qa/frames.
531
+ - Keep scene.json as the source of truth.
532
+ - Keep cues.json as the visual timeline map.
533
+ - Use visualfries apply-cues or visualfries/agent helpers for captions, b-roll, overlays, and transitions.
534
+ `,
535
+ 'utf8'
536
+ );
537
+
538
+ console.log(
539
+ JSON.stringify(
540
+ {
541
+ ok: true,
542
+ dir: root,
543
+ scene: scenePath,
544
+ cues: cuesPath,
545
+ assets: assetsDir,
546
+ qaFrames: qaFramesDir
547
+ },
548
+ null,
549
+ 2
550
+ )
551
+ );
552
+ }
553
+
554
+ async function inspectCommand(args) {
555
+ const filePath = args[0];
556
+ if (!filePath) throw new Error('Usage: visualfries inspect <scene.json>');
557
+ const rawScene = await readJson(filePath);
558
+ const report = inspectScene(rawScene, {
559
+ strictRuntimeSupport: hasFlag(args, '--strict-runtime-support')
560
+ });
561
+ if (!report.schemaValid) {
562
+ if (hasFlag(args, '--json')) console.log(JSON.stringify(report, null, 2));
563
+ else console.error(report.issues.map((issue) => issue.message).join('\n'));
564
+ process.exitCode = 1;
565
+ return;
566
+ }
567
+ const scene = SceneShape.parse(rawScene);
568
+ let screenshots;
569
+ if (hasFlag(args, '--screenshots')) {
570
+ const output =
571
+ readFlag(args, '--output') ??
572
+ path.resolve(`${path.basename(filePath, path.extname(filePath))}.qa`);
573
+ screenshots = await renderSampledSceneFrames({
574
+ args,
575
+ output,
576
+ scene
577
+ });
578
+ }
579
+ const output = screenshots ? { ...report, screenshots } : report;
580
+ if (hasFlag(args, '--json')) {
581
+ console.log(JSON.stringify(output, null, 2));
582
+ return;
583
+ }
584
+
585
+ console.log(`${report.valid ? 'OK' : 'FAIL'} ${filePath}`);
586
+ console.log(
587
+ `${report.summary.width}x${report.summary.height}, ${report.summary.duration}s, ${report.summary.layers} layers, ${report.summary.components} components`
588
+ );
589
+ for (const issue of report.issues) {
590
+ console.log(`${issue.level.toUpperCase()} ${issue.type}: ${issue.message}`);
591
+ }
592
+ if (screenshots) {
593
+ console.log(`SCREENSHOTS ${screenshots.frames.count} frames -> ${screenshots.frames.dir}`);
594
+ }
595
+ if (!report.valid) process.exitCode = 1;
596
+ }
597
+
598
+ async function qaCommand(args) {
599
+ const scenePath = args[0];
600
+ const outputDir = readFlag(args, '--output');
601
+ if (!scenePath || !outputDir) {
602
+ throw new Error('Usage: visualfries qa <scene.json> --output <dir> [--samples <n>]');
603
+ }
604
+
605
+ const scene = SceneShape.parse(await readJson(scenePath));
606
+ const report = inspectScene(scene);
607
+ const root = path.resolve(outputDir);
608
+ const framesDir = path.join(root, 'frames');
609
+ await fs.mkdir(framesDir, { recursive: true });
610
+ await writeJson(path.join(root, 'inspect.json'), report);
611
+
612
+ let screenshots;
613
+ if (report.valid) {
614
+ screenshots = await renderSampledSceneFrames({
615
+ args,
616
+ output: framesDir,
617
+ scene
618
+ });
619
+ await writeJson(path.join(root, 'screenshots.json'), screenshots);
620
+ }
621
+
622
+ const result = {
623
+ ok: report.valid,
624
+ output: root,
625
+ inspect: path.join(root, 'inspect.json'),
626
+ frames: screenshots?.frames,
627
+ issues: report.issues
628
+ };
629
+ console.log(JSON.stringify(result, null, 2));
630
+ if (!report.valid) process.exitCode = 1;
631
+ }
632
+
633
+ async function captionSceneCommand(args) {
634
+ const video = readFlag(args, '--video');
635
+ const transcriptPath = readFlag(args, '--transcript');
636
+ const output = readFlag(args, '--output');
637
+ if (!video || !transcriptPath || !output) {
638
+ throw new Error(
639
+ 'Usage: visualfries caption-scene --video <video> --transcript <file> --output <scene.json>'
640
+ );
641
+ }
642
+
643
+ const scene = createCaptionScene({
644
+ id: readFlag(args, '--id', 'agent-caption-scene'),
645
+ video: {
646
+ url: normalizeMediaUrl(video),
647
+ assetId: readFlag(args, '--asset-id', 'main-video'),
648
+ duration: numberFlag(args, '--duration')
649
+ },
650
+ transcript: await readTranscript(transcriptPath),
651
+ preset: readFlag(args, '--preset', 'reels-center'),
652
+ language: readFlag(args, '--language', 'en'),
653
+ width: numberFlag(args, '--width', 1080),
654
+ height: numberFlag(args, '--height', 1920),
655
+ fps: numberFlag(args, '--fps', 30),
656
+ duration: numberFlag(args, '--duration')
657
+ });
658
+
659
+ await writeJson(output, scene);
660
+ const report = inspectScene(scene);
661
+ console.log(
662
+ JSON.stringify(
663
+ {
664
+ ok: report.valid,
665
+ output,
666
+ scene: {
667
+ id: scene.id,
668
+ duration: scene.settings.duration,
669
+ width: scene.settings.width,
670
+ height: scene.settings.height
671
+ },
672
+ issues: report.issues
673
+ },
674
+ null,
675
+ 2
676
+ )
677
+ );
678
+ if (!report.valid) process.exitCode = 1;
679
+ }
680
+
681
+ async function applyCuesCommand(args) {
682
+ const scenePath = args[0];
683
+ const cuesPath = readFlag(args, '--cues');
684
+ const output = hasFlag(args, '--in-place') ? scenePath : readFlag(args, '--output');
685
+ if (!scenePath || !cuesPath || !output) {
686
+ throw new Error(
687
+ 'Usage: visualfries apply-cues <scene.json> --cues <cues.json> --output <scene.json> [--in-place]'
688
+ );
689
+ }
690
+
691
+ const result = applyAgentCueFile({
692
+ scene: SceneShape.parse(await readJson(scenePath)),
693
+ cues: await readJson(cuesPath),
694
+ cueFilePath: cuesPath
695
+ });
696
+ const parsed = SceneShape.parse(result.scene);
697
+ await writeJson(output, parsed);
698
+ const report = inspectScene(parsed);
699
+ console.log(
700
+ JSON.stringify(
701
+ {
702
+ ok: report.valid,
703
+ output: path.resolve(output),
704
+ applied: result.applied,
705
+ summary: report.summary,
706
+ issues: report.issues
707
+ },
708
+ null,
709
+ 2
710
+ )
711
+ );
712
+ if (!report.valid) process.exitCode = 1;
713
+ }
714
+
715
+ async function validateCuesCommand(args) {
716
+ const cuesPath = args[0];
717
+ if (!cuesPath) {
718
+ throw new Error('Usage: visualfries validate-cues <cues.json> [--duration <seconds>] [--json]');
719
+ }
720
+
721
+ const report = validateAgentCueFile({
722
+ cues: await readJson(cuesPath),
723
+ cueFilePath: cuesPath,
724
+ duration: numberFlag(args, '--duration')
725
+ });
726
+
727
+ if (hasFlag(args, '--json')) {
728
+ console.log(JSON.stringify(report, null, 2));
729
+ } else {
730
+ console.log(`${report.valid ? 'OK' : 'FAIL'} ${cuesPath}`);
731
+ console.log(
732
+ `${report.summary.broll} b-roll, ${report.summary.overlays} overlays, ${report.summary.transitions} transitions`
733
+ );
734
+ for (const issue of report.issues) {
735
+ console.log(`${issue.level.toUpperCase()} ${issue.path}: ${issue.message}`);
736
+ }
737
+ }
738
+ if (!report.valid) process.exitCode = 1;
739
+ }
740
+
741
+ async function presetCuesCommand(args) {
742
+ const output = readFlag(args, '--output');
743
+ const duration = numberFlag(args, '--duration');
744
+ if (!output || duration === undefined) {
745
+ throw new Error(
746
+ 'Usage: visualfries preset-cues --duration <seconds> --output <cues.json> [--preset <name>]'
747
+ );
748
+ }
749
+
750
+ const cues = createAgentCuePreset({
751
+ preset: readFlag(args, '--preset', 'hidden-engine-dynamic'),
752
+ duration
753
+ });
754
+ await writeJson(output, cues);
755
+ console.log(
756
+ JSON.stringify(
757
+ {
758
+ ok: true,
759
+ output: path.resolve(output),
760
+ preset: readFlag(args, '--preset', 'hidden-engine-dynamic'),
761
+ duration,
762
+ counts: {
763
+ broll: cues.broll?.length ?? 0,
764
+ overlays: cues.overlays?.length ?? 0,
765
+ transitions: cues.transitions?.length ?? 0
766
+ }
767
+ },
768
+ null,
769
+ 2
770
+ )
771
+ );
772
+ }
773
+
774
+ function captionSceneInputFromArgs(args) {
775
+ const video = readFlag(args, '--video');
776
+ const transcriptPath = readFlag(args, '--transcript');
777
+ if (!video || !transcriptPath) {
778
+ throw new Error('--video and --transcript are required.');
779
+ }
780
+
781
+ return {
782
+ video,
783
+ transcriptPath,
784
+ options: {
785
+ id: readFlag(args, '--id', 'agent-caption-scene'),
786
+ video: {
787
+ url: normalizeMediaUrl(video),
788
+ assetId: readFlag(args, '--asset-id', 'main-video'),
789
+ duration: numberFlag(args, '--duration')
790
+ },
791
+ preset: readFlag(args, '--preset', 'reels-center'),
792
+ language: readFlag(args, '--language', 'en'),
793
+ width: numberFlag(args, '--width', 1080),
794
+ height: numberFlag(args, '--height', 1920),
795
+ fps: numberFlag(args, '--fps', 30),
796
+ duration: numberFlag(args, '--duration')
797
+ }
798
+ };
799
+ }
800
+
801
+ async function composeSceneFromArgs(args) {
802
+ const { transcriptPath, options } = captionSceneInputFromArgs(args);
803
+ let scene = createCaptionScene({
804
+ ...options,
805
+ transcript: await readTranscript(transcriptPath)
806
+ });
807
+
808
+ const cueFiles = [];
809
+ const cuePreset = readFlag(args, '--cue-preset');
810
+ if (cuePreset) {
811
+ cueFiles.push(createAgentCuePreset({ preset: cuePreset, duration: scene.settings.duration }));
812
+ }
813
+
814
+ const cuesPath = readFlag(args, '--cues');
815
+ if (cuesPath) {
816
+ cueFiles.push(await readJson(cuesPath));
817
+ }
818
+
819
+ if (cueFiles.length > 0) {
820
+ scene = applyAgentCueFile({
821
+ scene,
822
+ cues: mergeAgentCueFiles(...cueFiles),
823
+ cueFilePath: cuesPath
824
+ }).scene;
825
+ }
826
+
827
+ return SceneShape.parse(scene);
828
+ }
829
+
830
+ async function composeCommand(args) {
831
+ const output = readFlag(args, '--output');
832
+ if (!output) {
833
+ throw new Error(
834
+ 'Usage: visualfries compose --video <video> --transcript <file> --output <out.mp4|frames-dir> [options]'
835
+ );
836
+ }
837
+
838
+ const scene = await composeSceneFromArgs(args);
839
+ const sceneOutput = readFlag(args, '--scene-output');
840
+ if (sceneOutput) {
841
+ await writeJson(sceneOutput, scene);
842
+ }
843
+
844
+ const report = inspectScene(scene);
845
+ if (!report.valid) {
846
+ throw new Error(`Scene is not renderable: ${JSON.stringify(report.issues, null, 2)}`);
847
+ }
848
+
849
+ const renderPlan = assertBrowserRenderAllowed(scene, args);
850
+
851
+ let qa;
852
+ const qaOutput = readFlag(args, '--qa-output');
853
+ if (qaOutput) {
854
+ qa = await renderSampledSceneFrames({
855
+ args,
856
+ output: qaOutput,
857
+ scene
858
+ });
859
+ }
860
+
861
+ const { fromFrame, toFrame, renderOptions } = fullRenderOptionsFromArgs(args, scene, output);
862
+ const result = await renderSceneWithBrowser(renderOptions);
863
+
864
+ console.log(
865
+ JSON.stringify(
866
+ {
867
+ ok: true,
868
+ scene: {
869
+ id: scene.id,
870
+ duration: scene.settings.duration,
871
+ width: scene.settings.width,
872
+ height: scene.settings.height
873
+ },
874
+ sceneOutput: sceneOutput ? path.resolve(sceneOutput) : undefined,
875
+ renderPlan,
876
+ qa,
877
+ render: {
878
+ ...result,
879
+ frames: {
880
+ ...result.frames,
881
+ fromFrame,
882
+ toFrame
883
+ }
884
+ }
885
+ },
886
+ null,
887
+ 2
888
+ )
889
+ );
890
+ }
891
+
892
+ async function detectLeadingSilence(filePath) {
893
+ return await new Promise((resolve, reject) => {
894
+ const child = spawn(
895
+ process.env.FFMPEG_PATH || 'ffmpeg',
896
+ [
897
+ '-hide_banner',
898
+ '-i',
899
+ filePath,
900
+ '-af',
901
+ 'silencedetect=noise=-45dB:d=0.01',
902
+ '-f',
903
+ 'null',
904
+ '-'
905
+ ],
906
+ { stdio: ['ignore', 'ignore', 'pipe'] }
907
+ );
908
+ let stderr = '';
909
+ child.stderr.on('data', (chunk) => {
910
+ stderr += String(chunk);
911
+ });
912
+ child.on('error', reject);
913
+ child.on('close', (code) => {
914
+ if (code !== 0) return reject(new Error(`Audio QA failed for ${filePath}.`));
915
+ const startsAtZero = /silence_start:\s*-?0(?:\.0+)?\b/.test(stderr);
916
+ const match = stderr.match(/silence_end:\s*([0-9.]+)/);
917
+ resolve(startsAtZero && match ? Number(match[1]) : 0);
918
+ });
919
+ });
920
+ }
921
+
922
+ async function extractQaFrames(filePath, times, outputDir) {
923
+ await fs.mkdir(outputDir, { recursive: true });
924
+ const items = [];
925
+ for (const [index, time] of times.entries()) {
926
+ const output = path.join(
927
+ outputDir,
928
+ `qa-${String(index + 1).padStart(2, '0')}-${time.toFixed(2).replace('.', '_')}s.png`
929
+ );
930
+ await new Promise((resolve, reject) => {
931
+ const child = spawn(
932
+ process.env.FFMPEG_PATH || 'ffmpeg',
933
+ ['-y', '-i', filePath, '-ss', time.toFixed(3), '-frames:v', '1', '-c:v', 'png', output],
934
+ { stdio: ['ignore', 'ignore', 'pipe'] }
935
+ );
936
+ let stderr = '';
937
+ child.stderr.on('data', (chunk) => {
938
+ stderr = (stderr + String(chunk)).slice(-2000);
939
+ });
940
+ child.on('error', reject);
941
+ child.on('close', (code) =>
942
+ code === 0
943
+ ? resolve()
944
+ : reject(new Error(`QA frame extraction failed (${code}): ${stderr}`))
945
+ );
946
+ });
947
+ items.push({ time, output });
948
+ }
949
+ return items;
950
+ }
951
+
952
+ function productionFrameBoundaries(plan) {
953
+ const fps = plan.settings.fps;
954
+ const times = [0, plan.settings.duration];
955
+ for (const beat of plan.beats) {
956
+ times.push(beat.start, beat.end);
957
+ for (const media of beat.media) {
958
+ times.push(media.start, media.end);
959
+ if (media.freezeAt !== undefined) {
960
+ times.push(media.start + (media.freezeAt - (media.sourceStart ?? 0)));
961
+ }
962
+ }
963
+ for (const overlay of beat.overlays) times.push(overlay.start, overlay.end);
964
+ }
965
+ for (const transition of plan.transitions) {
966
+ const duration = transition.duration ?? 0.26;
967
+ times.push(transition.time - duration / 2, transition.time + duration / 2);
968
+ }
969
+ return [
970
+ ...new Set(
971
+ times.map((time) =>
972
+ Math.max(0, Math.min(Math.ceil(plan.settings.duration * fps), Math.round(time * fps)))
973
+ )
974
+ )
975
+ ]
976
+ .sort((a, b) => a - b)
977
+ .filter((frame, index, frames) => index === 0 || frame > frames[index - 1]);
978
+ }
979
+
980
+ async function concatMp4Segments(segments, output) {
981
+ const listPath = path.join(path.dirname(segments[0]), 'concat.txt');
982
+ await fs.writeFile(
983
+ listPath,
984
+ `${segments.map((segment) => `file '${segment.replaceAll("'", "'\\''")}'`).join('\n')}\n`,
985
+ 'utf8'
986
+ );
987
+ await new Promise((resolve, reject) => {
988
+ const child = spawn(
989
+ process.env.FFMPEG_PATH || 'ffmpeg',
990
+ [
991
+ '-y',
992
+ '-f',
993
+ 'concat',
994
+ '-safe',
995
+ '0',
996
+ '-i',
997
+ listPath,
998
+ '-c:v',
999
+ 'libx264',
1000
+ '-crf',
1001
+ '18',
1002
+ '-preset',
1003
+ 'veryfast',
1004
+ '-pix_fmt',
1005
+ 'yuv420p',
1006
+ '-c:a',
1007
+ 'aac',
1008
+ '-b:a',
1009
+ '192k',
1010
+ '-movflags',
1011
+ '+faststart',
1012
+ output
1013
+ ],
1014
+ { stdio: ['ignore', 'ignore', 'pipe'] }
1015
+ );
1016
+ let stderr = '';
1017
+ child.stderr.on('data', (chunk) => {
1018
+ stderr = (stderr + String(chunk)).slice(-4000);
1019
+ });
1020
+ child.on('error', reject);
1021
+ child.on('close', (code) =>
1022
+ code === 0
1023
+ ? resolve()
1024
+ : reject(new Error(`Production segment concat failed (${code}): ${stderr}`))
1025
+ );
1026
+ });
1027
+ }
1028
+
1029
+ async function produceCommand(args) {
1030
+ const planPath = args[0];
1031
+ const output = readFlag(args, '--output');
1032
+ if (!planPath || !output) {
1033
+ throw new Error(
1034
+ 'Usage: visualfries produce <production-plan.json> --output <out.mp4> [options]'
1035
+ );
1036
+ }
1037
+ const compiled = await compileProductionPlan({
1038
+ plan: await readJson(planPath),
1039
+ planPath,
1040
+ generatedAssetsDir: readFlag(args, '--generated-assets')
1041
+ });
1042
+ const sceneOutput = readFlag(
1043
+ args,
1044
+ '--scene-output',
1045
+ path.resolve(
1046
+ path.dirname(planPath),
1047
+ `${path.basename(planPath, path.extname(planPath))}.scene.json`
1048
+ )
1049
+ );
1050
+ await writeJson(sceneOutput, compiled.scene);
1051
+ const inspection = inspectScene(compiled.scene);
1052
+ if (!inspection.valid)
1053
+ throw new Error(`Compiled scene is not renderable: ${JSON.stringify(inspection.issues)}`);
1054
+
1055
+ const qaRoot = readFlag(args, '--qa-output');
1056
+ const renderPlan = assertBrowserRenderAllowed(compiled.scene, args);
1057
+ const { renderOptions } = fullRenderOptionsFromArgs(args, compiled.scene, output);
1058
+ const segmentRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'visualfries-production-'));
1059
+ let render;
1060
+ try {
1061
+ const boundaries = productionFrameBoundaries(compiled.plan);
1062
+ const segments = [];
1063
+ const results = [];
1064
+ for (let index = 0; index < boundaries.length - 1; index += 1) {
1065
+ const fromFrame = boundaries[index];
1066
+ const toFrame = boundaries[index + 1];
1067
+ if (toFrame <= fromFrame) continue;
1068
+ const segment = path.join(segmentRoot, `segment-${String(index + 1).padStart(3, '0')}.mp4`);
1069
+ results.push(
1070
+ await renderSceneWithBrowser({
1071
+ ...renderOptions,
1072
+ output: segment,
1073
+ frameIndices: frameRange(fromFrame, toFrame),
1074
+ streamEncode: true
1075
+ })
1076
+ );
1077
+ segments.push(segment);
1078
+ }
1079
+ await concatMp4Segments(segments, path.resolve(output));
1080
+ render = {
1081
+ ok: true,
1082
+ mode: 'timeline-segmented',
1083
+ segments: segments.length,
1084
+ boundaries,
1085
+ frames: results.reduce((sum, result) => sum + result.frames.count, 0),
1086
+ audioSources: Math.max(...results.map((result) => result.audio.selectedSources), 0)
1087
+ };
1088
+ } finally {
1089
+ await fs.rm(segmentRoot, { recursive: true, force: true });
1090
+ }
1091
+ const qa = qaRoot
1092
+ ? await extractQaFrames(
1093
+ path.resolve(output),
1094
+ compiled.plan.qa.framesAt,
1095
+ path.join(path.resolve(qaRoot), 'frames')
1096
+ )
1097
+ : undefined;
1098
+ const leadingSilence = await detectLeadingSilence(path.resolve(output));
1099
+ const limit = compiled.plan.qa.maxLeadingSilence;
1100
+ const acceptance = {
1101
+ ok: limit === undefined || leadingSilence <= limit,
1102
+ leadingSilence,
1103
+ maxLeadingSilence: limit,
1104
+ requiredText: compiled.plan.qa.requiredText,
1105
+ qaFramesAt: compiled.plan.qa.framesAt,
1106
+ generatedAssets: compiled.generatedAssets,
1107
+ inspection
1108
+ };
1109
+ if (qaRoot) await writeJson(path.join(path.resolve(qaRoot), 'acceptance.json'), acceptance);
1110
+ if (!acceptance.ok) {
1111
+ throw new Error(
1112
+ `Production QA failed: leading silence ${leadingSilence.toFixed(3)}s exceeds ${limit}s.`
1113
+ );
1114
+ }
1115
+ console.log(
1116
+ JSON.stringify(
1117
+ {
1118
+ ok: true,
1119
+ output: path.resolve(output),
1120
+ sceneOutput: path.resolve(sceneOutput),
1121
+ renderPlan,
1122
+ render,
1123
+ qa,
1124
+ acceptance
1125
+ },
1126
+ null,
1127
+ 2
1128
+ )
1129
+ );
1130
+ }
1131
+
1132
+ async function checkOptionalModule(moduleName, envName) {
1133
+ try {
1134
+ await importFromOptionalPaths(moduleName, envName);
1135
+ return { ok: true };
1136
+ } catch (error) {
1137
+ return { ok: false, error: error?.message || String(error) };
1138
+ }
1139
+ }
1140
+
1141
+ async function doctorCommand(args) {
1142
+ const tmpParent =
1143
+ process.env.VISUALFRIES_TMPDIR || (existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir());
1144
+ const checks = {
1145
+ node: {
1146
+ ok: true,
1147
+ version: process.version
1148
+ },
1149
+ ffmpeg: await probeCommand('ffmpeg', ['-version']),
1150
+ vite: await checkOptionalModule('vite', 'VISUALFRIES_VITE_MODULES'),
1151
+ svelteVitePlugin: await checkOptionalModule(
1152
+ '@sveltejs/vite-plugin-svelte',
1153
+ 'VISUALFRIES_SVELTE_VITE_MODULES'
1154
+ ),
1155
+ playwright: await checkOptionalModule('playwright', 'VISUALFRIES_PLAYWRIGHT_MODULES'),
1156
+ chromiumExecutable: (() => {
1157
+ const executablePath = findBrowserExecutable();
1158
+ return executablePath
1159
+ ? { ok: true, path: executablePath }
1160
+ : {
1161
+ ok: false,
1162
+ error:
1163
+ 'No Chromium executable found. Set VISUALFRIES_CHROMIUM_PATH or PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH.'
1164
+ };
1165
+ })(),
1166
+ tempDir: {
1167
+ ok: existsSync(tmpParent),
1168
+ path: tmpParent
1169
+ }
1170
+ };
1171
+ const ok = Object.values(checks).every((check) => check.ok);
1172
+ const report = {
1173
+ ok,
1174
+ version: VERSION,
1175
+ packageRoot: PACKAGE_ROOT,
1176
+ env: {
1177
+ VISUALFRIES_NODE_MODULES: process.env.VISUALFRIES_NODE_MODULES,
1178
+ VISUALFRIES_TMPDIR: process.env.VISUALFRIES_TMPDIR,
1179
+ VISUALFRIES_CHROMIUM_PATH: process.env.VISUALFRIES_CHROMIUM_PATH,
1180
+ PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
1181
+ },
1182
+ checks
1183
+ };
1184
+
1185
+ if (hasFlag(args, '--json')) {
1186
+ console.log(JSON.stringify(report, null, 2));
1187
+ } else {
1188
+ console.log(`${ok ? 'OK' : 'FAIL'} visualfries doctor`);
1189
+ for (const [name, check] of Object.entries(checks)) {
1190
+ const detail = check.path ?? check.version ?? check.error ?? '';
1191
+ console.log(`${check.ok ? 'OK' : 'FAIL'} ${name}${detail ? ` - ${detail}` : ''}`);
1192
+ }
1193
+ }
1194
+ if (!ok) process.exitCode = 1;
1195
+ }
1196
+
1197
+ async function catalogCommand(args) {
1198
+ const requestedComponent = readFlag(args, '--component');
1199
+ const catalog = getAgentCatalog(
1200
+ requestedComponent ? { component: requestedComponent.toUpperCase() } : undefined
1201
+ );
1202
+ if (hasFlag(args, '--json')) {
1203
+ console.log(JSON.stringify(catalog, null, 2));
1204
+ return;
1205
+ }
1206
+
1207
+ console.log('VisualFries Agent Catalog');
1208
+ for (const [key, value] of Object.entries(catalog)) {
1209
+ if (Array.isArray(value)) {
1210
+ const printable = value.map((item) =>
1211
+ typeof item === 'object' && item && 'type' in item ? item.type : String(item)
1212
+ );
1213
+ console.log(`${key}: ${printable.join(', ')}`);
1214
+ }
1215
+ }
1216
+ console.log(`routine: ${catalog.recommendedWorkflow.routine}`);
1217
+ }
1218
+
1219
+ async function explainCommand(args) {
1220
+ const scenePath = args[0];
1221
+ const componentId = readFlag(args, '--component');
1222
+ if (!scenePath || !componentId) {
1223
+ throw new Error('Usage: visualfries explain <scene.json> --component <id> [--frame <n>]');
1224
+ }
1225
+ const scene = SceneShape.parse(await readJson(scenePath));
1226
+ const fps = scene.settings.fps ?? 30;
1227
+ const frame = numberFlag(args, '--frame', 0);
1228
+ const componentReport = inspectScene(scene).components.find((item) => item.id === componentId);
1229
+ if (!componentReport) throw new Error(`Component not found: ${componentId}`);
1230
+ const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'visualfries-explain-'));
1231
+ try {
1232
+ const result = await renderSceneLocally({
1233
+ scene,
1234
+ output: outputDir,
1235
+ framesOnly: true,
1236
+ frameIndices: [frame],
1237
+ fps,
1238
+ keepFrames: false,
1239
+ explain: [{ componentId, frame }],
1240
+ packageRoot: PACKAGE_ROOT,
1241
+ modulePaths: {
1242
+ vite: process.env.VISUALFRIES_VITE_MODULES,
1243
+ svelteVitePlugin: process.env.VISUALFRIES_SVELTE_VITE_MODULES,
1244
+ playwright: process.env.VISUALFRIES_PLAYWRIGHT_MODULES,
1245
+ nodeModules: process.env.VISUALFRIES_NODE_MODULES
1246
+ }
1247
+ });
1248
+ const output = {
1249
+ frame,
1250
+ fps,
1251
+ capability: componentReport.capability,
1252
+ state: result.explanations?.[0] ?? null
1253
+ };
1254
+ console.log(JSON.stringify(output, null, 2));
1255
+ } finally {
1256
+ await fs.rm(outputDir, { recursive: true, force: true });
1257
+ }
1258
+ }
1259
+
1260
+ async function compareFrameSsim(previewPath, finalPath, { semantic = false, crop } = {}) {
1261
+ return await new Promise((resolve, reject) => {
1262
+ let stderr = '';
1263
+ const semanticSigma = crop?.semanticSigma ?? 1;
1264
+ const filter = crop
1265
+ ? `[0:v]crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}${semantic ? `,gblur=sigma=${semanticSigma}` : ''}[a];[1:v]crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}${semantic ? `,gblur=sigma=${semanticSigma}` : ''}[b];[a][b]ssim`
1266
+ : semantic
1267
+ ? '[0:v]gblur=sigma=1[a];[1:v]gblur=sigma=1[b];[a][b]ssim'
1268
+ : 'ssim';
1269
+ const child = spawn(process.env.FFMPEG_PATH || 'ffmpeg', [
1270
+ '-i',
1271
+ previewPath,
1272
+ '-i',
1273
+ finalPath,
1274
+ '-lavfi',
1275
+ filter,
1276
+ '-f',
1277
+ 'null',
1278
+ '-'
1279
+ ]);
1280
+ child.stderr.on('data', (chunk) => (stderr += String(chunk)));
1281
+ child.on('error', reject);
1282
+ child.on('close', (code) => {
1283
+ if (code !== 0) return reject(new Error(`ffmpeg SSIM exited with code ${code}`));
1284
+ const match = stderr.match(/All:([0-9.]+)/);
1285
+ if (!match) return reject(new Error('ffmpeg did not report an SSIM All value'));
1286
+ resolve(Number(match[1]));
1287
+ });
1288
+ });
1289
+ }
1290
+
1291
+ async function sha256(filePath) {
1292
+ return createHash('sha256')
1293
+ .update(await fs.readFile(filePath))
1294
+ .digest('hex');
1295
+ }
1296
+
1297
+ async function writeParityDiffRow(previewPath, finalPath, outputPath) {
1298
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
1299
+ await new Promise((resolve, reject) => {
1300
+ const child = spawn(process.env.FFMPEG_PATH || 'ffmpeg', [
1301
+ '-y',
1302
+ '-i',
1303
+ previewPath,
1304
+ '-i',
1305
+ finalPath,
1306
+ '-filter_complex',
1307
+ '[0:v][1:v]blend=all_mode=difference,lutrgb=r=val*4:g=val*4:b=val*4[diff];[0:v][1:v][diff]hstack=inputs=3[out]',
1308
+ '-map',
1309
+ '[out]',
1310
+ outputPath
1311
+ ]);
1312
+ let stderr = '';
1313
+ child.stderr.on('data', (chunk) => (stderr += String(chunk)));
1314
+ child.on('error', reject);
1315
+ child.on('close', (code) =>
1316
+ code === 0 ? resolve() : reject(new Error(`ffmpeg parity diff exited ${code}: ${stderr}`))
1317
+ );
1318
+ });
1319
+ }
1320
+
1321
+ async function writeParityContactSheet(rows, outputPath) {
1322
+ if (!rows.length) return;
1323
+ await new Promise((resolve, reject) => {
1324
+ const args = ['-y'];
1325
+ for (const row of rows) args.push('-i', row);
1326
+ args.push('-filter_complex', `vstack=inputs=${rows.length}[out]`, '-map', '[out]', outputPath);
1327
+ const child = spawn(process.env.FFMPEG_PATH || 'ffmpeg', args);
1328
+ let stderr = '';
1329
+ child.stderr.on('data', (chunk) => (stderr += String(chunk)));
1330
+ child.on('error', reject);
1331
+ child.on('close', (code) =>
1332
+ code === 0
1333
+ ? resolve()
1334
+ : reject(new Error(`ffmpeg parity contact sheet exited ${code}: ${stderr}`))
1335
+ );
1336
+ });
1337
+ }
1338
+
1339
+ async function parityCommand(args) {
1340
+ const scenePath = args[0];
1341
+ const output = readFlag(args, '--output');
1342
+ if (!scenePath || !output) {
1343
+ throw new Error('Usage: visualfries parity <scene.json> --output <dir> [--frames 0,12,30]');
1344
+ }
1345
+ const scene = SceneShape.parse(await readJson(scenePath));
1346
+ const roiConfigPath = readFlag(args, '--rois');
1347
+ const roiConfig = roiConfigPath ? await readJson(roiConfigPath) : null;
1348
+ const regions = Array.isArray(roiConfig?.regions) ? roiConfig.regions : [];
1349
+ const fps = scene.settings.fps ?? 30;
1350
+ const defaultFrames = [
1351
+ 0,
1352
+ Math.floor((scene.settings.duration * fps) / 2),
1353
+ Math.max(0, Math.ceil(scene.settings.duration * fps) - 1)
1354
+ ];
1355
+ const frames = [
1356
+ ...new Set(
1357
+ (readFlag(args, '--frames')
1358
+ ? readFlag(args, '--frames').split(',').map(Number)
1359
+ : defaultFrames
1360
+ ).filter((frame) => Number.isInteger(frame) && frame >= 0)
1361
+ )
1362
+ ];
1363
+ if (!frames.length) throw new Error('--frames must contain at least one non-negative integer');
1364
+ const requestedFrames = [...frames, ...[...frames].reverse()];
1365
+ const root = path.resolve(output);
1366
+ const previewDir = path.join(root, 'preview');
1367
+ const finalDir = path.join(root, 'final');
1368
+ const common = {
1369
+ scene,
1370
+ framesOnly: true,
1371
+ frameIndices: requestedFrames,
1372
+ fps,
1373
+ keepFrames: false,
1374
+ packageRoot: PACKAGE_ROOT,
1375
+ modulePaths: {
1376
+ vite: process.env.VISUALFRIES_VITE_MODULES,
1377
+ svelteVitePlugin: process.env.VISUALFRIES_SVELTE_VITE_MODULES,
1378
+ playwright: process.env.VISUALFRIES_PLAYWRIGHT_MODULES,
1379
+ nodeModules: process.env.VISUALFRIES_NODE_MODULES
1380
+ }
1381
+ };
1382
+ const previewResult = await renderSceneLocally({
1383
+ ...common,
1384
+ output: previewDir,
1385
+ environment: 'client',
1386
+ serverRendererMode: 'webgl',
1387
+ renderPlan: resolveAgentRenderPlan(scene, { mode: 'preview' })
1388
+ });
1389
+ const finalResult = await renderSceneLocally({
1390
+ ...common,
1391
+ output: finalDir,
1392
+ environment: 'server',
1393
+ renderPlan: resolveAgentRenderPlan(scene, { mode: 'final' })
1394
+ });
1395
+
1396
+ const comparisons = [];
1397
+ const diffRows = [];
1398
+ for (let index = 0; index < frames.length; index += 1) {
1399
+ const name = `frame-${String(index + 1).padStart(6, '0')}.png`;
1400
+ const previewPath = path.join(previewDir, name);
1401
+ const finalPath = path.join(finalDir, name);
1402
+ const diffPath = path.join(root, 'diff', `frame-${String(frames[index]).padStart(6, '0')}.png`);
1403
+ await writeParityDiffRow(previewPath, finalPath, diffPath);
1404
+ diffRows.push(diffPath);
1405
+ const regionComparisons = await Promise.all(
1406
+ regions.map(async (region) => ({
1407
+ name: region.name,
1408
+ class: region.class,
1409
+ rawFloor: region.rawFloor,
1410
+ semanticFloor: region.semanticFloor,
1411
+ semanticSigma: region.semanticSigma ?? 1,
1412
+ ssim: await compareFrameSsim(previewPath, finalPath, { crop: region }),
1413
+ semanticSsim: await compareFrameSsim(previewPath, finalPath, {
1414
+ semantic: true,
1415
+ crop: region
1416
+ })
1417
+ }))
1418
+ );
1419
+ comparisons.push({
1420
+ frame: frames[index],
1421
+ preview: { path: previewPath, sha256: await sha256(previewPath) },
1422
+ final: { path: finalPath, sha256: await sha256(finalPath) },
1423
+ ssim: await compareFrameSsim(previewPath, finalPath),
1424
+ semanticSsim: await compareFrameSsim(previewPath, finalPath, { semantic: true }),
1425
+ diff: diffPath,
1426
+ ...(regionComparisons.length ? { regions: regionComparisons } : {})
1427
+ });
1428
+ }
1429
+ const contactSheetPath = path.join(root, 'contact-sheet.png');
1430
+ await writeParityContactSheet(diffRows, contactSheetPath);
1431
+ const reseekItems = [];
1432
+ const reseekSsimFloor = 0.999;
1433
+ for (let index = 0; index < frames.length; index += 1) {
1434
+ const firstName = `frame-${String(index + 1).padStart(6, '0')}.png`;
1435
+ const reverseIndex = frames.length + (frames.length - 1 - index) + 1;
1436
+ const repeatName = `frame-${String(reverseIndex).padStart(6, '0')}.png`;
1437
+ const previewFirstPath = path.join(previewDir, firstName);
1438
+ const previewRepeatPath = path.join(previewDir, repeatName);
1439
+ const finalFirstPath = path.join(finalDir, firstName);
1440
+ const finalRepeatPath = path.join(finalDir, repeatName);
1441
+ const previewExact = (await sha256(previewFirstPath)) === (await sha256(previewRepeatPath));
1442
+ const finalExact = (await sha256(finalFirstPath)) === (await sha256(finalRepeatPath));
1443
+ const previewSsim = previewExact
1444
+ ? 1
1445
+ : await compareFrameSsim(previewFirstPath, previewRepeatPath);
1446
+ const finalSsim = finalExact ? 1 : await compareFrameSsim(finalFirstPath, finalRepeatPath);
1447
+ reseekItems.push({
1448
+ frame: frames[index],
1449
+ previewExact,
1450
+ finalExact,
1451
+ previewSsim,
1452
+ finalSsim,
1453
+ stable: previewSsim >= reseekSsimFloor && finalSsim >= reseekSsimFloor
1454
+ });
1455
+ }
1456
+ const reseek = {
1457
+ order: requestedFrames,
1458
+ items: reseekItems,
1459
+ allExact: reseekItems.every((item) => item.previewExact && item.finalExact),
1460
+ ssimFloor: reseekSsimFloor,
1461
+ allStable: reseekItems.every((item) => item.stable)
1462
+ };
1463
+ const rawReviewFloor = 0.9;
1464
+ const semanticThreshold = 0.95;
1465
+ const manifest = {
1466
+ scene: scene.id,
1467
+ fps,
1468
+ frames,
1469
+ thresholds: {
1470
+ rawWholeFrameSsimReviewFloor: rawReviewFloor,
1471
+ semanticWholeFrameSsim: semanticThreshold,
1472
+ semanticBlurSigma: 1,
1473
+ deterministicReseekExactPreferred: true,
1474
+ deterministicReseekSsimFloor: reseekSsimFloor,
1475
+ ...(roiConfig?.classes ? { classes: roiConfig.classes } : {})
1476
+ },
1477
+ comparisons,
1478
+ contactSheet: contactSheetPath,
1479
+ reseek,
1480
+ runtimeErrors: {
1481
+ preview: previewResult.runtimeErrors ?? [],
1482
+ final: finalResult.runtimeErrors ?? []
1483
+ },
1484
+ passed:
1485
+ comparisons.every(
1486
+ (item) =>
1487
+ item.ssim >= rawReviewFloor &&
1488
+ item.semanticSsim >= semanticThreshold &&
1489
+ (item.regions ?? []).every(
1490
+ (region) =>
1491
+ region.ssim >= region.rawFloor && region.semanticSsim >= region.semanticFloor
1492
+ )
1493
+ ) &&
1494
+ reseek.allStable &&
1495
+ (previewResult.runtimeErrors?.length ?? 0) === 0 &&
1496
+ (finalResult.runtimeErrors?.length ?? 0) === 0,
1497
+ caveat:
1498
+ 'Raw SSIM remains visible for dither/antialias review. Semantic SSIM applies a 1px Gaussian blur to separate renderer pixel noise from missing or misplaced visuals.'
1499
+ };
1500
+ await fs.mkdir(root, { recursive: true });
1501
+ await fs.writeFile(path.join(root, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
1502
+ console.log(JSON.stringify(manifest, null, 2));
1503
+ if (!manifest.passed && hasFlag(args, '--strict')) process.exitCode = 1;
1504
+ }
1505
+
1506
+ async function renderCommand(args) {
1507
+ const scenePath = args[0];
1508
+ const output = readFlag(args, '--output');
1509
+ if (!scenePath || !output) {
1510
+ throw new Error(
1511
+ 'Usage: visualfries render <scene.json> --output <out.mp4|frames-dir> [--frames-only]'
1512
+ );
1513
+ }
1514
+
1515
+ const parsed = SceneShape.parse(await readJson(scenePath));
1516
+ const report = inspectScene(parsed);
1517
+ if (!report.valid) {
1518
+ throw new Error(`Scene is not renderable: ${JSON.stringify(report.issues, null, 2)}`);
1519
+ }
1520
+
1521
+ const renderPlan = assertBrowserRenderAllowed(parsed, args);
1522
+ const { fromFrame, toFrame, renderOptions } = fullRenderOptionsFromArgs(args, parsed, output);
1523
+ const result = await renderSceneWithBrowser(renderOptions);
1524
+
1525
+ console.log(
1526
+ JSON.stringify(
1527
+ {
1528
+ ...result,
1529
+ renderPlan,
1530
+ frames: {
1531
+ ...result.frames,
1532
+ fromFrame,
1533
+ toFrame
1534
+ }
1535
+ },
1536
+ null,
1537
+ 2
1538
+ )
1539
+ );
1540
+ }
1541
+
1542
+ async function main() {
1543
+ const [command, ...args] = process.argv.slice(2);
1544
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
1545
+ help();
1546
+ return;
1547
+ }
1548
+
1549
+ if (command === 'validate') return validateCommand(args);
1550
+ if (command === 'init') return initCommand(args);
1551
+ if (command === 'inspect') return inspectCommand(args);
1552
+ if (command === 'qa') return qaCommand(args);
1553
+ if (command === 'caption-scene') return captionSceneCommand(args);
1554
+ if (command === 'preset-cues') return presetCuesCommand(args);
1555
+ if (command === 'validate-cues') return validateCuesCommand(args);
1556
+ if (command === 'apply-cues') return applyCuesCommand(args);
1557
+ if (command === 'compose') return composeCommand(args);
1558
+ if (command === 'produce') return produceCommand(args);
1559
+ if (command === 'render') return renderCommand(args);
1560
+ if (command === 'catalog') return catalogCommand(args);
1561
+ if (command === 'explain') return explainCommand(args);
1562
+ if (command === 'parity') return parityCommand(args);
1563
+ if (command === 'doctor') return doctorCommand(args);
1564
+ throw new Error(`Unknown command: ${command}`);
1565
+ }
1566
+
1567
+ main().catch((error) => {
1568
+ console.error(error?.message || String(error));
1569
+ process.exitCode = 1;
1570
+ });