@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.
- package/README.md +1 -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/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/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 +27 -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
|
@@ -345,7 +345,13 @@ export function isEditableTarget(t: EventTarget | null): boolean {
|
|
|
345
345
|
*/
|
|
346
346
|
export function isOverlayTarget(t: EventTarget | null): boolean {
|
|
347
347
|
if (!t || !(t as Element).closest) return false;
|
|
348
|
-
|
|
348
|
+
// [data-mediaref-player] — the inline <video>/<audio controls> on a media
|
|
349
|
+
// reference chip (DDR-150 dogfood #8). The router must never claim (and
|
|
350
|
+
// preventDefault) pointerdowns over the player, or its native controls
|
|
351
|
+
// (play button, scrubber drag, volume) die under the move tool.
|
|
352
|
+
return !!(t as Element).closest(
|
|
353
|
+
'.cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, [data-mediaref-player]'
|
|
354
|
+
);
|
|
349
355
|
}
|
|
350
356
|
|
|
351
357
|
export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
@@ -79,6 +79,36 @@ export const RUNTIME_PACKAGES = [
|
|
|
79
79
|
// every real canvas) returned HTTP 500 "Bundle failed".
|
|
80
80
|
'lib0/decoding',
|
|
81
81
|
'lib0/encoding',
|
|
82
|
+
// DDR-148 — Remotion video-comp authoring. `remotion` carries the React
|
|
83
|
+
// context stack (RemotionEnvironment / Sequence / Timeline) that a video-comp
|
|
84
|
+
// canvas AND the `<Player>` preview BOTH consume — so it MUST be a single
|
|
85
|
+
// externalised bundle (importmap-shared), exactly like React itself, or the
|
|
86
|
+
// Player's provider and the composition's `useCurrentFrame()` bind to
|
|
87
|
+
// different context instances and the comp renders frozen at frame 0 (the
|
|
88
|
+
// dual-package hazard). `@remotion/player` + `@remotion/transitions` each
|
|
89
|
+
// externalise `remotion` (→ the one bundle) and INLINE their own stateless
|
|
90
|
+
// helpers — `remotion/no-react` (interpolate/random/validators/NoReactInternals,
|
|
91
|
+
// all pure), plus `@remotion/paths`/`@remotion/shapes` for transitions — which
|
|
92
|
+
// hold no cross-package state, so a per-bundle copy is harmless. NOT shipping
|
|
93
|
+
// `@remotion/renderer`/`@remotion/web-renderer` (export goes through the
|
|
94
|
+
// capture spine). Pre-built into dist/runtime + floored in .min-sizes.json.
|
|
95
|
+
'remotion',
|
|
96
|
+
'@remotion/player',
|
|
97
|
+
'@remotion/media',
|
|
98
|
+
'@remotion/transitions',
|
|
99
|
+
// Transition presentations are separate subpath modules by design (Remotion
|
|
100
|
+
// tree-shakes to what you import). But a canvas import specifier that isn't
|
|
101
|
+
// in RUNTIME_PACKAGES tries to resolve against the USER's node_modules at
|
|
102
|
+
// request-time Bun.build — which doesn't exist on an npm/marketplace install
|
|
103
|
+
// — so EVERY presentation the skill teaches must be pre-bundled + importmap-
|
|
104
|
+
// routed. v1 ships the six core wipes ("join 4 clips + crossfade" vocabulary);
|
|
105
|
+
// exotic presentations (dreamy-zoom, film-burn, …) are a documented follow-up.
|
|
106
|
+
'@remotion/transitions/fade',
|
|
107
|
+
'@remotion/transitions/slide',
|
|
108
|
+
'@remotion/transitions/wipe',
|
|
109
|
+
'@remotion/transitions/flip',
|
|
110
|
+
'@remotion/transitions/clock-wipe',
|
|
111
|
+
'@remotion/transitions/none',
|
|
82
112
|
] as const;
|
|
83
113
|
|
|
84
114
|
export type RuntimePackage = (typeof RUNTIME_PACKAGES)[number];
|
package/apps/studio/server.ts
CHANGED
|
@@ -18,13 +18,13 @@ import { spawn } from 'node:child_process';
|
|
|
18
18
|
|
|
19
19
|
import { createAcp } from './acp/index.ts';
|
|
20
20
|
import { createActivity } from './activity.ts';
|
|
21
|
-
import { createApi } from './api.ts';
|
|
21
|
+
import { ASSET_MAX_VIDEO_BYTES, createApi } from './api.ts';
|
|
22
22
|
import { bootSelfHeal } from './boot-self-heal.ts';
|
|
23
23
|
import { createCanvasListWatch } from './canvas-list-watch.ts';
|
|
24
24
|
import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts';
|
|
25
25
|
import { createGitLifecycle } from './collab/git-lifecycle.ts';
|
|
26
26
|
import { createCollab } from './collab/index.ts';
|
|
27
|
-
import { createContext } from './context.ts';
|
|
27
|
+
import { createContext, reloadConfig } from './context.ts';
|
|
28
28
|
import { createFsWatch } from './fs-watch.ts';
|
|
29
29
|
import { createGitWatch } from './git/watch.ts';
|
|
30
30
|
import { createHttp } from './http.ts';
|
|
@@ -124,13 +124,16 @@ const { port: BASE_PORT, explicit: PORT_EXPLICIT } = resolvePort();
|
|
|
124
124
|
|
|
125
125
|
type BunServer = ReturnType<typeof Bun.serve<WsData, never>>;
|
|
126
126
|
|
|
127
|
-
// Phase 23 security review (DDR-088
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
127
|
+
// Phase 23 security review (DDR-088) + DDR-148 — hard ceiling on a request
|
|
128
|
+
// body. Bun's 128 MB default would let the untrusted canvas origin send huge
|
|
129
|
+
// bodies to any route. DDR-088 pinned this at 16 MB. DDR-148 raises it to the
|
|
130
|
+
// video cap + 8 MB headroom so `POST /_api/asset` can accept a 100 MB clip —
|
|
131
|
+
// but that route now STREAMS the body to disk (saveAssetFromStream), so a big
|
|
132
|
+
// upload never lands as one ArrayBuffer in RAM. Other routes keep their small
|
|
133
|
+
// LOGICAL caps (annotation 1 MB, JSON) — a body over those still rejects; the
|
|
134
|
+
// only change is Bun accepts more bytes pre-handler (a bandwidth-bounded
|
|
135
|
+
// transient-buffer tradeoff on the buffering routes, noted in DDR-148).
|
|
136
|
+
const MAX_REQUEST_BODY = ASSET_MAX_VIDEO_BYTES + 8 * 1024 * 1024;
|
|
134
137
|
|
|
135
138
|
function startServer(port: number): BunServer {
|
|
136
139
|
return Bun.serve<WsData, never>({
|
|
@@ -401,6 +404,28 @@ const gitWatch = createGitWatch(ctx);
|
|
|
401
404
|
// `.ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md`.
|
|
402
405
|
const canvasListWatch = createCanvasListWatch(ctx);
|
|
403
406
|
|
|
407
|
+
// Config hot-reload — `/design:setup-ds` (or a hand edit) rewrites
|
|
408
|
+
// `.design/config.json` mid-session; without a re-read the server keeps
|
|
409
|
+
// serving the boot snapshot and a newly added canvas group (`system`) never
|
|
410
|
+
// reaches /_index-data, so scaffolded DS files stay invisible even on a
|
|
411
|
+
// manual tree reload. On change: swap ctx.cfg in place (reloadConfig), let
|
|
412
|
+
// canvasListWatch's set-diff emit `canvas-list-update` for canvases the new
|
|
413
|
+
// groups uncover, and tell shells to refetch /_config.
|
|
414
|
+
// RCA: .ai/logs/rca/issue-ds-scaffold-files-not-in-filetree-stale-config.md
|
|
415
|
+
const CONFIG_RELOAD_DEBOUNCE_MS = 150;
|
|
416
|
+
let configReloadTimer: ReturnType<typeof setTimeout> | null = null;
|
|
417
|
+
ctx.bus.on('fs:json', (rel: string) => {
|
|
418
|
+
if (rel.replace(/\\/g, '/').replace(/^\/+/, '') !== 'config.json') return;
|
|
419
|
+
if (configReloadTimer) clearTimeout(configReloadTimer);
|
|
420
|
+
configReloadTimer = setTimeout(() => {
|
|
421
|
+
configReloadTimer = null;
|
|
422
|
+
if (!reloadConfig(ctx)) return;
|
|
423
|
+
console.log(' config.json changed — reloaded live.');
|
|
424
|
+
void canvasListWatch.refresh();
|
|
425
|
+
ctx.bus.emit('config-updated');
|
|
426
|
+
}, CONFIG_RELOAD_DEBOUNCE_MS);
|
|
427
|
+
});
|
|
428
|
+
|
|
404
429
|
// Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
|
|
405
430
|
// linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
|
|
406
431
|
// off after fsWatch so the agent's bus subscription receives every fs event.
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type ArrowStroke,
|
|
17
17
|
type ImageStroke,
|
|
18
18
|
type LinkStroke,
|
|
19
|
+
type MediaRefStroke,
|
|
19
20
|
type RectStroke,
|
|
20
21
|
type StickyStroke,
|
|
21
22
|
type Stroke,
|
|
@@ -314,6 +315,52 @@ describe('annotations round-trip / link chip (Phase 23)', () => {
|
|
|
314
315
|
});
|
|
315
316
|
});
|
|
316
317
|
|
|
318
|
+
describe('annotations round-trip / media-reference chip (DDR-150 P4)', () => {
|
|
319
|
+
const ref: MediaRefStroke = {
|
|
320
|
+
id: 'mr1',
|
|
321
|
+
tool: 'mediaref',
|
|
322
|
+
x: 120,
|
|
323
|
+
y: 90,
|
|
324
|
+
w: 240,
|
|
325
|
+
h: 72,
|
|
326
|
+
src: 'assets/9f8e7d6c.mp4',
|
|
327
|
+
mediaKind: 'video',
|
|
328
|
+
title: 'b-roll-sunset.mp4',
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
test('mediaref survives serialize → parse with all fields intact', () => {
|
|
332
|
+
const [parsed] = svgToStrokes(strokesToSvg([ref])) as MediaRefStroke[];
|
|
333
|
+
expect(parsed).toEqual(ref);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('mediaref persists data-src (the agent enumerates refs off it), never a <Video>', () => {
|
|
337
|
+
const svg = strokesToSvg([ref]);
|
|
338
|
+
expect(svg).toContain('data-src="assets/9f8e7d6c.mp4"');
|
|
339
|
+
expect(svg).toContain('data-media-kind="video"');
|
|
340
|
+
expect(svg).not.toContain('<Video');
|
|
341
|
+
expect(svg).not.toContain('<video');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('mediaref serialize → parse is idempotent', () => {
|
|
345
|
+
const once = strokesToSvg([{ ...ref, mediaKind: 'audio', src: 'assets/track.mp3' }]);
|
|
346
|
+
expect(reparse(once)).toBe(once);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('mediaref SVG survives sanitizeAnnotationSvg byte-intact (PUT-path)', () => {
|
|
350
|
+
const svg = strokesToSvg([ref]);
|
|
351
|
+
expect(sanitizeAnnotationSvg(svg)).toBe(svg);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('a data-src with a traversal/scheme is dropped to an inert empty ref', () => {
|
|
355
|
+
const svg =
|
|
356
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
357
|
+
'<g data-id="mr2" data-tool="mediaref" data-src="../../etc/passwd" data-media-kind="video" data-title="x">' +
|
|
358
|
+
'<rect x="0" y="0" width="240" height="72" rx="8" ry="8"/></g></svg>';
|
|
359
|
+
const [parsed] = svgToStrokes(svg) as MediaRefStroke[];
|
|
360
|
+
expect(parsed?.src).toBe('');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
317
364
|
describe('annotations round-trip / standalone vs anchored text', () => {
|
|
318
365
|
test('standalone text round-trips its world (x, y) and stays unanchored', () => {
|
|
319
366
|
const t: TextStroke = {
|
|
@@ -122,6 +122,82 @@ describe('/_api/canvas — POST round-trip', () => {
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test('assembles a video-comp (kind: video-comp) from dropped clips (DDR-150 P4 Task 12)', async () => {
|
|
126
|
+
const { root, designRoot } = makeSandbox();
|
|
127
|
+
const port = nextPort();
|
|
128
|
+
const proc = await bootServer(root, port);
|
|
129
|
+
try {
|
|
130
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Content-Type': 'application/json' },
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
name: 'My Reel',
|
|
135
|
+
kind: 'video-comp',
|
|
136
|
+
fps: 30,
|
|
137
|
+
clips: [
|
|
138
|
+
{ src: 'assets/a.mp4', mediaKind: 'video', durationInFrames: 60 },
|
|
139
|
+
{ src: 'assets/b.mp4', mediaKind: 'video' },
|
|
140
|
+
{ src: 'assets/music.mp3', mediaKind: 'audio' },
|
|
141
|
+
],
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
expect(r.status).toBe(201);
|
|
145
|
+
const j = (await r.json()) as { ok: boolean; rel: string };
|
|
146
|
+
expect(j.ok).toBe(true);
|
|
147
|
+
const tsx = readFileSync(join(designRoot, 'ui', 'My Reel.tsx'), 'utf8');
|
|
148
|
+
expect(tsx).toContain('<VideoComp component={Comp}');
|
|
149
|
+
expect(tsx).toContain('<OffthreadVideo src="assets/a.mp4" />');
|
|
150
|
+
expect(tsx).toContain('<Audio src="assets/music.mp3" />');
|
|
151
|
+
expect(tsx).toContain('name="clip-1"');
|
|
152
|
+
const m = JSON.parse(readFileSync(join(designRoot, 'ui', 'My Reel.meta.json'), 'utf8'));
|
|
153
|
+
expect(m.kind).toBe('video-comp');
|
|
154
|
+
} finally {
|
|
155
|
+
await killProc(proc);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('rejects a video-comp with an empty clips[] (400) and a traversal src (400)', async () => {
|
|
160
|
+
const { root } = makeSandbox();
|
|
161
|
+
const port = nextPort();
|
|
162
|
+
const proc = await bootServer(root, port);
|
|
163
|
+
try {
|
|
164
|
+
const empty = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ name: 'Empty', kind: 'video-comp', clips: [] }),
|
|
168
|
+
});
|
|
169
|
+
expect(empty.status).toBe(400);
|
|
170
|
+
const bad = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
name: 'Bad',
|
|
175
|
+
kind: 'video-comp',
|
|
176
|
+
clips: [{ src: '../../etc/passwd', mediaKind: 'video' }],
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
expect(bad.status).toBe(400);
|
|
180
|
+
} finally {
|
|
181
|
+
await killProc(proc);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('rejects a cross-origin create (CSRF guard on /_api/canvas — DDR-150 P4)', async () => {
|
|
186
|
+
const { root } = makeSandbox();
|
|
187
|
+
const port = nextPort();
|
|
188
|
+
const proc = await bootServer(root, port);
|
|
189
|
+
try {
|
|
190
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
|
|
193
|
+
body: JSON.stringify({ name: 'Forged', kind: 'brief-board' }),
|
|
194
|
+
});
|
|
195
|
+
expect(r.status).toBe(403);
|
|
196
|
+
} finally {
|
|
197
|
+
await killProc(proc);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
125
201
|
test('create + delete emit canvas-list-update over the inspector WS (Phase 30)', async () => {
|
|
126
202
|
const { root } = makeSandbox();
|
|
127
203
|
const port = nextPort();
|
|
@@ -123,6 +123,59 @@ describe('canvas-edit / applyEdit', () => {
|
|
|
123
123
|
expect(out.source).toContain('<div style={{ padding: 14 }}>x</div>');
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
test('retime: bumps a referenced const so a derived total moves in lock-step (DDR-148)', async () => {
|
|
127
|
+
const { applyRetimeSequence } = await import('../canvas-edit.ts');
|
|
128
|
+
const src = [
|
|
129
|
+
'const A = 66;',
|
|
130
|
+
'const B = 62;',
|
|
131
|
+
'const XF = 16;',
|
|
132
|
+
'const TOTAL = A + B - XF;',
|
|
133
|
+
'function Movie(){ return (<TransitionSeries>',
|
|
134
|
+
' <TransitionSeries.Sequence durationInFrames={A}><Intro/></TransitionSeries.Sequence>',
|
|
135
|
+
' <TransitionSeries.Transition timing={linearTiming({ durationInFrames: XF })} />',
|
|
136
|
+
' <TransitionSeries.Sequence durationInFrames={B}><Payoff/></TransitionSeries.Sequence>',
|
|
137
|
+
'</TransitionSeries>); }',
|
|
138
|
+
].join('\n');
|
|
139
|
+
const r0 = applyRetimeSequence(CANVAS, src, 0, { durationInFrames: 80 });
|
|
140
|
+
expect(r0.source).toContain('const A = 80'); // const edited, not the literal
|
|
141
|
+
expect(r0.source).toContain('const TOTAL = A + B - XF'); // derived total intact → auto-updates
|
|
142
|
+
const r1 = applyRetimeSequence(CANVAS, r0.source, 1, { durationInFrames: 90 });
|
|
143
|
+
expect(r1.source).toContain('const B = 90');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('retime: edits a literal in place + supports from', async () => {
|
|
147
|
+
const { applyRetimeSequence } = await import('../canvas-edit.ts');
|
|
148
|
+
const src =
|
|
149
|
+
'function C(){ return <Sequence from={10} durationInFrames={40}><Foo/></Sequence>; }';
|
|
150
|
+
const r = applyRetimeSequence(CANVAS, src, 0, { from: 20, durationInFrames: 55 });
|
|
151
|
+
expect(r.source).toContain('from={20}');
|
|
152
|
+
expect(r.source).toContain('durationInFrames={55}');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('retime: an out-of-range sequence index throws', async () => {
|
|
156
|
+
const { applyRetimeSequence, CanvasEditError } = await import('../canvas-edit.ts');
|
|
157
|
+
const src = 'function C(){ return <Sequence durationInFrames={40}><Foo/></Sequence>; }';
|
|
158
|
+
expect(() => applyRetimeSequence(CANVAS, src, 5, { durationInFrames: 10 })).toThrow(
|
|
159
|
+
CanvasEditError
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('inserts into a style object with a TRAILING comma without a double comma (DDR-148 #4)', () => {
|
|
164
|
+
// A frame-driven inline style commonly ends the last property with a
|
|
165
|
+
// trailing comma. Appending must NOT produce `opacity: o, , color: …`
|
|
166
|
+
// (a syntax error that made the canvas render the OLD source on replay).
|
|
167
|
+
const src =
|
|
168
|
+
'function Demo() {\n const o = 1;\n return <div style={{ fontSize: 12, opacity: o, }}>x</div>;\n}';
|
|
169
|
+
const ids = idsOf(src);
|
|
170
|
+
const id = ids.div as string;
|
|
171
|
+
const out = applyEdit(CANVAS, src, id, 'style.color', "'#d48917'");
|
|
172
|
+
expect(out.source).toContain("color: '#d48917'");
|
|
173
|
+
expect(out.source).not.toMatch(/,\s*,/); // no double comma
|
|
174
|
+
// And it must still parse.
|
|
175
|
+
const reparse = applyEdit(CANVAS, out.source, id, 'style.fontSize', '13');
|
|
176
|
+
expect(reparse.source).toContain('fontSize: 13');
|
|
177
|
+
});
|
|
178
|
+
|
|
126
179
|
test('throws CanvasEditError when id is not found', () => {
|
|
127
180
|
const src = 'function Demo() { return <div>x</div>; }';
|
|
128
181
|
expect(() => applyEdit(CANVAS, src, 'deadbeef', 'className', 'x')).toThrow(CanvasEditError);
|
|
@@ -290,6 +343,30 @@ describe('canvas-edit / applyTextEdit', () => {
|
|
|
290
343
|
const src = `function Demo() { return <button>Save</button>; }`;
|
|
291
344
|
expect(() => applyTextEdit(CANVAS, src, 'deadbeef', 'x')).toThrow(CanvasEditError);
|
|
292
345
|
});
|
|
346
|
+
|
|
347
|
+
// DDR-150 P1 — widen the editable set to a single `{'string literal'}` child.
|
|
348
|
+
test('edits a single string-literal expression child', () => {
|
|
349
|
+
const src = `function Demo() { return <h1>{'Old Title'}</h1>; }`;
|
|
350
|
+
const id = idsOf(src).h1 as string;
|
|
351
|
+
const out = applyTextEdit(CANVAS, src, id, 'New Title');
|
|
352
|
+
expect(out.source).toBe(`function Demo() { return <h1>{"New Title"}</h1>; }`);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('string-literal rewrite is inert — quotes / braces / markup cannot escape the JS string', () => {
|
|
356
|
+
const src = `function Demo() { return <h1>{"x"}</h1>; }`;
|
|
357
|
+
const id = idsOf(src).h1 as string;
|
|
358
|
+
const out = applyTextEdit(CANVAS, src, id, 'say "hi" {expr} <b>');
|
|
359
|
+
// JSON.stringify escapes the double-quotes; {expr} / <b> stay INSIDE the JS
|
|
360
|
+
// string literal, so they never become a JSX expression or markup.
|
|
361
|
+
expect(out.source).toContain('say \\"hi\\"');
|
|
362
|
+
expect(() => transpileCanvasSource(CANVAS, out.source)).not.toThrow();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('still refuses a dynamic {identifier} child (routes to /design:edit)', () => {
|
|
366
|
+
const src = 'function Demo() { const title = "x"; return <h1>{title}</h1>; }';
|
|
367
|
+
const id = idsOf(src).h1 as string;
|
|
368
|
+
expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
|
|
369
|
+
});
|
|
293
370
|
});
|
|
294
371
|
|
|
295
372
|
// Phase 12 (DDR-103) — the `style.<prop>` write path the CSS knobs ride
|
|
@@ -316,4 +393,17 @@ describe('canvas-edit / applyEdit style.<prop> (CSS-knob path)', () => {
|
|
|
316
393
|
const out = applyEdit(CANVAS, src, id, 'style.padding', '"var(--space-3)"');
|
|
317
394
|
expect(out.source).toContain('style={{ padding: "var(--space-3)" }}');
|
|
318
395
|
});
|
|
396
|
+
|
|
397
|
+
// DDR-150 P1 — the exact video-comp shape: a var-valued prop ending in a
|
|
398
|
+
// trailing comma. Unconditionally prepending ", " produced `opacity: o, ,
|
|
399
|
+
// color` — a syntax error that left the Player rendering stale source (the
|
|
400
|
+
// "my CSS edit resets on replay" bug). Guarded by editStyleProp's between-scan.
|
|
401
|
+
test('appends onto a frame-driven inline style ending in a trailing comma (no double comma)', () => {
|
|
402
|
+
const src = `function Demo() { const o = 1; return <div style={{ opacity: o, }}>x</div>; }`;
|
|
403
|
+
const id = idsOf(src).div as string;
|
|
404
|
+
const out = applyEdit(CANVAS, src, id, 'style.color', '"var(--fg-0)"');
|
|
405
|
+
expect(out.source).not.toContain(', ,');
|
|
406
|
+
expect(out.source).toContain('color: "var(--fg-0)"');
|
|
407
|
+
expect(() => transpileCanvasSource(CANVAS, out.source)).not.toThrow();
|
|
408
|
+
});
|
|
319
409
|
});
|
|
@@ -320,3 +320,52 @@ describe('canvas-list-watch / real fs-watch end-to-end', () => {
|
|
|
320
320
|
}
|
|
321
321
|
}, 10_000);
|
|
322
322
|
});
|
|
323
|
+
|
|
324
|
+
describe('canvas-list-watch / config hot-reload (stale-config RCA)', () => {
|
|
325
|
+
// /design:setup-ds adds the `system` group to config.json mid-session;
|
|
326
|
+
// reloadConfig (context.ts) swaps ctx.cfg in place. The watcher must read
|
|
327
|
+
// groups at USE time — a boot-captured groupPaths list would keep gating
|
|
328
|
+
// the new group's files out forever.
|
|
329
|
+
// RCA: .ai/logs/rca/issue-ds-scaffold-files-not-in-filetree-stale-config.md
|
|
330
|
+
test('a group added by an in-place cfg swap uncovers its canvases', async () => {
|
|
331
|
+
const { root, designRoot } = sandbox();
|
|
332
|
+
mkdirSync(join(designRoot, 'system', 'kanban-glass', 'preview'), { recursive: true });
|
|
333
|
+
const ctx = mkCtx(designRoot);
|
|
334
|
+
// Pre-scaffold fixture shape: UI-only group.
|
|
335
|
+
ctx.cfg.canvasGroups = [{ label: 'UI', path: 'ui' }];
|
|
336
|
+
const got = collect(ctx);
|
|
337
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 10 });
|
|
338
|
+
await w.ready;
|
|
339
|
+
try {
|
|
340
|
+
// Scaffold writes specimens while the group is still absent — no emit.
|
|
341
|
+
writeFileSync(join(designRoot, 'system', 'kanban-glass', 'preview', 'colors.tsx'), TSX);
|
|
342
|
+
ctx.bus.emit('fs:any', 'system/kanban-glass/preview/colors.tsx');
|
|
343
|
+
await new Promise((r) => setTimeout(r, 10 + 40));
|
|
344
|
+
await w.refresh();
|
|
345
|
+
expect(got).toHaveLength(0);
|
|
346
|
+
|
|
347
|
+
// Hot reload: new array on the SAME cfg object (what reloadConfig does),
|
|
348
|
+
// then the server.ts subscriber calls refresh() — the diff must surface
|
|
349
|
+
// the pre-existing specimen.
|
|
350
|
+
ctx.cfg.canvasGroups = [
|
|
351
|
+
{ label: 'Design system', path: 'system' },
|
|
352
|
+
{ label: 'UI', path: 'ui' },
|
|
353
|
+
];
|
|
354
|
+
await w.refresh();
|
|
355
|
+
expect(got).toHaveLength(1);
|
|
356
|
+
expect(got[0]?.action).toBe('added');
|
|
357
|
+
expect(got[0]?.rel).toBe('system/kanban-glass/preview/colors.tsx');
|
|
358
|
+
|
|
359
|
+
// And FUTURE writes under the new group pass the live fs:any gate.
|
|
360
|
+
got.length = 0;
|
|
361
|
+
writeFileSync(join(designRoot, 'system', 'kanban-glass', 'preview', 'type.tsx'), TSX);
|
|
362
|
+
ctx.bus.emit('fs:any', 'system/kanban-glass/preview/type.tsx');
|
|
363
|
+
await new Promise((r) => setTimeout(r, 10 + 40));
|
|
364
|
+
await w.refresh();
|
|
365
|
+
expect(got.some((m) => m.action === 'added' && m.rel?.endsWith('type.tsx'))).toBe(true);
|
|
366
|
+
} finally {
|
|
367
|
+
w.stop();
|
|
368
|
+
rmSync(root, { recursive: true, force: true });
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isHttpUrl,
|
|
14
14
|
linkDomain,
|
|
15
15
|
type MediaPayload,
|
|
16
|
+
mediaSnippet,
|
|
16
17
|
normalizeUrl,
|
|
17
18
|
prettifyUrl,
|
|
18
19
|
} from '../use-canvas-media-drop.tsx';
|
|
@@ -116,10 +117,38 @@ describe('use-canvas-media-drop / classifyMediaPayload (dispatch matrix)', () =>
|
|
|
116
117
|
expect(intent?.kind).toBe('image');
|
|
117
118
|
});
|
|
118
119
|
|
|
119
|
-
test('a non-image file is ignored', () => {
|
|
120
|
+
test('a non-image, non-media file is ignored', () => {
|
|
120
121
|
expect(classifyMediaPayload({ ...empty, files: [fakeFile('application/pdf')] })).toBeNull();
|
|
121
122
|
});
|
|
122
123
|
|
|
124
|
+
test('a video file → a media intent (DDR-148)', () => {
|
|
125
|
+
const intent = classifyMediaPayload({ ...empty, files: [fakeFile('video/mp4')] });
|
|
126
|
+
expect(intent).toEqual({ kind: 'media', file: expect.anything(), mediaKind: 'video' });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('an audio file → a media intent', () => {
|
|
130
|
+
const intent = classifyMediaPayload({ ...empty, files: [fakeFile('audio/mpeg')] });
|
|
131
|
+
expect(intent?.kind).toBe('media');
|
|
132
|
+
expect(intent && intent.kind === 'media' && intent.mediaKind).toBe('audio');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('an image still wins over a co-dropped video', () => {
|
|
136
|
+
const intent = classifyMediaPayload({
|
|
137
|
+
...empty,
|
|
138
|
+
files: [fakeFile('image/png'), fakeFile('video/mp4')],
|
|
139
|
+
});
|
|
140
|
+
expect(intent?.kind).toBe('image');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('mediaSnippet emits the right Remotion element', () => {
|
|
144
|
+
expect(mediaSnippet('video', 'assets/ab12cd34.mp4')).toBe(
|
|
145
|
+
'<Video src="assets/ab12cd34.mp4" />'
|
|
146
|
+
);
|
|
147
|
+
expect(mediaSnippet('audio', 'assets/ff00aa11.mp3')).toBe(
|
|
148
|
+
'<Audio src="assets/ff00aa11.mp3" />'
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
123
152
|
test('a URL drop becomes a link with the anchor text as the title', () => {
|
|
124
153
|
const intent = classifyMediaPayload({
|
|
125
154
|
...empty,
|
|
@@ -67,6 +67,25 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
67
67
|
'/_api/edit-css',
|
|
68
68
|
'/_api/edit-text',
|
|
69
69
|
'/_api/edit-attr',
|
|
70
|
+
// DDR-148 — raw canvas source for the Timeline parser is MAIN-ORIGIN
|
|
71
|
+
// ONLY (the untrusted canvas iframe must never read project source).
|
|
72
|
+
'/_api/canvas-source',
|
|
73
|
+
// DDR-148 — Timeline drag-to-retime is a source-write, MAIN-ORIGIN ONLY.
|
|
74
|
+
'/_api/retime-sequence',
|
|
75
|
+
// DDR-150 P2 — the clip enumerator reads project source structure, so it
|
|
76
|
+
// is MAIN-ORIGIN ONLY (same boundary as /_api/canvas-source): the
|
|
77
|
+
// untrusted canvas iframe must never enumerate the comp it renders.
|
|
78
|
+
'/_api/comp-clips',
|
|
79
|
+
// DDR-150 P3 — clip removal is a source-write, MAIN-ORIGIN ONLY.
|
|
80
|
+
'/_api/remove-sequence',
|
|
81
|
+
// DDR-150 P4 — clip insert is a source-write, MAIN-ORIGIN ONLY.
|
|
82
|
+
'/_api/insert-sequence',
|
|
83
|
+
// DDR-150 P5 — z-order reorder is a source-write, MAIN-ORIGIN ONLY.
|
|
84
|
+
'/_api/reorder-sequence',
|
|
85
|
+
// DDR-150 dogfood — array-src replace is a source-write, MAIN-ORIGIN ONLY.
|
|
86
|
+
'/_api/edit-array-src',
|
|
87
|
+
// DDR-150 dogfood — clip hide/show is a source-write, MAIN-ORIGIN ONLY.
|
|
88
|
+
'/_api/toggle-hide',
|
|
70
89
|
// Phase 12.1 (DDR-138) — node-move reorder is a source-write, MAIN-ORIGIN
|
|
71
90
|
// ONLY. The canvas iframe requests a reorder over the dgn:* bus; the shell
|
|
72
91
|
// performs the write. A GET here 403s at the gate (route unreachable on
|
|
@@ -141,9 +160,26 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
141
160
|
const shell = await fetch(`${canvas}/_canvas-shell.html`);
|
|
142
161
|
const csp = shell.headers.get('content-security-policy') ?? '';
|
|
143
162
|
expect(csp).toContain("connect-src 'self'");
|
|
163
|
+
// DDR-148 — media-src must be present so a video-comp's <Video>/<Audio>
|
|
164
|
+
// isn't blocked by default-src 'none' (black video / silent audio).
|
|
165
|
+
expect(csp).toContain("media-src 'self'");
|
|
144
166
|
expect(csp).toContain("webrtc 'block'");
|
|
145
167
|
expect(csp).toContain('frame-ancestors');
|
|
146
168
|
expect(csp).toContain(`http://localhost:${port}`);
|
|
169
|
+
|
|
170
|
+
// (5) DDR-148 attacker F1 — the EXPORT/CAPTURE render (?hide-chrome=1) on
|
|
171
|
+
// the MAIN origin (where the normal shell CSP is off) MUST carry the
|
|
172
|
+
// network-locked capture CSP, else a canvas <Video src="http://internal">
|
|
173
|
+
// or comp fetch() turns ⌘E into read-SSRF + exfil-via-artifact.
|
|
174
|
+
const capMain = await fetch(`http://localhost:${port}/_canvas-shell.html?hide-chrome=1`);
|
|
175
|
+
const capCsp = capMain.headers.get('content-security-policy') ?? '';
|
|
176
|
+
expect(capCsp).toContain("media-src 'self'"); // <Video src=external> blocked
|
|
177
|
+
expect(capCsp).toContain("connect-src 'self'"); // comp fetch(external) blocked
|
|
178
|
+
expect(capCsp).toContain("img-src 'self'");
|
|
179
|
+
expect(capCsp).toContain("object-src 'none'");
|
|
180
|
+
// A normal main-origin shell (no capture flag) stays CSP-off (back-compat).
|
|
181
|
+
const plainMain = await fetch(`http://localhost:${port}/_canvas-shell.html`);
|
|
182
|
+
expect(plainMain.headers.get('content-security-policy')).toBeNull();
|
|
147
183
|
} finally {
|
|
148
184
|
await killProc(proc);
|
|
149
185
|
}
|