@1agh/maude 0.39.1 → 0.41.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 (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -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
+ }