retiam 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/render.js CHANGED
@@ -1,600 +1,517 @@
1
- #!/usr/bin/env node
2
-
3
- // =========================================================================
4
- // File: bin/render.js
5
- // =========================================================================
6
-
7
- import { spawn, spawnSync } from 'child_process';
8
- import { WebSocketServer } from 'ws';
9
- import puppeteer from 'puppeteer';
10
- import cliProgress from 'cli-progress';
11
- import fs from 'fs';
12
- import path from 'path';
13
- import { pathToFileURL } from 'url';
14
- import { createServer } from 'vite';
15
-
16
- const scriptStartTime = Date.now();
17
- const projectRoot = process.cwd(); // Target the user's current working directory
18
-
19
- // 1. Resolve and Load Configuration File (retiam.config.js or config.js in project root)
20
- let configPath = path.resolve(projectRoot, 'retiam.config.js');
21
- if (!fs.existsSync(configPath)) {
22
- configPath = path.resolve(projectRoot, 'config.js');
23
- }
24
-
25
- let defaultConfig = {};
26
- let configSource = 'built-in defaults';
27
-
28
- if (fs.existsSync(configPath)) {
29
- try {
30
- const configModule = await import(pathToFileURL(configPath).href);
31
- defaultConfig = configModule.default || {};
32
- configSource = path.basename(configPath);
33
- } catch (err) {
34
- console.warn(`⚠️ Failed to import ${path.basename(configPath)}, falling back to default values.`, err);
35
- }
36
- }
37
-
38
- const args = process.argv.slice(2);
39
-
40
- // Check for --help before parsing options
41
- const isHelp = args.includes('--help') || args.some((arg, idx) => arg === '-h' && (idx + 1 >= args.length || isNaN(parseInt(args[idx + 1], 10))));
42
- if (isHelp) {
43
- console.log(`
44
- 🎬 Retiam WebGPU Engine - Video Renderer
45
-
46
- Usage:
47
- npx retiam render [file] [options]
48
- npx retiam [file] [options]
49
-
50
- Arguments:
51
- [file] Target scene TypeScript file (e.g. src/scenes/AStarScene.ts)
52
- If omitted, renders the default scene defined in src/main.ts.
53
-
54
- Options:
55
- -w, --width <px> Output video width in pixels (default: 1920)
56
- -h, --height <px> Output video height in pixels (default: 1080)
57
- -f, --fps <num> Render frame rate (default: 60)
58
- -d, --duration <ms> Maximum rendering duration limit in milliseconds
59
- -o, --out <dir> Output directory for rendered video (default: ./outputs/video)
60
- --raytrace [true|false] Enable or disable hardware raytracing
61
- --no-raytrace Explicitly disable raytracing
62
- --denoiser <format> Denoiser pipeline ('none', 'spatial', 'oidn', 'native')
63
- --raw Use native raw HDR frame buffer export
64
- --replay <file.json> Replay recorded interactions JSON during rendering
65
- --help Show this help message and exit
66
-
67
- Examples:
68
- npx retiam render src/scenes/AStarScene.ts
69
- npx retiam render MyScene.ts -w 1920 -h 1080 -f 60
70
- npx retiam render MyScene.ts -o ./outputs/video -d 15000
71
- `);
72
- process.exit(0);
73
- }
74
-
75
- let targetFile = null;
76
- if (args[0] && !args[0].startsWith('-')) {
77
- targetFile = args[0];
78
- }
79
-
80
- const config = {
81
- fps: defaultConfig.fps ?? 60,
82
- width: defaultConfig.width ?? 1920,
83
- height: defaultConfig.height ?? 1080,
84
- bitrate: defaultConfig.videoBitrate ?? '20M',
85
- maxDuration: defaultConfig.maxDuration ?? 0,
86
- wsPort: defaultConfig.wsPort ?? 5001,
87
- devServerPort: defaultConfig.devServerPort ?? 5173,
88
- };
89
-
90
- const renderConfigKeys = [
91
- 'raytraceEnabled', 'rtSamples', 'rtMaxAccumulation', 'bvhMethod', 'gamma',
92
- 'denoiser', 'oidnModel', 'sample_count', 'AA', 'AA_Function', 'PPLL'
93
- ];
94
-
95
- const renderConfigOverrides = {};
96
- for (const key of renderConfigKeys) {
97
- if (defaultConfig[key] !== undefined) {
98
- renderConfigOverrides[key] = defaultConfig[key];
99
- }
100
- }
101
-
102
- const flagStartIndex = targetFile ? 1 : 0;
103
- const overrides = [];
104
- let replayFile = null;
105
- let customOutputDir = null;
106
-
107
- for (let i = flagStartIndex; i < args.length; i++) {
108
- if (args[i] === '-w' || args[i] === '--width') { config.width = parseInt(args[++i]); overrides.push(`width: ${config.width}px`); }
109
- else if (args[i] === '-h' || args[i] === '--height') { config.height = parseInt(args[++i]); overrides.push(`height: ${config.height}px`); }
110
- else if (args[i] === '-f' || args[i] === '--fps') { config.fps = parseInt(args[++i]); overrides.push(`fps: ${config.fps}`); }
111
- else if (args[i] === '-d' || args[i] === '--duration') { config.maxDuration = parseInt(args[++i]); overrides.push(`duration: ${config.maxDuration}ms`); }
112
- else if (args[i] === '-o' || args[i] === '--out') {
113
- customOutputDir = path.resolve(projectRoot, args[++i]);
114
- overrides.push(`out: ${customOutputDir}`);
115
- }
116
- else if (args[i] === '--raytrace') {
117
- const val = args[i + 1];
118
- if (val === 'true' || val === 'false') {
119
- renderConfigOverrides.raytraceEnabled = (val === 'true');
120
- i++;
121
- } else {
122
- renderConfigOverrides.raytraceEnabled = true;
123
- }
124
- }
125
- else if (args[i] === '--no-raytrace') {
126
- renderConfigOverrides.raytraceEnabled = false;
127
- }
128
- else if (args[i] === '--denoiser') {
129
- renderConfigOverrides.denoiser = args[++i];
130
- }
131
- else if (args[i] === '--raw') {
132
- renderConfigOverrides.denoiser = 'native';
133
- }
134
- else if (args[i] === '--replay') { replayFile = args[++i]; }
135
- else if (args[i].endsWith('.json') && !replayFile) { replayFile = args[i]; }
136
- }
137
-
138
- // Native OIDN mode requires denoiser === 'native' AND raytraceEnabled
139
- const activeDenoiser = renderConfigOverrides.denoiser || defaultConfig.denoiser || 'none';
140
- const isNativeDenoiser = activeDenoiser === 'native';
141
- config.useRawFrames = Boolean(isNativeDenoiser && renderConfigOverrides.raytraceEnabled !== false);
142
- config.exportAlbedoNormal = isNativeDenoiser;
143
-
144
- const defaultVideoDir = path.resolve(projectRoot, 'outputs/video');
145
- const outputDir = customOutputDir || (defaultConfig.outputDir ? path.resolve(projectRoot, defaultConfig.outputDir) : defaultVideoDir);
146
- const outputFormat = defaultConfig.outputFormat || 'mp4';
147
- const outputFile = path.join(outputDir, `output_${Date.now()}.${outputFormat}`);
148
- const tempVideoFile = path.join(outputDir, `.temp_video_${Date.now()}.${outputFormat}`);
149
- const tempAudioFile = path.join(outputDir, `.temp_audio_${Date.now()}.wav`);
150
- let hasAudio = false;
151
-
152
- if (!fs.existsSync(path.dirname(outputFile))) {
153
- fs.mkdirSync(path.dirname(outputFile), { recursive: true });
154
- }
155
-
156
- // --- DIRECTORY SETUP FOR VIRTUAL MEMORY FRAMES ---
157
- let framesDir = '';
158
- if (config.useRawFrames) {
159
- framesDir = path.join(outputDir, `temp_workspace_${Date.now()}`);
160
- if (!fs.existsSync(framesDir)) fs.mkdirSync(framesDir, { recursive: true });
161
- }
162
-
163
- // --- HELPER: Write WebGPU Buffer to Portable Float Map (PFM) for OIDN ---
164
- function savePFM(filePath, buffer, width, height) {
165
- const header = Buffer.from(`PF\n${width} ${height}\n-1.0\n`);
166
- const bytesPerPixel = 16;
167
- const unalignedBytesPerRow = width * bytesPerPixel;
168
- const bytesPerRow = Math.ceil(unalignedBytesPerRow / 256) * 256;
169
-
170
- const rgbFloats = new Float32Array(width * height * 3);
171
- const dataView = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
172
-
173
- for (let y = 0; y < height; y++) {
174
- const flippedY = height - 1 - y;
175
- for (let x = 0; x < width; x++) {
176
- const srcByteOffset = (y * bytesPerRow) + (x * bytesPerPixel);
177
- const dstFloatOffset = (flippedY * width + x) * 3;
178
-
179
- rgbFloats[dstFloatOffset] = dataView.getFloat32(srcByteOffset, true);
180
- rgbFloats[dstFloatOffset + 1] = dataView.getFloat32(srcByteOffset + 4, true);
181
- rgbFloats[dstFloatOffset + 2] = dataView.getFloat32(srcByteOffset + 8, true);
182
- }
183
- }
184
- const out = Buffer.concat([header, Buffer.from(rgbFloats.buffer)]);
185
- fs.writeFileSync(filePath, out);
186
- }
187
-
188
- // --- HELPER: ACES Film Tonemapping ---
189
- function ACESFilm(x) {
190
- const a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
191
- return Math.max(0, Math.min(1, (x * (a * x + b)) / (x * (c * x + d) + e)));
192
- }
193
-
194
- // --- HELPER: Print Total Time and Exit ---
195
- function printTotalTime(code) {
196
- const totalTimeMs = Date.now() - scriptStartTime;
197
- const hours = Math.floor(totalTimeMs / 3600000);
198
- const minutes = Math.floor((totalTimeMs % 3600000) / 60000);
199
- const seconds = ((totalTimeMs % 60000) / 1000).toFixed(2);
200
-
201
- let timeTaken = '';
202
- if (hours > 0) timeTaken += `${hours}h `;
203
- if (minutes > 0 || hours > 0) timeTaken += `${minutes}m `;
204
- timeTaken += `${seconds}s`;
205
-
206
- console.log(); // Clear progress bar
207
- if (code === 0 || !code) {
208
- console.log(`✅ Rendering Complete! Video saved to: ${outputFile}`);
209
- console.log(`⏱️ Total time taken: ${timeTaken}\n`);
210
- } else {
211
- console.log(`❌ Process Failed with code ${code}.`);
212
- console.log(`⏱️ Time elapsed before failure: ${timeTaken}\n`);
213
- }
214
- process.exit(code || 0);
215
- }
216
-
217
- console.log(`\n⚙️ Configurations loaded from: ${configSource}`);
218
- console.log(` Resolution: ${config.width}x${config.height}`);
219
- console.log(` Frame Rate: ${config.fps} fps`);
220
- if (config.useRawFrames) console.log(` OIDN Denoiser: Native (Memory-to-Memory Virtual Pipeline)`);
221
- else console.log(` Render Mode: Standard Canvas (Direct Video Stream)`);
222
-
223
- let replayEvents = null;
224
- let replayIndex = 0;
225
-
226
- if (replayFile) {
227
- const fullReplayPath = path.resolve(projectRoot, replayFile);
228
- if (fs.existsSync(fullReplayPath)) {
229
- replayEvents = JSON.parse(fs.readFileSync(fullReplayPath, 'utf8'));
230
- }
231
- }
232
-
233
- console.log(`\n🚀 Starting local servers...`);
234
- const userViteConfig = path.resolve(projectRoot, 'vite.config.ts');
235
- const viteServer = await createServer({
236
- configFile: fs.existsSync(userViteConfig) ? userViteConfig : undefined,
237
- root: projectRoot, // Serve from user project root
238
- server: { host: '127.0.0.1', port: config.devServerPort, strictPort: false },
239
- logLevel: 'error' // Silence Vite logs to keep the console clean
240
- });
241
- await viteServer.listen();
242
-
243
- const address = viteServer.httpServer.address();
244
- const actualPort = (address && typeof address === 'object') ? address.port : config.devServerPort;
245
-
246
- // --- STRICT FFMPEG RAW VIDEO DEMUXER FLAGS ---
247
- const ffmpegArgs = config.useRawFrames
248
- ? [
249
- '-y', '-loglevel', 'error',
250
- '-f', 'rawvideo',
251
- '-pixel_format', 'rgb24',
252
- '-video_size', `${config.width}x${config.height}`,
253
- '-framerate', config.fps.toString(),
254
- '-i', '-',
255
- '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-b:v', config.bitrate,
256
- '-pix_fmt', 'yuv420p', tempVideoFile
257
- ]
258
- : [
259
- '-y', '-loglevel', 'error',
260
- '-f', 'image2pipe', '-vcodec', 'mjpeg', '-r', config.fps.toString(),
261
- '-i', '-', '-c:v', 'libx264', '-preset', 'slow', '-crf', '18',
262
- '-b:v', config.bitrate, '-pix_fmt', 'yuv420p', tempVideoFile
263
- ];
264
-
265
- const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, { stdio: ['pipe', 'ignore', 'inherit'] });
266
- ffmpegProcess.on('close', async (code) => {
267
- if (code !== 0) {
268
- if (fs.existsSync(tempVideoFile)) try { fs.unlinkSync(tempVideoFile); } catch (e) {}
269
- if (fs.existsSync(tempAudioFile)) try { fs.unlinkSync(tempAudioFile); } catch (e) {}
270
- printTotalTime(code);
271
- return;
272
- }
273
-
274
- if (hasAudio && fs.existsSync(tempAudioFile) && fs.statSync(tempAudioFile).size > 44) {
275
- console.log(`\n🎛️ Muxing audio and video streams...`);
276
- const isWebm = (outputFormat === 'webm');
277
- const audioCodec = isWebm ? 'libopus' : 'aac';
278
- const audioBitrate = isWebm ? '128k' : '192k';
279
-
280
- const muxArgs = [
281
- '-y', '-loglevel', 'error',
282
- '-i', tempVideoFile,
283
- '-i', tempAudioFile,
284
- '-c:v', 'copy',
285
- '-c:a', audioCodec,
286
- '-b:a', audioBitrate,
287
- '-shortest',
288
- outputFile
289
- ];
290
-
291
- const muxResult = spawnSync('ffmpeg', muxArgs, { stdio: 'inherit' });
292
- if (muxResult.status === 0) {
293
- console.log(`✨ Audio and video successfully muxed.`);
294
- try { fs.unlinkSync(tempVideoFile); } catch (e) {}
295
- try { fs.unlinkSync(tempAudioFile); } catch (e) {}
296
- printTotalTime(0);
297
- } else {
298
- console.warn(`⚠️ FFmpeg muxing failed with code ${muxResult.status}. Falling back to video without audio.`);
299
- try { fs.renameSync(tempVideoFile, outputFile); } catch (e) {}
300
- try { fs.unlinkSync(tempAudioFile); } catch (e) {}
301
- printTotalTime(0);
302
- }
303
- } else {
304
- if (fs.existsSync(tempVideoFile)) {
305
- try { fs.renameSync(tempVideoFile, outputFile); } catch (e) {}
306
- }
307
- if (fs.existsSync(tempAudioFile)) {
308
- try { fs.unlinkSync(tempAudioFile); } catch (e) {}
309
- }
310
- printTotalTime(code);
311
- }
312
- });
313
-
314
- let wss = null;
315
- let actualWsPort = config.wsPort;
316
-
317
- for (let offset = 0; offset < 20; offset++) {
318
- const tryPort = config.wsPort + offset;
319
- try {
320
- wss = await new Promise((resolve, reject) => {
321
- const server = new WebSocketServer({
322
- host: '127.0.0.1',
323
- port: tryPort,
324
- maxPayload: 1024 * 1024 * 500
325
- });
326
- server.on('listening', () => resolve(server));
327
- server.on('error', (err) => reject(err));
328
- });
329
- actualWsPort = tryPort;
330
- break;
331
- } catch (err) {
332
- if (err.code === 'EADDRINUSE' && offset < 19) {
333
- continue;
334
- }
335
- throw err;
336
- }
337
- }
338
- const progressBar = new cliProgress.SingleBar({
339
- format: 'Rendering | {bar} | {percentage}% | Frame: {value}/{total} | Time: {time}ms',
340
- barCompleteChar: '\u2588', barIncompleteChar: '\u2591', hideCursor: true
341
- }, cliProgress.Presets.shades_classic);
342
-
343
- let isProgressStarted = false;
344
- let expectedTotalFrames = config.maxDuration > 0 ? Math.ceil((config.maxDuration / 1000) * config.fps) : 1000;
345
- let browser = null;
346
- let activePage = null;
347
-
348
- const shutdown = async () => {
349
- progressBar.stop();
350
- if (browser) try { await browser.close(); } catch (e) { }
351
- if (viteServer) try { await viteServer.close(); } catch (e) { }
352
-
353
- if (ffmpegProcess) ffmpegProcess.stdin.end();
354
-
355
- // Clean up virtual workspace directory
356
- if (config.useRawFrames && fs.existsSync(framesDir)) {
357
- try { fs.rmSync(framesDir, { recursive: true, force: true }); } catch (e) {}
358
- }
359
-
360
- wss.close();
361
- };
362
-
363
- async function replayPuppeteerEvents(time) {
364
- if (!replayEvents || !activePage || replayIndex >= replayEvents.length) return;
365
- while (replayIndex < replayEvents.length && replayEvents[replayIndex].time <= time) {
366
- const event = replayEvents[replayIndex];
367
- replayIndex++;
368
- const d = event.data;
369
- const x = d.clientX !== undefined ? d.clientX * config.width : 0;
370
- const y = d.clientY !== undefined ? d.clientY * config.height : 0;
371
- try {
372
- if (event.type === 'keydown') { if (d.code || d.key) await activePage.keyboard.down(d.code || d.key); }
373
- else if (event.type === 'keyup') { if (d.code || d.key) await activePage.keyboard.up(d.code || d.key); }
374
- else if (event.type === 'pointerdown' || event.type === 'mousedown') {
375
- await activePage.mouse.move(x, y); await activePage.mouse.down({ button: d.button === 0 ? 'left' : (d.button === 1 ? 'middle' : 'right') });
376
- }
377
- else if (event.type === 'pointerup' || event.type === 'mouseup') {
378
- await activePage.mouse.move(x, y); await activePage.mouse.up({ button: d.button === 0 ? 'left' : (d.button === 1 ? 'middle' : 'right') });
379
- }
380
- else if (event.type === 'pointermove' || event.type === 'mousemove') { await activePage.mouse.move(x, y); }
381
- } catch (err) { }
382
- }
383
- }
384
-
385
- // --- DYNAMIC OIDN & TONEMAP PIPELINE (Uses Virtual Memory Overwriting & Stream Backpressure) ---
386
- async function processAndPipeFrame() {
387
- const colorFile = path.join(framesDir, `temp_color.pfm`);
388
- const albedoFile = path.join(framesDir, `temp_albedo.pfm`);
389
- const normalFile = path.join(framesDir, `temp_normal.pfm`);
390
- const outFile = path.join(framesDir, `temp_denoised.pfm`);
391
-
392
- // 1. Run Native OIDN
393
- const oidnArgs = ['--hdr', colorFile];
394
- if (config.exportAlbedoNormal) {
395
- oidnArgs.push('--albedo', albedoFile, '--normal', normalFile);
396
- }
397
- oidnArgs.push('--output', outFile);
398
-
399
- const result = spawnSync('oidnDenoise', oidnArgs, { stdio: 'ignore' });
400
- if (result.status !== 0) {
401
- console.error(`\n❌ oidnDenoise failed. Ensure the OIDN executable is in your PATH.`);
402
- await shutdown();
403
- process.exit(1);
404
- }
405
-
406
- // 2. Read Denoised Float Map
407
- const pfmData = fs.readFileSync(outFile);
408
- const headerStr = pfmData.toString('utf8', 0, 50);
409
- const match = headerStr.match(/^PF\n(\d+) (\d+)\n-1\.0\n/);
410
- if (!match) throw new Error("Invalid PFM header");
411
-
412
- const width = parseInt(match[1]);
413
- const height = parseInt(match[2]);
414
- const headerBytes = match[0].length;
415
-
416
- const floatBuffer = pfmData.subarray(headerBytes);
417
- const alignedBuffer = floatBuffer.buffer.slice(floatBuffer.byteOffset, floatBuffer.byteOffset + floatBuffer.byteLength);
418
- const floatView = new Float32Array(alignedBuffer);
419
-
420
- // 3. Tonemap directly into a raw RGB byte buffer
421
- const rgbBuffer = Buffer.allocUnsafe(width * height * 3);
422
-
423
- for (let j = 0; j < floatView.length; j += 3) {
424
- let r = ACESFilm(floatView[j]);
425
- let g = ACESFilm(floatView[j+1]);
426
- let b = ACESFilm(floatView[j+2]);
427
-
428
- r = Math.pow(r, 1.0 / 2.2);
429
- g = Math.pow(g, 1.0 / 2.2);
430
- b = Math.pow(b, 1.0 / 2.2);
431
-
432
- const pixelIdx = j / 3;
433
- const x = pixelIdx % width;
434
- const y = Math.floor(pixelIdx / width);
435
- const flippedY = height - 1 - y;
436
-
437
- const targetIdx = (flippedY * width + x) * 3;
438
- rgbBuffer[targetIdx] = Math.max(0, Math.min(255, r * 255));
439
- rgbBuffer[targetIdx + 1] = Math.max(0, Math.min(255, g * 255));
440
- rgbBuffer[targetIdx + 2] = Math.max(0, Math.min(255, b * 255));
441
- }
442
-
443
- // 4. Send to FFmpeg and WAIT if the pipe is full (Node.js Stream Backpressure)
444
- const canWrite = ffmpegProcess.stdin.write(rgbBuffer);
445
- if (!canWrite) {
446
- await new Promise(resolve => ffmpegProcess.stdin.once('drain', resolve));
447
- }
448
- }
449
-
450
- wss.on('connection', (ws) => {
451
- let nextIsBinary = false;
452
- let nextIsAudioBinary = false;
453
- let currentTime = 0;
454
- let currentFrameType = 'color';
455
- let currentIsFloat = false;
456
-
457
- ws.on('message', async (message) => {
458
- if (nextIsAudioBinary) {
459
- nextIsAudioBinary = false;
460
- try {
461
- fs.writeFileSync(tempAudioFile, Buffer.from(message));
462
- hasAudio = true;
463
- console.log(`\n🎵 Audio track received (${Buffer.byteLength(message)} bytes).`);
464
- } catch (err) {
465
- console.error(`⚠️ Failed to save offline audio track:`, err);
466
- }
467
- ws.send("ack");
468
- return;
469
- }
470
-
471
- if (nextIsBinary) {
472
- nextIsBinary = false;
473
-
474
- if (config.useRawFrames && currentIsFloat) {
475
- const filePath = path.join(framesDir, `temp_${currentFrameType}.pfm`);
476
- savePFM(filePath, message, config.width, config.height);
477
-
478
- let isFrameComplete = false;
479
- if (config.exportAlbedoNormal && currentFrameType === 'normal') isFrameComplete = true;
480
- if (!config.exportAlbedoNormal && currentFrameType === 'color') isFrameComplete = true;
481
-
482
- if (isFrameComplete) {
483
- await processAndPipeFrame();
484
-
485
- if (!isProgressStarted) {
486
- console.log("🎬 Rendering in progress...");
487
- progressBar.start(expectedTotalFrames, 0);
488
- isProgressStarted = true;
489
- }
490
- progressBar.increment(1, { time: currentTime.toFixed(0) });
491
- }
492
- }
493
- else if (!config.useRawFrames && currentFrameType === 'color' && !currentIsFloat) {
494
- const canWrite = ffmpegProcess.stdin.write(message);
495
- if (!canWrite) {
496
- await new Promise(resolve => ffmpegProcess.stdin.once('drain', resolve));
497
- }
498
- if (!isProgressStarted) {
499
- console.log("🎬 Rendering in progress...");
500
- progressBar.start(expectedTotalFrames, 0);
501
- isProgressStarted = true;
502
- }
503
- progressBar.increment(1, { time: currentTime.toFixed(0) });
504
- }
505
-
506
- ws.send("ack");
507
- } else {
508
- try {
509
- const data = JSON.parse(message.toString());
510
- if (data.type === 'request_replay') {
511
- await replayPuppeteerEvents(data.time);
512
- ws.send("replay_done");
513
- } else if (data.type === 'frame') {
514
- currentTime = data.time;
515
- currentFrameType = data.frameType || 'color';
516
- currentIsFloat = data.isFloat || false;
517
- nextIsBinary = true;
518
- } else if (data.type === 'audio') {
519
- nextIsAudioBinary = true;
520
- } else if (data.type === 'finish') {
521
- await shutdown();
522
- }
523
- } catch (err) { }
524
- }
525
- });
526
- });
527
-
528
- process.on('SIGINT', async () => {
529
- await shutdown();
530
- if (fs.existsSync(tempVideoFile)) try { fs.unlinkSync(tempVideoFile); } catch (e) {}
531
- if (fs.existsSync(tempAudioFile)) try { fs.unlinkSync(tempAudioFile); } catch (e) {}
532
- process.exit(1);
533
- });
534
-
535
- (async () => {
536
- console.log(`🌐 Launching Headless Chrome...`);
537
- browser = await puppeteer.launch({
538
- headless: "new",
539
- args: [
540
- '--enable-unsafe-webgpu',
541
- '--disable-web-security',
542
- '--no-sandbox',
543
- '--enable-experimental-web-platform-features',
544
- '--autoplay-policy=no-user-gesture-required',
545
- '--disable-background-networking'
546
- ],
547
- channel: 'chrome'
548
- });
549
- activePage = await browser.newPage();
550
- activePage.on('console', async msg => {
551
- let text = msg.text();
552
- try {
553
- const args = await Promise.all(msg.args().map(async a => {
554
- try {
555
- return await a.evaluate(val => {
556
- if (val instanceof Error) return `${val.name}: ${val.message}\n${val.stack}`;
557
- if (typeof val === 'object') {
558
- try { return JSON.stringify(val); } catch { return String(val); }
559
- }
560
- return String(val);
561
- });
562
- } catch {
563
- return a.toString();
564
- }
565
- }));
566
- if (args.length > 0) text = args.join(' ');
567
- } catch {}
568
- if (text.includes('ERR_NETWORK_CHANGED') || text.includes('favicon.ico')) return;
569
- console.log(`🌐 [Browser Console] [${msg.type()}] ${text}`);
570
- });
571
- activePage.on('pageerror', err => {
572
- console.error(`🌐 [Browser Error]`, err);
573
- });
574
- activePage.on('response', res => {
575
- if (res.status() >= 400 && !res.url().includes('favicon.ico')) {
576
- console.log(`🌐 [HTTP ${res.status()}] ${res.url()}`);
577
- }
578
- });
579
- await activePage.setViewport({ width: config.width, height: config.height, deviceScaleFactor: 1.0 });
580
-
581
- let url = `http://127.0.0.1:${actualPort}?offline=true&width=${config.width}&height=${config.height}&fps=${config.fps}&maxDuration=${config.maxDuration}&wsUrl=ws://127.0.0.1:${actualWsPort}`;
582
- if (targetFile) url += `&file=${targetFile}`;
583
- if (Object.keys(renderConfigOverrides).length > 0) url += `&renderConfig=${encodeURIComponent(JSON.stringify(renderConfigOverrides))}`;
584
-
585
- let retries = 3;
586
- while (retries > 0) {
587
- try {
588
- await activePage.goto(url, { waitUntil: 'domcontentloaded' });
589
- break;
590
- } catch (e) {
591
- retries--;
592
- if (retries === 0) {
593
- console.error(`\n❌ Failed to connect to local server:`, e.message || e);
594
- await shutdown();
595
- process.exit(1);
596
- }
597
- await new Promise(r => setTimeout(r, 800));
598
- }
599
- }
600
- })();
1
+ #!/usr/bin/env node
2
+
3
+ // =========================================================================
4
+ // File: bin/render.js
5
+ // =========================================================================
6
+
7
+ import { spawn, spawnSync } from 'child_process';
8
+ import { WebSocketServer } from 'ws';
9
+ import puppeteer from 'puppeteer';
10
+ import cliProgress from 'cli-progress';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { createServer } from 'vite';
14
+ import { loadRenderConfig, resolveRenderOptions } from './render-config.js';
15
+
16
+ const scriptStartTime = Date.now();
17
+ const projectRoot = process.cwd(); // Target the user's current working directory
18
+
19
+ const { defaultConfig, configSource } = await loadRenderConfig(projectRoot);
20
+
21
+ const args = process.argv.slice(2);
22
+
23
+ // Check for --help before parsing options
24
+ const isHelp = args.includes('--help') || args.some((arg, idx) => arg === '-h' && (idx + 1 >= args.length || isNaN(parseInt(args[idx + 1], 10))));
25
+ if (isHelp) {
26
+ console.log(`
27
+ 🎬 Retiam WebGPU Engine - Video Renderer
28
+
29
+ Usage:
30
+ npx retiam render [file] [options]
31
+ npx retiam [file] [options]
32
+
33
+ Arguments:
34
+ [file] Target scene TypeScript file (e.g. src/scenes/AStarScene.ts)
35
+ If omitted, renders the default scene defined in src/main.ts.
36
+
37
+ Options:
38
+ -w, --width <px> Output video width in pixels (default: 1920)
39
+ -h, --height <px> Output video height in pixels (default: 1080)
40
+ -f, --fps <num> Render frame rate (default: 60)
41
+ -d, --duration <ms> Maximum rendering duration limit in milliseconds
42
+ -o, --out <dir> Output directory for rendered video (default: ./outputs/video)
43
+ --raytrace [true|false] Enable or disable hardware raytracing
44
+ --no-raytrace Explicitly disable raytracing
45
+ --denoiser <format> Denoiser pipeline ('none', 'spatial', 'oidn', 'native')
46
+ --raw Use native raw HDR frame buffer export
47
+ --replay <file.json> Replay recorded interactions JSON during rendering
48
+ --help Show this help message and exit
49
+
50
+ Examples:
51
+ npx retiam render src/scenes/AStarScene.ts
52
+ npx retiam render MyScene.ts -w 1920 -h 1080 -f 60
53
+ npx retiam render MyScene.ts -o ./outputs/video -d 15000
54
+
55
+ Configuration:
56
+ Reads config.js in the project root (retiam.config.js takes priority).
57
+ Command-line flags override file settings. Duration is in milliseconds.
58
+ `);
59
+ process.exit(0);
60
+ }
61
+
62
+ const { config, renderConfigOverrides, targetFile, replayFile, outputDir, outputFormat } =
63
+ resolveRenderOptions(defaultConfig, args, projectRoot);
64
+ const outputFile = path.join(outputDir, `output_${Date.now()}.${outputFormat}`);
65
+ const tempVideoFile = path.join(outputDir, `.temp_video_${Date.now()}.${outputFormat}`);
66
+ const tempAudioFile = path.join(outputDir, `.temp_audio_${Date.now()}.wav`);
67
+ let hasAudio = false;
68
+
69
+ if (!fs.existsSync(path.dirname(outputFile))) {
70
+ fs.mkdirSync(path.dirname(outputFile), { recursive: true });
71
+ }
72
+
73
+ // --- DIRECTORY SETUP FOR VIRTUAL MEMORY FRAMES ---
74
+ let framesDir = '';
75
+ if (config.useRawFrames) {
76
+ framesDir = path.join(outputDir, `temp_workspace_${Date.now()}`);
77
+ if (!fs.existsSync(framesDir)) fs.mkdirSync(framesDir, { recursive: true });
78
+ }
79
+
80
+ // --- HELPER: Write WebGPU Buffer to Portable Float Map (PFM) for OIDN ---
81
+ function savePFM(filePath, buffer, width, height) {
82
+ const header = Buffer.from(`PF\n${width} ${height}\n-1.0\n`);
83
+ const bytesPerPixel = 16;
84
+ const unalignedBytesPerRow = width * bytesPerPixel;
85
+ const bytesPerRow = Math.ceil(unalignedBytesPerRow / 256) * 256;
86
+
87
+ const rgbFloats = new Float32Array(width * height * 3);
88
+ const dataView = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
89
+
90
+ for (let y = 0; y < height; y++) {
91
+ const flippedY = height - 1 - y;
92
+ for (let x = 0; x < width; x++) {
93
+ const srcByteOffset = (y * bytesPerRow) + (x * bytesPerPixel);
94
+ const dstFloatOffset = (flippedY * width + x) * 3;
95
+
96
+ rgbFloats[dstFloatOffset] = dataView.getFloat32(srcByteOffset, true);
97
+ rgbFloats[dstFloatOffset + 1] = dataView.getFloat32(srcByteOffset + 4, true);
98
+ rgbFloats[dstFloatOffset + 2] = dataView.getFloat32(srcByteOffset + 8, true);
99
+ }
100
+ }
101
+ const out = Buffer.concat([header, Buffer.from(rgbFloats.buffer)]);
102
+ fs.writeFileSync(filePath, out);
103
+ }
104
+
105
+ // --- HELPER: ACES Film Tonemapping ---
106
+ function ACESFilm(x) {
107
+ const a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
108
+ return Math.max(0, Math.min(1, (x * (a * x + b)) / (x * (c * x + d) + e)));
109
+ }
110
+
111
+ // --- HELPER: Print Total Time and Exit ---
112
+ function printTotalTime(code) {
113
+ const totalTimeMs = Date.now() - scriptStartTime;
114
+ const hours = Math.floor(totalTimeMs / 3600000);
115
+ const minutes = Math.floor((totalTimeMs % 3600000) / 60000);
116
+ const seconds = ((totalTimeMs % 60000) / 1000).toFixed(2);
117
+
118
+ let timeTaken = '';
119
+ if (hours > 0) timeTaken += `${hours}h `;
120
+ if (minutes > 0 || hours > 0) timeTaken += `${minutes}m `;
121
+ timeTaken += `${seconds}s`;
122
+
123
+ console.log(); // Clear progress bar
124
+ if (code === 0 || !code) {
125
+ console.log(`✅ Rendering Complete! Video saved to: ${outputFile}`);
126
+ console.log(`⏱️ Total time taken: ${timeTaken}\n`);
127
+ } else {
128
+ console.log(`❌ Process Failed with code ${code}.`);
129
+ console.log(`⏱️ Time elapsed before failure: ${timeTaken}\n`);
130
+ }
131
+ process.exit(code || 0);
132
+ }
133
+
134
+ console.log(`\n⚙️ Configurations loaded from: ${configSource}`);
135
+ console.log(` Resolution: ${config.width}x${config.height}`);
136
+ console.log(` Frame Rate: ${config.fps} fps`);
137
+ if (config.useRawFrames) console.log(` OIDN Denoiser: Native (Memory-to-Memory Virtual Pipeline)`);
138
+ else console.log(` Render Mode: Standard Canvas (Direct Video Stream)`);
139
+
140
+ let replayEvents = null;
141
+ let replayIndex = 0;
142
+
143
+ if (replayFile) {
144
+ const fullReplayPath = path.resolve(projectRoot, replayFile);
145
+ if (fs.existsSync(fullReplayPath)) {
146
+ replayEvents = JSON.parse(fs.readFileSync(fullReplayPath, 'utf8'));
147
+ }
148
+ }
149
+
150
+ console.log(`\n🚀 Starting local servers...`);
151
+ const userViteConfig = path.resolve(projectRoot, 'vite.config.ts');
152
+ const viteServer = await createServer({
153
+ configFile: fs.existsSync(userViteConfig) ? userViteConfig : undefined,
154
+ root: projectRoot, // Serve from user project root
155
+ server: { host: '127.0.0.1', port: config.devServerPort, strictPort: false, open: false },
156
+ logLevel: 'error' // Silence Vite logs to keep the console clean
157
+ });
158
+ await viteServer.listen();
159
+
160
+ const address = viteServer.httpServer.address();
161
+ const actualPort = (address && typeof address === 'object') ? address.port : config.devServerPort;
162
+
163
+ // --- STRICT FFMPEG RAW VIDEO DEMUXER FLAGS ---
164
+ const ffmpegArgs = config.useRawFrames
165
+ ? [
166
+ '-y', '-loglevel', 'error',
167
+ '-f', 'rawvideo',
168
+ '-pixel_format', 'rgb24',
169
+ '-video_size', `${config.width}x${config.height}`,
170
+ '-framerate', config.fps.toString(),
171
+ '-i', '-',
172
+ '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-b:v', config.bitrate,
173
+ '-pix_fmt', 'yuv420p', tempVideoFile
174
+ ]
175
+ : [
176
+ '-y', '-loglevel', 'error',
177
+ '-f', 'image2pipe', '-vcodec', 'mjpeg', '-r', config.fps.toString(),
178
+ '-i', '-', '-c:v', 'libx264', '-preset', 'slow', '-crf', '18',
179
+ '-b:v', config.bitrate, '-pix_fmt', 'yuv420p', tempVideoFile
180
+ ];
181
+
182
+ const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, { stdio: ['pipe', 'ignore', 'inherit'] });
183
+ ffmpegProcess.on('close', async (code) => {
184
+ if (code !== 0) {
185
+ if (fs.existsSync(tempVideoFile)) try { fs.unlinkSync(tempVideoFile); } catch (e) {}
186
+ if (fs.existsSync(tempAudioFile)) try { fs.unlinkSync(tempAudioFile); } catch (e) {}
187
+ printTotalTime(code);
188
+ return;
189
+ }
190
+
191
+ if (hasAudio && fs.existsSync(tempAudioFile) && fs.statSync(tempAudioFile).size > 44) {
192
+ console.log(`\n🎛️ Muxing audio and video streams...`);
193
+ const isWebm = (outputFormat === 'webm');
194
+ const audioCodec = isWebm ? 'libopus' : 'aac';
195
+ const audioBitrate = isWebm ? '128k' : '192k';
196
+
197
+ const muxArgs = [
198
+ '-y', '-loglevel', 'error',
199
+ '-i', tempVideoFile,
200
+ '-i', tempAudioFile,
201
+ '-c:v', 'copy',
202
+ '-c:a', audioCodec,
203
+ '-b:a', audioBitrate,
204
+ '-shortest',
205
+ outputFile
206
+ ];
207
+
208
+ const muxResult = spawnSync('ffmpeg', muxArgs, { stdio: 'inherit' });
209
+ if (muxResult.status === 0) {
210
+ console.log(`✨ Audio and video successfully muxed.`);
211
+ try { fs.unlinkSync(tempVideoFile); } catch (e) {}
212
+ try { fs.unlinkSync(tempAudioFile); } catch (e) {}
213
+ printTotalTime(0);
214
+ } else {
215
+ console.warn(`⚠️ FFmpeg muxing failed with code ${muxResult.status}. Falling back to video without audio.`);
216
+ try { fs.renameSync(tempVideoFile, outputFile); } catch (e) {}
217
+ try { fs.unlinkSync(tempAudioFile); } catch (e) {}
218
+ printTotalTime(0);
219
+ }
220
+ } else {
221
+ if (fs.existsSync(tempVideoFile)) {
222
+ try { fs.renameSync(tempVideoFile, outputFile); } catch (e) {}
223
+ }
224
+ if (fs.existsSync(tempAudioFile)) {
225
+ try { fs.unlinkSync(tempAudioFile); } catch (e) {}
226
+ }
227
+ printTotalTime(code);
228
+ }
229
+ });
230
+
231
+ let wss = null;
232
+ let actualWsPort = config.wsPort;
233
+
234
+ for (let offset = 0; offset < 20; offset++) {
235
+ const tryPort = config.wsPort + offset;
236
+ try {
237
+ wss = await new Promise((resolve, reject) => {
238
+ const server = new WebSocketServer({
239
+ host: '127.0.0.1',
240
+ port: tryPort,
241
+ maxPayload: 1024 * 1024 * 500
242
+ });
243
+ server.on('listening', () => resolve(server));
244
+ server.on('error', (err) => reject(err));
245
+ });
246
+ actualWsPort = tryPort;
247
+ break;
248
+ } catch (err) {
249
+ if (err.code === 'EADDRINUSE' && offset < 19) {
250
+ continue;
251
+ }
252
+ throw err;
253
+ }
254
+ }
255
+ const progressBar = new cliProgress.SingleBar({
256
+ format: 'Rendering | {bar} | {percentage}% | Frame: {value}/{total} | Time: {time}ms',
257
+ barCompleteChar: '\u2588', barIncompleteChar: '\u2591', hideCursor: true
258
+ }, cliProgress.Presets.shades_classic);
259
+
260
+ let isProgressStarted = false;
261
+ let expectedTotalFrames = config.maxDuration > 0 ? Math.ceil((config.maxDuration / 1000) * config.fps) : 1000;
262
+ let browser = null;
263
+ let activePage = null;
264
+
265
+ const shutdown = async () => {
266
+ progressBar.stop();
267
+ if (browser) try { await browser.close(); } catch (e) { }
268
+ if (viteServer) try { await viteServer.close(); } catch (e) { }
269
+
270
+ if (ffmpegProcess) ffmpegProcess.stdin.end();
271
+
272
+ // Clean up virtual workspace directory
273
+ if (config.useRawFrames && fs.existsSync(framesDir)) {
274
+ try { fs.rmSync(framesDir, { recursive: true, force: true }); } catch (e) {}
275
+ }
276
+
277
+ wss.close();
278
+ };
279
+
280
+ async function replayPuppeteerEvents(time) {
281
+ if (!replayEvents || !activePage || replayIndex >= replayEvents.length) return;
282
+ while (replayIndex < replayEvents.length && replayEvents[replayIndex].time <= time) {
283
+ const event = replayEvents[replayIndex];
284
+ replayIndex++;
285
+ const d = event.data;
286
+ const x = d.clientX !== undefined ? d.clientX * config.width : 0;
287
+ const y = d.clientY !== undefined ? d.clientY * config.height : 0;
288
+ try {
289
+ if (event.type === 'keydown') { if (d.code || d.key) await activePage.keyboard.down(d.code || d.key); }
290
+ else if (event.type === 'keyup') { if (d.code || d.key) await activePage.keyboard.up(d.code || d.key); }
291
+ else if (event.type === 'pointerdown' || event.type === 'mousedown') {
292
+ await activePage.mouse.move(x, y); await activePage.mouse.down({ button: d.button === 0 ? 'left' : (d.button === 1 ? 'middle' : 'right') });
293
+ }
294
+ else if (event.type === 'pointerup' || event.type === 'mouseup') {
295
+ await activePage.mouse.move(x, y); await activePage.mouse.up({ button: d.button === 0 ? 'left' : (d.button === 1 ? 'middle' : 'right') });
296
+ }
297
+ else if (event.type === 'pointermove' || event.type === 'mousemove') { await activePage.mouse.move(x, y); }
298
+ } catch (err) { }
299
+ }
300
+ }
301
+
302
+ // --- DYNAMIC OIDN & TONEMAP PIPELINE (Uses Virtual Memory Overwriting & Stream Backpressure) ---
303
+ async function processAndPipeFrame() {
304
+ const colorFile = path.join(framesDir, `temp_color.pfm`);
305
+ const albedoFile = path.join(framesDir, `temp_albedo.pfm`);
306
+ const normalFile = path.join(framesDir, `temp_normal.pfm`);
307
+ const outFile = path.join(framesDir, `temp_denoised.pfm`);
308
+
309
+ // 1. Run Native OIDN
310
+ const oidnArgs = ['--hdr', colorFile];
311
+ if (config.exportAlbedoNormal) {
312
+ oidnArgs.push('--albedo', albedoFile, '--normal', normalFile);
313
+ }
314
+ oidnArgs.push('--output', outFile);
315
+
316
+ const result = spawnSync('oidnDenoise', oidnArgs, { stdio: 'ignore' });
317
+ if (result.status !== 0) {
318
+ console.error(`\n❌ oidnDenoise failed. Ensure the OIDN executable is in your PATH.`);
319
+ await shutdown();
320
+ process.exit(1);
321
+ }
322
+
323
+ // 2. Read Denoised Float Map
324
+ const pfmData = fs.readFileSync(outFile);
325
+ const headerStr = pfmData.toString('utf8', 0, 50);
326
+ const match = headerStr.match(/^PF\n(\d+) (\d+)\n-1\.0\n/);
327
+ if (!match) throw new Error("Invalid PFM header");
328
+
329
+ const width = parseInt(match[1]);
330
+ const height = parseInt(match[2]);
331
+ const headerBytes = match[0].length;
332
+
333
+ const floatBuffer = pfmData.subarray(headerBytes);
334
+ const alignedBuffer = floatBuffer.buffer.slice(floatBuffer.byteOffset, floatBuffer.byteOffset + floatBuffer.byteLength);
335
+ const floatView = new Float32Array(alignedBuffer);
336
+
337
+ // 3. Tonemap directly into a raw RGB byte buffer
338
+ const rgbBuffer = Buffer.allocUnsafe(width * height * 3);
339
+
340
+ for (let j = 0; j < floatView.length; j += 3) {
341
+ let r = ACESFilm(floatView[j]);
342
+ let g = ACESFilm(floatView[j+1]);
343
+ let b = ACESFilm(floatView[j+2]);
344
+
345
+ r = Math.pow(r, 1.0 / 2.2);
346
+ g = Math.pow(g, 1.0 / 2.2);
347
+ b = Math.pow(b, 1.0 / 2.2);
348
+
349
+ const pixelIdx = j / 3;
350
+ const x = pixelIdx % width;
351
+ const y = Math.floor(pixelIdx / width);
352
+ const flippedY = height - 1 - y;
353
+
354
+ const targetIdx = (flippedY * width + x) * 3;
355
+ rgbBuffer[targetIdx] = Math.max(0, Math.min(255, r * 255));
356
+ rgbBuffer[targetIdx + 1] = Math.max(0, Math.min(255, g * 255));
357
+ rgbBuffer[targetIdx + 2] = Math.max(0, Math.min(255, b * 255));
358
+ }
359
+
360
+ // 4. Send to FFmpeg and WAIT if the pipe is full (Node.js Stream Backpressure)
361
+ const canWrite = ffmpegProcess.stdin.write(rgbBuffer);
362
+ if (!canWrite) {
363
+ await new Promise(resolve => ffmpegProcess.stdin.once('drain', resolve));
364
+ }
365
+ }
366
+
367
+ wss.on('connection', (ws) => {
368
+ let nextIsBinary = false;
369
+ let nextIsAudioBinary = false;
370
+ let currentTime = 0;
371
+ let currentFrameType = 'color';
372
+ let currentIsFloat = false;
373
+
374
+ ws.on('message', async (message) => {
375
+ if (nextIsAudioBinary) {
376
+ nextIsAudioBinary = false;
377
+ try {
378
+ fs.writeFileSync(tempAudioFile, Buffer.from(message));
379
+ hasAudio = true;
380
+ console.log(`\n🎵 Audio track received (${Buffer.byteLength(message)} bytes).`);
381
+ } catch (err) {
382
+ console.error(`⚠️ Failed to save offline audio track:`, err);
383
+ }
384
+ ws.send("ack");
385
+ return;
386
+ }
387
+
388
+ if (nextIsBinary) {
389
+ nextIsBinary = false;
390
+
391
+ if (config.useRawFrames && currentIsFloat) {
392
+ const filePath = path.join(framesDir, `temp_${currentFrameType}.pfm`);
393
+ savePFM(filePath, message, config.width, config.height);
394
+
395
+ let isFrameComplete = false;
396
+ if (config.exportAlbedoNormal && currentFrameType === 'normal') isFrameComplete = true;
397
+ if (!config.exportAlbedoNormal && currentFrameType === 'color') isFrameComplete = true;
398
+
399
+ if (isFrameComplete) {
400
+ await processAndPipeFrame();
401
+
402
+ if (!isProgressStarted) {
403
+ console.log("🎬 Rendering in progress...");
404
+ progressBar.start(expectedTotalFrames, 0);
405
+ isProgressStarted = true;
406
+ }
407
+ progressBar.increment(1, { time: currentTime.toFixed(0) });
408
+ }
409
+ }
410
+ else if (!config.useRawFrames && currentFrameType === 'color' && !currentIsFloat) {
411
+ const canWrite = ffmpegProcess.stdin.write(message);
412
+ if (!canWrite) {
413
+ await new Promise(resolve => ffmpegProcess.stdin.once('drain', resolve));
414
+ }
415
+ if (!isProgressStarted) {
416
+ console.log("🎬 Rendering in progress...");
417
+ progressBar.start(expectedTotalFrames, 0);
418
+ isProgressStarted = true;
419
+ }
420
+ progressBar.increment(1, { time: currentTime.toFixed(0) });
421
+ }
422
+
423
+ ws.send("ack");
424
+ } else {
425
+ try {
426
+ const data = JSON.parse(message.toString());
427
+ if (data.type === 'request_replay') {
428
+ await replayPuppeteerEvents(data.time);
429
+ ws.send("replay_done");
430
+ } else if (data.type === 'frame') {
431
+ currentTime = data.time;
432
+ currentFrameType = data.frameType || 'color';
433
+ currentIsFloat = data.isFloat || false;
434
+ nextIsBinary = true;
435
+ } else if (data.type === 'audio') {
436
+ nextIsAudioBinary = true;
437
+ } else if (data.type === 'finish') {
438
+ await shutdown();
439
+ }
440
+ } catch (err) { }
441
+ }
442
+ });
443
+ });
444
+
445
+ process.on('SIGINT', async () => {
446
+ await shutdown();
447
+ if (fs.existsSync(tempVideoFile)) try { fs.unlinkSync(tempVideoFile); } catch (e) {}
448
+ if (fs.existsSync(tempAudioFile)) try { fs.unlinkSync(tempAudioFile); } catch (e) {}
449
+ process.exit(1);
450
+ });
451
+
452
+ (async () => {
453
+ console.log(`🌐 Launching Headless Chrome...`);
454
+ browser = await puppeteer.launch({
455
+ headless: "new",
456
+ args: [
457
+ '--enable-unsafe-webgpu',
458
+ '--disable-web-security',
459
+ '--no-sandbox',
460
+ '--enable-experimental-web-platform-features',
461
+ '--autoplay-policy=no-user-gesture-required',
462
+ '--disable-background-networking'
463
+ ],
464
+ channel: 'chrome'
465
+ });
466
+ activePage = await browser.newPage();
467
+ activePage.on('console', async msg => {
468
+ let text = msg.text();
469
+ try {
470
+ const args = await Promise.all(msg.args().map(async a => {
471
+ try {
472
+ return await a.evaluate(val => {
473
+ if (val instanceof Error) return `${val.name}: ${val.message}\n${val.stack}`;
474
+ if (typeof val === 'object') {
475
+ try { return JSON.stringify(val); } catch { return String(val); }
476
+ }
477
+ return String(val);
478
+ });
479
+ } catch {
480
+ return a.toString();
481
+ }
482
+ }));
483
+ if (args.length > 0) text = args.join(' ');
484
+ } catch {}
485
+ if (text.includes('ERR_NETWORK_CHANGED') || text.includes('favicon.ico')) return;
486
+ console.log(`🌐 [Browser Console] [${msg.type()}] ${text}`);
487
+ });
488
+ activePage.on('pageerror', err => {
489
+ console.error(`🌐 [Browser Error]`, err);
490
+ });
491
+ activePage.on('response', res => {
492
+ if (res.status() >= 400 && !res.url().includes('favicon.ico')) {
493
+ console.log(`🌐 [HTTP ${res.status()}] ${res.url()}`);
494
+ }
495
+ });
496
+ await activePage.setViewport({ width: config.width, height: config.height, deviceScaleFactor: 1.0 });
497
+
498
+ let url = `http://127.0.0.1:${actualPort}?offline=true&width=${config.width}&height=${config.height}&fps=${config.fps}&maxDuration=${config.maxDuration}&wsUrl=ws://127.0.0.1:${actualWsPort}`;
499
+ if (targetFile) url += `&file=${encodeURIComponent(targetFile)}`;
500
+ if (Object.keys(renderConfigOverrides).length > 0) url += `&renderConfig=${encodeURIComponent(JSON.stringify(renderConfigOverrides))}`;
501
+
502
+ let retries = 3;
503
+ while (retries > 0) {
504
+ try {
505
+ await activePage.goto(url, { waitUntil: 'domcontentloaded' });
506
+ break;
507
+ } catch (e) {
508
+ retries--;
509
+ if (retries === 0) {
510
+ console.error(`\n❌ Failed to connect to local server:`, e.message || e);
511
+ await shutdown();
512
+ process.exit(1);
513
+ }
514
+ await new Promise(r => setTimeout(r, 800));
515
+ }
516
+ }
517
+ })();