@1agh/maude 0.39.1 → 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.
- package/README.md +1 -1
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +253 -3
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +812 -64
- 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/prep.sh +8 -1
- package/apps/studio/canvas-edit.ts +1885 -104
- package/apps/studio/canvas-lib.tsx +19 -0
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +27 -0
- package/apps/studio/client/app.jsx +1029 -30
- package/apps/studio/client/github.js +7 -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 +107 -0
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +1 -1
- package/apps/studio/context.ts +113 -1
- package/apps/studio/dist/client.bundle.js +88 -16
- package/apps/studio/dist/comment-mount.js +1 -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/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/http.ts +535 -27
- package/apps/studio/input-router.tsx +7 -1
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +90 -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 +36 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -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/edit-persistence.test.ts +91 -0
- 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/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -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/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/ws.ts +5 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +25 -2
- 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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
+
}
|
package/apps/studio/bin/prep.sh
CHANGED
|
@@ -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,
|