@1agh/maude 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -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;
@@ -0,0 +1,108 @@
1
+ // video-render-lib.ts — DDR-148 addendum: in-page whole-comp render (video +
2
+ // audio, one pass) via @remotion/web-renderer's renderMediaOnWeb.
3
+ //
4
+ // Runs INSIDE the capture Chromium (injected via addScriptTag, mirroring
5
+ // video-encode-lib.ts). Replaces the frame-step + mediabunny screenshot loop
6
+ // for video-comp mp4/webm export: Remotion owns the TransitionSeries offset
7
+ // math and `<Audio volume={fn}>` closures internally, so a single
8
+ // renderMediaOnWeb call produces a correctly-muxed, frame-accurate video+audio
9
+ // file with no hand-rolled mixing. GIF export (no audio, no renderMediaOnWeb
10
+ // container support) stays on the frame-step + gifenc path — proven compatible
11
+ // with @remotion/media (screenshot-after-seek reliably shows decoded frames,
12
+ // including mid-transition composites; see the DDR-148 addendum retro).
13
+ //
14
+ // Externalizes 'remotion' + '@remotion/media' (resolved via the SAME importmap
15
+ // bundle the page's canvas module tree uses) to avoid the dual-package hazard:
16
+ // the `component` passed in was created against the page's imported instances,
17
+ // so renderMediaOnWeb's internal Composition/context providers MUST be the
18
+ // same instances, or useCurrentFrame()/<Audio> volume closures bind to the
19
+ // wrong (default) context and the render freezes / plays silent.
20
+ //
21
+ // Offline posture (DDR-148 security): the capture CSP already locks network
22
+ // egress to loopback-only, which blocks web-renderer's optional telemetry
23
+ // (remotion.pro/api/track/register-usage-point) at the network layer — no
24
+ // additional code needed here. `licenseKey`/`isProduction` are still threaded
25
+ // through per Remotion's disclosed-license posture (MAUDE_REMOTION_LICENSE).
26
+
27
+ import { renderMediaOnWeb } from '@remotion/web-renderer';
28
+ import type { ComponentType } from 'react';
29
+
30
+ interface RenderVideoOpts {
31
+ component: ComponentType<Record<string, unknown>>;
32
+ inputProps?: Record<string, unknown>;
33
+ width: number;
34
+ height: number;
35
+ fps: number;
36
+ durationInFrames: number;
37
+ container: 'mp4' | 'webm';
38
+ videoCodec?: string;
39
+ audioCodec?: string;
40
+ scale?: number;
41
+ muted?: boolean;
42
+ frameRange?: [number, number];
43
+ licenseKey?: string;
44
+ }
45
+
46
+ interface RenderVideoResult {
47
+ b64: string;
48
+ bytes: number;
49
+ container: string;
50
+ videoCodec: string | null;
51
+ audioCodec: string | null;
52
+ }
53
+
54
+ function bytesToB64(buf: ArrayBuffer | Uint8Array): string {
55
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
56
+ let bin = '';
57
+ const CHUNK = 0x8000;
58
+ for (let i = 0; i < bytes.length; i += CHUNK) {
59
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
60
+ }
61
+ return btoa(bin);
62
+ }
63
+
64
+ async function renderVideo(opts: RenderVideoOpts): Promise<RenderVideoResult> {
65
+ const muted = opts.muted ?? false;
66
+ const videoCodec = (opts.videoCodec ??
67
+ (opts.container === 'webm' ? 'vp9' : 'h264')) as Parameters<
68
+ typeof renderMediaOnWeb
69
+ >[0]['videoCodec'];
70
+ const audioCodec = muted
71
+ ? null
72
+ : ((opts.audioCodec ?? (opts.container === 'webm' ? 'opus' : 'aac')) as Parameters<
73
+ typeof renderMediaOnWeb
74
+ >[0]['audioCodec']);
75
+
76
+ const result = await renderMediaOnWeb({
77
+ composition: {
78
+ component: opts.component,
79
+ id: 'maude-export',
80
+ width: opts.width,
81
+ height: opts.height,
82
+ fps: opts.fps,
83
+ durationInFrames: opts.durationInFrames,
84
+ },
85
+ inputProps: opts.inputProps ?? {},
86
+ container: opts.container,
87
+ videoCodec,
88
+ audioCodec,
89
+ muted,
90
+ scale: opts.scale ?? 1,
91
+ frameRange: opts.frameRange ?? null,
92
+ isProduction: true,
93
+ licenseKey: opts.licenseKey ?? 'free-license',
94
+ logLevel: 'error',
95
+ });
96
+ const blob = await result.getBlob();
97
+ const buf = new Uint8Array(await blob.arrayBuffer());
98
+ return {
99
+ b64: bytesToB64(buf),
100
+ bytes: buf.length,
101
+ container: opts.container,
102
+ videoCodec: videoCodec ?? null,
103
+ audioCodec: audioCodec ?? null,
104
+ };
105
+ }
106
+
107
+ (globalThis as unknown as { __maudeRenderVideo__: typeof renderVideo }).__maudeRenderVideo__ =
108
+ renderVideo;
@@ -0,0 +1,184 @@
1
+ // DDR-148 — video/animation exporter (mp4 · webm · gif).
2
+ //
3
+ // Drives bin/_video-playwright.mjs, which picks one of two capture strategies:
4
+ // • mp4/webm of a registered video-comp — DDR-148 addendum (audio export):
5
+ // window.__maude_render_video__ → @remotion/web-renderer's renderMediaOnWeb,
6
+ // video+audio in one pass (Remotion owns the TransitionSeries/volume-closure
7
+ // timeline math). `getWebRendererBundle()` supplies the in-page renderer.
8
+ // • everything else (gif, ordinary/CSS-WAAPI artboards, or no registered
9
+ // comp) — the original frame-step spine: steps the artboard frame-by-frame
10
+ // via window.__maude_seek__ and encodes in-page with mediabunny (H.264/avc
11
+ // MP4, VP9/VP8 WebM fallback) or gifenc. `getEncodeLibBundle()` supplies
12
+ // this in-page encoder, and doubles as the render-lib path's fallback when
13
+ // the target has no registered comp (the shim decides at runtime — see
14
+ // _video-playwright.mjs for the exact routing condition).
15
+ // Scope is always `artboard`. No native binaries; the capture Chromium is the
16
+ // same one every other exporter already uses (DDR-041).
17
+
18
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ import { getEncodeLibBundle, getWebRendererBundle } from './_browser-bundles.ts';
23
+ import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
24
+ import {
25
+ canvasShellUrl,
26
+ type ExportContext,
27
+ type ExportOptions,
28
+ type ExportResult,
29
+ } from './index.ts';
30
+ import type { Target } from './scope.ts';
31
+
32
+ // DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir`. See _runtime.ts.
33
+ const VIDEO_PLAYWRIGHT = exportShimPath('_video-playwright.mjs');
34
+
35
+ type VideoFormat = 'mp4' | 'webm' | 'gif';
36
+
37
+ const CONTENT_TYPE: Record<string, string> = {
38
+ mp4: 'video/mp4',
39
+ webm: 'video/webm',
40
+ gif: 'image/gif',
41
+ };
42
+
43
+ /** Hard cap so a runaway/huge comp can't spawn an unbounded screenshot loop. */
44
+ const MAX_FRAMES = 900; // 30 s @ 30 fps
45
+
46
+ async function runVideo(
47
+ format: VideoFormat,
48
+ targets: Target[],
49
+ options: ExportOptions,
50
+ ctx: ExportContext
51
+ ): Promise<ExportResult> {
52
+ const el = targets.find((t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element');
53
+ if (!el) {
54
+ throw new Error(
55
+ `${format} export needs an artboard target — make the artboard a video-comp or add motion`
56
+ );
57
+ }
58
+
59
+ // gif has no renderMediaOnWeb container support (and no audio need — GIF is a
60
+ // silent format), so it never needs the render-lib bundle; mp4/webm build it
61
+ // alongside the frame-step encode-lib (the shim decides per-target at runtime
62
+ // whether the artboard has a registered comp to hand the renderer).
63
+ const [lib, renderLib] = await Promise.all([
64
+ getEncodeLibBundle(),
65
+ format === 'gif' ? Promise.resolve(null) : getWebRendererBundle(),
66
+ ]);
67
+ const tmp = mkdtempSync(path.join(tmpdir(), 'maude-video-'));
68
+ const outPath = path.join(tmp, `export.${format}`);
69
+
70
+ const args = [
71
+ VIDEO_PLAYWRIGHT,
72
+ '--url',
73
+ canvasShellUrl(ctx, el.file),
74
+ '--selector',
75
+ el.cssPath,
76
+ '--encode-lib',
77
+ lib,
78
+ '--format',
79
+ format,
80
+ '--out',
81
+ outPath,
82
+ '--timeout',
83
+ String((options.timeoutSec as number | undefined) ?? 120),
84
+ ];
85
+ if (renderLib) args.push('--render-lib', renderLib);
86
+ if (el.widen) args.push('--widen', '1');
87
+ // Ordinary artboards animate on wall-clock (WAAPI); video-comps on frame index.
88
+ if (options.mode === 'ordinary') args.push('--mode', 'ordinary');
89
+
90
+ // Export-with-audio (DDR-148 addendum) — default ON; only the render-lib path
91
+ // (mp4/webm of a registered comp) can actually produce audio, but the flag is
92
+ // harmless for the frame-step fallback (video-only, always has been).
93
+ const wantAudio = options.audio !== false;
94
+ args.push('--audio', wantAudio ? '1' : '0');
95
+ // Remotion license posture — MAUDE_REMOTION_LICENSE env (config-driven, per
96
+ // project); the shim defaults to 'free-license' when unset. Network egress
97
+ // for web-renderer's optional telemetry is blocked at the CSP layer regardless
98
+ // (cspForCapture's `connect-src 'self'`, http.ts) — no additional code needed
99
+ // for the offline posture beyond passing the key through.
100
+ if (process.env.MAUDE_REMOTION_LICENSE) {
101
+ args.push('--license-key', process.env.MAUDE_REMOTION_LICENSE);
102
+ }
103
+
104
+ // Resolution multiplier (1–3×) — the artboard's native size × scale. Default 2×
105
+ // so a 960×540 comp exports at 1920×1080 instead of a tiny native capture. The
106
+ // shim applies it as deviceScaleFactor AND sizes the encoder to match.
107
+ const scale = clampInt(options.scale, 1, 3) ?? 2;
108
+ args.push('--scale', String(scale));
109
+
110
+ const fps = clampInt(options.fps, 1, 60);
111
+ if (fps) args.push('--fps', String(fps));
112
+
113
+ const frames = resolveFrames(options, fps);
114
+ if (frames) args.push('--frames', String(frames));
115
+
116
+ if (format === 'gif') {
117
+ const colors = clampInt(options.gifColors, 2, 256);
118
+ if (colors) args.push('--gifColors', String(colors));
119
+ }
120
+
121
+ try {
122
+ const proc = Bun.spawn([resolveExportRuntime(), ...args], {
123
+ cwd: path.dirname(VIDEO_PLAYWRIGHT),
124
+ stdout: 'pipe',
125
+ stderr: 'pipe',
126
+ });
127
+ const [stdout, stderr] = await Promise.all([
128
+ new Response(proc.stdout).text(),
129
+ new Response(proc.stderr).text(),
130
+ ]);
131
+ const code = await proc.exited;
132
+ if (code !== 0) {
133
+ throw new Error(`_video-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
134
+ }
135
+ const body = new Uint8Array(readFileSync(outPath));
136
+ // The container can differ from the request (mp4 → webm fallback when the
137
+ // browser has no H.264 encoder). Read the summary line for the real ext.
138
+ let ext: string = format;
139
+ try {
140
+ const lastLine = stdout.trim().split('\n').filter(Boolean).pop() ?? '{}';
141
+ const summary = JSON.parse(lastLine) as { container?: string };
142
+ if (summary.container) ext = summary.container;
143
+ } catch {
144
+ /* keep the requested ext */
145
+ }
146
+ return {
147
+ filename: `${el.canvasSlug}.${ext}`,
148
+ contentType: CONTENT_TYPE[ext] ?? 'application/octet-stream',
149
+ body,
150
+ };
151
+ } finally {
152
+ rmSync(tmp, { recursive: true, force: true });
153
+ }
154
+ }
155
+
156
+ /** Coerce an option to an int within [lo, hi], or undefined when absent/invalid. */
157
+ function clampInt(raw: unknown, lo: number, hi: number): number | undefined {
158
+ const n = Math.round(Number(raw));
159
+ if (!Number.isFinite(n)) return undefined;
160
+ return Math.max(lo, Math.min(hi, n));
161
+ }
162
+
163
+ /** Resolve the frame count from options (frames | durationMs×fps), capped. */
164
+ function resolveFrames(options: ExportOptions, fps: number | undefined): number | undefined {
165
+ const explicit = clampInt(options.frames, 1, MAX_FRAMES);
166
+ if (explicit) return explicit;
167
+ const durationMs = Number(options.durationMs);
168
+ if (Number.isFinite(durationMs) && durationMs > 0 && fps) {
169
+ return Math.min(MAX_FRAMES, Math.max(1, Math.round((durationMs / 1000) * fps)));
170
+ }
171
+ // No hint → the shim falls back to the in-page comp meta (durationInFrames),
172
+ // still clamped there. Pass the ceiling so an unbounded comp can't run away.
173
+ return undefined;
174
+ }
175
+
176
+ export const mp4 = {
177
+ run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('mp4', t, o, c),
178
+ };
179
+ export const webm = {
180
+ run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('webm', t, o, c),
181
+ };
182
+ export const gif = {
183
+ run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('gif', t, o, c),
184
+ };