@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,367 @@
1
+ /**
2
+ * @file photo/schema.ts — PhotoEdit sidecar data model (Stage A, Task 1)
3
+ * @scope apps/studio/photo/schema.ts
4
+ * @purpose The single source of truth for the non-destructive photo-edit
5
+ * object persisted next to a source asset as `assets/<sha8>.photo.json`
6
+ * (DDR-115 taxonomy: VERSIONED, alongside the asset it edits).
7
+ *
8
+ * A `PhotoEdit` is *parameters only* — never pixels. The live
9
+ * compositor (`photo/pipeline.ts` → canvas-lib `<PhotoLayer>`) and
10
+ * the headless CLI (`bin/photo-*.sh`) both read this shape and
11
+ * reproduce the identical WebGL render. An absent sidecar, or one
12
+ * that `isDefaultEdit()` reports as neutral, renders as the plain
13
+ * untouched `<img>` / `<image>` with ZERO pixi.js cost.
14
+ *
15
+ * @invariant DEPENDENCY-FREE. This module is imported by BOTH the server
16
+ * (`photo-store.ts`, the `/_api/photo-edit` route) and the client
17
+ * WebGL compositor. It MUST NOT `import 'pixi.js'` or any browser /
18
+ * native lib — the server bundle must never pull in a WebGL runtime.
19
+ * Pure TS types + plain-object helpers + a hand-rolled structural
20
+ * validator (no Ajv) only.
21
+ *
22
+ * Field naming/style mirrors `annotations-model.ts`'s flat explicit
23
+ * per-type interfaces (`ImageStroke`, :245-260).
24
+ */
25
+
26
+ /**
27
+ * Schema version. Bumped when the shape changes incompatibly so a stored
28
+ * sidecar authored by an older Studio can be detected + migrated rather than
29
+ * mis-rendered. (BREAKER's flagged risk: a new versioned data-model type needs
30
+ * an explicit version marker — this is it.)
31
+ */
32
+ export const PHOTO_EDIT_VERSION = 1 as const;
33
+
34
+ /** Pattern-overlay texture families (built via `PIXI.TilingSprite`, Task 4). */
35
+ export type PatternType = 'dots' | 'grid' | 'lines' | 'diagonal' | 'crosshatch';
36
+ export const PATTERN_TYPES: readonly PatternType[] = [
37
+ 'dots',
38
+ 'grid',
39
+ 'lines',
40
+ 'diagonal',
41
+ 'crosshatch',
42
+ ];
43
+
44
+ /** Blend modes the pattern overlay may composite with (pixi v8 BLEND_MODES subset). */
45
+ export type PatternBlend = 'normal' | 'multiply' | 'screen' | 'overlay' | 'soft-light';
46
+ export const PATTERN_BLENDS: readonly PatternBlend[] = [
47
+ 'normal',
48
+ 'multiply',
49
+ 'screen',
50
+ 'overlay',
51
+ 'soft-light',
52
+ ];
53
+
54
+ /** Preset alpha masks (built via `PIXI.Graphics`, Task 4). */
55
+ export type MaskPreset = 'none' | 'vignette' | 'radial-reveal' | 'edge-fade';
56
+ export const MASK_PRESETS: readonly MaskPreset[] = [
57
+ 'none',
58
+ 'vignette',
59
+ 'radial-reveal',
60
+ 'edge-fade',
61
+ ];
62
+
63
+ /**
64
+ * Basic tonal adjustments. Every field is a NORMALIZED delta with a neutral
65
+ * origin so an all-zero object is a no-op (renders identical to the source):
66
+ * - brightness / contrast / saturation / exposure: −1 … +1, 0 = neutral.
67
+ * - hue: −180 … +180 degrees, 0 = neutral.
68
+ * - sepia / grayscale / invert: 0 … 1 amount, 0 = off.
69
+ * `filters.ts` maps these onto `PIXI.ColorMatrixFilter`'s convenience methods.
70
+ */
71
+ export interface PhotoAdjustments {
72
+ brightness?: number;
73
+ contrast?: number;
74
+ saturation?: number;
75
+ exposure?: number;
76
+ hue?: number;
77
+ sepia?: number;
78
+ grayscale?: number;
79
+ invert?: number;
80
+ }
81
+
82
+ /**
83
+ * Duotone remap — luminance → two-color gradient-map lerp. Needs a hand-authored
84
+ * GLSL fragment shader (`DuotoneFilter`, Task 4) because a two-point gradient map
85
+ * is a per-pixel remap that `ColorMatrixFilter`'s linear affine transform cannot
86
+ * express. `colorA`/`colorB` are `#rrggbb` hex (shadow → highlight).
87
+ */
88
+ export interface PhotoDuotone {
89
+ enabled?: boolean;
90
+ colorA?: string;
91
+ colorB?: string;
92
+ /** 0 … 1 — crossfade between original and full duotone. */
93
+ intensity?: number;
94
+ }
95
+
96
+ /** Film grain via `PIXI.NoiseFilter`. `amount` 0…1, `size` a px grain scale ≥ 1. */
97
+ export interface PhotoGrain {
98
+ enabled?: boolean;
99
+ amount?: number;
100
+ size?: number;
101
+ }
102
+
103
+ /** Repeating pattern overlay via `PIXI.TilingSprite` + blend mode. */
104
+ export interface PhotoPattern {
105
+ enabled?: boolean;
106
+ type?: PatternType;
107
+ /** Tile scale multiplier (1 = native). */
108
+ scale?: number;
109
+ /** 0 … 1 overlay opacity. */
110
+ opacity?: number;
111
+ blend?: PatternBlend;
112
+ /** `#rrggbb` ink color the pattern is drawn in (default white). Load-bearing
113
+ * for the blend modes: a WHITE pattern under `multiply` is a no-op (white ×
114
+ * base = base) so the pattern "vanishes" — a dark color makes multiply darken. */
115
+ color?: string;
116
+ }
117
+
118
+ /** Preset alpha mask + its strength (0 … 1). */
119
+ export interface PhotoMask {
120
+ preset?: MaskPreset;
121
+ strength?: number;
122
+ }
123
+
124
+ /**
125
+ * Non-destructive background removal. When `enabled`, `maskAsset` is the
126
+ * content-addressed `assets/<sha8>.png` cutout/alpha-matte produced client-side
127
+ * by `@imgly/background-removal` and uploaded through the existing
128
+ * `POST /_api/asset` route (Task 10). Turning `enabled` off restores the
129
+ * original — the matte asset is retained, never deleted.
130
+ */
131
+ export interface PhotoBackgroundRemoved {
132
+ enabled?: boolean;
133
+ /** `assets/<sha8>.png` — absent until the removal has actually run. */
134
+ maskAsset?: string;
135
+ }
136
+
137
+ /**
138
+ * The full non-destructive edit for one source photo. Every field is optional;
139
+ * an empty object is a valid "unedited" sidecar. `source` is the
140
+ * `assets/<sha8>.<ext>` the edit applies to (redundant with the sidecar's own
141
+ * filename, but self-describing for the CLI + audit).
142
+ */
143
+ export interface PhotoEdit {
144
+ /** Schema version of THIS object (defaults to PHOTO_EDIT_VERSION on write). */
145
+ version?: number;
146
+ /** `assets/<sha8>.<ext>` — the source the edit targets. */
147
+ source?: string;
148
+ adjustments?: PhotoAdjustments;
149
+ duotone?: PhotoDuotone;
150
+ grain?: PhotoGrain;
151
+ pattern?: PhotoPattern;
152
+ mask?: PhotoMask;
153
+ backgroundRemoved?: PhotoBackgroundRemoved;
154
+ }
155
+
156
+ /**
157
+ * Pipeline order is FIXED and load-bearing — filters are not commutative.
158
+ * `filters.ts` MUST build its graph in exactly this sequence. Exported so the
159
+ * compositor + tests share one source of truth.
160
+ */
161
+ export const PHOTO_PIPELINE_ORDER = ['adjustments', 'duotone', 'grain', 'pattern', 'mask'] as const;
162
+
163
+ const EPS = 1e-6;
164
+ const near = (v: number | undefined, origin: number): boolean =>
165
+ v == null || Math.abs(v - origin) < EPS;
166
+
167
+ /** True when the adjustments block has no visible effect. */
168
+ function adjustmentsAreNeutral(a?: PhotoAdjustments): boolean {
169
+ if (!a) return true;
170
+ return (
171
+ near(a.brightness, 0) &&
172
+ near(a.contrast, 0) &&
173
+ near(a.saturation, 0) &&
174
+ near(a.exposure, 0) &&
175
+ near(a.hue, 0) &&
176
+ near(a.sepia, 0) &&
177
+ near(a.grayscale, 0) &&
178
+ near(a.invert, 0)
179
+ );
180
+ }
181
+
182
+ /**
183
+ * True when the edit produces a render identical to the untouched source, so
184
+ * `<PhotoLayer>` can skip mounting pixi.js entirely (the lazy-bundle guarantee).
185
+ * "Neutral" = every section either absent, disabled, or at its no-op value.
186
+ */
187
+ export function isDefaultEdit(edit?: PhotoEdit | null): boolean {
188
+ if (!edit) return true;
189
+ if (!adjustmentsAreNeutral(edit.adjustments)) return false;
190
+ if (edit.duotone?.enabled && (edit.duotone.intensity ?? 1) > EPS) return false;
191
+ if (edit.grain?.enabled && (edit.grain.amount ?? 1) > EPS) return false;
192
+ if (edit.pattern?.enabled && (edit.pattern.opacity ?? 1) > EPS) return false;
193
+ if (edit.mask?.preset && edit.mask.preset !== 'none' && (edit.mask.strength ?? 1) > EPS)
194
+ return false;
195
+ if (edit.backgroundRemoved?.enabled && edit.backgroundRemoved.maskAsset) return false;
196
+ return true;
197
+ }
198
+
199
+ /** A fresh, fully-neutral edit for `source` (used when opening the Photo tab). */
200
+ export function emptyPhotoEdit(source?: string): PhotoEdit {
201
+ return { version: PHOTO_EDIT_VERSION, ...(source ? { source } : {}) };
202
+ }
203
+
204
+ // ── Structural validation (dependency-free — no Ajv) ─────────────────────────
205
+ // The `/_api/photo-edit` route (Task 8) validates untrusted JSON with this
206
+ // before persisting. Reject anything unexpected: unknown top-level keys, wrong
207
+ // types, out-of-range numbers, non-hex colors, non-relative asset paths. This
208
+ // is a security surface (DDR-088) — a crafted field must not round-trip to disk.
209
+
210
+ export interface ValidationResult {
211
+ ok: boolean;
212
+ errors: string[];
213
+ }
214
+
215
+ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
216
+ // Relative content-addressed asset path only — mirrors the annotation-layer
217
+ // `<image>` href allowlist; NEVER a data:/blob:/absolute/traversing path.
218
+ const ASSET_REL_RE = /^assets\/[0-9a-f]{8,}[A-Za-z0-9._-]*$/;
219
+
220
+ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
221
+ typeof v === 'object' && v !== null && !Array.isArray(v);
222
+
223
+ function num(errors: string[], obj: Record<string, unknown>, key: string, lo: number, hi: number) {
224
+ if (!(key in obj) || obj[key] == null) return;
225
+ const v = obj[key];
226
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
227
+ errors.push(`${key}: must be a finite number`);
228
+ return;
229
+ }
230
+ if (v < lo || v > hi) errors.push(`${key}: ${v} out of range [${lo}, ${hi}]`);
231
+ }
232
+
233
+ function bool(errors: string[], obj: Record<string, unknown>, key: string) {
234
+ if (key in obj && obj[key] != null && typeof obj[key] !== 'boolean')
235
+ errors.push(`${key}: must be a boolean`);
236
+ }
237
+
238
+ function assertKeys(
239
+ errors: string[],
240
+ obj: Record<string, unknown>,
241
+ allowed: string[],
242
+ where: string
243
+ ) {
244
+ for (const k of Object.keys(obj))
245
+ if (!allowed.includes(k)) errors.push(`${where}: unknown key "${k}"`);
246
+ }
247
+
248
+ /**
249
+ * Structurally validate an untrusted value as a `PhotoEdit`. Returns collected
250
+ * errors (empty ⇒ valid). Unknown keys, wrong types, out-of-range numbers,
251
+ * malformed colors, and non-relative asset paths all fail.
252
+ */
253
+ export function validatePhotoEdit(input: unknown): ValidationResult {
254
+ const errors: string[] = [];
255
+ if (!isPlainObject(input)) return { ok: false, errors: ['root: must be an object'] };
256
+
257
+ assertKeys(
258
+ errors,
259
+ input,
260
+ [
261
+ 'version',
262
+ 'source',
263
+ 'adjustments',
264
+ 'duotone',
265
+ 'grain',
266
+ 'pattern',
267
+ 'mask',
268
+ 'backgroundRemoved',
269
+ ],
270
+ 'root'
271
+ );
272
+
273
+ if ('version' in input && input.version != null && typeof input.version !== 'number')
274
+ errors.push('version: must be a number');
275
+ if ('source' in input && input.source != null) {
276
+ if (typeof input.source !== 'string' || !ASSET_REL_RE.test(input.source))
277
+ errors.push('source: must be a relative assets/<sha8>.<ext> path');
278
+ }
279
+
280
+ if (input.adjustments != null) {
281
+ const a = input.adjustments;
282
+ if (!isPlainObject(a)) errors.push('adjustments: must be an object');
283
+ else {
284
+ assertKeys(
285
+ errors,
286
+ a,
287
+ ['brightness', 'contrast', 'saturation', 'exposure', 'hue', 'sepia', 'grayscale', 'invert'],
288
+ 'adjustments'
289
+ );
290
+ num(errors, a, 'brightness', -1, 1);
291
+ num(errors, a, 'contrast', -1, 1);
292
+ num(errors, a, 'saturation', -1, 1);
293
+ num(errors, a, 'exposure', -1, 1);
294
+ num(errors, a, 'hue', -180, 180);
295
+ num(errors, a, 'sepia', 0, 1);
296
+ num(errors, a, 'grayscale', 0, 1);
297
+ num(errors, a, 'invert', 0, 1);
298
+ }
299
+ }
300
+
301
+ if (input.duotone != null) {
302
+ const d = input.duotone;
303
+ if (!isPlainObject(d)) errors.push('duotone: must be an object');
304
+ else {
305
+ assertKeys(errors, d, ['enabled', 'colorA', 'colorB', 'intensity'], 'duotone');
306
+ bool(errors, d, 'enabled');
307
+ for (const c of ['colorA', 'colorB'] as const)
308
+ if (c in d && d[c] != null && (typeof d[c] !== 'string' || !HEX_RE.test(d[c] as string)))
309
+ errors.push(`duotone.${c}: must be a #rrggbb hex color`);
310
+ num(errors, d, 'intensity', 0, 1);
311
+ }
312
+ }
313
+
314
+ if (input.grain != null) {
315
+ const g = input.grain;
316
+ if (!isPlainObject(g)) errors.push('grain: must be an object');
317
+ else {
318
+ assertKeys(errors, g, ['enabled', 'amount', 'size'], 'grain');
319
+ bool(errors, g, 'enabled');
320
+ num(errors, g, 'amount', 0, 1);
321
+ num(errors, g, 'size', 1, 32);
322
+ }
323
+ }
324
+
325
+ if (input.pattern != null) {
326
+ const p = input.pattern;
327
+ if (!isPlainObject(p)) errors.push('pattern: must be an object');
328
+ else {
329
+ assertKeys(errors, p, ['enabled', 'type', 'scale', 'opacity', 'blend', 'color'], 'pattern');
330
+ bool(errors, p, 'enabled');
331
+ if ('type' in p && p.type != null && !PATTERN_TYPES.includes(p.type as PatternType))
332
+ errors.push(`pattern.type: must be one of ${PATTERN_TYPES.join(', ')}`);
333
+ if ('blend' in p && p.blend != null && !PATTERN_BLENDS.includes(p.blend as PatternBlend))
334
+ errors.push(`pattern.blend: must be one of ${PATTERN_BLENDS.join(', ')}`);
335
+ if ('color' in p && p.color != null && (typeof p.color !== 'string' || !HEX_RE.test(p.color)))
336
+ errors.push('pattern.color: must be a #rrggbb hex color');
337
+ num(errors, p, 'scale', 0.1, 16);
338
+ num(errors, p, 'opacity', 0, 1);
339
+ }
340
+ }
341
+
342
+ if (input.mask != null) {
343
+ const m = input.mask;
344
+ if (!isPlainObject(m)) errors.push('mask: must be an object');
345
+ else {
346
+ assertKeys(errors, m, ['preset', 'strength'], 'mask');
347
+ if ('preset' in m && m.preset != null && !MASK_PRESETS.includes(m.preset as MaskPreset))
348
+ errors.push(`mask.preset: must be one of ${MASK_PRESETS.join(', ')}`);
349
+ num(errors, m, 'strength', 0, 1);
350
+ }
351
+ }
352
+
353
+ if (input.backgroundRemoved != null) {
354
+ const b = input.backgroundRemoved;
355
+ if (!isPlainObject(b)) errors.push('backgroundRemoved: must be an object');
356
+ else {
357
+ assertKeys(errors, b, ['enabled', 'maskAsset'], 'backgroundRemoved');
358
+ bool(errors, b, 'enabled');
359
+ if ('maskAsset' in b && b.maskAsset != null) {
360
+ if (typeof b.maskAsset !== 'string' || !ASSET_REL_RE.test(b.maskAsset))
361
+ errors.push('backgroundRemoved.maskAsset: must be a relative assets/<sha8>.png path');
362
+ }
363
+ }
364
+ }
365
+
366
+ return { ok: errors.length === 0, errors };
367
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @file photo-store.ts — PhotoEdit sidecar persistence (Stage C, Task 8)
3
+ * @scope apps/studio/photo-store.ts
4
+ * @purpose Read / write the non-destructive `assets/<sha8>.photo.json` sidecar
5
+ * that backs the photo editor. Mirrors `inspect.ts`'s `createX(ctx)`
6
+ * factory + `Bun.write`/`Bun.file` shape. Server-side only — imports
7
+ * the DEPENDENCY-FREE `photo/schema.ts` (never `pixi.js`).
8
+ *
9
+ * @security The `/_api/photo-edit` route is CANVAS-SAFE (reachable from the
10
+ * untrusted canvas iframe — DDR-054), and its write PATH is derived
11
+ * from an attacker-controllable `asset` param. Unlike `/_api/asset`
12
+ * (content-addressed → path not attacker-chosen), this route's path
13
+ * comes from the caller, so the DDR-088 cap stack is load-bearing:
14
+ * 1. strict sha8 extraction/validation (hex only, bounded length) —
15
+ * rejects `..`, `/`, `%2f`, absolute paths by construction;
16
+ * 2. explicit containment assert (belt-and-braces vs a poisoned
17
+ * designRoot — mirrors api.ts saveAssetFromStream);
18
+ * 3. structural `validatePhotoEdit` (unknown keys / bad types /
19
+ * out-of-range numbers / non-hex colors / non-relative asset
20
+ * paths all rejected — no crafted field round-trips to disk);
21
+ * 4. body/file size cap (64 KB — generous for parameters).
22
+ * Threat-table (DDR-088): path-traversal → (1)+(2); stored-XSS via a
23
+ * crafted field → (3), the JSON is only ever re-read by the schema-
24
+ * typed compositor, never eval'd/HTML-injected; oversized-body DoS →
25
+ * (4). No `sameOriginWrite` (would block the legit canvas origin, e.g.
26
+ * the headless bg-remove harness) — `isLoopbackHost` on the route is
27
+ * the DNS-rebinding guard. Residual (accepted, DDR-054 baseline): an
28
+ * already-untrusted canvas can write a WELL-FORMED sidecar for any
29
+ * sha8 — a low-severity integrity nuisance, bounded by (3), same class
30
+ * as its existing ability to write arbitrary images via /_api/asset.
31
+ */
32
+
33
+ import path from 'node:path';
34
+
35
+ import type { Context } from './context.ts';
36
+ import { PHOTO_EDIT_VERSION, type PhotoEdit, validatePhotoEdit } from './photo/schema.ts';
37
+
38
+ /** Max bytes for a sidecar (read + write). Parameters are tiny; this is slack. */
39
+ export const PHOTO_EDIT_MAX_BYTES = 64 * 1024;
40
+
41
+ const SHA_RE = /^[0-9a-f]{8,64}$/;
42
+
43
+ /**
44
+ * Extract + validate the content-address sha8 from an `asset` param. Accepts the
45
+ * source in any of the forms a caller might hold — `assets/<sha8>.<ext>`,
46
+ * `/assets/<sha8>.png`, `<sha8>.png`, or bare `<sha8>` — and returns the bare,
47
+ * validated sha8, or null if it isn't clean hex. The hex regex is the primary
48
+ * traversal defense: `..`, `/`, `%2f`, backslashes, and absolute paths can never
49
+ * match, so no path segment survives extraction.
50
+ */
51
+ export function assetSha8(param: string | null | undefined): string | null {
52
+ if (typeof param !== 'string') return null;
53
+ let s = param.trim();
54
+ if (!s) return null;
55
+ s = s.replace(/^\/+/, ''); // drop leading slashes
56
+ s = s.replace(/^assets\//, ''); // drop the assets/ prefix
57
+ s = s.replace(/\.[A-Za-z0-9]+$/, ''); // drop a single trailing extension
58
+ return SHA_RE.test(s) ? s : null;
59
+ }
60
+
61
+ export interface PhotoStore {
62
+ /** Absolute path of the sidecar for a validated sha8 (throws if it escapes). */
63
+ editPathForSha8(sha8: string): string;
64
+ /** Read the sidecar → PhotoEdit, or null if absent / unreadable / oversized. */
65
+ getPhotoEdit(assetParam: string | null | undefined): Promise<PhotoEdit | null>;
66
+ /** Validate + persist. Returns a discriminated result (never throws on bad input). */
67
+ savePhotoEdit(
68
+ assetParam: string | null | undefined,
69
+ edit: unknown
70
+ ): Promise<
71
+ { ok: true; path: string; edit: PhotoEdit } | { ok: false; status: number; error: string }
72
+ >;
73
+ }
74
+
75
+ export function createPhotoStore(ctx: Context): PhotoStore {
76
+ const assetsDir = path.join(ctx.paths.designRoot, 'assets');
77
+
78
+ function editPathForSha8(sha8: string): string {
79
+ if (!SHA_RE.test(sha8)) throw new Error(`invalid sha8: ${sha8}`);
80
+ const abs = path.join(assetsDir, `${sha8}.photo.json`);
81
+ // Belt-and-braces containment (defense in depth vs a poisoned designRoot —
82
+ // sha8 is already validated hex, so this can only fail pathologically).
83
+ const rel = path.relative(assetsDir, abs);
84
+ if (rel !== `${sha8}.photo.json` || rel.startsWith('..') || path.isAbsolute(rel)) {
85
+ throw new Error(`photo-edit path escaped assets/: ${abs}`);
86
+ }
87
+ return abs;
88
+ }
89
+
90
+ async function getPhotoEdit(assetParam: string | null | undefined): Promise<PhotoEdit | null> {
91
+ const sha8 = assetSha8(assetParam);
92
+ if (!sha8) return null;
93
+ try {
94
+ const file = Bun.file(editPathForSha8(sha8));
95
+ if (!(await file.exists())) return null;
96
+ if (file.size > PHOTO_EDIT_MAX_BYTES) return null; // corrupt / oversized — ignore
97
+ const raw = await file.text();
98
+ const parsed = JSON.parse(raw);
99
+ // A stored sidecar is trusted-ish (we wrote it validated), but re-validate
100
+ // so a hand-edited / corrupt file never reaches the compositor malformed.
101
+ return validatePhotoEdit(parsed).ok ? (parsed as PhotoEdit) : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ async function savePhotoEdit(
108
+ assetParam: string | null | undefined,
109
+ edit: unknown
110
+ ): Promise<
111
+ { ok: true; path: string; edit: PhotoEdit } | { ok: false; status: number; error: string }
112
+ > {
113
+ const sha8 = assetSha8(assetParam);
114
+ if (!sha8) return { ok: false, status: 400, error: 'invalid or missing asset (expected sha8)' };
115
+
116
+ // Size cap BEFORE validation (a giant blob shouldn't be walked field-by-field).
117
+ let serialized: string;
118
+ try {
119
+ serialized = JSON.stringify(edit ?? {});
120
+ } catch {
121
+ return { ok: false, status: 400, error: 'body is not serializable JSON' };
122
+ }
123
+ if (Buffer.byteLength(serialized, 'utf8') > PHOTO_EDIT_MAX_BYTES) {
124
+ return { ok: false, status: 413, error: `photo edit exceeds ${PHOTO_EDIT_MAX_BYTES} bytes` };
125
+ }
126
+
127
+ const check = validatePhotoEdit(edit);
128
+ if (!check.ok) {
129
+ return { ok: false, status: 400, error: `invalid PhotoEdit: ${check.errors.join('; ')}` };
130
+ }
131
+
132
+ // Stamp the schema version + canonical source on write (never trust the
133
+ // client's version claim; the sidecar's own filename is the source of truth).
134
+ const toWrite: PhotoEdit = {
135
+ ...(edit as PhotoEdit),
136
+ version: PHOTO_EDIT_VERSION,
137
+ };
138
+ let abs: string;
139
+ try {
140
+ abs = editPathForSha8(sha8);
141
+ } catch (e) {
142
+ return { ok: false, status: 400, error: e instanceof Error ? e.message : 'bad path' };
143
+ }
144
+ try {
145
+ // Bun.write creates the assets/ dir if absent.
146
+ await Bun.write(abs, JSON.stringify(toWrite, null, 2));
147
+ } catch (e) {
148
+ return { ok: false, status: 500, error: e instanceof Error ? e.message : 'write failed' };
149
+ }
150
+ return { ok: true, path: path.posix.join('assets', `${sha8}.photo.json`), edit: toWrite };
151
+ }
152
+
153
+ return { editPathForSha8, getPhotoEdit, savePhotoEdit };
154
+ }
@@ -47,12 +47,26 @@ export const RUNTIME_PACKAGES = [
47
47
  'react-dom/client',
48
48
  'react/jsx-runtime',
49
49
  'react/jsx-dev-runtime',
50
- // Phase 4 — Pixi.js v8 available as a per-iframe runtime bundle. Lazy-built
51
- // on first request; canvases that don't `import 'pixi.js'` never pay the
52
- // ~500 KB bundle cost. Reserved for future snapshot-to-texture rendering
53
- // (DDR-024 deferred path) and any high-end designer-tool overlays a canvas
54
- // wants to draw via WebGL.
50
+ // Pixi.js v8 per-iframe runtime bundle for the non-destructive photo editor
51
+ // (feature-photo-editor). The canvas-lib `<PhotoLayer>` reaches the WebGL
52
+ // compositor (`photo/pipeline.ts`) through a LAZY runtime `import('pixi.js')`,
53
+ // so a canvas with no edited photo never pays the ~500 KB bundle cost (the
54
+ // lazy-bundle guarantee regression-tested in test/photo-canvas-bundle.test.ts).
55
+ // Pre-built into dist/runtime/pixi-js.js + floored in .min-sizes.json.
56
+ // (Originally parked by DDR-024 for snapshot-to-texture rendering; the photo
57
+ // editor is the first real activation — see the photo-editor architecture DDR.)
55
58
  'pixi.js',
59
+ // @imgly/background-removal (feature-photo-editor) — client-side ML background
60
+ // removal (WASM/WebGPU via onnxruntime-web, ZERO native deps; the Node/native
61
+ // `-node` variant is NEVER imported — bun-compile-hostile, DDR-070 sharp-class
62
+ // exclusion). Externalised for the same lazy per-iframe bundling as pixi: the
63
+ // bg-remove harness (PhotoBgRemoveHarness) reaches it through a runtime
64
+ // `import('@imgly/background-removal')`, so a canvas that never removes a
65
+ // background pays zero cost. Model weights (~11–44 MB isnet) are fetched at
66
+ // first use (IMG.LY CDN by default; `publicPath` self-hosts for air-gapped —
67
+ // a documented follow-up, see the photo-editor DDR). Pre-built into
68
+ // dist/runtime + floored in .min-sizes.json.
69
+ '@imgly/background-removal',
56
70
  // Phase 3.7 / DDR-049 — Motion One (motion/react) is the canonical motion
57
71
  // library for the canvas-lib + handoff pipeline. Externalised here so the
58
72
  // canvas-lib motion helpers (<MotionDemo>, etc.) resolve through the same
@@ -220,7 +234,16 @@ export async function getRuntimeBundle(
220
234
  // The `as any` cast tolerates names like `__INTERNAL_DO_NOT_USE_OR_WARN`
221
235
  // that aren't declared in the package's .d.ts; the destructure still
222
236
  // succeeds at runtime, which is what matters.
223
- const entryContent = `import * as __mod__ from ${JSON.stringify(pkg)};\n${
237
+ // pixi.js compiles shaders with `new Function()` which the split-origin
238
+ // canvas iframe's strict CSP (DDR-054) forbids (no `unsafe-eval`), so the
239
+ // photo compositor threw "Current environment does not allow unsafe-eval" and
240
+ // the live preview silently failed in the DEFAULT mode (only same-origin, the
241
+ // opt-out, had no CSP to trip). pixi ships `pixi.js/unsafe-eval` — a
242
+ // side-effect module that swaps the eval-based shader/uniform sync for
243
+ // eval-free polyfills. Baking it into the runtime bundle here makes EVERY
244
+ // canvas's `import('pixi.js')` CSP-safe transparently (feature-photo-editor).
245
+ const sideEffects = pkg === 'pixi.js' ? `import "pixi.js/unsafe-eval";\n` : '';
246
+ const entryContent = `${sideEffects}import * as __mod__ from ${JSON.stringify(pkg)};\n${
224
247
  exportNames.length > 0
225
248
  ? `const {\n${namedLines}\n} = __mod__ as any;\n` + `export {\n${namedLines}\n};\n`
226
249
  : ''
@@ -242,12 +242,16 @@ function startCanvasServer(port: number): BunServer {
242
242
  '/_api/git-user': http.routes['/_api/git-user'],
243
243
  '/_api/canvas-meta': http.routes['/_api/canvas-meta'],
244
244
  '/_api/annotations': http.routes['/_api/annotations'],
245
- // Phase 23 — capped binary image upload (magic-byte sniff + 10 MB +
245
+ // Phase 23 — capped binary image upload (magic-byte sniff + category cap +
246
246
  // content-addressed name + traversal guard + no-SVG, in api.saveAsset).
247
247
  // Bun matches `routes` BEFORE `fetch`, so the route must be listed here
248
248
  // explicitly — the CANVAS_SAFE_API entry alone only opens the fetch
249
249
  // fall-through (which serves files, not route handlers). See DDR (Task 9).
250
250
  '/_api/asset': http.routes['/_api/asset'],
251
+ // feature-photo-editor — PhotoEdit sidecar GET/PUT. MUST be here AND in
252
+ // CANVAS_SAFE_API (http.ts): Bun matches `routes` before `fetch`, so a
253
+ // one-list entry 404s from the canvas iframe (the DDR-088 rollout bug).
254
+ '/_api/photo-edit': http.routes['/_api/photo-edit'],
251
255
  '/_api/git-committers': http.routes['/_api/git-committers'],
252
256
  '/_api/ai': http.routes['/_api/ai'],
253
257
  '/_comments': http.routes['/_comments'],