@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,607 @@
1
+ /**
2
+ * @file photo/pipeline.ts — browser-only pixi.js REALIZER (Stage B, Task 5)
3
+ * @scope apps/studio/photo/pipeline.ts
4
+ * @purpose Turn the pure `planPhotoPipeline()` steps into a live WebGL render.
5
+ * Owns the one-`Application`-per-photo lifecycle, texture load, the
6
+ * realization of each step into real pixi objects (ColorMatrixFilter /
7
+ * the custom duotone Filter / NoiseFilter / TilingSprite overlay /
8
+ * alpha mask), and a single static render (no ticker — reduced-motion
9
+ * safe, Task 6).
10
+ *
11
+ * @browser This is the ONLY photo/* module that imports `pixi.js`. It is
12
+ * imported exclusively by the canvas-lib `<PhotoLayer>` (client), so
13
+ * pixi resolves through the per-iframe runtime bundle (importmap →
14
+ * /_canvas-runtime/pixi.js.js). It MUST NOT be imported by any server
15
+ * module — the server bundle must never pull in a WebGL lib
16
+ * (schema.ts / filters.ts stay pixi-free for that reason).
17
+ *
18
+ * @testing pixi filters build a GlProgram in their constructor → touch
19
+ * `document`, so the render path can't run in headless `bun test`.
20
+ * The PURE decision logic (`adjustmentCalls`, `resolveSourceUrl`) is
21
+ * exported and unit-tested headlessly; the actual render is verified
22
+ * via the `maude design screenshot` proof path (Task 6) + desktop
23
+ * dogfooding (Task 25). Importing this module does ZERO pixi
24
+ * construction at load time, so the pure tests are safe.
25
+ */
26
+
27
+ // pixi.js is loaded via a RUNTIME dynamic import (see `loadPixi()`), NEVER a
28
+ // static import. This is load-bearing for the lazy-bundle guarantee: the canvas
29
+ // bundler runs with `splitting: false`, so a static `import 'pixi.js'` here would
30
+ // be HOISTED to eager even when <PhotoLayer> only dynamic-imports this module —
31
+ // making every canvas fetch the ~500 KB pixi runtime. Empirically verified
32
+ // (test/photo-canvas-bundle.test.ts). Types are `import type` (fully erased).
33
+ import type { Application, Filter } from 'pixi.js';
34
+
35
+ /** The pixi.js module, loaded once and threaded into the realizers. */
36
+ type Pixi = typeof import('pixi.js');
37
+ let pixiModule: Pixi | null = null;
38
+ async function loadPixi(): Promise<Pixi> {
39
+ if (!pixiModule) pixiModule = await import('pixi.js');
40
+ return pixiModule;
41
+ }
42
+
43
+ import type { AdjustmentsStep, GrainStep, MaskStep, PatternStep } from './filters.ts';
44
+ import {
45
+ DUOTONE_FRAG_SOURCE,
46
+ DUOTONE_VERT_SOURCE,
47
+ hexToRgb01,
48
+ planPhotoPipeline,
49
+ } from './filters.ts';
50
+ import type { PatternType } from './schema.ts';
51
+ import { isDefaultEdit, type PhotoEdit } from './schema.ts';
52
+
53
+ // ── Pure decision logic (headless-testable — NO pixi construction) ───────────
54
+
55
+ /** A single pixi `ColorMatrixFilter` method call. `isolateAlpha` (set for sepia
56
+ * / invert, which pixi exposes only as toggles) means "apply this on its OWN
57
+ * filter blended at this alpha" so the 0…1 amount is honored. */
58
+ export interface CmfCall {
59
+ method: 'brightness' | 'contrast' | 'saturate' | 'hue' | 'grayscale' | 'sepia' | 'negative';
60
+ args: number[];
61
+ isolateAlpha?: number;
62
+ }
63
+
64
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
65
+
66
+ /**
67
+ * Map the planner's normalized adjustment ops onto concrete pixi
68
+ * ColorMatrixFilter calls, preserving sub-order. Pure — returns a description,
69
+ * constructs nothing. This is the load-bearing normalized→pixi mapping, so it's
70
+ * unit-tested directly.
71
+ *
72
+ * Neutral references (pixi v8 semantics):
73
+ * brightness(b): b=1 neutral, 0=black, 2=2× — map delta d → 1+d.
74
+ * exposure: no pixi method; photographic — map d → brightness(2^d).
75
+ * contrast(a): a∈0..1, 0.5=normal — map d → 0.5 + d*0.5.
76
+ * saturate(a): a∈−1..1, 0 neutral — pass through.
77
+ * hue(deg): degrees — pass through.
78
+ * grayscale(s): s∈0..1 intensity — pass through.
79
+ * sepia()/negative(): toggles — isolate + alpha=amount.
80
+ */
81
+ export function adjustmentCalls(step: AdjustmentsStep): CmfCall[] {
82
+ const calls: CmfCall[] = [];
83
+ for (const { op, value } of step.ops) {
84
+ switch (op) {
85
+ case 'brightness':
86
+ calls.push({ method: 'brightness', args: [1 + value] });
87
+ break;
88
+ case 'exposure':
89
+ calls.push({ method: 'brightness', args: [2 ** value] });
90
+ break;
91
+ case 'contrast':
92
+ calls.push({ method: 'contrast', args: [clamp01(0.5 + value * 0.5)] });
93
+ break;
94
+ case 'saturation':
95
+ calls.push({ method: 'saturate', args: [value] });
96
+ break;
97
+ case 'hue':
98
+ calls.push({ method: 'hue', args: [value] });
99
+ break;
100
+ case 'grayscale':
101
+ calls.push({ method: 'grayscale', args: [clamp01(value)] });
102
+ break;
103
+ case 'sepia':
104
+ calls.push({ method: 'sepia', args: [], isolateAlpha: clamp01(value) });
105
+ break;
106
+ case 'invert':
107
+ calls.push({ method: 'negative', args: [], isolateAlpha: clamp01(value) });
108
+ break;
109
+ }
110
+ }
111
+ return calls;
112
+ }
113
+
114
+ /**
115
+ * Which URL the compositor should actually texture. When background removal is
116
+ * active, the content-addressed cutout matte REPLACES the original (the matte is
117
+ * the subject on transparency); otherwise the original source. Pure. Both inputs
118
+ * are already-validated relative `assets/…` paths (schema.ts).
119
+ */
120
+ export function resolveSourceUrl(edit: PhotoEdit | null | undefined, source: string): string {
121
+ if (edit?.backgroundRemoved?.enabled && edit.backgroundRemoved.maskAsset)
122
+ return edit.backgroundRemoved.maskAsset;
123
+ return source;
124
+ }
125
+
126
+ // ── pixi realization (browser-only — every constructor is threaded the loaded
127
+ // pixi module `pixi`, never a static import; see `loadPixi()` above) ─────────
128
+
129
+ function realizeAdjustments(pixi: Pixi, step: AdjustmentsStep): Filter[] {
130
+ const out: Filter[] = [];
131
+ let current: InstanceType<Pixi['ColorMatrixFilter']> | null = null;
132
+ const flush = () => {
133
+ if (current) {
134
+ out.push(current);
135
+ current = null;
136
+ }
137
+ };
138
+ for (const call of adjustmentCalls(step)) {
139
+ if (call.isolateAlpha != null) {
140
+ flush();
141
+ const cmf = new pixi.ColorMatrixFilter();
142
+ // biome-ignore lint/suspicious/noExplicitAny: pixi method dispatch by name
143
+ (cmf as any)[call.method](...call.args, false);
144
+ cmf.alpha = call.isolateAlpha;
145
+ out.push(cmf);
146
+ } else {
147
+ if (!current) current = new pixi.ColorMatrixFilter();
148
+ // biome-ignore lint/suspicious/noExplicitAny: pixi method dispatch by name
149
+ (current as any)[call.method](...call.args, true);
150
+ }
151
+ }
152
+ flush();
153
+ return out;
154
+ }
155
+
156
+ let duotoneProgram: InstanceType<Pixi['GlProgram']> | null = null;
157
+ function realizeDuotone(pixi: Pixi, colorA: string, colorB: string, intensity: number): Filter {
158
+ if (!duotoneProgram)
159
+ duotoneProgram = pixi.GlProgram.from({
160
+ vertex: DUOTONE_VERT_SOURCE,
161
+ fragment: DUOTONE_FRAG_SOURCE,
162
+ });
163
+ return new pixi.Filter({
164
+ glProgram: duotoneProgram,
165
+ resources: {
166
+ duotoneUniforms: {
167
+ uColorA: { value: hexToRgb01(colorA), type: 'vec3<f32>' },
168
+ uColorB: { value: hexToRgb01(colorB), type: 'vec3<f32>' },
169
+ uIntensity: { value: clamp01(intensity), type: 'f32' },
170
+ },
171
+ },
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Realize the FILTER portion of the pipeline (adjustments → duotone → grain).
177
+ * Pattern + mask are display-object overlays realized against the sprite box by
178
+ * the renderer, so they're returned separately. Honors the plan's
179
+ * `buildFilterGraph` name for the filter list. Takes the loaded pixi module.
180
+ */
181
+ export function buildFilterGraph(
182
+ pixi: Pixi,
183
+ edit: PhotoEdit
184
+ ): {
185
+ filters: Filter[];
186
+ grain: GrainStep | null;
187
+ pattern: PatternStep | null;
188
+ mask: MaskStep | null;
189
+ } {
190
+ const filters: Filter[] = [];
191
+ let grain: GrainStep | null = null;
192
+ let pattern: PatternStep | null = null;
193
+ let mask: MaskStep | null = null;
194
+ for (const step of planPhotoPipeline(edit)) {
195
+ switch (step.stage) {
196
+ case 'adjustments':
197
+ filters.push(...realizeAdjustments(pixi, step));
198
+ break;
199
+ case 'duotone':
200
+ filters.push(realizeDuotone(pixi, step.colorA, step.colorB, step.intensity));
201
+ break;
202
+ // Grain is realized as a monochrome-noise OVERLAY (below), not pixi's
203
+ // `NoiseFilter` — the filter is per-pixel with NO grain-SIZE concept, so the
204
+ // `size` knob did nothing. A noise texture drawn at 1/size resolution and
205
+ // nearest-scaled up gives real, size-controllable film grain.
206
+ case 'grain':
207
+ grain = step;
208
+ break;
209
+ case 'pattern':
210
+ pattern = step;
211
+ break;
212
+ case 'mask':
213
+ mask = step;
214
+ break;
215
+ }
216
+ }
217
+ return { filters, grain, pattern, mask };
218
+ }
219
+
220
+ // ── Procedural pattern + mask textures (2D-canvas — reliable, no pixi Graphics) ─
221
+
222
+ function drawPatternTile(type: PatternType, scale: number, color: string): HTMLCanvasElement {
223
+ const size = Math.max(4, Math.round(16 * scale));
224
+ const c = document.createElement('canvas');
225
+ c.width = size;
226
+ c.height = size;
227
+ const ctx = c.getContext('2d');
228
+ if (!ctx) return c;
229
+ ctx.strokeStyle = color;
230
+ ctx.fillStyle = color;
231
+ ctx.lineWidth = Math.max(1, size / 16);
232
+ const half = size / 2;
233
+ switch (type) {
234
+ case 'dots':
235
+ ctx.beginPath();
236
+ ctx.arc(half, half, Math.max(1, size / 6), 0, Math.PI * 2);
237
+ ctx.fill();
238
+ break;
239
+ case 'grid':
240
+ ctx.strokeRect(0.5, 0.5, size - 1, size - 1);
241
+ break;
242
+ case 'lines':
243
+ ctx.beginPath();
244
+ ctx.moveTo(0, half);
245
+ ctx.lineTo(size, half);
246
+ ctx.stroke();
247
+ break;
248
+ case 'diagonal':
249
+ ctx.beginPath();
250
+ ctx.moveTo(0, size);
251
+ ctx.lineTo(size, 0);
252
+ ctx.stroke();
253
+ break;
254
+ case 'crosshatch':
255
+ ctx.beginPath();
256
+ ctx.moveTo(0, size);
257
+ ctx.lineTo(size, 0);
258
+ ctx.moveTo(0, 0);
259
+ ctx.lineTo(size, size);
260
+ ctx.stroke();
261
+ break;
262
+ }
263
+ return c;
264
+ }
265
+
266
+ /** Radial (vignette / radial-reveal) or linear (edge-fade) alpha mask texture. */
267
+ function drawMaskCanvas(width: number, height: number, step: MaskStep): HTMLCanvasElement {
268
+ const c = document.createElement('canvas');
269
+ c.width = Math.max(1, Math.round(width));
270
+ c.height = Math.max(1, Math.round(height));
271
+ const ctx = c.getContext('2d');
272
+ if (!ctx) return c;
273
+ const s = clamp01(step.strength);
274
+ const cx = c.width / 2;
275
+ const cy = c.height / 2;
276
+ const r = Math.hypot(cx, cy);
277
+ if (step.preset === 'edge-fade') {
278
+ const inset = (0.5 * s * Math.min(c.width, c.height)) / 2;
279
+ const g = ctx.createLinearGradient(0, 0, 0, c.height);
280
+ g.addColorStop(0, 'rgba(255,255,255,0)');
281
+ g.addColorStop(clamp01(inset / c.height), 'rgba(255,255,255,1)');
282
+ g.addColorStop(clamp01(1 - inset / c.height), 'rgba(255,255,255,1)');
283
+ g.addColorStop(1, 'rgba(255,255,255,0)');
284
+ ctx.fillStyle = g;
285
+ ctx.fillRect(0, 0, c.width, c.height);
286
+ return c;
287
+ }
288
+ // vignette (dark edges → visible center) and radial-reveal (visible center → transparent edge)
289
+ const inner = step.preset === 'radial-reveal' ? r * (1 - s) * 0.4 : r * (1 - s);
290
+ const g = ctx.createRadialGradient(cx, cy, Math.max(0, inner), cx, cy, r);
291
+ g.addColorStop(0, 'rgba(255,255,255,1)');
292
+ g.addColorStop(1, 'rgba(255,255,255,0)');
293
+ ctx.fillStyle = g;
294
+ ctx.fillRect(0, 0, c.width, c.height);
295
+ return c;
296
+ }
297
+
298
+ /**
299
+ * Monochrome film-grain tile drawn at 1/size resolution — nearest-scaled up to
300
+ * the sprite it gives `size`-px grain cells (size=1 → per-pixel, size=8 → coarse).
301
+ * Centered on mid-gray so a `soft-light`/`overlay` composite adds symmetric
302
+ * light+dark speckle instead of only darkening.
303
+ */
304
+ function drawGrainCanvas(width: number, height: number, size: number): HTMLCanvasElement {
305
+ const cell = Math.max(1, Math.round(size));
306
+ const w = Math.max(1, Math.ceil(width / cell));
307
+ const h = Math.max(1, Math.ceil(height / cell));
308
+ const c = document.createElement('canvas');
309
+ c.width = w;
310
+ c.height = h;
311
+ const ctx = c.getContext('2d');
312
+ if (!ctx) return c;
313
+ const img = ctx.createImageData(w, h);
314
+ for (let i = 0; i < img.data.length; i += 4) {
315
+ // Mid-gray ± spread — symmetric grain under soft-light.
316
+ const v = (128 + (Math.random() * 2 - 1) * 127) | 0;
317
+ img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
318
+ img.data[i + 3] = 255;
319
+ }
320
+ ctx.putImageData(img, 0, 0);
321
+ return c;
322
+ }
323
+
324
+ /**
325
+ * Vignette = a DARKENING overlay (transparent center → black edges), NOT an alpha
326
+ * mask. The earlier "vignette" fed the alpha mask, which CLIPPED the edges to
327
+ * transparency (they vanished) instead of darkening them. `strength` grows both
328
+ * the dark ring's reach and its opacity.
329
+ */
330
+ function drawVignetteCanvas(width: number, height: number, strength: number): HTMLCanvasElement {
331
+ const c = document.createElement('canvas');
332
+ c.width = Math.max(1, Math.round(width));
333
+ c.height = Math.max(1, Math.round(height));
334
+ const ctx = c.getContext('2d');
335
+ if (!ctx) return c;
336
+ const s = clamp01(strength);
337
+ const cx = c.width / 2;
338
+ const cy = c.height / 2;
339
+ const r = Math.hypot(cx, cy);
340
+ const g = ctx.createRadialGradient(cx, cy, r * (1 - s) * 0.5, cx, cy, r);
341
+ g.addColorStop(0, 'rgba(0,0,0,0)');
342
+ g.addColorStop(1, `rgba(0,0,0,${(0.35 + 0.6 * s).toFixed(3)})`);
343
+ ctx.fillStyle = g;
344
+ ctx.fillRect(0, 0, c.width, c.height);
345
+ return c;
346
+ }
347
+
348
+ // pixi v8 blend-mode strings map 1:1 from our PatternBlend union.
349
+ const BLEND_MAP: Record<string, string> = {
350
+ normal: 'normal',
351
+ multiply: 'multiply',
352
+ screen: 'screen',
353
+ overlay: 'overlay',
354
+ 'soft-light': 'soft-light',
355
+ };
356
+
357
+ export interface PhotoRendererOptions {
358
+ canvas: HTMLCanvasElement;
359
+ source: string;
360
+ edit: PhotoEdit;
361
+ width: number;
362
+ height: number;
363
+ /** Resolve a relative `assets/…` path to a fetchable URL (canvas-lib supplies). */
364
+ resolveUrl?: (rel: string) => string;
365
+ /** Backing-buffer multiplier. Defaults to `devicePixelRatio` — right for the
366
+ * live on-canvas `<PhotoLayer>` authoring path, where `width`/`height` are a
367
+ * small CSS box that needs DPR upscaling to look sharp. `renderPhotoDataUrl`
368
+ * (below) passes `1` — it already renders at the source's NATIVE resolution,
369
+ * so an extra DPR multiplier would only upscale further with no real detail
370
+ * gained, while risking the GPU max-texture-size ceiling on large photos. */
371
+ resolution?: number;
372
+ }
373
+
374
+ /**
375
+ * One pixi Application per edited photo (never a shared global renderer — pixi
376
+ * v8 guidance; avoids cross-photo state bleed). Static render only.
377
+ */
378
+ export class PhotoRenderer {
379
+ private app: Application | null = null;
380
+ private pixi: Pixi | null = null;
381
+ private opts: PhotoRendererOptions;
382
+ private destroyed = false;
383
+
384
+ private constructor(opts: PhotoRendererOptions) {
385
+ this.opts = opts;
386
+ }
387
+
388
+ static async create(opts: PhotoRendererOptions): Promise<PhotoRenderer> {
389
+ const r = new PhotoRenderer(opts);
390
+ await r.init();
391
+ return r;
392
+ }
393
+
394
+ private resolve(rel: string): string {
395
+ return this.opts.resolveUrl ? this.opts.resolveUrl(rel) : rel;
396
+ }
397
+
398
+ private async init(): Promise<void> {
399
+ const pixi = await loadPixi(); // the ONLY pixi fetch — lazy, deferred to here.
400
+ if (this.destroyed) return;
401
+ this.pixi = pixi;
402
+ const { canvas, width, height } = this.opts;
403
+ // Backing-buffer resolution must be DPR-aware — `width`/`height` are the
404
+ // logical (CSS) box size. Without `resolution`, pixi rasterizes 1 device
405
+ // px per CSS px, so on any HiDPI/Retina display the composite is visibly
406
+ // softer/lower-res than the plain `<img>` it replaces (which the browser
407
+ // scales DPR-aware natively). `autoDensity: true` keeps the canvas's CSS
408
+ // size at the logical width/height while the backing store scales up.
409
+ const dpr =
410
+ this.opts.resolution ??
411
+ (typeof window !== 'undefined' ? Math.max(1, window.devicePixelRatio || 1) : 1);
412
+ const app = new pixi.Application();
413
+ await app.init({
414
+ canvas,
415
+ width: Math.max(1, Math.round(width)),
416
+ height: Math.max(1, Math.round(height)),
417
+ resolution: dpr,
418
+ autoDensity: true,
419
+ backgroundAlpha: 0,
420
+ antialias: true,
421
+ preference: 'webgl', // WKWebView WebGPU is partial (Task 25); pin WebGL.
422
+ autoStart: false, // static render — no per-frame ticker (reduced-motion safe).
423
+ });
424
+ if (this.destroyed) {
425
+ app.destroy(true);
426
+ return;
427
+ }
428
+ this.app = app;
429
+ await this.render();
430
+ }
431
+
432
+ /** Re-composite with a new edit (live preview / knob scrub). */
433
+ async update(edit: PhotoEdit): Promise<void> {
434
+ this.opts.edit = edit;
435
+ if (this.app && !this.destroyed) await this.render();
436
+ }
437
+
438
+ private async render(): Promise<void> {
439
+ const app = this.app;
440
+ const pixi = this.pixi;
441
+ if (!app || !pixi || this.destroyed) return;
442
+ app.stage.removeChildren();
443
+
444
+ const { edit, source, width, height } = this.opts;
445
+ const srcUrl = this.resolve(resolveSourceUrl(edit, source));
446
+ // Decode the source on the MAIN thread via an <img> element. pixi's
447
+ // `Assets.load` decodes off-thread in a Web Worker (+ createImageBitmap),
448
+ // which the split-origin canvas CSP (DDR-054) silently blocks — `worker-src`
449
+ // falls back to `default-src 'none'`, so the worker never spawns, the texture
450
+ // never arrives, and the sprite renders blank (the live preview "does
451
+ // nothing", with no console error). An <img> respects `img-src` and needs no
452
+ // worker; the asset is same-origin to the canvas iframe, so the WebGL upload
453
+ // isn't tainted. (feature-photo-editor.)
454
+ let texture: InstanceType<Pixi['Texture']>;
455
+ try {
456
+ const img = new Image();
457
+ img.decoding = 'async';
458
+ img.src = srcUrl;
459
+ if (typeof img.decode === 'function') await img.decode();
460
+ else
461
+ await new Promise<void>((res, rej) => {
462
+ img.onload = () => res();
463
+ img.onerror = () => rej(new Error(`image load failed: ${srcUrl}`));
464
+ });
465
+ texture = pixi.Texture.from(img);
466
+ } catch {
467
+ texture = pixi.Texture.from(srcUrl);
468
+ }
469
+ if (this.destroyed || this.app !== app) return;
470
+
471
+ const sprite = new pixi.Sprite(texture);
472
+ sprite.width = Math.max(1, Math.round(width));
473
+ sprite.height = Math.max(1, Math.round(height));
474
+
475
+ const { filters, grain, pattern, mask } = buildFilterGraph(pixi, edit);
476
+ if (filters.length) sprite.filters = filters;
477
+ app.stage.addChild(sprite);
478
+
479
+ // Grain — a nearest-scaled mid-gray noise overlay (soft-light) so `size`
480
+ // actually changes the grain cell (pixi's NoiseFilter had no size concept).
481
+ if (grain) {
482
+ const gTex = pixi.Texture.from(drawGrainCanvas(sprite.width, sprite.height, grain.size));
483
+ // biome-ignore lint/suspicious/noExplicitAny: pixi v8 scaleMode is a string union
484
+ if (gTex.source) (gTex.source as any).scaleMode = 'nearest';
485
+ const gSprite = new pixi.Sprite(gTex);
486
+ gSprite.width = sprite.width;
487
+ gSprite.height = sprite.height;
488
+ gSprite.alpha = clamp01(grain.amount);
489
+ // biome-ignore lint/suspicious/noExplicitAny: pixi v8 blendMode is a string union
490
+ (gSprite as any).blendMode = 'soft-light';
491
+ app.stage.addChild(gSprite);
492
+ }
493
+
494
+ if (pattern) {
495
+ const tile = pixi.Texture.from(drawPatternTile(pattern.type, pattern.scale, pattern.color));
496
+ const tiling = new pixi.TilingSprite({
497
+ texture: tile,
498
+ width: sprite.width,
499
+ height: sprite.height,
500
+ });
501
+ tiling.alpha = clamp01(pattern.opacity);
502
+ // biome-ignore lint/suspicious/noExplicitAny: pixi v8 blendMode is a string union
503
+ (tiling as any).blendMode = BLEND_MAP[pattern.blend] ?? 'normal';
504
+ app.stage.addChild(tiling);
505
+ }
506
+
507
+ if (mask) {
508
+ if (mask.preset === 'vignette') {
509
+ // Vignette DARKENS the edges (overlay) — it does not clip them.
510
+ const vig = new pixi.Sprite(
511
+ pixi.Texture.from(drawVignetteCanvas(sprite.width, sprite.height, mask.strength))
512
+ );
513
+ vig.width = sprite.width;
514
+ vig.height = sprite.height;
515
+ app.stage.addChild(vig);
516
+ app.stage.mask = null;
517
+ } else {
518
+ // radial-reveal / edge-fade → an alpha mask (fade to transparent).
519
+ const maskSprite = new pixi.Sprite(
520
+ pixi.Texture.from(drawMaskCanvas(sprite.width, sprite.height, mask))
521
+ );
522
+ maskSprite.width = sprite.width;
523
+ maskSprite.height = sprite.height;
524
+ app.stage.addChild(maskSprite);
525
+ app.stage.mask = maskSprite;
526
+ }
527
+ } else {
528
+ app.stage.mask = null;
529
+ }
530
+
531
+ app.render();
532
+ }
533
+
534
+ destroy(): void {
535
+ this.destroyed = true;
536
+ if (this.app) {
537
+ this.app.destroy(true, { children: true });
538
+ this.app = null;
539
+ }
540
+ }
541
+ }
542
+
543
+ /** Convenience: true when the edit needs pixi at all (else render the plain img). */
544
+ export function needsCompositor(edit: PhotoEdit | null | undefined): boolean {
545
+ return !isDefaultEdit(edit);
546
+ }
547
+
548
+ /** Decode `url` far enough to read its intrinsic pixel size, capped at `maxDim`
549
+ * on the long edge (GPU max-texture-size safety, mirrors the DDR-088 cap-stack
550
+ * posture elsewhere in this feature). */
551
+ async function naturalSize(
552
+ url: string,
553
+ maxDim: number
554
+ ): Promise<{ width: number; height: number }> {
555
+ const img = new Image();
556
+ img.decoding = 'async';
557
+ img.src = url;
558
+ if (typeof img.decode === 'function') await img.decode();
559
+ else
560
+ await new Promise<void>((res, rej) => {
561
+ img.onload = () => res();
562
+ img.onerror = () => rej(new Error(`image load failed: ${url}`));
563
+ });
564
+ const w = img.naturalWidth || 1;
565
+ const h = img.naturalHeight || 1;
566
+ const scale = Math.min(1, maxDim / Math.max(w, h));
567
+ return { width: Math.max(1, Math.round(w * scale)), height: Math.max(1, Math.round(h * scale)) };
568
+ }
569
+
570
+ export interface RenderPhotoDataUrlOptions {
571
+ /** Original (unedited) `assets/<sha8>.<ext>` source. */
572
+ source: string;
573
+ edit: PhotoEdit;
574
+ resolveUrl?: (rel: string) => string;
575
+ /** Long-edge cap in px. Default 4096 (safe GPU texture ceiling). */
576
+ maxDimension?: number;
577
+ }
578
+
579
+ /**
580
+ * Bake a `PhotoEdit` composite to a `data:image/png` URL, rendered at the
581
+ * SOURCE's native resolution (not whatever CSS box the DOM element currently
582
+ * happens to occupy). This is what lets the caller swap a real `<img>`/
583
+ * `<image>` element's `src`/`href` directly instead of floating a separately
584
+ * WebGL-rendered decoy on top of it (feature-photo-editor iteration 2) — the
585
+ * baked bitmap stays sharp across resize/zoom because the BROWSER scales it
586
+ * the same way it would the untouched original, no re-bake required.
587
+ */
588
+ export async function renderPhotoDataUrl(opts: RenderPhotoDataUrlOptions): Promise<string> {
589
+ const resolve = (rel: string) => (opts.resolveUrl ? opts.resolveUrl(rel) : rel);
590
+ const srcUrl = resolve(resolveSourceUrl(opts.edit, opts.source));
591
+ const { width, height } = await naturalSize(srcUrl, opts.maxDimension ?? 4096);
592
+ const canvas = document.createElement('canvas');
593
+ const renderer = await PhotoRenderer.create({
594
+ canvas,
595
+ source: opts.source,
596
+ edit: opts.edit,
597
+ width,
598
+ height,
599
+ resolveUrl: opts.resolveUrl,
600
+ resolution: 1, // already native resolution — see PhotoRendererOptions.resolution doc.
601
+ });
602
+ try {
603
+ return canvas.toDataURL('image/png');
604
+ } finally {
605
+ renderer.destroy();
606
+ }
607
+ }