@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
package/apps/studio/api.ts
CHANGED
|
@@ -3,17 +3,37 @@
|
|
|
3
3
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import type { Dirent } from 'node:fs';
|
|
6
|
-
import { mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
|
|
6
|
+
import { lstat, mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
|
|
7
7
|
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
|
+
deleteArtboard,
|
|
16
|
+
deleteElement,
|
|
17
|
+
duplicateElement,
|
|
18
|
+
type EditScope,
|
|
19
|
+
editArrayElementString,
|
|
12
20
|
editAttribute,
|
|
21
|
+
enumerateClips,
|
|
22
|
+
type InsertKind,
|
|
23
|
+
insertArtboard,
|
|
24
|
+
insertClip,
|
|
25
|
+
insertElement,
|
|
13
26
|
type MovePosition,
|
|
14
27
|
moveElement,
|
|
15
28
|
removeAttribute,
|
|
29
|
+
removeClip,
|
|
30
|
+
reorderClip,
|
|
31
|
+
resizeArtboard,
|
|
32
|
+
resolveEditScope,
|
|
33
|
+
retimeSequence,
|
|
34
|
+
retimeSequenceByClip,
|
|
16
35
|
editText as runEditText,
|
|
36
|
+
toggleClipHidden,
|
|
17
37
|
} from './canvas-edit.ts';
|
|
18
38
|
import type { Context } from './context.ts';
|
|
19
39
|
import { createHistory } from './history.ts';
|
|
@@ -180,7 +200,7 @@ export type DeleteCanvasResult =
|
|
|
180
200
|
|
|
181
201
|
/** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
|
|
182
202
|
export type EditOpResult =
|
|
183
|
-
| { ok: true; delta: number }
|
|
203
|
+
| { ok: true; delta: number; seq?: number }
|
|
184
204
|
| { ok: false; status: number; error: string };
|
|
185
205
|
|
|
186
206
|
/**
|
|
@@ -230,6 +250,10 @@ export interface Api {
|
|
|
230
250
|
saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
|
|
231
251
|
// Canvas meta sidecar (Phase 4 T5 — .design/ui/<slug>.meta.json)
|
|
232
252
|
loadCanvasMeta(file: string): Promise<Record<string, unknown> | null>;
|
|
253
|
+
/** DDR-148 — raw .tsx source for the Timeline sequence/keyframe parser. */
|
|
254
|
+
loadCanvasSource(
|
|
255
|
+
file: unknown
|
|
256
|
+
): Promise<{ ok: true; source: string } | { ok: false; status: number; error: string }>;
|
|
233
257
|
patchCanvasMeta(
|
|
234
258
|
file: string,
|
|
235
259
|
patch: Record<string, unknown>
|
|
@@ -239,17 +263,27 @@ export interface Api {
|
|
|
239
263
|
saveAnnotations(file: string, svg: string): Promise<boolean>;
|
|
240
264
|
// Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
|
|
241
265
|
saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
266
|
+
/** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
|
|
267
|
+
listAssets(): Promise<{ ok: true; assets: AssetListing[] }>;
|
|
268
|
+
/** DDR-148 — streaming variant for the HTTP route (100 MB video without a
|
|
269
|
+
* full in-RAM buffer). Sniffs + caps + content-addresses like saveAsset. */
|
|
270
|
+
saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult>;
|
|
242
271
|
// Persist a clipboard-pasted ACP composer image → runtime `_chat/attachments/`,
|
|
243
272
|
// returns an absolute path (Phase 31 follow-up — POST /_api/acp/attachment).
|
|
244
273
|
saveChatAttachment(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
245
274
|
// Resolve a content-addressed attachment name (`<sha8>.<ext>`) to its absolute
|
|
246
275
|
// path, or null (GET /_api/acp/attachment — the read side of the pair above).
|
|
247
276
|
resolveChatAttachment(name: unknown): Promise<string | null>;
|
|
248
|
-
// Create a blank brief board
|
|
277
|
+
// Create a blank brief board OR an assembled video-comp from the browser
|
|
278
|
+
// (Phase 22 — POST /_api/canvas; DDR-150 P4 Task 12 adds kind "video-comp").
|
|
249
279
|
createCanvas(input: {
|
|
250
280
|
name?: unknown;
|
|
251
281
|
kind?: unknown;
|
|
252
282
|
group?: unknown;
|
|
283
|
+
clips?: unknown;
|
|
284
|
+
fps?: unknown;
|
|
285
|
+
width?: unknown;
|
|
286
|
+
height?: unknown;
|
|
253
287
|
}): Promise<CreateCanvasResult>;
|
|
254
288
|
// Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
|
|
255
289
|
deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
|
|
@@ -260,6 +294,8 @@ export interface Api {
|
|
|
260
294
|
id?: unknown;
|
|
261
295
|
property?: unknown;
|
|
262
296
|
value?: unknown;
|
|
297
|
+
reset?: unknown;
|
|
298
|
+
idIndex?: unknown;
|
|
263
299
|
}): Promise<EditOpResult>;
|
|
264
300
|
// Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
|
|
265
301
|
editText(input: { canvas?: unknown; id?: unknown; text?: unknown }): Promise<EditOpResult>;
|
|
@@ -281,6 +317,142 @@ export interface Api {
|
|
|
281
317
|
refId?: unknown;
|
|
282
318
|
position?: unknown;
|
|
283
319
|
}): Promise<ReorderOpResult>;
|
|
320
|
+
/** DDR-148 — Timeline drag-to-retime a sequence's durationInFrames / from. */
|
|
321
|
+
retimeSequenceOp(input: {
|
|
322
|
+
canvas?: unknown;
|
|
323
|
+
// DDR-150 P2 — prefer stableId (comp-scoped, multi-comp-safe) over index.
|
|
324
|
+
stableId?: unknown;
|
|
325
|
+
artboardId?: unknown;
|
|
326
|
+
contentHash?: unknown;
|
|
327
|
+
index?: unknown;
|
|
328
|
+
durationInFrames?: unknown;
|
|
329
|
+
from?: unknown;
|
|
330
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
331
|
+
// DDR-150 P3 — remove a clip addressed by stableId (fingerprint + semantic
|
|
332
|
+
// gate; refuses the only clip; drops an adjacent transition in a series).
|
|
333
|
+
removeSequenceOp(input: {
|
|
334
|
+
canvas?: unknown;
|
|
335
|
+
stableId?: unknown;
|
|
336
|
+
artboardId?: unknown;
|
|
337
|
+
contentHash?: unknown;
|
|
338
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
339
|
+
// DDR-150 P4 — insert a new <Sequence> (optionally with media) after a comp's
|
|
340
|
+
// last clip. Returns the new clip's stableId.
|
|
341
|
+
insertSequenceOp(input: {
|
|
342
|
+
canvas?: unknown;
|
|
343
|
+
artboardId?: unknown;
|
|
344
|
+
from?: unknown;
|
|
345
|
+
durationInFrames?: unknown;
|
|
346
|
+
mediaTag?: unknown;
|
|
347
|
+
src?: unknown;
|
|
348
|
+
}): Promise<
|
|
349
|
+
| { ok: true; stableId: string | null; seq?: number }
|
|
350
|
+
| { ok: false; status: number; error: string }
|
|
351
|
+
>;
|
|
352
|
+
// DDR-150 P5 — z-order reorder: move a standalone <Sequence> before/after a
|
|
353
|
+
// sibling (render stacking), reusing moveElement + the semantic gate. Both
|
|
354
|
+
// clips are fingerprint-checked. Returns the moved clip's (re-settled) stableId.
|
|
355
|
+
reorderSequenceOp(input: {
|
|
356
|
+
canvas?: unknown;
|
|
357
|
+
artboardId?: unknown;
|
|
358
|
+
stableId?: unknown;
|
|
359
|
+
contentHash?: unknown;
|
|
360
|
+
refStableId?: unknown;
|
|
361
|
+
refContentHash?: unknown;
|
|
362
|
+
position?: unknown;
|
|
363
|
+
}): Promise<
|
|
364
|
+
| { ok: true; stableId: string | null; seq?: number }
|
|
365
|
+
| { ok: false; status: number; error: string }
|
|
366
|
+
>;
|
|
367
|
+
// DDR-150 dogfood — replace a media src that lives in an array literal
|
|
368
|
+
// (the showreel `CLIPS[i].src` pattern), addressed by mediaArrayRef.
|
|
369
|
+
editArraySrcOp(input: {
|
|
370
|
+
canvas?: unknown;
|
|
371
|
+
arrayName?: unknown;
|
|
372
|
+
index?: unknown;
|
|
373
|
+
field?: unknown;
|
|
374
|
+
value?: unknown;
|
|
375
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
376
|
+
// DDR-150 dogfood — hide/show a clip (gates its body behind {false && …}).
|
|
377
|
+
toggleHideOp(input: {
|
|
378
|
+
canvas?: unknown;
|
|
379
|
+
stableId?: unknown;
|
|
380
|
+
artboardId?: unknown;
|
|
381
|
+
contentHash?: unknown;
|
|
382
|
+
}): Promise<
|
|
383
|
+
{ ok: true; hidden: boolean; seq?: number } | { ok: false; status: number; error: string }
|
|
384
|
+
>;
|
|
385
|
+
// DDR-150 P2 — the single authoritative clip enumerator for a video-comp.
|
|
386
|
+
// Read-only; the Timeline addresses every op by the returned `stableId`
|
|
387
|
+
// (never a regex document-order index — the multi-comp mis-hit defect).
|
|
388
|
+
compClips(input: { canvas?: unknown; artboardId?: unknown }): Promise<
|
|
389
|
+
| {
|
|
390
|
+
ok: true;
|
|
391
|
+
compName: string | null;
|
|
392
|
+
artboardId: string | null;
|
|
393
|
+
fps: number | null;
|
|
394
|
+
durationInFrames: number | null;
|
|
395
|
+
clips: Array<Omit<ClipInfo, 'start' | 'end'>>;
|
|
396
|
+
}
|
|
397
|
+
| { ok: false; status: number; error: string }
|
|
398
|
+
>;
|
|
399
|
+
// Stage I (feature-element-editing-robustness) — general element structural
|
|
400
|
+
// edits. Each logs a whole-file undo seq (reverted via reorderRevert).
|
|
401
|
+
/** Delete an element by data-cd-id (reused-component instance via idIndex). */
|
|
402
|
+
deleteElementOp(input: {
|
|
403
|
+
canvas?: unknown;
|
|
404
|
+
id?: unknown;
|
|
405
|
+
idIndex?: unknown;
|
|
406
|
+
}): Promise<
|
|
407
|
+
{ ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
|
|
408
|
+
>;
|
|
409
|
+
/** Insert a synthesized div/text/image relative to a reference element. */
|
|
410
|
+
insertElementOp(input: {
|
|
411
|
+
canvas?: unknown;
|
|
412
|
+
refId?: unknown;
|
|
413
|
+
position?: unknown;
|
|
414
|
+
kind?: unknown;
|
|
415
|
+
src?: unknown;
|
|
416
|
+
refIndex?: unknown;
|
|
417
|
+
}): Promise<
|
|
418
|
+
{ ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
|
|
419
|
+
>;
|
|
420
|
+
/** Insert a new empty artboard from a screen-size preset. */
|
|
421
|
+
insertArtboardOp(input: {
|
|
422
|
+
canvas?: unknown;
|
|
423
|
+
id?: unknown;
|
|
424
|
+
label?: unknown;
|
|
425
|
+
width?: unknown;
|
|
426
|
+
height?: unknown;
|
|
427
|
+
}): Promise<
|
|
428
|
+
{ ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
|
|
429
|
+
>;
|
|
430
|
+
/** Free-hand artboard resize — write width/height numeric props (DDR-027, D4). */
|
|
431
|
+
resizeArtboardOp(input: {
|
|
432
|
+
canvas?: unknown;
|
|
433
|
+
artboardId?: unknown;
|
|
434
|
+
width?: unknown;
|
|
435
|
+
height?: unknown;
|
|
436
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
437
|
+
/** Duplicate an element (Cmd+D) — a copy as the next sibling, whole-file undo. */
|
|
438
|
+
duplicateElementOp(input: {
|
|
439
|
+
canvas?: unknown;
|
|
440
|
+
id?: unknown;
|
|
441
|
+
idIndex?: unknown;
|
|
442
|
+
}): Promise<
|
|
443
|
+
{ ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
|
|
444
|
+
>;
|
|
445
|
+
/** Delete an artboard by its `id` prop (whole-file undo seq). */
|
|
446
|
+
deleteArtboardOp(input: {
|
|
447
|
+
canvas?: unknown;
|
|
448
|
+
artboardId?: unknown;
|
|
449
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
450
|
+
/** Edit-scope verdict (local vs shared component instance) for the INV-3 badge. */
|
|
451
|
+
editScopeOp(input: {
|
|
452
|
+
canvas?: unknown;
|
|
453
|
+
id?: unknown;
|
|
454
|
+
rendered?: unknown;
|
|
455
|
+
}): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }>;
|
|
284
456
|
// Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
|
|
285
457
|
// file content swap from the in-memory revert log — immune to the positional
|
|
286
458
|
// data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
|
|
@@ -327,7 +499,10 @@ export const ASSET_MAX_BYTES = 10 * 1024 * 1024;
|
|
|
327
499
|
*/
|
|
328
500
|
export const ASSET_SESSION_BUDGET = (() => {
|
|
329
501
|
const env = Number(process.env.MAUDE_ASSET_SESSION_BUDGET);
|
|
330
|
-
|
|
502
|
+
// DDR-148 — raised 256 MB → 1 GB now that the route accepts video/audio (one
|
|
503
|
+
// 100 MB clip would blow a 256 MB budget after a couple of drops). Still an
|
|
504
|
+
// aggregate per-server-instance disk-fill bound; env-overridable.
|
|
505
|
+
return Number.isFinite(env) && env > 0 ? env : 1024 * 1024 * 1024;
|
|
331
506
|
})();
|
|
332
507
|
|
|
333
508
|
/**
|
|
@@ -392,6 +567,121 @@ export interface SaveAssetResult {
|
|
|
392
567
|
path?: string;
|
|
393
568
|
}
|
|
394
569
|
|
|
570
|
+
/** Stage F1 — one media asset in the AssetPicker listing. */
|
|
571
|
+
export interface AssetListing {
|
|
572
|
+
/** designRoot-relative path (`assets/<sha8>.<ext>`) — what a src/href uses. */
|
|
573
|
+
path: string;
|
|
574
|
+
name: string;
|
|
575
|
+
ext: string;
|
|
576
|
+
kind: 'image' | 'video';
|
|
577
|
+
size: number;
|
|
578
|
+
mtimeMs: number;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** DDR-148 — media category, decides which per-file cap applies. */
|
|
582
|
+
export type AssetCategory = 'image' | 'video' | 'audio';
|
|
583
|
+
|
|
584
|
+
export interface AssetTypeInfo {
|
|
585
|
+
/** Stored file extension (bytes decide it, never the upload name). */
|
|
586
|
+
ext: string;
|
|
587
|
+
category: AssetCategory;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* DDR-148 — per-file ceiling for time-based media (video + audio). Images keep
|
|
592
|
+
* the tighter {@link ASSET_MAX_BYTES} 10 MB cap. Overridable via
|
|
593
|
+
* `MAUDE_ASSET_MAX_VIDEO_BYTES` (bytes) for power users. The route lives on the
|
|
594
|
+
* (untrusted) canvas origin, so this cap + the session budget + the streamed
|
|
595
|
+
* write are the trust mitigation, exactly like the image caps (DDR-088).
|
|
596
|
+
*/
|
|
597
|
+
export const ASSET_MAX_VIDEO_BYTES = (() => {
|
|
598
|
+
const env = Number(process.env.MAUDE_ASSET_MAX_VIDEO_BYTES);
|
|
599
|
+
return Number.isFinite(env) && env > 0 ? env : 100 * 1024 * 1024;
|
|
600
|
+
})();
|
|
601
|
+
|
|
602
|
+
/** The byte cap for a category. */
|
|
603
|
+
export function assetCapForCategory(category: AssetCategory): number {
|
|
604
|
+
return category === 'image' ? ASSET_MAX_BYTES : ASSET_MAX_VIDEO_BYTES;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const UNSUPPORTED_ASSET_MSG =
|
|
608
|
+
'unsupported media type — png/jpeg/gif/webp images or mp4/mov/webm/mp3/wav/m4a media only (SVG/script rejected)';
|
|
609
|
+
|
|
610
|
+
function capError(category?: AssetCategory): string {
|
|
611
|
+
if (category === 'image') return 'image exceeds the 10 MB cap';
|
|
612
|
+
const mb = Math.round(ASSET_MAX_VIDEO_BYTES / (1024 * 1024));
|
|
613
|
+
return `media exceeds the ${mb} MB cap`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Concatenate a small list of chunks (used only for the ≤ few-KB sniff head). */
|
|
617
|
+
function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
|
|
618
|
+
let len = 0;
|
|
619
|
+
for (const c of chunks) len += c.length;
|
|
620
|
+
const out = new Uint8Array(len);
|
|
621
|
+
let off = 0;
|
|
622
|
+
for (const c of chunks) {
|
|
623
|
+
out.set(c, off);
|
|
624
|
+
off += c.length;
|
|
625
|
+
}
|
|
626
|
+
return out;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* DDR-148 — magic-byte type sniff for the WIDENED asset route: images (via
|
|
631
|
+
* {@link sniffImageType}) PLUS time-based media. The declared name / extension
|
|
632
|
+
* / Content-Type is NEVER trusted — the bytes decide the stored extension AND
|
|
633
|
+
* the category (→ which cap applies). The server only sniffs; it never PARSES a
|
|
634
|
+
* container (parsing happens in the sandboxed capture browser). Anything that
|
|
635
|
+
* isn't a recognised raster/video/audio magic number (SVG, HTML, arbitrary
|
|
636
|
+
* script) → null → rejected (415). Needs ≥ 12 bytes for the ISO-BMFF brands.
|
|
637
|
+
*/
|
|
638
|
+
export function sniffAssetType(bytes: Uint8Array): AssetTypeInfo | null {
|
|
639
|
+
const img = sniffImageType(bytes);
|
|
640
|
+
if (img) return { ext: img, category: 'image' };
|
|
641
|
+
const b = bytes;
|
|
642
|
+
// ISO-BMFF (mp4 / mov / m4a): "ftyp" box at offset 4, brand at offset 8.
|
|
643
|
+
if (
|
|
644
|
+
b.length >= 12 &&
|
|
645
|
+
b[4] === 0x66 && // f
|
|
646
|
+
b[5] === 0x74 && // t
|
|
647
|
+
b[6] === 0x79 && // y
|
|
648
|
+
b[7] === 0x70 // p
|
|
649
|
+
) {
|
|
650
|
+
const brand = String.fromCharCode(b[8] ?? 0, b[9] ?? 0, b[10] ?? 0, b[11] ?? 0);
|
|
651
|
+
if (brand === 'qt ') return { ext: 'mov', category: 'video' };
|
|
652
|
+
if (brand.startsWith('M4A')) return { ext: 'm4a', category: 'audio' };
|
|
653
|
+
if (brand.startsWith('M4V')) return { ext: 'm4v', category: 'video' };
|
|
654
|
+
// isom / mp41 / mp42 / avc1 / iso2 / iso5 / dash / mp4v / … → treat as mp4.
|
|
655
|
+
return { ext: 'mp4', category: 'video' };
|
|
656
|
+
}
|
|
657
|
+
// Matroska / WebM — EBML header 1A 45 DF A3.
|
|
658
|
+
if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) {
|
|
659
|
+
return { ext: 'webm', category: 'video' };
|
|
660
|
+
}
|
|
661
|
+
// MP3 — "ID3" tag OR a frame-sync (0xFF followed by 0b111xxxxx).
|
|
662
|
+
if (b.length >= 3 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) {
|
|
663
|
+
return { ext: 'mp3', category: 'audio' };
|
|
664
|
+
}
|
|
665
|
+
if (b.length >= 2 && b[0] === 0xff && ((b[1] ?? 0) & 0xe0) === 0xe0) {
|
|
666
|
+
return { ext: 'mp3', category: 'audio' };
|
|
667
|
+
}
|
|
668
|
+
// WAV — "RIFF"????"WAVE".
|
|
669
|
+
if (
|
|
670
|
+
b.length >= 12 &&
|
|
671
|
+
b[0] === 0x52 &&
|
|
672
|
+
b[1] === 0x49 &&
|
|
673
|
+
b[2] === 0x46 &&
|
|
674
|
+
b[3] === 0x46 &&
|
|
675
|
+
b[8] === 0x57 &&
|
|
676
|
+
b[9] === 0x41 &&
|
|
677
|
+
b[10] === 0x56 &&
|
|
678
|
+
b[11] === 0x45
|
|
679
|
+
) {
|
|
680
|
+
return { ext: 'wav', category: 'audio' };
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
395
685
|
export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
396
686
|
const onCommentsChanged = hooks.onCommentsChanged;
|
|
397
687
|
const onAnnotationsChanged = hooks.onAnnotationsChanged;
|
|
@@ -405,6 +695,67 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
405
695
|
const reorderLog = new Map<number, { abs: string; before: string; after: string }>();
|
|
406
696
|
let reorderSeq = 0;
|
|
407
697
|
|
|
698
|
+
// DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops
|
|
699
|
+
// (retime / remove / insert / z-reorder / replace-src): every successful op
|
|
700
|
+
// registers its {before, after} and returns the seq; the shell keeps a
|
|
701
|
+
// per-canvas undo/redo stack of seqs and replays them through
|
|
702
|
+
// /_api/reorder-revert (guarded whole-file swap, 409 on divergence).
|
|
703
|
+
function logUndo(abs: string, before: string, after: string): number {
|
|
704
|
+
const seq = ++reorderSeq;
|
|
705
|
+
reorderLog.set(seq, { abs, before, after });
|
|
706
|
+
while (reorderLog.size > REORDER_LOG_CAP) {
|
|
707
|
+
const oldest = reorderLog.keys().next().value;
|
|
708
|
+
if (oldest === undefined) break;
|
|
709
|
+
reorderLog.delete(oldest);
|
|
710
|
+
}
|
|
711
|
+
return seq;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ── Structural-write throttle + source-size ceiling (G3 security, DDR-152) ──
|
|
715
|
+
// The new structural verbs (delete / insert-element / insert-artboard /
|
|
716
|
+
// resize-artboard) are the first that let an UNTRUSTED active canvas both
|
|
717
|
+
// *remove* and *grow* its own source: the shell relays a canvas's `dgn:*`
|
|
718
|
+
// request after gating only on `e.source === activeWin` — there is no user
|
|
719
|
+
// gesture on the wire, so a hostile on-load script can drive these in a loop.
|
|
720
|
+
// Two bounds close the disk-fill / silent-shred DoS the adversarial review
|
|
721
|
+
// flagged (mirrors the ASSET_SESSION_BUDGET the /_api/asset lane already has,
|
|
722
|
+
// DDR-088 — this is the OTHER untrusted-origin disk-write surface):
|
|
723
|
+
// (a) a per-api token bucket caps the sustained rate. A human does a few
|
|
724
|
+
// structural edits/sec; a scripted loop can't beat the refill, which
|
|
725
|
+
// keeps every whole-file _history snapshot + RAM undo entry rate-bound.
|
|
726
|
+
// (b) a growth op is refused once the source already exceeds
|
|
727
|
+
// MAX_CANVAS_SOURCE, so inserts can't inflate the .tsx (and the snapshot
|
|
728
|
+
// + undo copies it holds) without bound. Deletes/shrinks always pass.
|
|
729
|
+
// Bucket state is per-createApi (each test/instance starts full); env-tunable.
|
|
730
|
+
const STRUCTURAL_BURST = (() => {
|
|
731
|
+
const env = Number(process.env.MAUDE_STRUCTURAL_BURST);
|
|
732
|
+
return Number.isFinite(env) && env > 0 ? Math.floor(env) : 40;
|
|
733
|
+
})();
|
|
734
|
+
const STRUCTURAL_REFILL_PER_SEC = 8;
|
|
735
|
+
let structuralTokens = STRUCTURAL_BURST;
|
|
736
|
+
let structuralLastRefill = Date.now();
|
|
737
|
+
/** Consume one token; false when the caller is over the sustained rate. */
|
|
738
|
+
function takeStructuralToken(): boolean {
|
|
739
|
+
const now = Date.now();
|
|
740
|
+
structuralTokens = Math.min(
|
|
741
|
+
STRUCTURAL_BURST,
|
|
742
|
+
structuralTokens + ((now - structuralLastRefill) / 1000) * STRUCTURAL_REFILL_PER_SEC
|
|
743
|
+
);
|
|
744
|
+
structuralLastRefill = now;
|
|
745
|
+
if (structuralTokens < 1) return false;
|
|
746
|
+
structuralTokens -= 1;
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
const RATE_LIMITED = {
|
|
750
|
+
ok: false as const,
|
|
751
|
+
status: 429,
|
|
752
|
+
error: 'too many structural edits — slow down',
|
|
753
|
+
};
|
|
754
|
+
const MAX_CANVAS_SOURCE = (() => {
|
|
755
|
+
const env = Number(process.env.MAUDE_MAX_CANVAS_SOURCE);
|
|
756
|
+
return Number.isFinite(env) && env > 0 ? Math.floor(env) : 512 * 1024;
|
|
757
|
+
})();
|
|
758
|
+
|
|
408
759
|
function fileSlug(file: string): string {
|
|
409
760
|
return canvasSlugFromRel(file, paths.designRel);
|
|
410
761
|
}
|
|
@@ -987,51 +1338,191 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
987
1338
|
// user input, but a poisoned designRoot must still not escape).
|
|
988
1339
|
// Running total of bytes this server instance has actually written (post-dedupe).
|
|
989
1340
|
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}`;
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* DDR-148 — the streaming write path behind `POST /_api/asset`. Reads the
|
|
1344
|
+
* request body chunk-by-chunk so a 100 MB video never lands as a single
|
|
1345
|
+
* ArrayBuffer in RAM (the memory-amplification vector DDR-088 §review flagged
|
|
1346
|
+
* for the untrusted canvas origin — worse now that the per-file cap is 100 MB,
|
|
1347
|
+
* not 10 MB). Sniffs the type from the head, applies the CATEGORY cap while
|
|
1348
|
+
* streaming (image 10 MB · video/audio {@link ASSET_MAX_VIDEO_BYTES}), writes
|
|
1349
|
+
* to a temp file, then content-addresses (sha8) → dedupe-rename. Nothing is
|
|
1350
|
+
* ever parsed — only magic bytes are read. `saveAsset(bytes)` wraps this so
|
|
1351
|
+
* both the route and any programmatic caller share ONE tested path.
|
|
1352
|
+
*/
|
|
1353
|
+
async function saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult> {
|
|
1005
1354
|
const assetsDir = path.join(paths.designRoot, 'assets');
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1355
|
+
const tmpName = `.tmp-${crypto.randomBytes(8).toString('hex')}`;
|
|
1356
|
+
const tmpAbs = path.join(assetsDir, tmpName);
|
|
1357
|
+
const hash = crypto.createHash('sha256');
|
|
1358
|
+
let sink: Bun.FileSink | null = null;
|
|
1359
|
+
let typeInfo: AssetTypeInfo | null = null;
|
|
1360
|
+
let cap = ASSET_MAX_VIDEO_BYTES; // provisional max until the head is sniffed
|
|
1361
|
+
let total = 0;
|
|
1362
|
+
const headChunks: Uint8Array[] = [];
|
|
1363
|
+
let headLen = 0;
|
|
1364
|
+
|
|
1365
|
+
const cleanup = async () => {
|
|
1366
|
+
try {
|
|
1367
|
+
if (sink) await sink.end();
|
|
1368
|
+
} catch {
|
|
1369
|
+
/* sink already closed */
|
|
1370
|
+
}
|
|
1371
|
+
try {
|
|
1372
|
+
await rm(tmpAbs, { force: true });
|
|
1373
|
+
} catch {
|
|
1374
|
+
/* temp already gone */
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
const flushHead = () => {
|
|
1378
|
+
for (const c of headChunks) {
|
|
1379
|
+
hash.update(c);
|
|
1380
|
+
sink?.write(c);
|
|
1381
|
+
}
|
|
1382
|
+
headChunks.length = 0;
|
|
1383
|
+
headLen = 0;
|
|
1384
|
+
};
|
|
1385
|
+
|
|
1386
|
+
// Explicit reader (NOT `for await` — Bun's HTTP `req.body` ReadableStream
|
|
1387
|
+
// does not implement Symbol.asyncIterator, so async-iteration throws
|
|
1388
|
+
// "undefined is not a function"; getReader().read() works for every stream).
|
|
1389
|
+
const reader = stream.getReader();
|
|
1014
1390
|
try {
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
if (
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
};
|
|
1391
|
+
await mkdir(assetsDir, { recursive: true });
|
|
1392
|
+
for (;;) {
|
|
1393
|
+
const { done, value } = await reader.read();
|
|
1394
|
+
if (done) break;
|
|
1395
|
+
const chunk = value;
|
|
1396
|
+
if (!chunk || chunk.length === 0) continue;
|
|
1397
|
+
total += chunk.length;
|
|
1398
|
+
if (total > cap) {
|
|
1399
|
+
await cleanup();
|
|
1400
|
+
return { ok: false, status: 413, error: capError(typeInfo?.category) };
|
|
1026
1401
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1402
|
+
if (!typeInfo) {
|
|
1403
|
+
headChunks.push(chunk);
|
|
1404
|
+
headLen += chunk.length;
|
|
1405
|
+
if (headLen >= 12) {
|
|
1406
|
+
typeInfo = sniffAssetType(concatBytes(headChunks));
|
|
1407
|
+
if (!typeInfo) {
|
|
1408
|
+
await cleanup(); // nothing written yet — no temp file to remove
|
|
1409
|
+
return { ok: false, status: 415, error: UNSUPPORTED_ASSET_MSG };
|
|
1410
|
+
}
|
|
1411
|
+
cap = assetCapForCategory(typeInfo.category);
|
|
1412
|
+
if (total > cap) {
|
|
1413
|
+
await cleanup();
|
|
1414
|
+
return { ok: false, status: 413, error: capError(typeInfo.category) };
|
|
1415
|
+
}
|
|
1416
|
+
sink = Bun.file(tmpAbs).writer();
|
|
1417
|
+
flushHead();
|
|
1418
|
+
}
|
|
1419
|
+
} else {
|
|
1420
|
+
hash.update(chunk);
|
|
1421
|
+
sink?.write(chunk);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (total === 0) {
|
|
1426
|
+
await cleanup();
|
|
1427
|
+
return { ok: false, status: 400, error: 'empty body' };
|
|
1428
|
+
}
|
|
1429
|
+
// A body shorter than 12 bytes never triggered the mid-stream sniff.
|
|
1430
|
+
if (!typeInfo) {
|
|
1431
|
+
typeInfo = sniffAssetType(concatBytes(headChunks));
|
|
1432
|
+
if (!typeInfo) {
|
|
1433
|
+
await cleanup();
|
|
1434
|
+
return { ok: false, status: 415, error: UNSUPPORTED_ASSET_MSG };
|
|
1435
|
+
}
|
|
1436
|
+
cap = assetCapForCategory(typeInfo.category);
|
|
1437
|
+
if (total > cap) {
|
|
1438
|
+
await cleanup();
|
|
1439
|
+
return { ok: false, status: 413, error: capError(typeInfo.category) };
|
|
1440
|
+
}
|
|
1441
|
+
sink = Bun.file(tmpAbs).writer();
|
|
1442
|
+
flushHead();
|
|
1443
|
+
}
|
|
1444
|
+
if (sink) await sink.end();
|
|
1445
|
+
|
|
1446
|
+
const sha8 = hash.digest('hex').slice(0, 8);
|
|
1447
|
+
const name = `${sha8}.${typeInfo.ext}`;
|
|
1448
|
+
const fileAbs = path.join(assetsDir, name);
|
|
1449
|
+
// Containment backstop — the name is content-addressed (sha8 hex + sniffed
|
|
1450
|
+
// ext), no user-controlled segment, but assert anyway (poisoned designRoot).
|
|
1451
|
+
if (path.resolve(fileAbs) !== path.join(path.resolve(assetsDir), name)) {
|
|
1452
|
+
await rm(tmpAbs, { force: true });
|
|
1453
|
+
return { ok: false, status: 400, error: 'resolved asset path escapes assets dir' };
|
|
1454
|
+
}
|
|
1455
|
+
// Dedupe — identical bytes hash to the same name; drop the temp copy.
|
|
1456
|
+
if (await Bun.file(fileAbs).exists()) {
|
|
1457
|
+
await rm(tmpAbs, { force: true });
|
|
1458
|
+
return { ok: true, path: `assets/${name}` };
|
|
1459
|
+
}
|
|
1460
|
+
// Aggregate write budget — bounds a scripted disk-fill loop from the
|
|
1461
|
+
// untrusted canvas origin. Only a genuinely NEW file counts.
|
|
1462
|
+
if (assetBytesWritten + total > ASSET_SESSION_BUDGET) {
|
|
1463
|
+
await rm(tmpAbs, { force: true });
|
|
1464
|
+
return {
|
|
1465
|
+
ok: false,
|
|
1466
|
+
status: 429,
|
|
1467
|
+
error: 'asset write budget exceeded for this server session',
|
|
1468
|
+
};
|
|
1030
1469
|
}
|
|
1470
|
+
await rename(tmpAbs, fileAbs);
|
|
1471
|
+
assetBytesWritten += total;
|
|
1472
|
+
return { ok: true, path: `assets/${name}` };
|
|
1031
1473
|
} catch (err) {
|
|
1474
|
+
await cleanup();
|
|
1032
1475
|
return { ok: false, status: 500, error: err instanceof Error ? err.message : 'write failed' };
|
|
1476
|
+
} finally {
|
|
1477
|
+
try {
|
|
1478
|
+
reader.releaseLock();
|
|
1479
|
+
} catch {
|
|
1480
|
+
/* already released */
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
async function saveAsset(bytes: Uint8Array): Promise<SaveAssetResult> {
|
|
1486
|
+
if (!bytes || bytes.length === 0) return { ok: false, status: 400, error: 'empty body' };
|
|
1487
|
+
return saveAssetFromStream(new Response(bytes).body as ReadableStream<Uint8Array>);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// Stage F1 (feature-element-editing-robustness) — list the versioned content-
|
|
1491
|
+
// addressed media under <designRoot>/assets/ for the AssetPicker (Replace
|
|
1492
|
+
// image / insert image). Read-only; returns image + video assets only (never
|
|
1493
|
+
// arbitrary files), newest first, capped. MAIN-ORIGIN ONLY at the route layer.
|
|
1494
|
+
async function listAssets(): Promise<{ ok: true; assets: AssetListing[] }> {
|
|
1495
|
+
const assetsDir = path.join(paths.designRoot, 'assets');
|
|
1496
|
+
const IMG = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg']);
|
|
1497
|
+
const VID = new Set(['.mp4', '.webm', '.mov', '.m4v', '.ogg']);
|
|
1498
|
+
let entries: string[] = [];
|
|
1499
|
+
try {
|
|
1500
|
+
entries = await readdir(assetsDir);
|
|
1501
|
+
} catch {
|
|
1502
|
+
return { ok: true, assets: [] }; // no assets dir yet
|
|
1503
|
+
}
|
|
1504
|
+
const out: AssetListing[] = [];
|
|
1505
|
+
for (const name of entries) {
|
|
1506
|
+
const ext = path.extname(name).toLowerCase();
|
|
1507
|
+
const kind = IMG.has(ext) ? 'image' : VID.has(ext) ? 'video' : null;
|
|
1508
|
+
if (!kind) continue;
|
|
1509
|
+
let size = 0;
|
|
1510
|
+
let mtimeMs = 0;
|
|
1511
|
+
try {
|
|
1512
|
+
// lstat (not stat): a symlink planted under assets/ resolves to isFile()
|
|
1513
|
+
// === false here → skipped. Defends the listing against a symlink that
|
|
1514
|
+
// would otherwise be served by the /assets/<file> static route.
|
|
1515
|
+
const st = await lstat(path.join(assetsDir, name));
|
|
1516
|
+
if (!st.isFile()) continue;
|
|
1517
|
+
size = st.size;
|
|
1518
|
+
mtimeMs = st.mtimeMs;
|
|
1519
|
+
} catch {
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
out.push({ path: `assets/${name}`, name, ext: ext.slice(1), kind, size, mtimeMs });
|
|
1033
1523
|
}
|
|
1034
|
-
|
|
1524
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1525
|
+
return { ok: true, assets: out.slice(0, 500) };
|
|
1035
1526
|
}
|
|
1036
1527
|
|
|
1037
1528
|
// Phase 31 follow-up — persist an image pasted straight from the clipboard into
|
|
@@ -1112,11 +1603,37 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1112
1603
|
name?: unknown;
|
|
1113
1604
|
kind?: unknown;
|
|
1114
1605
|
group?: unknown;
|
|
1606
|
+
// DDR-150 P4 Task 12 — video-comp assemble payload (kind === 'video-comp').
|
|
1607
|
+
clips?: unknown;
|
|
1608
|
+
fps?: unknown;
|
|
1609
|
+
width?: unknown;
|
|
1610
|
+
height?: unknown;
|
|
1115
1611
|
}): Promise<CreateCanvasResult> {
|
|
1116
|
-
// v1
|
|
1612
|
+
// v1 stamps blank brief-boards + assembled video-comps — richer generation
|
|
1613
|
+
// stays with `/design:new` (Claude).
|
|
1117
1614
|
const kind = input.kind == null || input.kind === '' ? 'brief-board' : input.kind;
|
|
1118
|
-
if (kind !== 'brief-board') {
|
|
1119
|
-
return { ok: false, status: 400, error: '
|
|
1615
|
+
if (kind !== 'brief-board' && kind !== 'video-comp') {
|
|
1616
|
+
return { ok: false, status: 400, error: 'kind must be "brief-board" or "video-comp"' };
|
|
1617
|
+
}
|
|
1618
|
+
// Parse + validate the assemble clip list up front (video-comp only).
|
|
1619
|
+
const assembleClips: AssembleClip[] = [];
|
|
1620
|
+
if (kind === 'video-comp') {
|
|
1621
|
+
if (!Array.isArray(input.clips) || input.clips.length === 0) {
|
|
1622
|
+
return { ok: false, status: 400, error: 'video-comp needs a non-empty clips[]' };
|
|
1623
|
+
}
|
|
1624
|
+
if (input.clips.length > 60) {
|
|
1625
|
+
return { ok: false, status: 400, error: 'too many clips (max 60)' };
|
|
1626
|
+
}
|
|
1627
|
+
for (const raw of input.clips) {
|
|
1628
|
+
const c = raw as { src?: unknown; mediaKind?: unknown; durationInFrames?: unknown };
|
|
1629
|
+
const src = typeof c?.src === 'string' ? c.src : '';
|
|
1630
|
+
if (!src) return { ok: false, status: 400, error: 'each clip needs a src' };
|
|
1631
|
+
const mediaKind = c?.mediaKind === 'audio' ? 'audio' : 'video';
|
|
1632
|
+
const durationInFrames = Number.isFinite(Number(c?.durationInFrames))
|
|
1633
|
+
? Math.max(1, Math.min(100000, Math.round(Number(c.durationInFrames))))
|
|
1634
|
+
: null;
|
|
1635
|
+
assembleClips.push({ src, mediaKind, durationInFrames });
|
|
1636
|
+
}
|
|
1120
1637
|
}
|
|
1121
1638
|
const v = validateCanvasName(input.name);
|
|
1122
1639
|
if (!v.ok || !v.name || !v.componentName) {
|
|
@@ -1169,25 +1686,55 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1169
1686
|
const slug = fileSlug(rel);
|
|
1170
1687
|
const dsName = cfg.defaultDesignSystem || 'project';
|
|
1171
1688
|
const platform = 'desktop';
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1689
|
+
let tsx: string;
|
|
1690
|
+
if (kind === 'video-comp') {
|
|
1691
|
+
try {
|
|
1692
|
+
tsx = assembleCompSource(v.componentName, assembleClips, {
|
|
1693
|
+
fps: Number.isFinite(Number(input.fps)) ? Number(input.fps) : undefined,
|
|
1694
|
+
width: Number.isFinite(Number(input.width)) ? Number(input.width) : undefined,
|
|
1695
|
+
height: Number.isFinite(Number(input.height)) ? Number(input.height) : undefined,
|
|
1696
|
+
});
|
|
1697
|
+
} catch (err) {
|
|
1698
|
+
return {
|
|
1699
|
+
ok: false,
|
|
1700
|
+
status: 400,
|
|
1701
|
+
error: err instanceof Error ? err.message : 'assemble failed',
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
} else {
|
|
1705
|
+
tsx = renderBriefBoard({
|
|
1706
|
+
name: v.name,
|
|
1707
|
+
componentName: v.componentName,
|
|
1708
|
+
dsName,
|
|
1709
|
+
platform,
|
|
1710
|
+
seedHint: 'Empty brief board — annotate me',
|
|
1711
|
+
historyDir: path.posix.join(paths.designRel, '_history', slug),
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1180
1714
|
const now = new Date().toISOString();
|
|
1181
|
-
const meta =
|
|
1182
|
-
kind
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1715
|
+
const meta =
|
|
1716
|
+
kind === 'video-comp'
|
|
1717
|
+
? {
|
|
1718
|
+
kind: 'video-comp',
|
|
1719
|
+
title: v.name,
|
|
1720
|
+
subtitle: 'assembled reel',
|
|
1721
|
+
brief: v.name,
|
|
1722
|
+
tags: ['video'],
|
|
1723
|
+
designSystem: dsName,
|
|
1724
|
+
platform,
|
|
1725
|
+
created: now,
|
|
1726
|
+
last_modified: now,
|
|
1727
|
+
}
|
|
1728
|
+
: {
|
|
1729
|
+
kind: 'brief-board',
|
|
1730
|
+
title: v.name,
|
|
1731
|
+
subtitle: 'brief board',
|
|
1732
|
+
brief: v.name,
|
|
1733
|
+
designSystem: dsName,
|
|
1734
|
+
platform,
|
|
1735
|
+
created: now,
|
|
1736
|
+
last_modified: now,
|
|
1737
|
+
};
|
|
1191
1738
|
await Bun.write(fileAbs, tsx);
|
|
1192
1739
|
await Bun.write(
|
|
1193
1740
|
path.join(groupAbs, `${v.name}.meta.json`),
|
|
@@ -1356,6 +1903,27 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1356
1903
|
return { ok: true, abs };
|
|
1357
1904
|
}
|
|
1358
1905
|
|
|
1906
|
+
/**
|
|
1907
|
+
* DDR-148 — read a canvas's RAW .tsx source (containment via resolveCanvasAbs).
|
|
1908
|
+
* The Timeline panel parses `<Sequence>`/`<TransitionSeries.Sequence>` blocks
|
|
1909
|
+
* + `interpolate` windows from it to draw the sequence/keyframe rows. Read-only,
|
|
1910
|
+
* MAIN-ORIGIN ONLY at the route layer (never the untrusted canvas iframe).
|
|
1911
|
+
*/
|
|
1912
|
+
async function loadCanvasSource(
|
|
1913
|
+
file: unknown
|
|
1914
|
+
): Promise<{ ok: true; source: string } | { ok: false; status: number; error: string }> {
|
|
1915
|
+
const r = resolveCanvasAbs(file);
|
|
1916
|
+
if (!r.ok) return r;
|
|
1917
|
+
const f = Bun.file(r.abs);
|
|
1918
|
+
if (!(await f.exists())) return { ok: false, status: 404, error: 'canvas not found' };
|
|
1919
|
+
const source = await f.text();
|
|
1920
|
+
// A comp is small; never stream a huge blob into the panel.
|
|
1921
|
+
if (source.length > 512 * 1024) {
|
|
1922
|
+
return { ok: false, status: 413, error: 'canvas source too large for the timeline parser' };
|
|
1923
|
+
}
|
|
1924
|
+
return { ok: true, source };
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1359
1927
|
// 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
|
|
1360
1928
|
const CD_ID_RE = /^[0-9a-f]{8}$/;
|
|
1361
1929
|
|
|
@@ -1374,11 +1942,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1374
1942
|
const rel = path.relative(paths.designRoot, abs);
|
|
1375
1943
|
ctx.bus.emit('activity:suppress', rel);
|
|
1376
1944
|
try {
|
|
1945
|
+
// DDR-150 dogfood #1 — capture before/after so the edit registers in the
|
|
1946
|
+
// whole-file undo log (the Timeline replace-src path rides this; other
|
|
1947
|
+
// inspector edits get an undoable seq for free).
|
|
1948
|
+
const before = await Bun.file(abs).text();
|
|
1377
1949
|
const res = await run();
|
|
1378
1950
|
// `changed === false` = the op collapsed to a no-op and nothing hit disk —
|
|
1379
1951
|
// NOT `delta === 0`, which an equal-length replacement also produces.
|
|
1380
|
-
if (res.changed === false)
|
|
1381
|
-
|
|
1952
|
+
if (res.changed === false) {
|
|
1953
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
1954
|
+
return { ok: true, delta: res.delta };
|
|
1955
|
+
}
|
|
1956
|
+
const after = await Bun.file(abs).text();
|
|
1957
|
+
const seq = after !== before ? logUndo(abs, before, after) : undefined;
|
|
1958
|
+
return { ok: true, delta: res.delta, seq };
|
|
1382
1959
|
} catch (err) {
|
|
1383
1960
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
1384
1961
|
return {
|
|
@@ -1395,11 +1972,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1395
1972
|
property?: unknown;
|
|
1396
1973
|
value?: unknown;
|
|
1397
1974
|
reset?: unknown;
|
|
1975
|
+
idIndex?: unknown;
|
|
1398
1976
|
}): Promise<EditOpResult> {
|
|
1399
1977
|
const r = resolveCanvasAbs(input.canvas);
|
|
1400
1978
|
if (!r.ok) return r;
|
|
1401
1979
|
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
1402
1980
|
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
1981
|
+
// Stage H3 — optional DOM-occurrence index. Present only for a whole-instance
|
|
1982
|
+
// move/resize (reposition/resize-request); routes the write to the dragged
|
|
1983
|
+
// component instance's own `<Component/>` usage so it stays LOCAL. Absent for
|
|
1984
|
+
// knob / paste-style edits (those stay global on the shared inner element).
|
|
1985
|
+
const idIndex =
|
|
1986
|
+
typeof input.idIndex === 'number' && Number.isInteger(input.idIndex) && input.idIndex >= 0
|
|
1987
|
+
? input.idIndex
|
|
1988
|
+
: undefined;
|
|
1403
1989
|
const property = typeof input.property === 'string' ? input.property.trim() : '';
|
|
1404
1990
|
// CSS property names are ASCII letters + hyphens only (optionally a leading
|
|
1405
1991
|
// `-` for vendor prefixes) — reject anything that could smuggle a second key
|
|
@@ -1414,7 +2000,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1414
2000
|
if (input.reset === true) {
|
|
1415
2001
|
return suppressedEdit(
|
|
1416
2002
|
r.abs,
|
|
1417
|
-
() => removeAttribute(r.abs, id, `style.${camel}
|
|
2003
|
+
() => removeAttribute(r.abs, id, `style.${camel}`, idIndex),
|
|
1418
2004
|
'reset failed'
|
|
1419
2005
|
);
|
|
1420
2006
|
}
|
|
@@ -1427,7 +2013,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1427
2013
|
// `#fff`, `8px`, `700`, `1.5` all ride verbatim.
|
|
1428
2014
|
return suppressedEdit(
|
|
1429
2015
|
r.abs,
|
|
1430
|
-
() => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value)),
|
|
2016
|
+
() => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value), idIndex),
|
|
1431
2017
|
'edit failed'
|
|
1432
2018
|
);
|
|
1433
2019
|
}
|
|
@@ -1478,6 +2064,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1478
2064
|
const value = typeof input.value === 'string' ? input.value : '';
|
|
1479
2065
|
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
1480
2066
|
if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
|
|
2067
|
+
// DDR-150 P3 — a media/image `src` edit (the clip-replace path) must stay a
|
|
2068
|
+
// contained asset reference: no path traversal, no script/file/data schemes.
|
|
2069
|
+
if (
|
|
2070
|
+
attr === 'src' &&
|
|
2071
|
+
(/\.\./.test(value) || /^\s*(javascript|vbscript|file|data):/i.test(value))
|
|
2072
|
+
) {
|
|
2073
|
+
return {
|
|
2074
|
+
ok: false,
|
|
2075
|
+
status: 400,
|
|
2076
|
+
error: 'src must be a contained asset path (no ../ or javascript:/file:/data: schemes)',
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
1481
2079
|
// Non-`style.` attr name → editAttribute writes a plain quoted JSX attribute.
|
|
1482
2080
|
// Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
|
|
1483
2081
|
// on replace, escapeAttr on insert) — pre-stringifying here double-encoded
|
|
@@ -1566,6 +2164,640 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1566
2164
|
}
|
|
1567
2165
|
}
|
|
1568
2166
|
|
|
2167
|
+
/**
|
|
2168
|
+
* DDR-148 — Timeline drag-to-retime. Rewrites the `durationInFrames` / `from`
|
|
2169
|
+
* of the `index`-th sequence (document order). MAIN-ORIGIN ONLY at the route
|
|
2170
|
+
* layer. Snapshots pre-retime for /design:rollback; suppresses the agent-rim.
|
|
2171
|
+
*/
|
|
2172
|
+
async function retimeSequenceOp(input: {
|
|
2173
|
+
canvas?: unknown;
|
|
2174
|
+
stableId?: unknown;
|
|
2175
|
+
artboardId?: unknown;
|
|
2176
|
+
contentHash?: unknown;
|
|
2177
|
+
index?: unknown;
|
|
2178
|
+
durationInFrames?: unknown;
|
|
2179
|
+
from?: unknown;
|
|
2180
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
2181
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2182
|
+
if (!r.ok) return r;
|
|
2183
|
+
// DDR-150 P2 — prefer the comp-scoped stableId (multi-comp-safe) over the
|
|
2184
|
+
// legacy whole-file index. The index path stays for back-compat.
|
|
2185
|
+
const stableId = typeof input.stableId === 'string' ? input.stableId : null;
|
|
2186
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2187
|
+
const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
|
|
2188
|
+
const index = Number.isInteger(input.index) ? (input.index as number) : -1;
|
|
2189
|
+
if (!stableId && (index < 0 || index > 500))
|
|
2190
|
+
return { ok: false, status: 400, error: 'invalid sequence index' };
|
|
2191
|
+
const frames = (v: unknown, lo: number): number | undefined => {
|
|
2192
|
+
const n = Math.round(Number(v));
|
|
2193
|
+
return Number.isFinite(n) ? Math.max(lo, Math.min(100000, n)) : undefined;
|
|
2194
|
+
};
|
|
2195
|
+
const patch: { durationInFrames?: number; from?: number } = {};
|
|
2196
|
+
if (input.durationInFrames != null) patch.durationInFrames = frames(input.durationInFrames, 1);
|
|
2197
|
+
if (input.from != null) patch.from = frames(input.from, 0);
|
|
2198
|
+
if (patch.durationInFrames == null && patch.from == null) {
|
|
2199
|
+
return { ok: false, status: 400, error: 'nothing to retime' };
|
|
2200
|
+
}
|
|
2201
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2202
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2203
|
+
try {
|
|
2204
|
+
const before = await Bun.file(r.abs).text();
|
|
2205
|
+
if (stableId) await retimeSequenceByClip(r.abs, artboardId, stableId, contentHash, patch);
|
|
2206
|
+
else await retimeSequence(r.abs, index, patch);
|
|
2207
|
+
const after = await Bun.file(r.abs).text();
|
|
2208
|
+
if (after === before) {
|
|
2209
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2210
|
+
return { ok: true };
|
|
2211
|
+
}
|
|
2212
|
+
try {
|
|
2213
|
+
await history.writeSnapshot(rel, before, 'pre-retime');
|
|
2214
|
+
} catch {
|
|
2215
|
+
/* snapshot best-effort */
|
|
2216
|
+
}
|
|
2217
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
2218
|
+
} catch (err) {
|
|
2219
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2220
|
+
return {
|
|
2221
|
+
ok: false,
|
|
2222
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2223
|
+
error: err instanceof Error ? err.message : 'retime failed',
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
async function removeSequenceOp(input: {
|
|
2229
|
+
canvas?: unknown;
|
|
2230
|
+
stableId?: unknown;
|
|
2231
|
+
artboardId?: unknown;
|
|
2232
|
+
contentHash?: unknown;
|
|
2233
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
2234
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2235
|
+
if (!r.ok) return r;
|
|
2236
|
+
const stableId = typeof input.stableId === 'string' ? input.stableId : null;
|
|
2237
|
+
if (!stableId) return { ok: false, status: 400, error: 'stableId required' };
|
|
2238
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2239
|
+
const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
|
|
2240
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2241
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2242
|
+
try {
|
|
2243
|
+
const before = await Bun.file(r.abs).text();
|
|
2244
|
+
await removeClip(r.abs, artboardId, stableId, contentHash);
|
|
2245
|
+
const after = await Bun.file(r.abs).text();
|
|
2246
|
+
if (after === before) {
|
|
2247
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2248
|
+
return { ok: true };
|
|
2249
|
+
}
|
|
2250
|
+
try {
|
|
2251
|
+
await history.writeSnapshot(rel, before, 'pre-remove-clip');
|
|
2252
|
+
} catch {
|
|
2253
|
+
/* snapshot best-effort */
|
|
2254
|
+
}
|
|
2255
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
2256
|
+
} catch (err) {
|
|
2257
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2258
|
+
return {
|
|
2259
|
+
ok: false,
|
|
2260
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2261
|
+
error: err instanceof Error ? err.message : 'remove failed',
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
async function insertSequenceOp(input: {
|
|
2267
|
+
canvas?: unknown;
|
|
2268
|
+
artboardId?: unknown;
|
|
2269
|
+
from?: unknown;
|
|
2270
|
+
durationInFrames?: unknown;
|
|
2271
|
+
mediaTag?: unknown;
|
|
2272
|
+
src?: unknown;
|
|
2273
|
+
}): Promise<
|
|
2274
|
+
| { ok: true; stableId: string | null; seq?: number }
|
|
2275
|
+
| { ok: false; status: number; error: string }
|
|
2276
|
+
> {
|
|
2277
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2278
|
+
if (!r.ok) return r;
|
|
2279
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2280
|
+
const from = Number.isFinite(Number(input.from))
|
|
2281
|
+
? Math.max(0, Math.round(Number(input.from)))
|
|
2282
|
+
: 0;
|
|
2283
|
+
const durationInFrames = Number.isFinite(Number(input.durationInFrames))
|
|
2284
|
+
? Math.max(1, Math.min(100000, Math.round(Number(input.durationInFrames))))
|
|
2285
|
+
: 90;
|
|
2286
|
+
const mediaTag = typeof input.mediaTag === 'string' ? input.mediaTag : null;
|
|
2287
|
+
const src = typeof input.src === 'string' ? input.src : null;
|
|
2288
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2289
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2290
|
+
try {
|
|
2291
|
+
const before = await Bun.file(r.abs).text();
|
|
2292
|
+
const res = await insertClip(r.abs, artboardId, { from, durationInFrames, mediaTag, src });
|
|
2293
|
+
const after = await Bun.file(r.abs).text();
|
|
2294
|
+
let seq: number | undefined;
|
|
2295
|
+
if (after === before) {
|
|
2296
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2297
|
+
} else {
|
|
2298
|
+
try {
|
|
2299
|
+
await history.writeSnapshot(rel, before, 'pre-insert-clip');
|
|
2300
|
+
} catch {
|
|
2301
|
+
/* snapshot best-effort */
|
|
2302
|
+
}
|
|
2303
|
+
seq = logUndo(r.abs, before, after);
|
|
2304
|
+
}
|
|
2305
|
+
return { ok: true, stableId: res.stableId, seq };
|
|
2306
|
+
} catch (err) {
|
|
2307
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2308
|
+
return {
|
|
2309
|
+
ok: false,
|
|
2310
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2311
|
+
error: err instanceof Error ? err.message : 'insert failed',
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// Stage I (feature-element-editing-robustness) — general element structural
|
|
2317
|
+
// edits (delete / insert / new-artboard) + free-hand artboard resize (D4). All
|
|
2318
|
+
// MAIN-ORIGIN ONLY at the route layer; each logs a whole-file {before, after}
|
|
2319
|
+
// under a seq (logUndo) so Cmd+Z reverts through /_api/reorder-revert (a
|
|
2320
|
+
// structural edit renumbers positional data-cd-ids → an inverse descriptor
|
|
2321
|
+
// goes stale). Same agent-rim suppression + pre-op snapshot as reorder.
|
|
2322
|
+
|
|
2323
|
+
/** Delete the element with `data-cd-id === id` (reused-component instance via idIndex). */
|
|
2324
|
+
async function deleteElementOp(input: {
|
|
2325
|
+
canvas?: unknown;
|
|
2326
|
+
id?: unknown;
|
|
2327
|
+
idIndex?: unknown;
|
|
2328
|
+
}): Promise<
|
|
2329
|
+
{ ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
|
|
2330
|
+
> {
|
|
2331
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2332
|
+
if (!r.ok) return r;
|
|
2333
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
2334
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
2335
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
2336
|
+
const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
|
|
2337
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2338
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2339
|
+
try {
|
|
2340
|
+
const before = await Bun.file(r.abs).text();
|
|
2341
|
+
const res = await deleteElement(r.abs, id, idIndex);
|
|
2342
|
+
const after = await Bun.file(r.abs).text();
|
|
2343
|
+
if (after === before) {
|
|
2344
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2345
|
+
return { ok: true, deletedId: res.deletedId };
|
|
2346
|
+
}
|
|
2347
|
+
try {
|
|
2348
|
+
await history.writeSnapshot(rel, before, 'pre-delete-element');
|
|
2349
|
+
} catch {
|
|
2350
|
+
/* snapshot best-effort */
|
|
2351
|
+
}
|
|
2352
|
+
return { ok: true, deletedId: res.deletedId, seq: logUndo(r.abs, before, after) };
|
|
2353
|
+
} catch (err) {
|
|
2354
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2355
|
+
return {
|
|
2356
|
+
ok: false,
|
|
2357
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2358
|
+
error: err instanceof Error ? err.message : 'delete failed',
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/** Insert a synthesized element (div/text/image) relative to `refId`. */
|
|
2364
|
+
async function insertElementOp(input: {
|
|
2365
|
+
canvas?: unknown;
|
|
2366
|
+
refId?: unknown;
|
|
2367
|
+
position?: unknown;
|
|
2368
|
+
kind?: unknown;
|
|
2369
|
+
src?: unknown;
|
|
2370
|
+
refIndex?: unknown;
|
|
2371
|
+
}): Promise<
|
|
2372
|
+
{ ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
|
|
2373
|
+
> {
|
|
2374
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2375
|
+
if (!r.ok) return r;
|
|
2376
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
2377
|
+
const refId = typeof input.refId === 'string' ? input.refId.trim() : '';
|
|
2378
|
+
if (!CD_ID_RE.test(refId)) {
|
|
2379
|
+
return { ok: false, status: 400, error: 'invalid reference data-cd-id' };
|
|
2380
|
+
}
|
|
2381
|
+
const position = input.position;
|
|
2382
|
+
if (typeof position !== 'string' || !MOVE_POSITIONS.has(position)) {
|
|
2383
|
+
return { ok: false, status: 400, error: 'invalid position' };
|
|
2384
|
+
}
|
|
2385
|
+
const kind = input.kind;
|
|
2386
|
+
if (kind !== 'div' && kind !== 'text' && kind !== 'image') {
|
|
2387
|
+
return { ok: false, status: 400, error: 'invalid kind (div|text|image)' };
|
|
2388
|
+
}
|
|
2389
|
+
const src = typeof input.src === 'string' ? input.src : undefined;
|
|
2390
|
+
const refIndex = Number.isInteger(input.refIndex) ? (input.refIndex as number) : undefined;
|
|
2391
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2392
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2393
|
+
try {
|
|
2394
|
+
const before = await Bun.file(r.abs).text();
|
|
2395
|
+
if (before.length > MAX_CANVAS_SOURCE) {
|
|
2396
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2397
|
+
return { ok: false, status: 413, error: 'canvas source too large to grow' };
|
|
2398
|
+
}
|
|
2399
|
+
const res = await insertElement(r.abs, refId, position as MovePosition, kind as InsertKind, {
|
|
2400
|
+
src,
|
|
2401
|
+
occurrence: refIndex,
|
|
2402
|
+
});
|
|
2403
|
+
const after = await Bun.file(r.abs).text();
|
|
2404
|
+
if (after === before) {
|
|
2405
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2406
|
+
return { ok: true, newId: res.newId };
|
|
2407
|
+
}
|
|
2408
|
+
try {
|
|
2409
|
+
await history.writeSnapshot(rel, before, 'pre-insert-element');
|
|
2410
|
+
} catch {
|
|
2411
|
+
/* snapshot best-effort */
|
|
2412
|
+
}
|
|
2413
|
+
return { ok: true, newId: res.newId, seq: logUndo(r.abs, before, after) };
|
|
2414
|
+
} catch (err) {
|
|
2415
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2416
|
+
return {
|
|
2417
|
+
ok: false,
|
|
2418
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2419
|
+
error: err instanceof Error ? err.message : 'insert failed',
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/** Insert a new empty artboard (id/label/width/height) after the last one. */
|
|
2425
|
+
async function insertArtboardOp(input: {
|
|
2426
|
+
canvas?: unknown;
|
|
2427
|
+
id?: unknown;
|
|
2428
|
+
label?: unknown;
|
|
2429
|
+
width?: unknown;
|
|
2430
|
+
height?: unknown;
|
|
2431
|
+
}): Promise<
|
|
2432
|
+
{ ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
|
|
2433
|
+
> {
|
|
2434
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2435
|
+
if (!r.ok) return r;
|
|
2436
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
2437
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
2438
|
+
if (!/^[A-Za-z][\w-]{0,63}$/.test(id)) {
|
|
2439
|
+
return { ok: false, status: 400, error: 'invalid artboard id' };
|
|
2440
|
+
}
|
|
2441
|
+
const label = typeof input.label === 'string' ? input.label.slice(0, 120) : id;
|
|
2442
|
+
const width = Number.isFinite(Number(input.width))
|
|
2443
|
+
? Math.max(64, Math.min(8192, Math.round(Number(input.width))))
|
|
2444
|
+
: 0;
|
|
2445
|
+
const height = Number.isFinite(Number(input.height))
|
|
2446
|
+
? Math.max(64, Math.min(8192, Math.round(Number(input.height))))
|
|
2447
|
+
: 0;
|
|
2448
|
+
if (!width || !height) return { ok: false, status: 400, error: 'width and height required' };
|
|
2449
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2450
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2451
|
+
try {
|
|
2452
|
+
const before = await Bun.file(r.abs).text();
|
|
2453
|
+
if (before.length > MAX_CANVAS_SOURCE) {
|
|
2454
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2455
|
+
return { ok: false, status: 413, error: 'canvas source too large to grow' };
|
|
2456
|
+
}
|
|
2457
|
+
const res = await insertArtboard(r.abs, { id, label, width, height });
|
|
2458
|
+
const after = await Bun.file(r.abs).text();
|
|
2459
|
+
if (after === before) {
|
|
2460
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2461
|
+
return { ok: true, artboardId: res.artboardId };
|
|
2462
|
+
}
|
|
2463
|
+
try {
|
|
2464
|
+
await history.writeSnapshot(rel, before, 'pre-insert-artboard');
|
|
2465
|
+
} catch {
|
|
2466
|
+
/* snapshot best-effort */
|
|
2467
|
+
}
|
|
2468
|
+
return { ok: true, artboardId: res.artboardId, seq: logUndo(r.abs, before, after) };
|
|
2469
|
+
} catch (err) {
|
|
2470
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2471
|
+
return {
|
|
2472
|
+
ok: false,
|
|
2473
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2474
|
+
error: err instanceof Error ? err.message : 'insert-artboard failed',
|
|
2475
|
+
};
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
/** Free-hand artboard resize (D4) — write width/height NUMERIC props (DDR-027). */
|
|
2480
|
+
async function resizeArtboardOp(input: {
|
|
2481
|
+
canvas?: unknown;
|
|
2482
|
+
artboardId?: unknown;
|
|
2483
|
+
width?: unknown;
|
|
2484
|
+
height?: unknown;
|
|
2485
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
2486
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2487
|
+
if (!r.ok) return r;
|
|
2488
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
2489
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
|
|
2490
|
+
if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
|
|
2491
|
+
return { ok: false, status: 400, error: 'invalid artboard id' };
|
|
2492
|
+
}
|
|
2493
|
+
const dim = (v: unknown): number | undefined =>
|
|
2494
|
+
Number.isFinite(Number(v)) ? Math.max(64, Math.min(8192, Math.round(Number(v)))) : undefined;
|
|
2495
|
+
const width = dim(input.width);
|
|
2496
|
+
const height = dim(input.height);
|
|
2497
|
+
if (width == null && height == null) {
|
|
2498
|
+
return { ok: false, status: 400, error: 'width or height required' };
|
|
2499
|
+
}
|
|
2500
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2501
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2502
|
+
try {
|
|
2503
|
+
const before = await Bun.file(r.abs).text();
|
|
2504
|
+
await resizeArtboard(r.abs, artboardId, width, height);
|
|
2505
|
+
const after = await Bun.file(r.abs).text();
|
|
2506
|
+
if (after === before) {
|
|
2507
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2508
|
+
return { ok: true };
|
|
2509
|
+
}
|
|
2510
|
+
try {
|
|
2511
|
+
await history.writeSnapshot(rel, before, 'pre-resize-artboard');
|
|
2512
|
+
} catch {
|
|
2513
|
+
/* snapshot best-effort */
|
|
2514
|
+
}
|
|
2515
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
2516
|
+
} catch (err) {
|
|
2517
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2518
|
+
return {
|
|
2519
|
+
ok: false,
|
|
2520
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2521
|
+
error: err instanceof Error ? err.message : 'resize-artboard failed',
|
|
2522
|
+
};
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/** Duplicate an element (Cmd+D) — insert a copy as the next sibling. */
|
|
2527
|
+
async function duplicateElementOp(input: {
|
|
2528
|
+
canvas?: unknown;
|
|
2529
|
+
id?: unknown;
|
|
2530
|
+
idIndex?: unknown;
|
|
2531
|
+
}): Promise<
|
|
2532
|
+
{ ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
|
|
2533
|
+
> {
|
|
2534
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2535
|
+
if (!r.ok) return r;
|
|
2536
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
2537
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
2538
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
2539
|
+
const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
|
|
2540
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2541
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2542
|
+
try {
|
|
2543
|
+
const before = await Bun.file(r.abs).text();
|
|
2544
|
+
if (before.length > MAX_CANVAS_SOURCE) {
|
|
2545
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2546
|
+
return { ok: false, status: 413, error: 'canvas source too large to grow' };
|
|
2547
|
+
}
|
|
2548
|
+
const res = await duplicateElement(r.abs, id, idIndex);
|
|
2549
|
+
const after = await Bun.file(r.abs).text();
|
|
2550
|
+
if (after === before) {
|
|
2551
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2552
|
+
return { ok: true, newId: res.newId };
|
|
2553
|
+
}
|
|
2554
|
+
try {
|
|
2555
|
+
await history.writeSnapshot(rel, before, 'pre-duplicate-element');
|
|
2556
|
+
} catch {
|
|
2557
|
+
/* snapshot best-effort */
|
|
2558
|
+
}
|
|
2559
|
+
return { ok: true, newId: res.newId, seq: logUndo(r.abs, before, after) };
|
|
2560
|
+
} catch (err) {
|
|
2561
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2562
|
+
return {
|
|
2563
|
+
ok: false,
|
|
2564
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2565
|
+
error: err instanceof Error ? err.message : 'duplicate failed',
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
/** Delete an artboard by its `id` prop (Backspace / context-menu on a frame). */
|
|
2571
|
+
async function deleteArtboardOp(input: {
|
|
2572
|
+
canvas?: unknown;
|
|
2573
|
+
artboardId?: unknown;
|
|
2574
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
2575
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2576
|
+
if (!r.ok) return r;
|
|
2577
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
2578
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
|
|
2579
|
+
if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
|
|
2580
|
+
return { ok: false, status: 400, error: 'invalid artboard id' };
|
|
2581
|
+
}
|
|
2582
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2583
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2584
|
+
try {
|
|
2585
|
+
const before = await Bun.file(r.abs).text();
|
|
2586
|
+
await deleteArtboard(r.abs, artboardId);
|
|
2587
|
+
const after = await Bun.file(r.abs).text();
|
|
2588
|
+
if (after === before) {
|
|
2589
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2590
|
+
return { ok: true };
|
|
2591
|
+
}
|
|
2592
|
+
try {
|
|
2593
|
+
await history.writeSnapshot(rel, before, 'pre-delete-artboard');
|
|
2594
|
+
} catch {
|
|
2595
|
+
/* snapshot best-effort */
|
|
2596
|
+
}
|
|
2597
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
2598
|
+
} catch (err) {
|
|
2599
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2600
|
+
return {
|
|
2601
|
+
ok: false,
|
|
2602
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2603
|
+
error: err instanceof Error ? err.message : 'delete-artboard failed',
|
|
2604
|
+
};
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/**
|
|
2609
|
+
* Edit-scope verdict for the INV-3 predictability badge (Stage H). READ-only —
|
|
2610
|
+
* parses the canvas + returns whether an edit to `id` is local or shared. No
|
|
2611
|
+
* write, no undo, no rate-cap (a mere parse the shell runs on selection). The
|
|
2612
|
+
* client supplies `rendered` = the DOM occurrence count of the cd-id so the
|
|
2613
|
+
* `.map()` case is honest.
|
|
2614
|
+
*/
|
|
2615
|
+
async function editScopeOp(input: {
|
|
2616
|
+
canvas?: unknown;
|
|
2617
|
+
id?: unknown;
|
|
2618
|
+
rendered?: unknown;
|
|
2619
|
+
}): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }> {
|
|
2620
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2621
|
+
if (!r.ok) return r;
|
|
2622
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
2623
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
2624
|
+
const rendered = Number.isFinite(Number(input.rendered))
|
|
2625
|
+
? Math.max(1, Math.round(Number(input.rendered)))
|
|
2626
|
+
: 1;
|
|
2627
|
+
try {
|
|
2628
|
+
const source = await Bun.file(r.abs).text();
|
|
2629
|
+
const scope = resolveEditScope(r.abs, source, id, rendered);
|
|
2630
|
+
return { ok: true, ...scope };
|
|
2631
|
+
} catch (err) {
|
|
2632
|
+
return { ok: false, status: 500, error: err instanceof Error ? err.message : 'scope failed' };
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
async function toggleHideOp(input: {
|
|
2637
|
+
canvas?: unknown;
|
|
2638
|
+
stableId?: unknown;
|
|
2639
|
+
artboardId?: unknown;
|
|
2640
|
+
contentHash?: unknown;
|
|
2641
|
+
}): Promise<
|
|
2642
|
+
{ ok: true; hidden: boolean; seq?: number } | { ok: false; status: number; error: string }
|
|
2643
|
+
> {
|
|
2644
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2645
|
+
if (!r.ok) return r;
|
|
2646
|
+
const stableId = typeof input.stableId === 'string' ? input.stableId : null;
|
|
2647
|
+
if (!stableId) return { ok: false, status: 400, error: 'stableId required' };
|
|
2648
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2649
|
+
const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
|
|
2650
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2651
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2652
|
+
try {
|
|
2653
|
+
const before = await Bun.file(r.abs).text();
|
|
2654
|
+
const res = await toggleClipHidden(r.abs, artboardId, stableId, contentHash);
|
|
2655
|
+
const after = await Bun.file(r.abs).text();
|
|
2656
|
+
if (after === before) {
|
|
2657
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2658
|
+
return { ok: true, hidden: res.hidden };
|
|
2659
|
+
}
|
|
2660
|
+
return { ok: true, hidden: res.hidden, seq: logUndo(r.abs, before, after) };
|
|
2661
|
+
} catch (err) {
|
|
2662
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2663
|
+
return {
|
|
2664
|
+
ok: false,
|
|
2665
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2666
|
+
error: err instanceof Error ? err.message : 'hide failed',
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
async function editArraySrcOp(input: {
|
|
2672
|
+
canvas?: unknown;
|
|
2673
|
+
arrayName?: unknown;
|
|
2674
|
+
index?: unknown;
|
|
2675
|
+
field?: unknown;
|
|
2676
|
+
value?: unknown;
|
|
2677
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
2678
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2679
|
+
if (!r.ok) return r;
|
|
2680
|
+
const arrayName = typeof input.arrayName === 'string' ? input.arrayName : '';
|
|
2681
|
+
const field = typeof input.field === 'string' ? input.field : 'src';
|
|
2682
|
+
const index = Number.isInteger(input.index) ? (input.index as number) : -1;
|
|
2683
|
+
const value = typeof input.value === 'string' ? input.value : '';
|
|
2684
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(arrayName)) {
|
|
2685
|
+
return { ok: false, status: 400, error: 'invalid array name' };
|
|
2686
|
+
}
|
|
2687
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) {
|
|
2688
|
+
return { ok: false, status: 400, error: 'invalid field' };
|
|
2689
|
+
}
|
|
2690
|
+
if (index < 0 || index > 500) return { ok: false, status: 400, error: 'invalid index' };
|
|
2691
|
+
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
2692
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2693
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2694
|
+
try {
|
|
2695
|
+
const before = await Bun.file(r.abs).text();
|
|
2696
|
+
await editArrayElementString(r.abs, arrayName, index, field, value);
|
|
2697
|
+
const after = await Bun.file(r.abs).text();
|
|
2698
|
+
if (after === before) {
|
|
2699
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2700
|
+
return { ok: true };
|
|
2701
|
+
}
|
|
2702
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
2703
|
+
} catch (err) {
|
|
2704
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2705
|
+
return {
|
|
2706
|
+
ok: false,
|
|
2707
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2708
|
+
error: err instanceof Error ? err.message : 'edit failed',
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
async function reorderSequenceOp(input: {
|
|
2714
|
+
canvas?: unknown;
|
|
2715
|
+
artboardId?: unknown;
|
|
2716
|
+
stableId?: unknown;
|
|
2717
|
+
contentHash?: unknown;
|
|
2718
|
+
refStableId?: unknown;
|
|
2719
|
+
refContentHash?: unknown;
|
|
2720
|
+
position?: unknown;
|
|
2721
|
+
}): Promise<
|
|
2722
|
+
| { ok: true; stableId: string | null; seq?: number }
|
|
2723
|
+
| { ok: false; status: number; error: string }
|
|
2724
|
+
> {
|
|
2725
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2726
|
+
if (!r.ok) return r;
|
|
2727
|
+
const stableId = typeof input.stableId === 'string' ? input.stableId : null;
|
|
2728
|
+
const refStableId = typeof input.refStableId === 'string' ? input.refStableId : null;
|
|
2729
|
+
if (!stableId || !refStableId) {
|
|
2730
|
+
return { ok: false, status: 400, error: 'stableId and refStableId required' };
|
|
2731
|
+
}
|
|
2732
|
+
const position: MovePosition = input.position === 'before' ? 'before' : 'after';
|
|
2733
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2734
|
+
const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
|
|
2735
|
+
const refContentHash =
|
|
2736
|
+
typeof input.refContentHash === 'string' ? input.refContentHash : undefined;
|
|
2737
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
2738
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
2739
|
+
try {
|
|
2740
|
+
const before = await Bun.file(r.abs).text();
|
|
2741
|
+
const res = await reorderClip(
|
|
2742
|
+
r.abs,
|
|
2743
|
+
artboardId,
|
|
2744
|
+
stableId,
|
|
2745
|
+
contentHash,
|
|
2746
|
+
refStableId,
|
|
2747
|
+
refContentHash,
|
|
2748
|
+
position
|
|
2749
|
+
);
|
|
2750
|
+
const after = await Bun.file(r.abs).text();
|
|
2751
|
+
let seq: number | undefined;
|
|
2752
|
+
if (after === before) {
|
|
2753
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2754
|
+
} else {
|
|
2755
|
+
try {
|
|
2756
|
+
await history.writeSnapshot(rel, before, 'pre-reorder-clip');
|
|
2757
|
+
} catch {
|
|
2758
|
+
/* snapshot best-effort */
|
|
2759
|
+
}
|
|
2760
|
+
seq = logUndo(r.abs, before, after);
|
|
2761
|
+
}
|
|
2762
|
+
return { ok: true, stableId: res.stableId, seq };
|
|
2763
|
+
} catch (err) {
|
|
2764
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
2765
|
+
return {
|
|
2766
|
+
ok: false,
|
|
2767
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2768
|
+
error: err instanceof Error ? err.message : 'reorder failed',
|
|
2769
|
+
};
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
async function compClips(input: { canvas?: unknown; artboardId?: unknown }) {
|
|
2774
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
2775
|
+
if (!r.ok) return r;
|
|
2776
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
2777
|
+
try {
|
|
2778
|
+
const source = await Bun.file(r.abs).text();
|
|
2779
|
+
const result = enumerateClips(r.abs, source, artboardId);
|
|
2780
|
+
// Strip the internal source offsets — the client addresses by stableId.
|
|
2781
|
+
const clips = result.clips.map(({ start: _s, end: _e, ...c }) => c);
|
|
2782
|
+
return {
|
|
2783
|
+
ok: true as const,
|
|
2784
|
+
compName: result.compName,
|
|
2785
|
+
artboardId: result.artboardId,
|
|
2786
|
+
fps: result.fps,
|
|
2787
|
+
durationInFrames: result.durationInFrames,
|
|
2788
|
+
clips,
|
|
2789
|
+
// DDR-150 dogfood #5 — loose media beds (audio/bg video) for replace.
|
|
2790
|
+
media: result.media,
|
|
2791
|
+
};
|
|
2792
|
+
} catch (err) {
|
|
2793
|
+
return {
|
|
2794
|
+
ok: false as const,
|
|
2795
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
2796
|
+
error: err instanceof Error ? err.message : 'enumerate failed',
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
|
|
1569
2801
|
async function reorderRevert(input: {
|
|
1570
2802
|
canvas?: unknown;
|
|
1571
2803
|
seq?: unknown;
|
|
@@ -2009,10 +3241,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2009
3241
|
loadCanvasState,
|
|
2010
3242
|
saveCanvasState,
|
|
2011
3243
|
loadCanvasMeta,
|
|
3244
|
+
loadCanvasSource,
|
|
2012
3245
|
patchCanvasMeta,
|
|
2013
3246
|
loadAnnotations,
|
|
2014
3247
|
saveAnnotations,
|
|
2015
3248
|
saveAsset,
|
|
3249
|
+
listAssets,
|
|
3250
|
+
saveAssetFromStream,
|
|
2016
3251
|
saveChatAttachment,
|
|
2017
3252
|
resolveChatAttachment,
|
|
2018
3253
|
createCanvas,
|
|
@@ -2020,7 +3255,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2020
3255
|
editCss,
|
|
2021
3256
|
editText,
|
|
2022
3257
|
editAttr,
|
|
3258
|
+
removeSequenceOp,
|
|
3259
|
+
insertSequenceOp,
|
|
3260
|
+
reorderSequenceOp,
|
|
3261
|
+
editArraySrcOp,
|
|
3262
|
+
toggleHideOp,
|
|
3263
|
+
compClips,
|
|
2023
3264
|
reorder,
|
|
3265
|
+
retimeSequenceOp,
|
|
3266
|
+
deleteElementOp,
|
|
3267
|
+
insertElementOp,
|
|
3268
|
+
insertArtboardOp,
|
|
3269
|
+
resizeArtboardOp,
|
|
3270
|
+
deleteArtboardOp,
|
|
3271
|
+
duplicateElementOp,
|
|
3272
|
+
editScopeOp,
|
|
2024
3273
|
reorderRevert,
|
|
2025
3274
|
buildIndexData,
|
|
2026
3275
|
buildSystemData,
|