@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.
- package/README.md +1 -1
- package/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +306 -5
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +1316 -67
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- 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/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +2625 -111
- package/apps/studio/canvas-lib.tsx +238 -2
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +514 -20
- package/apps/studio/client/app.jsx +2480 -52
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +15 -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 +155 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +16 -6
- package/apps/studio/context.ts +113 -1
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +89 -17
- package/apps/studio/dist/comment-mount.js +59 -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/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +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/history.ts +47 -1
- package/apps/studio/http.ts +758 -27
- package/apps/studio/input-router.tsx +19 -1
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +140 -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 +54 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -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/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -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/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -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/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +55 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// video-comp — DDR-148 seek-bridge unit coverage.
|
|
2
|
+
//
|
|
3
|
+
// The Remotion Player's actual frame render needs a real browser (rAF, layout,
|
|
4
|
+
// media) — that determinism ("two seeks to frame N → identical pixels") is an
|
|
5
|
+
// agent-browser live check (Task 11). What IS pure + testable here is the seek
|
|
6
|
+
// BRIDGE the capture spine + Timeline panel drive: the comp registry, the
|
|
7
|
+
// clamped seek routing to each Player, and the serializable snapshot. We stand
|
|
8
|
+
// up happy-dom, install the bridge without mounting a Player, register a fake
|
|
9
|
+
// Player handle (a seekTo/pause spy), and assert the routing contract.
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
12
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
13
|
+
|
|
14
|
+
import { installMaudeSeekBridge } from '../video-comp.tsx';
|
|
15
|
+
|
|
16
|
+
interface FakePlayer {
|
|
17
|
+
seekTo: (frame: number) => void;
|
|
18
|
+
pause: () => void;
|
|
19
|
+
seeks: number[];
|
|
20
|
+
pauses: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SeekWindow {
|
|
24
|
+
__maudeVideoComps?: Map<
|
|
25
|
+
string,
|
|
26
|
+
{
|
|
27
|
+
id: string;
|
|
28
|
+
fps: number;
|
|
29
|
+
durationInFrames: number;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
ref: { current: FakePlayer | null };
|
|
33
|
+
}
|
|
34
|
+
>;
|
|
35
|
+
__maude_seek__?: (frame: number, opts?: { fps?: number }) => Promise<void>;
|
|
36
|
+
__maude_comps__?: () => Array<{
|
|
37
|
+
id: string;
|
|
38
|
+
fps: number;
|
|
39
|
+
durationInFrames: number;
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeFakePlayer(): FakePlayer {
|
|
46
|
+
const p: FakePlayer = {
|
|
47
|
+
seeks: [],
|
|
48
|
+
pauses: 0,
|
|
49
|
+
seekTo(frame) {
|
|
50
|
+
p.seeks.push(frame);
|
|
51
|
+
},
|
|
52
|
+
pause() {
|
|
53
|
+
p.pauses += 1;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return p;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function registerComp(
|
|
60
|
+
id: string,
|
|
61
|
+
meta: { fps: number; durationInFrames: number; width: number; height: number },
|
|
62
|
+
player: FakePlayer
|
|
63
|
+
): void {
|
|
64
|
+
const w = window as unknown as SeekWindow;
|
|
65
|
+
if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
|
|
66
|
+
w.__maudeVideoComps.set(id, { id, ...meta, ref: { current: player } });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
beforeAll(() => {
|
|
70
|
+
GlobalRegistrator.register();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterAll(async () => {
|
|
74
|
+
await GlobalRegistrator.unregister();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
const w = window as unknown as SeekWindow;
|
|
79
|
+
w.__maudeVideoComps = undefined;
|
|
80
|
+
w.__maude_seek__ = undefined;
|
|
81
|
+
w.__maude_comps__ = undefined;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('video-comp seek bridge', () => {
|
|
85
|
+
test('installs window.__maude_seek__ + __maude_comps__ idempotently', () => {
|
|
86
|
+
const w = window as unknown as SeekWindow;
|
|
87
|
+
installMaudeSeekBridge();
|
|
88
|
+
const seek1 = w.__maude_seek__;
|
|
89
|
+
const comps1 = w.__maude_comps__;
|
|
90
|
+
expect(typeof seek1).toBe('function');
|
|
91
|
+
expect(typeof comps1).toBe('function');
|
|
92
|
+
installMaudeSeekBridge();
|
|
93
|
+
// Second install must NOT replace the functions (idempotent).
|
|
94
|
+
expect(w.__maude_seek__).toBe(seek1);
|
|
95
|
+
expect(w.__maude_comps__).toBe(comps1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('__maude_comps__ reflects the live registry (serializable, no functions)', () => {
|
|
99
|
+
const w = window as unknown as SeekWindow;
|
|
100
|
+
installMaudeSeekBridge();
|
|
101
|
+
registerComp(
|
|
102
|
+
'intro',
|
|
103
|
+
{ fps: 30, durationInFrames: 90, width: 640, height: 360 },
|
|
104
|
+
makeFakePlayer()
|
|
105
|
+
);
|
|
106
|
+
const comps = w.__maude_comps__?.() ?? [];
|
|
107
|
+
expect(comps).toHaveLength(1);
|
|
108
|
+
expect(comps[0]).toEqual({
|
|
109
|
+
id: 'intro',
|
|
110
|
+
fps: 30,
|
|
111
|
+
durationInFrames: 90,
|
|
112
|
+
width: 640,
|
|
113
|
+
height: 360,
|
|
114
|
+
});
|
|
115
|
+
// No ref/functions leak into the snapshot.
|
|
116
|
+
expect(Object.keys(comps[0])).not.toContain('ref');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('seek routes to every registered Player, paused first', async () => {
|
|
120
|
+
const w = window as unknown as SeekWindow;
|
|
121
|
+
installMaudeSeekBridge();
|
|
122
|
+
const a = makeFakePlayer();
|
|
123
|
+
const b = makeFakePlayer();
|
|
124
|
+
registerComp('a', { fps: 30, durationInFrames: 90, width: 640, height: 360 }, a);
|
|
125
|
+
registerComp('b', { fps: 24, durationInFrames: 48, width: 320, height: 240 }, b);
|
|
126
|
+
await w.__maude_seek__?.(30);
|
|
127
|
+
expect(a.seeks).toEqual([30]);
|
|
128
|
+
expect(b.seeks).toEqual([30]);
|
|
129
|
+
expect(a.pauses).toBe(1);
|
|
130
|
+
expect(b.pauses).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('seek clamps into [0, durationInFrames-1] per comp + rounds', async () => {
|
|
134
|
+
const w = window as unknown as SeekWindow;
|
|
135
|
+
installMaudeSeekBridge();
|
|
136
|
+
const a = makeFakePlayer();
|
|
137
|
+
registerComp('a', { fps: 30, durationInFrames: 90, width: 640, height: 360 }, a);
|
|
138
|
+
await w.__maude_seek__?.(999); // past the end → clamp to 89
|
|
139
|
+
await w.__maude_seek__?.(-5); // before the start → clamp to 0
|
|
140
|
+
await w.__maude_seek__?.(12.6); // fractional → round to 13
|
|
141
|
+
expect(a.seeks).toEqual([89, 0, 13]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('two seeks to the same frame issue identical Player calls (determinism contract)', async () => {
|
|
145
|
+
const w = window as unknown as SeekWindow;
|
|
146
|
+
installMaudeSeekBridge();
|
|
147
|
+
const a = makeFakePlayer();
|
|
148
|
+
registerComp('a', { fps: 30, durationInFrames: 90, width: 640, height: 360 }, a);
|
|
149
|
+
await w.__maude_seek__?.(42);
|
|
150
|
+
await w.__maude_seek__?.(42);
|
|
151
|
+
expect(a.seeks).toEqual([42, 42]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a Player ref that is not yet ready (null) does not throw', async () => {
|
|
155
|
+
const w = window as unknown as SeekWindow;
|
|
156
|
+
installMaudeSeekBridge();
|
|
157
|
+
if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
|
|
158
|
+
w.__maudeVideoComps.set('pending', {
|
|
159
|
+
id: 'pending',
|
|
160
|
+
fps: 30,
|
|
161
|
+
durationInFrames: 90,
|
|
162
|
+
width: 640,
|
|
163
|
+
height: 360,
|
|
164
|
+
ref: { current: null },
|
|
165
|
+
});
|
|
166
|
+
await expect(w.__maude_seek__?.(10)).resolves.toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -16,7 +16,9 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
16
16
|
|
|
17
17
|
import {
|
|
18
18
|
IconChevronDown,
|
|
19
|
+
IconLetterA,
|
|
19
20
|
IconPresentation,
|
|
21
|
+
IconSquare,
|
|
20
22
|
SHAPE_KIND_ICONS,
|
|
21
23
|
TOOL_ICONS,
|
|
22
24
|
} from './canvas-icons.tsx';
|
|
@@ -214,6 +216,35 @@ const PALETTE_CSS = `
|
|
|
214
216
|
background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 14%, transparent);
|
|
215
217
|
color: var(--maude-hud-accent, #d63b1f);
|
|
216
218
|
}
|
|
219
|
+
/* Stage I3 tail — "+ Element" insert popover. A vertical labeled list (not the
|
|
220
|
+
shape popover's icon grid): Div/Text/Image have no recognizable glyph on
|
|
221
|
+
their own, and this is a rare, must-be-legible action, not a recalled tool. */
|
|
222
|
+
.dc-tool-palette .dc-tp-insert { position: relative; display: inline-flex; }
|
|
223
|
+
.dc-tp-insert-popover {
|
|
224
|
+
position: absolute;
|
|
225
|
+
bottom: calc(100% + 8px);
|
|
226
|
+
left: 50%;
|
|
227
|
+
transform: translateX(-50%);
|
|
228
|
+
display: flex;
|
|
229
|
+
flex-direction: column;
|
|
230
|
+
gap: 2px;
|
|
231
|
+
padding: 4px;
|
|
232
|
+
min-width: 128px;
|
|
233
|
+
background: var(--maude-chrome-bg-0, #ffffff);
|
|
234
|
+
border: 1px solid var(--maude-chrome-fg-0, #1c1917);
|
|
235
|
+
border-radius: 8px;
|
|
236
|
+
box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
|
|
237
|
+
z-index: 8;
|
|
238
|
+
}
|
|
239
|
+
.dc-tp-insert-popover button {
|
|
240
|
+
width: auto;
|
|
241
|
+
height: auto;
|
|
242
|
+
justify-content: flex-start;
|
|
243
|
+
gap: 8px;
|
|
244
|
+
padding: 6px 8px;
|
|
245
|
+
border-radius: 4px;
|
|
246
|
+
}
|
|
247
|
+
.dc-tp-insert-popover button span.dc-tp-insert-label { font-size: 12px; }
|
|
217
248
|
`.trim();
|
|
218
249
|
|
|
219
250
|
function ensurePaletteStyles(): void {
|
|
@@ -250,6 +281,34 @@ const SHAPE_KINDS: ReadonlyArray<{ kind: ShapeKind; label: string }> = [
|
|
|
250
281
|
{ kind: 'triangle-down', label: 'Triangle down' },
|
|
251
282
|
];
|
|
252
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Stage I3 tail — resolve the insert anchor for the tool-palette "+ Element"
|
|
286
|
+
* affordance's "or appends to the active artboard" mode (the plan's fallback
|
|
287
|
+
* when the user hasn't right-clicked a specific element). Growth-only: an
|
|
288
|
+
* artboard's `<article data-dc-screen>` carries NO `data-cd-id` of its own
|
|
289
|
+
* (`DCArtboard` doesn't forward it to the DOM — only its SOURCE JSX tag is
|
|
290
|
+
* stamped, unresolvable from here), so `applyInsertElement`'s `refId` can't
|
|
291
|
+
* target the artboard directly. Anchor on the artboard's LAST direct child
|
|
292
|
+
* that DOES carry a `data-cd-id` and insert `position:'after'` it — the exact
|
|
293
|
+
* relation the context-menu's element-relative insert already uses. Returns
|
|
294
|
+
* `null` for an empty (or artboard-less) canvas — a documented v1 limit; the
|
|
295
|
+
* per-element context-menu "Insert ▸ …" still works there once a first
|
|
296
|
+
* element exists. `doc` is injectable for testing without a real iframe.
|
|
297
|
+
*/
|
|
298
|
+
export function resolveInsertAnchor(
|
|
299
|
+
doc: Document = document
|
|
300
|
+
): { refId: string; position: 'after' } | null {
|
|
301
|
+
const current = doc.querySelector('[data-dc-screen][aria-current="true"]');
|
|
302
|
+
const artboardEl = current ?? doc.querySelector('[data-dc-screen]');
|
|
303
|
+
if (!artboardEl) return null;
|
|
304
|
+
const body = artboardEl.querySelector(':scope > .dc-artboard-body');
|
|
305
|
+
if (!body) return null;
|
|
306
|
+
const kids = Array.from(body.children).reverse();
|
|
307
|
+
const last = kids.find((el) => el.hasAttribute('data-cd-id'));
|
|
308
|
+
const refId = last?.getAttribute('data-cd-id');
|
|
309
|
+
return refId ? { refId, position: 'after' } : null;
|
|
310
|
+
}
|
|
311
|
+
|
|
253
312
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
254
313
|
// Component
|
|
255
314
|
|
|
@@ -259,19 +318,22 @@ export function ToolPalette() {
|
|
|
259
318
|
const chrome = useChromeVisibility();
|
|
260
319
|
const [mounted, setMounted] = useState(false);
|
|
261
320
|
const [shapeOpen, setShapeOpen] = useState(false);
|
|
321
|
+
const [insertOpen, setInsertOpen] = useState(false);
|
|
262
322
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
263
323
|
|
|
264
324
|
useEffect(() => setMounted(true), []);
|
|
265
325
|
useEffect(() => {
|
|
266
|
-
if (!shapeOpen) return;
|
|
326
|
+
if (!shapeOpen && !insertOpen) return;
|
|
267
327
|
const onDown = (e: PointerEvent) => {
|
|
268
328
|
if (!containerRef.current?.contains(e.target as Node)) {
|
|
269
329
|
setShapeOpen(false);
|
|
330
|
+
setInsertOpen(false);
|
|
270
331
|
}
|
|
271
332
|
};
|
|
272
333
|
const onKey = (e: KeyboardEvent) => {
|
|
273
334
|
if (e.key === 'Escape') {
|
|
274
335
|
setShapeOpen(false);
|
|
336
|
+
setInsertOpen(false);
|
|
275
337
|
}
|
|
276
338
|
};
|
|
277
339
|
document.addEventListener('pointerdown', onDown, true);
|
|
@@ -280,12 +342,38 @@ export function ToolPalette() {
|
|
|
280
342
|
document.removeEventListener('pointerdown', onDown, true);
|
|
281
343
|
document.removeEventListener('keydown', onKey, true);
|
|
282
344
|
};
|
|
283
|
-
}, [shapeOpen]);
|
|
345
|
+
}, [shapeOpen, insertOpen]);
|
|
284
346
|
// Close the shape popover whenever the tool changes away from Shape.
|
|
285
347
|
useEffect(() => {
|
|
286
348
|
if (tool !== 'shape') setShapeOpen(false);
|
|
287
349
|
}, [tool]);
|
|
288
350
|
|
|
351
|
+
// Stage I3 tail — insert a Div/Text/Image relative to the active artboard's
|
|
352
|
+
// last existing element (`resolveInsertAnchor`). Same request verbs the
|
|
353
|
+
// context-menu's per-element "Insert ▸ …" already posts (`insert-request` /
|
|
354
|
+
// `insert-image-request`, main-origin shell WRITES — DDR-054); a null anchor
|
|
355
|
+
// (empty/artboard-less canvas) is a silent no-op — a documented v1 limit.
|
|
356
|
+
const insertViaPalette = (kind: 'div' | 'text' | 'image') => {
|
|
357
|
+
setInsertOpen(false);
|
|
358
|
+
const anchor = resolveInsertAnchor();
|
|
359
|
+
if (!anchor) return;
|
|
360
|
+
try {
|
|
361
|
+
if (kind === 'image') {
|
|
362
|
+
window.parent.postMessage(
|
|
363
|
+
{ dgn: 'insert-image-request', refId: anchor.refId, position: anchor.position },
|
|
364
|
+
'*'
|
|
365
|
+
);
|
|
366
|
+
} else {
|
|
367
|
+
window.parent.postMessage(
|
|
368
|
+
{ dgn: 'insert-request', refId: anchor.refId, position: anchor.position, kind },
|
|
369
|
+
'*'
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
/* detached / cross-origin teardown */
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
289
377
|
if (!mounted) return null;
|
|
290
378
|
// Presentation Mode hides the whole tool palette (clean artboards-only view).
|
|
291
379
|
if (chrome?.present) return null;
|
|
@@ -393,6 +481,38 @@ export function ToolPalette() {
|
|
|
393
481
|
</div>
|
|
394
482
|
<div className="dc-tp-sep" />
|
|
395
483
|
<div className="dc-tp-group">
|
|
484
|
+
<span className="dc-tp-insert">
|
|
485
|
+
<button
|
|
486
|
+
type="button"
|
|
487
|
+
aria-label="Insert element — Div, Text, or Image"
|
|
488
|
+
aria-haspopup="menu"
|
|
489
|
+
aria-expanded={insertOpen}
|
|
490
|
+
title="Insert element (appends to the active artboard)"
|
|
491
|
+
onClick={() => setInsertOpen((o) => !o)}
|
|
492
|
+
>
|
|
493
|
+
<span aria-hidden="true" style={{ fontSize: 16, lineHeight: 1 }}>
|
|
494
|
+
+
|
|
495
|
+
</span>
|
|
496
|
+
</button>
|
|
497
|
+
{insertOpen ? (
|
|
498
|
+
<div className="dc-tp-insert-popover" role="menu" aria-label="Insert element">
|
|
499
|
+
<button type="button" role="menuitem" onClick={() => insertViaPalette('div')}>
|
|
500
|
+
<IconSquare />
|
|
501
|
+
<span className="dc-tp-insert-label">Div</span>
|
|
502
|
+
</button>
|
|
503
|
+
<button type="button" role="menuitem" onClick={() => insertViaPalette('text')}>
|
|
504
|
+
<IconLetterA />
|
|
505
|
+
<span className="dc-tp-insert-label">Text</span>
|
|
506
|
+
</button>
|
|
507
|
+
<button type="button" role="menuitem" onClick={() => insertViaPalette('image')}>
|
|
508
|
+
<span aria-hidden="true" style={{ fontSize: 13, lineHeight: 1 }}>
|
|
509
|
+
▧
|
|
510
|
+
</span>
|
|
511
|
+
<span className="dc-tp-insert-label">Image</span>
|
|
512
|
+
</button>
|
|
513
|
+
</div>
|
|
514
|
+
) : null}
|
|
515
|
+
</span>
|
|
396
516
|
<button
|
|
397
517
|
type="button"
|
|
398
518
|
aria-label="Export (⌘E)"
|
package/apps/studio/undo-hud.tsx
CHANGED
|
@@ -32,12 +32,12 @@ const HUD_CSS = `
|
|
|
32
32
|
user-select: none;
|
|
33
33
|
opacity: 0;
|
|
34
34
|
transform: translateY(-4px);
|
|
35
|
-
transition: opacity
|
|
35
|
+
transition: opacity 200ms linear, transform 200ms ease-out;
|
|
36
36
|
}
|
|
37
37
|
.dc-undo-hud[data-visible="true"] {
|
|
38
38
|
opacity: 1;
|
|
39
39
|
transform: translateY(0);
|
|
40
|
-
transition-duration:
|
|
40
|
+
transition-duration: 120ms;
|
|
41
41
|
}
|
|
42
42
|
@media (prefers-reduced-motion: reduce) {
|
|
43
43
|
.dc-undo-hud {
|
|
@@ -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);
|