visualfries 0.1.10120 → 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 +97 -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.js +73 -6
- 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/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.js +8 -5
- package/dist/managers/ComponentsManager.svelte.d.ts +7 -7
- package/dist/managers/ComponentsManager.svelte.js +29 -15
- package/dist/managers/DeterministicMediaManager.js +38 -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.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,702 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { SceneShape } from '../schemas/scene/index.js';
|
|
9
|
+
import { buildLocalMixedAudioTrack, resolveAudioMixRanges } from './audioMixer.js';
|
|
10
|
+
import { prepareLocalDeterministicMedia } from './deterministicMedia.js';
|
|
11
|
+
import { resolveEffectiveRenderRanges } from './renderRanges.js';
|
|
12
|
+
import { resolveAgentRenderPlan } from './renderPlan.js';
|
|
13
|
+
import { muxAudioWithVideo, PipeFrameEncoder } from './streamingEncoder.js';
|
|
14
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
15
|
+
const currentDir = path.dirname(currentFile);
|
|
16
|
+
const defaultPackageRoot = path.resolve(currentDir, '..', '..');
|
|
17
|
+
const isRemoteUrl = (value) => typeof value === 'string' && /^[a-z]+:\/\//i.test(value) && !value.startsWith('file://');
|
|
18
|
+
export const localPathFromUrl = (value) => {
|
|
19
|
+
if (!value || typeof value !== 'string')
|
|
20
|
+
return undefined;
|
|
21
|
+
if (value.startsWith('file://'))
|
|
22
|
+
return fileURLToPath(value);
|
|
23
|
+
if (isRemoteUrl(value) || value.startsWith('data:'))
|
|
24
|
+
return undefined;
|
|
25
|
+
if (path.isAbsolute(value))
|
|
26
|
+
return existsSync(value) ? value : undefined;
|
|
27
|
+
return path.resolve(value);
|
|
28
|
+
};
|
|
29
|
+
const extensionForMedia = (filePath) => path.extname(filePath) || '.bin';
|
|
30
|
+
const normalizeImageFormat = (value) => {
|
|
31
|
+
const resolved = value ?? 'png';
|
|
32
|
+
if (resolved === 'png' || resolved === 'jpg')
|
|
33
|
+
return resolved;
|
|
34
|
+
if (resolved === 'jpeg')
|
|
35
|
+
return 'jpg';
|
|
36
|
+
throw new Error('imageFormat must be png, jpg, or jpeg.');
|
|
37
|
+
};
|
|
38
|
+
export const eventEmitter3ShimSource = `export default class EventEmitter{constructor(){this._events=new Map();}_add(name,fn,context,once){if(typeof fn!=='function')throw new TypeError('The listener must be a function');const list=this._events.get(name)||[];list.push({fn,context:context===undefined?this:context,once});this._events.set(name,list);return this;}on(name,fn,context){return this._add(name,fn,context,false);}addListener(name,fn,context){return this.on(name,fn,context);}once(name,fn,context){return this._add(name,fn,context,true);}off(name,fn,context,once){return this.removeListener(name,fn,context,once);}removeListener(name,fn,context,once){const list=this._events.get(name);if(!list)return this;if(fn===undefined){this._events.delete(name);return this;}const next=list.filter((listener)=>listener.fn!==fn||(context!==undefined&&listener.context!==context)||(once!==undefined&&listener.once!==once));if(next.length)this._events.set(name,next);else this._events.delete(name);return this;}removeAllListeners(name){if(name===undefined)this._events.clear();else this._events.delete(name);return this;}emit(name,...args){const list=this._events.get(name);if(!list?.length)return false;for(const listener of [...list]){if(listener.once)this.removeListener(name,listener.fn,listener.context,true);listener.fn.apply(listener.context,args);}return true;}listeners(name){return (this._events.get(name)||[]).map((listener)=>listener.fn);}listenerCount(name){return this._events.get(name)?.length||0;}eventNames(){return [...this._events.keys()];}}
|
|
39
|
+
export { EventEmitter };
|
|
40
|
+
`;
|
|
41
|
+
export const normalizeImageQuality = (value, fallback = 0.92) => {
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
return fallback;
|
|
44
|
+
if (!Number.isFinite(value))
|
|
45
|
+
throw new Error('imageQuality must be a finite number.');
|
|
46
|
+
if (value < 0)
|
|
47
|
+
throw new Error('imageQuality must be >= 0.');
|
|
48
|
+
if (value <= 1)
|
|
49
|
+
return value;
|
|
50
|
+
if (value <= 100)
|
|
51
|
+
return value / 100;
|
|
52
|
+
throw new Error('imageQuality must be in 0..1 or 0..100 percent form.');
|
|
53
|
+
};
|
|
54
|
+
const contiguousFrameRange = (frameIndices) => {
|
|
55
|
+
if (!frameIndices.length)
|
|
56
|
+
return null;
|
|
57
|
+
const fromFrame = frameIndices[0];
|
|
58
|
+
for (let index = 1; index < frameIndices.length; index += 1) {
|
|
59
|
+
if (frameIndices[index] !== fromFrame + index)
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return { fromFrame, toFrame: fromFrame + frameIndices.length };
|
|
63
|
+
};
|
|
64
|
+
const dataUrlToBuffer = (dataUrl) => {
|
|
65
|
+
const commaIndex = dataUrl.indexOf(',');
|
|
66
|
+
const base64 = commaIndex >= 0 ? dataUrl.slice(commaIndex + 1) : dataUrl;
|
|
67
|
+
return Buffer.from(base64, 'base64');
|
|
68
|
+
};
|
|
69
|
+
const writeDataUrlFrame = async (dataUrl, framePath) => {
|
|
70
|
+
await fs.writeFile(framePath, dataUrlToBuffer(dataUrl));
|
|
71
|
+
};
|
|
72
|
+
const framePattern = (framesDir, imageFormat) => path.join(framesDir, `frame-%06d.${imageFormat}`);
|
|
73
|
+
const runCommand = async (command, args) => {
|
|
74
|
+
await new Promise((resolve, reject) => {
|
|
75
|
+
const child = spawn(command, args, { stdio: 'inherit' });
|
|
76
|
+
child.on('error', reject);
|
|
77
|
+
child.on('close', (code) => {
|
|
78
|
+
if (code === 0)
|
|
79
|
+
resolve();
|
|
80
|
+
else
|
|
81
|
+
reject(new Error(`${command} exited with code ${code}.`));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const encodeFrames = async (input) => {
|
|
86
|
+
const args = [
|
|
87
|
+
'-y',
|
|
88
|
+
'-framerate',
|
|
89
|
+
String(input.fps),
|
|
90
|
+
'-i',
|
|
91
|
+
framePattern(input.framesDir, input.imageFormat)
|
|
92
|
+
];
|
|
93
|
+
if (input.audioInput) {
|
|
94
|
+
args.push('-i', input.audioInput, '-map', '0:v:0', '-map', '1:a:0?', '-shortest');
|
|
95
|
+
}
|
|
96
|
+
args.push('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-crf', '18', input.output);
|
|
97
|
+
await runCommand(process.env.FFMPEG_PATH || 'ffmpeg', args);
|
|
98
|
+
};
|
|
99
|
+
const importFromOptionalPaths = async (moduleName, envName, modulePaths = {}) => {
|
|
100
|
+
try {
|
|
101
|
+
return await import(moduleName);
|
|
102
|
+
}
|
|
103
|
+
catch (initialError) {
|
|
104
|
+
const configured = moduleName === 'vite'
|
|
105
|
+
? modulePaths.vite
|
|
106
|
+
: moduleName === '@sveltejs/vite-plugin-svelte'
|
|
107
|
+
? modulePaths.svelteVitePlugin
|
|
108
|
+
: moduleName === 'playwright'
|
|
109
|
+
? modulePaths.playwright
|
|
110
|
+
: undefined;
|
|
111
|
+
const extraPaths = [
|
|
112
|
+
configured,
|
|
113
|
+
modulePaths.nodeModules,
|
|
114
|
+
process.env[envName],
|
|
115
|
+
process.env.VISUALFRIES_NODE_MODULES
|
|
116
|
+
].filter(Boolean);
|
|
117
|
+
for (const moduleDir of extraPaths) {
|
|
118
|
+
try {
|
|
119
|
+
const require = createRequire(import.meta.url);
|
|
120
|
+
const resolved = require.resolve(moduleName, { paths: [moduleDir] });
|
|
121
|
+
return await import(pathToFileURL(resolved).toString());
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Try next path.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw initialError;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const resolveModulePathFromOptionalPaths = (moduleName, envName, modulePaths = {}, fallback) => {
|
|
131
|
+
const configured = moduleName === 'vite'
|
|
132
|
+
? modulePaths.vite
|
|
133
|
+
: moduleName === '@sveltejs/vite-plugin-svelte'
|
|
134
|
+
? modulePaths.svelteVitePlugin
|
|
135
|
+
: moduleName === 'playwright'
|
|
136
|
+
? modulePaths.playwright
|
|
137
|
+
: undefined;
|
|
138
|
+
const extraPaths = [
|
|
139
|
+
configured,
|
|
140
|
+
modulePaths.nodeModules,
|
|
141
|
+
process.env[envName],
|
|
142
|
+
process.env.VISUALFRIES_NODE_MODULES
|
|
143
|
+
].filter(Boolean);
|
|
144
|
+
const require = createRequire(import.meta.url);
|
|
145
|
+
for (const moduleDir of extraPaths) {
|
|
146
|
+
try {
|
|
147
|
+
return require.resolve(moduleName, { paths: [moduleDir] });
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Try next path.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
return require.resolve(moduleName);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
if (fallback)
|
|
158
|
+
return fallback;
|
|
159
|
+
throw new Error(`Could not resolve ${moduleName}. Pass modulePaths.nodeModules or set VISUALFRIES_NODE_MODULES.`);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const copyLocalMediaToRenderRoot = async (scene, renderRoot, baseUrl) => {
|
|
163
|
+
const cloned = structuredClone(scene);
|
|
164
|
+
const mediaDir = path.join(renderRoot, 'media');
|
|
165
|
+
await fs.mkdir(mediaDir, { recursive: true });
|
|
166
|
+
const copied = new Map();
|
|
167
|
+
let index = 0;
|
|
168
|
+
const rewriteUrl = async (value) => {
|
|
169
|
+
if (!value)
|
|
170
|
+
return value;
|
|
171
|
+
const localPath = localPathFromUrl(value);
|
|
172
|
+
if (!localPath || !existsSync(localPath))
|
|
173
|
+
return value;
|
|
174
|
+
if (copied.has(localPath))
|
|
175
|
+
return copied.get(localPath);
|
|
176
|
+
index += 1;
|
|
177
|
+
const targetName = `${String(index).padStart(3, '0')}${extensionForMedia(localPath)}`;
|
|
178
|
+
const targetPath = path.join(mediaDir, targetName);
|
|
179
|
+
await fs.copyFile(localPath, targetPath);
|
|
180
|
+
const servedUrl = new URL(`/media/${targetName}`, baseUrl).toString();
|
|
181
|
+
copied.set(localPath, servedUrl);
|
|
182
|
+
return servedUrl;
|
|
183
|
+
};
|
|
184
|
+
for (const asset of cloned.assets ?? []) {
|
|
185
|
+
asset.url = (await rewriteUrl(asset.url)) ?? asset.url;
|
|
186
|
+
}
|
|
187
|
+
if (cloned.settings.audio?.src) {
|
|
188
|
+
cloned.settings.audio.src =
|
|
189
|
+
(await rewriteUrl(cloned.settings.audio.src)) ?? cloned.settings.audio.src;
|
|
190
|
+
}
|
|
191
|
+
for (const layer of cloned.layers ?? []) {
|
|
192
|
+
for (const component of layer.components ?? []) {
|
|
193
|
+
if ('source' in component && component.source?.url) {
|
|
194
|
+
component.source.url = (await rewriteUrl(component.source.url)) ?? component.source.url;
|
|
195
|
+
}
|
|
196
|
+
if ('source' in component && component.source?.streamUrl) {
|
|
197
|
+
component.source.streamUrl =
|
|
198
|
+
(await rewriteUrl(component.source.streamUrl)) ?? component.source.streamUrl;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return cloned;
|
|
203
|
+
};
|
|
204
|
+
const findPrimaryAudioInput = (scene) => {
|
|
205
|
+
const audioSrc = localPathFromUrl(scene.settings.audio?.src);
|
|
206
|
+
if (audioSrc && existsSync(audioSrc))
|
|
207
|
+
return audioSrc;
|
|
208
|
+
if (isRemoteUrl(scene.settings.audio?.src))
|
|
209
|
+
return scene.settings.audio?.src;
|
|
210
|
+
for (const asset of scene.assets ?? []) {
|
|
211
|
+
if (asset.type !== 'VIDEO')
|
|
212
|
+
continue;
|
|
213
|
+
const localPath = localPathFromUrl(asset.url);
|
|
214
|
+
if (localPath && existsSync(localPath))
|
|
215
|
+
return localPath;
|
|
216
|
+
if (isRemoteUrl(asset.url))
|
|
217
|
+
return asset.url;
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
};
|
|
221
|
+
const resolveAudioInput = (value) => {
|
|
222
|
+
if (!value || value === 'none')
|
|
223
|
+
return undefined;
|
|
224
|
+
return isRemoteUrl(value) ? value : path.resolve(value);
|
|
225
|
+
};
|
|
226
|
+
const writeRenderClient = async (rootDir) => {
|
|
227
|
+
await fs.writeFile(path.join(rootDir, 'index.html'), `<!doctype html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><link rel="icon" href="data:," /><title>VisualFries Local Renderer</title><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#000}#vf-root{position:relative;overflow:hidden;background:#000}</style></head><body><div id="vf-root"></div><script type="module" src="/render-client.js"></script></body></html>`, 'utf8');
|
|
228
|
+
await fs.writeFile(path.join(rootDir, 'node-url-shim.js'), `export function parse(value){try{const u=new URL(value,globalThis.location?.href||'http://localhost/');return{protocol:u.protocol,slashes:value.includes('//'),auth:u.username?u.username+(u.password?':'+u.password:''):null,host:u.host,port:u.port,hostname:u.hostname,hash:u.hash,search:u.search,query:u.search?u.search.slice(1):null,pathname:u.pathname,path:u.pathname+u.search,href:u.href};}catch{return{href:String(value),path:String(value),pathname:String(value),query:null,search:null,hash:null};}}\nexport function format(value){if(typeof value==='string')return value;if(value?.href)return value.href;return String((value?.protocol||'')+(value?.slashes?'//':'')+(value?.host||'')+(value?.pathname||value?.path||'')+(value?.search||'')+(value?.hash||''));}\nexport function resolve(from,to){return new URL(to,from||globalThis.location?.href||'http://localhost/').toString();}\nexport default{parse,format,resolve};\n`, 'utf8');
|
|
229
|
+
await fs.writeFile(path.join(rootDir, 'eventemitter3-shim.js'), eventEmitter3ShimSource, 'utf8');
|
|
230
|
+
await fs.writeFile(path.join(rootDir, 'md5-shim.js'), `export default function md5(value){const text=typeof value==='string'?value:JSON.stringify(value);let h1=0x811c9dc5,h2=0x9e3779b9;for(let i=0;i<text.length;i++){const c=text.charCodeAt(i);h1^=c;h1=Math.imul(h1,0x01000193);h2^=c+(h1>>>0);h2=Math.imul(h2,0x85ebca6b);}return ((h1>>>0).toString(16).padStart(8,'0')+(h2>>>0).toString(16).padStart(8,'0')).repeat(2);}\n`, 'utf8');
|
|
231
|
+
await fs.writeFile(path.join(rootDir, 'gifuct-js-shim.js'), `const unsupported=()=>{throw new Error('Browser GIF decoding is unavailable in the local deterministic renderer. Use the predecoded deterministic media path.');};\nexport const parseGIF=unsupported;\nexport const decompressFrames=unsupported;\nexport const decompressFrame=unsupported;\nexport default{parseGIF,decompressFrames,decompressFrame};\n`, 'utf8');
|
|
232
|
+
await fs.writeFile(path.join(rootDir, 'render-client.js'), `import { createSceneBuilder } from 'visualfries-runtime';
|
|
233
|
+
const root = document.getElementById('vf-root');
|
|
234
|
+
let builder;
|
|
235
|
+
let scene;
|
|
236
|
+
let deterministicMediaPayload;
|
|
237
|
+
async function blobToDataUrl(blob){return await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(reader.error);reader.onload=()=>resolve(reader.result);reader.readAsDataURL(blob);});}
|
|
238
|
+
async function framePayloadToDataUrl(frame){if(typeof frame==='string')return frame.startsWith('data:')?frame:'data:image/png;base64,'+frame;if(frame instanceof Blob)return await blobToDataUrl(frame);if(frame instanceof ArrayBuffer){const bytes=new Uint8Array(frame);let binary='';for(let index=0;index<bytes.length;index+=1)binary+=String.fromCharCode(bytes[index]);return 'data:image/png;base64,'+btoa(binary);}throw new Error('Unsupported VisualFries frame payload.');}
|
|
239
|
+
window.__VISUALFRIES_RENDER__={
|
|
240
|
+
async init(){
|
|
241
|
+
const rawScene=await fetch('/scene.json').then((response)=>response.json());
|
|
242
|
+
const rendererOptions=await fetch('/renderer-options.json').then((response)=>response.json()).catch(()=>({}));
|
|
243
|
+
try{deterministicMediaPayload=await fetch('/deterministic-media.json').then((response)=>response.ok?response.json():null);}catch{deterministicMediaPayload=null;}
|
|
244
|
+
scene=rawScene;
|
|
245
|
+
root.style.width=scene.settings.width+'px';
|
|
246
|
+
root.style.height=scene.settings.height+'px';
|
|
247
|
+
const deterministicProvider=deterministicMediaPayload?.frameManifest?{async getFrame(request){const url=deterministicMediaPayload.frameManifest?.[request.componentId]?.[String(request.frameIndex)];if(!url)return null;return{kind:'url',cacheKey:request.componentId+':'+request.frameIndex+':'+url,url};}}:undefined;
|
|
248
|
+
const serverRendererMode=rendererOptions.serverRendererMode==='webgl'?'webgl':'canvas';
|
|
249
|
+
builder=await createSceneBuilder(scene,root,{environment:rendererOptions.environment==='client'?'client':'server',autoPlay:false,loop:false,scale:1,forceCanvas:rendererOptions.environment==='client'?false:serverRendererMode!=='webgl',serverRendererMode,preferWebGL2:rendererOptions.preferWebGL2!==false,powerPreference:rendererOptions.powerPreference||'high-performance',deterministicMedia:{enabled:Boolean(deterministicProvider),strict:Boolean(deterministicProvider&&deterministicMediaPayload?.mediaDeterministicStrict),diagnostics:Boolean(deterministicProvider&&deterministicMediaPayload?.diagnosticsEnabled),provider:deterministicProvider}});
|
|
250
|
+
await builder.seek(0);
|
|
251
|
+
return{id:scene.id,width:scene.settings.width,height:scene.settings.height,duration:scene.settings.duration,fps:scene.settings.fps??30};
|
|
252
|
+
},
|
|
253
|
+
async renderFrame({time,imageFormat,imageQuality}){if(!builder)throw new Error('VisualFries renderer is not initialized.');await builder.seek(time);const frame=await builder.renderFrame(undefined,'blob',1,{imageFormat,imageQuality});return await framePayloadToDataUrl(frame);},
|
|
254
|
+
async explainFrame({componentId,time}){if(!builder)throw new Error('VisualFries renderer is not initialized.');await builder.seek(time);return builder.explainComponentState(componentId);},
|
|
255
|
+
async seek({time}){if(!builder)throw new Error('VisualFries renderer is not initialized.');await builder.seek(time);return true;},
|
|
256
|
+
async renderFrameRange({fromFrame,toFrame,imageFormat,imageQuality,skipDuplicates}){if(!builder)throw new Error('VisualFries renderer is not initialized.');if(!window.__VISUALFRIES_FRAME_WRITER__)throw new Error('VisualFries frame writer binding is not available.');return await builder.renderFrameRange({fromFrame,toFrame,format:'blob',quality:1,imageFormat,imageQuality,skipDuplicates,onFrame:async({frameIndex,frame,isDuplicate,mimeType,release})=>{const dataUrl=isDuplicate?undefined:await framePayloadToDataUrl(frame);await window.__VISUALFRIES_FRAME_WRITER__({frameIndex,isDuplicate,mimeType,dataUrl});release();}});},
|
|
257
|
+
async destroy(){await builder?.destroy?.();builder=undefined;}
|
|
258
|
+
};`, 'utf8');
|
|
259
|
+
};
|
|
260
|
+
const writeEarcutShim = async (rootDir, earcutEntry) => {
|
|
261
|
+
const source = await fs.readFile(earcutEntry, 'utf8');
|
|
262
|
+
const esmSource = source
|
|
263
|
+
.replace(/['"]use strict['"];?\s*/, '')
|
|
264
|
+
.replace(/module\.exports\s*=\s*earcut;\s*/, '')
|
|
265
|
+
.replace(/module\.exports\.default\s*=\s*earcut;\s*/, '');
|
|
266
|
+
await fs.writeFile(path.join(rootDir, 'earcut-shim.js'), `${esmSource}\nexport default earcut;\n`, 'utf8');
|
|
267
|
+
};
|
|
268
|
+
const findBrowserExecutable = (explicit) => {
|
|
269
|
+
const candidates = [
|
|
270
|
+
explicit,
|
|
271
|
+
process.env.VISUALFRIES_CHROMIUM_PATH,
|
|
272
|
+
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
|
273
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
274
|
+
path.join(os.homedir(), '.cache/puppeteer/chrome-headless-shell/mac_arm-131.0.6778.204/chrome-headless-shell-mac-arm64/chrome-headless-shell')
|
|
275
|
+
].filter(Boolean);
|
|
276
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
277
|
+
};
|
|
278
|
+
const resolveRenderAudioInput = async (input) => {
|
|
279
|
+
if (input.audioOverride === 'none') {
|
|
280
|
+
return {
|
|
281
|
+
audioInput: undefined,
|
|
282
|
+
audioMix: { mode: 'none', selectedSources: 0, skippedSources: 0, plannedSources: 0 }
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (input.audioOverride) {
|
|
286
|
+
return {
|
|
287
|
+
audioInput: resolveAudioInput(input.audioOverride),
|
|
288
|
+
audioMix: { mode: 'override', selectedSources: 1, skippedSources: 0, plannedSources: 1 }
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (!input.activeRange) {
|
|
292
|
+
const fallback = findPrimaryAudioInput(input.scene);
|
|
293
|
+
return {
|
|
294
|
+
audioInput: fallback,
|
|
295
|
+
audioMix: {
|
|
296
|
+
mode: fallback ? 'primary-fallback' : 'none',
|
|
297
|
+
selectedSources: fallback ? 1 : 0,
|
|
298
|
+
skippedSources: 0,
|
|
299
|
+
plannedSources: fallback ? 1 : 0
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const ranges = resolveAudioMixRanges(input.scene, input.activeRange.fromFrame / input.fps, input.activeRange.toFrame / input.fps);
|
|
304
|
+
const mixed = await buildLocalMixedAudioTrack({
|
|
305
|
+
scene: input.scene,
|
|
306
|
+
ranges,
|
|
307
|
+
workDir: path.join(input.tempRoot, 'audio'),
|
|
308
|
+
renderId: `visualfries-local-${Date.now()}`
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
audioInput: mixed.audioPath ?? undefined,
|
|
312
|
+
audioMix: {
|
|
313
|
+
mode: mixed.audioPath ? 'mixed' : 'none',
|
|
314
|
+
selectedSources: mixed.selectedSources,
|
|
315
|
+
skippedSources: mixed.skippedSources,
|
|
316
|
+
plannedSources: mixed.plannedSources.length,
|
|
317
|
+
path: mixed.audioPath ?? undefined
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
};
|
|
321
|
+
export async function renderSceneLocally(options) {
|
|
322
|
+
const parsed = SceneShape.parse(options.scene);
|
|
323
|
+
const normalizedImageFormat = normalizeImageFormat(options.imageFormat);
|
|
324
|
+
const imageQuality = normalizeImageQuality(options.imageQuality);
|
|
325
|
+
const tmpParent = options.tmpDir ||
|
|
326
|
+
process.env.VISUALFRIES_TMPDIR ||
|
|
327
|
+
(existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir());
|
|
328
|
+
const tempRoot = await fs.mkdtemp(path.join(tmpParent, 'visualfries-render-'));
|
|
329
|
+
const framesOnly = options.framesOnly ?? false;
|
|
330
|
+
const framesDir = framesOnly ? path.resolve(options.output) : path.join(tempRoot, 'frames');
|
|
331
|
+
await fs.mkdir(framesDir, { recursive: true });
|
|
332
|
+
let server;
|
|
333
|
+
let browser;
|
|
334
|
+
let activeStreamEncoder;
|
|
335
|
+
try {
|
|
336
|
+
const activeRange = options.environment === 'client' ? null : contiguousFrameRange(options.frameIndices);
|
|
337
|
+
const mediaPreparationRange = activeRange ??
|
|
338
|
+
(options.frameIndices.length > 0
|
|
339
|
+
? {
|
|
340
|
+
fromFrame: Math.min(...options.frameIndices),
|
|
341
|
+
toFrame: Math.max(...options.frameIndices) + 1
|
|
342
|
+
}
|
|
343
|
+
: null);
|
|
344
|
+
const effectiveRanges = activeRange
|
|
345
|
+
? resolveEffectiveRenderRanges({
|
|
346
|
+
scene: parsed,
|
|
347
|
+
fromFrame: activeRange.fromFrame,
|
|
348
|
+
toFrame: activeRange.toFrame,
|
|
349
|
+
fps: options.fps
|
|
350
|
+
}).ranges
|
|
351
|
+
: [];
|
|
352
|
+
const renderPlan = options.renderPlan ?? resolveAgentRenderPlan(parsed);
|
|
353
|
+
if (activeRange && effectiveRanges.length === 0) {
|
|
354
|
+
if (!framesOnly) {
|
|
355
|
+
throw new Error('All requested frames are removed by trimZones; no frames to encode.');
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
scene: {
|
|
360
|
+
id: parsed.id,
|
|
361
|
+
width: parsed.settings.width,
|
|
362
|
+
height: parsed.settings.height,
|
|
363
|
+
duration: parsed.settings.duration,
|
|
364
|
+
fps: parsed.settings.fps
|
|
365
|
+
},
|
|
366
|
+
frames: {
|
|
367
|
+
count: 0,
|
|
368
|
+
dir: framesDir,
|
|
369
|
+
items: [],
|
|
370
|
+
transport: 'range-binding',
|
|
371
|
+
skippedDuplicates: 0
|
|
372
|
+
},
|
|
373
|
+
output: path.resolve(options.output),
|
|
374
|
+
encoded: false,
|
|
375
|
+
encoding: { mode: 'none' },
|
|
376
|
+
audio: {
|
|
377
|
+
mode: 'none',
|
|
378
|
+
selectedSources: 0,
|
|
379
|
+
skippedSources: 0,
|
|
380
|
+
plannedSources: 0
|
|
381
|
+
},
|
|
382
|
+
mediaDiagnosticsPath: null,
|
|
383
|
+
mediaDiagnostics: options.mediaDiagnostics
|
|
384
|
+
? [
|
|
385
|
+
{
|
|
386
|
+
kind: 'visualfries-local-render',
|
|
387
|
+
engine: renderPlan.engine,
|
|
388
|
+
frameCount: 0,
|
|
389
|
+
requestedFrameCount: options.frameIndices.length,
|
|
390
|
+
trimAwareRanges: effectiveRanges,
|
|
391
|
+
serverRendererMode: options.serverRendererMode ?? 'canvas',
|
|
392
|
+
transport: 'range-binding'
|
|
393
|
+
}
|
|
394
|
+
]
|
|
395
|
+
: []
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
let sceneForRender = parsed;
|
|
399
|
+
let deterministicMediaDiagnostics = [];
|
|
400
|
+
if (renderPlan.engine === 'deterministic-local') {
|
|
401
|
+
if (!mediaPreparationRange)
|
|
402
|
+
throw new Error('Local deterministic media render requires at least one frame.');
|
|
403
|
+
const deterministicMedia = await prepareLocalDeterministicMedia({
|
|
404
|
+
scene: parsed,
|
|
405
|
+
workDir: tempRoot,
|
|
406
|
+
fromFrame: mediaPreparationRange.fromFrame,
|
|
407
|
+
toFrame: mediaPreparationRange.toFrame,
|
|
408
|
+
strict: true,
|
|
409
|
+
diagnostics: options.mediaDiagnostics ?? false
|
|
410
|
+
});
|
|
411
|
+
sceneForRender = deterministicMedia.preparedScene;
|
|
412
|
+
deterministicMediaDiagnostics = deterministicMedia.media.map((media) => ({
|
|
413
|
+
kind: 'predecoded-media',
|
|
414
|
+
componentId: media.componentId,
|
|
415
|
+
type: media.type,
|
|
416
|
+
sourceUrl: media.sourceUrl,
|
|
417
|
+
framesPrepared: media.framesPrepared,
|
|
418
|
+
strategy: deterministicMedia.strategyUsed
|
|
419
|
+
}));
|
|
420
|
+
await fs.writeFile(path.join(tempRoot, 'deterministic-media.json'), `${JSON.stringify(deterministicMedia.payload, null, 2)}\n`);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
await fs.writeFile(path.join(tempRoot, 'deterministic-media.json'), 'null\n');
|
|
424
|
+
}
|
|
425
|
+
await fs.writeFile(path.join(tempRoot, 'renderer-options.json'), `${JSON.stringify({
|
|
426
|
+
serverRendererMode: options.serverRendererMode ?? 'canvas',
|
|
427
|
+
environment: options.environment ?? 'server',
|
|
428
|
+
preferWebGL2: options.preferWebGL2 ?? true,
|
|
429
|
+
powerPreference: options.powerPreference ?? 'high-performance'
|
|
430
|
+
}, null, 2)}\n`);
|
|
431
|
+
await writeRenderClient(tempRoot);
|
|
432
|
+
const packageRoot = options.packageRoot ?? defaultPackageRoot;
|
|
433
|
+
const { createServer } = await importFromOptionalPaths('vite', 'VISUALFRIES_VITE_MODULES', options.modulePaths);
|
|
434
|
+
const sveltePluginModule = await importFromOptionalPaths('@sveltejs/vite-plugin-svelte', 'VISUALFRIES_SVELTE_VITE_MODULES', options.modulePaths);
|
|
435
|
+
const sourceLib = path.join(packageRoot, 'src/lib');
|
|
436
|
+
const packagedLib = path.join(packageRoot, 'dist');
|
|
437
|
+
const libRoot = existsSync(path.join(sourceLib, 'index.ts')) ? sourceLib : packagedLib;
|
|
438
|
+
const runtimeEntry = existsSync(path.join(sourceLib, 'factories/SceneBuilderFactory.ts'))
|
|
439
|
+
? path.join(sourceLib, 'factories/SceneBuilderFactory.ts')
|
|
440
|
+
: path.join(packageRoot, 'dist/factories/SceneBuilderFactory.js');
|
|
441
|
+
const svelteClientEntry = resolveModulePathFromOptionalPaths('svelte/internal/client', 'VISUALFRIES_NODE_MODULES', options.modulePaths, path.join(packageRoot, 'node_modules/svelte/src/internal/client/index.js'));
|
|
442
|
+
const svelteServerEntry = resolveModulePathFromOptionalPaths('svelte/internal/server', 'VISUALFRIES_NODE_MODULES', options.modulePaths, path.join(packageRoot, 'node_modules/svelte/src/internal/server/index.js'));
|
|
443
|
+
const earcutEntry = resolveModulePathFromOptionalPaths('earcut', 'VISUALFRIES_NODE_MODULES', options.modulePaths, path.join(packageRoot, 'node_modules/earcut/src/earcut.js'));
|
|
444
|
+
await writeEarcutShim(tempRoot, earcutEntry);
|
|
445
|
+
const resolveAliases = {
|
|
446
|
+
'visualfries-runtime': runtimeEntry,
|
|
447
|
+
$lib: libRoot,
|
|
448
|
+
url: path.join(tempRoot, 'node-url-shim.js'),
|
|
449
|
+
eventemitter3: path.join(tempRoot, 'eventemitter3-shim.js'),
|
|
450
|
+
earcut: path.join(tempRoot, 'earcut-shim.js'),
|
|
451
|
+
md5: path.join(tempRoot, 'md5-shim.js'),
|
|
452
|
+
'svelte/internal/client': svelteClientEntry,
|
|
453
|
+
'svelte/internal/server': svelteServerEntry
|
|
454
|
+
};
|
|
455
|
+
if (renderPlan.engine === 'deterministic-local') {
|
|
456
|
+
resolveAliases['gifuct-js'] = path.join(tempRoot, 'gifuct-js-shim.js');
|
|
457
|
+
}
|
|
458
|
+
server = await createServer({
|
|
459
|
+
root: tempRoot,
|
|
460
|
+
logLevel: 'error',
|
|
461
|
+
plugins: [sveltePluginModule.svelte()],
|
|
462
|
+
server: { host: '127.0.0.1', port: 0, fs: { allow: [tempRoot, packageRoot] } },
|
|
463
|
+
resolve: {
|
|
464
|
+
alias: resolveAliases,
|
|
465
|
+
conditions: ['browser', 'svelte']
|
|
466
|
+
},
|
|
467
|
+
optimizeDeps: { exclude: ['visualfries-runtime'], include: ['earcut'] }
|
|
468
|
+
});
|
|
469
|
+
await server.listen();
|
|
470
|
+
const url = server.resolvedUrls?.local?.[0];
|
|
471
|
+
if (!url)
|
|
472
|
+
throw new Error('Vite renderer did not expose a local URL.');
|
|
473
|
+
const renderScene = await copyLocalMediaToRenderRoot(sceneForRender, tempRoot, url);
|
|
474
|
+
await fs.writeFile(path.join(tempRoot, 'scene.json'), `${JSON.stringify(renderScene, null, 2)}\n`);
|
|
475
|
+
const playwright = await importFromOptionalPaths('playwright', 'VISUALFRIES_PLAYWRIGHT_MODULES', options.modulePaths);
|
|
476
|
+
const chromium = playwright.chromium ?? playwright.default?.chromium;
|
|
477
|
+
if (!chromium)
|
|
478
|
+
throw new Error('playwright was found, but chromium is not available.');
|
|
479
|
+
const executablePath = findBrowserExecutable(options.chromiumPath);
|
|
480
|
+
browser = await chromium.launch({
|
|
481
|
+
headless: true,
|
|
482
|
+
...(executablePath ? { executablePath } : {})
|
|
483
|
+
});
|
|
484
|
+
const page = await browser.newPage({
|
|
485
|
+
viewport: { width: parsed.settings.width, height: parsed.settings.height },
|
|
486
|
+
deviceScaleFactor: 1
|
|
487
|
+
});
|
|
488
|
+
const { audioInput, audioMix } = await resolveRenderAudioInput({
|
|
489
|
+
scene: parsed,
|
|
490
|
+
audioOverride: options.audioOverride,
|
|
491
|
+
activeRange,
|
|
492
|
+
fps: options.fps,
|
|
493
|
+
tempRoot
|
|
494
|
+
});
|
|
495
|
+
const outputPath = path.resolve(options.output);
|
|
496
|
+
const useStreamEncode = Boolean(options.streamEncode && !framesOnly);
|
|
497
|
+
if (useStreamEncode && !activeRange)
|
|
498
|
+
throw new Error('streamEncode requires a contiguous frame range.');
|
|
499
|
+
const streamedSilentOutput = audioInput ? path.join(tempRoot, 'silent-stream.mp4') : outputPath;
|
|
500
|
+
activeStreamEncoder = useStreamEncode
|
|
501
|
+
? new PipeFrameEncoder({
|
|
502
|
+
fps: options.fps,
|
|
503
|
+
inputExt: normalizedImageFormat,
|
|
504
|
+
outputPath: streamedSilentOutput,
|
|
505
|
+
crf: options.crf,
|
|
506
|
+
preset: options.preset
|
|
507
|
+
})
|
|
508
|
+
: undefined;
|
|
509
|
+
const streamEncoder = activeStreamEncoder;
|
|
510
|
+
const frames = [];
|
|
511
|
+
let previousFramePath;
|
|
512
|
+
let previousFrameBuffer;
|
|
513
|
+
let outputFrameIndex = 0;
|
|
514
|
+
let streamEncodeMs = 0;
|
|
515
|
+
await page.exposeFunction('__VISUALFRIES_FRAME_WRITER__', async (payload) => {
|
|
516
|
+
if (!activeRange)
|
|
517
|
+
throw new Error('VisualFries frame writer is only available for contiguous frame ranges.');
|
|
518
|
+
outputFrameIndex += 1;
|
|
519
|
+
const outputIndex = outputFrameIndex;
|
|
520
|
+
const framePath = path.join(framesDir, `frame-${String(outputIndex).padStart(6, '0')}.${normalizedImageFormat}`);
|
|
521
|
+
if (streamEncoder) {
|
|
522
|
+
let frameBuffer;
|
|
523
|
+
if (payload.isDuplicate) {
|
|
524
|
+
if (!previousFrameBuffer)
|
|
525
|
+
throw new Error(`Frame ${payload.frameIndex} was marked duplicate without a previous frame.`);
|
|
526
|
+
frameBuffer = previousFrameBuffer;
|
|
527
|
+
}
|
|
528
|
+
else if (payload.dataUrl) {
|
|
529
|
+
frameBuffer = dataUrlToBuffer(payload.dataUrl);
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
throw new Error(`Frame ${payload.frameIndex} did not include frame data.`);
|
|
533
|
+
}
|
|
534
|
+
await streamEncoder.writeFrame(frameBuffer);
|
|
535
|
+
previousFrameBuffer = frameBuffer;
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
if (payload.isDuplicate) {
|
|
539
|
+
if (!previousFramePath)
|
|
540
|
+
throw new Error(`Frame ${payload.frameIndex} was marked duplicate without a previous frame.`);
|
|
541
|
+
await fs.copyFile(previousFramePath, framePath);
|
|
542
|
+
}
|
|
543
|
+
else if (payload.dataUrl) {
|
|
544
|
+
await writeDataUrlFrame(payload.dataUrl, framePath);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
throw new Error(`Frame ${payload.frameIndex} did not include frame data.`);
|
|
548
|
+
}
|
|
549
|
+
previousFramePath = framePath;
|
|
550
|
+
}
|
|
551
|
+
frames.push({
|
|
552
|
+
frame: payload.frameIndex,
|
|
553
|
+
time: payload.frameIndex / options.fps,
|
|
554
|
+
path: streamEncoder ? null : framePath,
|
|
555
|
+
isDuplicate: Boolean(payload.isDuplicate)
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
const runtimeErrors = [];
|
|
559
|
+
page.on('console', (message) => {
|
|
560
|
+
if (message.type() === 'error') {
|
|
561
|
+
const diagnostic = `console: ${message.text()}`;
|
|
562
|
+
runtimeErrors.push(diagnostic);
|
|
563
|
+
console.error(`[browser] ${message.text()}`);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
page.on('pageerror', (error) => {
|
|
567
|
+
runtimeErrors.push(`pageerror: ${error.message}`);
|
|
568
|
+
console.error(`[browser pageerror] ${error.message}\n${error.stack || ''}`);
|
|
569
|
+
});
|
|
570
|
+
page.on('requestfailed', (request) => {
|
|
571
|
+
runtimeErrors.push(`requestfailed: ${request.method()} ${request.url()} (${request.failure()?.errorText ?? 'unknown'})`);
|
|
572
|
+
});
|
|
573
|
+
page.on('response', (response) => {
|
|
574
|
+
if (response.status() >= 400) {
|
|
575
|
+
runtimeErrors.push(`http: ${response.status()} ${response.url()}`);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
await page.goto(url, { waitUntil: 'networkidle' });
|
|
579
|
+
await page.waitForFunction(() => Boolean(window.__VISUALFRIES_RENDER__));
|
|
580
|
+
const info = await page.evaluate(() => window.__VISUALFRIES_RENDER__.init());
|
|
581
|
+
const explanations = [];
|
|
582
|
+
for (const request of options.explain ?? []) {
|
|
583
|
+
explanations.push(await page.evaluate((payload) => window.__VISUALFRIES_RENDER__.explainFrame(payload), { componentId: request.componentId, time: request.frame / options.fps }));
|
|
584
|
+
}
|
|
585
|
+
let rangeSummary;
|
|
586
|
+
if (activeRange) {
|
|
587
|
+
rangeSummary = { framesSkipped: 0 };
|
|
588
|
+
for (const range of effectiveRanges) {
|
|
589
|
+
const summary = await page.evaluate((renderOptions) => window.__VISUALFRIES_RENDER__.renderFrameRange(renderOptions), {
|
|
590
|
+
fromFrame: range.fromFrame,
|
|
591
|
+
toFrame: range.toFrame,
|
|
592
|
+
imageFormat: normalizedImageFormat,
|
|
593
|
+
imageQuality,
|
|
594
|
+
skipDuplicates: options.skipDuplicates ?? false
|
|
595
|
+
});
|
|
596
|
+
rangeSummary.framesSkipped += summary?.framesSkipped ?? 0;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
for (let index = 0; index < options.frameIndices.length; index += 1) {
|
|
601
|
+
const frame = options.frameIndices[index];
|
|
602
|
+
const time = frame / options.fps;
|
|
603
|
+
const framePath = path.join(framesDir, `frame-${String(index + 1).padStart(6, '0')}.${normalizedImageFormat}`);
|
|
604
|
+
if (options.environment === 'client') {
|
|
605
|
+
await page.evaluate((payload) => window.__VISUALFRIES_RENDER__.seek(payload), { time });
|
|
606
|
+
const screenshot = await page.locator('#vf-root').screenshot({
|
|
607
|
+
type: normalizedImageFormat === 'png' ? 'png' : 'jpeg',
|
|
608
|
+
...(normalizedImageFormat === 'png' ? {} : { quality: Math.round(imageQuality * 100) })
|
|
609
|
+
});
|
|
610
|
+
await fs.writeFile(framePath, screenshot);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
const dataUrl = await page.evaluate((renderOptions) => window.__VISUALFRIES_RENDER__.renderFrame(renderOptions), { time, imageFormat: normalizedImageFormat, imageQuality });
|
|
614
|
+
await writeDataUrlFrame(dataUrl, framePath);
|
|
615
|
+
}
|
|
616
|
+
frames.push({ frame, time, path: framePath, isDuplicate: false });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
await page.evaluate(() => window.__VISUALFRIES_RENDER__.destroy());
|
|
620
|
+
let encoded = false;
|
|
621
|
+
if (!framesOnly) {
|
|
622
|
+
if (streamEncoder) {
|
|
623
|
+
const streamResult = await streamEncoder.finish();
|
|
624
|
+
activeStreamEncoder = undefined;
|
|
625
|
+
streamEncodeMs = streamResult.elapsedMs;
|
|
626
|
+
if (audioInput)
|
|
627
|
+
await muxAudioWithVideo({
|
|
628
|
+
videoPath: streamedSilentOutput,
|
|
629
|
+
audioPath: audioInput,
|
|
630
|
+
outputPath
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
await encodeFrames({
|
|
635
|
+
framesDir,
|
|
636
|
+
output: outputPath,
|
|
637
|
+
fps: options.fps,
|
|
638
|
+
imageFormat: normalizedImageFormat,
|
|
639
|
+
audioInput
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
encoded = true;
|
|
643
|
+
}
|
|
644
|
+
const mediaDiagnostics = options.mediaDiagnostics
|
|
645
|
+
? [
|
|
646
|
+
{
|
|
647
|
+
kind: 'visualfries-local-render',
|
|
648
|
+
engine: renderPlan.engine,
|
|
649
|
+
frameCount: frames.length,
|
|
650
|
+
requestedFrameCount: options.frameIndices.length,
|
|
651
|
+
trimAwareRanges: effectiveRanges,
|
|
652
|
+
serverRendererMode: options.serverRendererMode ?? 'canvas',
|
|
653
|
+
transport: streamEncoder
|
|
654
|
+
? 'range-binding-stream-encode'
|
|
655
|
+
: activeRange
|
|
656
|
+
? 'range-binding'
|
|
657
|
+
: 'sparse-evaluate'
|
|
658
|
+
},
|
|
659
|
+
...deterministicMediaDiagnostics
|
|
660
|
+
]
|
|
661
|
+
: [];
|
|
662
|
+
const mediaDiagnosticsPath = options.mediaDiagnostics
|
|
663
|
+
? path.join(framesOnly ? path.resolve(options.output) : path.dirname(outputPath), 'media-diagnostics.json')
|
|
664
|
+
: null;
|
|
665
|
+
if (mediaDiagnosticsPath) {
|
|
666
|
+
await fs.mkdir(path.dirname(mediaDiagnosticsPath), { recursive: true });
|
|
667
|
+
await fs.writeFile(mediaDiagnosticsPath, `${JSON.stringify(mediaDiagnostics, null, 2)}\n`, 'utf8');
|
|
668
|
+
}
|
|
669
|
+
return {
|
|
670
|
+
ok: true,
|
|
671
|
+
scene: info,
|
|
672
|
+
frames: {
|
|
673
|
+
count: frames.length,
|
|
674
|
+
dir: framesDir,
|
|
675
|
+
items: frames,
|
|
676
|
+
transport: streamEncoder
|
|
677
|
+
? 'range-binding-stream-encode'
|
|
678
|
+
: activeRange
|
|
679
|
+
? 'range-binding'
|
|
680
|
+
: 'sparse-evaluate',
|
|
681
|
+
skippedDuplicates: rangeSummary?.framesSkipped ?? 0
|
|
682
|
+
},
|
|
683
|
+
output: outputPath,
|
|
684
|
+
encoded,
|
|
685
|
+
encoding: streamEncoder
|
|
686
|
+
? { mode: 'image2pipe', elapsedMs: streamEncodeMs, audioMuxed: Boolean(audioInput) }
|
|
687
|
+
: { mode: framesOnly ? 'none' : 'frame-sequence' },
|
|
688
|
+
audio: audioMix,
|
|
689
|
+
mediaDiagnosticsPath,
|
|
690
|
+
mediaDiagnostics,
|
|
691
|
+
runtimeErrors: [...new Set(runtimeErrors)],
|
|
692
|
+
...(options.explain ? { explanations } : {})
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
finally {
|
|
696
|
+
activeStreamEncoder?.abort();
|
|
697
|
+
await browser?.close?.();
|
|
698
|
+
await server?.close?.();
|
|
699
|
+
if (!options.keepFrames)
|
|
700
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
701
|
+
}
|
|
702
|
+
}
|