@1agh/maude 0.41.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 (260) 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 -63
  6. package/apps/studio/bin/_html-playwright.mjs +26 -4
  7. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  8. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  9. package/apps/studio/bin/_png-playwright.mjs +15 -2
  10. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  11. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/_video-playwright.mjs +141 -71
  15. package/apps/studio/bin/ingest-footage.sh +27 -0
  16. package/apps/studio/bin/photo-adjust.sh +163 -0
  17. package/apps/studio/bin/photo-bg-remove.sh +160 -0
  18. package/apps/studio/bin/probe-footage.sh +28 -0
  19. package/apps/studio/bin/read-annotations.mjs +125 -2
  20. package/apps/studio/bin/screenshot.sh +53 -4
  21. package/apps/studio/canvas-edit.ts +459 -10
  22. package/apps/studio/canvas-lib.tsx +523 -5
  23. package/apps/studio/canvas-pipeline.ts +65 -7
  24. package/apps/studio/canvas-shell.tsx +156 -16
  25. package/apps/studio/client/app.jsx +476 -88
  26. package/apps/studio/client/export-center.jsx +460 -0
  27. package/apps/studio/client/github.js +4 -0
  28. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  29. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  30. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  31. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  32. package/apps/studio/client/photo-knobs.jsx +437 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +59 -0
  34. package/apps/studio/client/styles/4-components.css +150 -0
  35. package/apps/studio/commands/edit-source-command.ts +31 -4
  36. package/apps/studio/config.schema.json +2 -2
  37. package/apps/studio/context-menu.tsx +31 -6
  38. package/apps/studio/dist/client.bundle.js +5561 -19
  39. package/apps/studio/dist/comment-mount.js +2 -2
  40. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  41. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  42. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/dom-selection.ts +25 -0
  45. package/apps/studio/export-dialog.tsx +18 -25
  46. package/apps/studio/exporters/_runtime.ts +104 -0
  47. package/apps/studio/exporters/html.ts +12 -20
  48. package/apps/studio/exporters/index.ts +14 -2
  49. package/apps/studio/exporters/jobs.ts +376 -0
  50. package/apps/studio/exporters/pdf.ts +16 -20
  51. package/apps/studio/exporters/png.ts +12 -20
  52. package/apps/studio/exporters/pptx.ts +22 -23
  53. package/apps/studio/exporters/scope.ts +1 -0
  54. package/apps/studio/exporters/svg.ts +14 -22
  55. package/apps/studio/exporters/video.ts +37 -19
  56. package/apps/studio/footage/schema.test.ts +195 -0
  57. package/apps/studio/footage/schema.ts +484 -0
  58. package/apps/studio/footage-store.ts +235 -0
  59. package/apps/studio/fs-watch.ts +10 -0
  60. package/apps/studio/git/endpoints.ts +68 -7
  61. package/apps/studio/git/service.ts +198 -20
  62. package/apps/studio/github/service.ts +67 -0
  63. package/apps/studio/handoff.ts +3 -2
  64. package/apps/studio/http.ts +318 -64
  65. package/apps/studio/input-router.tsx +10 -0
  66. package/apps/studio/inspect.ts +53 -1
  67. package/apps/studio/paths.ts +10 -0
  68. package/apps/studio/photo/filters.ts +285 -0
  69. package/apps/studio/photo/pipeline.ts +607 -0
  70. package/apps/studio/photo/schema.ts +367 -0
  71. package/apps/studio/photo-store.ts +154 -0
  72. package/apps/studio/runtime-bundle.ts +29 -6
  73. package/apps/studio/server.ts +8 -2
  74. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  75. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  76. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  77. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  78. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  79. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  80. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  81. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  82. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  83. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  84. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  85. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  86. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  87. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  88. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  89. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  90. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  91. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  92. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  93. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  94. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  95. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  96. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  97. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  98. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  99. package/apps/studio/stickers/life-style/best.png +0 -0
  100. package/apps/studio/stickers/life-style/come-on.png +0 -0
  101. package/apps/studio/stickers/life-style/cut.png +0 -0
  102. package/apps/studio/stickers/life-style/detox.png +0 -0
  103. package/apps/studio/stickers/life-style/just.png +0 -0
  104. package/apps/studio/stickers/life-style/manifest.json +68 -0
  105. package/apps/studio/stickers/life-style/mirror.png +0 -0
  106. package/apps/studio/stickers/life-style/nice.png +0 -0
  107. package/apps/studio/stickers/life-style/objects.png +0 -0
  108. package/apps/studio/stickers/life-style/ok.png +0 -0
  109. package/apps/studio/stickers/life-style/queen.png +0 -0
  110. package/apps/studio/stickers/life-style/star.png +0 -0
  111. package/apps/studio/stickers/life-style/sun.png +0 -0
  112. package/apps/studio/stickers/life-style/water.png +0 -0
  113. package/apps/studio/stickers/life-style/yeah.png +0 -0
  114. package/apps/studio/stickers/life-style/you-can.png +0 -0
  115. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  116. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  117. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  118. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  119. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  120. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  121. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  122. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  123. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  124. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  125. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  126. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  127. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  128. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  129. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  130. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  131. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  132. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  133. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  134. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  135. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  136. package/apps/studio/stickers/project-status/group-100.png +0 -0
  137. package/apps/studio/stickers/project-status/group-101.png +0 -0
  138. package/apps/studio/stickers/project-status/group-102.png +0 -0
  139. package/apps/studio/stickers/project-status/group-103.png +0 -0
  140. package/apps/studio/stickers/project-status/group-104.png +0 -0
  141. package/apps/studio/stickers/project-status/group-105.png +0 -0
  142. package/apps/studio/stickers/project-status/group-106.png +0 -0
  143. package/apps/studio/stickers/project-status/group-107.png +0 -0
  144. package/apps/studio/stickers/project-status/group-108.png +0 -0
  145. package/apps/studio/stickers/project-status/group-109.png +0 -0
  146. package/apps/studio/stickers/project-status/group-110.png +0 -0
  147. package/apps/studio/stickers/project-status/group-111.png +0 -0
  148. package/apps/studio/stickers/project-status/group-112.png +0 -0
  149. package/apps/studio/stickers/project-status/group-113.png +0 -0
  150. package/apps/studio/stickers/project-status/group-114.png +0 -0
  151. package/apps/studio/stickers/project-status/group-115.png +0 -0
  152. package/apps/studio/stickers/project-status/group-117.png +0 -0
  153. package/apps/studio/stickers/project-status/group-118.png +0 -0
  154. package/apps/studio/stickers/project-status/group-119.png +0 -0
  155. package/apps/studio/stickers/project-status/group-120.png +0 -0
  156. package/apps/studio/stickers/project-status/group-121.png +0 -0
  157. package/apps/studio/stickers/project-status/group-122.png +0 -0
  158. package/apps/studio/stickers/project-status/group-41.png +0 -0
  159. package/apps/studio/stickers/project-status/group-42.png +0 -0
  160. package/apps/studio/stickers/project-status/group-43.png +0 -0
  161. package/apps/studio/stickers/project-status/group-44.png +0 -0
  162. package/apps/studio/stickers/project-status/group-45.png +0 -0
  163. package/apps/studio/stickers/project-status/group-46.png +0 -0
  164. package/apps/studio/stickers/project-status/group-47.png +0 -0
  165. package/apps/studio/stickers/project-status/group-48.png +0 -0
  166. package/apps/studio/stickers/project-status/group-49.png +0 -0
  167. package/apps/studio/stickers/project-status/group-50.png +0 -0
  168. package/apps/studio/stickers/project-status/group-51.png +0 -0
  169. package/apps/studio/stickers/project-status/group-52.png +0 -0
  170. package/apps/studio/stickers/project-status/group-53.png +0 -0
  171. package/apps/studio/stickers/project-status/group-54.png +0 -0
  172. package/apps/studio/stickers/project-status/group-55.png +0 -0
  173. package/apps/studio/stickers/project-status/group-56.png +0 -0
  174. package/apps/studio/stickers/project-status/group-57.png +0 -0
  175. package/apps/studio/stickers/project-status/group-58.png +0 -0
  176. package/apps/studio/stickers/project-status/group-59.png +0 -0
  177. package/apps/studio/stickers/project-status/group-60.png +0 -0
  178. package/apps/studio/stickers/project-status/group-61.png +0 -0
  179. package/apps/studio/stickers/project-status/group-62.png +0 -0
  180. package/apps/studio/stickers/project-status/group-63.png +0 -0
  181. package/apps/studio/stickers/project-status/group-64.png +0 -0
  182. package/apps/studio/stickers/project-status/group-65.png +0 -0
  183. package/apps/studio/stickers/project-status/group-66.png +0 -0
  184. package/apps/studio/stickers/project-status/group-67.png +0 -0
  185. package/apps/studio/stickers/project-status/group-68.png +0 -0
  186. package/apps/studio/stickers/project-status/group-69.png +0 -0
  187. package/apps/studio/stickers/project-status/group-70.png +0 -0
  188. package/apps/studio/stickers/project-status/group-71.png +0 -0
  189. package/apps/studio/stickers/project-status/group-72.png +0 -0
  190. package/apps/studio/stickers/project-status/group-73.png +0 -0
  191. package/apps/studio/stickers/project-status/group-74.png +0 -0
  192. package/apps/studio/stickers/project-status/group-75.png +0 -0
  193. package/apps/studio/stickers/project-status/group-76.png +0 -0
  194. package/apps/studio/stickers/project-status/group-77.png +0 -0
  195. package/apps/studio/stickers/project-status/group-78.png +0 -0
  196. package/apps/studio/stickers/project-status/group-79.png +0 -0
  197. package/apps/studio/stickers/project-status/group-80.png +0 -0
  198. package/apps/studio/stickers/project-status/group-81.png +0 -0
  199. package/apps/studio/stickers/project-status/group-82.png +0 -0
  200. package/apps/studio/stickers/project-status/group-83.png +0 -0
  201. package/apps/studio/stickers/project-status/group-84.png +0 -0
  202. package/apps/studio/stickers/project-status/group-85.png +0 -0
  203. package/apps/studio/stickers/project-status/group-86.png +0 -0
  204. package/apps/studio/stickers/project-status/group-87.png +0 -0
  205. package/apps/studio/stickers/project-status/group-88.png +0 -0
  206. package/apps/studio/stickers/project-status/group-89.png +0 -0
  207. package/apps/studio/stickers/project-status/group-90.png +0 -0
  208. package/apps/studio/stickers/project-status/group-91.png +0 -0
  209. package/apps/studio/stickers/project-status/group-92.png +0 -0
  210. package/apps/studio/stickers/project-status/group-93.png +0 -0
  211. package/apps/studio/stickers/project-status/group-94.png +0 -0
  212. package/apps/studio/stickers/project-status/group-96.png +0 -0
  213. package/apps/studio/stickers/project-status/group-97.png +0 -0
  214. package/apps/studio/stickers/project-status/group-98.png +0 -0
  215. package/apps/studio/stickers/project-status/group-99.png +0 -0
  216. package/apps/studio/stickers/project-status/manifest.json +328 -0
  217. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  218. package/apps/studio/test/acp-commands.test.ts +2 -1
  219. package/apps/studio/test/asset-api.test.ts +3 -3
  220. package/apps/studio/test/canvas-edit.test.ts +17 -6
  221. package/apps/studio/test/canvas-origin-gate.test.ts +19 -0
  222. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  223. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  224. package/apps/studio/test/edit-source-command.test.ts +23 -0
  225. package/apps/studio/test/element-structural-edit.test.ts +46 -0
  226. package/apps/studio/test/export-center.test.tsx +287 -0
  227. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  228. package/apps/studio/test/exporters/history.test.ts +32 -3
  229. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  230. package/apps/studio/test/footage-store.test.ts +100 -0
  231. package/apps/studio/test/git-branches.test.ts +97 -0
  232. package/apps/studio/test/github-api.test.ts +56 -1
  233. package/apps/studio/test/input-router.test.ts +33 -0
  234. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  235. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  236. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  237. package/apps/studio/test/photo-filters.test.ts +149 -0
  238. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  239. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  240. package/apps/studio/test/read-annotations.test.ts +164 -0
  241. package/apps/studio/test/shell-importmap.test.ts +49 -0
  242. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  243. package/apps/studio/test/timeline-parse.test.ts +57 -0
  244. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  245. package/apps/studio/test/undo-stack.test.ts +33 -0
  246. package/apps/studio/test/video-asset.test.ts +5 -5
  247. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  248. package/apps/studio/text-caret.ts +239 -0
  249. package/apps/studio/tool-palette.tsx +43 -20
  250. package/apps/studio/undo-stack.ts +94 -2
  251. package/apps/studio/use-annotation-resize.tsx +4 -4
  252. package/apps/studio/use-canvas-media-drop.tsx +38 -1
  253. package/apps/studio/use-selection-set.tsx +21 -0
  254. package/apps/studio/video-comp.tsx +48 -7
  255. package/apps/studio/whats-new.json +70 -0
  256. package/apps/studio/ws.ts +6 -0
  257. package/cli/commands/design.mjs +34 -3
  258. package/cli/lib/gitignore-block.mjs +2 -0
  259. package/package.json +10 -10
  260. package/plugins/design/templates/_shell.html +1 -0
@@ -13,10 +13,11 @@ import path from 'node:path';
13
13
  import JSZip from 'jszip';
14
14
 
15
15
  import { getBrowserBundle } from './_browser-bundles.ts';
16
- import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
16
+ import { exportShimPath, runShim } from './_runtime.ts';
17
17
  import {
18
18
  canvasShellUrl,
19
19
  type ExportContext,
20
+ type ExportHooks,
20
21
  type ExportOptions,
21
22
  type ExportResult,
22
23
  } from './index.ts';
@@ -30,7 +31,8 @@ async function captureSvg(
30
31
  ctx: ExportContext,
31
32
  outDir: string,
32
33
  timeoutSec: number,
33
- bundlePath: string
34
+ bundlePath: string,
35
+ hooks?: ExportHooks
34
36
  ): Promise<string[]> {
35
37
  const args = [
36
38
  SVG_PLAYWRIGHT,
@@ -55,31 +57,20 @@ async function captureSvg(
55
57
  // Run via a resolved node/bun runtime so the shim's `import 'playwright'`
56
58
  // resolves against dev-server/node_modules (playwright is a devDep). `npm exec`
57
59
  // doesn't bridge the module path for ESM imports — confirmed against npm 10.x.
58
- // resolveExportRuntime() replaces a hardcoded `'node'` so a compiled binary
59
- // without `node` on PATH surfaces an actionable error, not `posix_spawn 'node'`.
60
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
60
+ // runShim resolves the runtime internally so a compiled binary without
61
+ // `node` on PATH surfaces an actionable error, not `posix_spawn 'node'`.
62
+ return runShim(args, {
61
63
  cwd: path.dirname(SVG_PLAYWRIGHT),
62
- stdout: 'pipe',
63
- stderr: 'pipe',
64
+ signal: hooks?.signal,
65
+ onProgress: hooks?.onProgress,
64
66
  });
65
- const [stdout, stderr] = await Promise.all([
66
- new Response(proc.stdout).text(),
67
- new Response(proc.stderr).text(),
68
- ]);
69
- const code = await proc.exited;
70
- if (code !== 0) {
71
- throw new Error(`_svg-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
72
- }
73
- return stdout
74
- .split('\n')
75
- .map((s) => s.trim())
76
- .filter(Boolean);
77
67
  }
78
68
 
79
69
  export async function run(
80
70
  targets: Target[],
81
71
  options: ExportOptions,
82
- ctx: ExportContext
72
+ ctx: ExportContext,
73
+ hooks?: ExportHooks
83
74
  ): Promise<ExportResult> {
84
75
  if (!targets.length) {
85
76
  return { filename: 'export.svg', contentType: 'image/svg+xml', body: new Uint8Array(0) };
@@ -96,9 +87,10 @@ export async function run(
96
87
 
97
88
  try {
98
89
  const written: string[] = [];
99
- for (const t of elementTargets) {
100
- const paths = await captureSvg(t, ctx, tmp, timeoutSec, bundlePath);
90
+ for (let i = 0; i < elementTargets.length; i += 1) {
91
+ const paths = await captureSvg(elementTargets[i], ctx, tmp, timeoutSec, bundlePath, hooks);
101
92
  written.push(...paths);
93
+ hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
102
94
  }
103
95
  if (!written.length) {
104
96
  return { filename: 'export.svg', contentType: 'image/svg+xml', body: new Uint8Array(0) };
@@ -20,10 +20,11 @@ import { tmpdir } from 'node:os';
20
20
  import path from 'node:path';
21
21
 
22
22
  import { getEncodeLibBundle, getWebRendererBundle } from './_browser-bundles.ts';
23
- import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
23
+ import { exportShimPath, runShim } from './_runtime.ts';
24
24
  import {
25
25
  canvasShellUrl,
26
26
  type ExportContext,
27
+ type ExportHooks,
27
28
  type ExportOptions,
28
29
  type ExportResult,
29
30
  } from './index.ts';
@@ -47,7 +48,8 @@ async function runVideo(
47
48
  format: VideoFormat,
48
49
  targets: Target[],
49
50
  options: ExportOptions,
50
- ctx: ExportContext
51
+ ctx: ExportContext,
52
+ hooks?: ExportHooks
51
53
  ): Promise<ExportResult> {
52
54
  const el = targets.find((t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element');
53
55
  if (!el) {
@@ -119,27 +121,40 @@ async function runVideo(
119
121
  }
120
122
 
121
123
  try {
122
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
124
+ const stdoutLines = await runShim(args, {
123
125
  cwd: path.dirname(VIDEO_PLAYWRIGHT),
124
- stdout: 'pipe',
125
- stderr: 'pipe',
126
+ signal: hooks?.signal,
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,
126
133
  });
127
- const [stdout, stderr] = await Promise.all([
128
- new Response(proc.stdout).text(),
129
- new Response(proc.stderr).text(),
130
- ]);
131
- const code = await proc.exited;
132
- if (code !== 0) {
133
- throw new Error(`_video-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
134
- }
135
134
  const body = new Uint8Array(readFileSync(outPath));
136
135
  // The container can differ from the request (mp4 → webm fallback when the
137
- // 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).
138
141
  let ext: string = format;
139
142
  try {
140
- const lastLine = stdout.trim().split('\n').filter(Boolean).pop() ?? '{}';
141
- const summary = JSON.parse(lastLine) as { container?: string };
143
+ const lastLine = stdoutLines.at(-1) ?? '{}';
144
+ const summary = JSON.parse(lastLine) as {
145
+ container?: string;
146
+ degraded?: boolean;
147
+ audioDropped?: boolean;
148
+ fallbackReason?: string;
149
+ };
142
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
+ }
143
158
  } catch {
144
159
  /* keep the requested ext */
145
160
  }
@@ -174,11 +189,14 @@ function resolveFrames(options: ExportOptions, fps: number | undefined): number
174
189
  }
175
190
 
176
191
  export const mp4 = {
177
- run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('mp4', t, o, c),
192
+ run: (t: Target[], o: ExportOptions, c: ExportContext, h?: ExportHooks) =>
193
+ runVideo('mp4', t, o, c, h),
178
194
  };
179
195
  export const webm = {
180
- run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('webm', t, o, c),
196
+ run: (t: Target[], o: ExportOptions, c: ExportContext, h?: ExportHooks) =>
197
+ runVideo('webm', t, o, c, h),
181
198
  };
182
199
  export const gif = {
183
- run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('gif', t, o, c),
200
+ run: (t: Target[], o: ExportOptions, c: ExportContext, h?: ExportHooks) =>
201
+ runVideo('gif', t, o, c, h),
184
202
  };
@@ -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
+ });