@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
package/apps/studio/api.ts
CHANGED
|
@@ -8,12 +8,23 @@ import path from 'node:path';
|
|
|
8
8
|
|
|
9
9
|
import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
|
|
10
10
|
import {
|
|
11
|
+
type AssembleClip,
|
|
12
|
+
assembleCompSource,
|
|
11
13
|
CanvasEditError,
|
|
14
|
+
type ClipInfo,
|
|
15
|
+
editArrayElementString,
|
|
12
16
|
editAttribute,
|
|
17
|
+
enumerateClips,
|
|
18
|
+
insertClip,
|
|
13
19
|
type MovePosition,
|
|
14
20
|
moveElement,
|
|
15
21
|
removeAttribute,
|
|
22
|
+
removeClip,
|
|
23
|
+
reorderClip,
|
|
24
|
+
retimeSequence,
|
|
25
|
+
retimeSequenceByClip,
|
|
16
26
|
editText as runEditText,
|
|
27
|
+
toggleClipHidden,
|
|
17
28
|
} from './canvas-edit.ts';
|
|
18
29
|
import type { Context } from './context.ts';
|
|
19
30
|
import { createHistory } from './history.ts';
|
|
@@ -180,7 +191,7 @@ export type DeleteCanvasResult =
|
|
|
180
191
|
|
|
181
192
|
/** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
|
|
182
193
|
export type EditOpResult =
|
|
183
|
-
| { ok: true; delta: number }
|
|
194
|
+
| { ok: true; delta: number; seq?: number }
|
|
184
195
|
| { ok: false; status: number; error: string };
|
|
185
196
|
|
|
186
197
|
/**
|
|
@@ -230,6 +241,10 @@ export interface Api {
|
|
|
230
241
|
saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
|
|
231
242
|
// Canvas meta sidecar (Phase 4 T5 — .design/ui/<slug>.meta.json)
|
|
232
243
|
loadCanvasMeta(file: string): Promise<Record<string, unknown> | null>;
|
|
244
|
+
/** DDR-148 — raw .tsx source for the Timeline sequence/keyframe parser. */
|
|
245
|
+
loadCanvasSource(
|
|
246
|
+
file: unknown
|
|
247
|
+
): Promise<{ ok: true; source: string } | { ok: false; status: number; error: string }>;
|
|
233
248
|
patchCanvasMeta(
|
|
234
249
|
file: string,
|
|
235
250
|
patch: Record<string, unknown>
|
|
@@ -239,17 +254,25 @@ export interface Api {
|
|
|
239
254
|
saveAnnotations(file: string, svg: string): Promise<boolean>;
|
|
240
255
|
// Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
|
|
241
256
|
saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
257
|
+
/** DDR-148 — streaming variant for the HTTP route (100 MB video without a
|
|
258
|
+
* full in-RAM buffer). Sniffs + caps + content-addresses like saveAsset. */
|
|
259
|
+
saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult>;
|
|
242
260
|
// Persist a clipboard-pasted ACP composer image → runtime `_chat/attachments/`,
|
|
243
261
|
// returns an absolute path (Phase 31 follow-up — POST /_api/acp/attachment).
|
|
244
262
|
saveChatAttachment(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
245
263
|
// Resolve a content-addressed attachment name (`<sha8>.<ext>`) to its absolute
|
|
246
264
|
// path, or null (GET /_api/acp/attachment — the read side of the pair above).
|
|
247
265
|
resolveChatAttachment(name: unknown): Promise<string | null>;
|
|
248
|
-
// Create a blank brief board
|
|
266
|
+
// Create a blank brief board OR an assembled video-comp from the browser
|
|
267
|
+
// (Phase 22 — POST /_api/canvas; DDR-150 P4 Task 12 adds kind "video-comp").
|
|
249
268
|
createCanvas(input: {
|
|
250
269
|
name?: unknown;
|
|
251
270
|
kind?: unknown;
|
|
252
271
|
group?: unknown;
|
|
272
|
+
clips?: unknown;
|
|
273
|
+
fps?: unknown;
|
|
274
|
+
width?: unknown;
|
|
275
|
+
height?: unknown;
|
|
253
276
|
}): Promise<CreateCanvasResult>;
|
|
254
277
|
// Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
|
|
255
278
|
deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
|
|
@@ -281,6 +304,85 @@ export interface Api {
|
|
|
281
304
|
refId?: unknown;
|
|
282
305
|
position?: unknown;
|
|
283
306
|
}): Promise<ReorderOpResult>;
|
|
307
|
+
/** DDR-148 — Timeline drag-to-retime a sequence's durationInFrames / from. */
|
|
308
|
+
retimeSequenceOp(input: {
|
|
309
|
+
canvas?: unknown;
|
|
310
|
+
// DDR-150 P2 — prefer stableId (comp-scoped, multi-comp-safe) over index.
|
|
311
|
+
stableId?: unknown;
|
|
312
|
+
artboardId?: unknown;
|
|
313
|
+
contentHash?: unknown;
|
|
314
|
+
index?: unknown;
|
|
315
|
+
durationInFrames?: unknown;
|
|
316
|
+
from?: unknown;
|
|
317
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
318
|
+
// DDR-150 P3 — remove a clip addressed by stableId (fingerprint + semantic
|
|
319
|
+
// gate; refuses the only clip; drops an adjacent transition in a series).
|
|
320
|
+
removeSequenceOp(input: {
|
|
321
|
+
canvas?: unknown;
|
|
322
|
+
stableId?: unknown;
|
|
323
|
+
artboardId?: unknown;
|
|
324
|
+
contentHash?: unknown;
|
|
325
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
326
|
+
// DDR-150 P4 — insert a new <Sequence> (optionally with media) after a comp's
|
|
327
|
+
// last clip. Returns the new clip's stableId.
|
|
328
|
+
insertSequenceOp(input: {
|
|
329
|
+
canvas?: unknown;
|
|
330
|
+
artboardId?: unknown;
|
|
331
|
+
from?: unknown;
|
|
332
|
+
durationInFrames?: unknown;
|
|
333
|
+
mediaTag?: unknown;
|
|
334
|
+
src?: unknown;
|
|
335
|
+
}): Promise<
|
|
336
|
+
| { ok: true; stableId: string | null; seq?: number }
|
|
337
|
+
| { ok: false; status: number; error: string }
|
|
338
|
+
>;
|
|
339
|
+
// DDR-150 P5 — z-order reorder: move a standalone <Sequence> before/after a
|
|
340
|
+
// sibling (render stacking), reusing moveElement + the semantic gate. Both
|
|
341
|
+
// clips are fingerprint-checked. Returns the moved clip's (re-settled) stableId.
|
|
342
|
+
reorderSequenceOp(input: {
|
|
343
|
+
canvas?: unknown;
|
|
344
|
+
artboardId?: unknown;
|
|
345
|
+
stableId?: unknown;
|
|
346
|
+
contentHash?: unknown;
|
|
347
|
+
refStableId?: unknown;
|
|
348
|
+
refContentHash?: unknown;
|
|
349
|
+
position?: unknown;
|
|
350
|
+
}): Promise<
|
|
351
|
+
| { ok: true; stableId: string | null; seq?: number }
|
|
352
|
+
| { ok: false; status: number; error: string }
|
|
353
|
+
>;
|
|
354
|
+
// DDR-150 dogfood — replace a media src that lives in an array literal
|
|
355
|
+
// (the showreel `CLIPS[i].src` pattern), addressed by mediaArrayRef.
|
|
356
|
+
editArraySrcOp(input: {
|
|
357
|
+
canvas?: unknown;
|
|
358
|
+
arrayName?: unknown;
|
|
359
|
+
index?: unknown;
|
|
360
|
+
field?: unknown;
|
|
361
|
+
value?: unknown;
|
|
362
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
363
|
+
// DDR-150 dogfood — hide/show a clip (gates its body behind {false && …}).
|
|
364
|
+
toggleHideOp(input: {
|
|
365
|
+
canvas?: unknown;
|
|
366
|
+
stableId?: unknown;
|
|
367
|
+
artboardId?: unknown;
|
|
368
|
+
contentHash?: unknown;
|
|
369
|
+
}): Promise<
|
|
370
|
+
{ ok: true; hidden: boolean; seq?: number } | { ok: false; status: number; error: string }
|
|
371
|
+
>;
|
|
372
|
+
// DDR-150 P2 — the single authoritative clip enumerator for a video-comp.
|
|
373
|
+
// Read-only; the Timeline addresses every op by the returned `stableId`
|
|
374
|
+
// (never a regex document-order index — the multi-comp mis-hit defect).
|
|
375
|
+
compClips(input: { canvas?: unknown; artboardId?: unknown }): Promise<
|
|
376
|
+
| {
|
|
377
|
+
ok: true;
|
|
378
|
+
compName: string | null;
|
|
379
|
+
artboardId: string | null;
|
|
380
|
+
fps: number | null;
|
|
381
|
+
durationInFrames: number | null;
|
|
382
|
+
clips: Array<Omit<ClipInfo, 'start' | 'end'>>;
|
|
383
|
+
}
|
|
384
|
+
| { ok: false; status: number; error: string }
|
|
385
|
+
>;
|
|
284
386
|
// Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
|
|
285
387
|
// file content swap from the in-memory revert log — immune to the positional
|
|
286
388
|
// data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
|
|
@@ -327,7 +429,10 @@ export const ASSET_MAX_BYTES = 10 * 1024 * 1024;
|
|
|
327
429
|
*/
|
|
328
430
|
export const ASSET_SESSION_BUDGET = (() => {
|
|
329
431
|
const env = Number(process.env.MAUDE_ASSET_SESSION_BUDGET);
|
|
330
|
-
|
|
432
|
+
// DDR-148 — raised 256 MB → 1 GB now that the route accepts video/audio (one
|
|
433
|
+
// 100 MB clip would blow a 256 MB budget after a couple of drops). Still an
|
|
434
|
+
// aggregate per-server-instance disk-fill bound; env-overridable.
|
|
435
|
+
return Number.isFinite(env) && env > 0 ? env : 1024 * 1024 * 1024;
|
|
331
436
|
})();
|
|
332
437
|
|
|
333
438
|
/**
|
|
@@ -392,6 +497,110 @@ export interface SaveAssetResult {
|
|
|
392
497
|
path?: string;
|
|
393
498
|
}
|
|
394
499
|
|
|
500
|
+
/** DDR-148 — media category, decides which per-file cap applies. */
|
|
501
|
+
export type AssetCategory = 'image' | 'video' | 'audio';
|
|
502
|
+
|
|
503
|
+
export interface AssetTypeInfo {
|
|
504
|
+
/** Stored file extension (bytes decide it, never the upload name). */
|
|
505
|
+
ext: string;
|
|
506
|
+
category: AssetCategory;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* DDR-148 — per-file ceiling for time-based media (video + audio). Images keep
|
|
511
|
+
* the tighter {@link ASSET_MAX_BYTES} 10 MB cap. Overridable via
|
|
512
|
+
* `MAUDE_ASSET_MAX_VIDEO_BYTES` (bytes) for power users. The route lives on the
|
|
513
|
+
* (untrusted) canvas origin, so this cap + the session budget + the streamed
|
|
514
|
+
* write are the trust mitigation, exactly like the image caps (DDR-088).
|
|
515
|
+
*/
|
|
516
|
+
export const ASSET_MAX_VIDEO_BYTES = (() => {
|
|
517
|
+
const env = Number(process.env.MAUDE_ASSET_MAX_VIDEO_BYTES);
|
|
518
|
+
return Number.isFinite(env) && env > 0 ? env : 100 * 1024 * 1024;
|
|
519
|
+
})();
|
|
520
|
+
|
|
521
|
+
/** The byte cap for a category. */
|
|
522
|
+
export function assetCapForCategory(category: AssetCategory): number {
|
|
523
|
+
return category === 'image' ? ASSET_MAX_BYTES : ASSET_MAX_VIDEO_BYTES;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const UNSUPPORTED_ASSET_MSG =
|
|
527
|
+
'unsupported media type — png/jpeg/gif/webp images or mp4/mov/webm/mp3/wav/m4a media only (SVG/script rejected)';
|
|
528
|
+
|
|
529
|
+
function capError(category?: AssetCategory): string {
|
|
530
|
+
if (category === 'image') return 'image exceeds the 10 MB cap';
|
|
531
|
+
const mb = Math.round(ASSET_MAX_VIDEO_BYTES / (1024 * 1024));
|
|
532
|
+
return `media exceeds the ${mb} MB cap`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Concatenate a small list of chunks (used only for the ≤ few-KB sniff head). */
|
|
536
|
+
function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
|
|
537
|
+
let len = 0;
|
|
538
|
+
for (const c of chunks) len += c.length;
|
|
539
|
+
const out = new Uint8Array(len);
|
|
540
|
+
let off = 0;
|
|
541
|
+
for (const c of chunks) {
|
|
542
|
+
out.set(c, off);
|
|
543
|
+
off += c.length;
|
|
544
|
+
}
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* DDR-148 — magic-byte type sniff for the WIDENED asset route: images (via
|
|
550
|
+
* {@link sniffImageType}) PLUS time-based media. The declared name / extension
|
|
551
|
+
* / Content-Type is NEVER trusted — the bytes decide the stored extension AND
|
|
552
|
+
* the category (→ which cap applies). The server only sniffs; it never PARSES a
|
|
553
|
+
* container (parsing happens in the sandboxed capture browser). Anything that
|
|
554
|
+
* isn't a recognised raster/video/audio magic number (SVG, HTML, arbitrary
|
|
555
|
+
* script) → null → rejected (415). Needs ≥ 12 bytes for the ISO-BMFF brands.
|
|
556
|
+
*/
|
|
557
|
+
export function sniffAssetType(bytes: Uint8Array): AssetTypeInfo | null {
|
|
558
|
+
const img = sniffImageType(bytes);
|
|
559
|
+
if (img) return { ext: img, category: 'image' };
|
|
560
|
+
const b = bytes;
|
|
561
|
+
// ISO-BMFF (mp4 / mov / m4a): "ftyp" box at offset 4, brand at offset 8.
|
|
562
|
+
if (
|
|
563
|
+
b.length >= 12 &&
|
|
564
|
+
b[4] === 0x66 && // f
|
|
565
|
+
b[5] === 0x74 && // t
|
|
566
|
+
b[6] === 0x79 && // y
|
|
567
|
+
b[7] === 0x70 // p
|
|
568
|
+
) {
|
|
569
|
+
const brand = String.fromCharCode(b[8] ?? 0, b[9] ?? 0, b[10] ?? 0, b[11] ?? 0);
|
|
570
|
+
if (brand === 'qt ') return { ext: 'mov', category: 'video' };
|
|
571
|
+
if (brand.startsWith('M4A')) return { ext: 'm4a', category: 'audio' };
|
|
572
|
+
if (brand.startsWith('M4V')) return { ext: 'm4v', category: 'video' };
|
|
573
|
+
// isom / mp41 / mp42 / avc1 / iso2 / iso5 / dash / mp4v / … → treat as mp4.
|
|
574
|
+
return { ext: 'mp4', category: 'video' };
|
|
575
|
+
}
|
|
576
|
+
// Matroska / WebM — EBML header 1A 45 DF A3.
|
|
577
|
+
if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) {
|
|
578
|
+
return { ext: 'webm', category: 'video' };
|
|
579
|
+
}
|
|
580
|
+
// MP3 — "ID3" tag OR a frame-sync (0xFF followed by 0b111xxxxx).
|
|
581
|
+
if (b.length >= 3 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) {
|
|
582
|
+
return { ext: 'mp3', category: 'audio' };
|
|
583
|
+
}
|
|
584
|
+
if (b.length >= 2 && b[0] === 0xff && ((b[1] ?? 0) & 0xe0) === 0xe0) {
|
|
585
|
+
return { ext: 'mp3', category: 'audio' };
|
|
586
|
+
}
|
|
587
|
+
// WAV — "RIFF"????"WAVE".
|
|
588
|
+
if (
|
|
589
|
+
b.length >= 12 &&
|
|
590
|
+
b[0] === 0x52 &&
|
|
591
|
+
b[1] === 0x49 &&
|
|
592
|
+
b[2] === 0x46 &&
|
|
593
|
+
b[3] === 0x46 &&
|
|
594
|
+
b[8] === 0x57 &&
|
|
595
|
+
b[9] === 0x41 &&
|
|
596
|
+
b[10] === 0x56 &&
|
|
597
|
+
b[11] === 0x45
|
|
598
|
+
) {
|
|
599
|
+
return { ext: 'wav', category: 'audio' };
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
395
604
|
export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
396
605
|
const onCommentsChanged = hooks.onCommentsChanged;
|
|
397
606
|
const onAnnotationsChanged = hooks.onAnnotationsChanged;
|
|
@@ -405,6 +614,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
405
614
|
const reorderLog = new Map<number, { abs: string; before: string; after: string }>();
|
|
406
615
|
let reorderSeq = 0;
|
|
407
616
|
|
|
617
|
+
// DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops
|
|
618
|
+
// (retime / remove / insert / z-reorder / replace-src): every successful op
|
|
619
|
+
// registers its {before, after} and returns the seq; the shell keeps a
|
|
620
|
+
// per-canvas undo/redo stack of seqs and replays them through
|
|
621
|
+
// /_api/reorder-revert (guarded whole-file swap, 409 on divergence).
|
|
622
|
+
function logUndo(abs: string, before: string, after: string): number {
|
|
623
|
+
const seq = ++reorderSeq;
|
|
624
|
+
reorderLog.set(seq, { abs, before, after });
|
|
625
|
+
while (reorderLog.size > REORDER_LOG_CAP) {
|
|
626
|
+
const oldest = reorderLog.keys().next().value;
|
|
627
|
+
if (oldest === undefined) break;
|
|
628
|
+
reorderLog.delete(oldest);
|
|
629
|
+
}
|
|
630
|
+
return seq;
|
|
631
|
+
}
|
|
632
|
+
|
|
408
633
|
function fileSlug(file: string): string {
|
|
409
634
|
return canvasSlugFromRel(file, paths.designRel);
|
|
410
635
|
}
|
|
@@ -987,51 +1212,153 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
987
1212
|
// user input, but a poisoned designRoot must still not escape).
|
|
988
1213
|
// Running total of bytes this server instance has actually written (post-dedupe).
|
|
989
1214
|
let assetBytesWritten = 0;
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
const sha8 = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 8);
|
|
1004
|
-
const name = `${sha8}.${kind}`;
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* DDR-148 — the streaming write path behind `POST /_api/asset`. Reads the
|
|
1218
|
+
* request body chunk-by-chunk so a 100 MB video never lands as a single
|
|
1219
|
+
* ArrayBuffer in RAM (the memory-amplification vector DDR-088 §review flagged
|
|
1220
|
+
* for the untrusted canvas origin — worse now that the per-file cap is 100 MB,
|
|
1221
|
+
* not 10 MB). Sniffs the type from the head, applies the CATEGORY cap while
|
|
1222
|
+
* streaming (image 10 MB · video/audio {@link ASSET_MAX_VIDEO_BYTES}), writes
|
|
1223
|
+
* to a temp file, then content-addresses (sha8) → dedupe-rename. Nothing is
|
|
1224
|
+
* ever parsed — only magic bytes are read. `saveAsset(bytes)` wraps this so
|
|
1225
|
+
* both the route and any programmatic caller share ONE tested path.
|
|
1226
|
+
*/
|
|
1227
|
+
async function saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult> {
|
|
1005
1228
|
const assetsDir = path.join(paths.designRoot, 'assets');
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1229
|
+
const tmpName = `.tmp-${crypto.randomBytes(8).toString('hex')}`;
|
|
1230
|
+
const tmpAbs = path.join(assetsDir, tmpName);
|
|
1231
|
+
const hash = crypto.createHash('sha256');
|
|
1232
|
+
let sink: Bun.FileSink | null = null;
|
|
1233
|
+
let typeInfo: AssetTypeInfo | null = null;
|
|
1234
|
+
let cap = ASSET_MAX_VIDEO_BYTES; // provisional max until the head is sniffed
|
|
1235
|
+
let total = 0;
|
|
1236
|
+
const headChunks: Uint8Array[] = [];
|
|
1237
|
+
let headLen = 0;
|
|
1238
|
+
|
|
1239
|
+
const cleanup = async () => {
|
|
1240
|
+
try {
|
|
1241
|
+
if (sink) await sink.end();
|
|
1242
|
+
} catch {
|
|
1243
|
+
/* sink already closed */
|
|
1244
|
+
}
|
|
1245
|
+
try {
|
|
1246
|
+
await rm(tmpAbs, { force: true });
|
|
1247
|
+
} catch {
|
|
1248
|
+
/* temp already gone */
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
const flushHead = () => {
|
|
1252
|
+
for (const c of headChunks) {
|
|
1253
|
+
hash.update(c);
|
|
1254
|
+
sink?.write(c);
|
|
1255
|
+
}
|
|
1256
|
+
headChunks.length = 0;
|
|
1257
|
+
headLen = 0;
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
// Explicit reader (NOT `for await` — Bun's HTTP `req.body` ReadableStream
|
|
1261
|
+
// does not implement Symbol.asyncIterator, so async-iteration throws
|
|
1262
|
+
// "undefined is not a function"; getReader().read() works for every stream).
|
|
1263
|
+
const reader = stream.getReader();
|
|
1014
1264
|
try {
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
if (
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1265
|
+
await mkdir(assetsDir, { recursive: true });
|
|
1266
|
+
for (;;) {
|
|
1267
|
+
const { done, value } = await reader.read();
|
|
1268
|
+
if (done) break;
|
|
1269
|
+
const chunk = value;
|
|
1270
|
+
if (!chunk || chunk.length === 0) continue;
|
|
1271
|
+
total += chunk.length;
|
|
1272
|
+
if (total > cap) {
|
|
1273
|
+
await cleanup();
|
|
1274
|
+
return { ok: false, status: 413, error: capError(typeInfo?.category) };
|
|
1275
|
+
}
|
|
1276
|
+
if (!typeInfo) {
|
|
1277
|
+
headChunks.push(chunk);
|
|
1278
|
+
headLen += chunk.length;
|
|
1279
|
+
if (headLen >= 12) {
|
|
1280
|
+
typeInfo = sniffAssetType(concatBytes(headChunks));
|
|
1281
|
+
if (!typeInfo) {
|
|
1282
|
+
await cleanup(); // nothing written yet — no temp file to remove
|
|
1283
|
+
return { ok: false, status: 415, error: UNSUPPORTED_ASSET_MSG };
|
|
1284
|
+
}
|
|
1285
|
+
cap = assetCapForCategory(typeInfo.category);
|
|
1286
|
+
if (total > cap) {
|
|
1287
|
+
await cleanup();
|
|
1288
|
+
return { ok: false, status: 413, error: capError(typeInfo.category) };
|
|
1289
|
+
}
|
|
1290
|
+
sink = Bun.file(tmpAbs).writer();
|
|
1291
|
+
flushHead();
|
|
1292
|
+
}
|
|
1293
|
+
} else {
|
|
1294
|
+
hash.update(chunk);
|
|
1295
|
+
sink?.write(chunk);
|
|
1026
1296
|
}
|
|
1027
|
-
await mkdir(assetsDir, { recursive: true });
|
|
1028
|
-
await Bun.write(fileAbs, bytes);
|
|
1029
|
-
assetBytesWritten += bytes.length;
|
|
1030
1297
|
}
|
|
1298
|
+
|
|
1299
|
+
if (total === 0) {
|
|
1300
|
+
await cleanup();
|
|
1301
|
+
return { ok: false, status: 400, error: 'empty body' };
|
|
1302
|
+
}
|
|
1303
|
+
// A body shorter than 12 bytes never triggered the mid-stream sniff.
|
|
1304
|
+
if (!typeInfo) {
|
|
1305
|
+
typeInfo = sniffAssetType(concatBytes(headChunks));
|
|
1306
|
+
if (!typeInfo) {
|
|
1307
|
+
await cleanup();
|
|
1308
|
+
return { ok: false, status: 415, error: UNSUPPORTED_ASSET_MSG };
|
|
1309
|
+
}
|
|
1310
|
+
cap = assetCapForCategory(typeInfo.category);
|
|
1311
|
+
if (total > cap) {
|
|
1312
|
+
await cleanup();
|
|
1313
|
+
return { ok: false, status: 413, error: capError(typeInfo.category) };
|
|
1314
|
+
}
|
|
1315
|
+
sink = Bun.file(tmpAbs).writer();
|
|
1316
|
+
flushHead();
|
|
1317
|
+
}
|
|
1318
|
+
if (sink) await sink.end();
|
|
1319
|
+
|
|
1320
|
+
const sha8 = hash.digest('hex').slice(0, 8);
|
|
1321
|
+
const name = `${sha8}.${typeInfo.ext}`;
|
|
1322
|
+
const fileAbs = path.join(assetsDir, name);
|
|
1323
|
+
// Containment backstop — the name is content-addressed (sha8 hex + sniffed
|
|
1324
|
+
// ext), no user-controlled segment, but assert anyway (poisoned designRoot).
|
|
1325
|
+
if (path.resolve(fileAbs) !== path.join(path.resolve(assetsDir), name)) {
|
|
1326
|
+
await rm(tmpAbs, { force: true });
|
|
1327
|
+
return { ok: false, status: 400, error: 'resolved asset path escapes assets dir' };
|
|
1328
|
+
}
|
|
1329
|
+
// Dedupe — identical bytes hash to the same name; drop the temp copy.
|
|
1330
|
+
if (await Bun.file(fileAbs).exists()) {
|
|
1331
|
+
await rm(tmpAbs, { force: true });
|
|
1332
|
+
return { ok: true, path: `assets/${name}` };
|
|
1333
|
+
}
|
|
1334
|
+
// Aggregate write budget — bounds a scripted disk-fill loop from the
|
|
1335
|
+
// untrusted canvas origin. Only a genuinely NEW file counts.
|
|
1336
|
+
if (assetBytesWritten + total > ASSET_SESSION_BUDGET) {
|
|
1337
|
+
await rm(tmpAbs, { force: true });
|
|
1338
|
+
return {
|
|
1339
|
+
ok: false,
|
|
1340
|
+
status: 429,
|
|
1341
|
+
error: 'asset write budget exceeded for this server session',
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
await rename(tmpAbs, fileAbs);
|
|
1345
|
+
assetBytesWritten += total;
|
|
1346
|
+
return { ok: true, path: `assets/${name}` };
|
|
1031
1347
|
} catch (err) {
|
|
1348
|
+
await cleanup();
|
|
1032
1349
|
return { ok: false, status: 500, error: err instanceof Error ? err.message : 'write failed' };
|
|
1350
|
+
} finally {
|
|
1351
|
+
try {
|
|
1352
|
+
reader.releaseLock();
|
|
1353
|
+
} catch {
|
|
1354
|
+
/* already released */
|
|
1355
|
+
}
|
|
1033
1356
|
}
|
|
1034
|
-
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async function saveAsset(bytes: Uint8Array): Promise<SaveAssetResult> {
|
|
1360
|
+
if (!bytes || bytes.length === 0) return { ok: false, status: 400, error: 'empty body' };
|
|
1361
|
+
return saveAssetFromStream(new Response(bytes).body as ReadableStream<Uint8Array>);
|
|
1035
1362
|
}
|
|
1036
1363
|
|
|
1037
1364
|
// Phase 31 follow-up — persist an image pasted straight from the clipboard into
|
|
@@ -1112,11 +1439,37 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1112
1439
|
name?: unknown;
|
|
1113
1440
|
kind?: unknown;
|
|
1114
1441
|
group?: unknown;
|
|
1442
|
+
// DDR-150 P4 Task 12 — video-comp assemble payload (kind === 'video-comp').
|
|
1443
|
+
clips?: unknown;
|
|
1444
|
+
fps?: unknown;
|
|
1445
|
+
width?: unknown;
|
|
1446
|
+
height?: unknown;
|
|
1115
1447
|
}): Promise<CreateCanvasResult> {
|
|
1116
|
-
// v1
|
|
1448
|
+
// v1 stamps blank brief-boards + assembled video-comps — richer generation
|
|
1449
|
+
// stays with `/design:new` (Claude).
|
|
1117
1450
|
const kind = input.kind == null || input.kind === '' ? 'brief-board' : input.kind;
|
|
1118
|
-
if (kind !== 'brief-board') {
|
|
1119
|
-
return { ok: false, status: 400, error: '
|
|
1451
|
+
if (kind !== 'brief-board' && kind !== 'video-comp') {
|
|
1452
|
+
return { ok: false, status: 400, error: 'kind must be "brief-board" or "video-comp"' };
|
|
1453
|
+
}
|
|
1454
|
+
// Parse + validate the assemble clip list up front (video-comp only).
|
|
1455
|
+
const assembleClips: AssembleClip[] = [];
|
|
1456
|
+
if (kind === 'video-comp') {
|
|
1457
|
+
if (!Array.isArray(input.clips) || input.clips.length === 0) {
|
|
1458
|
+
return { ok: false, status: 400, error: 'video-comp needs a non-empty clips[]' };
|
|
1459
|
+
}
|
|
1460
|
+
if (input.clips.length > 60) {
|
|
1461
|
+
return { ok: false, status: 400, error: 'too many clips (max 60)' };
|
|
1462
|
+
}
|
|
1463
|
+
for (const raw of input.clips) {
|
|
1464
|
+
const c = raw as { src?: unknown; mediaKind?: unknown; durationInFrames?: unknown };
|
|
1465
|
+
const src = typeof c?.src === 'string' ? c.src : '';
|
|
1466
|
+
if (!src) return { ok: false, status: 400, error: 'each clip needs a src' };
|
|
1467
|
+
const mediaKind = c?.mediaKind === 'audio' ? 'audio' : 'video';
|
|
1468
|
+
const durationInFrames = Number.isFinite(Number(c?.durationInFrames))
|
|
1469
|
+
? Math.max(1, Math.min(100000, Math.round(Number(c.durationInFrames))))
|
|
1470
|
+
: null;
|
|
1471
|
+
assembleClips.push({ src, mediaKind, durationInFrames });
|
|
1472
|
+
}
|
|
1120
1473
|
}
|
|
1121
1474
|
const v = validateCanvasName(input.name);
|
|
1122
1475
|
if (!v.ok || !v.name || !v.componentName) {
|
|
@@ -1169,25 +1522,55 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1169
1522
|
const slug = fileSlug(rel);
|
|
1170
1523
|
const dsName = cfg.defaultDesignSystem || 'project';
|
|
1171
1524
|
const platform = 'desktop';
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1525
|
+
let tsx: string;
|
|
1526
|
+
if (kind === 'video-comp') {
|
|
1527
|
+
try {
|
|
1528
|
+
tsx = assembleCompSource(v.componentName, assembleClips, {
|
|
1529
|
+
fps: Number.isFinite(Number(input.fps)) ? Number(input.fps) : undefined,
|
|
1530
|
+
width: Number.isFinite(Number(input.width)) ? Number(input.width) : undefined,
|
|
1531
|
+
height: Number.isFinite(Number(input.height)) ? Number(input.height) : undefined,
|
|
1532
|
+
});
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
return {
|
|
1535
|
+
ok: false,
|
|
1536
|
+
status: 400,
|
|
1537
|
+
error: err instanceof Error ? err.message : 'assemble failed',
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
} else {
|
|
1541
|
+
tsx = renderBriefBoard({
|
|
1542
|
+
name: v.name,
|
|
1543
|
+
componentName: v.componentName,
|
|
1544
|
+
dsName,
|
|
1545
|
+
platform,
|
|
1546
|
+
seedHint: 'Empty brief board — annotate me',
|
|
1547
|
+
historyDir: path.posix.join(paths.designRel, '_history', slug),
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1180
1550
|
const now = new Date().toISOString();
|
|
1181
|
-
const meta =
|
|
1182
|
-
kind
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1551
|
+
const meta =
|
|
1552
|
+
kind === 'video-comp'
|
|
1553
|
+
? {
|
|
1554
|
+
kind: 'video-comp',
|
|
1555
|
+
title: v.name,
|
|
1556
|
+
subtitle: 'assembled reel',
|
|
1557
|
+
brief: v.name,
|
|
1558
|
+
tags: ['video'],
|
|
1559
|
+
designSystem: dsName,
|
|
1560
|
+
platform,
|
|
1561
|
+
created: now,
|
|
1562
|
+
last_modified: now,
|
|
1563
|
+
}
|
|
1564
|
+
: {
|
|
1565
|
+
kind: 'brief-board',
|
|
1566
|
+
title: v.name,
|
|
1567
|
+
subtitle: 'brief board',
|
|
1568
|
+
brief: v.name,
|
|
1569
|
+
designSystem: dsName,
|
|
1570
|
+
platform,
|
|
1571
|
+
created: now,
|
|
1572
|
+
last_modified: now,
|
|
1573
|
+
};
|
|
1191
1574
|
await Bun.write(fileAbs, tsx);
|
|
1192
1575
|
await Bun.write(
|
|
1193
1576
|
path.join(groupAbs, `${v.name}.meta.json`),
|
|
@@ -1356,6 +1739,27 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1356
1739
|
return { ok: true, abs };
|
|
1357
1740
|
}
|
|
1358
1741
|
|
|
1742
|
+
/**
|
|
1743
|
+
* DDR-148 — read a canvas's RAW .tsx source (containment via resolveCanvasAbs).
|
|
1744
|
+
* The Timeline panel parses `<Sequence>`/`<TransitionSeries.Sequence>` blocks
|
|
1745
|
+
* + `interpolate` windows from it to draw the sequence/keyframe rows. Read-only,
|
|
1746
|
+
* MAIN-ORIGIN ONLY at the route layer (never the untrusted canvas iframe).
|
|
1747
|
+
*/
|
|
1748
|
+
async function loadCanvasSource(
|
|
1749
|
+
file: unknown
|
|
1750
|
+
): Promise<{ ok: true; source: string } | { ok: false; status: number; error: string }> {
|
|
1751
|
+
const r = resolveCanvasAbs(file);
|
|
1752
|
+
if (!r.ok) return r;
|
|
1753
|
+
const f = Bun.file(r.abs);
|
|
1754
|
+
if (!(await f.exists())) return { ok: false, status: 404, error: 'canvas not found' };
|
|
1755
|
+
const source = await f.text();
|
|
1756
|
+
// A comp is small; never stream a huge blob into the panel.
|
|
1757
|
+
if (source.length > 512 * 1024) {
|
|
1758
|
+
return { ok: false, status: 413, error: 'canvas source too large for the timeline parser' };
|
|
1759
|
+
}
|
|
1760
|
+
return { ok: true, source };
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1359
1763
|
// 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
|
|
1360
1764
|
const CD_ID_RE = /^[0-9a-f]{8}$/;
|
|
1361
1765
|
|
|
@@ -1374,11 +1778,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1374
1778
|
const rel = path.relative(paths.designRoot, abs);
|
|
1375
1779
|
ctx.bus.emit('activity:suppress', rel);
|
|
1376
1780
|
try {
|
|
1781
|
+
// DDR-150 dogfood #1 — capture before/after so the edit registers in the
|
|
1782
|
+
// whole-file undo log (the Timeline replace-src path rides this; other
|
|
1783
|
+
// inspector edits get an undoable seq for free).
|
|
1784
|
+
const before = await Bun.file(abs).text();
|
|
1377
1785
|
const res = await run();
|
|
1378
1786
|
// `changed === false` = the op collapsed to a no-op and nothing hit disk —
|
|
1379
1787
|
// NOT `delta === 0`, which an equal-length replacement also produces.
|
|
1380
|
-
if (res.changed === false)
|
|
1381
|
-
|
|
1788
|
+
if (res.changed === false) {
|
|
1789
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
1790
|
+
return { ok: true, delta: res.delta };
|
|
1791
|
+
}
|
|
1792
|
+
const after = await Bun.file(abs).text();
|
|
1793
|
+
const seq = after !== before ? logUndo(abs, before, after) : undefined;
|
|
1794
|
+
return { ok: true, delta: res.delta, seq };
|
|
1382
1795
|
} catch (err) {
|
|
1383
1796
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
1384
1797
|
return {
|
|
@@ -1478,6 +1891,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1478
1891
|
const value = typeof input.value === 'string' ? input.value : '';
|
|
1479
1892
|
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
1480
1893
|
if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
|
|
1894
|
+
// DDR-150 P3 — a media/image `src` edit (the clip-replace path) must stay a
|
|
1895
|
+
// contained asset reference: no path traversal, no script/file/data schemes.
|
|
1896
|
+
if (
|
|
1897
|
+
attr === 'src' &&
|
|
1898
|
+
(/\.\./.test(value) || /^\s*(javascript|vbscript|file|data):/i.test(value))
|
|
1899
|
+
) {
|
|
1900
|
+
return {
|
|
1901
|
+
ok: false,
|
|
1902
|
+
status: 400,
|
|
1903
|
+
error: 'src must be a contained asset path (no ../ or javascript:/file:/data: schemes)',
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1481
1906
|
// Non-`style.` attr name → editAttribute writes a plain quoted JSX attribute.
|
|
1482
1907
|
// Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
|
|
1483
1908
|
// on replace, escapeAttr on insert) — pre-stringifying here double-encoded
|
|
@@ -1566,6 +1991,320 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1566
1991
|
}
|
|
1567
1992
|
}
|
|
1568
1993
|
|
|
1994
|
+
/**
|
|
1995
|
+
* DDR-148 — Timeline drag-to-retime. Rewrites the `durationInFrames` / `from`
|
|
1996
|
+
* of the `index`-th sequence (document order). MAIN-ORIGIN ONLY at the route
|
|
1997
|
+
* layer. Snapshots pre-retime for /design:rollback; suppresses the agent-rim.
|
|
1998
|
+
*/
|
|
1999
|
+
async function retimeSequenceOp(input: {
|
|
2000
|
+
canvas?: unknown;
|
|
2001
|
+
stableId?: unknown;
|
|
2002
|
+
artboardId?: unknown;
|
|
2003
|
+
contentHash?: unknown;
|
|
2004
|
+
index?: unknown;
|
|
2005
|
+
durationInFrames?: unknown;
|
|
2006
|
+
from?: unknown;
|
|
2007
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
2008
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2009
|
+
if (!r.ok) return r;
|
|
2010
|
+
// DDR-150 P2 — prefer the comp-scoped stableId (multi-comp-safe) over the
|
|
2011
|
+
// legacy whole-file index. The index path stays for back-compat.
|
|
2012
|
+
const stableId = typeof input.stableId === 'string' ? input.stableId : null;
|
|
2013
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2014
|
+
const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
|
|
2015
|
+
const index = Number.isInteger(input.index) ? (input.index as number) : -1;
|
|
2016
|
+
if (!stableId && (index < 0 || index > 500))
|
|
2017
|
+
return { ok: false, status: 400, error: 'invalid sequence index' };
|
|
2018
|
+
const frames = (v: unknown, lo: number): number | undefined => {
|
|
2019
|
+
const n = Math.round(Number(v));
|
|
2020
|
+
return Number.isFinite(n) ? Math.max(lo, Math.min(100000, n)) : undefined;
|
|
2021
|
+
};
|
|
2022
|
+
const patch: { durationInFrames?: number; from?: number } = {};
|
|
2023
|
+
if (input.durationInFrames != null) patch.durationInFrames = frames(input.durationInFrames, 1);
|
|
2024
|
+
if (input.from != null) patch.from = frames(input.from, 0);
|
|
2025
|
+
if (patch.durationInFrames == null && patch.from == null) {
|
|
2026
|
+
return { ok: false, status: 400, error: 'nothing to retime' };
|
|
2027
|
+
}
|
|
2028
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2029
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2030
|
+
try {
|
|
2031
|
+
const before = await Bun.file(r.abs).text();
|
|
2032
|
+
if (stableId) await retimeSequenceByClip(r.abs, artboardId, stableId, contentHash, patch);
|
|
2033
|
+
else await retimeSequence(r.abs, index, patch);
|
|
2034
|
+
const after = await Bun.file(r.abs).text();
|
|
2035
|
+
if (after === before) {
|
|
2036
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2037
|
+
return { ok: true };
|
|
2038
|
+
}
|
|
2039
|
+
try {
|
|
2040
|
+
await history.writeSnapshot(rel, before, 'pre-retime');
|
|
2041
|
+
} catch {
|
|
2042
|
+
/* snapshot best-effort */
|
|
2043
|
+
}
|
|
2044
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
2045
|
+
} catch (err) {
|
|
2046
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2047
|
+
return {
|
|
2048
|
+
ok: false,
|
|
2049
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2050
|
+
error: err instanceof Error ? err.message : 'retime failed',
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
async function removeSequenceOp(input: {
|
|
2056
|
+
canvas?: unknown;
|
|
2057
|
+
stableId?: unknown;
|
|
2058
|
+
artboardId?: unknown;
|
|
2059
|
+
contentHash?: unknown;
|
|
2060
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
2061
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2062
|
+
if (!r.ok) return r;
|
|
2063
|
+
const stableId = typeof input.stableId === 'string' ? input.stableId : null;
|
|
2064
|
+
if (!stableId) return { ok: false, status: 400, error: 'stableId required' };
|
|
2065
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2066
|
+
const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
|
|
2067
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2068
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2069
|
+
try {
|
|
2070
|
+
const before = await Bun.file(r.abs).text();
|
|
2071
|
+
await removeClip(r.abs, artboardId, stableId, contentHash);
|
|
2072
|
+
const after = await Bun.file(r.abs).text();
|
|
2073
|
+
if (after === before) {
|
|
2074
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2075
|
+
return { ok: true };
|
|
2076
|
+
}
|
|
2077
|
+
try {
|
|
2078
|
+
await history.writeSnapshot(rel, before, 'pre-remove-clip');
|
|
2079
|
+
} catch {
|
|
2080
|
+
/* snapshot best-effort */
|
|
2081
|
+
}
|
|
2082
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
2083
|
+
} catch (err) {
|
|
2084
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2085
|
+
return {
|
|
2086
|
+
ok: false,
|
|
2087
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2088
|
+
error: err instanceof Error ? err.message : 'remove failed',
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
async function insertSequenceOp(input: {
|
|
2094
|
+
canvas?: unknown;
|
|
2095
|
+
artboardId?: unknown;
|
|
2096
|
+
from?: unknown;
|
|
2097
|
+
durationInFrames?: unknown;
|
|
2098
|
+
mediaTag?: unknown;
|
|
2099
|
+
src?: unknown;
|
|
2100
|
+
}): Promise<
|
|
2101
|
+
| { ok: true; stableId: string | null; seq?: number }
|
|
2102
|
+
| { ok: false; status: number; error: string }
|
|
2103
|
+
> {
|
|
2104
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2105
|
+
if (!r.ok) return r;
|
|
2106
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2107
|
+
const from = Number.isFinite(Number(input.from))
|
|
2108
|
+
? Math.max(0, Math.round(Number(input.from)))
|
|
2109
|
+
: 0;
|
|
2110
|
+
const durationInFrames = Number.isFinite(Number(input.durationInFrames))
|
|
2111
|
+
? Math.max(1, Math.min(100000, Math.round(Number(input.durationInFrames))))
|
|
2112
|
+
: 90;
|
|
2113
|
+
const mediaTag = typeof input.mediaTag === 'string' ? input.mediaTag : null;
|
|
2114
|
+
const src = typeof input.src === 'string' ? input.src : null;
|
|
2115
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2116
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2117
|
+
try {
|
|
2118
|
+
const before = await Bun.file(r.abs).text();
|
|
2119
|
+
const res = await insertClip(r.abs, artboardId, { from, durationInFrames, mediaTag, src });
|
|
2120
|
+
const after = await Bun.file(r.abs).text();
|
|
2121
|
+
let seq: number | undefined;
|
|
2122
|
+
if (after === before) {
|
|
2123
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2124
|
+
} else {
|
|
2125
|
+
try {
|
|
2126
|
+
await history.writeSnapshot(rel, before, 'pre-insert-clip');
|
|
2127
|
+
} catch {
|
|
2128
|
+
/* snapshot best-effort */
|
|
2129
|
+
}
|
|
2130
|
+
seq = logUndo(r.abs, before, after);
|
|
2131
|
+
}
|
|
2132
|
+
return { ok: true, stableId: res.stableId, seq };
|
|
2133
|
+
} catch (err) {
|
|
2134
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2135
|
+
return {
|
|
2136
|
+
ok: false,
|
|
2137
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2138
|
+
error: err instanceof Error ? err.message : 'insert failed',
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
async function toggleHideOp(input: {
|
|
2144
|
+
canvas?: unknown;
|
|
2145
|
+
stableId?: unknown;
|
|
2146
|
+
artboardId?: unknown;
|
|
2147
|
+
contentHash?: unknown;
|
|
2148
|
+
}): Promise<
|
|
2149
|
+
{ ok: true; hidden: boolean; seq?: number } | { ok: false; status: number; error: string }
|
|
2150
|
+
> {
|
|
2151
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2152
|
+
if (!r.ok) return r;
|
|
2153
|
+
const stableId = typeof input.stableId === 'string' ? input.stableId : null;
|
|
2154
|
+
if (!stableId) return { ok: false, status: 400, error: 'stableId required' };
|
|
2155
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2156
|
+
const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
|
|
2157
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2158
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2159
|
+
try {
|
|
2160
|
+
const before = await Bun.file(r.abs).text();
|
|
2161
|
+
const res = await toggleClipHidden(r.abs, artboardId, stableId, contentHash);
|
|
2162
|
+
const after = await Bun.file(r.abs).text();
|
|
2163
|
+
if (after === before) {
|
|
2164
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2165
|
+
return { ok: true, hidden: res.hidden };
|
|
2166
|
+
}
|
|
2167
|
+
return { ok: true, hidden: res.hidden, seq: logUndo(r.abs, before, after) };
|
|
2168
|
+
} catch (err) {
|
|
2169
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2170
|
+
return {
|
|
2171
|
+
ok: false,
|
|
2172
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2173
|
+
error: err instanceof Error ? err.message : 'hide failed',
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
async function editArraySrcOp(input: {
|
|
2179
|
+
canvas?: unknown;
|
|
2180
|
+
arrayName?: unknown;
|
|
2181
|
+
index?: unknown;
|
|
2182
|
+
field?: unknown;
|
|
2183
|
+
value?: unknown;
|
|
2184
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
2185
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2186
|
+
if (!r.ok) return r;
|
|
2187
|
+
const arrayName = typeof input.arrayName === 'string' ? input.arrayName : '';
|
|
2188
|
+
const field = typeof input.field === 'string' ? input.field : 'src';
|
|
2189
|
+
const index = Number.isInteger(input.index) ? (input.index as number) : -1;
|
|
2190
|
+
const value = typeof input.value === 'string' ? input.value : '';
|
|
2191
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(arrayName)) {
|
|
2192
|
+
return { ok: false, status: 400, error: 'invalid array name' };
|
|
2193
|
+
}
|
|
2194
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) {
|
|
2195
|
+
return { ok: false, status: 400, error: 'invalid field' };
|
|
2196
|
+
}
|
|
2197
|
+
if (index < 0 || index > 500) return { ok: false, status: 400, error: 'invalid index' };
|
|
2198
|
+
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
2199
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2200
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2201
|
+
try {
|
|
2202
|
+
const before = await Bun.file(r.abs).text();
|
|
2203
|
+
await editArrayElementString(r.abs, arrayName, index, field, value);
|
|
2204
|
+
const after = await Bun.file(r.abs).text();
|
|
2205
|
+
if (after === before) {
|
|
2206
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2207
|
+
return { ok: true };
|
|
2208
|
+
}
|
|
2209
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
2210
|
+
} catch (err) {
|
|
2211
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2212
|
+
return {
|
|
2213
|
+
ok: false,
|
|
2214
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2215
|
+
error: err instanceof Error ? err.message : 'edit failed',
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
async function reorderSequenceOp(input: {
|
|
2221
|
+
canvas?: unknown;
|
|
2222
|
+
artboardId?: unknown;
|
|
2223
|
+
stableId?: unknown;
|
|
2224
|
+
contentHash?: unknown;
|
|
2225
|
+
refStableId?: unknown;
|
|
2226
|
+
refContentHash?: unknown;
|
|
2227
|
+
position?: unknown;
|
|
2228
|
+
}): Promise<
|
|
2229
|
+
| { ok: true; stableId: string | null; seq?: number }
|
|
2230
|
+
| { ok: false; status: number; error: string }
|
|
2231
|
+
> {
|
|
2232
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2233
|
+
if (!r.ok) return r;
|
|
2234
|
+
const stableId = typeof input.stableId === 'string' ? input.stableId : null;
|
|
2235
|
+
const refStableId = typeof input.refStableId === 'string' ? input.refStableId : null;
|
|
2236
|
+
if (!stableId || !refStableId) {
|
|
2237
|
+
return { ok: false, status: 400, error: 'stableId and refStableId required' };
|
|
2238
|
+
}
|
|
2239
|
+
const position: MovePosition = input.position === 'before' ? 'before' : 'after';
|
|
2240
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2241
|
+
const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
|
|
2242
|
+
const refContentHash =
|
|
2243
|
+
typeof input.refContentHash === 'string' ? input.refContentHash : undefined;
|
|
2244
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2245
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2246
|
+
try {
|
|
2247
|
+
const before = await Bun.file(r.abs).text();
|
|
2248
|
+
const res = await reorderClip(
|
|
2249
|
+
r.abs,
|
|
2250
|
+
artboardId,
|
|
2251
|
+
stableId,
|
|
2252
|
+
contentHash,
|
|
2253
|
+
refStableId,
|
|
2254
|
+
refContentHash,
|
|
2255
|
+
position
|
|
2256
|
+
);
|
|
2257
|
+
const after = await Bun.file(r.abs).text();
|
|
2258
|
+
let seq: number | undefined;
|
|
2259
|
+
if (after === before) {
|
|
2260
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2261
|
+
} else {
|
|
2262
|
+
try {
|
|
2263
|
+
await history.writeSnapshot(rel, before, 'pre-reorder-clip');
|
|
2264
|
+
} catch {
|
|
2265
|
+
/* snapshot best-effort */
|
|
2266
|
+
}
|
|
2267
|
+
seq = logUndo(r.abs, before, after);
|
|
2268
|
+
}
|
|
2269
|
+
return { ok: true, stableId: res.stableId, seq };
|
|
2270
|
+
} catch (err) {
|
|
2271
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2272
|
+
return {
|
|
2273
|
+
ok: false,
|
|
2274
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2275
|
+
error: err instanceof Error ? err.message : 'reorder failed',
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
async function compClips(input: { canvas?: unknown; artboardId?: unknown }) {
|
|
2281
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2282
|
+
if (!r.ok) return r;
|
|
2283
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2284
|
+
try {
|
|
2285
|
+
const source = await Bun.file(r.abs).text();
|
|
2286
|
+
const result = enumerateClips(r.abs, source, artboardId);
|
|
2287
|
+
// Strip the internal source offsets — the client addresses by stableId.
|
|
2288
|
+
const clips = result.clips.map(({ start: _s, end: _e, ...c }) => c);
|
|
2289
|
+
return {
|
|
2290
|
+
ok: true as const,
|
|
2291
|
+
compName: result.compName,
|
|
2292
|
+
artboardId: result.artboardId,
|
|
2293
|
+
fps: result.fps,
|
|
2294
|
+
durationInFrames: result.durationInFrames,
|
|
2295
|
+
clips,
|
|
2296
|
+
// DDR-150 dogfood #5 — loose media beds (audio/bg video) for replace.
|
|
2297
|
+
media: result.media,
|
|
2298
|
+
};
|
|
2299
|
+
} catch (err) {
|
|
2300
|
+
return {
|
|
2301
|
+
ok: false as const,
|
|
2302
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2303
|
+
error: err instanceof Error ? err.message : 'enumerate failed',
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
|
|
1569
2308
|
async function reorderRevert(input: {
|
|
1570
2309
|
canvas?: unknown;
|
|
1571
2310
|
seq?: unknown;
|
|
@@ -2009,10 +2748,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2009
2748
|
loadCanvasState,
|
|
2010
2749
|
saveCanvasState,
|
|
2011
2750
|
loadCanvasMeta,
|
|
2751
|
+
loadCanvasSource,
|
|
2012
2752
|
patchCanvasMeta,
|
|
2013
2753
|
loadAnnotations,
|
|
2014
2754
|
saveAnnotations,
|
|
2015
2755
|
saveAsset,
|
|
2756
|
+
saveAssetFromStream,
|
|
2016
2757
|
saveChatAttachment,
|
|
2017
2758
|
resolveChatAttachment,
|
|
2018
2759
|
createCanvas,
|
|
@@ -2020,7 +2761,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2020
2761
|
editCss,
|
|
2021
2762
|
editText,
|
|
2022
2763
|
editAttr,
|
|
2764
|
+
removeSequenceOp,
|
|
2765
|
+
insertSequenceOp,
|
|
2766
|
+
reorderSequenceOp,
|
|
2767
|
+
editArraySrcOp,
|
|
2768
|
+
toggleHideOp,
|
|
2769
|
+
compClips,
|
|
2023
2770
|
reorder,
|
|
2771
|
+
retimeSequenceOp,
|
|
2024
2772
|
reorderRevert,
|
|
2025
2773
|
buildIndexData,
|
|
2026
2774
|
buildSystemData,
|