@1agh/maude 1.0.5 → 1.0.7

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.
@@ -0,0 +1,291 @@
1
+ // capture-core.ts — the SHARED in-page capture spine (DDR-231 single-spine).
2
+ //
3
+ // Pure browser code, two hosts, ONE source:
4
+ // 1. The playwright SVG shim (`bin/_svg-playwright.mjs`) injects this file
5
+ // as a module bundle (`getCaptureCoreBundle()`) and calls it inside
6
+ // `page.evaluate` — the desktop / render-worker lane.
7
+ // 2. The canvas runtime (`canvas-lib.tsx` export-capture listener) imports
8
+ // it directly — the workspace `browser` lane, where the member's own
9
+ // browser does the capture (DDR-231: images render client-side).
10
+ //
11
+ // History: this logic lived inline in `_svg-playwright.mjs`'s serializeOne
12
+ // (backdrop rect from the artboard's effective background, oklch→sRGB
13
+ // normalization for vector editors, dom-to-svg inlineResources). Extracting it
14
+ // is DDR-231's BUILDER condition — if the two hosts fork, the browser lane
15
+ // rots into a second implementation. Do not re-inline.
16
+ //
17
+ // NO Node imports allowed here — this file must bundle for `target: 'browser'`
18
+ // with zero shims. Loaded as a side-effect module: assigns
19
+ // `window.__maudeCaptureCore` so the shim can reach it without an importmap.
20
+
21
+ /** The subset of dom-to-svg the capture uses (IIFE `window.domToSvg` in the
22
+ * shim host; `await import('dom-to-svg')` through the importmap in the canvas
23
+ * runtime host). */
24
+ export interface DomToSvgApi {
25
+ elementToSVG(el: Element): XMLDocument;
26
+ inlineResources(el: Element): Promise<void>;
27
+ }
28
+
29
+ // Raster ceilings — mirrored from bin/_png-playwright.mjs (feature-2-print-
30
+ // artboards T4): 16k px matches common canvas/texture limits across engines;
31
+ // ~600MB is the RGBA-buffer ceiling that still admits a 600dpi A0 poster.
32
+ export const MAX_OUTPUT_SIDE_PX = 16000;
33
+ export const MAX_OUTPUT_BYTES = 600 * 1024 * 1024;
34
+
35
+ /** Throws with an actionable message (max supported DPI for this artboard)
36
+ * when the requested raster exceeds the render guard. */
37
+ export function assertRasterSizeOk(widthCss: number, heightCss: number, scale: number): void {
38
+ const outW = Math.ceil(widthCss * scale);
39
+ const outH = Math.ceil(heightCss * scale);
40
+ const estBytes = outW * outH * 4;
41
+ if (outW > MAX_OUTPUT_SIDE_PX || outH > MAX_OUTPUT_SIDE_PX || estBytes > MAX_OUTPUT_BYTES) {
42
+ const maxScaleForSide = MAX_OUTPUT_SIDE_PX / Math.max(widthCss, heightCss);
43
+ const maxScaleForBytes = Math.sqrt(MAX_OUTPUT_BYTES / 4 / (widthCss * heightCss));
44
+ const maxScale = Math.min(maxScaleForSide, maxScaleForBytes);
45
+ const maxDpi = Math.floor(maxScale * 96);
46
+ throw new Error(
47
+ `requested output ${outW}×${outH}px at ${scale}× exceeds the render guard ` +
48
+ `(max side ${MAX_OUTPUT_SIDE_PX}px / ~${Math.round(MAX_OUTPUT_BYTES / 1024 / 1024)}MB). ` +
49
+ `For this artboard (${Math.round(widthCss)}×${Math.round(heightCss)}px) the max ` +
50
+ `supported DPI is ~${maxDpi}.`
51
+ );
52
+ }
53
+ }
54
+
55
+ /** Pin an element's enclosing artboard to (0,0); returns the style values to
56
+ * hand back to {@link restoreArtboard}. Per-target save/restore (not a global
57
+ * reset) — the multi-artboard scatter-bug lesson from the playwright shims. */
58
+ export function pinArtboard(el: Element): { left: string; top: string } {
59
+ const ab = (el.closest('[data-dc-screen]') ?? el) as HTMLElement;
60
+ const prev = { left: ab.style.left, top: ab.style.top };
61
+ ab.style.left = '0px';
62
+ ab.style.top = '0px';
63
+ return prev;
64
+ }
65
+
66
+ export function restoreArtboard(el: Element, prev: { left: string; top: string }): void {
67
+ const ab = (el.closest('[data-dc-screen]') ?? el) as HTMLElement;
68
+ ab.style.left = prev.left;
69
+ ab.style.top = prev.top;
70
+ }
71
+
72
+ /** Reset the world plane's pan/zoom so artboards measure at their declared
73
+ * dimensions (CSS `zoom` shrinks LAYOUT — getBoundingClientRect lies under
74
+ * it). Returns the saved styles for {@link restoreWorldPlane}; the live-canvas
75
+ * host MUST restore (the member is looking at the plane), the throwaway shim
76
+ * page never bothers. */
77
+ export function restoreWorldPlane(saved: { zoom: string; transform: string } | null): void {
78
+ const world = document.querySelector<HTMLElement>('.dc-world');
79
+ if (!world || !saved) return;
80
+ world.style.zoom = saved.zoom;
81
+ world.style.transform = saved.transform;
82
+ }
83
+
84
+ export function resetWorldPlane(): { zoom: string; transform: string } | null {
85
+ const world = document.querySelector<HTMLElement>('.dc-world');
86
+ if (!world) return null;
87
+ const saved = { zoom: world.style.zoom, transform: world.style.transform };
88
+ world.style.zoom = '1';
89
+ world.style.transform = 'none';
90
+ return saved;
91
+ }
92
+
93
+ /** Convert ANY computed color (modern Chromium serializes `oklch(...)`
94
+ * verbatim from getComputedStyle) to sRGB rgb()/rgba(). Affinity Designer /
95
+ * older Illustrator / Inkscape don't parse CSS Color 4 and silently drop the
96
+ * fill. A 1×1 canvas normalizes any CSS color string to sRGB bytes; null when
97
+ * the value can't be painted (keep the original rather than emit a wrong
98
+ * colour). */
99
+ function makeToSrgb(): (color: string) => string | null {
100
+ const cache = new Map<string, string | null>();
101
+ const canvas = document.createElement('canvas');
102
+ canvas.width = 1;
103
+ canvas.height = 1;
104
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
105
+ return (color: string) => {
106
+ const hit = cache.get(color);
107
+ if (hit !== undefined) return hit;
108
+ let result: string | null = null;
109
+ try {
110
+ if (ctx) {
111
+ ctx.clearRect(0, 0, 1, 1);
112
+ ctx.fillStyle = '#000';
113
+ ctx.fillStyle = color; // ignored (stays #000) if syntax unsupported
114
+ ctx.fillRect(0, 0, 1, 1);
115
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
116
+ if (a !== 0) {
117
+ result =
118
+ a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
119
+ }
120
+ }
121
+ } catch {
122
+ result = null;
123
+ }
124
+ cache.set(color, result);
125
+ return result;
126
+ };
127
+ }
128
+
129
+ const isOpaque = (bg: string | null): bg is string =>
130
+ !!bg &&
131
+ bg !== 'transparent' &&
132
+ !/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(bg) &&
133
+ !/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0(\.0+)?\s*\)$/.test(bg);
134
+
135
+ /** Effective background of the artboard region: the DS theme bg usually sits
136
+ * on an inner full-bleed wrapper (`.app` / `.mdcc`), occasionally on the
137
+ * artboard element itself, sometimes on an ancestor. Descend first for a
138
+ * near-full-bleed opaque bg, then fall back to ancestors. An intentionally
139
+ * transparent artboard returns null → stays transparent. */
140
+ function effectiveBg(node: Element): string | null {
141
+ const abRect = node.getBoundingClientRect();
142
+ const abArea = Math.max(1, abRect.width * abRect.height);
143
+ const queue: Element[] = [node];
144
+ let guard = 0;
145
+ while (queue.length && guard < 80) {
146
+ const e = queue.shift() as Element;
147
+ guard += 1;
148
+ const bg = getComputedStyle(e).backgroundColor;
149
+ if (isOpaque(bg)) {
150
+ const r = e.getBoundingClientRect();
151
+ if (r.width * r.height >= abArea * 0.6) return bg;
152
+ }
153
+ for (const c of e.children) queue.push(c);
154
+ }
155
+ let cur = node.parentElement;
156
+ let up = 0;
157
+ while (cur && up < 12) {
158
+ const bg = getComputedStyle(cur).backgroundColor;
159
+ if (isOpaque(bg)) return bg;
160
+ cur = cur.parentElement;
161
+ up += 1;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ export interface SvgCaptureOptions {
167
+ /** Capture the enclosing `[data-dc-screen]` rather than the element itself. */
168
+ widenToArtboard?: boolean;
169
+ }
170
+
171
+ /**
172
+ * Serialize one element (usually an artboard) to a portable SVG string:
173
+ * dom-to-svg primitives + a backdrop `<rect>` from the effective background
174
+ * (dom-to-svg doesn't paint the root's fill — the "artboard bg vanished in
175
+ * Affinity" bug) + base64-inlined fonts/images + a global CSS-Color-4 → sRGB
176
+ * rewrite so vector editors keep every fill.
177
+ */
178
+ export async function svgForElement(
179
+ el: Element,
180
+ api: DomToSvgApi,
181
+ opts: SvgCaptureOptions = {}
182
+ ): Promise<string> {
183
+ const target = opts.widenToArtboard ? (el.closest('[data-dc-screen]') ?? el) : el;
184
+ const toSrgb = makeToSrgb();
185
+
186
+ const bg = effectiveBg(target);
187
+ const fillColor = bg ? toSrgb(bg) : null;
188
+ const svgDoc = api.elementToSVG(target);
189
+ const root = svgDoc.documentElement;
190
+ if (fillColor) {
191
+ const NS = 'http://www.w3.org/2000/svg';
192
+ const rect = svgDoc.createElementNS(NS, 'rect');
193
+ const vb = (root.getAttribute('viewBox') || '').split(/\s+/).map(Number);
194
+ if (vb.length === 4 && vb.every((n) => !Number.isNaN(n))) {
195
+ rect.setAttribute('x', String(vb[0]));
196
+ rect.setAttribute('y', String(vb[1]));
197
+ rect.setAttribute('width', String(vb[2]));
198
+ rect.setAttribute('height', String(vb[3]));
199
+ } else {
200
+ rect.setAttribute('x', '0');
201
+ rect.setAttribute('y', '0');
202
+ rect.setAttribute('width', '100%');
203
+ rect.setAttribute('height', '100%');
204
+ }
205
+ rect.setAttribute('fill', fillColor);
206
+ root.insertBefore(rect, root.firstChild);
207
+ }
208
+ // base64-embeds fonts + images so the SVG is portable outside the serving
209
+ // origin — and, for the raster path, so the <img> decode is self-contained
210
+ // (an SVG-as-image loads NO external resources; a data: font/image does).
211
+ try {
212
+ await api.inlineResources(root);
213
+ } catch {
214
+ /* best-effort — a missing resource beats a missing primitive */
215
+ }
216
+ let out = new XMLSerializer().serializeToString(svgDoc);
217
+ out = out.replace(/(?:oklch|oklab|lch|lab|color)\([^)]*\)/gi, (m) => toSrgb(m) || m);
218
+ return out;
219
+ }
220
+
221
+ /** Parse the root viewBox (or width/height) out of a serialized SVG. */
222
+ export function svgDimensions(svg: string): { width: number; height: number } | null {
223
+ const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
224
+ const root = doc.documentElement;
225
+ if (root.nodeName === 'parsererror') return null;
226
+ const vb = (root.getAttribute('viewBox') || '').split(/\s+/).map(Number);
227
+ if (vb.length === 4 && vb.every((n) => !Number.isNaN(n)) && vb[2] > 0 && vb[3] > 0) {
228
+ return { width: vb[2], height: vb[3] };
229
+ }
230
+ const w = Number.parseFloat(root.getAttribute('width') || '');
231
+ const h = Number.parseFloat(root.getAttribute('height') || '');
232
+ return Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0
233
+ ? { width: w, height: h }
234
+ : null;
235
+ }
236
+
237
+ /**
238
+ * Rasterize a self-contained SVG string to a PNG Blob at `scale`×. The SVG
239
+ * must already have its resources inlined ({@link svgForElement} does) — an
240
+ * external URL inside an SVG-as-image silently doesn't load, and a
241
+ * cross-origin bitmap would taint the canvas and make toBlob throw.
242
+ */
243
+ export async function rasterizeSvg(svg: string, scale: number): Promise<Blob> {
244
+ const dims = svgDimensions(svg);
245
+ if (!dims) throw new Error('capture produced an SVG with no measurable dimensions');
246
+ assertRasterSizeOk(dims.width, dims.height, scale);
247
+ const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
248
+ try {
249
+ const img = new Image();
250
+ img.decoding = 'async';
251
+ await new Promise<void>((resolve, reject) => {
252
+ img.onload = () => resolve();
253
+ img.onerror = () => reject(new Error('SVG rasterization failed to decode'));
254
+ img.src = url;
255
+ });
256
+ const canvas = document.createElement('canvas');
257
+ canvas.width = Math.max(1, Math.ceil(dims.width * scale));
258
+ canvas.height = Math.max(1, Math.ceil(dims.height * scale));
259
+ const ctx = canvas.getContext('2d');
260
+ if (!ctx) throw new Error('2d canvas context unavailable');
261
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
262
+ const blob = await new Promise<Blob | null>((resolve) => {
263
+ canvas.toBlob(resolve, 'image/png');
264
+ });
265
+ if (!blob) throw new Error('canvas.toBlob returned null');
266
+ return blob;
267
+ } finally {
268
+ URL.revokeObjectURL(url);
269
+ }
270
+ }
271
+
272
+ const core = {
273
+ MAX_OUTPUT_SIDE_PX,
274
+ MAX_OUTPUT_BYTES,
275
+ assertRasterSizeOk,
276
+ pinArtboard,
277
+ restoreArtboard,
278
+ resetWorldPlane,
279
+ restoreWorldPlane,
280
+ svgForElement,
281
+ svgDimensions,
282
+ rasterizeSvg,
283
+ };
284
+
285
+ export type CaptureCore = typeof core;
286
+
287
+ // Side-effect global for the playwright-shim host (injected via addScriptTag,
288
+ // no importmap there) — mirrors video-encode-lib's `window.__maudeEnc`.
289
+ if (typeof window !== 'undefined') {
290
+ (window as unknown as { __maudeCaptureCore: CaptureCore }).__maudeCaptureCore = core;
291
+ }
@@ -269,30 +269,58 @@ function pngArgsFor(target: ElementTarget, url: string, outDir: string): string[
269
269
 
270
270
  // ─── PNG fallback deck ────────────────────────────────────────────────────────
271
271
 
272
- async function buildPngDeck(pngPaths: string[]): Promise<Uint8Array> {
272
+ /**
273
+ * DDR-231 (hybrid export lanes) — the browser-free half of the PNG deck,
274
+ * working on BYTES so the workspace assemble route (`/_api/export-assemble`)
275
+ * can feed it captures made in the MEMBER's browser: the client's
276
+ * export-capture bridge rasterizes the artboards, this composes the .pptx
277
+ * in-cell with PptxGenJS (pure JS — same containment class as zip). The
278
+ * desktop path below ({@link buildPngDeck}) is a thin file-reading wrapper,
279
+ * so both hosts share one composition (the single-spine rule).
280
+ *
281
+ * @param captureScale the deviceScale the PNGs were rendered at — slide
282
+ * dimensions normalise back to artboard inches through it.
283
+ */
284
+ export async function assemblePngDeck(
285
+ images: Uint8Array[],
286
+ captureScale: number
287
+ ): Promise<Uint8Array> {
273
288
  const JSZip = (await import('jszip')).default;
274
- // Slide size from the first PNG (px → inches); contain-fit the rest.
275
- const dims = pngPaths.map((p) => {
289
+ // The full 8-byte PNG signature (security L3 — 4 bytes let a `\x89PNG…`
290
+ // prefix on non-PNG bytes through) and a sane dimension ceiling (security
291
+ // F3 — the images arrive from the untrusted client; an IHDR claiming
292
+ // 2^31×2^31 would drive PptxGenJS's EMU geometry into overflow/garbage).
293
+ const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
294
+ const MAX_DIM = 20000; // px — above any real artboard × 8× export scale
295
+ // Slide size from the largest PNG (px → inches); contain-fit the rest.
296
+ const dims = images.map((bytes) => {
276
297
  // PNG IHDR: width @ byte 16, height @ 20 (big-endian).
277
- const b = readFileSync(p);
278
- return { w: b.readUInt32BE(16), h: b.readUInt32BE(20), p };
298
+ const b = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
299
+ if (b.length < 24 || PNG_SIG.some((sig, i) => b[i] !== sig)) {
300
+ throw new Error('assemblePngDeck: not a PNG');
301
+ }
302
+ const w = b.readUInt32BE(16);
303
+ const h = b.readUInt32BE(20);
304
+ if (w < 1 || h < 1 || w > MAX_DIM || h > MAX_DIM) {
305
+ throw new Error(`assemblePngDeck: implausible PNG dimensions ${w}×${h}`);
306
+ }
307
+ return { w, h, b };
279
308
  });
280
309
  const maxW = Math.max(...dims.map((d) => d.w));
281
310
  const maxH = Math.max(...dims.map((d) => d.h));
282
- // The PNGs are FALLBACK_SCALE× the artboard; normalise to artboard inches.
283
- const slideW = maxW / SCREEN_DPI / FALLBACK_SCALE;
284
- const slideH = maxH / SCREEN_DPI / FALLBACK_SCALE;
311
+ const slideW = maxW / SCREEN_DPI / captureScale;
312
+ const slideH = maxH / SCREEN_DPI / captureScale;
285
313
  const pptx = new PptxGenJS();
286
314
  pptx.defineLayout({ name: 'MAUDE_ARTBOARD', width: slideW, height: slideH });
287
315
  pptx.layout = 'MAUDE_ARTBOARD';
288
316
  for (const d of dims) {
289
317
  const slide = pptx.addSlide();
290
- const nativeW = d.w / SCREEN_DPI / FALLBACK_SCALE;
291
- const nativeH = d.h / SCREEN_DPI / FALLBACK_SCALE;
318
+ const nativeW = d.w / SCREEN_DPI / captureScale;
319
+ const nativeH = d.h / SCREEN_DPI / captureScale;
292
320
  const scale = Math.min(slideW / nativeW, slideH / nativeH);
293
321
  const w = nativeW * scale;
294
322
  const h = nativeH * scale;
295
- const dataUri = `data:image/png;base64,${readFileSync(d.p).toString('base64')}`;
323
+ const dataUri = `data:image/png;base64,${d.b.toString('base64')}`;
296
324
  slide.addImage({ data: dataUri, x: (slideW - w) / 2, y: (slideH - h) / 2, w, h });
297
325
  }
298
326
  const buf = new Uint8Array((await pptx.write({ outputType: 'nodebuffer' })) as Uint8Array);
@@ -310,6 +338,13 @@ async function buildPngDeck(pngPaths: string[]): Promise<Uint8Array> {
310
338
  return zip.generateAsync({ type: 'uint8array' });
311
339
  }
312
340
 
341
+ async function buildPngDeck(pngPaths: string[]): Promise<Uint8Array> {
342
+ return assemblePngDeck(
343
+ pngPaths.map((p) => new Uint8Array(readFileSync(p))),
344
+ FALLBACK_SCALE
345
+ );
346
+ }
347
+
313
348
  export async function run(
314
349
  targets: Target[],
315
350
  options: ExportOptions,
@@ -169,15 +169,29 @@ export async function renderRemotely(args: {
169
169
  ...options,
170
170
  timeoutSec: Math.max(Number((options as { timeoutSec?: number }).timeoutSec) || 0, 60),
171
171
  };
172
- const res = await fetch(`${service.url}/render`, {
173
- method: 'POST',
174
- signal,
175
- headers: {
176
- 'content-type': 'application/json',
177
- authorization: `Bearer ${service.secret}`,
178
- },
179
- body: JSON.stringify({ format, targets, options: remoteOptions, canvas }),
180
- });
172
+ let res: Response;
173
+ try {
174
+ res = await fetch(`${service.url}/render`, {
175
+ method: 'POST',
176
+ signal,
177
+ headers: {
178
+ 'content-type': 'application/json',
179
+ authorization: `Bearer ${service.secret}`,
180
+ },
181
+ body: JSON.stringify({ format, targets, options: remoteOptions, canvas }),
182
+ });
183
+ } catch (err) {
184
+ if (signal?.aborted) throw err;
185
+ // DDR-231 T7 — the member sees this in the export dialog / notification
186
+ // center: name the LIKELY cause (a sleeping container waking) instead of
187
+ // leaking a bare fetch error.
188
+ throw new Error(
189
+ 'The render service didn’t answer — it may still be waking up. Try the export again in a minute.'
190
+ );
191
+ }
192
+ if (res.status === 503) {
193
+ throw new Error('The render service is busy with other exports right now — try again shortly.');
194
+ }
181
195
  if (!res.ok) {
182
196
  const detail = (await res.text().catch(() => '')) || `${res.status}`;
183
197
  throw new Error(`render service refused the job: ${detail}`);
@@ -12,7 +12,7 @@ import path from 'node:path';
12
12
 
13
13
  import JSZip from 'jszip';
14
14
 
15
- import { getBrowserBundle } from './_browser-bundles.ts';
15
+ import { getBrowserBundle, getCaptureCoreBundle } from './_browser-bundles.ts';
16
16
  import { exportShimPath, runShim } from './_runtime.ts';
17
17
  import {
18
18
  canvasShellUrl,
@@ -32,6 +32,7 @@ async function captureSvg(
32
32
  outDir: string,
33
33
  timeoutSec: number,
34
34
  bundlePath: string,
35
+ corePath: string,
35
36
  hooks?: ExportHooks
36
37
  ): Promise<string[]> {
37
38
  const args = [
@@ -42,6 +43,8 @@ async function captureSvg(
42
43
  target.cssPath,
43
44
  '--bundle-path',
44
45
  bundlePath,
46
+ '--core-path',
47
+ corePath,
45
48
  '--timeout',
46
49
  String(timeoutSec),
47
50
  ];
@@ -82,13 +85,24 @@ export async function run(
82
85
  throw new Error('svg adapter requires element targets (got file-tree)');
83
86
  }
84
87
  const timeoutSec = (options.timeoutSec as number | undefined) ?? 12;
85
- const bundlePath = await getBrowserBundle('dom-to-svg', 'domToSvg');
88
+ const [bundlePath, corePath] = await Promise.all([
89
+ getBrowserBundle('dom-to-svg', 'domToSvg'),
90
+ getCaptureCoreBundle(),
91
+ ]);
86
92
  const tmp = mkdtempSync(path.join(tmpdir(), 'maude-svg-'));
87
93
 
88
94
  try {
89
95
  const written: string[] = [];
90
96
  for (let i = 0; i < elementTargets.length; i += 1) {
91
- const paths = await captureSvg(elementTargets[i], ctx, tmp, timeoutSec, bundlePath, hooks);
97
+ const paths = await captureSvg(
98
+ elementTargets[i],
99
+ ctx,
100
+ tmp,
101
+ timeoutSec,
102
+ bundlePath,
103
+ corePath,
104
+ hooks
105
+ );
92
106
  written.push(...paths);
93
107
  hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
94
108
  }
@@ -4425,6 +4425,107 @@ export function createHttp(
4425
4425
  });
4426
4426
  },
4427
4427
 
4428
+ '/_api/export-warmup': async (req: Request) => {
4429
+ // DDR-231 T7 — wake the render service while the member is still picking
4430
+ // export options: the dialog fires this on open (remote lane only), so
4431
+ // by the time a video/PDF job lands the multi-GB Chromium container is
4432
+ // already booting instead of starting cold. Proxies GET /_health — no
4433
+ // job body, no secret in the response, nothing evaluates.
4434
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
4435
+ if (!isTrustedRequestHost(req))
4436
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4437
+ // Side-effecting GET (it triggers an outbound fetch that wakes/bills a
4438
+ // container) → gate it like every other side-effecting GET, so a
4439
+ // cross-site page a member visits can't fire it as a keep-warm
4440
+ // amplification (DDR-231 security L2).
4441
+ if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
4442
+ const serviceUrl = process.env.MAUDE_RENDER_URL;
4443
+ if (!serviceUrl) return Response.json({ ok: false, lane: resolveRenderLane() });
4444
+ try {
4445
+ const r = await fetch(`${serviceUrl.replace(/\/+$/, '')}/_health`, {
4446
+ signal: AbortSignal.timeout(8_000),
4447
+ });
4448
+ const body = (await r.json().catch(() => null)) as { ok?: boolean } | null;
4449
+ return Response.json(
4450
+ { ok: Boolean(body?.ok), lane: 'remote' },
4451
+ { headers: { 'Cache-Control': 'no-store' } }
4452
+ );
4453
+ } catch {
4454
+ // A timeout here usually MEANS the container is waking — that is the
4455
+ // point of the ping; report it calmly.
4456
+ return Response.json(
4457
+ { ok: false, waking: true, lane: 'remote' },
4458
+ { headers: { 'Cache-Control': 'no-store' } }
4459
+ );
4460
+ }
4461
+ },
4462
+
4463
+ '/_api/export-assemble': async (req: Request) => {
4464
+ // DDR-231 (hybrid export lanes) — the browser lane's ASSEMBLE half. The
4465
+ // member's browser captured the artboard PNGs (canvas-lib's
4466
+ // export-capture bridge — the only place the pixels exist in a
4467
+ // workspace); this composes them into a .pptx deck with PptxGenJS —
4468
+ // pure JS over pure data, the same containment class as zip: no tenant
4469
+ // TSX evaluates here and no browser enters the image. MAIN-ORIGIN ONLY
4470
+ // (absent from CANVAS_SAFE_API + the canvas-origin routes map) and
4471
+ // CSRF-gated like its jobs siblings.
4472
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
4473
+ if (!isTrustedRequestHost(req))
4474
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4475
+ if (!sameOriginWrite(req))
4476
+ return new Response('cross-origin write rejected', { status: 403 });
4477
+ // Byte cap BEFORE parsing: 50 artboards at 3× are tens of MB; 96 MB sits
4478
+ // under Bun's global maxRequestBodySize (~108 MB) so formData() can't be
4479
+ // pushed past it, and still fits a large deck (PptxGenJS base64-encodes
4480
+ // every image → memory ~3× input). REQUIRE an honest content-length: a
4481
+ // chunked / absent-length body would otherwise skip this fast-reject and
4482
+ // buffer up to Bun's ceiling before the loop below could stop it
4483
+ // (adversarial pass). formData() itself is still Bun-capped as a backstop.
4484
+ const MAX_ASSEMBLE_BYTES = 96 * 1024 * 1024;
4485
+ const declared = Number(req.headers.get('content-length'));
4486
+ if (!Number.isFinite(declared) || declared <= 0)
4487
+ return new Response('content-length required', { status: 411 });
4488
+ if (declared > MAX_ASSEMBLE_BYTES)
4489
+ return new Response('assembly payload too large', { status: 413 });
4490
+ const form = await req.formData().catch(() => null);
4491
+ if (!form) return new Response('multipart form body required', { status: 400 });
4492
+ if (form.get('format') !== 'pptx')
4493
+ return new Response('unsupported assemble format', { status: 400 });
4494
+ const scale = Math.max(1, Math.min(8, Number(form.get('scale')) || 1));
4495
+ const images: Uint8Array[] = [];
4496
+ let total = 0;
4497
+ for (const [key, value] of form.entries()) {
4498
+ if (key !== 'image') continue;
4499
+ // Bun's FormData typings collapse the entry value; runtime-check the
4500
+ // Blob shape instead of trusting the declared union.
4501
+ const file = value as unknown;
4502
+ if (!(file instanceof Blob)) continue;
4503
+ const bytes = new Uint8Array(await file.arrayBuffer());
4504
+ total += bytes.byteLength;
4505
+ if (images.length >= 100 || total > MAX_ASSEMBLE_BYTES)
4506
+ return new Response('assembly payload too large', { status: 413 });
4507
+ images.push(bytes);
4508
+ }
4509
+ if (!images.length) return new Response('no images to assemble', { status: 400 });
4510
+ try {
4511
+ const { assemblePngDeck } = await import('./exporters/pptx.ts');
4512
+ const body = await assemblePngDeck(images, scale);
4513
+ const name = String(form.get('name') || 'export').replace(/[^A-Za-z0-9._-]/g, '_');
4514
+ return new Response(body as unknown as BodyInit, {
4515
+ status: 200,
4516
+ headers: {
4517
+ 'Content-Type':
4518
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
4519
+ 'Content-Disposition': `attachment; filename="${name}.pptx"`,
4520
+ 'Cache-Control': 'no-store',
4521
+ },
4522
+ });
4523
+ } catch (err) {
4524
+ const msg = err instanceof Error ? err.message : String(err);
4525
+ return new Response(`deck assembly failed: ${msg}`, { status: 422 });
4526
+ }
4527
+ },
4528
+
4428
4529
  // feature-ai-media-generation (DDR-16x) — the background AI-media generation
4429
4530
  // job queue. The privileged sibling of /_api/export-jobs: MAIN-ORIGIN ONLY
4430
4531
  // (absent from CANVAS_SAFE_API + startCanvasServer's routes — the untrusted
@@ -123,6 +123,12 @@ export const RUNTIME_PACKAGES = [
123
123
  '@remotion/transitions/flip',
124
124
  '@remotion/transitions/clock-wipe',
125
125
  '@remotion/transitions/none',
126
+ // DDR-231 (hybrid export lanes) — the workspace `browser` lane captures
127
+ // artboards in the MEMBER's browser. canvas-lib's export-capture listener
128
+ // reaches dom-to-svg through a LAZY runtime `import('dom-to-svg')` (the
129
+ // pixi.js pattern), so a canvas that never exports pays zero bundle cost —
130
+ // regression-pinned alongside pixi in test/photo-canvas-bundle.test.ts.
131
+ 'dom-to-svg',
126
132
  ] as const;
127
133
 
128
134
  export type RuntimePackage = (typeof RUNTIME_PACKAGES)[number];
@@ -0,0 +1,32 @@
1
+ // capture-core.test.ts — pure-logic guards of the shared capture spine
2
+ // (DDR-231). The DOM-dependent half (svgForElement, rasterizeSvg) is exercised
3
+ // end-to-end by the fidelity gate (plan T6) — no DOM emulation here.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import {
8
+ assertRasterSizeOk,
9
+ MAX_OUTPUT_BYTES,
10
+ MAX_OUTPUT_SIDE_PX,
11
+ } from '../exporters/capture-core.ts';
12
+
13
+ describe('assertRasterSizeOk — the raster render guard', () => {
14
+ test('a 1440×900 artboard at 2× passes', () => {
15
+ expect(() => assertRasterSizeOk(1440, 900, 2)).not.toThrow();
16
+ });
17
+
18
+ test('a 600dpi A0 poster still fits (the guard ceiling rationale)', () => {
19
+ // 9933×14043 px ≈ 558MB RGBA — deliberately inside the ~600MB ceiling.
20
+ expect(() => assertRasterSizeOk(9933 / 6.25, 14043 / 6.25, 6.25)).not.toThrow();
21
+ });
22
+
23
+ test('exceeding the side ceiling throws with an actionable max-DPI hint', () => {
24
+ expect(() => assertRasterSizeOk(MAX_OUTPUT_SIDE_PX, 900, 2)).toThrow(/max supported DPI/);
25
+ });
26
+
27
+ test('exceeding the byte ceiling throws even when both sides fit', () => {
28
+ // 15000×15000 × 4B = 900MB > ceiling while each side < 16000.
29
+ expect(15000 * 15000 * 4).toBeGreaterThan(MAX_OUTPUT_BYTES);
30
+ expect(() => assertRasterSizeOk(15000, 15000, 1)).toThrow(/render guard/);
31
+ });
32
+ });