@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.
- package/apps/studio/bin/_png-playwright.mjs +5 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -145
- package/apps/studio/canvas-lib.tsx +129 -0
- package/apps/studio/client/app.jsx +235 -8
- package/apps/studio/client/export-lane.js +156 -0
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/runtime/.min-sizes.json +2 -1
- package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
- package/apps/studio/exporters/_browser-bundles.ts +30 -0
- package/apps/studio/exporters/capture-core.ts +291 -0
- package/apps/studio/exporters/pptx.ts +46 -11
- package/apps/studio/exporters/remote.ts +23 -9
- package/apps/studio/exporters/svg.ts +17 -3
- package/apps/studio/http.ts +101 -0
- package/apps/studio/runtime-bundle.ts +6 -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/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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
275
|
-
|
|
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 =
|
|
278
|
-
|
|
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
|
-
|
|
283
|
-
const
|
|
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 /
|
|
291
|
-
const nativeH = d.h / SCREEN_DPI /
|
|
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,${
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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(
|
|
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
|
}
|
package/apps/studio/http.ts
CHANGED
|
@@ -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
|
+
});
|