@1agh/maude 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/acp/bridge.ts +144 -5
  3. package/apps/studio/acp/index.ts +11 -1
  4. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  5. package/apps/studio/annotations-layer.tsx +253 -3
  6. package/apps/studio/annotations-model.ts +107 -6
  7. package/apps/studio/api.ts +812 -64
  8. package/apps/studio/bin/_html-playwright.mjs +9 -1
  9. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  10. package/apps/studio/bin/_png-playwright.mjs +8 -1
  11. package/apps/studio/bin/_pw-launch.mjs +54 -0
  12. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  13. package/apps/studio/bin/_video-playwright.mjs +452 -0
  14. package/apps/studio/bin/prep.sh +8 -1
  15. package/apps/studio/canvas-edit.ts +1885 -104
  16. package/apps/studio/canvas-lib.tsx +19 -0
  17. package/apps/studio/canvas-list-watch.ts +6 -2
  18. package/apps/studio/canvas-shell.tsx +27 -0
  19. package/apps/studio/client/app.jsx +1029 -30
  20. package/apps/studio/client/github.js +7 -0
  21. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  22. package/apps/studio/client/panels/timeline-parse.js +229 -0
  23. package/apps/studio/client/panels/timeline-snap.js +55 -0
  24. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  25. package/apps/studio/config.schema.json +14 -0
  26. package/apps/studio/context-menu.tsx +1 -1
  27. package/apps/studio/context.ts +113 -1
  28. package/apps/studio/dist/client.bundle.js +88 -16
  29. package/apps/studio/dist/comment-mount.js +1 -1
  30. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  31. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  32. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  38. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  39. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  40. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  41. package/apps/studio/dist/runtime/remotion.js +42 -0
  42. package/apps/studio/dist/styles.css +1 -1
  43. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  44. package/apps/studio/exporters/_runtime.ts +69 -0
  45. package/apps/studio/exporters/html.ts +4 -3
  46. package/apps/studio/exporters/index.ts +28 -2
  47. package/apps/studio/exporters/pdf.ts +4 -3
  48. package/apps/studio/exporters/png.ts +5 -3
  49. package/apps/studio/exporters/pptx.ts +6 -4
  50. package/apps/studio/exporters/svg.ts +9 -5
  51. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  52. package/apps/studio/exporters/video-render-lib.ts +108 -0
  53. package/apps/studio/exporters/video.ts +184 -0
  54. package/apps/studio/http.ts +535 -27
  55. package/apps/studio/input-router.tsx +7 -1
  56. package/apps/studio/runtime-bundle.ts +30 -0
  57. package/apps/studio/server.ts +34 -9
  58. package/apps/studio/test/acp-bridge.test.ts +132 -0
  59. package/apps/studio/test/acp-commands.test.ts +8 -3
  60. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  62. package/apps/studio/test/canvas-edit.test.ts +90 -0
  63. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  64. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  65. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  66. package/apps/studio/test/clip-addressing.test.ts +732 -0
  67. package/apps/studio/test/config-reload.test.ts +230 -0
  68. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  69. package/apps/studio/test/edit-persistence.test.ts +91 -0
  70. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  71. package/apps/studio/test/file-lock.test.ts +84 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
  73. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  74. package/apps/studio/test/timeline-parse.test.ts +127 -0
  75. package/apps/studio/test/timeline-snap.test.ts +85 -0
  76. package/apps/studio/test/video-asset.test.ts +163 -0
  77. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  78. package/apps/studio/test/video-comp.test.ts +168 -0
  79. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  80. package/apps/studio/use-annotation-resize.tsx +6 -3
  81. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  82. package/apps/studio/video-comp.tsx +444 -0
  83. package/apps/studio/whats-new.json +36 -0
  84. package/apps/studio/ws.ts +5 -0
  85. package/package.json +8 -8
  86. package/plugins/design/templates/_shell.html +25 -2
  87. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -45,7 +45,15 @@ const browser = await launchChromium();
45
45
  try {
46
46
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
47
47
  const page = await ctx.newPage();
48
- await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
48
+ await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
49
+ await page.evaluate(() => document.fonts.ready);
50
+ // `load` fires before React mounts (and a video comp never reaches
51
+ // `networkidle`), so gate on the artboard rendering before we query it —
52
+ // otherwise a multi `.all()` can run against an empty page.
53
+ await page
54
+ .locator(selector ?? '[data-dc-screen]')
55
+ .first()
56
+ .waitFor({ state: 'visible', timeout: timeoutMs });
49
57
  // Reset the world plane's CSS zoom + transform so the captured artboard
50
58
  // outerHTML carries 1440×900 dimensions instead of the pan-zoomed thumb.
51
59
  await page.evaluate(() => {
@@ -43,13 +43,20 @@ const browser = await launchChromium();
43
43
  try {
44
44
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
45
45
  const page = await ctx.newPage();
46
- await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
46
+ await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
47
47
  // Wait for web fonts so `@font-face` glyphs land in the PDF instead of
48
48
  // fallback Latin (puppeteer #3183 / playwright equivalent).
49
49
  await page.evaluate(() => document.fonts.ready);
50
50
  // Print-media emulation — Chromium's PDF output otherwise applies *screen*
51
51
  // CSS and ignores `@media print` rules entirely.
52
52
  await page.emulateMedia({ media: 'print' });
53
+ // `load` fires before React mounts (and a video comp never reaches
54
+ // `networkidle`), so gate on the artboard rendering before we query it —
55
+ // otherwise a multi `.all()` can run against an empty page.
56
+ await page
57
+ .locator(selector ?? '[data-dc-screen]')
58
+ .first()
59
+ .waitFor({ state: 'visible', timeout: timeoutMs });
53
60
 
54
61
  const written = [];
55
62
  const screens = multi
@@ -46,8 +46,15 @@ try {
46
46
  deviceScaleFactor,
47
47
  });
48
48
  const page = await ctx.newPage();
49
- await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
49
+ await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
50
50
  await page.evaluate(() => document.fonts.ready);
51
+ // `load` fires before React mounts (and a video comp never reaches
52
+ // `networkidle`), so gate on the artboard element actually rendering before we
53
+ // query/capture — otherwise a multi `.all()` can run against an empty page.
54
+ await page
55
+ .locator(selector ?? '[data-dc-screen]')
56
+ .first()
57
+ .waitFor({ state: 'visible', timeout: timeoutMs });
51
58
 
52
59
  const written = [];
53
60
 
@@ -14,6 +14,10 @@
14
14
  //
15
15
  // See `.ai/logs/rca/issue-nefunguji-exporty.md`.
16
16
 
17
+ import { execFileSync } from 'node:child_process';
18
+ import { existsSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
17
21
  import { chromium } from 'playwright';
18
22
 
19
23
  /** Exit code the shims surface for "Chromium browser binary not installed". */
@@ -23,6 +27,43 @@ export const NO_BROWSER_EXIT = 3;
23
27
  export const INSTALL_HINT =
24
28
  "Playwright's Chromium browser isn't installed. Run: npx playwright install chromium";
25
29
 
30
+ /**
31
+ * Resolve a Chrome-family executable to fall back on when Playwright's OWN
32
+ * bundled browser is absent — the packaged desktop case (RCA
33
+ * issue-desktop-export-failures): the app ships the `playwright` JS closure but
34
+ * NOT its ~150 MB browser, so `chromium.launch()` with no `executablePath` finds
35
+ * nothing. Priority: explicit env override → the shared `_ensure-browser.mjs`
36
+ * resolver (the same Chrome / provisioned chrome-headless-shell the screenshot
37
+ * path uses; `--no-download` so an export never silently triggers a 94 MB
38
+ * fetch). Returns `undefined` when nothing is found — the caller then surfaces
39
+ * the install hint. Reachable at runtime because the shim runs under real `node`
40
+ * (its `import.meta.url` is a genuine disk path, unlike the compiled server).
41
+ */
42
+ function resolveBrowserExecutable() {
43
+ for (const env of [
44
+ process.env.MAUDE_BROWSER_EXECUTABLE,
45
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
46
+ process.env.AGENT_BROWSER_EXECUTABLE_PATH,
47
+ ]) {
48
+ if (env && existsSync(env)) return env;
49
+ }
50
+ try {
51
+ const ensure = join(dirname(fileURLToPath(import.meta.url)), '_ensure-browser.mjs');
52
+ if (!existsSync(ensure)) return undefined;
53
+ const out = execFileSync(process.execPath, [ensure, '--no-download', '--quiet'], {
54
+ encoding: 'utf8',
55
+ });
56
+ const path = out
57
+ .split('\n')
58
+ .map((s) => s.trim())
59
+ .filter(Boolean)
60
+ .pop();
61
+ return path && existsSync(path) ? path : undefined;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
26
67
  /**
27
68
  * True when a `chromium.launch()` rejection is the "browser binary not
28
69
  * downloaded" case (as opposed to a sandbox / port / crash failure). Pure +
@@ -51,6 +92,19 @@ export async function launchChromium(opts) {
51
92
  } catch (err) {
52
93
  const msg = err instanceof Error ? err.message : String(err);
53
94
  if (isMissingBrowserError(msg)) {
95
+ // Playwright's own browser isn't present (packaged desktop app, or a fresh
96
+ // machine that never ran `playwright install`). Retry once against a
97
+ // resolved Chrome-family engine — system Chrome / a provisioned
98
+ // chrome-headless-shell — before giving up. This is what makes render
99
+ // export work in the bundled app, where only the playwright JS ships.
100
+ const executablePath = resolveBrowserExecutable();
101
+ if (executablePath) {
102
+ try {
103
+ return await chromium.launch({ ...opts, executablePath });
104
+ } catch {
105
+ /* fall through to the install hint */
106
+ }
107
+ }
54
108
  // Single clean line — the export endpoint pipes shim stderr into the 500
55
109
  // body, so this is what the user reads. No stack trace.
56
110
  console.error(INSTALL_HINT);
@@ -53,8 +53,15 @@ const browser = await launchChromium();
53
53
  try {
54
54
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
55
55
  const page = await ctx.newPage();
56
- await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
56
+ await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
57
57
  await page.evaluate(() => document.fonts.ready);
58
+ // `load` fires before React mounts (and a video comp never reaches
59
+ // `networkidle`), so gate on the artboard rendering before we query it —
60
+ // otherwise a multi `.all()` can run against an empty page.
61
+ await page
62
+ .locator(selector ?? '[data-dc-screen]')
63
+ .first()
64
+ .waitFor({ state: 'visible', timeout: timeoutMs });
58
65
  // Reset the world plane's CSS zoom + transform so artboards render at
59
66
  // declared dimensions before dom-to-svg walks the layout.
60
67
  await page.evaluate(() => {
@@ -0,0 +1,452 @@
1
+ // _video-playwright.mjs — DDR-148 deterministic video/animation capture shim.
2
+ //
3
+ // The export counterpart to _png-playwright.mjs. Two capture strategies:
4
+ //
5
+ // 1. WHOLE-COMP RENDER (DDR-148 addendum, audio export) — mp4/webm export of
6
+ // an artboard with a registered VideoComp goes through
7
+ // window.__maude_render_video__ (video-comp.tsx bridge → the injected
8
+ // render-lib's renderMediaOnWeb). Remotion renders video+audio in ONE
9
+ // pass, owning the TransitionSeries offset math and <Audio volume={fn}>
10
+ // closures internally — no hand-rolled frame loop, no separate mixing.
11
+ // 2. FRAME-STEP CAPTURE (original DDR-148 spine) — everything else: gif
12
+ // (no audio, no renderMediaOnWeb container support), ordinary
13
+ // (non-comp/CSS-WAAPI) artboards, and the fallback when no VideoComp is
14
+ // registered on the target artboard. Steps the artboard frame-by-frame
15
+ // via window.__maude_seek__, screenshots each frame, encodes in-page via
16
+ // mediabunny/gifenc (video-encode-lib.ts). A video-comp is deterministic
17
+ // BY CONSTRUCTION (Remotion's useCurrentFrame + the seek bridge), so two
18
+ // capture runs to frame N are pixel-identical.
19
+ //
20
+ // Seek model (path 2 only):
21
+ // • video-comp artboard → window.__maude_seek__(frame) (Player frame prop).
22
+ // • ordinary artboard → time-based: document.getAnimations() currentTime +
23
+ // every <video>.currentTime, awaiting `seeked`. Selected by --mode.
24
+
25
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ import { launchChromium } from './_pw-launch.mjs';
29
+
30
+ const args = Object.fromEntries(
31
+ process.argv.slice(2).reduce((acc, cur, i, all) => {
32
+ if (cur.startsWith('--')) acc.push([cur.slice(2), all[i + 1] ?? '1']);
33
+ return acc;
34
+ }, [])
35
+ );
36
+
37
+ const {
38
+ url,
39
+ artboard, // data-dc-screen id to capture (default: first artboard)
40
+ selector: selectorArg, // explicit CSS selector (overrides --artboard); from the export target
41
+ fps: fpsArg,
42
+ frames: framesArg,
43
+ 'dump-frames': dumpDir,
44
+ 'encode-lib': encodeLib, // path to the bundled in-page frame encoder (mediabunny/gifenc)
45
+ 'render-lib': renderLib, // path to the bundled in-page whole-comp renderer (renderMediaOnWeb)
46
+ format, // 'mp4' | 'webm' | 'gif' — enables the encode sink (with --out)
47
+ out, // encoded file destination
48
+ mode = 'comp', // 'comp' | 'ordinary'
49
+ audio, // '1' | '0' — include audio in the render-lib path (default on; presence-checked below)
50
+ 'license-key': licenseKeyArg,
51
+ timeout = '60',
52
+ scale = '1',
53
+ } = args;
54
+
55
+ if (!url) {
56
+ console.error(
57
+ 'usage: _video-playwright.mjs --url <shell-url> [--artboard <id>] [--fps N] [--frames N] --dump-frames <dir>'
58
+ );
59
+ process.exit(2);
60
+ }
61
+
62
+ const timeoutMs = Number(timeout) * 1000;
63
+ const deviceScaleFactor = Math.max(1, Math.min(4, Number(scale) || 1));
64
+ // `--audio` is presence-checked (Object.fromEntries maps a bare flag to '1'), so
65
+ // absence of the flag entirely (not passed) still defaults to audio ON — video.ts
66
+ // always passes an explicit '0'/'1'.
67
+ const wantAudio = audio !== '0';
68
+ const licenseKey = licenseKeyArg || 'free-license';
69
+
70
+ /** Wait a real turn of the event loop + a frame so a seek settles before shot. */
71
+ const SETTLE_MS = 16;
72
+
73
+ const browser = await launchChromium();
74
+ try {
75
+ const ctx = await browser.newContext({
76
+ viewport: { width: 1440, height: 900 },
77
+ deviceScaleFactor,
78
+ });
79
+ const page = await ctx.newPage();
80
+ await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
81
+ await page.evaluate(() => document.fonts.ready);
82
+
83
+ // Resolve the target artboard handle. Priority: explicit --selector (from the
84
+ // export scope Target) → --artboard id → first artboard. `--widen` climbs to
85
+ // the enclosing [data-dc-screen] (the descendant-selector export case).
86
+ const widen = args.widen !== undefined;
87
+ const selector = selectorArg
88
+ ? selectorArg
89
+ : artboard
90
+ ? `[data-dc-screen="${cssEscape(artboard)}"]`
91
+ : '[data-dc-screen]';
92
+ const located = page.locator(selector).first();
93
+ await located.waitFor({ state: 'visible', timeout: timeoutMs });
94
+ const handle = widen
95
+ ? await located.evaluateHandle((el) => el.closest('[data-dc-screen]') ?? el)
96
+ : located;
97
+
98
+ // DDR-041 world-plane reset + pin the target artboard to (0,0) — needed by
99
+ // both capture strategies: the frame-step path screenshots this rect; the
100
+ // whole-comp path still benefits from a settled, non-panned layout when it
101
+ // reads the comp's registered width/height (defensive, cheap).
102
+ await page.evaluate(() => {
103
+ const world = document.querySelector('.dc-world');
104
+ if (world) {
105
+ world.style.zoom = '1';
106
+ world.style.transform = 'none';
107
+ }
108
+ });
109
+ await handle.evaluate((el) => {
110
+ const ab = el.closest('[data-dc-screen]') ?? el;
111
+ ab.style.left = '0px';
112
+ ab.style.top = '0px';
113
+ });
114
+
115
+ // Resolve the compId of the nearest VideoComp mounted inside the target
116
+ // artboard (the artboard's `data-dc-screen` id and the comp's own `id` prop
117
+ // are independent — an author rarely sets the latter — so we resolve by DOM
118
+ // containment). Null for an ordinary/CSS artboard with no registered comp.
119
+ const compId = await handle.evaluate(
120
+ (el) => el.querySelector('[data-comp-id]')?.getAttribute('data-comp-id') ?? null
121
+ );
122
+
123
+ // Whole-comp render path: mp4/webm export of a registered video-comp. GIF has
124
+ // no renderMediaOnWeb container support (and no audio need); an artboard with
125
+ // no registered comp (ordinary/CSS) has nothing for renderMediaOnWeb to render
126
+ // — both fall through to the frame-step path below.
127
+ const useRenderer = !!renderLib && format !== 'gif' && !!compId;
128
+
129
+ if (useRenderer) {
130
+ const t0 = Date.now();
131
+ const libSrc = readFileSync(renderLib, 'utf8');
132
+ await page.addScriptTag({ content: libSrc, type: 'module' });
133
+ await page.waitForFunction(() => typeof window.__maudeRenderVideo__ === 'function', {
134
+ timeout: timeoutMs,
135
+ });
136
+
137
+ const explicitFrames = Number(framesArg);
138
+ const frameRange =
139
+ Number.isFinite(explicitFrames) && explicitFrames > 0 ? [0, explicitFrames - 1] : null;
140
+
141
+ const rendered = await page.evaluate(
142
+ async ({ compId, container, scale, muted, frameRange, licenseKey }) => {
143
+ return window.__maude_render_video__(compId, {
144
+ container,
145
+ scale,
146
+ muted,
147
+ frameRange,
148
+ licenseKey,
149
+ });
150
+ },
151
+ {
152
+ compId,
153
+ container: format,
154
+ scale: deviceScaleFactor,
155
+ muted: !wantAudio,
156
+ frameRange,
157
+ licenseKey,
158
+ }
159
+ );
160
+
161
+ writeFileSync(out, Buffer.from(rendered.b64, 'base64'));
162
+
163
+ // Comp meta for the summary (diagnostic only — video.ts reads `container`).
164
+ const compMeta = await page.evaluate(
165
+ (id) =>
166
+ (typeof window.__maude_comps__ === 'function' ? window.__maude_comps__() : []).find(
167
+ (c) => c.id === id
168
+ ) ?? null,
169
+ compId
170
+ );
171
+ const frameCount = frameRange
172
+ ? frameRange[1] - frameRange[0] + 1
173
+ : (compMeta?.durationInFrames ?? 0);
174
+
175
+ const result = {
176
+ fps: compMeta?.fps ?? null,
177
+ frameCount,
178
+ width: Math.round((compMeta?.width ?? 0) * deviceScaleFactor),
179
+ height: Math.round((compMeta?.height ?? 0) * deviceScaleFactor),
180
+ out,
181
+ bytes: rendered.bytes,
182
+ container: rendered.container,
183
+ codec: rendered.videoCodec,
184
+ audioCodec: rendered.audioCodec,
185
+ };
186
+ const ms = Date.now() - t0;
187
+ console.error(
188
+ `✓ rendered ${rendered.container}/${rendered.videoCodec}` +
189
+ `${rendered.audioCodec ? `+${rendered.audioCodec}` : ' (muted)'} → ${out} (${rendered.bytes} B) in ${ms}ms`
190
+ );
191
+ console.log(JSON.stringify(result));
192
+ } else {
193
+ await frameStepCapture({
194
+ page,
195
+ handle,
196
+ compId,
197
+ fpsArg,
198
+ framesArg,
199
+ dumpDir,
200
+ encodeLib,
201
+ format,
202
+ out,
203
+ mode,
204
+ deviceScaleFactor,
205
+ timeoutMs,
206
+ });
207
+ }
208
+ } finally {
209
+ await browser.close();
210
+ }
211
+
212
+ /**
213
+ * Original DDR-148 frame-step + mediabunny/gifenc capture (gif, ordinary
214
+ * artboards, and the no-registered-comp fallback). Unchanged behavior from the
215
+ * pre-addendum shim.
216
+ */
217
+ async function frameStepCapture({
218
+ page,
219
+ handle,
220
+ compId,
221
+ fpsArg,
222
+ framesArg,
223
+ dumpDir,
224
+ encodeLib,
225
+ format,
226
+ out,
227
+ mode,
228
+ deviceScaleFactor,
229
+ timeoutMs,
230
+ }) {
231
+ const rect0 = await handle.evaluate((el) => {
232
+ const r = el.getBoundingClientRect();
233
+ return { width: r.width, height: r.height };
234
+ });
235
+ await page.setViewportSize({
236
+ width: Math.max(1, Math.ceil(rect0.width)),
237
+ height: Math.max(1, Math.ceil(rect0.height)),
238
+ });
239
+ await handle.evaluate((el) => {
240
+ el.scrollIntoView({ block: 'start', inline: 'start' });
241
+ window.scrollTo(0, 0);
242
+ });
243
+ const clip = await handle.evaluate((el) => {
244
+ const r = el.getBoundingClientRect();
245
+ return {
246
+ x: Math.max(0, Math.floor(r.left)),
247
+ y: Math.max(0, Math.floor(r.top)),
248
+ width: Math.max(1, Math.ceil(r.width)),
249
+ height: Math.max(1, Math.ceil(r.height)),
250
+ };
251
+ });
252
+
253
+ // Resolve fps + frame count. Prefer explicit args (the exporter computes them
254
+ // from comp meta / options); fall back to the registered comp meta in-page —
255
+ // matched by the RESOLVED compId when the target has one (not just "the
256
+ // first comp on the page", which mis-hit on multi-comp canvases).
257
+ const compMeta = await page.evaluate((id) => {
258
+ try {
259
+ const comps = typeof window.__maude_comps__ === 'function' ? window.__maude_comps__() : [];
260
+ if (id) return comps.find((c) => c.id === id) ?? comps[0] ?? null;
261
+ return comps[0] ?? null;
262
+ } catch {
263
+ return null;
264
+ }
265
+ }, compId);
266
+ const fps = Number(fpsArg) || compMeta?.fps || 30;
267
+ // Cap at 900 frames (30 s @ 30 fps) so a runaway/huge comp can't spawn an
268
+ // unbounded screenshot loop — matches exporters/video.ts MAX_FRAMES.
269
+ const frameCount = Math.min(
270
+ 900,
271
+ Number(framesArg) || compMeta?.durationInFrames || Math.round(fps * 3) // 3 s default
272
+ );
273
+ if (!Number.isFinite(fps) || fps <= 0 || !Number.isFinite(frameCount) || frameCount <= 0) {
274
+ console.error('_video-playwright: could not resolve fps/frameCount (no comp meta, no args)');
275
+ process.exit(2);
276
+ }
277
+
278
+ // For ordinary artboards, prime the <video> elements (await first seeked).
279
+ if (mode === 'ordinary') {
280
+ await page.evaluate(async () => {
281
+ const vids = Array.from(document.querySelectorAll('video'));
282
+ await Promise.all(
283
+ vids.map(
284
+ (v) =>
285
+ new Promise((res) => {
286
+ if (v.readyState >= 2) return res(undefined);
287
+ v.addEventListener('loadeddata', () => res(undefined), { once: true });
288
+ // safety timeout
289
+ setTimeout(() => res(undefined), 2000);
290
+ })
291
+ )
292
+ );
293
+ });
294
+ }
295
+
296
+ const dump = dumpDir ? (mkdirSync(dumpDir, { recursive: true }), dumpDir) : null;
297
+ const framePaths = [];
298
+ const t0 = Date.now();
299
+
300
+ // Output resolution = the artboard's CSS clip × deviceScaleFactor. `clip` is in
301
+ // CSS px, but `page.screenshot` emits deviceScaleFactor× device pixels, so the
302
+ // encoder canvas must match the SCREENSHOT's real pixel size — otherwise every
303
+ // frame is drawn down to the native artboard size (the "tiny resolution" bug:
304
+ // scale was accepted but never applied to the encode). scale=2 → 960×540 comp
305
+ // exports at 1920×1080. Even dims (H.264 requires width/height divisible by 2).
306
+ const outW = Math.max(2, Math.round((clip.width * deviceScaleFactor) / 2) * 2);
307
+ const outH = Math.max(2, Math.round((clip.height * deviceScaleFactor) / 2) * 2);
308
+
309
+ // Encode sink (Task 6) — inject the in-page mediabunny/gifenc lib and start it.
310
+ const encoding = !!(encodeLib && format && out);
311
+ const isGif = format === 'gif';
312
+ if (encoding) {
313
+ const libSrc = readFileSync(encodeLib, 'utf8');
314
+ await page.addScriptTag({ content: libSrc, type: 'module' });
315
+ await page.waitForFunction(() => typeof window.__maudeEnc === 'object', { timeout: timeoutMs });
316
+ const started = await page.evaluate(
317
+ async ({ width, height, fps, isGif, gifColors, fmt }) => {
318
+ if (isGif) {
319
+ window.__maudeEnc.startGif({ width, height, fps, maxColors: gifColors });
320
+ return { container: 'gif', codec: 'gif' };
321
+ }
322
+ return window.__maudeEnc.startVideo({ width, height, fps, format: fmt });
323
+ },
324
+ {
325
+ width: outW,
326
+ height: outH,
327
+ fps,
328
+ isGif,
329
+ gifColors: Number(args.gifColors) || 256,
330
+ fmt: format,
331
+ }
332
+ );
333
+ console.error(`encoder: ${started.container} / ${started.codec} @ ${outW}×${outH}`);
334
+ }
335
+
336
+ for (let f = 0; f < frameCount; f += 1) {
337
+ await seekFrame(page, f, fps, mode);
338
+ await page.waitForTimeout(SETTLE_MS);
339
+ const shot = await page.screenshot({ clip });
340
+ if (dump) {
341
+ const p = join(dump, `frame-${String(f).padStart(5, '0')}.png`);
342
+ writeFileSync(p, shot);
343
+ framePaths.push(p);
344
+ }
345
+ if (encoding) {
346
+ const b64 = shot.toString('base64');
347
+ await page.evaluate(
348
+ async ({ b64, isGif }) => {
349
+ if (isGif) return window.__maudeEnc.addGifFrame(b64);
350
+ return window.__maudeEnc.addVideoFrame(b64);
351
+ },
352
+ { b64, isGif }
353
+ );
354
+ }
355
+ if (f % 30 === 0) console.error(`frame ${f + 1}/${frameCount}`);
356
+ }
357
+
358
+ let result = { fps, frameCount, width: outW, height: outH, framePaths };
359
+ if (encoding) {
360
+ const enc = await page.evaluate(
361
+ async ({ isGif }) =>
362
+ isGif ? window.__maudeEnc.finishGif() : window.__maudeEnc.finishVideo(),
363
+ { isGif }
364
+ );
365
+ writeFileSync(out, Buffer.from(enc.b64, 'base64'));
366
+ result = { ...result, out, bytes: enc.bytes, container: enc.container, codec: enc.codec };
367
+ console.error(`✓ encoded ${enc.container}/${enc.codec} → ${out} (${enc.bytes} B)`);
368
+ }
369
+
370
+ const ms = Date.now() - t0;
371
+ console.error(
372
+ `✓ captured ${frameCount} frames @ ${fps}fps (${clip.width}×${clip.height}) in ${ms}ms`
373
+ );
374
+ // stdout = machine-readable summary for the exporter.
375
+ console.log(JSON.stringify(result));
376
+ }
377
+
378
+ /** Seek one frame via the right bridge. Resolves after the frame has painted. */
379
+ async function seekFrame(page, frame, fps, mode) {
380
+ if (mode === 'ordinary') {
381
+ await page.evaluate(
382
+ async ({ frame, fps }) => {
383
+ const ms = (frame / fps) * 1000;
384
+ for (const a of document.getAnimations()) {
385
+ try {
386
+ a.pause();
387
+ a.currentTime = ms;
388
+ } catch {
389
+ /* unseekable */
390
+ }
391
+ }
392
+ const vids = Array.from(document.querySelectorAll('video'));
393
+ await Promise.all(
394
+ vids.map(
395
+ (v) =>
396
+ new Promise((res) => {
397
+ const target = ms / 1000;
398
+ if (Math.abs(v.currentTime - target) < 1e-3) return res(undefined);
399
+ v.addEventListener('seeked', () => res(undefined), { once: true });
400
+ v.currentTime = target;
401
+ setTimeout(() => res(undefined), 500);
402
+ })
403
+ )
404
+ );
405
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
406
+ },
407
+ { frame, fps }
408
+ );
409
+ return;
410
+ }
411
+ // comp mode — the seek bridge pauses + seeks the Player and resolves post-paint.
412
+ // A comp with classic remotion <Video>/<OffthreadVideo> renders a real <video>
413
+ // whose seek is ASYNC — 2 rAF isn't enough, so wait for every video to land on
414
+ // its frame (`seeked` / readyState) before the screenshot, else a stale frame
415
+ // is caught. @remotion/media's <Video> decodes to a <canvas> (no <video> tag),
416
+ // and empirically settles within the same 2-rAF window this wait already ends
417
+ // on (verified across mid-clip + mid-transition frames — DDR-148 addendum).
418
+ await page.evaluate(async (frame) => {
419
+ if (typeof window.__maude_seek__ === 'function') {
420
+ await window.__maude_seek__(frame);
421
+ }
422
+ const vids = Array.from(document.querySelectorAll('video'));
423
+ if (vids.length) {
424
+ await Promise.all(
425
+ vids.map(
426
+ (v) =>
427
+ new Promise((res) => {
428
+ if (v.readyState >= 2 && !v.seeking) return res(undefined);
429
+ let done = false;
430
+ const finish = () => {
431
+ if (done) return;
432
+ done = true;
433
+ clearInterval(iv);
434
+ res(undefined);
435
+ };
436
+ const iv = setInterval(() => {
437
+ if (v.readyState >= 2 && !v.seeking) finish();
438
+ }, 16);
439
+ v.addEventListener('seeked', finish, { once: true });
440
+ setTimeout(finish, 1500); // never hang a frame
441
+ })
442
+ )
443
+ );
444
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
445
+ }
446
+ }, frame);
447
+ }
448
+
449
+ /** Minimal CSS.escape for the data-dc-screen attribute selector. */
450
+ function cssEscape(s) {
451
+ return String(s).replace(/["\\\]]/g, '\\$&');
452
+ }
@@ -77,6 +77,7 @@ DEFAULT_DS="project"
77
77
  KNOWN_DS=""
78
78
  ACCENT_STRATEGY="single"
79
79
  COLOR_SPACE="oklch"
80
+ MOODBOARD_VARIANTS="3"
80
81
 
81
82
  if [ "$CONFIG_PRESENT" = "true" ] && [ "$HAVE_JQ" = "1" ]; then
82
83
  NAME=$(jq -r '.name // "Project"' "$CFG")
@@ -91,6 +92,9 @@ if [ "$CONFIG_PRESENT" = "true" ] && [ "$HAVE_JQ" = "1" ]; then
91
92
  KNOWN_DS=$(jq -r '.designSystems[]?.name // empty' "$CFG" 2>/dev/null | tr '\n' ' ' | sed 's/ $//')
92
93
  ACCENT_STRATEGY=$(jq -r '.accentStrategy // "single"' "$CFG")
93
94
  COLOR_SPACE=$(jq -r '.colorSpace // "oklch"' "$CFG")
95
+ # Type-coerce + clamp in jq: config.json is untrusted input (a poisoned repo
96
+ # could put a string here) and --shell-export output gets eval'd by callers.
97
+ MOODBOARD_VARIANTS=$(jq -r '.moodboard.variants // 3 | if type=="number" then floor else 3 end | if . < 1 then 1 elif . > 3 then 3 else . end' "$CFG")
94
98
  fi
95
99
 
96
100
  DR_ABS="$REPO/$DESIGN_ROOT"
@@ -159,6 +163,7 @@ if [ "$MODE" = "shell" ]; then
159
163
  printf 'export KNOWN_DS=%q\n' "$KNOWN_DS"
160
164
  printf 'export ACCENT_STRATEGY=%q\n' "$ACCENT_STRATEGY"
161
165
  printf 'export COLOR_SPACE=%q\n' "$COLOR_SPACE"
166
+ printf 'export MOODBOARD_VARIANTS=%q\n' "$MOODBOARD_VARIANTS"
162
167
  printf 'export DEPS_OK=%s\n' "$DEPS_OK"
163
168
  printf 'export DEPS_MISSING=%q\n' "$DEPS_MISSING"
164
169
  printf 'export SERVER_UP=%s\n' "$SERVER_UP"
@@ -181,6 +186,7 @@ if [ "$HAVE_JQ" = "1" ]; then
181
186
  --arg tok "$TOKENS_REL" --arg ncd "$NEW_CANVAS_DIR" --arg ncm "$NEW_COMPONENT_DIR" \
182
187
  --arg acc "$TEAM_ACCENT" --arg dds "$DEFAULT_DS" --arg kds "$KNOWN_DS" \
183
188
  --arg astrat "$ACCENT_STRATEGY" --arg cspace "$COLOR_SPACE" \
189
+ --arg mbvar "$MOODBOARD_VARIANTS" \
184
190
  --arg depsok "$DEPS_OK" --arg depsmiss "$DEPS_MISSING" \
185
191
  --arg sup "$SERVER_UP" --arg sport "$SERVER_PORT" \
186
192
  --arg act "$ACTIVE_CANVAS" --arg selfile "$SELECTED_FILE" \
@@ -194,7 +200,8 @@ if [ "$HAVE_JQ" = "1" ]; then
194
200
  tokens_rel: $tok, new_canvas_dir: $ncd, new_component_dir: $ncm,
195
201
  team_accent: $acc, default_ds: $dds,
196
202
  known_ds: ($kds | if . == "" then [] else split(" ") end),
197
- accent_strategy: $astrat, color_space: $cspace
203
+ accent_strategy: $astrat, color_space: $cspace,
204
+ moodboard_variants: ($mbvar | tonumber? // 3)
198
205
  },
199
206
  active: {
200
207
  canvas: $act, selected: $sel, selected_file: $selfile,