@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
@@ -0,0 +1,149 @@
1
+ // video-render-bridge — DDR-148 addendum (audio export) unit coverage.
2
+ //
3
+ // The actual renderMediaOnWeb call needs a real browser (WebCodecs, canvas
4
+ // decode) — that's a manual/agent-browser verification (see the DDR-148
5
+ // addendum § Manual verification recipe), not a unit test, mirroring
6
+ // video-comp.test.ts's own split between the pure seek-bridge routing (tested
7
+ // here) and live Player rendering (not). What IS pure + testable: `findCompIdIn`
8
+ // (DOM containment resolution) and the `__maude_render_video__` bridge's
9
+ // routing contract — it must merge the registered comp's component/meta with
10
+ // the caller's opts and delegate to the injected `__maudeRenderVideo__`, and
11
+ // fail clearly when either the comp or the render-lib injection is missing.
12
+
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
14
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
15
+
16
+ import { findCompIdIn, installMaudeSeekBridge } from '../video-comp.tsx';
17
+
18
+ interface RenderCall {
19
+ component: unknown;
20
+ inputProps: unknown;
21
+ width: number;
22
+ height: number;
23
+ fps: number;
24
+ durationInFrames: number;
25
+ [k: string]: unknown;
26
+ }
27
+
28
+ interface RenderWindow {
29
+ __maudeVideoComps?: Map<
30
+ string,
31
+ {
32
+ id: string;
33
+ fps: number;
34
+ durationInFrames: number;
35
+ width: number;
36
+ height: number;
37
+ ref: { current: null };
38
+ component: unknown;
39
+ inputProps: Record<string, unknown>;
40
+ }
41
+ >;
42
+ __maude_render_video__?: (compId: string, opts: Record<string, unknown>) => Promise<unknown>;
43
+ __maudeRenderVideo__?: (opts: RenderCall) => Promise<unknown>;
44
+ }
45
+
46
+ function registerComp(
47
+ id: string,
48
+ meta: { fps: number; durationInFrames: number; width: number; height: number },
49
+ component: unknown,
50
+ inputProps: Record<string, unknown> = {}
51
+ ): void {
52
+ const w = window as unknown as RenderWindow;
53
+ if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
54
+ w.__maudeVideoComps.set(id, { id, ...meta, ref: { current: null }, component, inputProps });
55
+ }
56
+
57
+ beforeAll(() => {
58
+ GlobalRegistrator.register();
59
+ });
60
+
61
+ afterAll(async () => {
62
+ await GlobalRegistrator.unregister();
63
+ });
64
+
65
+ beforeEach(() => {
66
+ const w = window as unknown as RenderWindow;
67
+ w.__maudeVideoComps = undefined;
68
+ w.__maudeRenderVideo__ = undefined;
69
+ (w as unknown as { __maude_render_video__?: unknown }).__maude_render_video__ = undefined;
70
+ (w as unknown as { __maude_seek__?: unknown }).__maude_seek__ = undefined;
71
+ (w as unknown as { __maude_comps__?: unknown }).__maude_comps__ = undefined;
72
+ });
73
+
74
+ describe('findCompIdIn', () => {
75
+ test('resolves the nearest [data-comp-id] descendant', () => {
76
+ document.body.innerHTML =
77
+ '<div data-dc-screen="reel"><div data-comp-id="videocomp-2"><span>x</span></div></div>';
78
+ const artboard = document.querySelector('[data-dc-screen="reel"]');
79
+ expect(artboard).not.toBeNull();
80
+ expect(findCompIdIn(artboard as Element)).toBe('videocomp-2');
81
+ });
82
+
83
+ test('returns null for an artboard with no registered comp (ordinary/CSS artboard)', () => {
84
+ document.body.innerHTML = '<div data-dc-screen="static"><p>just a div</p></div>';
85
+ const artboard = document.querySelector('[data-dc-screen="static"]');
86
+ expect(findCompIdIn(artboard as Element)).toBeNull();
87
+ });
88
+ });
89
+
90
+ describe('__maude_render_video__ bridge', () => {
91
+ test('merges the registered component + meta with caller opts, delegates to __maudeRenderVideo__', async () => {
92
+ const w = window as unknown as RenderWindow;
93
+ installMaudeSeekBridge();
94
+ const FakeComp = () => null;
95
+ registerComp('reel', { fps: 30, durationInFrames: 228, width: 960, height: 540 }, FakeComp, {
96
+ title: 'Showreel',
97
+ });
98
+ let received: RenderCall | null = null;
99
+ w.__maudeRenderVideo__ = async (opts) => {
100
+ received = opts;
101
+ return { b64: 'AA==', bytes: 2, container: 'mp4', videoCodec: 'h264', audioCodec: 'aac' };
102
+ };
103
+ const result = await w.__maude_render_video__?.('reel', {
104
+ container: 'mp4',
105
+ scale: 2,
106
+ muted: false,
107
+ frameRange: [0, 30],
108
+ });
109
+ expect(result).toEqual({
110
+ b64: 'AA==',
111
+ bytes: 2,
112
+ container: 'mp4',
113
+ videoCodec: 'h264',
114
+ audioCodec: 'aac',
115
+ });
116
+ expect(received).not.toBeNull();
117
+ const call = received as unknown as RenderCall;
118
+ expect(call.component).toBe(FakeComp);
119
+ expect(call.inputProps).toEqual({ title: 'Showreel' });
120
+ expect(call.width).toBe(960);
121
+ expect(call.height).toBe(540);
122
+ expect(call.fps).toBe(30);
123
+ expect(call.durationInFrames).toBe(228);
124
+ // Caller opts pass through alongside the merged meta.
125
+ expect(call.container).toBe('mp4');
126
+ expect(call.scale).toBe(2);
127
+ expect(call.muted).toBe(false);
128
+ expect(call.frameRange).toEqual([0, 30]);
129
+ });
130
+
131
+ test('throws a clear error for an unregistered compId', async () => {
132
+ const w = window as unknown as RenderWindow;
133
+ installMaudeSeekBridge();
134
+ await expect(w.__maude_render_video__?.('nope', { container: 'mp4' })).rejects.toThrow(
135
+ /no registered video-comp/i
136
+ );
137
+ });
138
+
139
+ test('throws a clear error when the render-lib bundle was never injected', async () => {
140
+ const w = window as unknown as RenderWindow;
141
+ installMaudeSeekBridge();
142
+ registerComp('reel', { fps: 30, durationInFrames: 90, width: 960, height: 540 }, () => null);
143
+ // __maudeRenderVideo__ intentionally left unset (simulates a capture that
144
+ // skipped addScriptTag — e.g. gif/ordinary-artboard export never injects it).
145
+ await expect(w.__maude_render_video__?.('reel', { container: 'mp4' })).rejects.toThrow(
146
+ /render-lib bundle not injected/i
147
+ );
148
+ });
149
+ });
@@ -159,6 +159,7 @@ function isResizable(
159
159
  s.tool === 'sticky' ||
160
160
  s.tool === 'image' ||
161
161
  s.tool === 'link' ||
162
+ s.tool === 'mediaref' ||
162
163
  s.tool === 'section'
163
164
  );
164
165
  }
@@ -374,11 +375,13 @@ export function resizeStroke(
374
375
  start.tool === 'sticky' ||
375
376
  start.tool === 'polygon' ||
376
377
  start.tool === 'link' ||
378
+ start.tool === 'mediaref' ||
377
379
  start.tool === 'section'
378
380
  ) {
379
- // Rect / polygon / sticky / link all resize via their shared x / y / w / h
380
- // bbox. Text re-wraps inside the foreignObject automatically. Sticky stays
381
- // 1:1; the link card free-resizes (Shift still locks its current ratio).
381
+ // Rect / polygon / sticky / link / mediaref all resize via their shared
382
+ // x / y / w / h bbox. Sticky stays 1:1; the link + media-ref cards
383
+ // free-resize (Shift still locks their current ratio) so the user can size
384
+ // the inline player up (DDR-150 dogfood — "video annotation nejde zvětšit").
382
385
  const b0 = { x: start.x, y: start.y, w: start.w, h: start.h };
383
386
  const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, start.tool === 'sticky');
384
387
  return box as Partial<RectStroke | StickyStroke | PolygonStroke | LinkStroke>;
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * @file use-canvas-media-drop.tsx — Phase 23 canvas media intake
3
3
  * @scope apps/studio/use-canvas-media-drop.tsx
4
- * @purpose OS-level drag-and-drop + clipboard paste of images and URLs onto
5
- * the canvas. Routes each gesture to a create callback owned by
6
- * AnnotationsLayer (which holds the commit/undo sink + screenToWorld):
4
+ * @purpose OS-level drag-and-drop + clipboard paste of images, video/audio,
5
+ * and URLs onto the canvas. Routes each gesture to a create callback
6
+ * owned by AnnotationsLayer (which holds the commit/undo sink +
7
+ * screenToWorld):
7
8
  * • image file → `onImage(file, world)` → optimistic stroke + upload
9
+ * • video/audio → `onMedia?(file, kind, world)` → upload to assets/
10
+ * + toast the `<Video>`/`<Audio>` snippet (DDR-148; auto-insert
11
+ * into the comp TSX is a documented follow-up)
8
12
  * • http(s) URL → `onLink(url, title, world)` → client-only link chip
9
13
  *
10
14
  * The classification + URL helpers are PURE + exported so the unit
@@ -35,6 +39,7 @@ export interface MediaPayload {
35
39
 
36
40
  export type MediaIntent =
37
41
  | { kind: 'image'; file: File }
42
+ | { kind: 'media'; file: File; mediaKind: 'video' | 'audio' }
38
43
  | { kind: 'link'; url: string; title: string };
39
44
 
40
45
  /** True only for an absolute http(s) URL — the one scheme a link chip accepts. */
@@ -136,6 +141,19 @@ export function anchorTextFromHtml(html: string): string | null {
136
141
  export function classifyMediaPayload(p: MediaPayload): MediaIntent | null {
137
142
  const imageFile = p.files.find((f) => typeof f.type === 'string' && f.type.startsWith('image/'));
138
143
  if (imageFile) return { kind: 'image', file: imageFile };
144
+ // DDR-148 — video/audio files land in the project's assets/ (the widened asset
145
+ // route, DDR-088) so a comp can reference them as <Video>/<Audio src="assets/…">.
146
+ const mediaFile = p.files.find(
147
+ (f) =>
148
+ typeof f.type === 'string' && (f.type.startsWith('video/') || f.type.startsWith('audio/'))
149
+ );
150
+ if (mediaFile) {
151
+ return {
152
+ kind: 'media',
153
+ file: mediaFile,
154
+ mediaKind: mediaFile.type.startsWith('video/') ? 'video' : 'audio',
155
+ };
156
+ }
139
157
  const url = firstHttpUrl(p.uriList) ?? firstHttpUrl(p.plain);
140
158
  if (url) {
141
159
  const title = anchorTextFromHtml(p.html) ?? prettifyUrl(url);
@@ -242,6 +260,45 @@ export function showCanvasToast(message: string): void {
242
260
  export interface MediaDropCallbacks {
243
261
  onImage: (file: File, world: [number, number]) => void;
244
262
  onLink: (url: string, title: string, world: [number, number]) => void;
263
+ /**
264
+ * DDR-148 — a video/audio file was dropped. Optional: when omitted, the hook
265
+ * uploads it to `assets/` and toasts a ready-to-use `<Video>`/`<Audio>`
266
+ * snippet (the drop-then-reference flow). A future host can provide this to
267
+ * auto-insert the element into the composition TSX (the deferred slice — the
268
+ * comp-placement question, see DDR-148 open items).
269
+ */
270
+ onMedia?: (file: File, mediaKind: 'video' | 'audio', world: [number, number]) => void;
271
+ }
272
+
273
+ /** The snippet the toast surfaces so the dropped clip is one paste away. */
274
+ export function mediaSnippet(mediaKind: 'video' | 'audio', assetPath: string): string {
275
+ if (mediaKind === 'audio') return `<Audio src="${assetPath}" />`;
276
+ return `<Video src="${assetPath}" />`;
277
+ }
278
+
279
+ /**
280
+ * Default media-drop handler: upload the file to the widened asset route, then
281
+ * toast the `assets/…` path + the snippet to paste into the comp. Copies the
282
+ * snippet to the clipboard when available. Exported for reuse/testing.
283
+ */
284
+ export async function uploadAndAnnounceMedia(
285
+ file: File,
286
+ mediaKind: 'video' | 'audio'
287
+ ): Promise<void> {
288
+ const sizeMb = file.size / (1024 * 1024);
289
+ const res = await uploadAsset(file);
290
+ if ('error' in res) {
291
+ showCanvasToast(`Couldn't add ${mediaKind}: ${res.error}`);
292
+ return;
293
+ }
294
+ const snippet = mediaSnippet(mediaKind, res.path);
295
+ try {
296
+ await navigator.clipboard?.writeText(snippet);
297
+ } catch {
298
+ /* clipboard unavailable — the toast still shows the snippet */
299
+ }
300
+ const warn = sizeMb > 20 ? ' · ⚠ >20 MB rides git + sync' : '';
301
+ showCanvasToast(`Added ${res.path}${warn} — snippet copied: ${snippet}`);
245
302
  }
246
303
 
247
304
  export function useCanvasMediaDrop(opts: {
@@ -317,7 +374,12 @@ export function useCanvasMediaDrop(opts: {
317
374
 
318
375
  const dispatchIntent = (intent: MediaIntent, world: [number, number]) => {
319
376
  if (intent.kind === 'image') callbacks.onImage(intent.file, world);
320
- else if (isHttpUrl(intent.url)) callbacks.onLink(intent.url, intent.title, world);
377
+ else if (intent.kind === 'media') {
378
+ if (callbacks.onMedia) callbacks.onMedia(intent.file, intent.mediaKind, world);
379
+ else void uploadAndAnnounceMedia(intent.file, intent.mediaKind);
380
+ } else if (intent.kind === 'link' && isHttpUrl(intent.url)) {
381
+ callbacks.onLink(intent.url, intent.title, world);
382
+ }
321
383
  };
322
384
 
323
385
  document.addEventListener('dragover', onDragOver);