sketchmark 2.0.0 → 2.1.0
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/ANIMATABLE_MATRIX.md +177 -0
- package/KERNEL_SPEC.md +412 -0
- package/PACKS.md +81 -0
- package/PRESETS.md +182 -0
- package/README.md +274 -188
- package/bin/editor-ui.cjs +2285 -0
- package/bin/preview-ui.cjs +74 -0
- package/bin/sketchmark.cjs +648 -2008
- package/dist/src/animatable.d.ts +21 -0
- package/dist/src/animatable.js +439 -0
- package/dist/src/builders/index.d.ts +1 -11
- package/dist/src/builders/index.js +1 -19
- package/dist/src/diagnostics.js +1 -64
- package/dist/src/edit.d.ts +27 -0
- package/dist/src/edit.js +162 -0
- package/dist/src/index.d.ts +4 -13
- package/dist/src/index.js +4 -13
- package/dist/src/keyframes.d.ts +48 -0
- package/dist/src/keyframes.js +182 -0
- package/dist/src/motion.d.ts +4 -0
- package/dist/src/motion.js +262 -0
- package/dist/src/normalize.js +120 -151
- package/dist/src/presets/characters.d.ts +15 -0
- package/dist/src/presets/characters.js +113 -0
- package/dist/src/presets/compose.d.ts +5 -0
- package/dist/src/presets/compose.js +80 -0
- package/dist/src/presets/effects.d.ts +40 -0
- package/dist/src/presets/effects.js +79 -0
- package/dist/src/presets/helpers.d.ts +33 -0
- package/dist/src/presets/helpers.js +165 -0
- package/dist/src/presets/index.d.ts +9 -0
- package/dist/src/presets/index.js +48 -0
- package/dist/src/presets/motions.d.ts +33 -0
- package/dist/src/presets/motions.js +75 -0
- package/dist/src/presets/scenes.d.ts +35 -0
- package/dist/src/presets/scenes.js +134 -0
- package/dist/src/presets/shapes.d.ts +71 -0
- package/dist/src/presets/shapes.js +96 -0
- package/dist/src/presets/transitions.d.ts +29 -0
- package/dist/src/presets/transitions.js +113 -0
- package/dist/src/presets/types.d.ts +34 -0
- package/dist/src/presets/types.js +2 -0
- package/dist/src/render/html.js +1 -4
- package/dist/src/render/svg.d.ts +2 -2
- package/dist/src/render/svg.js +86 -82
- package/dist/src/render/three-html.js +67 -113
- package/dist/src/scenes.js +1 -0
- package/dist/src/schema.js +218 -280
- package/dist/src/shapes/builtins.js +11 -47
- package/dist/src/shapes/common.js +12 -11
- package/dist/src/shapes/registry.d.ts +0 -1
- package/dist/src/shapes/registry.js +0 -4
- package/dist/src/shapes/types.d.ts +1 -3
- package/dist/src/types.d.ts +57 -288
- package/dist/src/utils.d.ts +2 -11
- package/dist/src/utils.js +13 -70
- package/dist/src/validate.js +321 -275
- package/dist/tests/run.js +576 -510
- package/examples/1730642890464.jpg +0 -0
- package/examples/app-screen.svg +1 -0
- package/examples/app-screen.visual.json +503 -0
- package/examples/dashboard-table.svg +1 -0
- package/examples/dashboard-table.visual.json +708 -0
- package/examples/dev-docs.svg +1 -0
- package/examples/dev-docs.visual.json +248 -0
- package/examples/explainer.mp4 +0 -0
- package/examples/explainer.visual.json +1713 -0
- package/examples/group-origin-effects-lab-check.svg +1 -0
- package/examples/group-origin-effects-lab.visual.json +1880 -0
- package/examples/image-clip-radius.visual.json +271 -0
- package/examples/make-app-screen.cjs +368 -0
- package/examples/make-dashboard-table.cjs +277 -0
- package/examples/make-dev-docs.cjs +233 -0
- package/examples/make-explainer.cjs +438 -0
- package/examples/make-group-origin-effects-lab.cjs +370 -0
- package/examples/make-image-clip-radius.cjs +169 -0
- package/examples/make-modal-dialog.cjs +355 -0
- package/examples/make-origin-effects-lab.cjs +311 -0
- package/examples/make-preset-character-motion.cjs +32 -0
- package/examples/make-presets-demo.cjs +30 -0
- package/examples/make-pricing.cjs +286 -0
- package/examples/make-product-demo.cjs +468 -0
- package/examples/make-product-hero.cjs +223 -0
- package/examples/make-release-notes.cjs +333 -0
- package/examples/make-settings-panel.cjs +435 -0
- package/examples/make-split-preview.cjs +248 -0
- package/examples/make-storyboard.cjs +215 -0
- package/examples/make-transcript.cjs +234 -0
- package/examples/make-typography-test.cjs +397 -0
- package/examples/make-ui-demo-explainer.cjs +1094 -0
- package/examples/make-ui-flow.cjs +762 -0
- package/examples/make-walkthrough.cjs +815 -0
- package/examples/modal-dialog.svg +1 -0
- package/examples/modal-dialog.visual.json +239 -0
- package/examples/origin-effects-lab-check.svg +1 -0
- package/examples/origin-effects-lab.visual.json +1412 -0
- package/examples/preset-character-motion.visual.json +949 -0
- package/examples/presets-demo.visual.json +787 -0
- package/examples/pricing.svg +1 -0
- package/examples/pricing.visual.json +652 -0
- package/examples/product-demo.mp4 +0 -0
- package/examples/product-demo.visual.json +866 -0
- package/examples/product-hero.svg +1 -0
- package/examples/product-hero.visual.json +242 -0
- package/examples/release-notes.svg +1 -0
- package/examples/release-notes.visual.json +467 -0
- package/examples/settings-panel.svg +1 -0
- package/examples/settings-panel.visual.json +501 -0
- package/examples/split-preview.svg +1 -0
- package/examples/split-preview.visual.json +124 -0
- package/examples/storyboard.svg +1 -0
- package/examples/storyboard.visual.json +312 -0
- package/examples/transcript.svg +1 -0
- package/examples/transcript.visual.json +407 -0
- package/examples/typography-indent-check.svg +1 -0
- package/examples/typography-lineheight-0.svg +1 -0
- package/examples/typography-lineheight-2.svg +1 -0
- package/examples/typography-test-check.svg +1 -0
- package/examples/typography-test.svg +1 -0
- package/examples/typography-test.visual.json +757 -0
- package/examples/ui-demo-explainer-billing.svg +1 -0
- package/examples/ui-demo-explainer-check.svg +1 -0
- package/examples/ui-demo-explainer-save.svg +1 -0
- package/examples/ui-demo-explainer-toggle.svg +1 -0
- package/examples/ui-demo-explainer.mp4 +0 -0
- package/examples/ui-demo-explainer.visual.json +2597 -0
- package/examples/ui-flow.mp4 +0 -0
- package/examples/ui-flow.visual.json +1211 -0
- package/examples/walkthrough.mp4 +0 -0
- package/examples/walkthrough.visual.json +1372 -0
- package/package.json +52 -52
- package/schema/visual.schema.json +1086 -930
package/bin/sketchmark.cjs
CHANGED
|
@@ -1,2008 +1,648 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
const fs = require("node:fs");
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
const {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (command === "
|
|
36
|
-
await
|
|
37
|
-
return;
|
|
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
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
if (url.pathname === "/
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
console.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const doc =
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
<
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
let startTime = 0;
|
|
650
|
-
let timer = 0;
|
|
651
|
-
let currentRenderer = "svg";
|
|
652
|
-
let svgLayer = null;
|
|
653
|
-
let threeIframe = null;
|
|
654
|
-
let threeHtml = "";
|
|
655
|
-
let deckStep = -1;
|
|
656
|
-
let deckMeta = null;
|
|
657
|
-
const preloadedThree = new Map();
|
|
658
|
-
init().catch((error) => setError(error.message || String(error)));
|
|
659
|
-
|
|
660
|
-
async function init() {
|
|
661
|
-
const response = await fetch("/api/initial");
|
|
662
|
-
const data = await response.json();
|
|
663
|
-
if (!data.ok) throw new Error(data.error || "Could not load initial file.");
|
|
664
|
-
fileName.textContent = data.fileName || data.file || "";
|
|
665
|
-
editor.value = data.source || "";
|
|
666
|
-
renderButton.addEventListener("click", () => renderNow());
|
|
667
|
-
saveButton.addEventListener("click", () => saveNow());
|
|
668
|
-
exportButton.addEventListener("click", () => exportNow());
|
|
669
|
-
editor.addEventListener("input", debounceRender);
|
|
670
|
-
scrub.addEventListener("input", () => renderNow());
|
|
671
|
-
sceneSelect.addEventListener("change", () => {
|
|
672
|
-
if (sceneSelect.value) sequenceSelect.value = "";
|
|
673
|
-
deckStep = -1;
|
|
674
|
-
renderNow();
|
|
675
|
-
});
|
|
676
|
-
sequenceSelect.addEventListener("change", () => {
|
|
677
|
-
if (sequenceSelect.value) sceneSelect.value = "";
|
|
678
|
-
deckStep = -1;
|
|
679
|
-
renderNow();
|
|
680
|
-
});
|
|
681
|
-
deckStepSelect.addEventListener("change", () => {
|
|
682
|
-
deckStep = Number(deckStepSelect.value || -1);
|
|
683
|
-
renderNow();
|
|
684
|
-
});
|
|
685
|
-
prevStep.addEventListener("click", () => {
|
|
686
|
-
if (!deckMeta) return;
|
|
687
|
-
deckStep = Math.max(-1, deckStep - 1);
|
|
688
|
-
renderNow();
|
|
689
|
-
});
|
|
690
|
-
nextStep.addEventListener("click", () => {
|
|
691
|
-
if (!deckMeta) return;
|
|
692
|
-
deckStep = Math.min(Number(deckMeta.count || 1) - 2, deckStep + 1);
|
|
693
|
-
renderNow();
|
|
694
|
-
});
|
|
695
|
-
play.addEventListener("click", togglePlay);
|
|
696
|
-
await renderNow();
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function debounceRender() {
|
|
700
|
-
window.clearTimeout(timer);
|
|
701
|
-
clearThreeCache();
|
|
702
|
-
timer = window.setTimeout(() => renderNow(), 300);
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
async function renderNow() {
|
|
706
|
-
renderButton.disabled = true;
|
|
707
|
-
const time = Number(scrub.value || 0);
|
|
708
|
-
try {
|
|
709
|
-
const response = await fetch("/api/render", {
|
|
710
|
-
method: "POST",
|
|
711
|
-
headers: { "content-type": "application/json" },
|
|
712
|
-
body: JSON.stringify({
|
|
713
|
-
source: editor.value,
|
|
714
|
-
time,
|
|
715
|
-
scene: sceneSelect.value || initialScene || undefined,
|
|
716
|
-
sequence: sequenceSelect.value || initialSequence || undefined,
|
|
717
|
-
deck: initialDeck,
|
|
718
|
-
deckStep: sequenceSelect.value || initialSequence ? undefined : deckStep
|
|
719
|
-
})
|
|
720
|
-
});
|
|
721
|
-
const data = await response.json();
|
|
722
|
-
if (!data.ok) {
|
|
723
|
-
setError(formatError(data));
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
726
|
-
duration = Number(data.duration || 0);
|
|
727
|
-
if (data.canvas && data.canvas.width && data.canvas.height) {
|
|
728
|
-
stage.style.aspectRatio = Number(data.canvas.width) + " / " + Number(data.canvas.height);
|
|
729
|
-
}
|
|
730
|
-
scrub.max = String(Math.max(0, duration));
|
|
731
|
-
scrub.value = String(Math.max(0, Math.min(duration || time, Number(data.time ?? time))));
|
|
732
|
-
clock.textContent = Number(scrub.value || 0).toFixed(2) + "s";
|
|
733
|
-
currentRenderer = data.renderer || "svg";
|
|
734
|
-
if (data.sequence && Array.isArray(data.sequence.clips)) preloadThreeScenes(data.sequence.clips);
|
|
735
|
-
if (data.html) mountThreePreview(data.html, data.selectedScene || "current", Number(data.frameTime || 0));
|
|
736
|
-
else mountSvgPreview(data.svg || "");
|
|
737
|
-
updateSelect(sceneSelect, data.scenes || [], data.selectedScene || initialScene, "document");
|
|
738
|
-
updateSelect(sequenceSelect, data.sequences || [], data.selectedSequence || initialSequence, "no sequence");
|
|
739
|
-
updateDeckControls(data.deck);
|
|
740
|
-
const warningText = data.warnings && data.warnings.length ? " - " + data.warnings.length + " warning(s)" : "";
|
|
741
|
-
const deckText = data.deck ? " - " + (data.deck.labels?.[Number(data.deck.selectedStep || -1) + 1] || "Base") : "";
|
|
742
|
-
setStatus("Rendered " + (data.renderer || "svg") + " at " + Number(data.frameTime || 0).toFixed(2) + "s" + deckText + warningText);
|
|
743
|
-
} catch (error) {
|
|
744
|
-
setError(error.message || String(error));
|
|
745
|
-
} finally {
|
|
746
|
-
renderButton.disabled = false;
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
async function saveNow() {
|
|
751
|
-
saveButton.disabled = true;
|
|
752
|
-
try {
|
|
753
|
-
const response = await fetch("/api/save", {
|
|
754
|
-
method: "POST",
|
|
755
|
-
headers: { "content-type": "application/json" },
|
|
756
|
-
body: JSON.stringify({ source: editor.value })
|
|
757
|
-
});
|
|
758
|
-
const data = await response.json();
|
|
759
|
-
if (!data.ok) {
|
|
760
|
-
setError(data.error || "Save failed.");
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
setStatus("Saved.");
|
|
764
|
-
} catch (error) {
|
|
765
|
-
setError(error.message || String(error));
|
|
766
|
-
} finally {
|
|
767
|
-
saveButton.disabled = false;
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
async function exportNow() {
|
|
772
|
-
const fallback = currentRenderer === "three" ? "mp4" : "png";
|
|
773
|
-
const format = window.prompt("Export format: svg, png, jpg, html, mp4, webm, pdf, pptx", fallback);
|
|
774
|
-
if (!format) return;
|
|
775
|
-
exportButton.disabled = true;
|
|
776
|
-
const normalizedFormat = String(format).toLowerCase();
|
|
777
|
-
const selectedSequence = sequenceSelect.value || initialSequence || "";
|
|
778
|
-
const sequenceExport = selectedSequence && ["svg", "png", "jpg", "html", "pdf", "mp4", "webm"].includes(normalizedFormat);
|
|
779
|
-
const deckExport = Boolean(deckMeta) && !sequenceExport;
|
|
780
|
-
setStatus("Exporting " + normalizedFormat + "...");
|
|
781
|
-
try {
|
|
782
|
-
const response = await fetch("/api/export", {
|
|
783
|
-
method: "POST",
|
|
784
|
-
headers: { "content-type": "application/json" },
|
|
785
|
-
body: JSON.stringify({
|
|
786
|
-
source: editor.value,
|
|
787
|
-
format: normalizedFormat,
|
|
788
|
-
scene: sequenceExport ? undefined : sceneSelect.value || initialScene || undefined,
|
|
789
|
-
sequence: sequenceExport ? selectedSequence : undefined,
|
|
790
|
-
deck: deckExport && (normalizedFormat === "html" || normalizedFormat === "pptx"),
|
|
791
|
-
deckStep: deckExport ? deckStep : undefined,
|
|
792
|
-
time: Number(scrub.value || 0),
|
|
793
|
-
download: true
|
|
794
|
-
})
|
|
795
|
-
});
|
|
796
|
-
const contentType = response.headers.get("content-type") || "";
|
|
797
|
-
if (contentType.includes("application/json")) {
|
|
798
|
-
const data = await response.json();
|
|
799
|
-
setError(formatError(data));
|
|
800
|
-
return;
|
|
801
|
-
}
|
|
802
|
-
if (!response.ok) {
|
|
803
|
-
setError(await response.text());
|
|
804
|
-
return;
|
|
805
|
-
}
|
|
806
|
-
const blob = await response.blob();
|
|
807
|
-
const filename = downloadFilename(response.headers.get("content-disposition")) || exportFilename(normalizedFormat);
|
|
808
|
-
downloadBlob(blob, filename);
|
|
809
|
-
const warnings = parseWarnings(response.headers.get("x-sketchmark-warnings"));
|
|
810
|
-
const warningText = warnings.length ? " (" + warnings.length + " warning" + (warnings.length === 1 ? "" : "s") + ")" : "";
|
|
811
|
-
setStatus("Downloaded " + filename + warningText);
|
|
812
|
-
} catch (error) {
|
|
813
|
-
setError(error.message || String(error));
|
|
814
|
-
} finally {
|
|
815
|
-
exportButton.disabled = false;
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
function exportFilename(format) {
|
|
820
|
-
const cleanFile = String(fileName.textContent || "sketchmark").split(/[\\\\/]/).pop() || "sketchmark";
|
|
821
|
-
const base = cleanFile.replace(/\\.visual\\.json$/i, "").replace(/\\.(json|txt)$/i, "");
|
|
822
|
-
const selectedSequence = sequenceSelect.value || initialSequence || "";
|
|
823
|
-
const isSequenceExport = selectedSequence && ["svg", "png", "jpg", "html", "pdf", "mp4", "webm"].includes(String(format || "").toLowerCase());
|
|
824
|
-
const suffix = isSequenceExport
|
|
825
|
-
? "-" + selectedSequence
|
|
826
|
-
: sceneSelect.value
|
|
827
|
-
? "-" + sceneSelect.value + (deckMeta ? "-step-" + (deckStep + 1) : "")
|
|
828
|
-
: "";
|
|
829
|
-
return base + suffix + "." + String(format || "png").toLowerCase();
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function downloadFilename(contentDisposition) {
|
|
833
|
-
const match = /filename\\*?=(?:UTF-8''|")?([^";]+)/i.exec(String(contentDisposition || ""));
|
|
834
|
-
return match ? decodeURIComponent(match[1].replace(/"$/, "")) : "";
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
function parseWarnings(header) {
|
|
838
|
-
if (!header) return [];
|
|
839
|
-
try {
|
|
840
|
-
const value = JSON.parse(decodeURIComponent(header));
|
|
841
|
-
return Array.isArray(value) ? value : [];
|
|
842
|
-
} catch {
|
|
843
|
-
return [];
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function downloadBlob(blob, filename) {
|
|
848
|
-
const url = URL.createObjectURL(blob);
|
|
849
|
-
const anchor = document.createElement("a");
|
|
850
|
-
anchor.href = url;
|
|
851
|
-
anchor.download = filename;
|
|
852
|
-
anchor.style.display = "none";
|
|
853
|
-
document.body.appendChild(anchor);
|
|
854
|
-
anchor.click();
|
|
855
|
-
anchor.remove();
|
|
856
|
-
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
function togglePlay() {
|
|
860
|
-
playing = !playing;
|
|
861
|
-
play.textContent = playing ? "Pause" : "Play";
|
|
862
|
-
start = performance.now();
|
|
863
|
-
startTime = Number(scrub.value || 0);
|
|
864
|
-
if (playing) playFrame = requestAnimationFrame(tick);
|
|
865
|
-
else if (playFrame) cancelAnimationFrame(playFrame);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
async function tick(now) {
|
|
869
|
-
if (!playing) return;
|
|
870
|
-
let t = startTime + (now - start) / 1000;
|
|
871
|
-
if (duration > 0 && t > duration) {
|
|
872
|
-
t = t % duration;
|
|
873
|
-
start = now;
|
|
874
|
-
startTime = t;
|
|
875
|
-
}
|
|
876
|
-
scrub.value = String(t);
|
|
877
|
-
await renderNow();
|
|
878
|
-
if (playing) playFrame = requestAnimationFrame(tick);
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
function ensureSvgLayer() {
|
|
882
|
-
if (!svgLayer) {
|
|
883
|
-
svgLayer = document.createElement("div");
|
|
884
|
-
svgLayer.className = "svg-layer";
|
|
885
|
-
stage.appendChild(svgLayer);
|
|
886
|
-
}
|
|
887
|
-
return svgLayer;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
function mountSvgPreview(svg) {
|
|
891
|
-
const layer = ensureSvgLayer();
|
|
892
|
-
layer.innerHTML = svg;
|
|
893
|
-
layer.style.display = "";
|
|
894
|
-
if (threeIframe) threeIframe.style.display = "none";
|
|
895
|
-
for (const iframe of preloadedThree.values()) {
|
|
896
|
-
if (iframe && iframe.nodeType === 1) iframe.style.display = "none";
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
function mountThreePreview(html, sceneId, time) {
|
|
901
|
-
const cached = preloadedThree.get(sceneId);
|
|
902
|
-
if (cached && cached.nodeType === 1) {
|
|
903
|
-
threeIframe = cached;
|
|
904
|
-
threeHtml = html;
|
|
905
|
-
cached.style.display = "";
|
|
906
|
-
if (svgLayer) svgLayer.style.display = "none";
|
|
907
|
-
hidePreloadedExcept(sceneId);
|
|
908
|
-
showThreeTime(time);
|
|
909
|
-
return;
|
|
910
|
-
}
|
|
911
|
-
if (!threeIframe || threeHtml !== html) {
|
|
912
|
-
threeHtml = html;
|
|
913
|
-
if (!threeIframe || threeIframe.dataset.preloadScene) {
|
|
914
|
-
threeIframe = createThreeIframe();
|
|
915
|
-
stage.appendChild(threeIframe);
|
|
916
|
-
}
|
|
917
|
-
threeIframe.addEventListener("load", () => showThreeTime(time), { once: true });
|
|
918
|
-
threeIframe.srcdoc = html;
|
|
919
|
-
}
|
|
920
|
-
threeIframe.style.display = "";
|
|
921
|
-
if (svgLayer) svgLayer.style.display = "none";
|
|
922
|
-
hidePreloadedExcept(null);
|
|
923
|
-
showThreeTime(time);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
function showThreeTime(time) {
|
|
927
|
-
if (!threeIframe || !threeIframe.contentWindow) return;
|
|
928
|
-
threeIframe.contentWindow.postMessage({ type: "sketchmark-show", time: Number(time || 0) }, "*");
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
function createThreeIframe() {
|
|
932
|
-
const iframe = document.createElement("iframe");
|
|
933
|
-
iframe.title = "Sketchmark Three preview";
|
|
934
|
-
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
|
|
935
|
-
return iframe;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
function preloadThreeScenes(clips) {
|
|
939
|
-
for (const clip of clips) {
|
|
940
|
-
if (clip.renderer !== "three" || !clip.scene || preloadedThree.has(clip.scene)) continue;
|
|
941
|
-
preloadedThree.set(clip.scene, "loading");
|
|
942
|
-
fetch("/api/render", {
|
|
943
|
-
method: "POST",
|
|
944
|
-
headers: { "content-type": "application/json" },
|
|
945
|
-
body: JSON.stringify({ source: editor.value, scene: clip.scene, sequence: "", time: 0 })
|
|
946
|
-
})
|
|
947
|
-
.then((response) => response.json())
|
|
948
|
-
.then((data) => {
|
|
949
|
-
if (!data.ok || !data.html) {
|
|
950
|
-
preloadedThree.delete(clip.scene);
|
|
951
|
-
return;
|
|
952
|
-
}
|
|
953
|
-
const iframe = createThreeIframe();
|
|
954
|
-
iframe.dataset.preloadScene = clip.scene;
|
|
955
|
-
iframe.srcdoc = data.html;
|
|
956
|
-
iframe.style.display = "none";
|
|
957
|
-
stage.appendChild(iframe);
|
|
958
|
-
preloadedThree.set(clip.scene, iframe);
|
|
959
|
-
})
|
|
960
|
-
.catch(() => preloadedThree.delete(clip.scene));
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
function hidePreloadedExcept(sceneId) {
|
|
965
|
-
for (const [id, iframe] of preloadedThree.entries()) {
|
|
966
|
-
if (!iframe || iframe.nodeType !== 1) continue;
|
|
967
|
-
iframe.style.display = id === sceneId ? "" : "none";
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
function clearThreeCache() {
|
|
972
|
-
for (const iframe of preloadedThree.values()) {
|
|
973
|
-
if (iframe && iframe.nodeType === 1) iframe.remove();
|
|
974
|
-
}
|
|
975
|
-
preloadedThree.clear();
|
|
976
|
-
if (threeIframe) {
|
|
977
|
-
threeIframe.remove();
|
|
978
|
-
threeIframe = null;
|
|
979
|
-
threeHtml = "";
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
function updateDeckControls(meta) {
|
|
984
|
-
deckMeta = meta && Number(meta.count || 0) > 1 ? meta : null;
|
|
985
|
-
const controls = [prevStep, nextStep, deckStepSelect];
|
|
986
|
-
for (const control of controls) control.classList.toggle("visible", Boolean(deckMeta));
|
|
987
|
-
if (!deckMeta) {
|
|
988
|
-
deckStepSelect.innerHTML = "";
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
deckStep = Number.isFinite(Number(deckMeta.selectedStep)) ? Number(deckMeta.selectedStep) : -1;
|
|
992
|
-
deckStepSelect.innerHTML = "";
|
|
993
|
-
const labels = Array.isArray(deckMeta.labels) ? deckMeta.labels : ["Base"];
|
|
994
|
-
for (let index = 0; index < Number(deckMeta.count || labels.length); index += 1) {
|
|
995
|
-
const option = document.createElement("option");
|
|
996
|
-
option.value = String(index - 1);
|
|
997
|
-
option.textContent = labels[index] || (index === 0 ? "Base" : "Step " + index);
|
|
998
|
-
deckStepSelect.appendChild(option);
|
|
999
|
-
}
|
|
1000
|
-
deckStepSelect.value = String(deckStep);
|
|
1001
|
-
prevStep.disabled = deckStep <= -1;
|
|
1002
|
-
nextStep.disabled = deckStep >= Number(deckMeta.count || 1) - 2;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
function updateSelect(select, values, selected, emptyLabel) {
|
|
1006
|
-
const current = select.value || selected || "";
|
|
1007
|
-
select.innerHTML = "";
|
|
1008
|
-
const empty = document.createElement("option");
|
|
1009
|
-
empty.value = "";
|
|
1010
|
-
empty.textContent = emptyLabel;
|
|
1011
|
-
select.appendChild(empty);
|
|
1012
|
-
for (const value of values) {
|
|
1013
|
-
const option = document.createElement("option");
|
|
1014
|
-
option.value = value;
|
|
1015
|
-
option.textContent = value;
|
|
1016
|
-
select.appendChild(option);
|
|
1017
|
-
}
|
|
1018
|
-
select.value = values.includes(current) ? current : "";
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
function formatError(data) {
|
|
1022
|
-
if (data.error) return data.error;
|
|
1023
|
-
if (Array.isArray(data.issues)) return data.issues.map((issue) => issue.path + ": " + issue.message).join("\\n");
|
|
1024
|
-
return "Render failed.";
|
|
1025
|
-
}
|
|
1026
|
-
function setStatus(message) { status.classList.remove("error"); status.textContent = message; }
|
|
1027
|
-
function setError(message) { status.classList.add("error"); status.textContent = message; }
|
|
1028
|
-
</script>
|
|
1029
|
-
</body>
|
|
1030
|
-
</html>`;
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
async function renderRaster(doc, outputPath, format, options) {
|
|
1034
|
-
const sharp = loadSharp();
|
|
1035
|
-
const frame = frameDocument(doc, options);
|
|
1036
|
-
if (frame.document.canvas.renderer === "three") {
|
|
1037
|
-
const tempDir = format === "png" ? undefined : fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-three-shot-"));
|
|
1038
|
-
const pngPath = format === "png" ? outputPath : path.join(tempDir, "frame.png");
|
|
1039
|
-
try {
|
|
1040
|
-
await captureThreeFrames(frame.document, [{ time: frame.localTime, outputPath: pngPath }], options);
|
|
1041
|
-
if (format !== "png") {
|
|
1042
|
-
await sharp(pngPath).flatten({ background: doc.canvas.background || "#ffffff" }).jpeg({ quality: 92 }).toFile(outputPath);
|
|
1043
|
-
}
|
|
1044
|
-
} finally {
|
|
1045
|
-
if (tempDir) removeTempDir(tempDir);
|
|
1046
|
-
}
|
|
1047
|
-
return;
|
|
1048
|
-
}
|
|
1049
|
-
const svg = core.renderToSvg(frame.document, { time: frame.localTime, transparent: options.transparent });
|
|
1050
|
-
const image = sharp(Buffer.from(svg));
|
|
1051
|
-
if (format === "png") {
|
|
1052
|
-
await image.png().toFile(outputPath);
|
|
1053
|
-
} else {
|
|
1054
|
-
await image.flatten({ background: doc.canvas.background || "#ffffff" }).jpeg({ quality: 92 }).toFile(outputPath);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
async function renderPdf(doc, outputPath, options) {
|
|
1059
|
-
const frame = frameDocument(doc, options);
|
|
1060
|
-
const sharp = loadSharp();
|
|
1061
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-pdf-"));
|
|
1062
|
-
try {
|
|
1063
|
-
const jpgPath = path.join(tempDir, "page.jpg");
|
|
1064
|
-
await renderRaster(doc, jpgPath, "jpg", { scene: options.scene, sequence: options.sequence, time: options.time ?? 0, browser: options.browser });
|
|
1065
|
-
const jpeg = fs.readFileSync(jpgPath);
|
|
1066
|
-
const metadata = await sharp(jpeg).metadata();
|
|
1067
|
-
writeJpegPdf(outputPath, jpeg, metadata.width || frame.document.canvas.width, metadata.height || frame.document.canvas.height);
|
|
1068
|
-
} finally {
|
|
1069
|
-
removeTempDir(tempDir);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
async function renderPptx(doc, outputPath, options) {
|
|
1074
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-pptx-"));
|
|
1075
|
-
try {
|
|
1076
|
-
const slides = await pptxSlides(doc, tempDir, options);
|
|
1077
|
-
writePptx(outputPath, slides, doc.canvas.width, doc.canvas.height);
|
|
1078
|
-
} finally {
|
|
1079
|
-
removeTempDir(tempDir);
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
async function pptxSlides(doc, tempDir, options) {
|
|
1084
|
-
const slides = [];
|
|
1085
|
-
if (options.deck) {
|
|
1086
|
-
const sceneId = options.scene || firstSceneId(doc);
|
|
1087
|
-
if (!sceneId) throw new Error("--deck requires a scene with steps.");
|
|
1088
|
-
const scene = doc.scenes?.[sceneId];
|
|
1089
|
-
const count = Math.max(1, (scene?.steps?.length ?? 0) + 1);
|
|
1090
|
-
for (let index = 0; index < count; index += 1) {
|
|
1091
|
-
const frame = index === 0 ? core.documentForScene(doc, sceneId) : core.documentForDeckStep(doc, sceneId, index - 1);
|
|
1092
|
-
slides.push(await renderDocumentPng(frame, path.join(tempDir, `slide-${index + 1}.png`), options));
|
|
1093
|
-
}
|
|
1094
|
-
return slides;
|
|
1095
|
-
}
|
|
1096
|
-
if (options.sequence) {
|
|
1097
|
-
const sequence = core.compileVisualSequence(doc, options.sequence);
|
|
1098
|
-
for (const clip of sequence.clips) {
|
|
1099
|
-
const frame = core.documentForSequenceTime(doc, options.sequence, clip.start);
|
|
1100
|
-
slides.push(await renderDocumentPng(frame.document, path.join(tempDir, `slide-${slides.length + 1}.png`), options));
|
|
1101
|
-
}
|
|
1102
|
-
return slides;
|
|
1103
|
-
}
|
|
1104
|
-
const frame = options.scene ? core.documentForScene(doc, options.scene) : frameDocument(doc, options).document;
|
|
1105
|
-
slides.push(await renderDocumentPng(frame, path.join(tempDir, "slide-1.png"), options));
|
|
1106
|
-
return slides;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
async function renderDocumentPng(document, outputPath, options) {
|
|
1110
|
-
const sharp = loadSharp();
|
|
1111
|
-
if (document.canvas.renderer === "three") {
|
|
1112
|
-
await captureThreeFrames(document, [{ time: options.time ?? 0, outputPath }], options);
|
|
1113
|
-
return fs.readFileSync(outputPath);
|
|
1114
|
-
}
|
|
1115
|
-
const svg = core.renderToSvg(document, { time: options.time ?? 0, transparent: options.transparent });
|
|
1116
|
-
await sharp(Buffer.from(svg)).png().toFile(outputPath);
|
|
1117
|
-
return fs.readFileSync(outputPath);
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
async function renderMp4(doc, outputPath, options) {
|
|
1121
|
-
await renderVideoFrames(doc, options, (frameDir, fps) => {
|
|
1122
|
-
runFfmpeg([
|
|
1123
|
-
"-y",
|
|
1124
|
-
"-framerate",
|
|
1125
|
-
String(fps),
|
|
1126
|
-
"-i",
|
|
1127
|
-
path.join(frameDir, "frame-%05d.png"),
|
|
1128
|
-
"-c:v",
|
|
1129
|
-
"libx264",
|
|
1130
|
-
"-pix_fmt",
|
|
1131
|
-
"yuv420p",
|
|
1132
|
-
"-vf",
|
|
1133
|
-
"pad=ceil(iw/2)*2:ceil(ih/2)*2",
|
|
1134
|
-
outputPath
|
|
1135
|
-
]);
|
|
1136
|
-
}, outputPath);
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
async function renderWebm(doc, outputPath, options) {
|
|
1140
|
-
await renderVideoFrames(doc, options, (frameDir, fps) => {
|
|
1141
|
-
runFfmpeg([
|
|
1142
|
-
"-y",
|
|
1143
|
-
"-framerate",
|
|
1144
|
-
String(fps),
|
|
1145
|
-
"-i",
|
|
1146
|
-
path.join(frameDir, "frame-%05d.png"),
|
|
1147
|
-
"-c:v",
|
|
1148
|
-
"libvpx-vp9",
|
|
1149
|
-
"-pix_fmt",
|
|
1150
|
-
options.transparent ? "yuva420p" : "yuv420p",
|
|
1151
|
-
"-auto-alt-ref",
|
|
1152
|
-
options.transparent ? "0" : "1",
|
|
1153
|
-
outputPath
|
|
1154
|
-
]);
|
|
1155
|
-
}, outputPath);
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
async function renderVideoFrames(doc, options, encode, outputPath) {
|
|
1159
|
-
const fps = Math.max(1, Math.round(options.fps || 30));
|
|
1160
|
-
const defaultDuration = options.sequence ? core.compileVisualSequence(doc, options.sequence).duration : options.scene ? frameDocument(doc, { scene: options.scene, time: 0 }).duration : doc.canvas.duration;
|
|
1161
|
-
const duration = Math.max(0.001, Number(options.duration || defaultDuration || 1));
|
|
1162
|
-
const frameCount = Math.max(1, Math.ceil(duration * fps));
|
|
1163
|
-
const frameDir = fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-json-frames-"));
|
|
1164
|
-
const sharp = loadSharp();
|
|
1165
|
-
const threeGroups = new Map();
|
|
1166
|
-
try {
|
|
1167
|
-
for (let index = 0; index < frameCount; index += 1) {
|
|
1168
|
-
const time = index / fps;
|
|
1169
|
-
const framePath = path.join(frameDir, `frame-${String(index + 1).padStart(5, "0")}.png`);
|
|
1170
|
-
const frame = frameDocument(doc, { scene: options.scene, sequence: options.sequence, time });
|
|
1171
|
-
if (frame.document.canvas.renderer === "three") {
|
|
1172
|
-
const key = frame.scene || frame.sequenceId || "__document";
|
|
1173
|
-
const group = threeGroups.get(key) || { document: frame.document, requests: [] };
|
|
1174
|
-
group.requests.push({ time: frame.localTime, outputPath: framePath });
|
|
1175
|
-
threeGroups.set(key, group);
|
|
1176
|
-
} else {
|
|
1177
|
-
const svg = core.renderToSvg(frame.document, { time: frame.localTime, transparent: options.transparent });
|
|
1178
|
-
await sharp(Buffer.from(svg)).png().toFile(framePath);
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
for (const group of threeGroups.values()) {
|
|
1182
|
-
await captureThreeFrames(group.document, group.requests, options);
|
|
1183
|
-
}
|
|
1184
|
-
encode(frameDir, fps);
|
|
1185
|
-
if (options.keepFrames) {
|
|
1186
|
-
const kept = `${outputPath}.frames`;
|
|
1187
|
-
if (fs.existsSync(kept)) fs.rmSync(kept, { recursive: true, force: true });
|
|
1188
|
-
fs.renameSync(frameDir, kept);
|
|
1189
|
-
console.log(`Kept frames: ${kept}`);
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
} finally {
|
|
1193
|
-
if (!options.keepFrames && fs.existsSync(frameDir)) fs.rmSync(frameDir, { recursive: true, force: true });
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
async function renderBrowserImage(doc, outputPath, options) {
|
|
1198
|
-
const html = core.renderToHtml(doc, { time: options.time || 0 });
|
|
1199
|
-
const browser = findBrowser(options.browser);
|
|
1200
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-browser-"));
|
|
1201
|
-
try {
|
|
1202
|
-
const htmlPath = path.join(tempDir, "frame.html");
|
|
1203
|
-
const capturePath = path.join(tempDir, "capture.png");
|
|
1204
|
-
fs.writeFileSync(htmlPath, html, "utf8");
|
|
1205
|
-
runBrowser(browser, [
|
|
1206
|
-
"--headless",
|
|
1207
|
-
"--disable-gpu",
|
|
1208
|
-
"--disable-gpu-sandbox",
|
|
1209
|
-
"--disable-dev-shm-usage",
|
|
1210
|
-
"--disable-extensions",
|
|
1211
|
-
"--disable-background-networking",
|
|
1212
|
-
"--hide-scrollbars",
|
|
1213
|
-
"--no-first-run",
|
|
1214
|
-
"--no-default-browser-check",
|
|
1215
|
-
`--user-data-dir=${path.join(tempDir, "profile")}`,
|
|
1216
|
-
`--window-size=${Math.round(doc.canvas.width)},${Math.round(doc.canvas.height)}`,
|
|
1217
|
-
"--run-all-compositor-stages-before-draw",
|
|
1218
|
-
`--screenshot=${capturePath}`,
|
|
1219
|
-
pathToFileURL(htmlPath).href
|
|
1220
|
-
]);
|
|
1221
|
-
if (!fs.existsSync(capturePath)) throw new Error("Browser capture did not produce a PNG file.");
|
|
1222
|
-
fs.copyFileSync(capturePath, outputPath);
|
|
1223
|
-
} finally {
|
|
1224
|
-
removeTempDir(tempDir);
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
async function renderBrowserPdfHtml(html, outputPath, canvas, options) {
|
|
1229
|
-
const browser = findBrowser(options.browser);
|
|
1230
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-browser-"));
|
|
1231
|
-
try {
|
|
1232
|
-
const htmlPath = path.join(tempDir, "page.html");
|
|
1233
|
-
const capturePath = path.join(tempDir, "capture.pdf");
|
|
1234
|
-
fs.writeFileSync(htmlPath, html, "utf8");
|
|
1235
|
-
runBrowser(browser, [
|
|
1236
|
-
"--headless",
|
|
1237
|
-
"--disable-gpu",
|
|
1238
|
-
"--disable-gpu-sandbox",
|
|
1239
|
-
"--disable-dev-shm-usage",
|
|
1240
|
-
"--disable-extensions",
|
|
1241
|
-
"--disable-background-networking",
|
|
1242
|
-
"--no-first-run",
|
|
1243
|
-
"--no-default-browser-check",
|
|
1244
|
-
`--user-data-dir=${path.join(tempDir, "profile")}`,
|
|
1245
|
-
`--window-size=${Math.round(canvas.width)},${Math.round(canvas.height)}`,
|
|
1246
|
-
`--print-to-pdf=${capturePath}`,
|
|
1247
|
-
pathToFileURL(htmlPath).href
|
|
1248
|
-
]);
|
|
1249
|
-
if (!fs.existsSync(capturePath)) throw new Error("Browser capture did not produce a PDF file.");
|
|
1250
|
-
fs.copyFileSync(capturePath, outputPath);
|
|
1251
|
-
} finally {
|
|
1252
|
-
removeTempDir(tempDir);
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
async function captureThreeFrames(document, frames, options = {}) {
|
|
1257
|
-
if (!frames.length) return;
|
|
1258
|
-
const browser = findBrowser(options.browser);
|
|
1259
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-three-cdp-"));
|
|
1260
|
-
const userDataDir = path.join(tempDir, "profile");
|
|
1261
|
-
const htmlPath = path.join(tempDir, "scene.html");
|
|
1262
|
-
const width = Math.ceil(document.canvas.width);
|
|
1263
|
-
const height = Math.ceil(document.canvas.height);
|
|
1264
|
-
const html = core.renderToHtml(document, {
|
|
1265
|
-
transparent: options.transparent,
|
|
1266
|
-
threeRuntime: "/three.module.js"
|
|
1267
|
-
});
|
|
1268
|
-
|
|
1269
|
-
fs.mkdirSync(userDataDir, { recursive: true });
|
|
1270
|
-
fs.writeFileSync(htmlPath, html, "utf8");
|
|
1271
|
-
const server = await startThreeExportServer(tempDir);
|
|
1272
|
-
const browserProcess = spawn(browser, [
|
|
1273
|
-
"--headless=new",
|
|
1274
|
-
"--hide-scrollbars",
|
|
1275
|
-
"--mute-audio",
|
|
1276
|
-
"--disable-background-timer-throttling",
|
|
1277
|
-
"--disable-renderer-backgrounding",
|
|
1278
|
-
"--disable-gpu-sandbox",
|
|
1279
|
-
"--enable-unsafe-swiftshader",
|
|
1280
|
-
"--no-sandbox",
|
|
1281
|
-
"--use-angle=swiftshader",
|
|
1282
|
-
"--remote-debugging-port=0",
|
|
1283
|
-
`--user-data-dir=${userDataDir}`,
|
|
1284
|
-
`--window-size=${width},${height}`,
|
|
1285
|
-
server.url
|
|
1286
|
-
], { stdio: "pipe" });
|
|
1287
|
-
const stderr = [];
|
|
1288
|
-
browserProcess.stderr?.on("data", (chunk) => stderr.push(chunk));
|
|
1289
|
-
|
|
1290
|
-
let cdp;
|
|
1291
|
-
try {
|
|
1292
|
-
const port = await readDevToolsPort(userDataDir);
|
|
1293
|
-
const targets = await httpJson(`http://127.0.0.1:${port}/json/list`);
|
|
1294
|
-
const target = Array.isArray(targets) ? targets.find((item) => item.type === "page" && item.webSocketDebuggerUrl) : undefined;
|
|
1295
|
-
if (!target) throw new Error("Could not find a Chromium page target for renderer: three export.");
|
|
1296
|
-
|
|
1297
|
-
cdp = await connectCdp(target.webSocketDebuggerUrl, () => Buffer.concat(stderr).toString("utf8"));
|
|
1298
|
-
await cdp.send("Page.enable");
|
|
1299
|
-
await cdp.send("Runtime.enable");
|
|
1300
|
-
await cdp.send("Page.navigate", { url: server.url });
|
|
1301
|
-
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
1302
|
-
width,
|
|
1303
|
-
height,
|
|
1304
|
-
deviceScaleFactor: 1,
|
|
1305
|
-
mobile: false
|
|
1306
|
-
});
|
|
1307
|
-
await waitForThreeReady(cdp);
|
|
1308
|
-
|
|
1309
|
-
for (const frame of frames) {
|
|
1310
|
-
await cdp.send("Runtime.evaluate", {
|
|
1311
|
-
expression: `window.__SKETCHMARK_SHOW_TIME__(${JSON.stringify(frame.time)})`,
|
|
1312
|
-
awaitPromise: true
|
|
1313
|
-
});
|
|
1314
|
-
await cdp.send("Runtime.evaluate", {
|
|
1315
|
-
expression: "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
|
|
1316
|
-
awaitPromise: true
|
|
1317
|
-
});
|
|
1318
|
-
const clip = await canvasClip(cdp, width, height);
|
|
1319
|
-
const screenshot = await cdp.send("Page.captureScreenshot", {
|
|
1320
|
-
format: "png",
|
|
1321
|
-
fromSurface: true,
|
|
1322
|
-
clip
|
|
1323
|
-
});
|
|
1324
|
-
fs.writeFileSync(frame.outputPath, Buffer.from(screenshot.data, "base64"));
|
|
1325
|
-
}
|
|
1326
|
-
} finally {
|
|
1327
|
-
cdp?.close();
|
|
1328
|
-
await stopProcess(browserProcess);
|
|
1329
|
-
await server.close();
|
|
1330
|
-
removeTempDir(tempDir);
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
async function waitForThreeReady(cdp) {
|
|
1335
|
-
const deadline = Date.now() + 30_000;
|
|
1336
|
-
let lastError = "";
|
|
1337
|
-
while (Date.now() < deadline) {
|
|
1338
|
-
const result = await cdp.send("Runtime.evaluate", {
|
|
1339
|
-
expression: "({ ready: Boolean(window.__SKETCHMARK_READY__ && window.__SKETCHMARK_SHOW_TIME__), error: window.__SKETCHMARK_ERROR__ || '' })",
|
|
1340
|
-
returnByValue: true
|
|
1341
|
-
});
|
|
1342
|
-
const value = result.result?.value || {};
|
|
1343
|
-
if (value.ready === true) return;
|
|
1344
|
-
if (value.error) lastError = String(value.error);
|
|
1345
|
-
await delay(100);
|
|
1346
|
-
}
|
|
1347
|
-
let debug = "";
|
|
1348
|
-
try {
|
|
1349
|
-
const result = await cdp.send("Runtime.evaluate", {
|
|
1350
|
-
expression: "({ href: location.href, readyState: document.readyState, title: document.title, body: document.body ? document.body.innerText.slice(0, 200) : '', scripts: document.scripts.length, error: window.__SKETCHMARK_ERROR__ || '', resources: performance.getEntriesByType('resource').map(r => ({ name: r.name, transferSize: r.transferSize, duration: Math.round(r.duration) })).slice(0, 8) })",
|
|
1351
|
-
returnByValue: true
|
|
1352
|
-
});
|
|
1353
|
-
debug = JSON.stringify(result.result?.value || {});
|
|
1354
|
-
} catch {
|
|
1355
|
-
// Keep the original timeout message if Chromium is already gone.
|
|
1356
|
-
}
|
|
1357
|
-
throw new Error(lastError
|
|
1358
|
-
? `Timed out waiting for renderer: three page to become ready. Browser error: ${lastError}`
|
|
1359
|
-
: `Timed out waiting for renderer: three page to become ready.${debug ? ` Debug: ${debug}` : ""}`);
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
async function canvasClip(cdp, fallbackWidth, fallbackHeight) {
|
|
1363
|
-
const result = await cdp.send("Runtime.evaluate", {
|
|
1364
|
-
expression: `(() => {
|
|
1365
|
-
const canvas = document.getElementById("stage");
|
|
1366
|
-
if (!canvas) return { x: 0, y: 0, width: ${fallbackWidth}, height: ${fallbackHeight}, scale: 1 };
|
|
1367
|
-
const r = canvas.getBoundingClientRect();
|
|
1368
|
-
return { x: r.x, y: r.y, width: r.width, height: r.height, scale: 1 };
|
|
1369
|
-
})()`,
|
|
1370
|
-
returnByValue: true
|
|
1371
|
-
});
|
|
1372
|
-
const value = result.result?.value ?? {};
|
|
1373
|
-
return {
|
|
1374
|
-
x: Math.max(0, Number(value.x || 0)),
|
|
1375
|
-
y: Math.max(0, Number(value.y || 0)),
|
|
1376
|
-
width: Math.max(1, Number(value.width || fallbackWidth)),
|
|
1377
|
-
height: Math.max(1, Number(value.height || fallbackHeight)),
|
|
1378
|
-
scale: 1
|
|
1379
|
-
};
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
async function readDevToolsPort(userDataDir) {
|
|
1383
|
-
const filePath = path.join(userDataDir, "DevToolsActivePort");
|
|
1384
|
-
const deadline = Date.now() + 10_000;
|
|
1385
|
-
while (Date.now() < deadline) {
|
|
1386
|
-
if (fs.existsSync(filePath)) {
|
|
1387
|
-
const text = fs.readFileSync(filePath, "utf8");
|
|
1388
|
-
const port = Number(text.split(/\r?\n/)[0]);
|
|
1389
|
-
if (Number.isFinite(port) && port > 0) return port;
|
|
1390
|
-
}
|
|
1391
|
-
await delay(100);
|
|
1392
|
-
}
|
|
1393
|
-
throw new Error("Timed out waiting for Chromium DevTools port.");
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
function httpJson(url) {
|
|
1397
|
-
return new Promise((resolve, reject) => {
|
|
1398
|
-
http.get(url, (response) => {
|
|
1399
|
-
const chunks = [];
|
|
1400
|
-
response.on("data", (chunk) => chunks.push(chunk));
|
|
1401
|
-
response.on("end", () => {
|
|
1402
|
-
try {
|
|
1403
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
1404
|
-
} catch (error) {
|
|
1405
|
-
reject(error);
|
|
1406
|
-
}
|
|
1407
|
-
});
|
|
1408
|
-
}).on("error", reject);
|
|
1409
|
-
});
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
class CdpClient {
|
|
1413
|
-
constructor(socket, diagnostics = () => "") {
|
|
1414
|
-
this.socket = socket;
|
|
1415
|
-
this.diagnostics = diagnostics;
|
|
1416
|
-
this.buffer = Buffer.alloc(0);
|
|
1417
|
-
this.nextId = 1;
|
|
1418
|
-
this.pending = new Map();
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
send(method, params = {}) {
|
|
1422
|
-
const id = this.nextId++;
|
|
1423
|
-
const payload = JSON.stringify({ id, method, params });
|
|
1424
|
-
this.socket.write(encodeWsFrame(payload));
|
|
1425
|
-
return new Promise((resolve, reject) => {
|
|
1426
|
-
this.pending.set(id, { resolve, reject });
|
|
1427
|
-
});
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
handleData(data) {
|
|
1431
|
-
this.buffer = Buffer.concat([this.buffer, data]);
|
|
1432
|
-
while (this.buffer.length >= 2) {
|
|
1433
|
-
const first = this.buffer[0];
|
|
1434
|
-
const second = this.buffer[1];
|
|
1435
|
-
const opcode = first & 0x0f;
|
|
1436
|
-
const masked = Boolean(second & 0x80);
|
|
1437
|
-
let length = second & 0x7f;
|
|
1438
|
-
let offset = 2;
|
|
1439
|
-
|
|
1440
|
-
if (length === 126) {
|
|
1441
|
-
if (this.buffer.length < offset + 2) return;
|
|
1442
|
-
length = this.buffer.readUInt16BE(offset);
|
|
1443
|
-
offset += 2;
|
|
1444
|
-
} else if (length === 127) {
|
|
1445
|
-
if (this.buffer.length < offset + 8) return;
|
|
1446
|
-
length = Number(this.buffer.readBigUInt64BE(offset));
|
|
1447
|
-
offset += 8;
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
let mask;
|
|
1451
|
-
if (masked) {
|
|
1452
|
-
if (this.buffer.length < offset + 4) return;
|
|
1453
|
-
mask = this.buffer.slice(offset, offset + 4);
|
|
1454
|
-
offset += 4;
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
if (this.buffer.length < offset + length) return;
|
|
1458
|
-
let payload = this.buffer.slice(offset, offset + length);
|
|
1459
|
-
this.buffer = this.buffer.slice(offset + length);
|
|
1460
|
-
|
|
1461
|
-
if (masked && mask) payload = Buffer.from(payload.map((byte, index) => byte ^ mask[index % 4]));
|
|
1462
|
-
if (opcode === 8) {
|
|
1463
|
-
this.close();
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
if (opcode === 9) {
|
|
1467
|
-
this.socket.write(encodeWsFrame(payload, 0x0a));
|
|
1468
|
-
continue;
|
|
1469
|
-
}
|
|
1470
|
-
if (opcode !== 1) continue;
|
|
1471
|
-
|
|
1472
|
-
const message = JSON.parse(payload.toString("utf8"));
|
|
1473
|
-
if (!message.id) continue;
|
|
1474
|
-
const pending = this.pending.get(message.id);
|
|
1475
|
-
if (!pending) continue;
|
|
1476
|
-
this.pending.delete(message.id);
|
|
1477
|
-
if (message.error) pending.reject(new Error(message.error.message || "Chrome DevTools Protocol error."));
|
|
1478
|
-
else pending.resolve(message.result);
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
close() {
|
|
1483
|
-
const details = this.diagnostics();
|
|
1484
|
-
const message = details.trim()
|
|
1485
|
-
? `Chrome DevTools connection closed.\n${details.trim()}`
|
|
1486
|
-
: "Chrome DevTools connection closed.";
|
|
1487
|
-
for (const pending of this.pending.values()) pending.reject(new Error(message));
|
|
1488
|
-
this.pending.clear();
|
|
1489
|
-
this.socket.destroy();
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
function connectCdp(wsUrl, diagnostics) {
|
|
1494
|
-
return new Promise((resolve, reject) => {
|
|
1495
|
-
const parsed = new URL(wsUrl);
|
|
1496
|
-
const socket = net.connect(Number(parsed.port), parsed.hostname);
|
|
1497
|
-
const client = new CdpClient(socket, diagnostics);
|
|
1498
|
-
const key = crypto.randomBytes(16).toString("base64");
|
|
1499
|
-
let handshake = Buffer.alloc(0);
|
|
1500
|
-
|
|
1501
|
-
const fail = (error) => {
|
|
1502
|
-
socket.destroy();
|
|
1503
|
-
reject(error);
|
|
1504
|
-
};
|
|
1505
|
-
|
|
1506
|
-
socket.once("error", fail);
|
|
1507
|
-
socket.once("connect", () => {
|
|
1508
|
-
socket.write([
|
|
1509
|
-
`GET ${parsed.pathname}${parsed.search} HTTP/1.1`,
|
|
1510
|
-
`Host: ${parsed.host}`,
|
|
1511
|
-
"Upgrade: websocket",
|
|
1512
|
-
"Connection: Upgrade",
|
|
1513
|
-
`Sec-WebSocket-Key: ${key}`,
|
|
1514
|
-
"Sec-WebSocket-Version: 13",
|
|
1515
|
-
"",
|
|
1516
|
-
""
|
|
1517
|
-
].join("\r\n"));
|
|
1518
|
-
});
|
|
1519
|
-
|
|
1520
|
-
const onData = (chunk) => {
|
|
1521
|
-
handshake = Buffer.concat([handshake, chunk]);
|
|
1522
|
-
const end = handshake.indexOf("\r\n\r\n");
|
|
1523
|
-
if (end === -1) return;
|
|
1524
|
-
|
|
1525
|
-
const header = handshake.slice(0, end).toString("utf8");
|
|
1526
|
-
if (!/^HTTP\/1\.1 101/.test(header)) {
|
|
1527
|
-
fail(new Error(`Unexpected DevTools WebSocket response: ${header.split(/\r?\n/)[0]}`));
|
|
1528
|
-
return;
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
socket.off("data", onData);
|
|
1532
|
-
socket.off("error", fail);
|
|
1533
|
-
socket.on("data", (data) => client.handleData(data));
|
|
1534
|
-
socket.on("error", () => client.close());
|
|
1535
|
-
|
|
1536
|
-
const rest = handshake.slice(end + 4);
|
|
1537
|
-
if (rest.length) client.handleData(rest);
|
|
1538
|
-
resolve(client);
|
|
1539
|
-
};
|
|
1540
|
-
|
|
1541
|
-
socket.on("data", onData);
|
|
1542
|
-
});
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
function encodeWsFrame(payload, opcode = 0x01) {
|
|
1546
|
-
const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
1547
|
-
const mask = crypto.randomBytes(4);
|
|
1548
|
-
let header;
|
|
1549
|
-
|
|
1550
|
-
if (data.length < 126) {
|
|
1551
|
-
header = Buffer.alloc(2);
|
|
1552
|
-
header[0] = 0x80 | opcode;
|
|
1553
|
-
header[1] = 0x80 | data.length;
|
|
1554
|
-
} else if (data.length < 65536) {
|
|
1555
|
-
header = Buffer.alloc(4);
|
|
1556
|
-
header[0] = 0x80 | opcode;
|
|
1557
|
-
header[1] = 0x80 | 126;
|
|
1558
|
-
header.writeUInt16BE(data.length, 2);
|
|
1559
|
-
} else {
|
|
1560
|
-
header = Buffer.alloc(10);
|
|
1561
|
-
header[0] = 0x80 | opcode;
|
|
1562
|
-
header[1] = 0x80 | 127;
|
|
1563
|
-
header.writeBigUInt64BE(BigInt(data.length), 2);
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
const masked = Buffer.alloc(data.length);
|
|
1567
|
-
for (let index = 0; index < data.length; index += 1) {
|
|
1568
|
-
masked[index] = data[index] ^ mask[index % 4];
|
|
1569
|
-
}
|
|
1570
|
-
return Buffer.concat([header, mask, masked]);
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
function delay(ms) {
|
|
1574
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
function stopProcess(child) {
|
|
1578
|
-
if (!child || child.exitCode !== null || child.killed) return Promise.resolve();
|
|
1579
|
-
child.kill();
|
|
1580
|
-
return new Promise((resolve) => {
|
|
1581
|
-
const timer = setTimeout(resolve, 1500);
|
|
1582
|
-
child.once("exit", () => {
|
|
1583
|
-
clearTimeout(timer);
|
|
1584
|
-
resolve();
|
|
1585
|
-
});
|
|
1586
|
-
});
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
function startThreeExportServer(tempDir) {
|
|
1590
|
-
const threeBuildDir = findThreeBuildDir();
|
|
1591
|
-
return new Promise((resolve, reject) => {
|
|
1592
|
-
const server = http.createServer((request, response) => {
|
|
1593
|
-
const requestUrl = new URL(request.url || "/", "http://127.0.0.1");
|
|
1594
|
-
const pathname = decodeURIComponent(requestUrl.pathname);
|
|
1595
|
-
const filePath = pathname.startsWith("/three.")
|
|
1596
|
-
? path.join(threeBuildDir, path.basename(pathname))
|
|
1597
|
-
: path.resolve(tempDir, pathname === "/" ? "scene.html" : pathname.slice(1));
|
|
1598
|
-
const allowed = filePath.startsWith(threeBuildDir) || filePath.startsWith(tempDir);
|
|
1599
|
-
if (!allowed || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
1600
|
-
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
1601
|
-
response.end("Not found");
|
|
1602
|
-
return;
|
|
1603
|
-
}
|
|
1604
|
-
response.writeHead(200, { "content-type": mimeType(filePath), "cache-control": "no-store" });
|
|
1605
|
-
response.end(fs.readFileSync(filePath));
|
|
1606
|
-
});
|
|
1607
|
-
|
|
1608
|
-
server.once("error", reject);
|
|
1609
|
-
server.listen(0, "127.0.0.1", () => {
|
|
1610
|
-
const address = server.address();
|
|
1611
|
-
const port = typeof address === "object" && address ? address.port : 0;
|
|
1612
|
-
resolve({
|
|
1613
|
-
url: `http://127.0.0.1:${port}/scene.html`,
|
|
1614
|
-
close: () => new Promise((done) => server.close(() => done()))
|
|
1615
|
-
});
|
|
1616
|
-
});
|
|
1617
|
-
});
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
function findThreeRuntimePath() {
|
|
1621
|
-
return path.join(findThreeBuildDir(), "three.module.js");
|
|
1622
|
-
}
|
|
1623
|
-
|
|
1624
|
-
function threeBuildFile(requestPath) {
|
|
1625
|
-
const filePath = path.join(findThreeBuildDir(), path.basename(requestPath));
|
|
1626
|
-
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
1627
|
-
throw new Error(`Could not find Three runtime file '${requestPath}'.`);
|
|
1628
|
-
}
|
|
1629
|
-
return filePath;
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
function findThreeBuildDir() {
|
|
1633
|
-
const candidates = [
|
|
1634
|
-
path.resolve(__dirname, "..", "node_modules", "three", "build"),
|
|
1635
|
-
path.resolve(__dirname, "..", "..", "node_modules", "three", "build"),
|
|
1636
|
-
path.resolve(__dirname, "..", "..", "sketchmark-core", "node_modules", "three", "build")
|
|
1637
|
-
];
|
|
1638
|
-
for (const candidate of candidates) {
|
|
1639
|
-
if (fs.existsSync(path.join(candidate, "three.module.js"))) return candidate;
|
|
1640
|
-
}
|
|
1641
|
-
const pnpmRoot = path.resolve(__dirname, "..", "..", "node_modules", ".pnpm");
|
|
1642
|
-
if (fs.existsSync(pnpmRoot)) {
|
|
1643
|
-
for (const name of fs.readdirSync(pnpmRoot)) {
|
|
1644
|
-
const candidate = path.join(pnpmRoot, name, "node_modules", "three", "build");
|
|
1645
|
-
if (name.startsWith("three@") && fs.existsSync(path.join(candidate, "three.module.js"))) return candidate;
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
throw new Error("Could not find three.module.js. Install three in the workspace or keep sketchmark-core/node_modules available.");
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
function mimeType(filePath) {
|
|
1652
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
1653
|
-
if (ext === ".js" || ext === ".mjs") return "text/javascript; charset=utf-8";
|
|
1654
|
-
if (ext === ".html") return "text/html; charset=utf-8";
|
|
1655
|
-
if (ext === ".json") return "application/json; charset=utf-8";
|
|
1656
|
-
if (ext === ".svg") return "image/svg+xml; charset=utf-8";
|
|
1657
|
-
if (ext === ".png") return "image/png";
|
|
1658
|
-
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
1659
|
-
if (ext === ".pdf") return "application/pdf";
|
|
1660
|
-
if (ext === ".pptx") return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
1661
|
-
if (ext === ".mp4") return "video/mp4";
|
|
1662
|
-
if (ext === ".webm") return "video/webm";
|
|
1663
|
-
return "application/octet-stream";
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
function removeTempDir(tempDir) {
|
|
1667
|
-
if (!fs.existsSync(tempDir)) return;
|
|
1668
|
-
try {
|
|
1669
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
1670
|
-
} catch {
|
|
1671
|
-
// Browser profile crash reporters can keep a handle briefly on Windows.
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
function writeJpegPdf(outputPath, jpeg, width, height) {
|
|
1676
|
-
const objects = [];
|
|
1677
|
-
const add = (body) => {
|
|
1678
|
-
const id = objects.length + 1;
|
|
1679
|
-
objects.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body), "binary"));
|
|
1680
|
-
return id;
|
|
1681
|
-
};
|
|
1682
|
-
add("<< /Type /Catalog /Pages 2 0 R >>");
|
|
1683
|
-
add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
|
1684
|
-
add(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${width} ${height}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>`);
|
|
1685
|
-
add(Buffer.concat([
|
|
1686
|
-
Buffer.from(`<< /Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.length} >>\nstream\n`, "binary"),
|
|
1687
|
-
jpeg,
|
|
1688
|
-
Buffer.from("\nendstream", "binary")
|
|
1689
|
-
]));
|
|
1690
|
-
const content = Buffer.from(`q\n${width} 0 0 ${height} 0 0 cm\n/Im0 Do\nQ`, "binary");
|
|
1691
|
-
add(`<< /Length ${content.length} >>\nstream\n${content.toString("binary")}\nendstream`);
|
|
1692
|
-
|
|
1693
|
-
const chunks = [Buffer.from("%PDF-1.4\n", "binary")];
|
|
1694
|
-
const offsets = [0];
|
|
1695
|
-
for (let index = 0; index < objects.length; index += 1) {
|
|
1696
|
-
offsets.push(Buffer.concat(chunks).length);
|
|
1697
|
-
chunks.push(Buffer.from(`${index + 1} 0 obj\n`, "binary"), objects[index], Buffer.from("\nendobj\n", "binary"));
|
|
1698
|
-
}
|
|
1699
|
-
const xrefOffset = Buffer.concat(chunks).length;
|
|
1700
|
-
const xref = ["xref", `0 ${objects.length + 1}`, "0000000000 65535 f "];
|
|
1701
|
-
for (let index = 1; index < offsets.length; index += 1) {
|
|
1702
|
-
xref.push(`${String(offsets[index]).padStart(10, "0")} 00000 n `);
|
|
1703
|
-
}
|
|
1704
|
-
chunks.push(Buffer.from(`${xref.join("\n")}\ntrailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`, "binary"));
|
|
1705
|
-
fs.writeFileSync(outputPath, Buffer.concat(chunks));
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
function writePptx(outputPath, slideImages, width, height) {
|
|
1709
|
-
const slideWidth = Math.round(width * 9525);
|
|
1710
|
-
const slideHeight = Math.round(height * 9525);
|
|
1711
|
-
const files = new Map();
|
|
1712
|
-
const contentTypes = [
|
|
1713
|
-
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
|
1714
|
-
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
|
|
1715
|
-
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>',
|
|
1716
|
-
'<Default Extension="xml" ContentType="application/xml"/>',
|
|
1717
|
-
'<Default Extension="png" ContentType="image/png"/>',
|
|
1718
|
-
'<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>',
|
|
1719
|
-
'<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>',
|
|
1720
|
-
'<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>',
|
|
1721
|
-
'<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>',
|
|
1722
|
-
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
|
|
1723
|
-
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
|
|
1724
|
-
...slideImages.map((_, index) => `<Override PartName="/ppt/slides/slide${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`),
|
|
1725
|
-
'</Types>'
|
|
1726
|
-
].join("");
|
|
1727
|
-
|
|
1728
|
-
files.set("[Content_Types].xml", contentTypes);
|
|
1729
|
-
files.set("_rels/.rels", rels([{ id: "rId1", type: "officeDocument", target: "ppt/presentation.xml" }, { id: "rId2", type: "metadata/core-properties", target: "docProps/core.xml" }, { id: "rId3", type: "extended-properties", target: "docProps/app.xml" }]));
|
|
1730
|
-
files.set("docProps/core.xml", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Sketchmark Export</dc:title></cp:coreProperties>');
|
|
1731
|
-
files.set("docProps/app.xml", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Sketchmark</Application></Properties>');
|
|
1732
|
-
files.set("ppt/presentation.xml", presentationXml(slideImages.length, slideWidth, slideHeight));
|
|
1733
|
-
files.set("ppt/_rels/presentation.xml.rels", rels([
|
|
1734
|
-
...slideImages.map((_, index) => ({ id: `rId${index + 1}`, type: "slide", target: `slides/slide${index + 1}.xml` })),
|
|
1735
|
-
{ id: `rId${slideImages.length + 1}`, type: "slideMaster", target: "slideMasters/slideMaster1.xml" },
|
|
1736
|
-
{ id: `rId${slideImages.length + 2}`, type: "theme", target: "theme/theme1.xml" }
|
|
1737
|
-
]));
|
|
1738
|
-
files.set("ppt/slideMasters/slideMaster1.xml", masterXml());
|
|
1739
|
-
files.set("ppt/slideMasters/_rels/slideMaster1.xml.rels", rels([{ id: "rId1", type: "slideLayout", target: "../slideLayouts/slideLayout1.xml" }]));
|
|
1740
|
-
files.set("ppt/slideLayouts/slideLayout1.xml", layoutXml());
|
|
1741
|
-
files.set("ppt/slideLayouts/_rels/slideLayout1.xml.rels", rels([{ id: "rId1", type: "slideMaster", target: "../slideMasters/slideMaster1.xml" }]));
|
|
1742
|
-
files.set("ppt/theme/theme1.xml", themeXml());
|
|
1743
|
-
for (const [index, image] of slideImages.entries()) {
|
|
1744
|
-
files.set(`ppt/media/image${index + 1}.png`, image);
|
|
1745
|
-
files.set(`ppt/slides/slide${index + 1}.xml`, slideXml(index + 1, slideWidth, slideHeight));
|
|
1746
|
-
files.set(`ppt/slides/_rels/slide${index + 1}.xml.rels`, rels([{ id: "rId1", type: "image", target: `../media/image${index + 1}.png` }]));
|
|
1747
|
-
}
|
|
1748
|
-
fs.writeFileSync(outputPath, zipStore(files));
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
function rels(items) {
|
|
1752
|
-
const typePrefix = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
|
|
1753
|
-
const packagePrefix = "http://schemas.openxmlformats.org/package/2006/relationships/";
|
|
1754
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${packagePrefix}">${items.map((item) => `<Relationship Id="${item.id}" Type="${item.type.startsWith("http") ? item.type : `${typePrefix}${item.type}`}" Target="${item.target}"/>`).join("")}</Relationships>`;
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
function presentationXml(count, width, height) {
|
|
1758
|
-
const ids = Array.from({ length: count }, (_, index) => `<p:sldId id="${256 + index}" r:id="rId${index + 1}"/>`).join("");
|
|
1759
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId${count + 1}"/></p:sldMasterIdLst><p:sldIdLst>${ids}</p:sldIdLst><p:sldSz cx="${width}" cy="${height}" type="custom"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>`;
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
function slideXml(index, width, height) {
|
|
1763
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${width}" cy="${height}"/><a:chOff x="0" y="0"/><a:chExt cx="${width}" cy="${height}"/></a:xfrm></p:grpSpPr><p:pic><p:nvPicPr><p:cNvPr id="2" name="slide${index}.png"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rId1"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${width}" cy="${height}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>`;
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
function masterXml() {
|
|
1767
|
-
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:sldMaster>';
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
function layoutXml() {
|
|
1771
|
-
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1"><p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>';
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
function themeXml() {
|
|
1775
|
-
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Sketchmark"><a:themeElements><a:clrScheme name="Sketchmark"><a:dk1><a:srgbClr val="111827"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F2937"/></a:dk2><a:lt2><a:srgbClr val="F8FAFC"/></a:lt2><a:accent1><a:srgbClr val="2563EB"/></a:accent1><a:accent2><a:srgbClr val="22C55E"/></a:accent2><a:accent3><a:srgbClr val="EF4444"/></a:accent3><a:accent4><a:srgbClr val="F59E0B"/></a:accent4><a:accent5><a:srgbClr val="8B5CF6"/></a:accent5><a:accent6><a:srgbClr val="06B6D4"/></a:accent6><a:hlink><a:srgbClr val="2563EB"/></a:hlink><a:folHlink><a:srgbClr val="7C3AED"/></a:folHlink></a:clrScheme><a:fontScheme name="Sketchmark"><a:majorFont><a:latin typeface="Aptos Display"/></a:majorFont><a:minorFont><a:latin typeface="Aptos"/></a:minorFont></a:fontScheme><a:fmtScheme name="Sketchmark"><a:fillStyleLst/><a:lnStyleLst/><a:effectStyleLst/><a:bgFillStyleLst/></a:fmtScheme></a:themeElements></a:theme>';
|
|
1776
|
-
}
|
|
1777
|
-
|
|
1778
|
-
function zipStore(files) {
|
|
1779
|
-
const chunks = [];
|
|
1780
|
-
const central = [];
|
|
1781
|
-
let offset = 0;
|
|
1782
|
-
for (const [name, value] of files.entries()) {
|
|
1783
|
-
const data = Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");
|
|
1784
|
-
const filename = Buffer.from(name, "utf8");
|
|
1785
|
-
const crc = crc32(data);
|
|
1786
|
-
const local = Buffer.alloc(30);
|
|
1787
|
-
local.writeUInt32LE(0x04034b50, 0);
|
|
1788
|
-
local.writeUInt16LE(20, 4);
|
|
1789
|
-
local.writeUInt16LE(0, 6);
|
|
1790
|
-
local.writeUInt16LE(0, 8);
|
|
1791
|
-
local.writeUInt16LE(0, 10);
|
|
1792
|
-
local.writeUInt16LE(0, 12);
|
|
1793
|
-
local.writeUInt32LE(crc, 14);
|
|
1794
|
-
local.writeUInt32LE(data.length, 18);
|
|
1795
|
-
local.writeUInt32LE(data.length, 22);
|
|
1796
|
-
local.writeUInt16LE(filename.length, 26);
|
|
1797
|
-
local.writeUInt16LE(0, 28);
|
|
1798
|
-
chunks.push(local, filename, data);
|
|
1799
|
-
|
|
1800
|
-
const directory = Buffer.alloc(46);
|
|
1801
|
-
directory.writeUInt32LE(0x02014b50, 0);
|
|
1802
|
-
directory.writeUInt16LE(20, 4);
|
|
1803
|
-
directory.writeUInt16LE(20, 6);
|
|
1804
|
-
directory.writeUInt16LE(0, 8);
|
|
1805
|
-
directory.writeUInt16LE(0, 10);
|
|
1806
|
-
directory.writeUInt16LE(0, 12);
|
|
1807
|
-
directory.writeUInt16LE(0, 14);
|
|
1808
|
-
directory.writeUInt32LE(crc, 16);
|
|
1809
|
-
directory.writeUInt32LE(data.length, 20);
|
|
1810
|
-
directory.writeUInt32LE(data.length, 24);
|
|
1811
|
-
directory.writeUInt16LE(filename.length, 28);
|
|
1812
|
-
directory.writeUInt16LE(0, 30);
|
|
1813
|
-
directory.writeUInt16LE(0, 32);
|
|
1814
|
-
directory.writeUInt16LE(0, 34);
|
|
1815
|
-
directory.writeUInt16LE(0, 36);
|
|
1816
|
-
directory.writeUInt32LE(0, 38);
|
|
1817
|
-
directory.writeUInt32LE(offset, 42);
|
|
1818
|
-
central.push(directory, filename);
|
|
1819
|
-
offset += local.length + filename.length + data.length;
|
|
1820
|
-
}
|
|
1821
|
-
const centralSize = central.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
1822
|
-
const end = Buffer.alloc(22);
|
|
1823
|
-
end.writeUInt32LE(0x06054b50, 0);
|
|
1824
|
-
end.writeUInt16LE(0, 4);
|
|
1825
|
-
end.writeUInt16LE(0, 6);
|
|
1826
|
-
end.writeUInt16LE(files.size, 8);
|
|
1827
|
-
end.writeUInt16LE(files.size, 10);
|
|
1828
|
-
end.writeUInt32LE(centralSize, 12);
|
|
1829
|
-
end.writeUInt32LE(offset, 16);
|
|
1830
|
-
end.writeUInt16LE(0, 20);
|
|
1831
|
-
return Buffer.concat([...chunks, ...central, end]);
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
const CRC_TABLE = Array.from({ length: 256 }, (_, index) => {
|
|
1835
|
-
let value = index;
|
|
1836
|
-
for (let bit = 0; bit < 8; bit += 1) {
|
|
1837
|
-
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
1838
|
-
}
|
|
1839
|
-
return value >>> 0;
|
|
1840
|
-
});
|
|
1841
|
-
|
|
1842
|
-
function crc32(buffer) {
|
|
1843
|
-
let crc = 0xffffffff;
|
|
1844
|
-
for (const byte of buffer) {
|
|
1845
|
-
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
1846
|
-
}
|
|
1847
|
-
return (crc ^ 0xffffffff) >>> 0;
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
function runBrowser(browser, args) {
|
|
1851
|
-
if (process.platform === "win32" && path.isAbsolute(browser)) {
|
|
1852
|
-
const script = `$exe=${psQuote(browser)};$args=@(${args.map(psQuote).join(",")});& $exe @args;exit $LASTEXITCODE`;
|
|
1853
|
-
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
1854
|
-
const result = spawnSync("powershell.exe", ["-NoProfile", "-EncodedCommand", encoded], { stdio: "ignore", timeout: 30000 });
|
|
1855
|
-
if (result.error) throw new Error(`Browser capture failed: ${result.error.message}`);
|
|
1856
|
-
if (result.status !== 0) throw new Error(`Browser capture failed with exit code ${result.status}.`);
|
|
1857
|
-
return;
|
|
1858
|
-
}
|
|
1859
|
-
const result = spawnSync(browser, args, { stdio: "ignore", timeout: 30000 });
|
|
1860
|
-
if (result.error) throw new Error(`Browser capture failed: ${result.error.message}`);
|
|
1861
|
-
if (result.status !== 0) throw new Error(`Browser capture failed with exit code ${result.status}.`);
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
function psQuote(value) {
|
|
1865
|
-
return `'${String(value).replace(/'/g, "''")}'`;
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
function findBrowser(explicit) {
|
|
1869
|
-
const candidates = [
|
|
1870
|
-
explicit,
|
|
1871
|
-
process.env.SKETCHMARK_BROWSER,
|
|
1872
|
-
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
1873
|
-
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
1874
|
-
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
1875
|
-
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
1876
|
-
"msedge",
|
|
1877
|
-
"chrome",
|
|
1878
|
-
"chromium",
|
|
1879
|
-
"google-chrome"
|
|
1880
|
-
].filter(Boolean);
|
|
1881
|
-
for (const candidate of candidates) {
|
|
1882
|
-
if (path.isAbsolute(candidate) && fs.existsSync(candidate)) return candidate;
|
|
1883
|
-
if (!path.isAbsolute(candidate)) return candidate;
|
|
1884
|
-
}
|
|
1885
|
-
throw new Error("PNG/PDF capture for browser-rendered documents requires Edge or Chrome. Pass --browser <path> or set SKETCHMARK_BROWSER.");
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
function runFfmpeg(args) {
|
|
1889
|
-
const result = spawnSync("ffmpeg", args, { stdio: "pipe", encoding: "utf8" });
|
|
1890
|
-
if (result.error) throw new Error(`ffmpeg is required for MP4 export: ${result.error.message}`);
|
|
1891
|
-
if (result.status !== 0) throw new Error(`ffmpeg failed: ${result.stderr || result.stdout}`);
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
function loadSharp() {
|
|
1895
|
-
try {
|
|
1896
|
-
return require("sharp");
|
|
1897
|
-
} catch {
|
|
1898
|
-
// Continue below.
|
|
1899
|
-
}
|
|
1900
|
-
|
|
1901
|
-
const pnpmRoot = path.resolve(__dirname, "..", "..", "node_modules", ".pnpm");
|
|
1902
|
-
if (fs.existsSync(pnpmRoot)) {
|
|
1903
|
-
const candidates = fs.readdirSync(pnpmRoot).filter((name) => name.startsWith("sharp@"));
|
|
1904
|
-
for (const candidate of candidates) {
|
|
1905
|
-
const sharpPath = path.join(pnpmRoot, candidate, "node_modules", "sharp");
|
|
1906
|
-
try {
|
|
1907
|
-
return require(sharpPath);
|
|
1908
|
-
} catch {
|
|
1909
|
-
// Try the next platform candidate.
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
throw new Error("sharp is required for PNG/JPG/MP4 rendering. Install sharp in the workspace.");
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
function listen(server, port) {
|
|
1918
|
-
return new Promise((resolve, reject) => {
|
|
1919
|
-
server.once("error", reject);
|
|
1920
|
-
server.listen(port, "127.0.0.1", resolve);
|
|
1921
|
-
});
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
function send(response, status, body, type) {
|
|
1925
|
-
response.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
1926
|
-
response.end(body);
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
function sendJson(response, status, body) {
|
|
1930
|
-
send(response, status, JSON.stringify(body), "application/json; charset=utf-8");
|
|
1931
|
-
}
|
|
1932
|
-
|
|
1933
|
-
function sendDownloadFile(response, filePath, downloadName, contentType, warnings, cleanup) {
|
|
1934
|
-
let cleaned = false;
|
|
1935
|
-
const finish = () => {
|
|
1936
|
-
if (cleaned) return;
|
|
1937
|
-
cleaned = true;
|
|
1938
|
-
cleanup();
|
|
1939
|
-
};
|
|
1940
|
-
const stream = fs.createReadStream(filePath);
|
|
1941
|
-
stream.on("error", () => {
|
|
1942
|
-
finish();
|
|
1943
|
-
if (!response.headersSent) send(response, 404, "File not found", "text/plain; charset=utf-8");
|
|
1944
|
-
else response.destroy();
|
|
1945
|
-
});
|
|
1946
|
-
response.on("finish", finish);
|
|
1947
|
-
response.on("close", finish);
|
|
1948
|
-
response.writeHead(200, {
|
|
1949
|
-
"content-type": contentType,
|
|
1950
|
-
"content-disposition": `attachment; filename="${downloadName.replace(/["\\]/g, "_")}"`,
|
|
1951
|
-
"cache-control": "no-store",
|
|
1952
|
-
"x-sketchmark-warnings": encodeURIComponent(JSON.stringify(warnings || []))
|
|
1953
|
-
});
|
|
1954
|
-
stream.pipe(response);
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
function readRequestBody(request, limit) {
|
|
1958
|
-
return new Promise((resolve, reject) => {
|
|
1959
|
-
let size = 0;
|
|
1960
|
-
const chunks = [];
|
|
1961
|
-
request.on("data", (chunk) => {
|
|
1962
|
-
size += chunk.length;
|
|
1963
|
-
if (size > limit) {
|
|
1964
|
-
reject(new Error("Request body is too large."));
|
|
1965
|
-
request.destroy();
|
|
1966
|
-
return;
|
|
1967
|
-
}
|
|
1968
|
-
chunks.push(chunk);
|
|
1969
|
-
});
|
|
1970
|
-
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
1971
|
-
request.on("error", reject);
|
|
1972
|
-
});
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
function openBrowser(url) {
|
|
1976
|
-
const command = process.platform === "win32"
|
|
1977
|
-
? "cmd"
|
|
1978
|
-
: process.platform === "darwin"
|
|
1979
|
-
? "open"
|
|
1980
|
-
: "xdg-open";
|
|
1981
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
1982
|
-
const child = spawn(command, args, { stdio: "ignore", detached: true, shell: false });
|
|
1983
|
-
child.unref();
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
function inferFormat(outputPath) {
|
|
1987
|
-
const ext = path.extname(outputPath).toLowerCase().replace(".", "");
|
|
1988
|
-
if (ext === "jpeg") return "jpg";
|
|
1989
|
-
if (["svg", "html", "png", "jpg", "pdf", "pptx", "mp4", "webm"].includes(ext)) return ext;
|
|
1990
|
-
throw new Error(`Cannot infer output format from '${outputPath}'.`);
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
function numberOption(args, name, fallback) {
|
|
1994
|
-
const index = args.indexOf(name);
|
|
1995
|
-
if (index === -1) return fallback;
|
|
1996
|
-
const value = Number(args[index + 1]);
|
|
1997
|
-
return Number.isFinite(value) ? value : fallback;
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
function stringOption(args, name) {
|
|
2001
|
-
const index = args.indexOf(name);
|
|
2002
|
-
if (index === -1) return undefined;
|
|
2003
|
-
return args[index + 1] ? String(args[index + 1]) : undefined;
|
|
2004
|
-
}
|
|
2005
|
-
|
|
2006
|
-
function escapeHtml(value) {
|
|
2007
|
-
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2008
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const http = require("node:http");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
7
|
+
|
|
8
|
+
const core = require("../dist/src");
|
|
9
|
+
const { editorHtml } = require("./editor-ui.cjs");
|
|
10
|
+
const { previewHtml } = require("./preview-ui.cjs");
|
|
11
|
+
|
|
12
|
+
const LOCAL_FONTS_DIR = path.resolve(__dirname, "..", "fonts");
|
|
13
|
+
const FONT_EXTENSIONS = new Set([".ttf", ".otf", ".woff", ".woff2"]);
|
|
14
|
+
let rasterFontConfigPrepared = false;
|
|
15
|
+
|
|
16
|
+
main().catch((error) => {
|
|
17
|
+
console.error(error?.message || String(error));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
const [command, ...args] = process.argv.slice(2);
|
|
23
|
+
if (!command || command === "-h" || command === "--help") {
|
|
24
|
+
usage();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (command === "render") {
|
|
28
|
+
await render(args);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (command === "preview") {
|
|
32
|
+
await preview(args);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (command === "edit") {
|
|
36
|
+
await edit(args);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (command === "lint") {
|
|
40
|
+
lint(args);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Unknown command '${command}'.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function usage() {
|
|
47
|
+
console.log(`Sketchmark render-kernel CLI
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
sketchmark render <input.visual.json> <output.svg|html|mp4|webm> [--time 1.2] [--transparent]
|
|
51
|
+
sketchmark render <input.visual.json> <output.mp4|webm> [--duration 5] [--fps 30]
|
|
52
|
+
sketchmark preview <input.visual.json> [--port 4177] [--no-open]
|
|
53
|
+
sketchmark edit <input.visual.json> [--port 4179] [--no-open]
|
|
54
|
+
sketchmark lint <input.visual.json> [--json]
|
|
55
|
+
`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function render(args) {
|
|
59
|
+
const input = args[0];
|
|
60
|
+
const output = args[1];
|
|
61
|
+
if (!input || !output) throw new Error("render requires input and output paths.");
|
|
62
|
+
const inputPath = path.resolve(input);
|
|
63
|
+
const outputPath = path.resolve(output);
|
|
64
|
+
const doc = loadDocument(inputPath);
|
|
65
|
+
const format = inferFormat(outputPath);
|
|
66
|
+
const options = {
|
|
67
|
+
time: numberOption(args, "--time", 0),
|
|
68
|
+
transparent: args.includes("--transparent")
|
|
69
|
+
};
|
|
70
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
71
|
+
if (format === "svg") {
|
|
72
|
+
fs.writeFileSync(outputPath, core.renderToSvg(doc, options), "utf8");
|
|
73
|
+
} else if (format === "html") {
|
|
74
|
+
fs.writeFileSync(outputPath, core.renderToHtml(doc, options), "utf8");
|
|
75
|
+
} else {
|
|
76
|
+
await renderVideo(doc, outputPath, format, {
|
|
77
|
+
duration: numberOption(args, "--duration", undefined),
|
|
78
|
+
fps: numberOption(args, "--fps", undefined),
|
|
79
|
+
transparent: options.transparent
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
console.log(`Rendered ${format.toUpperCase()}: ${outputPath}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function preview(args) {
|
|
86
|
+
await edit(args, { defaultPort: 4177, label: "Preview", readOnly: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function edit(args, options = {}) {
|
|
90
|
+
const input = args[0];
|
|
91
|
+
if (!input) throw new Error(`${(options.label || "edit").toLowerCase()} requires an input JSON file.`);
|
|
92
|
+
const inputPath = path.resolve(input);
|
|
93
|
+
loadDocument(inputPath);
|
|
94
|
+
const port = Math.round(numberOption(args, "--port", options.defaultPort ?? 4179));
|
|
95
|
+
if (!Number.isFinite(port) || port <= 0) throw new Error(`${(options.label || "edit").toLowerCase()} --port must be a positive number.`);
|
|
96
|
+
|
|
97
|
+
const server = http.createServer(async (request, response) => {
|
|
98
|
+
try {
|
|
99
|
+
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
|
|
100
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
101
|
+
const pageHtml = options.readOnly ? previewHtml : editorHtml;
|
|
102
|
+
send(response, 200, "text/html; charset=utf-8", pageHtml(path.basename(inputPath)));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (request.method === "GET" && url.pathname.startsWith("/fonts/")) {
|
|
106
|
+
if (!sendFontAsset(response, url.pathname)) send(response, 404, "text/plain; charset=utf-8", "Font not found");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (request.method === "GET" && url.pathname === "/api/document") {
|
|
110
|
+
const doc = loadDocument(inputPath);
|
|
111
|
+
sendJson(response, 200, editorDocumentPayload(doc));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (request.method === "GET" && url.pathname === "/api/frame") {
|
|
115
|
+
const doc = loadDocument(inputPath);
|
|
116
|
+
const duration = Number(doc.canvas.duration ?? 0);
|
|
117
|
+
const time = clamp(Number(url.searchParams.get("time") || 0), 0, Math.max(duration, 0));
|
|
118
|
+
const resolved = core.resolveVisualFrame(doc, time);
|
|
119
|
+
sendJson(response, 200, { ok: true, svg: core.renderResolvedSvg(resolved), resolved, duration, fps: visualFps(doc), canvas: doc.canvas });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (request.method === "GET" && url.pathname === "/api/export") {
|
|
123
|
+
await exportFromEditor(response, loadDocument(inputPath), inputPath, url);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (request.method === "POST" && url.pathname === "/api/canvas") {
|
|
127
|
+
if (options.readOnly) {
|
|
128
|
+
sendJson(response, 403, { ok: false, error: "Preview mode is read-only." });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const payload = await readJson(request);
|
|
132
|
+
const doc = applyCanvasPatch(loadDocument(inputPath), payload);
|
|
133
|
+
saveDocument(inputPath, doc);
|
|
134
|
+
sendJson(response, 200, editorDocumentPayload(doc));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (request.method === "POST" && url.pathname === "/api/property") {
|
|
138
|
+
if (options.readOnly) {
|
|
139
|
+
sendJson(response, 403, { ok: false, error: "Preview mode is read-only." });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const payload = await readJson(request);
|
|
143
|
+
const doc = core.setElementProperty(loadDocument(inputPath), requiredString(payload.id, "id"), requiredString(payload.property, "property"), normalizeMotionValue(payload.value));
|
|
144
|
+
saveDocument(inputPath, doc);
|
|
145
|
+
sendJson(response, 200, editorDocumentPayload(doc));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (request.method === "POST" && url.pathname === "/api/keyframe") {
|
|
149
|
+
if (options.readOnly) {
|
|
150
|
+
sendJson(response, 403, { ok: false, error: "Preview mode is read-only." });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const payload = await readJson(request);
|
|
154
|
+
const curve = curveFromPayload(payload);
|
|
155
|
+
const doc = core.setTimelineKeyframe(
|
|
156
|
+
loadDocument(inputPath),
|
|
157
|
+
requiredString(payload.id, "id"),
|
|
158
|
+
requiredString(payload.property, "property"),
|
|
159
|
+
requiredNumber(payload.time, "time"),
|
|
160
|
+
normalizeMotionValue(payload.value),
|
|
161
|
+
curve ? { out: curve } : {}
|
|
162
|
+
);
|
|
163
|
+
saveDocument(inputPath, doc);
|
|
164
|
+
sendJson(response, 200, editorDocumentPayload(doc));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (request.method === "POST" && url.pathname === "/api/remove-keyframe") {
|
|
168
|
+
if (options.readOnly) {
|
|
169
|
+
sendJson(response, 403, { ok: false, error: "Preview mode is read-only." });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const payload = await readJson(request);
|
|
173
|
+
const doc = core.removeTimelineKeyframe(loadDocument(inputPath), requiredString(payload.id, "id"), requiredString(payload.property, "property"), requiredNumber(payload.time, "time"));
|
|
174
|
+
saveDocument(inputPath, doc);
|
|
175
|
+
sendJson(response, 200, editorDocumentPayload(doc));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
send(response, 404, "text/plain; charset=utf-8", "Not found");
|
|
179
|
+
} catch (error) {
|
|
180
|
+
sendJson(response, 500, { ok: false, error: error?.message || String(error) });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await listen(server, port);
|
|
185
|
+
const url = `http://localhost:${port}/`;
|
|
186
|
+
console.log(`${options.label || "Editor"}: ${url}`);
|
|
187
|
+
if (!args.includes("--no-open")) openBrowser(url);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function lint(args) {
|
|
191
|
+
const input = args[0];
|
|
192
|
+
if (!input) throw new Error("lint requires an input JSON file.");
|
|
193
|
+
const doc = JSON.parse(fs.readFileSync(path.resolve(input), "utf8"));
|
|
194
|
+
const validation = core.validateVisualDocument(doc);
|
|
195
|
+
const diagnostics = validation.ok ? core.lintVisualDocument(doc) : { warnings: [] };
|
|
196
|
+
const payload = {
|
|
197
|
+
ok: validation.ok && diagnostics.warnings.length === 0,
|
|
198
|
+
issues: validation.issues,
|
|
199
|
+
warnings: [...validation.warnings, ...diagnostics.warnings]
|
|
200
|
+
};
|
|
201
|
+
if (args.includes("--json")) {
|
|
202
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
for (const issue of payload.issues) console.error(`Issue ${issue.path}: ${issue.message}${issue.suggestion ? ` ${issue.suggestion}` : ""}`);
|
|
206
|
+
for (const warning of payload.warnings) console.warn(`Warning ${warning.path}: ${warning.message}${warning.suggestion ? ` ${warning.suggestion}` : ""}`);
|
|
207
|
+
if (payload.issues.length) process.exitCode = 1;
|
|
208
|
+
if (!payload.issues.length && !payload.warnings.length) console.log("No issues or warnings.");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function loadDocument(inputPath) {
|
|
212
|
+
const doc = JSON.parse(fs.readFileSync(inputPath, "utf8"));
|
|
213
|
+
const result = core.validateVisualDocument(doc);
|
|
214
|
+
for (const warning of result.warnings) console.warn(`Warning ${warning.path}: ${warning.message}${warning.suggestion ? ` ${warning.suggestion}` : ""}`);
|
|
215
|
+
if (!result.ok) {
|
|
216
|
+
const first = result.issues[0];
|
|
217
|
+
throw new Error(first ? `${first.path}: ${first.message}` : "Invalid visual document.");
|
|
218
|
+
}
|
|
219
|
+
return doc;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function saveDocument(inputPath, document) {
|
|
223
|
+
const result = core.validateVisualDocument(document);
|
|
224
|
+
if (!result.ok) {
|
|
225
|
+
const first = result.issues[0];
|
|
226
|
+
throw new Error(first ? `${first.path}: ${first.message}` : "Invalid visual document.");
|
|
227
|
+
}
|
|
228
|
+
fs.writeFileSync(inputPath, JSON.stringify(document, null, 2) + "\n", "utf8");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function inferFormat(outputPath) {
|
|
232
|
+
const ext = path.extname(outputPath).toLowerCase().replace(".", "");
|
|
233
|
+
if (ext === "svg" || ext === "html" || ext === "mp4" || ext === "webm") return ext;
|
|
234
|
+
throw new Error(`Cannot infer kernel output format from '${outputPath}'. Use .svg, .html, .mp4, or .webm.`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function exportFromEditor(response, document, inputPath, url) {
|
|
238
|
+
const format = String(url.searchParams.get("format") || "svg").toLowerCase();
|
|
239
|
+
const duration = Number(document.canvas.duration ?? 0);
|
|
240
|
+
const time = clamp(Number(url.searchParams.get("time") || 0), 0, Math.max(duration, 0));
|
|
241
|
+
const base = exportBaseName(inputPath);
|
|
242
|
+
if (format === "svg") {
|
|
243
|
+
const svg = core.renderToSvg(document, { time });
|
|
244
|
+
sendDownload(response, 200, "image/svg+xml; charset=utf-8", `${base}-${timeLabel(time)}.svg`, Buffer.from(svg, "utf8"));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (format === "png") {
|
|
248
|
+
const sharp = loadSharp();
|
|
249
|
+
const svg = core.renderToSvg(document, { time });
|
|
250
|
+
const png = await sharp(Buffer.from(svg, "utf8")).png().toBuffer();
|
|
251
|
+
sendDownload(response, 200, "image/png", `${base}-${timeLabel(time)}.png`, png);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (format === "mp4") {
|
|
255
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-editor-export-"));
|
|
256
|
+
const outputPath = path.join(tempDir, "export.mp4");
|
|
257
|
+
try {
|
|
258
|
+
await renderVideo(document, outputPath, "mp4", {
|
|
259
|
+
duration: visualDuration(document),
|
|
260
|
+
fps: visualFps(document),
|
|
261
|
+
transparent: false
|
|
262
|
+
});
|
|
263
|
+
sendDownload(response, 200, "video/mp4", `${base}.mp4`, fs.readFileSync(outputPath));
|
|
264
|
+
} finally {
|
|
265
|
+
safeRemoveDirectory(tempDir);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
throw new Error("Editor export format must be svg, png, or mp4.");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function renderVideo(document, outputPath, format, options) {
|
|
273
|
+
if (format === "mp4" && options.transparent) {
|
|
274
|
+
throw new Error("MP4 does not support alpha in this exporter. Use .webm for transparent video.");
|
|
275
|
+
}
|
|
276
|
+
const sharp = loadSharp();
|
|
277
|
+
const ffmpeg = findExecutable("ffmpeg");
|
|
278
|
+
const duration = visualDuration(document, options.duration);
|
|
279
|
+
const fps = visualFps(document, options.fps);
|
|
280
|
+
const frameCount = Math.max(1, Math.ceil(duration * fps));
|
|
281
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-kernel-video-"));
|
|
282
|
+
try {
|
|
283
|
+
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
284
|
+
const time = Math.min(duration, frame / fps);
|
|
285
|
+
const svg = core.renderToSvg(document, { time, transparent: options.transparent });
|
|
286
|
+
const framePath = path.join(tempDir, `frame-${String(frame + 1).padStart(5, "0")}.png`);
|
|
287
|
+
await sharp(Buffer.from(svg)).png().toFile(framePath);
|
|
288
|
+
}
|
|
289
|
+
const pattern = path.join(tempDir, "frame-%05d.png");
|
|
290
|
+
const args = format === "mp4"
|
|
291
|
+
? ["-y", "-framerate", String(fps), "-i", pattern, "-pix_fmt", "yuv420p", "-movflags", "+faststart", outputPath]
|
|
292
|
+
: ["-y", "-framerate", String(fps), "-i", pattern, "-c:v", "libvpx-vp9", "-pix_fmt", options.transparent ? "yuva420p" : "yuv420p", outputPath];
|
|
293
|
+
const result = spawnSync(ffmpeg, args, { stdio: "pipe" });
|
|
294
|
+
if (result.status !== 0) {
|
|
295
|
+
const stderr = result.stderr?.toString("utf8").trim();
|
|
296
|
+
throw new Error(stderr ? `ffmpeg failed: ${stderr}` : "ffmpeg failed.");
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
safeRemoveDirectory(tempDir);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function loadSharp() {
|
|
304
|
+
ensureRasterFontConfig();
|
|
305
|
+
try {
|
|
306
|
+
return require("sharp");
|
|
307
|
+
} catch {
|
|
308
|
+
const pnpmRoot = path.resolve(__dirname, "..", "..", "node_modules", ".pnpm");
|
|
309
|
+
if (fs.existsSync(pnpmRoot)) {
|
|
310
|
+
for (const entry of fs.readdirSync(pnpmRoot)) {
|
|
311
|
+
const candidate = path.join(pnpmRoot, entry, "node_modules", "sharp");
|
|
312
|
+
if (entry.startsWith("sharp@") && fs.existsSync(candidate)) return require(candidate);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
throw new Error("Video export requires the optional 'sharp' package to rasterize SVG frames.");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function ensureRasterFontConfig() {
|
|
320
|
+
if (rasterFontConfigPrepared) return;
|
|
321
|
+
rasterFontConfigPrepared = true;
|
|
322
|
+
if (process.env.SKETCHMARK_DISABLE_FONTCONFIG === "1") return;
|
|
323
|
+
if (process.env.FONTCONFIG_FILE) return;
|
|
324
|
+
|
|
325
|
+
const fontDirs = localFontDirs();
|
|
326
|
+
|
|
327
|
+
if (!fontDirs.length) return;
|
|
328
|
+
|
|
329
|
+
const configDir = path.join(os.tmpdir(), "sketchmark-fontconfig");
|
|
330
|
+
const cacheDir = path.join(configDir, "cache");
|
|
331
|
+
const configFile = path.join(configDir, "fonts.conf");
|
|
332
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
333
|
+
|
|
334
|
+
const dirsXml = fontDirs.map((dir) => ` <dir>${escapeXml(fontconfigPath(dir))}</dir>`).join("\n");
|
|
335
|
+
const xml = [
|
|
336
|
+
"<?xml version=\"1.0\"?>",
|
|
337
|
+
"<!DOCTYPE fontconfig SYSTEM \"fonts.dtd\">",
|
|
338
|
+
"<fontconfig>",
|
|
339
|
+
dirsXml,
|
|
340
|
+
` <cachedir>${escapeXml(fontconfigPath(cacheDir))}</cachedir>`,
|
|
341
|
+
" <alias>",
|
|
342
|
+
" <family>Inter</family>",
|
|
343
|
+
" <prefer>",
|
|
344
|
+
" <family>Roboto</family>",
|
|
345
|
+
" <family>sans-serif</family>",
|
|
346
|
+
" </prefer>",
|
|
347
|
+
" </alias>",
|
|
348
|
+
" <alias>",
|
|
349
|
+
" <family>Arial</family>",
|
|
350
|
+
" <prefer>",
|
|
351
|
+
" <family>Roboto</family>",
|
|
352
|
+
" <family>sans-serif</family>",
|
|
353
|
+
" </prefer>",
|
|
354
|
+
" </alias>",
|
|
355
|
+
" <alias>",
|
|
356
|
+
" <family>Helvetica</family>",
|
|
357
|
+
" <prefer>",
|
|
358
|
+
" <family>Roboto</family>",
|
|
359
|
+
" <family>sans-serif</family>",
|
|
360
|
+
" </prefer>",
|
|
361
|
+
" </alias>",
|
|
362
|
+
" <alias>",
|
|
363
|
+
" <family>system-ui</family>",
|
|
364
|
+
" <prefer>",
|
|
365
|
+
" <family>Roboto</family>",
|
|
366
|
+
" <family>sans-serif</family>",
|
|
367
|
+
" </prefer>",
|
|
368
|
+
" </alias>",
|
|
369
|
+
"</fontconfig>",
|
|
370
|
+
""
|
|
371
|
+
].join("\n");
|
|
372
|
+
|
|
373
|
+
fs.writeFileSync(configFile, xml, "utf8");
|
|
374
|
+
process.env.FONTCONFIG_FILE = configFile;
|
|
375
|
+
process.env.FONTCONFIG_PATH = configDir;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function fontconfigPath(value) {
|
|
379
|
+
return String(value || "").replace(/\\/g, "/");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function escapeXml(value) {
|
|
383
|
+
return String(value || "")
|
|
384
|
+
.replace(/&/g, "&")
|
|
385
|
+
.replace(/</g, "<")
|
|
386
|
+
.replace(/>/g, ">")
|
|
387
|
+
.replace(/"/g, """)
|
|
388
|
+
.replace(/'/g, "'");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function findExecutable(name) {
|
|
392
|
+
const check = process.platform === "win32" ? `${name}.exe` : name;
|
|
393
|
+
const result = spawnSync(check, ["-version"], { stdio: "ignore" });
|
|
394
|
+
if (!result.error) return check;
|
|
395
|
+
throw new Error(`Video export requires '${name}' to be available on PATH.`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function visualDuration(document, override) {
|
|
399
|
+
const value = Number(override ?? document.canvas.duration ?? 0);
|
|
400
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
401
|
+
throw new Error("Video export and preview require a positive canvas.duration or --duration value.");
|
|
402
|
+
}
|
|
403
|
+
return value;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function visualFps(document, override) {
|
|
407
|
+
const value = Number(override ?? document.canvas.fps ?? 30);
|
|
408
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error("FPS must be a positive number.");
|
|
409
|
+
return Math.round(value);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function listen(server, port) {
|
|
413
|
+
return new Promise((resolve, reject) => {
|
|
414
|
+
server.once("error", reject);
|
|
415
|
+
server.listen(port, () => {
|
|
416
|
+
server.off("error", reject);
|
|
417
|
+
resolve();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function send(response, status, type, body) {
|
|
423
|
+
response.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
424
|
+
response.end(body);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function sendDownload(response, status, type, filename, body) {
|
|
428
|
+
response.writeHead(status, {
|
|
429
|
+
"content-type": type,
|
|
430
|
+
"content-disposition": `attachment; filename="${safeHeaderFilename(filename)}"`,
|
|
431
|
+
"cache-control": "no-store"
|
|
432
|
+
});
|
|
433
|
+
response.end(body);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function sendJson(response, status, payload) {
|
|
437
|
+
send(response, status, "application/json; charset=utf-8", JSON.stringify(payload));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function sendFontAsset(response, pathname) {
|
|
441
|
+
const fontPath = resolveFontAsset(pathname);
|
|
442
|
+
if (!fontPath) return false;
|
|
443
|
+
send(response, 200, fontMimeType(path.extname(fontPath)), fs.readFileSync(fontPath));
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function resolveFontAsset(pathname) {
|
|
448
|
+
if (typeof pathname !== "string" || !pathname.startsWith("/fonts/")) return "";
|
|
449
|
+
let relativePath = pathname.slice("/fonts/".length);
|
|
450
|
+
try {
|
|
451
|
+
relativePath = decodeURIComponent(relativePath);
|
|
452
|
+
} catch {
|
|
453
|
+
return "";
|
|
454
|
+
}
|
|
455
|
+
if (!relativePath || relativePath.includes("\0")) return "";
|
|
456
|
+
const normalized = path.normalize(relativePath).replace(/^([\\/])+/, "");
|
|
457
|
+
const root = path.resolve(LOCAL_FONTS_DIR);
|
|
458
|
+
const resolved = path.resolve(root, normalized);
|
|
459
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return "";
|
|
460
|
+
const extension = path.extname(resolved).toLowerCase();
|
|
461
|
+
if (!FONT_EXTENSIONS.has(extension)) return "";
|
|
462
|
+
if (!fs.existsSync(resolved)) return "";
|
|
463
|
+
const stats = fs.statSync(resolved);
|
|
464
|
+
if (!stats.isFile()) return "";
|
|
465
|
+
return resolved;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function fontMimeType(extension) {
|
|
469
|
+
const ext = String(extension || "").toLowerCase();
|
|
470
|
+
if (ext === ".otf") return "font/otf";
|
|
471
|
+
if (ext === ".woff") return "font/woff";
|
|
472
|
+
if (ext === ".woff2") return "font/woff2";
|
|
473
|
+
return "font/ttf";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function localFontDirs() {
|
|
477
|
+
const root = path.resolve(LOCAL_FONTS_DIR);
|
|
478
|
+
if (!fs.existsSync(root)) return [];
|
|
479
|
+
const dirs = new Set();
|
|
480
|
+
const pending = [root];
|
|
481
|
+
while (pending.length) {
|
|
482
|
+
const current = pending.pop();
|
|
483
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
484
|
+
for (const entry of entries) {
|
|
485
|
+
const target = path.join(current, entry.name);
|
|
486
|
+
if (entry.isDirectory()) {
|
|
487
|
+
pending.push(target);
|
|
488
|
+
} else if (entry.isFile() && FONT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
489
|
+
dirs.add(current);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return Array.from(dirs);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function editorDocumentPayload(document) {
|
|
497
|
+
return {
|
|
498
|
+
ok: true,
|
|
499
|
+
document,
|
|
500
|
+
elements: core.listElementReferences(document),
|
|
501
|
+
canvas: document.canvas,
|
|
502
|
+
duration: Number(document.canvas.duration ?? 0),
|
|
503
|
+
fps: visualFps(document)
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function exportBaseName(inputPath) {
|
|
508
|
+
const file = path.basename(inputPath).replace(/\.visual\.json$/i, "").replace(/\.[^.]+$/i, "");
|
|
509
|
+
return safeHeaderFilename(file || "sketchmark");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function timeLabel(time) {
|
|
513
|
+
return `t${Number(time).toFixed(2).replace(".", "-")}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function safeHeaderFilename(value) {
|
|
517
|
+
return String(value || "sketchmark").replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function readJson(request) {
|
|
521
|
+
return new Promise((resolve, reject) => {
|
|
522
|
+
let body = "";
|
|
523
|
+
request.on("data", (chunk) => {
|
|
524
|
+
body += chunk;
|
|
525
|
+
if (body.length > 1_000_000) {
|
|
526
|
+
request.destroy();
|
|
527
|
+
reject(new Error("Request body is too large."));
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
request.on("end", () => {
|
|
531
|
+
try {
|
|
532
|
+
resolve(body ? JSON.parse(body) : {});
|
|
533
|
+
} catch {
|
|
534
|
+
reject(new Error("Request body must be JSON."));
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
request.on("error", reject);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function requiredString(value, name) {
|
|
542
|
+
if (typeof value !== "string" || !value) throw new Error(`${name} must be a non-empty string.`);
|
|
543
|
+
return value;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function requiredNumber(value, name) {
|
|
547
|
+
const number = Number(value);
|
|
548
|
+
if (!Number.isFinite(number)) throw new Error(`${name} must be a finite number.`);
|
|
549
|
+
return number;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function applyCanvasPatch(document, payload) {
|
|
553
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
554
|
+
throw new Error("canvas payload must be an object.");
|
|
555
|
+
}
|
|
556
|
+
const next = {
|
|
557
|
+
...document,
|
|
558
|
+
canvas: { ...(document.canvas || {}) }
|
|
559
|
+
};
|
|
560
|
+
if ("width" in payload) next.canvas.width = requiredCanvasDimension(payload.width, "width");
|
|
561
|
+
if ("height" in payload) next.canvas.height = requiredCanvasDimension(payload.height, "height");
|
|
562
|
+
if ("background" in payload) {
|
|
563
|
+
if (payload.background === null || payload.background === "") delete next.canvas.background;
|
|
564
|
+
else if (typeof payload.background === "string") next.canvas.background = payload.background;
|
|
565
|
+
else throw new Error("background must be a string or null.");
|
|
566
|
+
}
|
|
567
|
+
if ("duration" in payload) {
|
|
568
|
+
if (payload.duration === null || payload.duration === "") delete next.canvas.duration;
|
|
569
|
+
else {
|
|
570
|
+
const duration = Number(payload.duration);
|
|
571
|
+
if (!Number.isFinite(duration) || duration < 0) throw new Error("duration must be a non-negative number or null.");
|
|
572
|
+
next.canvas.duration = duration;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if ("fps" in payload) {
|
|
576
|
+
if (payload.fps === null || payload.fps === "") delete next.canvas.fps;
|
|
577
|
+
else {
|
|
578
|
+
const fps = Number(payload.fps);
|
|
579
|
+
if (!Number.isFinite(fps) || fps <= 0) throw new Error("fps must be a positive number or null.");
|
|
580
|
+
next.canvas.fps = Math.round(fps);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return next;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function requiredCanvasDimension(value, name) {
|
|
587
|
+
const number = Number(value);
|
|
588
|
+
if (!Number.isFinite(number) || number <= 0) throw new Error(`${name} must be a positive number.`);
|
|
589
|
+
return Math.round(number);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function normalizeMotionValue(value) {
|
|
593
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
594
|
+
if (typeof value === "string") return value;
|
|
595
|
+
if (Array.isArray(value)) {
|
|
596
|
+
if (value.every((item) => Number.isFinite(Number(item)))) return value.map((item) => Number(item));
|
|
597
|
+
if (value.every((item) => typeof item === "string")) return value.slice();
|
|
598
|
+
throw new Error("array values must contain only numbers or only strings.");
|
|
599
|
+
}
|
|
600
|
+
if (value && typeof value === "object") {
|
|
601
|
+
const out = {};
|
|
602
|
+
for (const [key, item] of Object.entries(value)) out[key] = normalizeJsonMotionValue(item);
|
|
603
|
+
return out;
|
|
604
|
+
}
|
|
605
|
+
throw new Error("value must be a JSON-safe timeline value.");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function normalizeJsonMotionValue(value) {
|
|
609
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return value;
|
|
610
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
611
|
+
if (Array.isArray(value)) return value.map(normalizeJsonMotionValue);
|
|
612
|
+
if (value && typeof value === "object") {
|
|
613
|
+
const out = {};
|
|
614
|
+
for (const [key, item] of Object.entries(value)) out[key] = normalizeJsonMotionValue(item);
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
617
|
+
throw new Error("object timeline values must be JSON-safe.");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function curveFromPayload(payload) {
|
|
621
|
+
if (payload.curve && typeof payload.curve === "object") return payload.curve;
|
|
622
|
+
if (typeof payload.curvePreset === "string") return core.timelineCurvePreset(payload.curvePreset);
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function openBrowser(url) {
|
|
627
|
+
const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
628
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
629
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
630
|
+
child.unref();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function safeRemoveDirectory(directory) {
|
|
634
|
+
const root = os.tmpdir();
|
|
635
|
+
const resolved = path.resolve(directory);
|
|
636
|
+
if (resolved.startsWith(path.resolve(root))) fs.rmSync(resolved, { recursive: true, force: true });
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function numberOption(args, name, fallback) {
|
|
640
|
+
const index = args.indexOf(name);
|
|
641
|
+
if (index === -1) return fallback;
|
|
642
|
+
const value = Number(args[index + 1]);
|
|
643
|
+
return Number.isFinite(value) ? value : fallback;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function clamp(value, min, max) {
|
|
647
|
+
return Math.max(min, Math.min(max, value));
|
|
648
|
+
}
|