@1agh/maude 1.0.6 → 1.0.8

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 (48) hide show
  1. package/apps/studio/bin/_html-playwright.mjs +198 -19
  2. package/apps/studio/bin/_pdf-playwright.mjs +1 -0
  3. package/apps/studio/bin/_png-playwright.mjs +28 -8
  4. package/apps/studio/bin/_pptx-playwright.mjs +4 -1
  5. package/apps/studio/bin/_pw-launch.mjs +48 -0
  6. package/apps/studio/bin/_svg-playwright.mjs +63 -149
  7. package/apps/studio/bin/_video-playwright.mjs +123 -11
  8. package/apps/studio/canvas-edit.ts +33 -0
  9. package/apps/studio/canvas-lib.tsx +136 -0
  10. package/apps/studio/client/app.jsx +316 -28
  11. package/apps/studio/client/export-lane.js +191 -0
  12. package/apps/studio/dist/client.bundle.js +907 -907
  13. package/apps/studio/dist/runtime/.min-sizes.json +2 -1
  14. package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
  15. package/apps/studio/export-dialog.tsx +24 -19
  16. package/apps/studio/exporters/_browser-bundles.ts +30 -0
  17. package/apps/studio/exporters/_runtime.ts +31 -1
  18. package/apps/studio/exporters/capture-chrome.ts +111 -0
  19. package/apps/studio/exporters/capture-core.ts +947 -0
  20. package/apps/studio/exporters/format-scopes.ts +88 -0
  21. package/apps/studio/exporters/html.ts +3 -2
  22. package/apps/studio/exporters/jobs.ts +180 -18
  23. package/apps/studio/exporters/pdf.ts +21 -1
  24. package/apps/studio/exporters/pptx.ts +46 -11
  25. package/apps/studio/exporters/remote.ts +94 -11
  26. package/apps/studio/exporters/scope.ts +14 -3
  27. package/apps/studio/exporters/svg.ts +17 -3
  28. package/apps/studio/exporters/video.ts +75 -8
  29. package/apps/studio/http.ts +175 -7
  30. package/apps/studio/runtime-bundle.ts +6 -0
  31. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  32. package/apps/studio/test/capture-core.test.ts +32 -0
  33. package/apps/studio/test/export-assemble.test.ts +53 -0
  34. package/apps/studio/test/export-browser-lane.test.ts +126 -0
  35. package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
  36. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  37. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  38. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  39. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  40. package/apps/studio/test/exporters/scope.test.ts +33 -0
  41. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  42. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  43. package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
  44. package/apps/studio/test/workspace-containment.test.ts +8 -0
  45. package/apps/studio/whats-new.json +18 -0
  46. package/apps/studio/workspace-mode.ts +15 -2
  47. package/package.json +8 -8
  48. package/plugins/design/templates/_shell.html +2 -1
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { mkdirSync, writeFileSync } from 'node:fs';
20
20
  import { dirname, join } from 'node:path';
21
- import { launchChromium } from './_pw-launch.mjs';
21
+ import { launchChromium, safeArtboardFilename } from './_pw-launch.mjs';
22
22
 
23
23
  const args = Object.fromEntries(
24
24
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -33,14 +33,16 @@ const {
33
33
  out,
34
34
  'out-dir': outDir,
35
35
  'bundle-path': bundlePath,
36
+ 'core-path': corePath,
36
37
  'widen-to-artboard': widenFlag,
37
38
  multi: multiFlag,
38
39
  timeout = '12',
39
40
  } = args;
40
41
 
41
- if (!url || !bundlePath) {
42
+ if (!url || !bundlePath || !corePath) {
42
43
  console.error(
43
- 'usage: _svg-playwright.mjs --url <url> --selector <css> --out <path> --bundle-path <iife.js>'
44
+ 'usage: _svg-playwright.mjs --url <url> --selector <css> --out <path> ' +
45
+ '--bundle-path <iife.js> --core-path <capture-core.js>'
44
46
  );
45
47
  process.exit(2);
46
48
  }
@@ -51,7 +53,10 @@ const timeoutMs = Number(timeout) * 1000;
51
53
 
52
54
  const browser = await launchChromium();
53
55
  try {
54
- const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
56
+ const ctx = await browser.newContext({
57
+ viewport: { width: 1440, height: 900 },
58
+ serviceWorkers: 'block',
59
+ });
55
60
  const page = await ctx.newPage();
56
61
  await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
57
62
  await page.evaluate(() => document.fonts.ready);
@@ -71,9 +76,41 @@ try {
71
76
  world.style.transform = 'none';
72
77
  }
73
78
  });
74
- // Inject dom-to-svg into the page. Bundle attaches its exports under
75
- // `window.domToSvg`.
76
- await page.addScriptTag({ path: bundlePath });
79
+ // Get dom-to-svg (`window.domToSvg`) and the SHARED capture spine
80
+ // (`window.__maudeCaptureCore`) into the page. The core is the SAME source
81
+ // the canvas runtime's export-capture bridge imports (exporters/
82
+ // capture-core.ts, DDR-231 single-spine): the serializer logic must never be
83
+ // re-inlined here — it forked once and the browser lane would silently drift
84
+ // from this shim.
85
+ //
86
+ // PREFER WHAT THE PAGE ALREADY HAS (DDR-231 Phase 2 T4). A real canvas
87
+ // bundles capture-core through canvas-lib, so `__maudeCaptureCore` is
88
+ // already set, and `dom-to-svg` resolves through the shell's importmap.
89
+ // `addScriptTag({path})` injects an INLINE script, which the canvas origin's
90
+ // strict shell CSP (`script-src 'self' 'sha256-…'`) refuses outright — so on
91
+ // the render-worker lane, where the canvas is ALWAYS loaded from the canvas
92
+ // origin, every SVG export died with "Executing inline script violates the
93
+ // following Content Security Policy directive". Reaching for the in-page
94
+ // copy first fixes that lane without relaxing anyone's CSP, and makes the
95
+ // worker run literally the same code the member's browser runs.
96
+ const reuse = await page.evaluate(async () => {
97
+ const w = window;
98
+ if (!w.domToSvg) {
99
+ try {
100
+ w.domToSvg = await import('dom-to-svg');
101
+ } catch {
102
+ /* no importmap on this page — the injection fallback below covers it */
103
+ }
104
+ }
105
+ return !!w.__maudeCaptureCore && !!w.domToSvg;
106
+ });
107
+ if (!reuse) {
108
+ await page.addScriptTag({ path: bundlePath });
109
+ await page.addScriptTag({ path: corePath, type: 'module' });
110
+ }
111
+ await page.waitForFunction(() => !!window.__maudeCaptureCore && !!window.domToSvg, {
112
+ timeout: timeoutMs,
113
+ });
77
114
 
78
115
  const written = [];
79
116
 
@@ -82,155 +119,28 @@ try {
82
119
  // multi-target loop must not leave an already-captured artboard sitting at
83
120
  // the origin, overlapping the next target's geometry (the scatter bug).
84
121
  const pinArtboard = async (handle) => {
85
- const saved = await handle.evaluate((el) => {
86
- const ab = el.closest('[data-dc-screen]') ?? el;
87
- const prev = { left: ab.style.left, top: ab.style.top };
88
- ab.style.left = '0px';
89
- ab.style.top = '0px';
90
- return prev;
91
- });
122
+ const saved = await handle.evaluate((el) => window.__maudeCaptureCore.pinArtboard(el));
92
123
  return async () => {
93
- await handle.evaluate((el, prev) => {
94
- const ab = el.closest('[data-dc-screen]') ?? el;
95
- ab.style.left = prev.left;
96
- ab.style.top = prev.top;
97
- }, saved);
124
+ await handle.evaluate(
125
+ (el, prev) => window.__maudeCaptureCore.restoreArtboard(el, prev),
126
+ saved
127
+ );
98
128
  };
99
129
  };
100
130
 
101
131
  // Single in-page serializer used by BOTH branches (single + multi) so they
102
132
  // can't drift again (the `formatXML` 500 bug came from a divergent copy).
103
- // Note: dom-to-svg exports only elementToSVG / inlineResources /
104
- // documentToSVG — there is NO `formatXML` pretty-printer; serialize straight
105
- // to a string. dom-to-svg also does NOT paint the captured root's background
106
- // fill, so the artboard's background was dropped (item 4) — we prepend a
107
- // backdrop <rect> from the artboard's effective (visible) background color.
133
+ // The ENTIRE capture logic (backdrop rect from the effective background,
134
+ // oklch→sRGB normalization for vector editors, inlineResources) lives in the
135
+ // shared capture spine (exporters/capture-core.ts) injected above — the same
136
+ // code the canvas runtime's export-capture bridge runs in the member's
137
+ // browser (DDR-231). Nothing capture-shaped may be inlined here again.
108
138
  const serializeOne = async (handle, widenToArtboard) => {
109
139
  return await handle.evaluate(
110
- async (el, opts) => {
111
- const target = opts.widenToArtboard ? (el.closest('[data-dc-screen]') ?? el) : el;
112
- // window.domToSvg is the IIFE-injected entry.
113
- const { elementToSVG, inlineResources } = /** @type any */ (window).domToSvg;
114
-
115
- // Convert ANY computed color (modern Chromium serializes `oklch(...)`
116
- // verbatim from getComputedStyle) to sRGB rgb()/rgba(). Affinity
117
- // Designer / older Illustrator / Inkscape don't parse CSS Color 4
118
- // (`oklch`, `color()`) and silently drop the fill — the artboard then
119
- // shows as transparent (the exact symptom: bg vanished in Affinity).
120
- // A 1×1 canvas normalizes any CSS color string to sRGB bytes. Returns
121
- // null if the value can't be painted (keep the rect off rather than
122
- // emit a wrong colour).
123
- const _srgbCache = new Map();
124
- const _srgbCanvas = document.createElement('canvas');
125
- _srgbCanvas.width = 1;
126
- _srgbCanvas.height = 1;
127
- const _srgbCtx = _srgbCanvas.getContext('2d');
128
- const toSrgb = (color) => {
129
- if (_srgbCache.has(color)) return _srgbCache.get(color);
130
- let result = null;
131
- try {
132
- if (_srgbCtx) {
133
- _srgbCtx.clearRect(0, 0, 1, 1);
134
- _srgbCtx.fillStyle = '#000';
135
- _srgbCtx.fillStyle = color; // ignored (stays #000) if syntax unsupported
136
- _srgbCtx.fillRect(0, 0, 1, 1);
137
- const [r, g, b, a] = _srgbCtx.getImageData(0, 0, 1, 1).data;
138
- if (a !== 0) {
139
- result =
140
- a === 255
141
- ? `rgb(${r}, ${g}, ${b})`
142
- : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
143
- }
144
- }
145
- } catch {
146
- result = null;
147
- }
148
- _srgbCache.set(color, result);
149
- return result;
150
- };
151
-
152
- const isOpaque = (bg) =>
153
- !!bg &&
154
- bg !== 'transparent' &&
155
- !/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(bg) &&
156
- !/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0(\.0+)?\s*\)$/.test(bg);
157
- // Effective background of the artboard region: the DS theme bg usually
158
- // sits on an inner full-bleed wrapper (`.app` / `.mdcc`), occasionally
159
- // on the artboard element itself, sometimes on body. Descend first to
160
- // find a near-full-bleed opaque bg, then fall back to ancestors. An
161
- // intentionally transparent artboard returns null → stays transparent.
162
- const effectiveBg = (node) => {
163
- const abRect = node.getBoundingClientRect();
164
- const abArea = Math.max(1, abRect.width * abRect.height);
165
- const queue = [node];
166
- let guard = 0;
167
- while (queue.length && guard < 80) {
168
- const e = queue.shift();
169
- guard += 1;
170
- const bg = getComputedStyle(e).backgroundColor;
171
- if (isOpaque(bg)) {
172
- const r = e.getBoundingClientRect();
173
- if (r.width * r.height >= abArea * 0.6) return bg;
174
- }
175
- for (const c of e.children) queue.push(c);
176
- }
177
- let cur = node.parentElement;
178
- let up = 0;
179
- while (cur && up < 12) {
180
- const bg = getComputedStyle(cur).backgroundColor;
181
- if (isOpaque(bg)) return bg;
182
- cur = cur.parentElement;
183
- up += 1;
184
- }
185
- return null;
186
- };
187
-
188
- const bg = effectiveBg(target);
189
- // Normalize to sRGB so the fill survives in vector editors (item 4 /
190
- // oklch follow-up). If conversion fails, skip the backdrop rather than
191
- // emit an unparseable fill.
192
- const fillColor = bg ? toSrgb(bg) : null;
193
- const svgDoc = elementToSVG(target);
194
- const root = svgDoc.documentElement;
195
- if (fillColor) {
196
- const NS = 'http://www.w3.org/2000/svg';
197
- const rect = svgDoc.createElementNS(NS, 'rect');
198
- const vb = (root.getAttribute('viewBox') || '').split(/\s+/).map(Number);
199
- if (vb.length === 4 && vb.every((n) => !Number.isNaN(n))) {
200
- rect.setAttribute('x', String(vb[0]));
201
- rect.setAttribute('y', String(vb[1]));
202
- rect.setAttribute('width', String(vb[2]));
203
- rect.setAttribute('height', String(vb[3]));
204
- } else {
205
- rect.setAttribute('x', '0');
206
- rect.setAttribute('y', '0');
207
- rect.setAttribute('width', '100%');
208
- rect.setAttribute('height', '100%');
209
- }
210
- rect.setAttribute('fill', fillColor);
211
- root.insertBefore(rect, root.firstChild);
212
- }
213
- // base64-embeds fonts + images so the SVG is portable outside the
214
- // dev-server origin. Some external fetches fail silently — Affinity
215
- // tolerates missing resources better than missing primitives.
216
- try {
217
- await inlineResources(root);
218
- } catch {
219
- /* best-effort */
220
- }
221
- let out = new XMLSerializer().serializeToString(svgDoc);
222
- // dom-to-svg copies computed colours through VERBATIM, and modern
223
- // Chromium serializes DS tokens as `oklch(...)` — Affinity Designer /
224
- // older Illustrator / Inkscape can't parse CSS Color 4 and drop every
225
- // such fill/stroke (the artboard bg + many element colours vanish).
226
- // Rewrite every CSS Color 4 colour function (oklch/oklab/lch/lab/color)
227
- // anywhere in the serialized SVG to its sRGB rgb()/rgba() equivalent.
228
- // Each token is a standalone colour (no nested parens), so a single
229
- // `\([^)]*\)` match is safe; toSrgb returns null on an unconvertible
230
- // value and we keep the original rather than corrupt it.
231
- out = out.replace(/(?:oklch|oklab|lch|lab|color)\([^)]*\)/gi, (m) => toSrgb(m) || m);
232
- return out;
233
- },
140
+ (el, opts) =>
141
+ window.__maudeCaptureCore.svgForElement(el, window.domToSvg, {
142
+ widenToArtboard: opts.widenToArtboard,
143
+ }),
234
144
  { widenToArtboard }
235
145
  );
236
146
  };
@@ -249,7 +159,11 @@ try {
249
159
  // Each multi handle is already a `[data-dc-screen]` artboard — no widen.
250
160
  const svg = await serializeOne(handle, false);
251
161
  await restore();
252
- const target = join(outDir, `${id}.svg`);
162
+ // `id` is the tenant-authored `data-dc-screen` (untrusted, DDR-054) — a
163
+ // `../`-laden id in `join(outDir, `${id}.svg`)` is an arbitrary-path
164
+ // write of attacker bytes (security review F1). The png shim already
165
+ // routes through this; the svg/html siblings did not.
166
+ const target = join(outDir, safeArtboardFilename(id, i, 'svg'));
253
167
  writeFileSync(target, svg, 'utf8');
254
168
  written.push(target);
255
169
  console.log(`MAUDE_PROGRESS {"current":${i + 1},"total":${screens.length}}`);
@@ -25,7 +25,7 @@
25
25
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
 
28
- import { assertRenderOutputSizeOk, launchChromium } from './_pw-launch.mjs';
28
+ import { addScriptCspSafe, assertRenderOutputSizeOk, launchChromium } from './_pw-launch.mjs';
29
29
 
30
30
  const args = Object.fromEntries(
31
31
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -63,6 +63,24 @@ if (!url) {
63
63
 
64
64
  const timeoutMs = Number(timeout) * 1000;
65
65
  const deviceScaleFactor = Math.max(1, Math.min(4, Number(scale) || 1));
66
+
67
+ // Hard self-kill — the whole shim, not just individual Playwright calls, has a
68
+ // wall-clock ceiling. `launchChromium` has NO timeout, and on headless Linux
69
+ // `--enable-gpu` can hang Chromium at GPU-process init before a single log line
70
+ // — a silent wedge that ate the full 600s render budget with nothing to show.
71
+ // This guarantees the process EXITS with a diagnostic instead of hanging, so
72
+ // the render worker (and the export gate) fail fast and legibly. Generous
73
+ // enough for a slow software encode; well under any job/test budget.
74
+ const HARD_KILL_MS = Math.max(timeoutMs, 150_000);
75
+ const hardKill = setTimeout(() => {
76
+ console.error(
77
+ `_video-playwright: HARD TIMEOUT after ${HARD_KILL_MS}ms — exiting. The render did not ` +
78
+ `complete (see the last stage logged above; on headless Linux this is usually the video ` +
79
+ `encoder or GPU init). gpu=${process.env.MAUDE_CAPTURE_GPU === '1' ? 'on' : 'off'}.`
80
+ );
81
+ process.exit(7);
82
+ }, HARD_KILL_MS);
83
+ hardKill.unref?.();
66
84
  // `--audio` is presence-checked (Object.fromEntries maps a bare flag to '1'), so
67
85
  // absence of the flag entirely (not passed) still defaults to audio ON — video.ts
68
86
  // always passes an explicit '0'/'1'.
@@ -74,6 +92,35 @@ const frameFormat = frameFormatArg === 'jpeg' ? 'jpeg' : 'png';
74
92
  /** Wait a real turn of the event loop + a frame so a seek settles before shot. */
75
93
  const SETTLE_MS = 16;
76
94
 
95
+ /**
96
+ * Run a `page.evaluate` with a hard wall-clock cap. A `page.evaluate` awaits its
97
+ * in-page promise with NO Playwright timeout, so a WebCodecs encoder that never
98
+ * resolves (headless Linux without a working encoder) hangs the whole render
99
+ * indefinitely. This races the evaluate against a timer and throws a NAMED
100
+ * error on overrun, so the shim exits (fail-fast + diagnostic) instead of
101
+ * wedging the render worker until the job timeout.
102
+ */
103
+ async function evalWithTimeout(page, fn, arg, ms, stage) {
104
+ let timer;
105
+ const timeout = new Promise((_, reject) => {
106
+ timer = setTimeout(
107
+ () =>
108
+ reject(
109
+ new Error(
110
+ `encode stage "${stage}" did not resolve within ${ms}ms — headless WebCodecs ` +
111
+ `likely has no working video encoder (see the "webcodecs caps" line above).`
112
+ )
113
+ ),
114
+ ms
115
+ );
116
+ });
117
+ try {
118
+ return await Promise.race([page.evaluate(fn, arg), timeout]);
119
+ } finally {
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+
77
124
  // GPU opt-in, measured not assumed (2026-08-09, M-series mac, 1920x1080):
78
125
  //
79
126
  // config h264 prefer-hardware png jpeg q90
@@ -93,15 +140,58 @@ const SETTLE_MS = 16;
93
140
  // something to smuggle in behind a perf flag. Opt in with MAUDE_CAPTURE_GPU=1.
94
141
  const gpuArgs =
95
142
  process.env.MAUDE_CAPTURE_GPU === '1' ? ['--enable-gpu', '--ignore-gpu-blocklist'] : [];
143
+ console.error(
144
+ `_video-playwright: launching chromium (gpu=${gpuArgs.length ? 'on' : 'off'}, format=${format})`
145
+ );
96
146
  const browser = await launchChromium(gpuArgs.length ? { args: gpuArgs } : undefined);
147
+ console.error('_video-playwright: browser launched');
97
148
  try {
98
149
  const ctx = await browser.newContext({
150
+ serviceWorkers: 'block',
99
151
  viewport: { width: 1440, height: 900 },
100
152
  deviceScaleFactor,
101
153
  });
102
154
  const page = await ctx.newPage();
103
155
  await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
104
156
  await page.evaluate(() => document.fonts.ready);
157
+ console.error('_video-playwright: page loaded');
158
+
159
+ // UNCONDITIONAL WebCodecs capability probe — logged for every video render
160
+ // regardless of which encode path runs, so the render worker's log always
161
+ // names what this headless box can actually encode. This is THE fact that
162
+ // differs between a mac dev machine and headless Linux/the cloud worker, and
163
+ // the answer decides whether video needs a different encoder entirely. Bounded
164
+ // so a hung isConfigSupported (seen on some headless builds) can't wedge here.
165
+ try {
166
+ const caps = await evalWithTimeout(
167
+ page,
168
+ async () => {
169
+ const probe = async (codec) => {
170
+ try {
171
+ if (typeof VideoEncoder?.isConfigSupported !== 'function') return 'no-webcodecs';
172
+ const r = await VideoEncoder.isConfigSupported({ codec, width: 320, height: 180 });
173
+ return r?.supported ? 'yes' : 'no';
174
+ } catch (e) {
175
+ return `err:${e?.name || 'x'}`;
176
+ }
177
+ };
178
+ return {
179
+ avc: await probe('avc1.42001f'),
180
+ vp9: await probe('vp09.00.10.08'),
181
+ vp8: await probe('vp8'),
182
+ av1: await probe('av01.0.04M.08'),
183
+ };
184
+ },
185
+ undefined,
186
+ 15_000,
187
+ 'isConfigSupported probe'
188
+ );
189
+ console.error(
190
+ `_video-playwright: webcodecs caps avc=${caps.avc} vp9=${caps.vp9} vp8=${caps.vp8} av1=${caps.av1}`
191
+ );
192
+ } catch (e) {
193
+ console.error(`_video-playwright: cap probe failed: ${e instanceof Error ? e.message : e}`);
194
+ }
105
195
 
106
196
  // Resolve the target artboard handle. Priority: explicit --selector (from the
107
197
  // export scope Target) → --artboard id → first artboard. `--widen` climbs to
@@ -166,7 +256,7 @@ try {
166
256
  try {
167
257
  const t0 = Date.now();
168
258
  const libSrc = readFileSync(renderLib, 'utf8');
169
- await page.addScriptTag({ content: libSrc, type: 'module' });
259
+ await addScriptCspSafe(page, { content: libSrc, type: 'module', name: 'render-lib' });
170
260
  await page.waitForFunction(() => typeof window.__maudeRenderVideo__ === 'function', {
171
261
  timeout: timeoutMs,
172
262
  });
@@ -175,7 +265,14 @@ try {
175
265
  const frameRange =
176
266
  Number.isFinite(explicitFrames) && explicitFrames > 0 ? [0, explicitFrames - 1] : null;
177
267
 
178
- const rendered = await page.evaluate(
268
+ // Bound the whole-comp render: @remotion/web-renderer drives WebCodecs,
269
+ // which on headless Linux without a working encoder never resolves — a
270
+ // hang, not a throw, so the graceful frame-step fallback below never
271
+ // fires. A wall-clock cap turns that hang INTO a throw, which the catch
272
+ // converts into exactly that fallback (frame-step, muted) — the video
273
+ // still exports instead of wedging the render worker.
274
+ const rendered = await evalWithTimeout(
275
+ page,
179
276
  async ({ compId, container, scale, muted, frameRange, licenseKey }) => {
180
277
  return window.__maude_render_video__(compId, {
181
278
  container,
@@ -192,7 +289,9 @@ try {
192
289
  muted: !wantAudio,
193
290
  frameRange,
194
291
  licenseKey,
195
- }
292
+ },
293
+ Math.min(timeoutMs, 90_000),
294
+ `renderMediaOnWeb(${format})`
196
295
  );
197
296
 
198
297
  writeFileSync(out, Buffer.from(rendered.b64, 'base64'));
@@ -421,9 +520,14 @@ async function frameStepCapture({
421
520
  const isGif = format === 'gif';
422
521
  if (encoding) {
423
522
  const libSrc = readFileSync(encodeLib, 'utf8');
424
- await page.addScriptTag({ content: libSrc, type: 'module' });
523
+ await addScriptCspSafe(page, { content: libSrc, type: 'module', name: 'encode-lib' });
425
524
  await page.waitForFunction(() => typeof window.__maudeEnc === 'object', { timeout: timeoutMs });
426
- const started = await page.evaluate(
525
+ // Bound the encoder start: on headless Linux without a working WebCodecs
526
+ // encoder this promise never resolves, so a wall-clock cap converts the hang
527
+ // into a fast, diagnostic failure instead of consuming the whole render
528
+ // budget in silence.
529
+ const started = await evalWithTimeout(
530
+ page,
427
531
  async ({ width, height, fps, isGif, gifColors, fmt }) => {
428
532
  if (isGif) {
429
533
  window.__maudeEnc.startGif({ width, height, fps, maxColors: gifColors });
@@ -438,7 +542,9 @@ async function frameStepCapture({
438
542
  isGif,
439
543
  gifColors: Number(args.gifColors) || 256,
440
544
  fmt: format,
441
- }
545
+ },
546
+ Math.min(timeoutMs, 45_000),
547
+ `startVideo(${format})`
442
548
  );
443
549
  console.error(`encoder: ${started.container} / ${started.codec} @ ${outW}×${outH}`);
444
550
  }
@@ -470,12 +576,15 @@ async function frameStepCapture({
470
576
  if (encoding) {
471
577
  const tEnc = Date.now();
472
578
  const b64 = shot.toString('base64');
473
- await page.evaluate(
579
+ await evalWithTimeout(
580
+ page,
474
581
  async ({ b64, isGif, format }) => {
475
582
  if (isGif) return window.__maudeEnc.addGifFrame(b64);
476
583
  return window.__maudeEnc.addVideoFrame(b64, format);
477
584
  },
478
- { b64, isGif, format: shotFormat }
585
+ { b64, isGif, format: shotFormat },
586
+ 30_000,
587
+ `addFrame#${f}`
479
588
  );
480
589
  // NB: this stage is the double CDP crossing — the screenshot bytes go out
481
590
  // to node as base64 and straight back into the same page, to an encoder
@@ -512,10 +621,13 @@ async function frameStepCapture({
512
621
  result = { ...result, degraded: true, audioDropped: true, fallbackReason };
513
622
  }
514
623
  if (encoding) {
515
- const enc = await page.evaluate(
624
+ const enc = await evalWithTimeout(
625
+ page,
516
626
  async ({ isGif }) =>
517
627
  isGif ? window.__maudeEnc.finishGif() : window.__maudeEnc.finishVideo(),
518
- { isGif }
628
+ { isGif },
629
+ Math.min(timeoutMs, 45_000),
630
+ `finish(${format})`
519
631
  );
520
632
  writeFileSync(out, Buffer.from(enc.b64, 'base64'));
521
633
  result = { ...result, out, bytes: enc.bytes, container: enc.container, codec: enc.codec };
@@ -4180,6 +4180,39 @@ export function readArtboardPrintProp(
4180
4180
  return objectExpressionToPlain(resolved);
4181
4181
  }
4182
4182
 
4183
+ /**
4184
+ * Every print artboard's `print` prop in a canvas, keyed by artboard id.
4185
+ *
4186
+ * The per-id reader above is what the PDF adapter uses when it has the checkout
4187
+ * on disk. A render worker (DDR-230) does NOT — it holds no tenant files by
4188
+ * design — so the cell resolves these up front and ships them inside the job
4189
+ * (`options.printProps`, exporters/jobs.ts). Until it did, a cloud print PDF
4190
+ * silently came back with no BleedBox/TrimBox and no marks: the adapter's
4191
+ * `readFileSync` failed against the worker's empty scratch root and the
4192
+ * fall-through was "not a print artboard" (DDR-232 follow-up, found by the
4193
+ * worker-lane export E2E).
4194
+ */
4195
+ export function readAllArtboardPrintProps(
4196
+ canvasAbsPath: string,
4197
+ source: string
4198
+ ): Record<string, Record<string, unknown>> {
4199
+ const out: Record<string, Record<string, unknown>> = {};
4200
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
4201
+ if (parsed.errors && parsed.errors.length > 0) return out;
4202
+ for (const ab of collectJsxByTag(parsed.program, 'DCArtboard')) {
4203
+ const id = getStringAttr(ab.openingElement, 'id');
4204
+ if (!id || getStringAttr(ab.openingElement, 'kind') !== 'print') continue;
4205
+ const attr = findAttribute(ab.openingElement, 'print');
4206
+ if (attr?.value?.type !== 'JSXExpressionContainer') continue;
4207
+ const expr = attr.value.expression as AnyNode | undefined;
4208
+ if (!expr) continue;
4209
+ const resolved = resolveValueNode(parsed.program, expr);
4210
+ if (resolved?.type !== 'ObjectExpression') continue;
4211
+ out[id] = objectExpressionToPlain(resolved);
4212
+ }
4213
+ return out;
4214
+ }
4215
+
4183
4216
  /**
4184
4217
  * Delete the `<DCArtboard id="…">` whose `id` prop equals `artboardId` — the
4185
4218
  * artboard counterpart of applyDeleteElement (an artboard is addressed by its id