@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
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @file footage-store.ts — Footage-analysis + EDL sidecar persistence
3
+ * (feature-footage-analysis-director, Task 2).
4
+ * @scope apps/studio/footage-store.ts
5
+ * @purpose Read / write the two VERSIONED sidecars behind the footage
6
+ * "director" pipeline (DDR-115 taxonomy):
7
+ * • `assets/<sha8>.footage.json` — a `FootageAnalysis` (per clip)
8
+ * • `<designRoot>/<slug>.edl.json` — an `Edl` (per cut)
9
+ * Mirrors `photo-store.ts`'s `createX(ctx)` factory + `Bun.write`/
10
+ * `Bun.file` shape. Server-side only — imports the DEPENDENCY-FREE
11
+ * `footage/schema.ts`.
12
+ *
13
+ * @security UNLIKE `/_api/photo-edit`, the `/_api/footage` route is
14
+ * MAIN-ORIGIN ONLY (privileged — NOT in either canvas allowlist).
15
+ * It is written by the `footage-analyst` / `footage-director` agents
16
+ * over loopback, never by the untrusted canvas iframe (DDR-054), so
17
+ * a `GET → 405` assertion from the canvas origin guards it
18
+ * (canvas-origin-gate.test.ts). The write path is still derived from
19
+ * caller-supplied params, so the DDR-088 cap stack is preserved:
20
+ * 1. strict sha8 / slug extraction (hex or kebab, bounded) —
21
+ * rejects `..`, `/`, `%2f`, absolute paths by construction;
22
+ * 2. explicit containment assert (poisoned-designRoot backstop);
23
+ * 3. structural `validateFootageAnalysis` / `validateEdl`;
24
+ * 4. body/file size cap (256 KB — generous for analysis JSON).
25
+ */
26
+
27
+ import path from 'node:path';
28
+
29
+ import type { Context } from './context.ts';
30
+ import {
31
+ EDL_VERSION,
32
+ type Edl,
33
+ FOOTAGE_ANALYSIS_VERSION,
34
+ type FootageAnalysis,
35
+ validateEdl,
36
+ validateFootageAnalysis,
37
+ } from './footage/schema.ts';
38
+
39
+ /** Max bytes for either sidecar (read + write). Analysis JSON is small; slack. */
40
+ export const FOOTAGE_MAX_BYTES = 256 * 1024;
41
+
42
+ const SHA_RE = /^[0-9a-f]{8,64}$/;
43
+ // A cut slug: lowercase kebab, bounded. Matches the `slug.sh` normalization
44
+ // shape — no path segment, no dot, so `..`/`/`/absolute can never survive.
45
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,80}$/;
46
+
47
+ /**
48
+ * Extract + validate the content-address sha8 from an `asset` param, in any of
49
+ * the forms a caller might hold (`assets/<sha8>.<ext>`, `/assets/<sha8>.png`,
50
+ * `<sha8>.png`, bare `<sha8>`). The hex regex is the traversal defense.
51
+ * (Copied from photo-store.assetSha8 — same contract, kept local so the two
52
+ * stores stay independent.)
53
+ */
54
+ export function assetSha8(param: string | null | undefined): string | null {
55
+ if (typeof param !== 'string') return null;
56
+ let s = param.trim();
57
+ if (!s) return null;
58
+ s = s.replace(/^\/+/, '');
59
+ s = s.replace(/^assets\//, '');
60
+ s = s.replace(/\.[A-Za-z0-9]+$/, '');
61
+ return SHA_RE.test(s) ? s : null;
62
+ }
63
+
64
+ /** Validate a cut slug (already-normalized kebab), or null. */
65
+ export function edlSlug(param: string | null | undefined): string | null {
66
+ if (typeof param !== 'string') return null;
67
+ const s = param.trim();
68
+ return SLUG_RE.test(s) ? s : null;
69
+ }
70
+
71
+ export interface FootageStore {
72
+ analysisPathForSha8(sha8: string): string;
73
+ edlPathForSlug(slug: string): string;
74
+ getAnalysis(assetParam: string | null | undefined): Promise<FootageAnalysis | null>;
75
+ saveAnalysis(
76
+ assetParam: string | null | undefined,
77
+ analysis: unknown
78
+ ): Promise<
79
+ | { ok: true; path: string; analysis: FootageAnalysis }
80
+ | { ok: false; status: number; error: string }
81
+ >;
82
+ getEdl(slugParam: string | null | undefined): Promise<Edl | null>;
83
+ saveEdl(
84
+ slugParam: string | null | undefined,
85
+ edl: unknown
86
+ ): Promise<{ ok: true; path: string; edl: Edl } | { ok: false; status: number; error: string }>;
87
+ }
88
+
89
+ export function createFootageStore(ctx: Context): FootageStore {
90
+ const designRoot = ctx.paths.designRoot;
91
+ const assetsDir = path.join(designRoot, 'assets');
92
+
93
+ function analysisPathForSha8(sha8: string): string {
94
+ if (!SHA_RE.test(sha8)) throw new Error(`invalid sha8: ${sha8}`);
95
+ const abs = path.join(assetsDir, `${sha8}.footage.json`);
96
+ const rel = path.relative(assetsDir, abs);
97
+ if (rel !== `${sha8}.footage.json` || rel.startsWith('..') || path.isAbsolute(rel)) {
98
+ throw new Error(`footage path escaped assets/: ${abs}`);
99
+ }
100
+ return abs;
101
+ }
102
+
103
+ function edlPathForSlug(slug: string): string {
104
+ if (!SLUG_RE.test(slug)) throw new Error(`invalid slug: ${slug}`);
105
+ const abs = path.join(designRoot, `${slug}.edl.json`);
106
+ const rel = path.relative(designRoot, abs);
107
+ if (rel !== `${slug}.edl.json` || rel.startsWith('..') || path.isAbsolute(rel)) {
108
+ throw new Error(`edl path escaped designRoot: ${abs}`);
109
+ }
110
+ return abs;
111
+ }
112
+
113
+ async function readSidecar<T>(
114
+ abs: string,
115
+ validate: (v: unknown) => { ok: boolean }
116
+ ): Promise<T | null> {
117
+ try {
118
+ const file = Bun.file(abs);
119
+ if (!(await file.exists())) return null;
120
+ if (file.size > FOOTAGE_MAX_BYTES) return null;
121
+ const parsed = JSON.parse(await file.text());
122
+ return validate(parsed).ok ? (parsed as T) : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ async function getAnalysis(
129
+ assetParam: string | null | undefined
130
+ ): Promise<FootageAnalysis | null> {
131
+ const sha8 = assetSha8(assetParam);
132
+ if (!sha8) return null;
133
+ return readSidecar<FootageAnalysis>(analysisPathForSha8(sha8), validateFootageAnalysis);
134
+ }
135
+
136
+ async function saveAnalysis(
137
+ assetParam: string | null | undefined,
138
+ analysis: unknown
139
+ ): Promise<
140
+ | { ok: true; path: string; analysis: FootageAnalysis }
141
+ | { ok: false; status: number; error: string }
142
+ > {
143
+ const sha8 = assetSha8(assetParam);
144
+ if (!sha8) return { ok: false, status: 400, error: 'invalid or missing asset (expected sha8)' };
145
+
146
+ let serialized: string;
147
+ try {
148
+ serialized = JSON.stringify(analysis ?? {});
149
+ } catch {
150
+ return { ok: false, status: 400, error: 'body is not serializable JSON' };
151
+ }
152
+ if (Buffer.byteLength(serialized, 'utf8') > FOOTAGE_MAX_BYTES) {
153
+ return {
154
+ ok: false,
155
+ status: 413,
156
+ error: `footage analysis exceeds ${FOOTAGE_MAX_BYTES} bytes`,
157
+ };
158
+ }
159
+
160
+ const check = validateFootageAnalysis(analysis);
161
+ if (!check.ok) {
162
+ return {
163
+ ok: false,
164
+ status: 400,
165
+ error: `invalid FootageAnalysis: ${check.errors.join('; ')}`,
166
+ };
167
+ }
168
+
169
+ // Stamp version only; the sidecar filename (<sha8>.footage.json) is the
170
+ // authoritative source key. `asset` is already validated as a well-formed
171
+ // relative path — keep the client's value verbatim.
172
+ const toWrite: FootageAnalysis = {
173
+ ...(analysis as FootageAnalysis),
174
+ version: FOOTAGE_ANALYSIS_VERSION,
175
+ };
176
+
177
+ let abs: string;
178
+ try {
179
+ abs = analysisPathForSha8(sha8);
180
+ } catch (e) {
181
+ return { ok: false, status: 400, error: e instanceof Error ? e.message : 'bad path' };
182
+ }
183
+ try {
184
+ await Bun.write(abs, JSON.stringify(toWrite, null, 2));
185
+ } catch (e) {
186
+ return { ok: false, status: 500, error: e instanceof Error ? e.message : 'write failed' };
187
+ }
188
+ return { ok: true, path: path.posix.join('assets', `${sha8}.footage.json`), analysis: toWrite };
189
+ }
190
+
191
+ async function getEdl(slugParam: string | null | undefined): Promise<Edl | null> {
192
+ const slug = edlSlug(slugParam);
193
+ if (!slug) return null;
194
+ return readSidecar<Edl>(edlPathForSlug(slug), validateEdl);
195
+ }
196
+
197
+ async function saveEdl(
198
+ slugParam: string | null | undefined,
199
+ edl: unknown
200
+ ): Promise<{ ok: true; path: string; edl: Edl } | { ok: false; status: number; error: string }> {
201
+ const slug = edlSlug(slugParam);
202
+ if (!slug) return { ok: false, status: 400, error: 'invalid or missing slug (expected kebab)' };
203
+
204
+ let serialized: string;
205
+ try {
206
+ serialized = JSON.stringify(edl ?? {});
207
+ } catch {
208
+ return { ok: false, status: 400, error: 'body is not serializable JSON' };
209
+ }
210
+ if (Buffer.byteLength(serialized, 'utf8') > FOOTAGE_MAX_BYTES) {
211
+ return { ok: false, status: 413, error: `edl exceeds ${FOOTAGE_MAX_BYTES} bytes` };
212
+ }
213
+
214
+ const check = validateEdl(edl);
215
+ if (!check.ok) {
216
+ return { ok: false, status: 400, error: `invalid Edl: ${check.errors.join('; ')}` };
217
+ }
218
+
219
+ const toWrite: Edl = { ...(edl as Edl), version: EDL_VERSION };
220
+ let abs: string;
221
+ try {
222
+ abs = edlPathForSlug(slug);
223
+ } catch (e) {
224
+ return { ok: false, status: 400, error: e instanceof Error ? e.message : 'bad path' };
225
+ }
226
+ try {
227
+ await Bun.write(abs, JSON.stringify(toWrite, null, 2));
228
+ } catch (e) {
229
+ return { ok: false, status: 500, error: e instanceof Error ? e.message : 'write failed' };
230
+ }
231
+ return { ok: true, path: `${slug}.edl.json`, edl: toWrite };
232
+ }
233
+
234
+ return { analysisPathForSha8, edlPathForSlug, getAnalysis, saveAnalysis, getEdl, saveEdl };
235
+ }
@@ -34,6 +34,16 @@ export function createFsWatch(ctx: Context): FsWatch {
34
34
 
35
35
  function start() {
36
36
  if (watcher) return;
37
+ // Capture/CI opt-out (MAUDE_NO_WATCH=1): a headless render server has no
38
+ // interactive editor to hot-reload, so it never needs the watcher. It's a
39
+ // DEFENSIVE measure for long video exports — an HMR hard-reload broadcast
40
+ // fired mid-capture (from a concurrent write to designRoot, e.g. a peer edit
41
+ // or a runtime-state save NOT in the skip-list below) destroys the Playwright
42
+ // page's execution context. (NB: in the 2026-07-10 cinematic-cut dogfood the
43
+ // ACTUAL cause of the "Execution context was destroyed" crashes turned out to
44
+ // be renderer compositing pressure — too many full-frame mix-blend-mode /
45
+ // filter layers per frame — not the watcher; this guard is belt-and-braces.)
46
+ if (process.env.MAUDE_NO_WATCH === '1') return;
37
47
  try {
38
48
  watcher = watch(ctx.paths.designRoot, { recursive: true }, (_event, filename) => {
39
49
  if (!filename) return;
@@ -14,6 +14,8 @@
14
14
  // echoed, or persisted (it is stripped from the JSON we return on error).
15
15
 
16
16
  import type { Context } from '../context.ts';
17
+ import { parseGitHubRemote } from '../github/endpoints.ts';
18
+ import { createPullRequest, GitHubApiError } from '../github/service.ts';
17
19
  import { getGithubToken } from '../github/token.ts';
18
20
  import {
19
21
  type GitFileStatus,
@@ -284,19 +286,64 @@ export function createGitEndpoints(ctx: Context): GitEndpoints {
284
286
  }
285
287
 
286
288
  async function fold(body: unknown): Promise<GitEndpointResult> {
287
- // Token resolution mirrors push (it publishes the Shared version): body token
288
- // keychain bridge undefined authRequired.
289
+ // Token resolution mirrors push: body token keychain bridge undefined. Needed
290
+ // for the GitHub PR API call; an ssh draft-push authenticates with the user's key.
289
291
  const token = readToken(body) ?? (await getGithubToken()) ?? undefined;
290
292
  const b = (body ?? {}) as { name?: unknown; remote?: unknown };
291
293
  const name = safeGitArg(b.name);
292
294
  if (name === undefined) return bad('Invalid draft name.');
293
295
  if (b.remote != null && safeRemoteArg(b.remote) === undefined) return bad('Invalid remote.');
294
296
  const res = await gitFoldDraft(dir, name, token, { remote: safeRemoteArg(b.remote) });
295
- if (res.ok) return { status: 200, json: { ok: true, shared: res.shared } };
296
- if (res.authRequired)
297
- return { status: 401, json: { ok: false, authRequired: true, error: res.error } };
298
- if (res.conflict) return { status: 409, json: { ok: false, conflict: true, error: res.error } };
299
- return { status: 502, json: { ok: false, error: res.error ?? 'Could not add the draft.' } };
297
+ if (!res.ok) {
298
+ if (res.authRequired)
299
+ return { status: 401, json: { ok: false, authRequired: true, error: res.error } };
300
+ if (res.conflict)
301
+ return { status: 409, json: { ok: false, conflict: true, error: res.error } };
302
+ return { status: 502, json: { ok: false, error: res.error ?? 'Could not add the draft.' } };
303
+ }
304
+ // Local-merge path (no GitHub remote): the draft is already in the Shared version.
305
+ if (!res.prReady) return { status: 200, json: { ok: true, shared: res.shared } };
306
+ // PR path: the draft branch is pushed — open a pull request draft→shared. The merge
307
+ // itself happens on GitHub after review (this is how a protected `main` is added to).
308
+ const gh = res.remoteUrl ? parseGitHubRemote(res.remoteUrl) : null;
309
+ if (!gh)
310
+ return draftPublishedNoPr(
311
+ res.shared,
312
+ res.head,
313
+ 'Draft published. Open a pull request on your Git host to add it to the Shared version.'
314
+ );
315
+ if (!token)
316
+ return draftPublishedNoPr(
317
+ res.shared,
318
+ res.head,
319
+ 'Draft published. Sign in with GitHub in Maude to open the pull request.'
320
+ );
321
+ try {
322
+ const pr = await createPullRequest(token, gh.owner, gh.repo, {
323
+ head: res.head as string,
324
+ base: res.base as string,
325
+ title: `Add draft “${res.head}” to ${res.base}`,
326
+ body: 'Opened from Maude — “Add to Shared version”.',
327
+ });
328
+ return {
329
+ status: 200,
330
+ // Surface the resolved destination repo (F2): the user should SEE which
331
+ // owner/repo their draft was pushed to / PR'd against, not just "the Shared
332
+ // version" — a poisoned `origin` would otherwise silently target another repo.
333
+ json: {
334
+ ok: true,
335
+ shared: res.shared,
336
+ prUrl: pr.html_url,
337
+ prNumber: pr.number,
338
+ repo: `${gh.owner}/${gh.repo}`,
339
+ },
340
+ };
341
+ } catch (e) {
342
+ // The draft IS pushed — a PR-creation failure is a partial success, not a hard
343
+ // fail. Surface the reason and keep the draft so the user can retry / open it.
344
+ const msg = e instanceof GitHubApiError ? e.message : 'Could not open the pull request.';
345
+ return draftPublishedNoPr(res.shared, res.head, msg);
346
+ }
300
347
  }
301
348
 
302
349
  async function fetchRemote(body: unknown): Promise<GitEndpointResult> {
@@ -335,6 +382,20 @@ function readToken(body: unknown): string | null {
335
382
  return typeof t === 'string' && t.length > 0 ? t : null;
336
383
  }
337
384
 
385
+ /** Fold partial-success: the draft branch was pushed but no PR could be opened (no
386
+ * sign-in, a non-GitHub remote, or the PR call failed). `ok:true` because the work IS
387
+ * published; the UI shows `error` as a heads-up and keeps the draft for a retry. */
388
+ function draftPublishedNoPr(
389
+ shared: string | undefined,
390
+ head: string | undefined,
391
+ error: string
392
+ ): GitEndpointResult {
393
+ return {
394
+ status: 200,
395
+ json: { ok: true, shared, published: head, prUnavailable: true, error },
396
+ };
397
+ }
398
+
338
399
  /** DDR-112 — expand each selected path to include any DIRTY same-directory,
339
400
  * same-stem sidecar (`<stem>.meta.json`, `<stem>.annotations.svg`, …). The `.`
340
401
  * delimiter prevents `ui/Pricing` from grabbing `ui/Pricing v3.*`. */