@1agh/maude 1.0.7 → 1.0.9
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 +23 -6
- 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 +45 -12
- package/apps/studio/bin/_video-playwright.mjs +135 -12
- package/apps/studio/canvas-edit.ts +33 -0
- package/apps/studio/canvas-lib.tsx +7 -0
- package/apps/studio/client/app.jsx +84 -23
- package/apps/studio/client/export-lane.js +37 -2
- package/apps/studio/dist/client.bundle.js +601 -601
- package/apps/studio/export-dialog.tsx +24 -19
- package/apps/studio/exporters/_runtime.ts +31 -1
- package/apps/studio/exporters/capture-chrome.ts +111 -0
- package/apps/studio/exporters/capture-core.ts +659 -3
- package/apps/studio/exporters/format-scopes.ts +88 -0
- package/apps/studio/exporters/html.ts +3 -2
- package/apps/studio/exporters/jobs.ts +190 -18
- package/apps/studio/exporters/pdf.ts +21 -1
- package/apps/studio/exporters/remote.ts +73 -4
- package/apps/studio/exporters/scope.ts +14 -3
- package/apps/studio/exporters/video.ts +75 -8
- package/apps/studio/http.ts +74 -7
- package/apps/studio/test/canvas-hide-chrome.test.ts +30 -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/whats-new.json +9 -0
- package/package.json +8 -8
|
@@ -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
|
|
@@ -147,7 +237,18 @@ try {
|
|
|
147
237
|
// no renderMediaOnWeb container support (and no audio need); an artboard with
|
|
148
238
|
// no registered comp (ordinary/CSS) has nothing for renderMediaOnWeb to render
|
|
149
239
|
// — both fall through to the frame-step path below.
|
|
150
|
-
|
|
240
|
+
//
|
|
241
|
+
// MAUDE_RENDER_SKIP_WHOLE_COMP=1 forces the frame-step path even for a
|
|
242
|
+
// registered comp. @remotion/web-renderer's renderMediaOnWeb never RESOLVES on
|
|
243
|
+
// the headless Linux render worker (measured on the cloud fleet AND CI — even
|
|
244
|
+
// a 12-frame comp and even muted; WebCodecs itself works, per the caps probe,
|
|
245
|
+
// so it is something in web-renderer's whole-comp pipeline that wedges). On
|
|
246
|
+
// that host it only ever burned the renderMediaOnWeb timeout and then fell to
|
|
247
|
+
// frame-step anyway — so the worker skips it and goes straight to the path
|
|
248
|
+
// that WORKS, reclaiming those minutes for the actual (muted) render. The
|
|
249
|
+
// desktop, where renderMediaOnWeb works and carries audio, leaves it on.
|
|
250
|
+
const skipWholeComp = process.env.MAUDE_RENDER_SKIP_WHOLE_COMP === '1';
|
|
251
|
+
const useRenderer = !skipWholeComp && !!renderLib && format !== 'gif' && !!compId;
|
|
151
252
|
|
|
152
253
|
// When the whole-comp renderer path throws we DON'T fail the export. The most
|
|
153
254
|
// common cause is a `RangeError: Maximum call stack size exceeded` from
|
|
@@ -166,7 +267,7 @@ try {
|
|
|
166
267
|
try {
|
|
167
268
|
const t0 = Date.now();
|
|
168
269
|
const libSrc = readFileSync(renderLib, 'utf8');
|
|
169
|
-
await page
|
|
270
|
+
await addScriptCspSafe(page, { content: libSrc, type: 'module', name: 'render-lib' });
|
|
170
271
|
await page.waitForFunction(() => typeof window.__maudeRenderVideo__ === 'function', {
|
|
171
272
|
timeout: timeoutMs,
|
|
172
273
|
});
|
|
@@ -175,7 +276,14 @@ try {
|
|
|
175
276
|
const frameRange =
|
|
176
277
|
Number.isFinite(explicitFrames) && explicitFrames > 0 ? [0, explicitFrames - 1] : null;
|
|
177
278
|
|
|
178
|
-
|
|
279
|
+
// Bound the whole-comp render: @remotion/web-renderer drives WebCodecs,
|
|
280
|
+
// which on headless Linux without a working encoder never resolves — a
|
|
281
|
+
// hang, not a throw, so the graceful frame-step fallback below never
|
|
282
|
+
// fires. A wall-clock cap turns that hang INTO a throw, which the catch
|
|
283
|
+
// converts into exactly that fallback (frame-step, muted) — the video
|
|
284
|
+
// still exports instead of wedging the render worker.
|
|
285
|
+
const rendered = await evalWithTimeout(
|
|
286
|
+
page,
|
|
179
287
|
async ({ compId, container, scale, muted, frameRange, licenseKey }) => {
|
|
180
288
|
return window.__maude_render_video__(compId, {
|
|
181
289
|
container,
|
|
@@ -192,7 +300,9 @@ try {
|
|
|
192
300
|
muted: !wantAudio,
|
|
193
301
|
frameRange,
|
|
194
302
|
licenseKey,
|
|
195
|
-
}
|
|
303
|
+
},
|
|
304
|
+
Math.min(timeoutMs, 90_000),
|
|
305
|
+
`renderMediaOnWeb(${format})`
|
|
196
306
|
);
|
|
197
307
|
|
|
198
308
|
writeFileSync(out, Buffer.from(rendered.b64, 'base64'));
|
|
@@ -421,9 +531,14 @@ async function frameStepCapture({
|
|
|
421
531
|
const isGif = format === 'gif';
|
|
422
532
|
if (encoding) {
|
|
423
533
|
const libSrc = readFileSync(encodeLib, 'utf8');
|
|
424
|
-
await page
|
|
534
|
+
await addScriptCspSafe(page, { content: libSrc, type: 'module', name: 'encode-lib' });
|
|
425
535
|
await page.waitForFunction(() => typeof window.__maudeEnc === 'object', { timeout: timeoutMs });
|
|
426
|
-
|
|
536
|
+
// Bound the encoder start: on headless Linux without a working WebCodecs
|
|
537
|
+
// encoder this promise never resolves, so a wall-clock cap converts the hang
|
|
538
|
+
// into a fast, diagnostic failure instead of consuming the whole render
|
|
539
|
+
// budget in silence.
|
|
540
|
+
const started = await evalWithTimeout(
|
|
541
|
+
page,
|
|
427
542
|
async ({ width, height, fps, isGif, gifColors, fmt }) => {
|
|
428
543
|
if (isGif) {
|
|
429
544
|
window.__maudeEnc.startGif({ width, height, fps, maxColors: gifColors });
|
|
@@ -438,7 +553,9 @@ async function frameStepCapture({
|
|
|
438
553
|
isGif,
|
|
439
554
|
gifColors: Number(args.gifColors) || 256,
|
|
440
555
|
fmt: format,
|
|
441
|
-
}
|
|
556
|
+
},
|
|
557
|
+
Math.min(timeoutMs, 45_000),
|
|
558
|
+
`startVideo(${format})`
|
|
442
559
|
);
|
|
443
560
|
console.error(`encoder: ${started.container} / ${started.codec} @ ${outW}×${outH}`);
|
|
444
561
|
}
|
|
@@ -470,12 +587,15 @@ async function frameStepCapture({
|
|
|
470
587
|
if (encoding) {
|
|
471
588
|
const tEnc = Date.now();
|
|
472
589
|
const b64 = shot.toString('base64');
|
|
473
|
-
await
|
|
590
|
+
await evalWithTimeout(
|
|
591
|
+
page,
|
|
474
592
|
async ({ b64, isGif, format }) => {
|
|
475
593
|
if (isGif) return window.__maudeEnc.addGifFrame(b64);
|
|
476
594
|
return window.__maudeEnc.addVideoFrame(b64, format);
|
|
477
595
|
},
|
|
478
|
-
{ b64, isGif, format: shotFormat }
|
|
596
|
+
{ b64, isGif, format: shotFormat },
|
|
597
|
+
30_000,
|
|
598
|
+
`addFrame#${f}`
|
|
479
599
|
);
|
|
480
600
|
// NB: this stage is the double CDP crossing — the screenshot bytes go out
|
|
481
601
|
// to node as base64 and straight back into the same page, to an encoder
|
|
@@ -512,10 +632,13 @@ async function frameStepCapture({
|
|
|
512
632
|
result = { ...result, degraded: true, audioDropped: true, fallbackReason };
|
|
513
633
|
}
|
|
514
634
|
if (encoding) {
|
|
515
|
-
const enc = await
|
|
635
|
+
const enc = await evalWithTimeout(
|
|
636
|
+
page,
|
|
516
637
|
async ({ isGif }) =>
|
|
517
638
|
isGif ? window.__maudeEnc.finishGif() : window.__maudeEnc.finishVideo(),
|
|
518
|
-
{ isGif }
|
|
639
|
+
{ isGif },
|
|
640
|
+
Math.min(timeoutMs, 45_000),
|
|
641
|
+
`finish(${format})`
|
|
519
642
|
);
|
|
520
643
|
writeFileSync(out, Buffer.from(enc.b64, 'base64'));
|
|
521
644
|
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
|
|
@@ -124,6 +124,7 @@ import {
|
|
|
124
124
|
diffLayoutPositions,
|
|
125
125
|
} from './commands/move-artboards-command.ts';
|
|
126
126
|
import { scopedCdSelector, selectorIndex, shortText } from './dom-selection.ts';
|
|
127
|
+
import { applyCaptureChrome } from './exporters/capture-chrome.ts';
|
|
127
128
|
import {
|
|
128
129
|
type DomToSvgApi,
|
|
129
130
|
pinArtboard,
|
|
@@ -1860,6 +1861,11 @@ function useExportCaptureBridge() {
|
|
|
1860
1861
|
: null;
|
|
1861
1862
|
void (async () => {
|
|
1862
1863
|
const savedWorld = resetWorldPlane();
|
|
1864
|
+
// Hold the chrome suppression across the WHOLE batch. `svgForElement`
|
|
1865
|
+
// applies it per target too (ref-counted), but a 10-artboard deck would
|
|
1866
|
+
// otherwise add and remove the style ten times — ten forced reflows,
|
|
1867
|
+
// and the member watching the canvas sees the chrome flicker.
|
|
1868
|
+
const releaseChrome = applyCaptureChrome(document);
|
|
1863
1869
|
try {
|
|
1864
1870
|
const all = Array.from(document.querySelectorAll('[data-dc-screen]'));
|
|
1865
1871
|
const targets = wanted?.length
|
|
@@ -1905,6 +1911,7 @@ function useExportCaptureBridge() {
|
|
|
1905
1911
|
message: err instanceof Error ? err.message : String(err),
|
|
1906
1912
|
});
|
|
1907
1913
|
} finally {
|
|
1914
|
+
releaseChrome();
|
|
1908
1915
|
restoreWorldPlane(savedWorld);
|
|
1909
1916
|
busy = false;
|
|
1910
1917
|
}
|
|
@@ -12,6 +12,11 @@ import { createRoot } from 'react-dom/client';
|
|
|
12
12
|
// import that Bun erases), so this pulls only string constants into the client
|
|
13
13
|
// bundle — no React, no input-router. See the tool-cursor handler below.
|
|
14
14
|
import { resolveToolCursor } from '../canvas-cursors.ts';
|
|
15
|
+
import {
|
|
16
|
+
defaultScopeForFormat,
|
|
17
|
+
isScopeValidForFormat,
|
|
18
|
+
validScopesForFormat,
|
|
19
|
+
} from '../exporters/format-scopes.ts';
|
|
15
20
|
// feature-3-web-artboards T5 — the single source (grid-track-handles.ts) for
|
|
16
21
|
// the Inspector's Grid-section track parser/serializer, shared with the
|
|
17
22
|
// on-canvas gutter-drag overlay so both edit the SAME track-list shape.
|
|
@@ -35,6 +40,7 @@ import {
|
|
|
35
40
|
captureDeckViaBrowser,
|
|
36
41
|
captureScale,
|
|
37
42
|
sanitizeCapturedItems,
|
|
43
|
+
recordBrowserExport,
|
|
38
44
|
} from './export-lane.js';
|
|
39
45
|
import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
|
|
40
46
|
import { useTreeDrag } from './use-tree-drag.js';
|
|
@@ -1175,18 +1181,6 @@ const EXPORT_SCOPE_LABELS = {
|
|
|
1175
1181
|
'canvas-as-separate': 'Canvas · artboards separate',
|
|
1176
1182
|
'project-raw': 'Whole project (raw)',
|
|
1177
1183
|
};
|
|
1178
|
-
const EXPORT_VALID_SCOPES = {
|
|
1179
|
-
png: ['selection', 'artboard', 'canvas-as-separate'],
|
|
1180
|
-
pdf: ['selection', 'artboard', 'canvas-as-separate'],
|
|
1181
|
-
svg: ['selection', 'artboard', 'canvas-as-separate'],
|
|
1182
|
-
html: ['artboard', 'canvas-as-separate'],
|
|
1183
|
-
pptx: ['canvas-as-separate'],
|
|
1184
|
-
mp4: ['artboard'],
|
|
1185
|
-
gif: ['artboard'],
|
|
1186
|
-
webm: ['artboard'],
|
|
1187
|
-
canva: ['canvas-as-separate'],
|
|
1188
|
-
zip: ['project-raw'],
|
|
1189
|
-
};
|
|
1190
1184
|
const PNG_SCALES = [
|
|
1191
1185
|
{ value: 1, label: '1× (native)' },
|
|
1192
1186
|
{ value: 2, label: '2× (retina)' },
|
|
@@ -1583,8 +1577,17 @@ function ExportDialog({
|
|
|
1583
1577
|
[exportLane]
|
|
1584
1578
|
);
|
|
1585
1579
|
const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : 'png');
|
|
1586
|
-
|
|
1587
|
-
|
|
1580
|
+
// DDR-231 Phase 2 T4 — seed from the shared table, and only honour an
|
|
1581
|
+
// incoming hint the CHOSEN FORMAT can actually render. A hint the format
|
|
1582
|
+
// can't take (a context menu's `project-raw`, a re-run of a zip history
|
|
1583
|
+
// entry) used to survive into the request and reach the render service as
|
|
1584
|
+
// an unrenderable file-tree target: "invalid render job".
|
|
1585
|
+
// The dialog opens on the PNG card; the format-change effect below re-seeds
|
|
1586
|
+
// the scope whenever the member picks another one.
|
|
1587
|
+
const [scope, setScope] = useState(() =>
|
|
1588
|
+
initialScope && isScopeValidForFormat('png', initialScope)
|
|
1589
|
+
? initialScope
|
|
1590
|
+
: defaultScopeForFormat('png')
|
|
1588
1591
|
);
|
|
1589
1592
|
const [scale, setScale] = useState(2);
|
|
1590
1593
|
// feature-2-print-artboards T4/T6 — PNG resolution (folds scale + dpi, see
|
|
@@ -1608,7 +1611,7 @@ function ExportDialog({
|
|
|
1608
1611
|
const [status, setStatus] = useState(null); // { ok, msg }
|
|
1609
1612
|
const [recent, setRecent] = useState([]);
|
|
1610
1613
|
const card = EXPORT_CARDS.find((c) => c.id === sel) || EXPORT_CARDS[0];
|
|
1611
|
-
const validScopes = card.handoff ? [] :
|
|
1614
|
+
const validScopes = card.handoff ? [] : validScopesForFormat(card.format);
|
|
1612
1615
|
// Long-comp tiers (feature-enhanced-video-editing Task 1). 900 frames is the
|
|
1613
1616
|
// safe tier (the frame-step fallback runs ~2.5 s/frame and 1080p captures
|
|
1614
1617
|
// have died from memory pressure above it); 3600 is the server's default cap
|
|
@@ -1629,10 +1632,13 @@ function ExportDialog({
|
|
|
1629
1632
|
const exportOverCap = card.temporal && exportCompFrames > EXPORT_DEFAULT_CAP;
|
|
1630
1633
|
|
|
1631
1634
|
// Keep the scope valid for the chosen format (pptx/zip etc. only allow a
|
|
1632
|
-
// subset)
|
|
1635
|
+
// subset). Both dialogs and the server now read one table
|
|
1636
|
+
// (exporters/format-scopes.ts) — see DDR-231 Phase 2 T4.
|
|
1633
1637
|
useEffect(() => {
|
|
1634
|
-
if (validScopes.length && !validScopes.includes(scope))
|
|
1635
|
-
|
|
1638
|
+
if (validScopes.length && !validScopes.includes(scope)) {
|
|
1639
|
+
setScope(defaultScopeForFormat(card.format));
|
|
1640
|
+
}
|
|
1641
|
+
}, [validScopes, scope, card.format]);
|
|
1636
1642
|
|
|
1637
1643
|
// DDR-231 T7 — wake the render service the moment the dialog opens on a
|
|
1638
1644
|
// remote-lane workspace: the multi-GB Chromium container starts booting
|
|
@@ -1715,17 +1721,31 @@ function ExportDialog({
|
|
|
1715
1721
|
// the in-canvas dialog's captureScopeHints.
|
|
1716
1722
|
if (activeArtboardId) options.artboardId = activeArtboardId;
|
|
1717
1723
|
if (selection?.selector) options.selection = selection;
|
|
1724
|
+
// Which canvas FILE this dialog is exporting — the server's `_active.json`
|
|
1725
|
+
// lags a tab switch, and a job resolved against the stale file renders the
|
|
1726
|
+
// wrong canvas (with this dialog's artboardId, which then never matches).
|
|
1727
|
+
if (activePath && activePath !== SYSTEM_TAB) options.canvasFile = activePath;
|
|
1718
1728
|
// DDR-231 — the browser lane: in a workspace, png/svg of the active
|
|
1719
1729
|
// artboard is captured by the member's OWN browser (the canvas already
|
|
1720
1730
|
// renders here) — instant, no fleet wake. Everything else continues to
|
|
1721
1731
|
// the jobs lane below.
|
|
1732
|
+
// NOTE the format gate. `browserCaptureEligible` answers for the whole
|
|
1733
|
+
// browser lane, pptx included — but this branch is the SINGLE-ARTBOARD
|
|
1734
|
+
// capture that downloads what the bridge returns verbatim. Without the
|
|
1735
|
+
// gate, a pptx export fell in here, asked the bridge for a "pptx" (which it
|
|
1736
|
+
// renders as PNG), and died in `sanitizeCapturedItems` on "not a valid
|
|
1737
|
+
// pptx" — silently degrading to the worker lane, which is why the deck
|
|
1738
|
+
// arrived minutes later with none of the browser lane's fixes in it. The
|
|
1739
|
+
// dedicated deck branch below was unreachable. Found by the T7 export E2E.
|
|
1722
1740
|
const browserEligible =
|
|
1741
|
+
BROWSER_CAPTURE_FORMATS.has(card.format) &&
|
|
1723
1742
|
browserCaptureEligible({
|
|
1724
1743
|
exportLane,
|
|
1725
1744
|
format: card.format,
|
|
1726
1745
|
scope,
|
|
1727
1746
|
artboardId: options.artboardId,
|
|
1728
|
-
}) &&
|
|
1747
|
+
}) &&
|
|
1748
|
+
typeof onBrowserCapture === 'function';
|
|
1729
1749
|
if (browserEligible) {
|
|
1730
1750
|
try {
|
|
1731
1751
|
const capScale = captureScale(options);
|
|
@@ -1742,8 +1762,18 @@ function ExportDialog({
|
|
|
1742
1762
|
// caps count/bytes, sniffs magic, forces name+MIME.
|
|
1743
1763
|
const safeItems = await sanitizeCapturedItems(items, card.format);
|
|
1744
1764
|
for (const it of safeItems) downloadCapturedBlob(it.name, it.blob);
|
|
1765
|
+
// DDR-231 Phase 2 T6 — the export is DONE and the member should see
|
|
1766
|
+
// that, in the dialog and in the ledger. Phase 1 closed the modal the
|
|
1767
|
+
// instant the download was handed off, so a browser-lane export left
|
|
1768
|
+
// no trace anywhere ("v exports dialog nic nevidim").
|
|
1769
|
+
for (const it of safeItems) {
|
|
1770
|
+
await recordBrowserExport({ format: card.format, scope, filename: it.name });
|
|
1771
|
+
}
|
|
1745
1772
|
setBusy(false);
|
|
1746
|
-
|
|
1773
|
+
setStatus({
|
|
1774
|
+
ok: true,
|
|
1775
|
+
msg: `Saved ${safeItems.map((it) => it.name).join(', ')} to your downloads.`,
|
|
1776
|
+
});
|
|
1747
1777
|
return;
|
|
1748
1778
|
} catch (err) {
|
|
1749
1779
|
if (exportLane !== 'remote') {
|
|
@@ -1773,10 +1803,17 @@ function ExportDialog({
|
|
|
1773
1803
|
name: deckName,
|
|
1774
1804
|
onProgress: (current, total) =>
|
|
1775
1805
|
setStatus({ ok: true, msg: `Capturing ${current}/${total}…` }),
|
|
1806
|
+
// Composition happens in-cell AFTER the last capture, and it is not
|
|
1807
|
+
// instant for a 10-slide deck. Without this the status sat on
|
|
1808
|
+
// "Capturing 10/10…" through the whole assemble step and the deck
|
|
1809
|
+
// then appeared out of nowhere — the reported "modal closes, nothing
|
|
1810
|
+
// happens, and after a while it downloads by itself".
|
|
1811
|
+
onAssemble: () => setStatus({ ok: true, msg: 'Assembling deck…' }),
|
|
1776
1812
|
});
|
|
1777
1813
|
downloadCapturedBlob(filename, blob);
|
|
1814
|
+
await recordBrowserExport({ format: card.format, scope, filename });
|
|
1778
1815
|
setBusy(false);
|
|
1779
|
-
|
|
1816
|
+
setStatus({ ok: true, msg: `Saved ${filename} to your downloads.` });
|
|
1780
1817
|
return;
|
|
1781
1818
|
} catch (err) {
|
|
1782
1819
|
if (exportLane !== 'remote') {
|
|
@@ -1844,6 +1881,10 @@ function ExportDialog({
|
|
|
1844
1881
|
<button
|
|
1845
1882
|
type="button"
|
|
1846
1883
|
key={c.id}
|
|
1884
|
+
// Stable hooks for the export E2E harnesses (agent-browser web
|
|
1885
|
+
// + desktop-e2e native) — data-testid convention, DDR-231
|
|
1886
|
+
// Phase 2 T7/T8.
|
|
1887
|
+
data-testid={`export-format-${c.id}`}
|
|
1847
1888
|
className={'st-fmt' + (c.id === sel ? ' is-on' : '')}
|
|
1848
1889
|
disabled={laneBlocked(c)}
|
|
1849
1890
|
title={
|
|
@@ -1878,6 +1919,7 @@ function ExportDialog({
|
|
|
1878
1919
|
</label>
|
|
1879
1920
|
<select
|
|
1880
1921
|
id="st-export-scope"
|
|
1922
|
+
data-testid="export-scope"
|
|
1881
1923
|
className="st-select"
|
|
1882
1924
|
value={scope}
|
|
1883
1925
|
onChange={(e) => setScope(e.target.value)}
|
|
@@ -2060,12 +2102,14 @@ function ExportDialog({
|
|
|
2060
2102
|
<div
|
|
2061
2103
|
className={'callout ' + (status.ok ? 'callout--success' : 'callout--error')}
|
|
2062
2104
|
style={{ fontSize: 12 }}
|
|
2105
|
+
data-testid="export-status"
|
|
2106
|
+
data-ok={status.ok ? '1' : '0'}
|
|
2063
2107
|
>
|
|
2064
2108
|
{status.msg}
|
|
2065
2109
|
</div>
|
|
2066
2110
|
)}
|
|
2067
2111
|
{recent.length > 0 && (
|
|
2068
|
-
<div className="st-export-recent">
|
|
2112
|
+
<div className="st-export-recent" data-testid="export-recent">
|
|
2069
2113
|
<div className="st-rp-hd">Recent</div>
|
|
2070
2114
|
{recent.map((h, i) => (
|
|
2071
2115
|
<div className="st-export-recent-row" key={i}>
|
|
@@ -2083,7 +2127,13 @@ function ExportDialog({
|
|
|
2083
2127
|
<button type="button" className="btn btn--ghost" onClick={onClose}>
|
|
2084
2128
|
Cancel
|
|
2085
2129
|
</button>
|
|
2086
|
-
<button
|
|
2130
|
+
<button
|
|
2131
|
+
type="button"
|
|
2132
|
+
className="btn btn--primary"
|
|
2133
|
+
data-testid="export-submit"
|
|
2134
|
+
disabled={busy}
|
|
2135
|
+
onClick={doExport}
|
|
2136
|
+
>
|
|
2087
2137
|
<StIcon name="download" size={14} />
|
|
2088
2138
|
{card.handoff
|
|
2089
2139
|
? 'Copy handoff command'
|
|
@@ -13183,6 +13233,17 @@ function App() {
|
|
|
13183
13233
|
// Reply target for the export bridge: the canvas iframe's own origin.
|
|
13184
13234
|
const replyOrigin = cfg?.canvasOrigin || window.location.origin;
|
|
13185
13235
|
async function runBridgedExport(source, id, payload) {
|
|
13236
|
+
// The in-canvas dialog exports the canvas it LIVES IN — which the M1
|
|
13237
|
+
// gate above just proved is `activePath`. Stamp it so scope resolution
|
|
13238
|
+
// never falls back to the server's (asynchronously written, possibly
|
|
13239
|
+
// stale) `_active.json` — same rule as the shell dialog's canvasFile.
|
|
13240
|
+
// OVERWRITE, not fill-if-absent: the M1 gate above proved the sender IS
|
|
13241
|
+
// the active canvas iframe (untrusted, DDR-054), so a canvasFile IT
|
|
13242
|
+
// supplied must not survive — the bridged export renders the canvas it
|
|
13243
|
+
// lives in, full stop (security review W2).
|
|
13244
|
+
if (payload?.options && activePath && activePath !== SYSTEM_TAB) {
|
|
13245
|
+
payload.options.canvasFile = activePath;
|
|
13246
|
+
}
|
|
13186
13247
|
const reply = (msg) => {
|
|
13187
13248
|
try {
|
|
13188
13249
|
if (source) source.postMessage({ dgn: 'export-result', id, ...msg }, replyOrigin);
|
|
@@ -42,10 +42,17 @@ export function browserCaptureEligible({ exportLane, format, scope, artboardId }
|
|
|
42
42
|
* 3× mirrors the desktop PNG-deck fallback's FALLBACK_SCALE (crisp in
|
|
43
43
|
* non-vector viewers).
|
|
44
44
|
*
|
|
45
|
-
* @param {{ capture: Function, name?: string, scale?: number, onProgress?: Function
|
|
45
|
+
* @param {{ capture: Function, name?: string, scale?: number, onProgress?: Function,
|
|
46
|
+
* onAssemble?: Function }} args
|
|
46
47
|
* @returns {Promise<{ filename: string, blob: Blob }>}
|
|
47
48
|
*/
|
|
48
|
-
export async function captureDeckViaBrowser({
|
|
49
|
+
export async function captureDeckViaBrowser({
|
|
50
|
+
capture,
|
|
51
|
+
name = 'export',
|
|
52
|
+
scale = 3,
|
|
53
|
+
onProgress,
|
|
54
|
+
onAssemble,
|
|
55
|
+
}) {
|
|
49
56
|
const items = await capture({
|
|
50
57
|
format: 'png',
|
|
51
58
|
artboardIds: null,
|
|
@@ -59,6 +66,9 @@ export async function captureDeckViaBrowser({ capture, name = 'export', scale =
|
|
|
59
66
|
fd.set('scale', String(scale));
|
|
60
67
|
fd.set('name', name);
|
|
61
68
|
for (const it of items) fd.append('image', it.blob, it.name);
|
|
69
|
+
// Composition is a distinct, non-instant phase — tell the caller so the
|
|
70
|
+
// status stops claiming the capture is still running.
|
|
71
|
+
onAssemble?.();
|
|
62
72
|
const r = await fetch('/_api/export-assemble', { method: 'POST', body: fd });
|
|
63
73
|
if (!r.ok) throw new Error((await r.text()) || `deck assembly failed (${r.status})`);
|
|
64
74
|
const disp = r.headers.get('content-disposition') || '';
|
|
@@ -154,3 +164,28 @@ export async function sanitizeCapturedItems(items, format) {
|
|
|
154
164
|
export function captureScale(options = {}) {
|
|
155
165
|
return Math.max(1, Math.min(8, options.dpi ? options.dpi / 96 : options.scale || 1));
|
|
156
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Record a browser-lane export in the cell's ledger so it shows up in the
|
|
170
|
+
* Recent tab and the notification center like every other export.
|
|
171
|
+
*
|
|
172
|
+
* DDR-231 Phase 2 T6: the browser lane produces the file in the member's own
|
|
173
|
+
* browser, so there is no job to complete and nothing wrote a history row —
|
|
174
|
+
* the reported symptom was "the modal just closes and I see nothing in the
|
|
175
|
+
* export dialog". Best-effort by design: the member already HAS the file, so a
|
|
176
|
+
* failed ledger write must never surface as a failed export.
|
|
177
|
+
*
|
|
178
|
+
* @param {{ format: string, scope: string, filename: string }} entry
|
|
179
|
+
* @returns {Promise<void>}
|
|
180
|
+
*/
|
|
181
|
+
export async function recordBrowserExport(entry) {
|
|
182
|
+
try {
|
|
183
|
+
await fetch('/_api/export-history', {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'content-type': 'application/json' },
|
|
186
|
+
body: JSON.stringify(entry),
|
|
187
|
+
});
|
|
188
|
+
} catch {
|
|
189
|
+
/* the file is already saved — the ledger is a convenience, not the export */
|
|
190
|
+
}
|
|
191
|
+
}
|