@1agh/maude 0.42.0 → 0.43.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.
Files changed (236) hide show
  1. package/apps/studio/.ai/cache/_stats.json +7 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +12 -2
  3. package/apps/studio/annotations-layer.tsx +1055 -278
  4. package/apps/studio/annotations-model.ts +58 -0
  5. package/apps/studio/api.ts +173 -22
  6. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  7. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  8. package/apps/studio/bin/_video-playwright.mjs +141 -71
  9. package/apps/studio/bin/ingest-footage.sh +27 -0
  10. package/apps/studio/bin/photo-adjust.sh +163 -0
  11. package/apps/studio/bin/photo-bg-remove.sh +160 -0
  12. package/apps/studio/bin/probe-footage.sh +28 -0
  13. package/apps/studio/bin/read-annotations.mjs +125 -2
  14. package/apps/studio/canvas-edit.ts +459 -10
  15. package/apps/studio/canvas-lib.tsx +523 -5
  16. package/apps/studio/canvas-pipeline.ts +65 -7
  17. package/apps/studio/canvas-shell.tsx +156 -16
  18. package/apps/studio/client/app.jsx +451 -34
  19. package/apps/studio/client/export-center.jsx +35 -1
  20. package/apps/studio/client/github.js +4 -0
  21. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  23. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  24. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  25. package/apps/studio/client/photo-knobs.jsx +437 -0
  26. package/apps/studio/client/styles/3-shell-maude.css +42 -0
  27. package/apps/studio/commands/edit-source-command.ts +31 -4
  28. package/apps/studio/context-menu.tsx +31 -6
  29. package/apps/studio/dist/client.bundle.js +5561 -19
  30. package/apps/studio/dist/comment-mount.js +2 -2
  31. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  32. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  33. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  34. package/apps/studio/dist/styles.css +1 -1
  35. package/apps/studio/dom-selection.ts +25 -0
  36. package/apps/studio/exporters/jobs.ts +47 -5
  37. package/apps/studio/exporters/video.ts +24 -4
  38. package/apps/studio/footage/schema.test.ts +195 -0
  39. package/apps/studio/footage/schema.ts +484 -0
  40. package/apps/studio/footage-store.ts +235 -0
  41. package/apps/studio/fs-watch.ts +10 -0
  42. package/apps/studio/git/endpoints.ts +68 -7
  43. package/apps/studio/git/service.ts +196 -20
  44. package/apps/studio/github/service.ts +67 -0
  45. package/apps/studio/handoff.ts +3 -2
  46. package/apps/studio/http.ts +173 -14
  47. package/apps/studio/input-router.tsx +10 -0
  48. package/apps/studio/inspect.ts +53 -1
  49. package/apps/studio/paths.ts +10 -0
  50. package/apps/studio/photo/filters.ts +285 -0
  51. package/apps/studio/photo/pipeline.ts +607 -0
  52. package/apps/studio/photo/schema.ts +367 -0
  53. package/apps/studio/photo-store.ts +154 -0
  54. package/apps/studio/runtime-bundle.ts +29 -6
  55. package/apps/studio/server.ts +5 -1
  56. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  57. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  58. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  59. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  60. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  61. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  62. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  63. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  64. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  65. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  66. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  67. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  68. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  69. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  70. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  71. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  72. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  73. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  74. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  75. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  76. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  77. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  78. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  79. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  80. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  81. package/apps/studio/stickers/life-style/best.png +0 -0
  82. package/apps/studio/stickers/life-style/come-on.png +0 -0
  83. package/apps/studio/stickers/life-style/cut.png +0 -0
  84. package/apps/studio/stickers/life-style/detox.png +0 -0
  85. package/apps/studio/stickers/life-style/just.png +0 -0
  86. package/apps/studio/stickers/life-style/manifest.json +68 -0
  87. package/apps/studio/stickers/life-style/mirror.png +0 -0
  88. package/apps/studio/stickers/life-style/nice.png +0 -0
  89. package/apps/studio/stickers/life-style/objects.png +0 -0
  90. package/apps/studio/stickers/life-style/ok.png +0 -0
  91. package/apps/studio/stickers/life-style/queen.png +0 -0
  92. package/apps/studio/stickers/life-style/star.png +0 -0
  93. package/apps/studio/stickers/life-style/sun.png +0 -0
  94. package/apps/studio/stickers/life-style/water.png +0 -0
  95. package/apps/studio/stickers/life-style/yeah.png +0 -0
  96. package/apps/studio/stickers/life-style/you-can.png +0 -0
  97. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  98. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  99. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  100. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  101. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  102. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  103. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  104. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  105. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  106. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  107. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  108. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  109. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  110. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  111. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  112. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  113. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  114. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  115. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  116. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  117. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  118. package/apps/studio/stickers/project-status/group-100.png +0 -0
  119. package/apps/studio/stickers/project-status/group-101.png +0 -0
  120. package/apps/studio/stickers/project-status/group-102.png +0 -0
  121. package/apps/studio/stickers/project-status/group-103.png +0 -0
  122. package/apps/studio/stickers/project-status/group-104.png +0 -0
  123. package/apps/studio/stickers/project-status/group-105.png +0 -0
  124. package/apps/studio/stickers/project-status/group-106.png +0 -0
  125. package/apps/studio/stickers/project-status/group-107.png +0 -0
  126. package/apps/studio/stickers/project-status/group-108.png +0 -0
  127. package/apps/studio/stickers/project-status/group-109.png +0 -0
  128. package/apps/studio/stickers/project-status/group-110.png +0 -0
  129. package/apps/studio/stickers/project-status/group-111.png +0 -0
  130. package/apps/studio/stickers/project-status/group-112.png +0 -0
  131. package/apps/studio/stickers/project-status/group-113.png +0 -0
  132. package/apps/studio/stickers/project-status/group-114.png +0 -0
  133. package/apps/studio/stickers/project-status/group-115.png +0 -0
  134. package/apps/studio/stickers/project-status/group-117.png +0 -0
  135. package/apps/studio/stickers/project-status/group-118.png +0 -0
  136. package/apps/studio/stickers/project-status/group-119.png +0 -0
  137. package/apps/studio/stickers/project-status/group-120.png +0 -0
  138. package/apps/studio/stickers/project-status/group-121.png +0 -0
  139. package/apps/studio/stickers/project-status/group-122.png +0 -0
  140. package/apps/studio/stickers/project-status/group-41.png +0 -0
  141. package/apps/studio/stickers/project-status/group-42.png +0 -0
  142. package/apps/studio/stickers/project-status/group-43.png +0 -0
  143. package/apps/studio/stickers/project-status/group-44.png +0 -0
  144. package/apps/studio/stickers/project-status/group-45.png +0 -0
  145. package/apps/studio/stickers/project-status/group-46.png +0 -0
  146. package/apps/studio/stickers/project-status/group-47.png +0 -0
  147. package/apps/studio/stickers/project-status/group-48.png +0 -0
  148. package/apps/studio/stickers/project-status/group-49.png +0 -0
  149. package/apps/studio/stickers/project-status/group-50.png +0 -0
  150. package/apps/studio/stickers/project-status/group-51.png +0 -0
  151. package/apps/studio/stickers/project-status/group-52.png +0 -0
  152. package/apps/studio/stickers/project-status/group-53.png +0 -0
  153. package/apps/studio/stickers/project-status/group-54.png +0 -0
  154. package/apps/studio/stickers/project-status/group-55.png +0 -0
  155. package/apps/studio/stickers/project-status/group-56.png +0 -0
  156. package/apps/studio/stickers/project-status/group-57.png +0 -0
  157. package/apps/studio/stickers/project-status/group-58.png +0 -0
  158. package/apps/studio/stickers/project-status/group-59.png +0 -0
  159. package/apps/studio/stickers/project-status/group-60.png +0 -0
  160. package/apps/studio/stickers/project-status/group-61.png +0 -0
  161. package/apps/studio/stickers/project-status/group-62.png +0 -0
  162. package/apps/studio/stickers/project-status/group-63.png +0 -0
  163. package/apps/studio/stickers/project-status/group-64.png +0 -0
  164. package/apps/studio/stickers/project-status/group-65.png +0 -0
  165. package/apps/studio/stickers/project-status/group-66.png +0 -0
  166. package/apps/studio/stickers/project-status/group-67.png +0 -0
  167. package/apps/studio/stickers/project-status/group-68.png +0 -0
  168. package/apps/studio/stickers/project-status/group-69.png +0 -0
  169. package/apps/studio/stickers/project-status/group-70.png +0 -0
  170. package/apps/studio/stickers/project-status/group-71.png +0 -0
  171. package/apps/studio/stickers/project-status/group-72.png +0 -0
  172. package/apps/studio/stickers/project-status/group-73.png +0 -0
  173. package/apps/studio/stickers/project-status/group-74.png +0 -0
  174. package/apps/studio/stickers/project-status/group-75.png +0 -0
  175. package/apps/studio/stickers/project-status/group-76.png +0 -0
  176. package/apps/studio/stickers/project-status/group-77.png +0 -0
  177. package/apps/studio/stickers/project-status/group-78.png +0 -0
  178. package/apps/studio/stickers/project-status/group-79.png +0 -0
  179. package/apps/studio/stickers/project-status/group-80.png +0 -0
  180. package/apps/studio/stickers/project-status/group-81.png +0 -0
  181. package/apps/studio/stickers/project-status/group-82.png +0 -0
  182. package/apps/studio/stickers/project-status/group-83.png +0 -0
  183. package/apps/studio/stickers/project-status/group-84.png +0 -0
  184. package/apps/studio/stickers/project-status/group-85.png +0 -0
  185. package/apps/studio/stickers/project-status/group-86.png +0 -0
  186. package/apps/studio/stickers/project-status/group-87.png +0 -0
  187. package/apps/studio/stickers/project-status/group-88.png +0 -0
  188. package/apps/studio/stickers/project-status/group-89.png +0 -0
  189. package/apps/studio/stickers/project-status/group-90.png +0 -0
  190. package/apps/studio/stickers/project-status/group-91.png +0 -0
  191. package/apps/studio/stickers/project-status/group-92.png +0 -0
  192. package/apps/studio/stickers/project-status/group-93.png +0 -0
  193. package/apps/studio/stickers/project-status/group-94.png +0 -0
  194. package/apps/studio/stickers/project-status/group-96.png +0 -0
  195. package/apps/studio/stickers/project-status/group-97.png +0 -0
  196. package/apps/studio/stickers/project-status/group-98.png +0 -0
  197. package/apps/studio/stickers/project-status/group-99.png +0 -0
  198. package/apps/studio/stickers/project-status/manifest.json +328 -0
  199. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  200. package/apps/studio/test/acp-commands.test.ts +2 -1
  201. package/apps/studio/test/asset-api.test.ts +3 -3
  202. package/apps/studio/test/canvas-edit.test.ts +17 -6
  203. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  204. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  205. package/apps/studio/test/edit-source-command.test.ts +23 -0
  206. package/apps/studio/test/element-structural-edit.test.ts +46 -0
  207. package/apps/studio/test/footage-store.test.ts +100 -0
  208. package/apps/studio/test/git-branches.test.ts +97 -0
  209. package/apps/studio/test/github-api.test.ts +56 -1
  210. package/apps/studio/test/input-router.test.ts +33 -0
  211. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  212. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  213. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  214. package/apps/studio/test/photo-filters.test.ts +149 -0
  215. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  216. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  217. package/apps/studio/test/read-annotations.test.ts +164 -0
  218. package/apps/studio/test/shell-importmap.test.ts +49 -0
  219. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  220. package/apps/studio/test/timeline-parse.test.ts +57 -0
  221. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  222. package/apps/studio/test/undo-stack.test.ts +33 -0
  223. package/apps/studio/test/video-asset.test.ts +5 -5
  224. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  225. package/apps/studio/text-caret.ts +239 -0
  226. package/apps/studio/tool-palette.tsx +43 -20
  227. package/apps/studio/undo-stack.ts +94 -2
  228. package/apps/studio/use-annotation-resize.tsx +4 -4
  229. package/apps/studio/use-canvas-media-drop.tsx +38 -1
  230. package/apps/studio/use-selection-set.tsx +21 -0
  231. package/apps/studio/video-comp.tsx +48 -7
  232. package/apps/studio/whats-new.json +53 -0
  233. package/cli/commands/design.mjs +34 -3
  234. package/cli/lib/gitignore-block.mjs +1 -0
  235. package/package.json +10 -10
  236. package/plugins/design/templates/_shell.html +1 -0
@@ -463,6 +463,31 @@ export function hoverTargetToSelection(target: HoverTarget, file?: string): Sele
463
463
  worldW: el instanceof HTMLElement ? Math.round(el.offsetWidth) : undefined,
464
464
  worldH: el instanceof HTMLElement ? Math.round(el.offsetHeight) : undefined,
465
465
  html: el ? (el.outerHTML ?? '').slice(0, 4000) : '',
466
+ // feature-photo-editor (Task 14) — flag a content-addressed artboard `<img>`
467
+ // so the Inspector can offer the Photo tab. Only a real `assets/<sha8>.<ext>`
468
+ // src qualifies (an external URL / SVG icon / data: URI has no sidecar).
469
+ // `data-photo-asset` (stamped by canvas-lib's PhotoPreviewBridge the first
470
+ // time it bakes an edit into this element) is checked FIRST — once an edit
471
+ // is applied, the live `src` is a `data:` URL (the baked composite), which
472
+ // no longer matches the asset regex. Without this, an already-edited photo
473
+ // would lose its Photo tab the moment you select it (the exact regression
474
+ // this fixes — the bridge's own direct-src-swap made the element's `src`
475
+ // stop being a reliable asset key).
476
+ ...(() => {
477
+ if (el?.tagName?.toLowerCase() !== 'img') return {};
478
+ // The canvas iframe is untrusted content (DDR-054) — an authored `<img
479
+ // data-photo-asset="...">` is attacker-controllable, so the tag is only
480
+ // trusted when it actually has the `assets/<sha8>.<ext>` shape (security
481
+ // review finding: an unshaped value would ride unbounded into
482
+ // `_active.json`/the WS broadcast via inspect.ts's `enrich()`).
483
+ const assetRe = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
484
+ const tagged = (el as HTMLElement).getAttribute?.('data-photo-asset');
485
+ if (tagged && assetRe.test(tagged))
486
+ return { photoKind: 'artboard-img' as const, photoAsset: tagged };
487
+ const src = (el as HTMLImageElement).getAttribute?.('src') || '';
488
+ const m = assetRe.exec(src);
489
+ return m ? { photoKind: 'artboard-img' as const, photoAsset: m[0] } : {};
490
+ })(),
466
491
  ...styleMapsFor(el),
467
492
  };
468
493
  }
@@ -93,6 +93,49 @@ const HISTORY_DEPTH = 20;
93
93
  const MAX_JOB_AGE_MS = 24 * 60 * 60 * 1000;
94
94
  const DEFAULT_JOB_TIMEOUT_MS = 5 * 60 * 1000;
95
95
 
96
+ // Video/animation exports run a per-frame capture loop, so they legitimately
97
+ // take far longer than the 5-min default that's fine for image exports. The
98
+ // fast one-pass renderMediaOnWeb path is quick, but a comp that can't use it —
99
+ // e.g. it overflows @remotion/web-renderer's recursive precompositing (RCA
100
+ // issue-video-mp4-rendermediaonweb-stack-overflow) — degrades to frame-step
101
+ // screenshots at ~1.5–2 s/frame (2× scale). A 900-frame comp then runs 20–30
102
+ // min, so the image default would abort it mid-render (the "Target page closed
103
+ // at frame ~180/900" failure). Size the budget to the WORK — a generous
104
+ // per-frame estimate × the frame count — clamped to [5 min, 60 min]. The lower
105
+ // bound is deliberately the same 5 min as image exports (not a flat 30 min): a
106
+ // tiny clip that WEDGES shouldn't hold a scarce render slot for half an hour,
107
+ // and a security review flagged that a hostile comp which reliably overflows
108
+ // the renderer could otherwise turn a fast-fail into a long slot occupation.
109
+ // The upper bound is a runaway backstop. MAUDE_EXPORT_VIDEO_TIMEOUT_MS overrides
110
+ // end-to-end for renders bigger than the ceiling (e.g. if MAX_FRAMES is raised)
111
+ // — it DELIBERATELY bypasses the 60-min cap, since removing the backstop is the
112
+ // whole point of an operator escape hatch (trusted env, not attacker-reachable).
113
+ const VIDEO_FORMATS = new Set(['mp4', 'webm', 'gif']);
114
+ const VIDEO_TIMEOUT_MIN_MS = 5 * 60 * 1000; // 5 min — same baseline as image exports
115
+ const VIDEO_TIMEOUT_CEIL_MS = 60 * 60 * 1000; // 60 min hard ceiling (runaway backstop)
116
+ const VIDEO_PER_FRAME_BUDGET_MS = 2500; // ~2.5 s/frame — covers the slow frame-step path up to 3× scale
117
+ const VIDEO_SETUP_BUDGET_MS = 60 * 1000; // boot + goto + renderMediaOnWeb attempt before fallback
118
+
119
+ /** Wall-clock render budget for a job — sized to the frame count for video, 5 min otherwise. */
120
+ function jobTimeoutMs(args: EnqueueArgs): number {
121
+ if (!VIDEO_FORMATS.has(args.format)) return DEFAULT_JOB_TIMEOUT_MS;
122
+ // Operator escape hatch — intentionally uncapped (see note above).
123
+ const envOverride = Number(process.env.MAUDE_EXPORT_VIDEO_TIMEOUT_MS);
124
+ if (Number.isFinite(envOverride) && envOverride > 0) return envOverride;
125
+ const o = args.options ?? {};
126
+ const fps = Number(o.fps) || 30;
127
+ const explicitFrames = Number(o.frames);
128
+ const durationMs = Number(o.durationMs);
129
+ const frames =
130
+ Number.isFinite(explicitFrames) && explicitFrames > 0
131
+ ? explicitFrames
132
+ : Number.isFinite(durationMs) && durationMs > 0
133
+ ? Math.round((durationMs / 1000) * fps)
134
+ : 900; // no duration hint → assume the frame cap (worst case)
135
+ const est = VIDEO_SETUP_BUDGET_MS + frames * VIDEO_PER_FRAME_BUDGET_MS;
136
+ return Math.min(VIDEO_TIMEOUT_CEIL_MS, Math.max(VIDEO_TIMEOUT_MIN_MS, est));
137
+ }
138
+
96
139
  /**
97
140
  * Security fan-out finding (defender, /flow:done) — MEDIUM: `enqueue()` had
98
141
  * no cap on queued/running jobs, so a flood of `POST /_api/export-jobs`
@@ -258,11 +301,10 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
258
301
  const release = await semaphore.acquire();
259
302
  // Started here, not at enqueue — the timeout bounds RENDER time (the
260
303
  // intent), not queue-wait time. A job can legitimately sit `queued`
261
- // behind MAUDE_EXPORT_MAX_CONCURRENT other jobs for longer than
262
- // DEFAULT_JOB_TIMEOUT_MS; arming the timer before the semaphore
263
- // resolves would abort it before it ever ran (code-review finding,
264
- // /flow:done).
265
- const timer = setTimeout(() => controller.abort(), DEFAULT_JOB_TIMEOUT_MS);
304
+ // behind MAUDE_EXPORT_MAX_CONCURRENT other jobs for longer than its
305
+ // render budget; arming the timer before the semaphore resolves would
306
+ // abort it before it ever ran (code-review finding, /flow:done).
307
+ const timer = setTimeout(() => controller.abort(), jobTimeoutMs(args));
266
308
  try {
267
309
  job.status = 'running';
268
310
  job.startedAt = new Date().toISOString();
@@ -124,17 +124,37 @@ async function runVideo(
124
124
  const stdoutLines = await runShim(args, {
125
125
  cwd: path.dirname(VIDEO_PLAYWRIGHT),
126
126
  signal: hooks?.signal,
127
- // No MAUDE_PROGRESS line from this shim yet — frame-count progress is a
128
- // follow-up (video scope is always a single artboard, never multi).
127
+ // Frame-step capture emits `MAUDE_PROGRESS {current,total}` per frame a
128
+ // live "N of M" progress bar in the Exports panel. This is exactly the
129
+ // slow path (the renderMediaOnWeb-overflow fallback can run many minutes);
130
+ // the fast one-pass renderer finishes before progress matters and reports
131
+ // none until done (it exposes no per-frame hook) — acceptable.
132
+ onProgress: hooks?.onProgress,
129
133
  });
130
134
  const body = new Uint8Array(readFileSync(outPath));
131
135
  // The container can differ from the request (mp4 → webm fallback when the
132
- // browser has no H.264 encoder). Read the summary line for the real ext.
136
+ // browser has no H.264 encoder). Read the summary line for the real ext, and
137
+ // for the degradation marker the shim sets when it had to fall back from the
138
+ // renderMediaOnWeb audio path to frame-step screenshots (RCA
139
+ // issue-video-mp4-rendermediaonweb-stack-overflow) — surfaced here so the
140
+ // dropped audio isn't silent (job stderr / logs; a UI toast is a follow-up).
133
141
  let ext: string = format;
134
142
  try {
135
143
  const lastLine = stdoutLines.at(-1) ?? '{}';
136
- const summary = JSON.parse(lastLine) as { container?: string };
144
+ const summary = JSON.parse(lastLine) as {
145
+ container?: string;
146
+ degraded?: boolean;
147
+ audioDropped?: boolean;
148
+ fallbackReason?: string;
149
+ };
137
150
  if (summary.container) ext = summary.container;
151
+ if (summary.degraded) {
152
+ console.error(
153
+ `⚠ ${format} export degraded: the audio renderer failed ` +
154
+ `(${summary.fallbackReason ?? 'unknown'}), so this file was captured ` +
155
+ 'frame-by-frame and has no audio.'
156
+ );
157
+ }
138
158
  } catch {
139
159
  /* keep the requested ext */
140
160
  }
@@ -0,0 +1,195 @@
1
+ // footage/schema — validator + helper coverage (feature-footage-analysis-director).
2
+ // Mirrors the photo/schema house style: a valid fixture round-trips, and every
3
+ // rejection lane (unknown key / bad type / out-of-range / non-relative asset /
4
+ // end≤start / end>duration / bad transition presentation) is asserted so a
5
+ // crafted field can never reach disk via the `/_api/footage` route.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import {
10
+ EDL_VERSION,
11
+ type Edl,
12
+ emptyFootageAnalysis,
13
+ FOOTAGE_ANALYSIS_VERSION,
14
+ type FootageAnalysis,
15
+ isEmptyAnalysis,
16
+ validateEdl,
17
+ validateFootageAnalysis,
18
+ } from './schema.ts';
19
+
20
+ const GOOD_ANALYSIS: FootageAnalysis = {
21
+ version: FOOTAGE_ANALYSIS_VERSION,
22
+ asset: 'assets/a1b2c3d4.mp4',
23
+ durationSec: 12.5,
24
+ width: 1920,
25
+ height: 1080,
26
+ keyframes: 12,
27
+ shots: [
28
+ {
29
+ start: 0,
30
+ end: 3.2,
31
+ kind: 'establishing',
32
+ motion: 'push-in',
33
+ subject: 'drone push over the river',
34
+ lighting: 'golden hour',
35
+ mood: 'calm',
36
+ quality: 0.9,
37
+ usable: true,
38
+ note: 'hero shot',
39
+ },
40
+ { start: 4, end: 6, kind: 'detail', usable: false },
41
+ ],
42
+ summary: 'Calm aspirational exterior b-roll.',
43
+ tags: ['rebrand', 'exterior'],
44
+ };
45
+
46
+ const GOOD_EDL: Edl = {
47
+ version: EDL_VERSION,
48
+ title: 'Alligators rebrand reel',
49
+ fps: 30,
50
+ width: 1920,
51
+ height: 1080,
52
+ beats: [
53
+ {
54
+ clip: 'assets/a1b2c3d4.mp4',
55
+ startSec: 0,
56
+ durationFrames: 60,
57
+ name: 'intro',
58
+ transition: null,
59
+ },
60
+ {
61
+ clip: 'assets/a1b2c3d4.mp4',
62
+ startSec: 8,
63
+ durationFrames: 45,
64
+ why: 'second shot from the same clip',
65
+ transition: { presentation: 'fade', frames: 15 },
66
+ overlay: { kind: 'title', text: 'Alligators' },
67
+ name: 'hero',
68
+ },
69
+ ],
70
+ music: { asset: 'assets/deadbeef.mp3', fadeOutFrames: 20 },
71
+ };
72
+
73
+ describe('validateFootageAnalysis', () => {
74
+ test('accepts a well-formed analysis', () => {
75
+ expect(validateFootageAnalysis(GOOD_ANALYSIS)).toEqual({ ok: true, errors: [] });
76
+ });
77
+
78
+ test('accepts an empty analysis from the helper', () => {
79
+ expect(validateFootageAnalysis(emptyFootageAnalysis('assets/a1b2c3d4.mp4')).ok).toBe(true);
80
+ });
81
+
82
+ test('rejects a non-object root', () => {
83
+ expect(validateFootageAnalysis(null).ok).toBe(false);
84
+ expect(validateFootageAnalysis(42).ok).toBe(false);
85
+ });
86
+
87
+ test('rejects an unknown top-level key', () => {
88
+ const r = validateFootageAnalysis({ ...GOOD_ANALYSIS, evil: 1 });
89
+ expect(r.ok).toBe(false);
90
+ expect(r.errors.join()).toContain('unknown key "evil"');
91
+ });
92
+
93
+ test('rejects a non-relative / traversing asset path', () => {
94
+ for (const asset of ['/etc/passwd', '../secret.mp4', 'assets/../x.mp4', 'http://x/y.mp4']) {
95
+ expect(validateFootageAnalysis({ ...GOOD_ANALYSIS, asset }).ok).toBe(false);
96
+ }
97
+ });
98
+
99
+ test('rejects a shot whose end ≤ start', () => {
100
+ const r = validateFootageAnalysis({
101
+ ...GOOD_ANALYSIS,
102
+ shots: [{ start: 5, end: 5 }],
103
+ });
104
+ expect(r.ok).toBe(false);
105
+ expect(r.errors.join()).toContain('end must be > start');
106
+ });
107
+
108
+ test('rejects a shot that runs past the clip duration', () => {
109
+ const r = validateFootageAnalysis({
110
+ ...GOOD_ANALYSIS,
111
+ durationSec: 5,
112
+ shots: [{ start: 1, end: 9 }],
113
+ });
114
+ expect(r.ok).toBe(false);
115
+ expect(r.errors.join()).toContain('exceeds clip duration');
116
+ });
117
+
118
+ test('rejects an out-of-range quality + bad shot kind', () => {
119
+ expect(
120
+ validateFootageAnalysis({ ...GOOD_ANALYSIS, shots: [{ start: 0, end: 1, quality: 2 }] }).ok
121
+ ).toBe(false);
122
+ expect(
123
+ validateFootageAnalysis({ ...GOOD_ANALYSIS, shots: [{ start: 0, end: 1, kind: 'nope' }] }).ok
124
+ ).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe('isEmptyAnalysis', () => {
129
+ test('true for null / no shots / no usable shots', () => {
130
+ expect(isEmptyAnalysis(null)).toBe(true);
131
+ expect(isEmptyAnalysis({ shots: [] })).toBe(true);
132
+ expect(isEmptyAnalysis({ shots: [{ start: 0, end: 1, usable: false }] })).toBe(true);
133
+ });
134
+ test('false when at least one shot is usable', () => {
135
+ expect(isEmptyAnalysis(GOOD_ANALYSIS)).toBe(false);
136
+ });
137
+ });
138
+
139
+ describe('validateEdl', () => {
140
+ test('accepts a well-formed EDL', () => {
141
+ expect(validateEdl(GOOD_EDL)).toEqual({ ok: true, errors: [] });
142
+ });
143
+
144
+ test('rejects a beat with no clip', () => {
145
+ const r = validateEdl({ ...GOOD_EDL, beats: [{ startSec: 0, durationFrames: 30 }] });
146
+ expect(r.ok).toBe(false);
147
+ expect(r.errors.join()).toContain('clip');
148
+ });
149
+
150
+ test('rejects a non-relative beat clip', () => {
151
+ expect(
152
+ validateEdl({ ...GOOD_EDL, beats: [{ clip: '/x.mp4', startSec: 0, durationFrames: 30 }] }).ok
153
+ ).toBe(false);
154
+ });
155
+
156
+ test('rejects durationFrames < 1 / non-integer', () => {
157
+ expect(
158
+ validateEdl({
159
+ ...GOOD_EDL,
160
+ beats: [{ clip: 'assets/a1b2c3d4.mp4', startSec: 0, durationFrames: 0 }],
161
+ }).ok
162
+ ).toBe(false);
163
+ expect(
164
+ validateEdl({
165
+ ...GOOD_EDL,
166
+ beats: [{ clip: 'assets/a1b2c3d4.mp4', startSec: 0, durationFrames: 12.5 }],
167
+ }).ok
168
+ ).toBe(false);
169
+ });
170
+
171
+ test('rejects an unbundled transition presentation', () => {
172
+ const r = validateEdl({
173
+ ...GOOD_EDL,
174
+ beats: [
175
+ {
176
+ clip: 'assets/a1b2c3d4.mp4',
177
+ startSec: 0,
178
+ durationFrames: 30,
179
+ transition: { presentation: 'dreamy-zoom', frames: 10 },
180
+ },
181
+ ],
182
+ });
183
+ expect(r.ok).toBe(false);
184
+ expect(r.errors.join()).toContain('presentation');
185
+ });
186
+
187
+ test('rejects an fps above 120 / below 1', () => {
188
+ expect(validateEdl({ ...GOOD_EDL, fps: 999 }).ok).toBe(false);
189
+ expect(validateEdl({ ...GOOD_EDL, fps: 0 }).ok).toBe(false);
190
+ });
191
+
192
+ test('rejects a music bed with a non-relative asset', () => {
193
+ expect(validateEdl({ ...GOOD_EDL, music: { asset: 'http://x/y.mp3' } }).ok).toBe(false);
194
+ });
195
+ });