retiam 1.0.0 → 1.0.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/bin/cli.js +213 -209
- package/bin/export.js +322 -322
- package/bin/init.js +425 -425
- package/bin/lint.js +354 -354
- package/bin/render-config.js +86 -0
- package/bin/render.js +517 -600
- package/bin/snapshot.js +231 -231
- package/dist/{UTIF-pBEih-vH.js → UTIF-X7q9996p.js} +1 -1
- package/dist/assets/roboto-msdf.json +3642 -3642
- package/dist/{index-CnM0BhUR.js → index-Be4c9A99.js} +9988 -9941
- package/dist/{main-Bw2LTrnR.js → main-IC5HXR90.js} +1 -1
- package/dist/retiam.es.js +739 -734
- package/dist/src/engine/_core/Material.d.ts +13 -18
- package/dist/src/engine/_core/Scene.d.ts +3 -0
- package/dist/src/engine/_core/SnapshotManager.d.ts +3 -0
- package/dist/src/engine/components/fragment/ModelFragment.d.ts +1 -6
- package/dist/src/engine/material/shading/CelShading.d.ts +16 -0
- package/dist/src/engine/material/shading/ClayShading.d.ts +6 -0
- package/dist/src/engine/material/shading/GoochShading.d.ts +14 -0
- package/dist/src/engine/material/shading/PBRShading.d.ts +6 -0
- package/dist/src/engine/material/shading/Shading.d.ts +17 -0
- package/dist/src/engine/material/shading/index.d.ts +5 -0
- package/dist/src/engine/render/RenderConfig.d.ts +1 -23
- package/dist/src/index.d.ts +1 -0
- package/package.json +107 -116
- package/readme.md +437 -143
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 {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
const scriptStartTime = Date.now();
|
|
17
|
-
const projectRoot = process.cwd(); // Target the user's current working directory
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
fs.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// ---
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
let
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
+
})();
|