sketchmark 2.0.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.
Files changed (64) hide show
  1. package/README.md +188 -0
  2. package/bin/sketchmark.cjs +2008 -0
  3. package/dist/src/builders/index.d.ts +74 -0
  4. package/dist/src/builders/index.js +230 -0
  5. package/dist/src/compounds.d.ts +13 -0
  6. package/dist/src/compounds.js +118 -0
  7. package/dist/src/deck.d.ts +4 -0
  8. package/dist/src/deck.js +91 -0
  9. package/dist/src/diagnostics.d.ts +5 -0
  10. package/dist/src/diagnostics.js +113 -0
  11. package/dist/src/export/index.d.ts +8 -0
  12. package/dist/src/export/index.js +15 -0
  13. package/dist/src/index.d.ts +19 -0
  14. package/dist/src/index.js +35 -0
  15. package/dist/src/kernel.d.ts +8 -0
  16. package/dist/src/kernel.js +68 -0
  17. package/dist/src/normalize.d.ts +6 -0
  18. package/dist/src/normalize.js +191 -0
  19. package/dist/src/patch.d.ts +5 -0
  20. package/dist/src/patch.js +72 -0
  21. package/dist/src/path-sampling.d.ts +3 -0
  22. package/dist/src/path-sampling.js +275 -0
  23. package/dist/src/player/index.d.ts +68 -0
  24. package/dist/src/player/index.js +600 -0
  25. package/dist/src/project.d.ts +11 -0
  26. package/dist/src/project.js +107 -0
  27. package/dist/src/render/html.d.ts +2 -0
  28. package/dist/src/render/html.js +13 -0
  29. package/dist/src/render/raw-three.d.ts +7 -0
  30. package/dist/src/render/raw-three.js +17 -0
  31. package/dist/src/render/svg.d.ts +3 -0
  32. package/dist/src/render/svg.js +277 -0
  33. package/dist/src/render/three-html.d.ts +2 -0
  34. package/dist/src/render/three-html.js +303 -0
  35. package/dist/src/render/three-preview-svg.d.ts +3 -0
  36. package/dist/src/render/three-preview-svg.js +102 -0
  37. package/dist/src/scenes.d.ts +4 -0
  38. package/dist/src/scenes.js +25 -0
  39. package/dist/src/schema.d.ts +2 -0
  40. package/dist/src/schema.js +403 -0
  41. package/dist/src/sequences.d.ts +43 -0
  42. package/dist/src/sequences.js +109 -0
  43. package/dist/src/shapes/builtins.d.ts +2 -0
  44. package/dist/src/shapes/builtins.js +429 -0
  45. package/dist/src/shapes/common.d.ts +9 -0
  46. package/dist/src/shapes/common.js +75 -0
  47. package/dist/src/shapes/geometry.d.ts +22 -0
  48. package/dist/src/shapes/geometry.js +166 -0
  49. package/dist/src/shapes/index.d.ts +2 -0
  50. package/dist/src/shapes/index.js +18 -0
  51. package/dist/src/shapes/registry.d.ts +9 -0
  52. package/dist/src/shapes/registry.js +35 -0
  53. package/dist/src/shapes/types.d.ts +34 -0
  54. package/dist/src/shapes/types.js +2 -0
  55. package/dist/src/types.d.ts +439 -0
  56. package/dist/src/types.js +2 -0
  57. package/dist/src/utils.d.ts +25 -0
  58. package/dist/src/utils.js +157 -0
  59. package/dist/src/validate.d.ts +2 -0
  60. package/dist/src/validate.js +434 -0
  61. package/dist/tests/run.d.ts +1 -0
  62. package/dist/tests/run.js +651 -0
  63. package/package.json +52 -0
  64. package/schema/visual.schema.json +930 -0
@@ -0,0 +1,2008 @@
1
+ #!/usr/bin/env node
2
+ const fs = require("node:fs");
3
+ const crypto = require("node:crypto");
4
+ const http = require("node:http");
5
+ const net = require("node:net");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const { pathToFileURL } = require("node:url");
9
+ const { spawn, spawnSync } = require("node:child_process");
10
+
11
+ const core = require("../dist/src");
12
+
13
+ main().catch((error) => {
14
+ console.error(error?.message || String(error));
15
+ process.exit(1);
16
+ });
17
+
18
+ async function main() {
19
+ const [command, ...args] = process.argv.slice(2);
20
+ if (!command || command === "-h" || command === "--help") {
21
+ usage();
22
+ return;
23
+ }
24
+
25
+ if (command === "preview") {
26
+ await preview(args);
27
+ return;
28
+ }
29
+
30
+ if (command === "render") {
31
+ await render(args);
32
+ return;
33
+ }
34
+
35
+ if (command === "timeline") {
36
+ await timeline(args);
37
+ return;
38
+ }
39
+
40
+ if (command === "lint") {
41
+ await lint(args);
42
+ return;
43
+ }
44
+
45
+ if (command === "screenshot-lint") {
46
+ await screenshotLint(args);
47
+ return;
48
+ }
49
+
50
+ throw new Error(`Unknown command '${command}'.`);
51
+ }
52
+
53
+ function usage() {
54
+ console.log(`Sketchmark primitive JSON visual CLI
55
+
56
+ Usage:
57
+ sketchmark preview <input.visual.json> [--scene intro] [--sequence main] [--deck] [--port 5177] [--no-open]
58
+ sketchmark render <input.visual.json> <output.svg|html|png|jpg|pdf|pptx|mp4|webm> [--scene intro] [--sequence main] [--deck] [--time 1.2] [--fps 30] [--duration 4] [--keep-frames] [--transparent]
59
+ sketchmark timeline <input.visual.json> [--sequence main] [--fps 30] [--out timeline.json]
60
+ sketchmark lint <input.visual.json> [--json]
61
+ sketchmark screenshot-lint <input.visual.json> [--scene intro] [--sequence main] [--time 1.2] [--json]
62
+
63
+ Examples:
64
+ sketchmark preview examples/dns.visual.json
65
+ sketchmark render examples/dns.visual.json out.svg
66
+ sketchmark render examples/dns.visual.json out.png --time 2
67
+ sketchmark render examples/dns.visual.json out.mp4 --fps 30 --duration 8
68
+ sketchmark render examples/dns.visual.json transparent.webm --transparent --fps 30 --duration 8
69
+ sketchmark render examples/three-cube.visual.json cube.png
70
+ sketchmark render examples/dns.visual.json out.pdf
71
+ sketchmark render examples/deck.visual.json deck.pptx --deck --scene slide
72
+ sketchmark timeline examples/project.visual.json --sequence main --fps 12
73
+ `);
74
+ }
75
+
76
+ async function preview(args) {
77
+ const input = args[0];
78
+ if (!input) throw new Error("preview requires an input JSON file.");
79
+ const port = numberOption(args, "--port", 5177);
80
+ const shouldOpen = !args.includes("--no-open");
81
+ const scene = stringOption(args, "--scene");
82
+ const sequence = stringOption(args, "--sequence");
83
+ const deck = args.includes("--deck");
84
+ const inputPath = path.resolve(input);
85
+
86
+ const server = http.createServer((request, response) => {
87
+ void (async () => {
88
+ try {
89
+ const url = new URL(request.url || "/", "http://127.0.0.1");
90
+ if (url.pathname === "/") {
91
+ send(response, 200, inlineEditorHtml({ scene, sequence, deck }), "text/html; charset=utf-8");
92
+ return;
93
+ }
94
+ if (url.pathname.startsWith("/three.")) {
95
+ const filePath = threeBuildFile(url.pathname);
96
+ send(response, 200, fs.readFileSync(filePath), mimeType(filePath));
97
+ return;
98
+ }
99
+ if (url.pathname === "/api/initial") {
100
+ const source = fs.readFileSync(inputPath, "utf8");
101
+ sendJson(response, 200, {
102
+ ok: true,
103
+ file: inputPath,
104
+ fileName: path.basename(inputPath),
105
+ source,
106
+ scene,
107
+ sequence,
108
+ deck
109
+ });
110
+ return;
111
+ }
112
+ if (request.method === "POST" && url.pathname === "/api/render") {
113
+ const body = JSON.parse(await readRequestBody(request, 8_000_000));
114
+ const source = String(body.source || "");
115
+ const time = Number(body.time || 0);
116
+ const requestedScene = Object.prototype.hasOwnProperty.call(body, "scene") ? (body.scene ? String(body.scene) : undefined) : scene;
117
+ const requestedSequence = Object.prototype.hasOwnProperty.call(body, "sequence") ? (body.sequence ? String(body.sequence) : undefined) : sequence;
118
+ const requestedDeck = Object.prototype.hasOwnProperty.call(body, "deck") ? Boolean(body.deck) : deck;
119
+ const requestedDeckStep = Object.prototype.hasOwnProperty.call(body, "deckStep") ? Number(body.deckStep) : undefined;
120
+ const result = renderPreviewSource(inputPath, source, { scene: requestedScene, sequence: requestedSequence, time, deck: requestedDeck, deckStep: requestedDeckStep });
121
+ sendJson(response, 200, result);
122
+ return;
123
+ }
124
+ if (request.method === "POST" && url.pathname === "/api/save") {
125
+ const body = JSON.parse(await readRequestBody(request, 8_000_000));
126
+ const source = String(body.source || "");
127
+ JSON.parse(source);
128
+ fs.writeFileSync(inputPath, source, "utf8");
129
+ sendJson(response, 200, { ok: true });
130
+ return;
131
+ }
132
+ if (request.method === "POST" && url.pathname === "/api/export") {
133
+ const body = JSON.parse(await readRequestBody(request, 8_000_000));
134
+ await exportPreviewSource(inputPath, body, response);
135
+ return;
136
+ }
137
+ if (url.pathname === "/frame.svg") {
138
+ const time = Number(url.searchParams.get("time") || 0);
139
+ const doc = loadDocument(inputPath);
140
+ const frame = frameDocument(doc, { scene, sequence, time });
141
+ send(response, 200, core.renderToSvg(frame.document, { time: frame.localTime }), "image/svg+xml; charset=utf-8");
142
+ return;
143
+ }
144
+ send(response, 404, "Not found", "text/plain; charset=utf-8");
145
+ } catch (error) {
146
+ if (!response.headersSent) send(response, 500, String(error?.message || error), "text/plain; charset=utf-8");
147
+ }
148
+ })();
149
+ });
150
+
151
+ await listen(server, port);
152
+ const url = `http://127.0.0.1:${port}/`;
153
+ console.log(`Sketchmark preview: ${url}`);
154
+ console.log(`Source: ${inputPath}`);
155
+ if (shouldOpen) openBrowser(url);
156
+ }
157
+
158
+ async function render(args) {
159
+ const input = args[0];
160
+ const output = args[1];
161
+ if (!input || !output) throw new Error("render requires input and output paths.");
162
+
163
+ const inputPath = path.resolve(input);
164
+ const outputPath = path.resolve(output);
165
+ const doc = loadDocument(inputPath);
166
+ const format = inferFormat(outputPath);
167
+ const time = numberOption(args, "--time", 0);
168
+ const scene = stringOption(args, "--scene");
169
+ const sequence = stringOption(args, "--sequence");
170
+ const deck = args.includes("--deck");
171
+ const fps = numberOption(args, "--fps", doc.canvas.fps || 30);
172
+ const duration = numberOption(args, "--duration", doc.canvas.duration || 0);
173
+ const keepFrames = args.includes("--keep-frames");
174
+ const browser = stringOption(args, "--browser");
175
+ const transparent = args.includes("--transparent");
176
+
177
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
178
+
179
+ if (format === "svg") {
180
+ const frame = frameDocument(doc, { scene, sequence, time });
181
+ fs.writeFileSync(outputPath, core.renderToSvg(frame.document, { time: frame.localTime, transparent }), "utf8");
182
+ } else if (format === "html") {
183
+ if (deck) {
184
+ const sceneId = scene || firstSceneId(doc);
185
+ if (!sceneId) throw new Error("--deck requires a scene with steps.");
186
+ fs.writeFileSync(outputPath, core.renderDeckToHtml(doc, sceneId), "utf8");
187
+ } else {
188
+ const frame = frameDocument(doc, { scene, sequence, time });
189
+ fs.writeFileSync(outputPath, core.renderToHtml(frame.document, { time: frame.localTime, transparent }), "utf8");
190
+ }
191
+ } else if (format === "png" || format === "jpg") {
192
+ await renderRaster(doc, outputPath, format, { scene, sequence, time, browser, transparent });
193
+ } else if (format === "pdf") {
194
+ await renderPdf(doc, outputPath, { scene, sequence, time, deck, browser });
195
+ } else if (format === "pptx") {
196
+ await renderPptx(doc, outputPath, { scene, sequence, time, deck, transparent });
197
+ } else if (format === "mp4") {
198
+ if (transparent) throw new Error("Transparent MP4 is not supported. Use transparent PNG frames with --keep-frames, or export PNG/SVG.");
199
+ await renderMp4(doc, outputPath, { fps, duration, keepFrames, scene, sequence, browser, transparent });
200
+ } else if (format === "webm") {
201
+ await renderWebm(doc, outputPath, { fps, duration, keepFrames, scene, sequence, browser, transparent });
202
+ } else {
203
+ throw new Error(`Unsupported output format '${format}'.`);
204
+ }
205
+
206
+ console.log(`Rendered ${format.toUpperCase()}: ${outputPath}`);
207
+ }
208
+
209
+ async function lint(args) {
210
+ const input = args[0];
211
+ if (!input) throw new Error("lint requires an input JSON file.");
212
+ const doc = loadDocumentForLint(path.resolve(input));
213
+ const validation = core.validateVisualDocument(doc);
214
+ const diagnostics = core.lintVisualDocument(doc);
215
+ const payload = {
216
+ ok: validation.ok && diagnostics.warnings.length === 0,
217
+ issues: validation.issues,
218
+ warnings: [...validation.warnings, ...diagnostics.warnings]
219
+ };
220
+ if (args.includes("--json")) {
221
+ console.log(JSON.stringify(payload, null, 2));
222
+ return;
223
+ }
224
+ for (const issue of payload.issues) {
225
+ console.error(`Issue ${issue.path}: ${issue.message}${issue.suggestion ? ` ${issue.suggestion}` : ""}`);
226
+ }
227
+ for (const warning of payload.warnings) {
228
+ console.warn(`Warning ${warning.path}: ${warning.message}${warning.suggestion ? ` ${warning.suggestion}` : ""}`);
229
+ }
230
+ if (payload.issues.length) process.exitCode = 1;
231
+ if (!payload.issues.length && !payload.warnings.length) console.log("No issues or warnings.");
232
+ }
233
+
234
+ function loadDocumentForLint(inputPath) {
235
+ try {
236
+ return core.loadVisualProject(inputPath).document;
237
+ } catch {
238
+ return JSON.parse(fs.readFileSync(inputPath, "utf8"));
239
+ }
240
+ }
241
+
242
+ async function screenshotLint(args) {
243
+ const input = args[0];
244
+ if (!input) throw new Error("screenshot-lint requires an input JSON file.");
245
+ const doc = loadDocument(path.resolve(input));
246
+ const scene = stringOption(args, "--scene");
247
+ const sequence = stringOption(args, "--sequence");
248
+ const time = numberOption(args, "--time", 0);
249
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-shot-lint-"));
250
+ try {
251
+ const pngPath = path.join(tempDir, "frame.png");
252
+ await renderRaster(doc, pngPath, "png", { scene, sequence, time });
253
+ const report = await analyzePng(pngPath);
254
+ if (args.includes("--json")) {
255
+ console.log(JSON.stringify(report, null, 2));
256
+ return;
257
+ }
258
+ for (const warning of report.warnings) {
259
+ console.warn(`Warning ${warning.code}: ${warning.message}`);
260
+ }
261
+ if (!report.warnings.length) console.log("Screenshot lint passed.");
262
+ } finally {
263
+ removeTempDir(tempDir);
264
+ }
265
+ }
266
+
267
+ async function analyzePng(pngPath) {
268
+ const sharp = loadSharp();
269
+ const { data, info } = await sharp(pngPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
270
+ let opaque = 0;
271
+ let changed = 0;
272
+ const first = [data[0] ?? 0, data[1] ?? 0, data[2] ?? 0, data[3] ?? 0];
273
+ for (let index = 0; index < data.length; index += 4) {
274
+ const alpha = data[index + 3] ?? 0;
275
+ if (alpha > 5) opaque += 1;
276
+ if (
277
+ Math.abs((data[index] ?? 0) - first[0]) > 3 ||
278
+ Math.abs((data[index + 1] ?? 0) - first[1]) > 3 ||
279
+ Math.abs((data[index + 2] ?? 0) - first[2]) > 3 ||
280
+ Math.abs(alpha - first[3]) > 3
281
+ ) {
282
+ changed += 1;
283
+ }
284
+ }
285
+ const pixels = Math.max(1, info.width * info.height);
286
+ const warnings = [];
287
+ if (opaque / pixels < 0.01) warnings.push({ code: "mostly_transparent", message: "Rendered frame is almost entirely transparent." });
288
+ if (changed / pixels < 0.005) warnings.push({ code: "nearly_blank", message: "Rendered frame has very little visual variation." });
289
+ return {
290
+ ok: warnings.length === 0,
291
+ width: info.width,
292
+ height: info.height,
293
+ opaqueRatio: opaque / pixels,
294
+ changedRatio: changed / pixels,
295
+ warnings
296
+ };
297
+ }
298
+
299
+ async function timeline(args) {
300
+ const input = args[0];
301
+ if (!input) throw new Error("timeline requires an input JSON file.");
302
+ const inputPath = path.resolve(input);
303
+ const doc = loadDocument(inputPath);
304
+ const sequence = stringOption(args, "--sequence") || core.defaultSequenceId(doc);
305
+ if (!sequence) throw new Error("timeline requires a sequence.");
306
+ const fps = numberOption(args, "--fps", doc.canvas.fps || 30);
307
+ const output = stringOption(args, "--out");
308
+ const payload = JSON.stringify(core.sequenceTimeline(doc, sequence, fps), null, 2);
309
+ if (output) {
310
+ const outputPath = path.resolve(output);
311
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
312
+ fs.writeFileSync(outputPath, payload, "utf8");
313
+ console.log(`Rendered timeline: ${outputPath}`);
314
+ } else {
315
+ console.log(payload);
316
+ }
317
+ }
318
+
319
+ function firstSceneId(doc) {
320
+ return Object.keys(doc.scenes || {})[0];
321
+ }
322
+
323
+ function loadDocument(inputPath) {
324
+ const doc = core.loadVisualProject(inputPath).document;
325
+ const result = core.validateVisualDocument(doc);
326
+ for (const warning of result.warnings) {
327
+ console.warn(`Warning ${warning.path}: ${warning.message}${warning.suggestion ? ` ${warning.suggestion}` : ""}`);
328
+ }
329
+ if (!result.ok) {
330
+ const first = result.issues[0];
331
+ throw new Error(first ? `${first.path}: ${first.message}` : "Invalid visual document.");
332
+ }
333
+ return doc;
334
+ }
335
+
336
+ function frameDocument(doc, options = {}) {
337
+ const wantsDeck = options.deck || options.deckStep !== undefined;
338
+ if (wantsDeck && !options.sequence) {
339
+ const sceneId = options.scene || firstSceneId(doc);
340
+ if (!sceneId) throw new Error("Deck preview requires a scene with steps.");
341
+ const stepIndex = Number.isFinite(Number(options.deckStep)) ? Math.trunc(Number(options.deckStep)) : -1;
342
+ const document = stepIndex < 0 ? core.documentForScene(doc, sceneId) : core.documentForDeckStep(doc, sceneId, stepIndex);
343
+ return {
344
+ document,
345
+ localTime: Number(options.time || 0),
346
+ duration: 0,
347
+ scene: sceneId,
348
+ deckStep: stepIndex
349
+ };
350
+ }
351
+ if (options.sequence) {
352
+ const frame = core.documentForSequenceTime(doc, options.sequence, Number(options.time || 0));
353
+ return { document: frame.document, localTime: frame.localTime, duration: core.compileVisualSequence(doc, options.sequence).duration, sequenceId: options.sequence, scene: frame.scene, globalTime: frame.globalTime };
354
+ }
355
+ if (options.scene) {
356
+ return { document: core.documentForScene(doc, options.scene), localTime: Number(options.time || 0), duration: Number((doc.scenes?.[options.scene]?.canvas?.duration) || doc.canvas.duration || 0), scene: options.scene };
357
+ }
358
+ const defaultSequence = core.defaultSequenceId(doc);
359
+ if (defaultSequence) {
360
+ const frame = core.documentForSequenceTime(doc, defaultSequence, Number(options.time || 0));
361
+ return { document: frame.document, localTime: frame.localTime, duration: core.compileVisualSequence(doc, defaultSequence).duration, sequenceId: defaultSequence, scene: frame.scene, globalTime: frame.globalTime };
362
+ }
363
+ return { document: doc, localTime: Number(options.time || 0), duration: Number(doc.canvas.duration || 0) };
364
+ }
365
+
366
+ function renderPreviewSource(inputPath, source, options) {
367
+ try {
368
+ const doc = loadDocumentFromSource(inputPath, source);
369
+ const validation = core.validateVisualDocument(doc);
370
+ const diagnostics = core.lintVisualDocument(doc);
371
+ if (!validation.ok) {
372
+ return { ok: false, issues: validation.issues, warnings: validation.warnings };
373
+ }
374
+ const scenes = Object.keys(doc.scenes || {});
375
+ const sequences = Object.keys(doc.sequences || {});
376
+ const sceneForPreview = options.sequence
377
+ ? options.scene
378
+ : options.scene || (scenes.length && !(doc.elements || []).length ? scenes[0] : undefined);
379
+ const shouldUseDeck = !options.sequence && sceneForPreview && (options.deck || options.deckStep !== undefined || hasDeckSteps(doc, sceneForPreview));
380
+ const deckStep = Number.isFinite(Number(options.deckStep)) ? Math.trunc(Number(options.deckStep)) : -1;
381
+ const frame = frameDocument(doc, { ...options, scene: sceneForPreview, deck: shouldUseDeck, deckStep: shouldUseDeck ? deckStep : undefined });
382
+ const sequence = frame.sequenceId ? previewSequenceMeta(doc, frame.sequenceId) : undefined;
383
+ const deck = shouldUseDeck && frame.scene ? previewDeckMeta(doc, frame.scene, deckStep) : undefined;
384
+ if (frame.document.canvas.renderer === "three") {
385
+ return {
386
+ ok: true,
387
+ renderer: "three",
388
+ html: core.renderToHtml(frame.document, { time: 0, threeRuntime: "/three.module.js" }),
389
+ duration: frame.duration,
390
+ time: Number(options.time || 0),
391
+ frameTime: frame.localTime,
392
+ scenes,
393
+ sequences,
394
+ selectedScene: frame.scene || options.scene || "",
395
+ selectedSequence: frame.sequenceId || options.sequence || "",
396
+ sequence,
397
+ deck,
398
+ warnings: [...validation.warnings, ...diagnostics.warnings],
399
+ canvas: frame.document.canvas
400
+ };
401
+ }
402
+ return {
403
+ ok: true,
404
+ renderer: "svg",
405
+ svg: core.renderToSvg(frame.document, { time: frame.localTime }),
406
+ duration: frame.duration,
407
+ time: Number(options.time || 0),
408
+ frameTime: frame.localTime,
409
+ scenes,
410
+ sequences,
411
+ selectedScene: frame.scene || options.scene || "",
412
+ selectedSequence: frame.sequenceId || options.sequence || "",
413
+ sequence,
414
+ deck,
415
+ warnings: [...validation.warnings, ...diagnostics.warnings],
416
+ canvas: frame.document.canvas
417
+ };
418
+ } catch (error) {
419
+ return { ok: false, error: error?.message || String(error) };
420
+ }
421
+ }
422
+
423
+ async function exportPreviewSource(inputPath, payload, response) {
424
+ const source = String(payload.source || "");
425
+ const format = String(payload.format || "").toLowerCase();
426
+ const scene = payload.scene ? String(payload.scene) : undefined;
427
+ const sequence = payload.sequence ? String(payload.sequence) : undefined;
428
+ const deck = Boolean(payload.deck) || Object.prototype.hasOwnProperty.call(payload, "deckStep");
429
+ const deckStep = Number.isFinite(Number(payload.deckStep)) ? Math.trunc(Number(payload.deckStep)) : -1;
430
+ const time = Number(payload.time || 0);
431
+ const transparent = Boolean(payload.transparent);
432
+ const supported = new Set(["svg", "html", "png", "jpg", "pdf", "pptx", "mp4", "webm"]);
433
+
434
+ try {
435
+ if (!supported.has(format)) {
436
+ sendJson(response, 400, { ok: false, error: `Unsupported export format '${format}'.` });
437
+ return;
438
+ }
439
+
440
+ const doc = loadDocumentFromSource(inputPath, source);
441
+ const validation = core.validateVisualDocument(doc);
442
+ if (!validation.ok) {
443
+ sendJson(response, 200, { ok: false, issues: validation.issues, warnings: validation.warnings });
444
+ return;
445
+ }
446
+
447
+ const parsed = path.parse(inputPath);
448
+ const wantsVideo = format === "mp4" || format === "webm";
449
+ const sequenceForVideo = wantsVideo ? sequence || core.defaultSequenceId(doc) : undefined;
450
+ const frameSequence = sequence && !sequenceForVideo && ["svg", "html", "png", "jpg", "pdf"].includes(format) ? sequence : undefined;
451
+ const suffix = sequenceForVideo
452
+ ? `-${sequenceForVideo}`
453
+ : frameSequence
454
+ ? `-${frameSequence}`
455
+ : scene
456
+ ? `-${scene}${deck ? `-step-${deckStep + 1}` : ""}`
457
+ : "";
458
+ const outputName = `${parsed.name}${suffix}.${format}`;
459
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sketchmark-json-preview-"));
460
+ const outputPath = path.join(tempDir, outputName);
461
+ const fps = Number(payload.fps || doc.canvas.fps || 30);
462
+ const duration = Number(payload.duration || doc.canvas.duration || 0);
463
+ const options = {
464
+ scene: sequenceForVideo || frameSequence ? undefined : scene,
465
+ sequence: sequenceForVideo,
466
+ deck: deck && !sequenceForVideo && !frameSequence,
467
+ deckStep,
468
+ time,
469
+ fps,
470
+ duration,
471
+ transparent,
472
+ browser: payload.browser ? String(payload.browser) : undefined
473
+ };
474
+
475
+ if (format === "svg") {
476
+ const frame = frameSequence ? core.documentForSequenceTime(doc, frameSequence, time) : frameDocument(doc, options);
477
+ fs.writeFileSync(outputPath, core.renderToSvg(frame.document, { time: frame.localTime, transparent }), "utf8");
478
+ } else if (format === "html") {
479
+ if (options.deck && options.scene) {
480
+ fs.writeFileSync(outputPath, core.renderDeckToHtml(doc, options.scene), "utf8");
481
+ } else {
482
+ const frame = frameSequence ? core.documentForSequenceTime(doc, frameSequence, time) : frameDocument(doc, options);
483
+ fs.writeFileSync(outputPath, core.renderToHtml(frame.document, { time: frame.localTime, transparent }), "utf8");
484
+ }
485
+ } else if (format === "png" || format === "jpg") {
486
+ const rasterOptions = frameSequence ? { ...options, sequence: frameSequence, scene: undefined } : options;
487
+ await renderRaster(doc, outputPath, format, rasterOptions);
488
+ } else if (format === "pdf") {
489
+ const pdfOptions = frameSequence ? { ...options, sequence: frameSequence, scene: undefined } : options;
490
+ await renderPdf(doc, outputPath, pdfOptions);
491
+ } else if (format === "pptx") {
492
+ await renderPptx(doc, outputPath, { ...options, deck: options.deck });
493
+ } else if (format === "mp4") {
494
+ if (transparent) throw new Error("Transparent MP4 is not supported. Use WebM or PNG/SVG.");
495
+ await renderMp4(doc, outputPath, options);
496
+ } else if (format === "webm") {
497
+ await renderWebm(doc, outputPath, options);
498
+ }
499
+
500
+ sendDownloadFile(response, outputPath, outputName, mimeType(outputPath), validation.warnings || [], () => removeTempDir(tempDir));
501
+ } catch (error) {
502
+ sendJson(response, 400, { ok: false, error: error?.message || String(error) });
503
+ }
504
+ }
505
+
506
+ function previewSequenceMeta(doc, sequenceId) {
507
+ const sequence = core.compileVisualSequence(doc, sequenceId);
508
+ return {
509
+ id: sequenceId,
510
+ clips: sequence.clips.map((clip) => {
511
+ const sceneDoc = core.documentForScene(doc, clip.scene);
512
+ return {
513
+ scene: clip.scene,
514
+ start: clip.start,
515
+ duration: clip.duration,
516
+ renderer: sceneDoc.canvas.renderer === "three" ? "three" : "svg"
517
+ };
518
+ })
519
+ };
520
+ }
521
+
522
+ function hasDeckSteps(doc, sceneId) {
523
+ return Boolean(doc.scenes?.[sceneId]?.steps?.length);
524
+ }
525
+
526
+ function previewDeckMeta(doc, sceneId, selectedStep) {
527
+ const steps = doc.scenes?.[sceneId]?.steps || [];
528
+ return {
529
+ scene: sceneId,
530
+ selectedStep,
531
+ labels: ["Base", ...steps.map((step) => step.id || "step")],
532
+ count: steps.length + 1
533
+ };
534
+ }
535
+
536
+ function loadDocumentFromSource(inputPath, source) {
537
+ const document = JSON.parse(source);
538
+ if (!document.imports || typeof document.imports !== "object") return document;
539
+ return mergeProjectFromSource(inputPath, document, new Set());
540
+ }
541
+
542
+ function mergeProjectFromSource(filePath, sourceDocument, seen) {
543
+ const absolute = path.resolve(filePath);
544
+ if (seen.has(absolute)) throw new Error(`Circular import detected at '${absolute}'.`);
545
+ seen.add(absolute);
546
+ const merged = {
547
+ ...sourceDocument,
548
+ elements: [...(sourceDocument.elements || [])],
549
+ scenes: { ...(sourceDocument.scenes || {}) },
550
+ sequences: { ...(sourceDocument.sequences || {}) },
551
+ assets: { ...(sourceDocument.assets || {}) }
552
+ };
553
+ for (const [key, importPath] of Object.entries(sourceDocument.imports || {})) {
554
+ const childPath = path.resolve(path.dirname(absolute), String(importPath));
555
+ const child = JSON.parse(fs.readFileSync(childPath, "utf8"));
556
+ const loaded = child.imports ? mergeProjectFromSource(childPath, child, seen) : child;
557
+ if (loaded.elements?.length) {
558
+ merged.scenes[key] = { id: key, canvas: loaded.canvas, elements: loaded.elements };
559
+ }
560
+ merged.scenes = { ...merged.scenes, ...(loaded.scenes || {}) };
561
+ merged.sequences = { ...merged.sequences, ...(loaded.sequences || {}) };
562
+ merged.assets = { ...merged.assets, ...(loaded.assets || {}) };
563
+ }
564
+ seen.delete(absolute);
565
+ return merged;
566
+ }
567
+
568
+ function inlineEditorHtml(initialOptions) {
569
+ return `<!doctype html>
570
+ <html>
571
+ <head>
572
+ <meta charset="utf-8">
573
+ <meta name="viewport" content="width=device-width,initial-scale=1">
574
+ <title>Sketchmark Inline Preview</title>
575
+ <style>
576
+ * { box-sizing: border-box; }
577
+ html, body { margin: 0; min-height: 100%; background: #0b1020; color: #e5e7eb; font-family: Inter, Arial, sans-serif; }
578
+ body { min-height: 100vh; display: grid; grid-template-columns: minmax(360px, 42vw) 1fr; }
579
+ header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; background: #111827; border-bottom: 1px solid #26324a; }
580
+ h1 { margin: 0; font-size: 14px; }
581
+ .file { color: #94a3b8; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
582
+ .editor-pane, .preview-pane { min-width: 0; min-height: 100vh; display: flex; flex-direction: column; }
583
+ .editor-pane { border-right: 1px solid #26324a; background: #0f172a; }
584
+ textarea { flex: 1; width: 100%; resize: none; border: 0; outline: 0; padding: 16px; background: #0f172a; color: #dbeafe; font: 13px/1.55 "Cascadia Code", Consolas, monospace; tab-size: 2; }
585
+ .stage-wrap { flex: 1; display: grid; place-items: center; padding: 24px; overflow: auto; background: linear-gradient(90deg,#ffffff08 1px,transparent 1px),linear-gradient(#ffffff08 1px,transparent 1px),#0b1020; background-size: 32px 32px; }
586
+ #stage { position: relative; width: min(100%, 1280px); max-width: 100%; max-height: calc(100vh - 132px); box-shadow: 0 18px 48px #00000066; background: transparent; }
587
+ #stage .svg-layer { width: 100%; height: 100%; }
588
+ #stage svg { display: block; max-width: 100%; max-height: calc(100vh - 132px); height: auto; }
589
+ #stage iframe { display: block; width: 100%; min-height: 480px; aspect-ratio: inherit; border: 0; background: #0f172a; }
590
+ .toolbar { display: flex; gap: 10px; align-items: center; padding: 12px 16px; background: #111827; border-top: 1px solid #26324a; }
591
+ button, select { border: 1px solid #334155; border-radius: 6px; padding: 7px 12px; background: #1f2937; color: #f8fafc; font: inherit; }
592
+ button { cursor: pointer; }
593
+ button:hover { background: #263244; }
594
+ button:disabled { opacity: .55; cursor: not-allowed; }
595
+ .deck-control { display: none; }
596
+ .deck-control.visible { display: inline-flex; }
597
+ select.deck-control.visible { display: inline-block; }
598
+ input[type=range] { flex: 1; }
599
+ .time { min-width: 88px; text-align: right; color: #cbd5e1; font-variant-numeric: tabular-nums; font-size: 12px; }
600
+ .status { min-height: 34px; padding: 8px 16px; border-top: 1px solid #26324a; background: #111827; color: #94a3b8; font-size: 12px; white-space: pre-wrap; }
601
+ .status.error { color: #fecaca; }
602
+ @media (max-width: 900px) { body { grid-template-columns: 1fr; } .editor-pane { min-height: 46vh; border-right: 0; border-bottom: 1px solid #26324a; } .preview-pane { min-height: 54vh; } }
603
+ </style>
604
+ </head>
605
+ <body>
606
+ <section class="editor-pane">
607
+ <header><h1>Sketchmark JSON</h1><div class="file" id="fileName"></div></header>
608
+ <textarea id="editor" spellcheck="false"></textarea>
609
+ <div class="toolbar"><button id="render">Render</button><button id="save">Save</button><button id="export">Export</button></div>
610
+ </section>
611
+ <section class="preview-pane">
612
+ <header><h1>Preview</h1><div class="file">inline editor</div></header>
613
+ <div class="stage-wrap"><div id="stage"></div></div>
614
+ <div class="toolbar">
615
+ <button id="play">Play</button>
616
+ <input id="scrub" type="range" min="0" max="0" step="0.01" value="0">
617
+ <div class="time" id="clock">0.00s</div>
618
+ <select id="scene"></select>
619
+ <select id="sequence"></select>
620
+ <button id="prevStep" class="deck-control">Prev Step</button>
621
+ <select id="deckStep" class="deck-control"></select>
622
+ <button id="nextStep" class="deck-control">Next Step</button>
623
+ </div>
624
+ <div id="status" class="status">Loading...</div>
625
+ </section>
626
+ <script>
627
+ const initialScene = ${JSON.stringify(initialOptions.scene || "")};
628
+ const initialSequence = ${JSON.stringify(initialOptions.sequence || "")};
629
+ const initialDeck = ${JSON.stringify(Boolean(initialOptions.deck))};
630
+ const editor = document.getElementById("editor");
631
+ const fileName = document.getElementById("fileName");
632
+ const stage = document.getElementById("stage");
633
+ const status = document.getElementById("status");
634
+ const renderButton = document.getElementById("render");
635
+ const saveButton = document.getElementById("save");
636
+ const exportButton = document.getElementById("export");
637
+ const scrub = document.getElementById("scrub");
638
+ const clock = document.getElementById("clock");
639
+ const play = document.getElementById("play");
640
+ const sceneSelect = document.getElementById("scene");
641
+ const sequenceSelect = document.getElementById("sequence");
642
+ const prevStep = document.getElementById("prevStep");
643
+ const nextStep = document.getElementById("nextStep");
644
+ const deckStepSelect = document.getElementById("deckStep");
645
+ let duration = 0;
646
+ let playing = false;
647
+ let playFrame = 0;
648
+ let start = 0;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2008
+ }