@1agh/maude 0.39.0 → 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/acp/bridge.ts +144 -5
- package/apps/studio/acp/index.ts +11 -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/acp-bridge.test.ts +132 -0
- package/apps/studio/test/acp-commands.test.ts +8 -3
- 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/mock-acp-agent.mjs +24 -1
- 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 +36 -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
|
@@ -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
|
|
380
|
-
// bbox.
|
|
381
|
-
//
|
|
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
|
|
5
|
-
* the canvas. Routes each gesture to a create callback
|
|
6
|
-
* AnnotationsLayer (which holds the commit/undo sink +
|
|
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 (
|
|
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);
|