@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.
- package/README.md +1 -1
- package/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +306 -5
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +1316 -67
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +2625 -111
- package/apps/studio/canvas-lib.tsx +238 -2
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +514 -20
- package/apps/studio/client/app.jsx +2480 -52
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +15 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +155 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +16 -6
- package/apps/studio/context.ts +113 -1
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +89 -17
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +758 -27
- package/apps/studio/input-router.tsx +19 -1
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +140 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +55 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
- package/plugins/design/templates/_shell.html +25 -2
- 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: '
|
|
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
|
+
}
|