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.
- package/README.md +122 -0
- package/bin/visualfries.js +1570 -0
- package/dist/DIContainer.d.ts +2 -1
- package/dist/DIContainer.js +16 -5
- package/dist/SceneBuilder.svelte.d.ts +70 -1
- package/dist/SceneBuilder.svelte.js +96 -29
- package/dist/agent/audioMixer.d.ts +34 -0
- package/dist/agent/audioMixer.js +308 -0
- package/dist/agent/broll.d.ts +35 -0
- package/dist/agent/broll.js +226 -0
- package/dist/agent/capabilities.d.ts +34 -0
- package/dist/agent/capabilities.js +246 -0
- package/dist/agent/captionScene.d.ts +23 -0
- package/dist/agent/captionScene.js +204 -0
- package/dist/agent/catalog.d.ts +24 -0
- package/dist/agent/catalog.js +67 -0
- package/dist/agent/cueFiles.d.ts +54 -0
- package/dist/agent/cueFiles.js +219 -0
- package/dist/agent/cuePresets.d.ts +7 -0
- package/dist/agent/cuePresets.js +50 -0
- package/dist/agent/deterministicMedia.d.ts +61 -0
- package/dist/agent/deterministicMedia.js +303 -0
- package/dist/agent/index.d.ts +18 -0
- package/dist/agent/index.js +18 -0
- package/dist/agent/inspectScene.d.ts +36 -0
- package/dist/agent/inspectScene.js +139 -0
- package/dist/agent/localRender.d.ts +82 -0
- package/dist/agent/localRender.js +702 -0
- package/dist/agent/overlays.d.ts +40 -0
- package/dist/agent/overlays.js +325 -0
- package/dist/agent/productionPlan.d.ts +131 -0
- package/dist/agent/productionPlan.js +322 -0
- package/dist/agent/renderPlan.d.ts +27 -0
- package/dist/agent/renderPlan.js +68 -0
- package/dist/agent/renderRanges.d.ts +24 -0
- package/dist/agent/renderRanges.js +73 -0
- package/dist/agent/runtimeSupport.d.ts +19 -0
- package/dist/agent/runtimeSupport.js +192 -0
- package/dist/agent/streamingEncoder.d.ts +31 -0
- package/dist/agent/streamingEncoder.js +208 -0
- package/dist/agent/transcripts.d.ts +29 -0
- package/dist/agent/transcripts.js +146 -0
- package/dist/agent/transitions.d.ts +20 -0
- package/dist/agent/transitions.js +169 -0
- package/dist/animations/PixiAnimationTarget.d.ts +29 -0
- package/dist/animations/PixiAnimationTarget.js +63 -0
- package/dist/animations/builders/LineHighlighterAnimationBuilder.js +20 -3
- package/dist/animations/builders/WordHighlighterAnimationBuilder.js +15 -2
- package/dist/browser/browserExport.d.ts +73 -0
- package/dist/browser/browserExport.js +180 -0
- package/dist/browser/glTransitions.d.ts +37 -0
- package/dist/browser/glTransitions.js +261 -0
- package/dist/browser/index.d.ts +3 -0
- package/dist/browser/index.js +3 -0
- package/dist/browser/mediaDecode.d.ts +31 -0
- package/dist/browser/mediaDecode.js +152 -0
- package/dist/builders/PixiComponentBuilder.d.ts +6 -0
- package/dist/builders/PixiComponentBuilder.js +12 -0
- package/dist/builders/_ComponentState.svelte.d.ts +7 -5
- package/dist/builders/_ComponentState.svelte.js +29 -15
- package/dist/builders/html/StyleBuilder.js +9 -8
- package/dist/builders/html/TextComponentHtmlBuilder.js +4 -2
- package/dist/builders/html/processors/AppearanceStyleProcessor.js +1 -1
- package/dist/builders/html/processors/TextAppearanceStyleProcessor.js +7 -0
- package/dist/builders/html/processors/TextEffectsStyleProcessor.js +32 -13
- package/dist/commands/ReplaceSourceOnTimeCommand.js +9 -1
- package/dist/commands/SeekCommand.js +19 -6
- package/dist/components/AnimatedGIF.d.ts +3 -0
- package/dist/components/AnimatedGIF.js +28 -1
- package/dist/components/Component.svelte.d.ts +5 -5
- package/dist/components/Component.svelte.js +29 -13
- package/dist/components/ComponentContext.svelte.js +68 -23
- package/dist/components/hooks/AnimationHook.js +1 -1
- package/dist/components/hooks/CanvasFillHook.d.ts +12 -0
- package/dist/components/hooks/CanvasFillHook.js +75 -0
- package/dist/components/hooks/DeterministicMediaFrameHook.js +3 -0
- package/dist/components/hooks/HtmlTextHook.js +11 -3
- package/dist/components/hooks/HtmlToCanvasHook.js +8 -25
- package/dist/components/hooks/MediaHook.js +172 -47
- package/dist/components/hooks/MediaSeekingHook.js +138 -88
- package/dist/components/hooks/PixiGifHook.js +4 -4
- package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.d.ts +3 -0
- package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.js +85 -8
- package/dist/components/hooks/PixiVideoTextureHook.js +10 -4
- package/dist/components/hooks/PixiVisualTransformHook.d.ts +16 -0
- package/dist/components/hooks/PixiVisualTransformHook.js +102 -0
- package/dist/components/hooks/SubtitlesHook.js +48 -24
- package/dist/components/hooks/VerifyMediaHook.js +2 -2
- package/dist/directors/ComponentDirector.d.ts +1 -0
- package/dist/directors/ComponentDirector.js +41 -8
- package/dist/factories/SceneBuilderFactory.d.ts +3 -0
- package/dist/factories/SceneBuilderFactory.js +6 -0
- package/dist/fonts/fontDiscovery.js +12 -1
- package/dist/layers/Layer.svelte.d.ts +54 -0
- package/dist/layers/Layer.svelte.js +2 -25
- package/dist/managers/AppManager.svelte.d.ts +12 -0
- package/dist/managers/AppManager.svelte.js +180 -8
- package/dist/managers/ComponentsManager.svelte.d.ts +7 -7
- package/dist/managers/ComponentsManager.svelte.js +29 -15
- package/dist/managers/DeterministicMediaManager.d.ts +6 -0
- package/dist/managers/DeterministicMediaManager.js +57 -3
- package/dist/managers/EventManager.js +3 -1
- package/dist/managers/LayersManager.svelte.d.ts +54 -0
- package/dist/managers/RenderManager.d.ts +5 -0
- package/dist/managers/RenderManager.js +67 -21
- package/dist/managers/StateManager.svelte.d.ts +56 -0
- package/dist/managers/StateManager.svelte.js +60 -58
- package/dist/managers/SubtitlesManager.svelte.js +1 -3
- package/dist/managers/TimeManager.svelte.d.ts +4 -0
- package/dist/managers/TimeManager.svelte.js +23 -7
- package/dist/schemas/runtime/deterministic.d.ts +3 -0
- package/dist/schemas/runtime/deterministic.js +5 -1
- package/dist/schemas/runtime/types.d.ts +36 -15
- package/dist/schemas/scene/components.d.ts +126 -0
- package/dist/schemas/scene/components.js +11 -0
- package/dist/schemas/scene/core.d.ts +109 -10
- package/dist/schemas/scene/core.js +1 -1
- package/dist/utils/mediaWindow.d.ts +13 -0
- package/dist/utils/mediaWindow.js +25 -0
- package/dist/utils/svgGenerator.js +1 -1
- package/dist/utils/timelineWindow.d.ts +2 -0
- package/dist/utils/timelineWindow.js +7 -0
- package/dist/utils/utils.js +2 -4
- package/docs/AGENT_PATTERNS.md +243 -0
- package/docs/AGENT_WORKFLOW.md +364 -0
- package/docs/AUTHORING_BEST_PRACTICES.md +188 -0
- package/docs/AUTHORING_SYSTEM_AUDIT.md +98 -0
- package/docs/BROWSER_EXPORT_SPIKE.md +55 -0
- package/docs/DETERMINISTIC_RENDERER.md +83 -0
- package/docs/NEXT_RELEASE.md +28 -0
- package/docs/PROPOSED_VISUALFRIES_SKILL.md +68 -0
- package/docs/examples/agent-cues.json +49 -0
- package/docs/examples/agent-transcript.json +23 -0
- package/docs/examples/agent-transcript.srt +7 -0
- package/docs/examples/agent-transcript.vtt +7 -0
- package/docs/examples/image-animation.json +87 -0
- package/docs/examples/native-text-metric-badge.json +109 -0
- package/package.json +133 -100
- 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
|
+
});
|