@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.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  3. package/apps/studio/annotations-layer.tsx +253 -3
  4. package/apps/studio/annotations-model.ts +107 -6
  5. package/apps/studio/api.ts +812 -64
  6. package/apps/studio/bin/_html-playwright.mjs +9 -1
  7. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  8. package/apps/studio/bin/_png-playwright.mjs +8 -1
  9. package/apps/studio/bin/_pw-launch.mjs +54 -0
  10. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  11. package/apps/studio/bin/_video-playwright.mjs +452 -0
  12. package/apps/studio/bin/prep.sh +8 -1
  13. package/apps/studio/canvas-edit.ts +1885 -104
  14. package/apps/studio/canvas-lib.tsx +19 -0
  15. package/apps/studio/canvas-list-watch.ts +6 -2
  16. package/apps/studio/canvas-shell.tsx +27 -0
  17. package/apps/studio/client/app.jsx +1029 -30
  18. package/apps/studio/client/github.js +7 -0
  19. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  20. package/apps/studio/client/panels/timeline-parse.js +229 -0
  21. package/apps/studio/client/panels/timeline-snap.js +55 -0
  22. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  23. package/apps/studio/config.schema.json +14 -0
  24. package/apps/studio/context-menu.tsx +1 -1
  25. package/apps/studio/context.ts +113 -1
  26. package/apps/studio/dist/client.bundle.js +88 -16
  27. package/apps/studio/dist/comment-mount.js +1 -1
  28. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  29. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  30. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  31. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  32. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  38. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  39. package/apps/studio/dist/runtime/remotion.js +42 -0
  40. package/apps/studio/dist/styles.css +1 -1
  41. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  42. package/apps/studio/exporters/_runtime.ts +69 -0
  43. package/apps/studio/exporters/html.ts +4 -3
  44. package/apps/studio/exporters/index.ts +28 -2
  45. package/apps/studio/exporters/pdf.ts +4 -3
  46. package/apps/studio/exporters/png.ts +5 -3
  47. package/apps/studio/exporters/pptx.ts +6 -4
  48. package/apps/studio/exporters/svg.ts +9 -5
  49. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  50. package/apps/studio/exporters/video-render-lib.ts +108 -0
  51. package/apps/studio/exporters/video.ts +184 -0
  52. package/apps/studio/http.ts +535 -27
  53. package/apps/studio/input-router.tsx +7 -1
  54. package/apps/studio/runtime-bundle.ts +30 -0
  55. package/apps/studio/server.ts +34 -9
  56. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  57. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  58. package/apps/studio/test/canvas-edit.test.ts +90 -0
  59. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  60. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  62. package/apps/studio/test/clip-addressing.test.ts +732 -0
  63. package/apps/studio/test/config-reload.test.ts +230 -0
  64. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  65. package/apps/studio/test/edit-persistence.test.ts +91 -0
  66. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  67. package/apps/studio/test/file-lock.test.ts +84 -0
  68. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  69. package/apps/studio/test/timeline-parse.test.ts +127 -0
  70. package/apps/studio/test/timeline-snap.test.ts +85 -0
  71. package/apps/studio/test/video-asset.test.ts +163 -0
  72. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  73. package/apps/studio/test/video-comp.test.ts +168 -0
  74. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  75. package/apps/studio/use-annotation-resize.tsx +6 -3
  76. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  77. package/apps/studio/video-comp.tsx +444 -0
  78. package/apps/studio/whats-new.json +27 -0
  79. package/apps/studio/ws.ts +5 -0
  80. package/package.json +8 -8
  81. package/plugins/design/templates/_shell.html +25 -2
  82. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -9,6 +9,8 @@ import { existsSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import path from 'node:path';
11
11
 
12
+ import { DEV_SERVER_ROOT } from '../paths.ts';
13
+
12
14
  interface CachedBundle {
13
15
  path: string;
14
16
  ready: Promise<string>;
@@ -68,6 +70,121 @@ async function buildIife(entry: string, globalName: string, cachePath: string):
68
70
  return cachePath;
69
71
  }
70
72
 
73
+ /**
74
+ * DDR-148 — build the in-page video encoder (`video-encode-lib.ts`) to a
75
+ * self-contained browser ESM module cached under the OS temp dir. It imports
76
+ * mediabunny + gifenc (both INLINED — no externals) and assigns
77
+ * `window.__maudeEnc`; the capture shim injects it via
78
+ * `page.addScriptTag({ content, type: 'module' })`. Returns the cache path.
79
+ *
80
+ * Unlike getBrowserBundle this bundles a LOCAL source entry (not an npm
81
+ * package) and keeps ESM (the module's top-level `window.__maudeEnc = …` side
82
+ * effect runs on load — no IIFE export-hoisting needed).
83
+ */
84
+ let encodeLibReady: Promise<string> | null = null;
85
+ export function getEncodeLibBundle(): Promise<string> {
86
+ if (encodeLibReady) return encodeLibReady;
87
+ // DDR-045: derive from DEV_SERVER_ROOT, never `import.meta.dir`. In a compiled
88
+ // binary the latter is the virtual `/$bunfs/root`, so `Bun.build` fails with
89
+ // "failed to open root directory: /$bunfs/root" (the mp4/gif export bug).
90
+ const entry = path.join(DEV_SERVER_ROOT, 'exporters', 'video-encode-lib.ts');
91
+ const cachePath = path.join(tmpdir(), 'maude-video-encode-lib.mjs');
92
+ encodeLibReady = (async () => {
93
+ const built = await Bun.build({
94
+ entrypoints: [entry],
95
+ target: 'browser',
96
+ format: 'esm',
97
+ minify: true,
98
+ conditions: ['browser', 'import'],
99
+ });
100
+ if (!built.success) {
101
+ throw new Error(`encode-lib bundle failed: ${built.logs.map((l) => l.message).join('; ')}`);
102
+ }
103
+ const first = built.outputs[0];
104
+ if (!first) throw new Error('encode-lib bundle produced no outputs');
105
+ await Bun.write(cachePath, await first.text());
106
+ return cachePath;
107
+ })();
108
+ return encodeLibReady;
109
+ }
110
+
111
+ /**
112
+ * DDR-148 addendum (audio export) — build the in-page whole-comp renderer
113
+ * (`video-render-lib.ts`, wraps @remotion/web-renderer's renderMediaOnWeb) to a
114
+ * self-contained browser ESM module cached under the OS temp dir. Assigns
115
+ * `window.__maudeRenderVideo__`; the capture shim injects it via addScriptTag
116
+ * before calling `window.__maude_render_video__` (video-comp.tsx bridge).
117
+ *
118
+ * UNLIKE getEncodeLibBundle, this externalizes 'remotion' + '@remotion/media' —
119
+ * they must resolve to the SAME module instances the canvas's importmap already
120
+ * loaded (dual-package hazard: a second bundled copy would give
121
+ * renderMediaOnWeb's internal Composition/context providers a different
122
+ * `TimelineContext` than the one the passed `component`'s hooks read from,
123
+ * freezing every frame at 0 / silencing audio). The browser's importmap
124
+ * (declared in _shell.html) resolves these bare specifiers even for a
125
+ * dynamically injected `<script type=module>` tag.
126
+ */
127
+ let webRendererLibReady: Promise<string> | null = null;
128
+ export function getWebRendererBundle(): Promise<string> {
129
+ if (webRendererLibReady) return webRendererLibReady;
130
+ const entry = path.join(DEV_SERVER_ROOT, 'exporters', 'video-render-lib.ts');
131
+ const cachePath = path.join(tmpdir(), 'maude-video-render-lib.mjs');
132
+ webRendererLibReady = (async () => {
133
+ // Bun's `external` field is package-name PREFIX matched (runtime-bundle.ts's
134
+ // comment on the same gotcha) — listing 'remotion' also externalizes deep
135
+ // subpaths like 'remotion/version' (a real import inside web-renderer's own
136
+ // dependency tree). The importmap only maps the bare 'remotion' specifier, so
137
+ // an externalized 'remotion/version' 404s in the browser with "Failed to
138
+ // resolve module specifier". Use an exact-match onResolve plugin (mirrors
139
+ // canvas-build.ts's `exact-externals`) so ONLY the specifiers the importmap
140
+ // actually covers are externalized; every subpath (version, no-react, etc.)
141
+ // gets bundled inline like any other dependency.
142
+ //
143
+ // react/react-dom MUST also be externalized here (not just remotion) — a
144
+ // second bundled React copy inside this module renders the SAME `component`
145
+ // reference the page's importmap-loaded React created, and two React
146
+ // instances in one page is the textbook "Invalid Hook Call" (minified React
147
+ // error #321): the component's hooks resolve against instance A's dispatcher
148
+ // while web-renderer's internal renderer/reconciler is instance B. Discovered
149
+ // empirically — the first working build threw exactly this error.
150
+ const EXTERNAL_EXACT = new Set([
151
+ 'remotion',
152
+ '@remotion/media',
153
+ 'react',
154
+ 'react-dom',
155
+ 'react-dom/client',
156
+ 'react/jsx-runtime',
157
+ 'react/jsx-dev-runtime',
158
+ ]);
159
+ const built = await Bun.build({
160
+ entrypoints: [entry],
161
+ target: 'browser',
162
+ format: 'esm',
163
+ minify: true,
164
+ conditions: ['browser', 'import'],
165
+ plugins: [
166
+ {
167
+ name: 'exact-externals',
168
+ setup(builder) {
169
+ builder.onResolve({ filter: /.*/ }, (args) => {
170
+ if (EXTERNAL_EXACT.has(args.path)) return { path: args.path, external: true };
171
+ return null;
172
+ });
173
+ },
174
+ },
175
+ ],
176
+ });
177
+ if (!built.success) {
178
+ throw new Error(`web-renderer bundle failed: ${built.logs.map((l) => l.message).join('; ')}`);
179
+ }
180
+ const first = built.outputs[0];
181
+ if (!first) throw new Error('web-renderer bundle produced no outputs');
182
+ await Bun.write(cachePath, await first.text());
183
+ return cachePath;
184
+ })();
185
+ return webRendererLibReady;
186
+ }
187
+
71
188
  /**
72
189
  * Returns the path to an IIFE bundle for the given npm package, attaching its
73
190
  * exports under `window[globalName]`. Caches under the OS temp dir so a long-
@@ -0,0 +1,69 @@
1
+ // Shared runtime + path resolution for the external playwright render shims
2
+ // (`bin/_{png,pdf,svg,html,pptx,video}-playwright.mjs`).
3
+ //
4
+ // RCA: issue-desktop-export-failures. The render exporters were written for the
5
+ // dev-server runtime and never migrated for the compiled-Bun sidecar packaging,
6
+ // so they broke in two ways in every `bun --compile` standalone binary (the
7
+ // desktop app AND `npm i -g @1agh/maude`):
8
+ //
9
+ // 1. Shim paths computed from `import.meta.dir` → inside a compiled binary that
10
+ // is the virtual `/$bunfs/root`, not a real disk path, so `Bun.spawn` /
11
+ // `Bun.build` fail with "failed to open root directory: /$bunfs/root". This
12
+ // is the DDR-045 bug: filesystem-relative paths MUST derive from
13
+ // `paths.ts`, never a local `import.meta.dir`.
14
+ // 2. Spawning a hardcoded `'node'` → the desktop sidecar is a compiled Bun
15
+ // binary with no bundled `node`; a Finder-launched `.app` can't rely on one
16
+ // being on PATH, so the spawn fails with `posix_spawn 'node'` ENOENT.
17
+ //
18
+ // This module centralizes both concerns so all six adapters resolve identically.
19
+
20
+ import path from 'node:path';
21
+
22
+ import { DEV_SERVER_ROOT } from '../paths.ts';
23
+
24
+ /**
25
+ * Real-disk dir holding the render shims (`<DEV_SERVER_ROOT>/bin`).
26
+ *
27
+ * Derived from `DEV_SERVER_ROOT` (paths.ts) — NEVER `import.meta.dir`, which is
28
+ * the virtual `/$bunfs/root` inside a compiled binary (DDR-045). In dev this is
29
+ * the same `apps/studio/bin` the old `path.join(import.meta.dir, '..', 'bin')`
30
+ * resolved to; in the desktop bundle it resolves to `Resources/apps/studio/bin`
31
+ * (the shims ARE staged there — stage-resources ships the whole source tree).
32
+ */
33
+ export const EXPORT_SHIM_DIR: string = path.join(DEV_SERVER_ROOT, 'bin');
34
+
35
+ /** Absolute path to a render shim by basename (e.g. `_png-playwright.mjs`). */
36
+ export function exportShimPath(basename: string): string {
37
+ return path.join(EXPORT_SHIM_DIR, basename);
38
+ }
39
+
40
+ /**
41
+ * Resolve a JS runtime able to execute the external `.mjs` render shims.
42
+ *
43
+ * Ladder (mirrors `acp/probe.ts` resolveAgentRuntime): explicit override → a
44
+ * real `node` on PATH → a standalone `bun` on PATH. The compiled maude binary
45
+ * (`process.execPath`) is deliberately NOT a fallback — a `bun --compile`
46
+ * standalone ignores a script argument and re-runs its own embedded entrypoint,
47
+ * so it can't run a shim. When nothing resolves (a packaged desktop app whose
48
+ * sidecar has no node/bun on PATH) we throw an ACTIONABLE error instead of the
49
+ * cryptic `posix_spawn 'node'` ENOENT the hardcoded `'node'` produced.
50
+ */
51
+ export function resolveExportRuntime(): string {
52
+ // Look up against the LIVE process PATH (the desktop sidecar sets it via env at
53
+ // spawn — DDR-128), not Bun's startup env snapshot, so a corrected PATH is
54
+ // honored and the lookup is deterministic/testable.
55
+ const pathEnv = process.env.PATH;
56
+ const runtime =
57
+ process.env.MAUDE_EXPORT_RUNTIME ||
58
+ Bun.which('node', { PATH: pathEnv }) ||
59
+ Bun.which('bun', { PATH: pathEnv });
60
+ if (!runtime) {
61
+ throw new Error(
62
+ 'render export needs a JS runtime (node or bun) on PATH, but this build has none. ' +
63
+ 'Run the export from the browser dev-server (`maude design serve`), install Node, ' +
64
+ 'or set MAUDE_EXPORT_RUNTIME to a node/bun binary. ' +
65
+ '(Native desktop render-export backend is pending — see RCA issue-desktop-export-failures.)'
66
+ );
67
+ }
68
+ return runtime;
69
+ }
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os';
11
11
  import path from 'node:path';
12
12
 
13
13
  import JSZip from 'jszip';
14
-
14
+ import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
15
15
  import {
16
16
  canvasShellUrl,
17
17
  type ExportContext,
@@ -20,7 +20,8 @@ import {
20
20
  } from './index.ts';
21
21
  import type { Target } from './scope.ts';
22
22
 
23
- const HTML_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_html-playwright.mjs');
23
+ // DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir`. See _runtime.ts.
24
+ const HTML_PLAYWRIGHT = exportShimPath('_html-playwright.mjs');
24
25
 
25
26
  async function captureHtml(
26
27
  target: Extract<Target, { kind: 'element' }>,
@@ -45,7 +46,7 @@ async function captureHtml(
45
46
  if (target.widen) args.push('--widen-to-artboard', '1');
46
47
  args.push('--out', path.join(outDir, `${target.canvasSlug}.html`));
47
48
  }
48
- const proc = Bun.spawn(['node', ...args], {
49
+ const proc = Bun.spawn([resolveExportRuntime(), ...args], {
49
50
  cwd: path.dirname(HTML_PLAYWRIGHT),
50
51
  stdout: 'pipe',
51
52
  stderr: 'pipe',
@@ -15,11 +15,34 @@ import * as png from './png.ts';
15
15
  import * as pptx from './pptx.ts';
16
16
  import { type ResolveScopeArgs, resolveScope, type Scope, type Target } from './scope.ts';
17
17
  import * as svg from './svg.ts';
18
+ // DDR-148 — temporal formats (capture-spine encode). Named exports mp4/webm/gif.
19
+ import { gif, mp4, webm } from './video.ts';
18
20
  import * as zip from './zip.ts';
19
21
 
20
- export type Format = 'png' | 'pdf' | 'svg' | 'html' | 'pptx' | 'canva' | 'zip';
22
+ export type Format =
23
+ | 'png'
24
+ | 'pdf'
25
+ | 'svg'
26
+ | 'html'
27
+ | 'pptx'
28
+ | 'canva'
29
+ | 'zip'
30
+ | 'mp4'
31
+ | 'webm'
32
+ | 'gif';
21
33
 
22
- export const FORMATS: readonly Format[] = ['png', 'pdf', 'svg', 'html', 'pptx', 'canva', 'zip'];
34
+ export const FORMATS: readonly Format[] = [
35
+ 'png',
36
+ 'pdf',
37
+ 'svg',
38
+ 'html',
39
+ 'pptx',
40
+ 'canva',
41
+ 'zip',
42
+ 'mp4',
43
+ 'webm',
44
+ 'gif',
45
+ ];
23
46
 
24
47
  /** Options bag forwarded to the adapter. Format-specific keys are validated by each adapter. */
25
48
  export type ExportOptions = Record<string, unknown>;
@@ -63,6 +86,9 @@ const REGISTRY: Record<Format, Adapter> = {
63
86
  pptx,
64
87
  canva,
65
88
  zip,
89
+ mp4,
90
+ webm,
91
+ gif,
66
92
  };
67
93
 
68
94
  export function getAdapter(format: Format): Adapter | null {
@@ -13,7 +13,7 @@ import { tmpdir } from 'node:os';
13
13
  import path from 'node:path';
14
14
 
15
15
  import { PDFDocument } from 'pdf-lib';
16
-
16
+ import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
17
17
  import {
18
18
  canvasShellUrl,
19
19
  type ExportContext,
@@ -22,7 +22,8 @@ import {
22
22
  } from './index.ts';
23
23
  import type { Target } from './scope.ts';
24
24
 
25
- const PDF_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_pdf-playwright.mjs');
25
+ // DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir`. See _runtime.ts.
26
+ const PDF_PLAYWRIGHT = exportShimPath('_pdf-playwright.mjs');
26
27
 
27
28
  async function capturePdf(
28
29
  target: Extract<Target, { kind: 'element' }>,
@@ -48,7 +49,7 @@ async function capturePdf(
48
49
  args.push('--out', path.join(outDir, `${target.canvasSlug}.pdf`));
49
50
  }
50
51
 
51
- const proc = Bun.spawn(['node', ...args], {
52
+ const proc = Bun.spawn([resolveExportRuntime(), ...args], {
52
53
  cwd: path.dirname(PDF_PLAYWRIGHT),
53
54
  stdout: 'pipe',
54
55
  stderr: 'pipe',
@@ -12,7 +12,7 @@ import { tmpdir } from 'node:os';
12
12
  import path from 'node:path';
13
13
 
14
14
  import JSZip from 'jszip';
15
-
15
+ import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
16
16
  import {
17
17
  canvasShellUrl,
18
18
  type ExportContext,
@@ -21,7 +21,9 @@ import {
21
21
  } from './index.ts';
22
22
  import type { Target } from './scope.ts';
23
23
 
24
- const PNG_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_png-playwright.mjs');
24
+ // DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir` (→ /$bunfs/root
25
+ // in a compiled binary). See exporters/_runtime.ts.
26
+ const PNG_PLAYWRIGHT = exportShimPath('_png-playwright.mjs');
25
27
 
26
28
  interface CaptureOptions {
27
29
  scale?: 1 | 2 | 3;
@@ -66,7 +68,7 @@ async function captureElement(
66
68
  if (target.widen) args.push('--widen-to-artboard', '1');
67
69
  args.push('--out', path.join(outDir, `${target.canvasSlug}.png`));
68
70
  }
69
- const proc = Bun.spawn(['node', ...args], {
71
+ const proc = Bun.spawn([resolveExportRuntime(), ...args], {
70
72
  cwd: path.dirname(PNG_PLAYWRIGHT),
71
73
  stdout: 'pipe',
72
74
  stderr: 'pipe',
@@ -27,6 +27,7 @@ import path from 'node:path';
27
27
  import PptxGenJS from 'pptxgenjs';
28
28
 
29
29
  import { getBrowserBundle } from './_browser-bundles.ts';
30
+ import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
30
31
  import {
31
32
  canvasShellUrl,
32
33
  type ExportContext,
@@ -35,8 +36,9 @@ import {
35
36
  } from './index.ts';
36
37
  import type { Target } from './scope.ts';
37
38
 
38
- const SVG_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_svg-playwright.mjs');
39
- const PNG_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_png-playwright.mjs');
39
+ // DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir`. See _runtime.ts.
40
+ const SVG_PLAYWRIGHT = exportShimPath('_svg-playwright.mjs');
41
+ const PNG_PLAYWRIGHT = exportShimPath('_png-playwright.mjs');
40
42
  const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
41
43
  const SLIDE_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide';
42
44
  const SLIDE_CT = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
@@ -49,9 +51,9 @@ function isElementTarget(t: Target): t is ElementTarget {
49
51
  return t.kind === 'element';
50
52
  }
51
53
 
52
- /** Spawn a playwright shim via `node`, returning the written file paths (stdout). */
54
+ /** Spawn a playwright shim via a resolved runtime, returning the written file paths (stdout). */
53
55
  async function spawnShim(args: string[]): Promise<string[]> {
54
- const proc = Bun.spawn(['node', ...args], {
56
+ const proc = Bun.spawn([resolveExportRuntime(), ...args], {
55
57
  cwd: path.dirname(args[0]),
56
58
  stdout: 'pipe',
57
59
  stderr: 'pipe',
@@ -13,6 +13,7 @@ import path from 'node:path';
13
13
  import JSZip from 'jszip';
14
14
 
15
15
  import { getBrowserBundle } from './_browser-bundles.ts';
16
+ import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
16
17
  import {
17
18
  canvasShellUrl,
18
19
  type ExportContext,
@@ -21,7 +22,8 @@ import {
21
22
  } from './index.ts';
22
23
  import type { Target } from './scope.ts';
23
24
 
24
- const SVG_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_svg-playwright.mjs');
25
+ // DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir`. See _runtime.ts.
26
+ const SVG_PLAYWRIGHT = exportShimPath('_svg-playwright.mjs');
25
27
 
26
28
  async function captureSvg(
27
29
  target: Extract<Target, { kind: 'element' }>,
@@ -50,10 +52,12 @@ async function captureSvg(
50
52
  args.push('--out', path.join(outDir, `${target.canvasSlug}.svg`));
51
53
  }
52
54
 
53
- // Run via `node` so the shim's `import 'playwright'` resolves against
54
- // dev-server/node_modules (playwright is a devDep). `npm exec` doesn't
55
- // bridge the module path for ESM imports — confirmed against npm 10.x.
56
- const proc = Bun.spawn(['node', ...args], {
55
+ // Run via a resolved node/bun runtime so the shim's `import 'playwright'`
56
+ // resolves against dev-server/node_modules (playwright is a devDep). `npm exec`
57
+ // doesn't bridge the module path for ESM imports — confirmed against npm 10.x.
58
+ // resolveExportRuntime() replaces a hardcoded `'node'` so a compiled binary
59
+ // without `node` on PATH surfaces an actionable error, not `posix_spawn 'node'`.
60
+ const proc = Bun.spawn([resolveExportRuntime(), ...args], {
57
61
  cwd: path.dirname(SVG_PLAYWRIGHT),
58
62
  stdout: 'pipe',
59
63
  stderr: 'pipe',
@@ -0,0 +1,200 @@
1
+ // video-encode-lib.ts — DDR-148 in-page MP4/WebM/GIF encoder.
2
+ //
3
+ // Runs INSIDE the capture Chromium (bundled to a self-contained module by
4
+ // _browser-bundles.getEncodeLibBundle, injected via page.addScriptTag). WebCodecs
5
+ // is guaranteed there — the user's shell browser / WKWebView never encodes.
6
+ //
7
+ // The Node shim (bin/_video-playwright.mjs) screenshots each frame, hands the
8
+ // PNG in as base64, and this lib decodes → draws to an offscreen canvas →
9
+ // encodes: mediabunny (Output + CanvasSource, H.264/avc in MP4, VP9/VP8 in WebM
10
+ // as the fallback), or gifenc (quantize → applyPalette). Video codec is chosen
11
+ // by mediabunny's own capability probe, so a browser without an H.264 encoder
12
+ // degrades to WebM instead of failing. Audio mixing is added in a follow-up
13
+ // slice (the comp's <Audio>/<Video> sources via OfflineAudioContext).
14
+ //
15
+ // The lib assigns a single `window.__maudeEnc` surface (no exports — it's a
16
+ // side-effecting module script).
17
+
18
+ import { applyPalette, GIFEncoder, quantize } from 'gifenc';
19
+ import {
20
+ BufferTarget,
21
+ CanvasSource,
22
+ getFirstEncodableVideoCodec,
23
+ Mp4OutputFormat,
24
+ Output,
25
+ QUALITY_HIGH,
26
+ type VideoCodec,
27
+ WebMOutputFormat,
28
+ } from 'mediabunny';
29
+
30
+ interface StartVideoOpts {
31
+ width: number;
32
+ height: number;
33
+ fps: number;
34
+ format?: 'mp4' | 'webm';
35
+ }
36
+ interface StartGifOpts {
37
+ width: number;
38
+ height: number;
39
+ fps: number;
40
+ maxColors?: number;
41
+ }
42
+ interface EncodeResult {
43
+ b64: string;
44
+ bytes: number;
45
+ container: string;
46
+ codec: string;
47
+ }
48
+
49
+ interface MaudeEnc {
50
+ startVideo(opts: StartVideoOpts): Promise<{ container: string; codec: string }>;
51
+ addVideoFrame(b64png: string): Promise<void>;
52
+ finishVideo(): Promise<EncodeResult>;
53
+ startGif(opts: StartGifOpts): void;
54
+ addGifFrame(b64png: string): Promise<void>;
55
+ finishGif(): EncodeResult;
56
+ }
57
+
58
+ function b64ToBytes(b64: string): Uint8Array {
59
+ const bin = atob(b64);
60
+ const out = new Uint8Array(bin.length);
61
+ for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
62
+ return out;
63
+ }
64
+
65
+ function bytesToB64(buf: ArrayBuffer | Uint8Array): string {
66
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
67
+ let bin = '';
68
+ const CHUNK = 0x8000;
69
+ for (let i = 0; i < bytes.length; i += CHUNK) {
70
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
71
+ }
72
+ return btoa(bin);
73
+ }
74
+
75
+ async function decodeToBitmap(b64: string): Promise<ImageBitmap> {
76
+ return createImageBitmap(new Blob([b64ToBytes(b64)], { type: 'image/png' }));
77
+ }
78
+
79
+ let vstate: {
80
+ output: Output;
81
+ source: CanvasSource;
82
+ canvas: OffscreenCanvas;
83
+ ctx: OffscreenCanvasRenderingContext2D;
84
+ fps: number;
85
+ frame: number;
86
+ container: string;
87
+ codec: string;
88
+ } | null = null;
89
+
90
+ let gstate: {
91
+ enc: ReturnType<typeof GIFEncoder>;
92
+ canvas: OffscreenCanvas;
93
+ ctx: OffscreenCanvasRenderingContext2D;
94
+ delay: number;
95
+ maxColors: number;
96
+ } | null = null;
97
+
98
+ const MP4_CODECS: VideoCodec[] = ['avc', 'hevc'];
99
+ const WEBM_CODECS: VideoCodec[] = ['vp9', 'vp8', 'av1'];
100
+
101
+ const api: MaudeEnc = {
102
+ async startVideo({ width, height, fps, format }) {
103
+ const canvas = new OffscreenCanvas(width, height);
104
+ const ctx = canvas.getContext('2d');
105
+ if (!ctx) throw new Error('no 2d context for encode canvas');
106
+
107
+ // Prefer the requested container's codecs; fall back to WebM/VP9 so a
108
+ // browser without an H.264 encoder still produces a file.
109
+ const wantMp4 = format !== 'webm';
110
+ let outFormat: Mp4OutputFormat | WebMOutputFormat = wantMp4
111
+ ? new Mp4OutputFormat()
112
+ : new WebMOutputFormat();
113
+ let codec = await getFirstEncodableVideoCodec(wantMp4 ? MP4_CODECS : WEBM_CODECS, {
114
+ width,
115
+ height,
116
+ });
117
+ if (!codec && wantMp4) {
118
+ outFormat = new WebMOutputFormat();
119
+ codec = await getFirstEncodableVideoCodec(WEBM_CODECS, { width, height });
120
+ }
121
+ if (!codec) throw new Error('no encodable video codec available in this browser');
122
+
123
+ const output = new Output({ format: outFormat, target: new BufferTarget() });
124
+ const source = new CanvasSource(canvas, { codec, bitrate: QUALITY_HIGH });
125
+ output.addVideoTrack(source, { frameRate: fps });
126
+ await output.start();
127
+ const container = outFormat instanceof Mp4OutputFormat ? 'mp4' : 'webm';
128
+ vstate = { output, source, canvas, ctx, fps, frame: 0, container, codec };
129
+ return { container, codec };
130
+ },
131
+
132
+ async addVideoFrame(b64png) {
133
+ if (!vstate) throw new Error('startVideo not called');
134
+ const bmp = await decodeToBitmap(b64png);
135
+ vstate.ctx.drawImage(bmp, 0, 0, vstate.canvas.width, vstate.canvas.height);
136
+ if ('close' in bmp) bmp.close();
137
+ await vstate.source.add(vstate.frame / vstate.fps, 1 / vstate.fps);
138
+ vstate.frame += 1;
139
+ },
140
+
141
+ async finishVideo() {
142
+ if (!vstate) throw new Error('startVideo not called');
143
+ await vstate.output.finalize();
144
+ const buf = vstate.output.target.buffer;
145
+ if (!buf) throw new Error('encode produced no buffer');
146
+ const res: EncodeResult = {
147
+ b64: bytesToB64(buf),
148
+ bytes: buf.byteLength,
149
+ container: vstate.container,
150
+ codec: vstate.codec,
151
+ };
152
+ vstate = null;
153
+ return res;
154
+ },
155
+
156
+ startGif({ width, height, fps, maxColors }) {
157
+ const canvas = new OffscreenCanvas(width, height);
158
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
159
+ if (!ctx) throw new Error('no 2d context for gif canvas');
160
+ gstate = {
161
+ enc: GIFEncoder(),
162
+ canvas,
163
+ ctx,
164
+ delay: Math.max(2, Math.round(100 / fps)) * 10, // gifenc delay is in ms
165
+ maxColors: Math.max(2, Math.min(256, maxColors ?? 256)),
166
+ };
167
+ },
168
+
169
+ async addGifFrame(b64png) {
170
+ if (!gstate) throw new Error('startGif not called');
171
+ const bmp = await decodeToBitmap(b64png);
172
+ gstate.ctx.drawImage(bmp, 0, 0, gstate.canvas.width, gstate.canvas.height);
173
+ if ('close' in bmp) bmp.close();
174
+ const { data, width, height } = gstate.ctx.getImageData(
175
+ 0,
176
+ 0,
177
+ gstate.canvas.width,
178
+ gstate.canvas.height
179
+ );
180
+ const palette = quantize(data, gstate.maxColors);
181
+ const index = applyPalette(data, palette);
182
+ gstate.enc.writeFrame(index, width, height, { palette, delay: gstate.delay });
183
+ },
184
+
185
+ finishGif() {
186
+ if (!gstate) throw new Error('startGif not called');
187
+ gstate.enc.finish();
188
+ const bytes = gstate.enc.bytes();
189
+ const res: EncodeResult = {
190
+ b64: bytesToB64(bytes),
191
+ bytes: bytes.length,
192
+ container: 'gif',
193
+ codec: 'gif',
194
+ };
195
+ gstate = null;
196
+ return res;
197
+ },
198
+ };
199
+
200
+ (globalThis as unknown as { __maudeEnc: MaudeEnc }).__maudeEnc = api;