@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.
- package/apps/studio/bin/_html-playwright.mjs +198 -19
- package/apps/studio/bin/_pdf-playwright.mjs +1 -0
- package/apps/studio/bin/_png-playwright.mjs +28 -8
- package/apps/studio/bin/_pptx-playwright.mjs +4 -1
- package/apps/studio/bin/_pw-launch.mjs +48 -0
- package/apps/studio/bin/_svg-playwright.mjs +63 -149
- package/apps/studio/bin/_video-playwright.mjs +123 -11
- package/apps/studio/canvas-edit.ts +33 -0
- package/apps/studio/canvas-lib.tsx +136 -0
- package/apps/studio/client/app.jsx +316 -28
- package/apps/studio/client/export-lane.js +191 -0
- package/apps/studio/dist/client.bundle.js +907 -907
- package/apps/studio/dist/runtime/.min-sizes.json +2 -1
- package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
- package/apps/studio/export-dialog.tsx +24 -19
- package/apps/studio/exporters/_browser-bundles.ts +30 -0
- package/apps/studio/exporters/_runtime.ts +31 -1
- package/apps/studio/exporters/capture-chrome.ts +111 -0
- package/apps/studio/exporters/capture-core.ts +947 -0
- package/apps/studio/exporters/format-scopes.ts +88 -0
- package/apps/studio/exporters/html.ts +3 -2
- package/apps/studio/exporters/jobs.ts +180 -18
- package/apps/studio/exporters/pdf.ts +21 -1
- package/apps/studio/exporters/pptx.ts +46 -11
- package/apps/studio/exporters/remote.ts +94 -11
- package/apps/studio/exporters/scope.ts +14 -3
- package/apps/studio/exporters/svg.ts +17 -3
- package/apps/studio/exporters/video.ts +75 -8
- package/apps/studio/http.ts +175 -7
- package/apps/studio/runtime-bundle.ts +6 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
- package/apps/studio/test/capture-core.test.ts +32 -0
- package/apps/studio/test/export-assemble.test.ts +53 -0
- package/apps/studio/test/export-browser-lane.test.ts +126 -0
- package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
- package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
- package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
- package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
- package/apps/studio/test/exporters/jobs.test.ts +73 -0
- package/apps/studio/test/exporters/scope.test.ts +33 -0
- package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
- package/apps/studio/test/workspace-containment.test.ts +8 -0
- package/apps/studio/whats-new.json +18 -0
- package/apps/studio/workspace-mode.ts +15 -2
- package/package.json +8 -8
- 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>
|
|
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({
|
|
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
|
-
//
|
|
75
|
-
// `window.
|
|
76
|
-
|
|
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(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
523
|
+
await addScriptCspSafe(page, { content: libSrc, type: 'module', name: 'encode-lib' });
|
|
425
524
|
await page.waitForFunction(() => typeof window.__maudeEnc === 'object', { timeout: timeoutMs });
|
|
426
|
-
|
|
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
|
|
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
|
|
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
|