@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
@@ -0,0 +1,484 @@
1
+ /**
2
+ * @file footage/schema.ts — Footage-analysis + EDL sidecar data model
3
+ * (feature-footage-analysis-director, Task 1).
4
+ * @scope apps/studio/footage/schema.ts
5
+ * @purpose The single source of truth for the two artifacts the footage
6
+ * "director" pipeline persists (DDR-115 taxonomy: both VERSIONED,
7
+ * alongside the footage they describe — same bucket as `.photo.json`
8
+ * / `.meta.json` / `.annotations.svg`):
9
+ *
10
+ * 1. `assets/<sha8>.footage.json` — a `FootageAnalysis`: what the
11
+ * `footage-analyst` agent SAW when it watched the keyframes of
12
+ * one clip (shots, good-moment ranges, tags, a summary). Per-clip.
13
+ * 2. `<designRoot>/<slug>.edl.json` — an `Edl`: what the
14
+ * `footage-director` agent decided (an ordered beat list — the
15
+ * cut). Per-project-cut.
16
+ *
17
+ * Both are *metadata only* — never pixels, never re-encoded video.
18
+ * The `/design:reel` codegen reads the `Edl` and emits a
19
+ * Timeline-parseable `<TransitionSeries>` video-comp TSX (video-comp
20
+ * SKILL.md is the authoring contract).
21
+ *
22
+ * @invariant DEPENDENCY-FREE. Imported by BOTH the server (`footage-store.ts`,
23
+ * the `/_api/footage` route) and any client/agent surface. MUST NOT
24
+ * import any browser / native / heavy lib. Pure TS types + plain
25
+ * helpers + a hand-rolled structural validator (no Ajv) — the exact
26
+ * house style of `photo/schema.ts`.
27
+ *
28
+ * @time-model Footage time is in **SECONDS** (source-relative), NOT frames: an
29
+ * HTML5 `<video>` (the probe surface) can report `duration` but not a
30
+ * reliable native fps, and the analyst reasons about seconds. The
31
+ * `Edl` composes at a chosen OUTPUT fps and lengths there ARE in
32
+ * frames (Remotion's clock); the director converts source-seconds →
33
+ * output-frames at codegen. Keeping the two clocks separate is
34
+ * deliberate — see the plan's Task-1 note.
35
+ */
36
+
37
+ /** Schema version of a `FootageAnalysis`. Bump on an incompatible shape change. */
38
+ export const FOOTAGE_ANALYSIS_VERSION = 1 as const;
39
+ /** Schema version of an `Edl`. */
40
+ export const EDL_VERSION = 1 as const;
41
+
42
+ /**
43
+ * The six transition presentations Maude bundles from `@remotion/transitions`
44
+ * (video-comp SKILL.md — the ONLY ones that resolve on an end-user install).
45
+ * `none` is a hard cut. The director may only choose from these.
46
+ */
47
+ export type TransitionPresentation = 'none' | 'fade' | 'slide' | 'wipe' | 'flip' | 'clock-wipe';
48
+ export const TRANSITION_PRESENTATIONS: readonly TransitionPresentation[] = [
49
+ 'none',
50
+ 'fade',
51
+ 'slide',
52
+ 'wipe',
53
+ 'flip',
54
+ 'clock-wipe',
55
+ ];
56
+
57
+ /** Coarse shot vocabulary the analyst tags each segment with (advisory). */
58
+ export type ShotKind =
59
+ | 'wide'
60
+ | 'medium'
61
+ | 'close'
62
+ | 'detail'
63
+ | 'establishing'
64
+ | 'action'
65
+ | 'portrait'
66
+ | 'product'
67
+ | 'transition'
68
+ | 'other';
69
+ export const SHOT_KINDS: readonly ShotKind[] = [
70
+ 'wide',
71
+ 'medium',
72
+ 'close',
73
+ 'detail',
74
+ 'establishing',
75
+ 'action',
76
+ 'portrait',
77
+ 'product',
78
+ 'transition',
79
+ 'other',
80
+ ];
81
+
82
+ /** Dominant camera/subject motion the analyst reads across the keyframe run. */
83
+ export type MotionKind =
84
+ | 'static'
85
+ | 'pan'
86
+ | 'tilt'
87
+ | 'push-in'
88
+ | 'pull-out'
89
+ | 'handheld'
90
+ | 'tracking'
91
+ | 'fast'
92
+ | 'other';
93
+ export const MOTION_KINDS: readonly MotionKind[] = [
94
+ 'static',
95
+ 'pan',
96
+ 'tilt',
97
+ 'push-in',
98
+ 'pull-out',
99
+ 'handheld',
100
+ 'tracking',
101
+ 'fast',
102
+ 'other',
103
+ ];
104
+
105
+ /**
106
+ * One shot the analyst found inside a clip. `start`/`end` are SECONDS in the
107
+ * SOURCE clip (0 ≤ start < end ≤ clip duration). `quality` is the analyst's
108
+ * 0..1 confidence that this is a *usable, good* moment; `usable` is the boolean
109
+ * gate the director filters on first.
110
+ */
111
+ export interface FootageShot {
112
+ /** Source in-point, seconds. */
113
+ start: number;
114
+ /** Source out-point, seconds (> start). */
115
+ end: number;
116
+ kind?: ShotKind;
117
+ motion?: MotionKind;
118
+ /** Free-text: who/what is on screen ("drone push over the river"). */
119
+ subject?: string;
120
+ /** Free-text lighting note ("golden hour, backlit"). */
121
+ lighting?: string;
122
+ /** Free-text mood ("calm, aspirational"). */
123
+ mood?: string;
124
+ /** 0..1 — how strong a shot this is for a reel. */
125
+ quality?: number;
126
+ /** The director only considers `usable !== false` shots. */
127
+ usable?: boolean;
128
+ /** One-line director-facing note ("hero shot; hold on the logo reveal"). */
129
+ note?: string;
130
+ }
131
+
132
+ /**
133
+ * The full analysis of one clip. `asset` is the `assets/<sha8>.<ext>` it
134
+ * describes (redundant with the sidecar filename, but self-describing for the
135
+ * director + audit). `durationSec` / `width` / `height` come from the probe
136
+ * (`<video>` metadata). `keyframes` is the count of frames the analyst actually
137
+ * watched (provenance — so a downstream reader knows the sampling density).
138
+ */
139
+ export interface FootageAnalysis {
140
+ version?: number;
141
+ /** `assets/<sha8>.<ext>` — the source this analysis targets. */
142
+ asset?: string;
143
+ durationSec?: number;
144
+ width?: number;
145
+ height?: number;
146
+ /** How many keyframes were sampled to produce this analysis. */
147
+ keyframes?: number;
148
+ shots?: FootageShot[];
149
+ /** One-paragraph character-of-the-clip summary. */
150
+ summary?: string;
151
+ /** Cross-cutting tags ("rebrand", "exterior", "people"). */
152
+ tags?: string[];
153
+ }
154
+
155
+ /** An optional graphic overlay laid over a beat (title card / lower-third / logo). */
156
+ export interface EdlOverlay {
157
+ kind: 'title' | 'lower-third' | 'caption' | 'logo';
158
+ /** Display text (absent for a bare logo). */
159
+ text?: string;
160
+ }
161
+
162
+ /** The transition BETWEEN this beat and the previous one (null on beat 0 / hard cut). */
163
+ export interface EdlTransition {
164
+ presentation: TransitionPresentation;
165
+ /** Overlap length in OUTPUT frames. */
166
+ frames: number;
167
+ }
168
+
169
+ /**
170
+ * One beat of the cut. `clip` is the `assets/<sha8>.<ext>` it draws from;
171
+ * `startSec` is the SOURCE in-point (seconds), `durationFrames` the beat length
172
+ * in OUTPUT frames (at the Edl's `fps`). Two beats may share one `clip` with
173
+ * different `startSec` — pulling multiple shots from one clip is first-class.
174
+ */
175
+ export interface EdlBeat {
176
+ /** `assets/<sha8>.<ext>` — the clip this beat draws from. */
177
+ clip: string;
178
+ /** Source in-point, seconds. */
179
+ startSec: number;
180
+ /** Beat length in OUTPUT frames. */
181
+ durationFrames: number;
182
+ /** Why this shot, here — the director's reasoning (audit + regen). */
183
+ why?: string;
184
+ /** Transition INTO this beat from the previous one (null ⇒ hard cut). */
185
+ transition?: EdlTransition | null;
186
+ /** Optional graphic over the beat. */
187
+ overlay?: EdlOverlay | null;
188
+ /** Stable Timeline identity ("beat-1", "hero", "logo"). */
189
+ name?: string;
190
+ }
191
+
192
+ /** A plain user-dropped music bed laid under the whole cut (NOT analysis — see the plan's scope note). */
193
+ export interface EdlMusic {
194
+ /** `assets/<sha8>.<ext>` audio track. */
195
+ asset: string;
196
+ /** Fade the bed out over the last N output frames. */
197
+ fadeOutFrames?: number;
198
+ }
199
+
200
+ /**
201
+ * The edit decision list — the director's output, one per cut. `beats` is the
202
+ * ordered timeline; the composition's total length = sum(durationFrames) −
203
+ * sum(transition overlaps).
204
+ */
205
+ export interface Edl {
206
+ version?: number;
207
+ title?: string;
208
+ /** Output composition fps (default 30). */
209
+ fps?: number;
210
+ /** Output composition dimensions. */
211
+ width?: number;
212
+ height?: number;
213
+ beats?: EdlBeat[];
214
+ music?: EdlMusic | null;
215
+ }
216
+
217
+ // ── shared helpers ───────────────────────────────────────────────────────────
218
+
219
+ const ASSET_REL_RE = /^assets\/[0-9a-f]{8,}[A-Za-z0-9._-]*$/;
220
+ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
221
+ typeof v === 'object' && v !== null && !Array.isArray(v);
222
+
223
+ /** True when the analysis has nothing usable in it (no shots, or none usable). */
224
+ export function isEmptyAnalysis(a?: FootageAnalysis | null): boolean {
225
+ if (!a || !a.shots || a.shots.length === 0) return true;
226
+ return !a.shots.some((s) => s.usable !== false);
227
+ }
228
+
229
+ /** A fresh, empty analysis for `asset`. */
230
+ export function emptyFootageAnalysis(asset?: string): FootageAnalysis {
231
+ return { version: FOOTAGE_ANALYSIS_VERSION, ...(asset ? { asset } : {}), shots: [] };
232
+ }
233
+
234
+ // ── structural validation (dependency-free — no Ajv) ─────────────────────────
235
+ // Mirrors photo/schema.ts: the `/_api/footage` route validates untrusted JSON
236
+ // with these before persisting. Unknown keys / wrong types / out-of-range
237
+ // numbers / non-relative asset paths all fail — no crafted field round-trips.
238
+
239
+ export interface ValidationResult {
240
+ ok: boolean;
241
+ errors: string[];
242
+ }
243
+
244
+ function num(
245
+ errors: string[],
246
+ obj: Record<string, unknown>,
247
+ key: string,
248
+ lo: number,
249
+ hi: number,
250
+ where: string
251
+ ) {
252
+ if (!(key in obj) || obj[key] == null) return;
253
+ const v = obj[key];
254
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
255
+ errors.push(`${where}.${key}: must be a finite number`);
256
+ return;
257
+ }
258
+ if (v < lo || v > hi) errors.push(`${where}.${key}: ${v} out of range [${lo}, ${hi}]`);
259
+ }
260
+
261
+ function intGe(
262
+ errors: string[],
263
+ obj: Record<string, unknown>,
264
+ key: string,
265
+ lo: number,
266
+ where: string
267
+ ) {
268
+ if (!(key in obj) || obj[key] == null) return;
269
+ const v = obj[key];
270
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < lo)
271
+ errors.push(`${where}.${key}: must be an integer ≥ ${lo}`);
272
+ }
273
+
274
+ function bool(errors: string[], obj: Record<string, unknown>, key: string, where: string) {
275
+ if (key in obj && obj[key] != null && typeof obj[key] !== 'boolean')
276
+ errors.push(`${where}.${key}: must be a boolean`);
277
+ }
278
+
279
+ function str(
280
+ errors: string[],
281
+ obj: Record<string, unknown>,
282
+ key: string,
283
+ maxLen: number,
284
+ where: string
285
+ ) {
286
+ if (!(key in obj) || obj[key] == null) return;
287
+ const v = obj[key];
288
+ if (typeof v !== 'string') errors.push(`${where}.${key}: must be a string`);
289
+ else if (v.length > maxLen) errors.push(`${where}.${key}: exceeds ${maxLen} chars`);
290
+ }
291
+
292
+ function assetRel(errors: string[], obj: Record<string, unknown>, key: string, where: string) {
293
+ if (!(key in obj) || obj[key] == null) return;
294
+ const v = obj[key];
295
+ if (typeof v !== 'string' || !ASSET_REL_RE.test(v))
296
+ errors.push(`${where}.${key}: must be a relative assets/<sha8>.<ext> path`);
297
+ }
298
+
299
+ function assertKeys(
300
+ errors: string[],
301
+ obj: Record<string, unknown>,
302
+ allowed: string[],
303
+ where: string
304
+ ) {
305
+ for (const k of Object.keys(obj))
306
+ if (!allowed.includes(k)) errors.push(`${where}: unknown key "${k}"`);
307
+ }
308
+
309
+ function strArray(
310
+ errors: string[],
311
+ obj: Record<string, unknown>,
312
+ key: string,
313
+ maxItems: number,
314
+ where: string
315
+ ) {
316
+ if (!(key in obj) || obj[key] == null) return;
317
+ const v = obj[key];
318
+ if (!Array.isArray(v)) {
319
+ errors.push(`${where}.${key}: must be an array`);
320
+ return;
321
+ }
322
+ if (v.length > maxItems) errors.push(`${where}.${key}: more than ${maxItems} items`);
323
+ for (const s of v)
324
+ if (typeof s !== 'string') errors.push(`${where}.${key}: items must be strings`);
325
+ }
326
+
327
+ /** Structurally validate an untrusted value as a `FootageAnalysis`. */
328
+ export function validateFootageAnalysis(input: unknown): ValidationResult {
329
+ const errors: string[] = [];
330
+ if (!isPlainObject(input)) return { ok: false, errors: ['root: must be an object'] };
331
+
332
+ assertKeys(
333
+ errors,
334
+ input,
335
+ ['version', 'asset', 'durationSec', 'width', 'height', 'keyframes', 'shots', 'summary', 'tags'],
336
+ 'root'
337
+ );
338
+ if ('version' in input && input.version != null && typeof input.version !== 'number')
339
+ errors.push('version: must be a number');
340
+ assetRel(errors, input, 'asset', 'root');
341
+ num(errors, input, 'durationSec', 0, 24 * 3600, 'root');
342
+ num(errors, input, 'width', 0, 100000, 'root');
343
+ num(errors, input, 'height', 0, 100000, 'root');
344
+ intGe(errors, input, 'keyframes', 0, 'root');
345
+ str(errors, input, 'summary', 4000, 'root');
346
+ strArray(errors, input, 'tags', 64, 'root');
347
+
348
+ const dur = typeof input.durationSec === 'number' ? input.durationSec : Infinity;
349
+ if ('shots' in input && input.shots != null) {
350
+ if (!Array.isArray(input.shots)) errors.push('shots: must be an array');
351
+ else if (input.shots.length > 512) errors.push('shots: more than 512 items');
352
+ else
353
+ input.shots.forEach((raw, i) => {
354
+ const where = `shots[${i}]`;
355
+ if (!isPlainObject(raw)) {
356
+ errors.push(`${where}: must be an object`);
357
+ return;
358
+ }
359
+ assertKeys(
360
+ errors,
361
+ raw,
362
+ [
363
+ 'start',
364
+ 'end',
365
+ 'kind',
366
+ 'motion',
367
+ 'subject',
368
+ 'lighting',
369
+ 'mood',
370
+ 'quality',
371
+ 'usable',
372
+ 'note',
373
+ ],
374
+ where
375
+ );
376
+ num(errors, raw, 'start', 0, 24 * 3600, where);
377
+ num(errors, raw, 'end', 0, 24 * 3600, where);
378
+ if (typeof raw.start === 'number' && typeof raw.end === 'number' && !(raw.end > raw.start))
379
+ errors.push(`${where}: end must be > start`);
380
+ if (typeof raw.end === 'number' && raw.end > dur + 0.5)
381
+ errors.push(`${where}: end ${raw.end}s exceeds clip duration ${dur}s`);
382
+ if ('kind' in raw && raw.kind != null && !SHOT_KINDS.includes(raw.kind as ShotKind))
383
+ errors.push(`${where}.kind: must be one of ${SHOT_KINDS.join(', ')}`);
384
+ if (
385
+ 'motion' in raw &&
386
+ raw.motion != null &&
387
+ !MOTION_KINDS.includes(raw.motion as MotionKind)
388
+ )
389
+ errors.push(`${where}.motion: must be one of ${MOTION_KINDS.join(', ')}`);
390
+ str(errors, raw, 'subject', 500, where);
391
+ str(errors, raw, 'lighting', 500, where);
392
+ str(errors, raw, 'mood', 500, where);
393
+ str(errors, raw, 'note', 1000, where);
394
+ num(errors, raw, 'quality', 0, 1, where);
395
+ bool(errors, raw, 'usable', where);
396
+ });
397
+ }
398
+
399
+ return { ok: errors.length === 0, errors };
400
+ }
401
+
402
+ function validateOverlay(errors: string[], raw: unknown, where: string) {
403
+ if (raw == null) return;
404
+ if (!isPlainObject(raw)) {
405
+ errors.push(`${where}: must be an object`);
406
+ return;
407
+ }
408
+ assertKeys(errors, raw, ['kind', 'text'], where);
409
+ const kinds = ['title', 'lower-third', 'caption', 'logo'];
410
+ if (!kinds.includes(raw.kind as string))
411
+ errors.push(`${where}.kind: must be one of ${kinds.join(', ')}`);
412
+ str(errors, raw, 'text', 500, where);
413
+ }
414
+
415
+ function validateTransition(errors: string[], raw: unknown, where: string) {
416
+ if (raw == null) return;
417
+ if (!isPlainObject(raw)) {
418
+ errors.push(`${where}: must be an object`);
419
+ return;
420
+ }
421
+ assertKeys(errors, raw, ['presentation', 'frames'], where);
422
+ if (!TRANSITION_PRESENTATIONS.includes(raw.presentation as TransitionPresentation))
423
+ errors.push(`${where}.presentation: must be one of ${TRANSITION_PRESENTATIONS.join(', ')}`);
424
+ intGe(errors, raw, 'frames', 0, where);
425
+ }
426
+
427
+ /** Structurally validate an untrusted value as an `Edl`. */
428
+ export function validateEdl(input: unknown): ValidationResult {
429
+ const errors: string[] = [];
430
+ if (!isPlainObject(input)) return { ok: false, errors: ['root: must be an object'] };
431
+
432
+ assertKeys(
433
+ errors,
434
+ input,
435
+ ['version', 'title', 'fps', 'width', 'height', 'beats', 'music'],
436
+ 'root'
437
+ );
438
+ if ('version' in input && input.version != null && typeof input.version !== 'number')
439
+ errors.push('version: must be a number');
440
+ str(errors, input, 'title', 300, 'root');
441
+ num(errors, input, 'fps', 1, 120, 'root');
442
+ num(errors, input, 'width', 1, 100000, 'root');
443
+ num(errors, input, 'height', 1, 100000, 'root');
444
+
445
+ if ('beats' in input && input.beats != null) {
446
+ if (!Array.isArray(input.beats)) errors.push('beats: must be an array');
447
+ else if (input.beats.length > 512) errors.push('beats: more than 512 items');
448
+ else
449
+ input.beats.forEach((raw, i) => {
450
+ const where = `beats[${i}]`;
451
+ if (!isPlainObject(raw)) {
452
+ errors.push(`${where}: must be an object`);
453
+ return;
454
+ }
455
+ assertKeys(
456
+ errors,
457
+ raw,
458
+ ['clip', 'startSec', 'durationFrames', 'why', 'transition', 'overlay', 'name'],
459
+ where
460
+ );
461
+ assetRel(errors, raw, 'clip', where);
462
+ if (!('clip' in raw) || raw.clip == null) errors.push(`${where}.clip: required`);
463
+ num(errors, raw, 'startSec', 0, 24 * 3600, where);
464
+ intGe(errors, raw, 'durationFrames', 1, where);
465
+ str(errors, raw, 'why', 1000, where);
466
+ str(errors, raw, 'name', 120, where);
467
+ if ('transition' in raw) validateTransition(errors, raw.transition, `${where}.transition`);
468
+ if ('overlay' in raw) validateOverlay(errors, raw.overlay, `${where}.overlay`);
469
+ });
470
+ }
471
+
472
+ if ('music' in input && input.music != null) {
473
+ const m = input.music;
474
+ if (!isPlainObject(m)) errors.push('music: must be an object');
475
+ else {
476
+ assertKeys(errors, m, ['asset', 'fadeOutFrames'], 'music');
477
+ assetRel(errors, m, 'asset', 'music');
478
+ if (!('asset' in m) || m.asset == null) errors.push('music.asset: required');
479
+ intGe(errors, m, 'fadeOutFrames', 0, 'music');
480
+ }
481
+ }
482
+
483
+ return { ok: errors.length === 0, errors };
484
+ }