agentvfx 0.0.0-stage → 0.3.4

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 (483) hide show
  1. package/AGENTS.md +8 -0
  2. package/GEMINI.md +1 -0
  3. package/LICENSE +172 -0
  4. package/README.md +236 -2
  5. package/package.json +35 -4
  6. package/skills/agentvfx/LICENSE +172 -0
  7. package/skills/agentvfx/NOTICE +44 -0
  8. package/skills/agentvfx/SKILL.md +90 -0
  9. package/skills/agentvfx/engine/core/constants.mjs +84 -0
  10. package/skills/agentvfx/engine/core/flash-constants.mjs +23 -0
  11. package/skills/agentvfx/engine/core/flash.mjs +240 -0
  12. package/skills/agentvfx/engine/core/mark.mjs +28 -0
  13. package/skills/agentvfx/engine/core/palettes.mjs +36 -0
  14. package/skills/agentvfx/engine/core/schedule.mjs +85 -0
  15. package/skills/agentvfx/engine/core/templates.mjs +43 -0
  16. package/skills/agentvfx/engine/core/util.mjs +167 -0
  17. package/skills/agentvfx/engine/core/validate.mjs +334 -0
  18. package/skills/agentvfx/engine/node/analysis.mjs +172 -0
  19. package/skills/agentvfx/engine/node/audio.mjs +621 -0
  20. package/skills/agentvfx/engine/node/board.mjs +24 -0
  21. package/skills/agentvfx/engine/node/chrome.mjs +349 -0
  22. package/skills/agentvfx/engine/node/encode.mjs +110 -0
  23. package/skills/agentvfx/engine/node/eye.mjs +77 -0
  24. package/skills/agentvfx/engine/node/footage.mjs +129 -0
  25. package/skills/agentvfx/engine/node/frame-worker.mjs +39 -0
  26. package/skills/agentvfx/engine/node/host.mjs +31 -0
  27. package/skills/agentvfx/engine/node/job.mjs +181 -0
  28. package/skills/agentvfx/engine/node/png.mjs +112 -0
  29. package/skills/agentvfx/engine/node/render.mjs +161 -0
  30. package/skills/agentvfx/engine/node/stage-capture.mjs +187 -0
  31. package/skills/agentvfx/engine/node/stage-serve.mjs +72 -0
  32. package/skills/agentvfx/engine/node/stills.mjs +100 -0
  33. package/skills/agentvfx/engine/node/ws-server.mjs +132 -0
  34. package/skills/agentvfx/engine/web/gl.mjs +319 -0
  35. package/skills/agentvfx/engine/web/glsl-common.mjs +78 -0
  36. package/skills/agentvfx/engine/web/init.js +23 -0
  37. package/skills/agentvfx/engine/web/prelude.mjs +150 -0
  38. package/skills/agentvfx/engine/web/render.html +11 -0
  39. package/skills/agentvfx/engine/web/render.mjs +388 -0
  40. package/skills/agentvfx/engine/web/scale.mjs +15 -0
  41. package/skills/agentvfx/engine/web/text.mjs +158 -0
  42. package/skills/agentvfx/plates/terminalcore/README.md +61 -0
  43. package/skills/agentvfx/plates/terminalcore/__pycache__/crt.cpython-313.pyc +0 -0
  44. package/skills/agentvfx/plates/terminalcore/__pycache__/font.cpython-313.pyc +0 -0
  45. package/skills/agentvfx/plates/terminalcore/__pycache__/gallery.cpython-313.pyc +0 -0
  46. package/skills/agentvfx/plates/terminalcore/__pycache__/gallery_mats.cpython-313.pyc +0 -0
  47. package/skills/agentvfx/plates/terminalcore/__pycache__/gallery_room.cpython-313.pyc +0 -0
  48. package/skills/agentvfx/plates/terminalcore/__pycache__/materials.cpython-313.pyc +0 -0
  49. package/skills/agentvfx/plates/terminalcore/__pycache__/nodekit.cpython-313.pyc +0 -0
  50. package/skills/agentvfx/plates/terminalcore/__pycache__/room.cpython-313.pyc +0 -0
  51. package/skills/agentvfx/plates/terminalcore/__pycache__/shots.cpython-313.pyc +0 -0
  52. package/skills/agentvfx/plates/terminalcore/__pycache__/textures.cpython-313.pyc +0 -0
  53. package/skills/agentvfx/plates/terminalcore/__pycache__/wall.cpython-313.pyc +0 -0
  54. package/skills/agentvfx/plates/terminalcore/crt.py +143 -0
  55. package/skills/agentvfx/plates/terminalcore/font.py +80 -0
  56. package/skills/agentvfx/plates/terminalcore/gallery.py +61 -0
  57. package/skills/agentvfx/plates/terminalcore/gallery_mats.py +144 -0
  58. package/skills/agentvfx/plates/terminalcore/gallery_room.py +126 -0
  59. package/skills/agentvfx/plates/terminalcore/materials.py +170 -0
  60. package/skills/agentvfx/plates/terminalcore/nodekit.py +87 -0
  61. package/skills/agentvfx/plates/terminalcore/room.py +82 -0
  62. package/skills/agentvfx/plates/terminalcore/scene.py +219 -0
  63. package/skills/agentvfx/plates/terminalcore/shots.py +48 -0
  64. package/skills/agentvfx/plates/terminalcore/textures.py +79 -0
  65. package/skills/agentvfx/plates/terminalcore/wall.py +103 -0
  66. package/skills/agentvfx/references/plates.md +22 -0
  67. package/skills/agentvfx/references/template-format.md +149 -0
  68. package/skills/agentvfx/references/web.md +63 -0
  69. package/skills/agentvfx/runtime/agentvfx.js +708 -0
  70. package/skills/agentvfx/schemas/fx-score.schema.json +98 -0
  71. package/skills/agentvfx/scripts/export-web.mjs +155 -0
  72. package/skills/agentvfx/scripts/loop.mjs +129 -0
  73. package/skills/agentvfx/scripts/make-beat.mjs +133 -0
  74. package/skills/agentvfx/scripts/plate.mjs +82 -0
  75. package/skills/agentvfx/scripts/vfx.mjs +173 -0
  76. package/skills/agentvfx/templates/_test/feedback-counter/fx.json +11 -0
  77. package/skills/agentvfx/templates/_test/feedback-counter/main.frag +4 -0
  78. package/skills/agentvfx/templates/_test/feedback-counter/pass0.frag +4 -0
  79. package/skills/agentvfx/templates/_test/gradient/fx.json +23 -0
  80. package/skills/agentvfx/templates/_test/gradient/main.frag +5 -0
  81. package/skills/agentvfx/templates/_test/identity/fx.json +10 -0
  82. package/skills/agentvfx/templates/_test/identity/main.frag +4 -0
  83. package/skills/agentvfx/templates/_test/strobe/fx.json +14 -0
  84. package/skills/agentvfx/templates/_test/strobe/main.frag +6 -0
  85. package/skills/agentvfx/templates/_test/text-probe/fx.json +15 -0
  86. package/skills/agentvfx/templates/_test/text-probe/main.frag +6 -0
  87. package/skills/agentvfx/templates/brutal/NOTES.md +14 -0
  88. package/skills/agentvfx/templates/brutal/_src/boot-log/def.mjs +57 -0
  89. package/skills/agentvfx/templates/brutal/_src/boot-log/main.src.frag +129 -0
  90. package/skills/agentvfx/templates/brutal/_src/build.mjs +33 -0
  91. package/skills/agentvfx/templates/brutal/_src/halftone-field/def.mjs +72 -0
  92. package/skills/agentvfx/templates/brutal/_src/halftone-field/main.src.frag +94 -0
  93. package/skills/agentvfx/templates/brutal/_src/hud-deck/deck-left.src.glsl +100 -0
  94. package/skills/agentvfx/templates/brutal/_src/hud-deck/deck-right.src.glsl +61 -0
  95. package/skills/agentvfx/templates/brutal/_src/hud-deck/deck-tiles.src.glsl +153 -0
  96. package/skills/agentvfx/templates/brutal/_src/hud-deck/def.mjs +82 -0
  97. package/skills/agentvfx/templates/brutal/_src/hud-deck/main.src.frag +110 -0
  98. package/skills/agentvfx/templates/brutal/_src/hud-deck/pass0.src.frag +38 -0
  99. package/skills/agentvfx/templates/brutal/_src/shared/defs.mjs +28 -0
  100. package/skills/agentvfx/templates/brutal/_src/shared/icons.glsl +72 -0
  101. package/skills/agentvfx/templates/brutal/_src/shared/lib.glsl +115 -0
  102. package/skills/agentvfx/templates/brutal/_src/shared/screen.glsl +43 -0
  103. package/skills/agentvfx/templates/brutal/boot-log/demo-acid.json +1 -0
  104. package/skills/agentvfx/templates/brutal/boot-log/demo-phosphor.json +1 -0
  105. package/skills/agentvfx/templates/brutal/boot-log/demo-ultramarine.json +1 -0
  106. package/skills/agentvfx/templates/brutal/boot-log/fx.json +257 -0
  107. package/skills/agentvfx/templates/brutal/boot-log/main.frag +242 -0
  108. package/skills/agentvfx/templates/brutal/collage/_src/00-lib.glsl +73 -0
  109. package/skills/agentvfx/templates/brutal/collage/_src/10-spectrum-scope.glsl +73 -0
  110. package/skills/agentvfx/templates/brutal/collage/_src/20-meter-readout-cross.glsl +87 -0
  111. package/skills/agentvfx/templates/brutal/collage/_src/30-footage-fillers.glsl +92 -0
  112. package/skills/agentvfx/templates/brutal/collage/_src/40-editorial.glsl +232 -0
  113. package/skills/agentvfx/templates/brutal/collage/_src/50-typefx.glsl +163 -0
  114. package/skills/agentvfx/templates/brutal/collage/_src/60-scene-lib.glsl +104 -0
  115. package/skills/agentvfx/templates/brutal/collage/_src/61-scene-specimen.glsl +71 -0
  116. package/skills/agentvfx/templates/brutal/collage/_src/62-scene-wiring.glsl +96 -0
  117. package/skills/agentvfx/templates/brutal/collage/_src/63-scene-fieldcut.glsl +119 -0
  118. package/skills/agentvfx/templates/brutal/collage/_src/64-scene-pattern.glsl +64 -0
  119. package/skills/agentvfx/templates/brutal/collage/_src/641-scene-anatomy.glsl +49 -0
  120. package/skills/agentvfx/templates/brutal/collage/_src/642-scene-deck.glsl +78 -0
  121. package/skills/agentvfx/templates/brutal/collage/_src/643-scene-bootlog.glsl +60 -0
  122. package/skills/agentvfx/templates/brutal/collage/_src/644-scene-tunnel.glsl +48 -0
  123. package/skills/agentvfx/templates/brutal/collage/_src/645-scene-runner.glsl +42 -0
  124. package/skills/agentvfx/templates/brutal/collage/_src/646-scene-ascii.glsl +66 -0
  125. package/skills/agentvfx/templates/brutal/collage/_src/647-scene-cubewall.glsl +80 -0
  126. package/skills/agentvfx/templates/brutal/collage/_src/648-scene-vortex.glsl +46 -0
  127. package/skills/agentvfx/templates/brutal/collage/_src/649-scene-sticker.glsl +72 -0
  128. package/skills/agentvfx/templates/brutal/collage/_src/65-scene-main.glsl +1 -0
  129. package/skills/agentvfx/templates/brutal/collage/_src/650-scene-ribbon.glsl +55 -0
  130. package/skills/agentvfx/templates/brutal/collage/_src/651-scene-slab.glsl +38 -0
  131. package/skills/agentvfx/templates/brutal/collage/_src/652-scene-framedial.glsl +79 -0
  132. package/skills/agentvfx/templates/brutal/collage/_src/653-scene-tilt.glsl +40 -0
  133. package/skills/agentvfx/templates/brutal/collage/_src/654-scene-marquee.glsl +45 -0
  134. package/skills/agentvfx/templates/brutal/collage/_src/655-scene-lens.glsl +57 -0
  135. package/skills/agentvfx/templates/brutal/collage/_src/656-scene-liquid.glsl +35 -0
  136. package/skills/agentvfx/templates/brutal/collage/_src/657-scene-panelgrid.glsl +75 -0
  137. package/skills/agentvfx/templates/brutal/collage/_src/658-scene-mirror.glsl +49 -0
  138. package/skills/agentvfx/templates/brutal/collage/_src/66-scene-main.glsl +27 -0
  139. package/skills/agentvfx/templates/brutal/collage/_src/90-main.glsl +120 -0
  140. package/skills/agentvfx/templates/brutal/collage/_src/build.mjs +24 -0
  141. package/skills/agentvfx/templates/brutal/collage/_src/sync-presets.mjs +16 -0
  142. package/skills/agentvfx/templates/brutal/collage/demo-bone-lime.json +1 -0
  143. package/skills/agentvfx/templates/brutal/collage/demo-lime-sparse.json +1 -0
  144. package/skills/agentvfx/templates/brutal/collage/demo-orange-dense.json +1 -0
  145. package/skills/agentvfx/templates/brutal/collage/demo-ultra-slow.json +1 -0
  146. package/skills/agentvfx/templates/brutal/collage/fx.json +4882 -0
  147. package/skills/agentvfx/templates/brutal/collage/layout.mjs +97 -0
  148. package/skills/agentvfx/templates/brutal/collage/layouts.json +366 -0
  149. package/skills/agentvfx/templates/brutal/collage/main.frag +2367 -0
  150. package/skills/agentvfx/templates/brutal/collage/pass0.frag +265 -0
  151. package/skills/agentvfx/templates/brutal/halftone-field/demo-acid.json +1 -0
  152. package/skills/agentvfx/templates/brutal/halftone-field/demo-ascii.json +1 -0
  153. package/skills/agentvfx/templates/brutal/halftone-field/demo-dither.json +1 -0
  154. package/skills/agentvfx/templates/brutal/halftone-field/demo-orange.json +1 -0
  155. package/skills/agentvfx/templates/brutal/halftone-field/demo-phosphor.json +1 -0
  156. package/skills/agentvfx/templates/brutal/halftone-field/demo-ultramarine.json +1 -0
  157. package/skills/agentvfx/templates/brutal/halftone-field/fx.json +474 -0
  158. package/skills/agentvfx/templates/brutal/halftone-field/main.frag +248 -0
  159. package/skills/agentvfx/templates/brutal/hud-deck/demo-acid.json +1 -0
  160. package/skills/agentvfx/templates/brutal/hud-deck/demo-night.json +1 -0
  161. package/skills/agentvfx/templates/brutal/hud-deck/demo-octagon.json +1 -0
  162. package/skills/agentvfx/templates/brutal/hud-deck/demo-ultramarine.json +1 -0
  163. package/skills/agentvfx/templates/brutal/hud-deck/fx.json +601 -0
  164. package/skills/agentvfx/templates/brutal/hud-deck/main.frag +645 -0
  165. package/skills/agentvfx/templates/brutal/hud-deck/pass0.frag +38 -0
  166. package/skills/agentvfx/templates/brutal/lattice/demo-cobalt-fine.json +1 -0
  167. package/skills/agentvfx/templates/brutal/lattice/demo-ink-yellow.json +1 -0
  168. package/skills/agentvfx/templates/brutal/lattice/demo-vermilion.json +1 -0
  169. package/skills/agentvfx/templates/brutal/lattice/demo-violet-slow.json +1 -0
  170. package/skills/agentvfx/templates/brutal/lattice/fx.json +130 -0
  171. package/skills/agentvfx/templates/brutal/lattice/main.frag +175 -0
  172. package/skills/agentvfx/templates/brutal/lattice/pass0.frag +29 -0
  173. package/skills/agentvfx/templates/glitch/block-glitch/demo-mosaic.json +1 -0
  174. package/skills/agentvfx/templates/glitch/block-glitch/demo-onset.json +1 -0
  175. package/skills/agentvfx/templates/glitch/block-glitch/demo-static.json +1 -0
  176. package/skills/agentvfx/templates/glitch/block-glitch/demo-tear.json +1 -0
  177. package/skills/agentvfx/templates/glitch/block-glitch/fx.json +54 -0
  178. package/skills/agentvfx/templates/glitch/block-glitch/main.frag +132 -0
  179. package/skills/agentvfx/templates/glitch/block-glitch/pass0.frag +12 -0
  180. package/skills/agentvfx/templates/glitch/pixel-sort/NOTES.md +13 -0
  181. package/skills/agentvfx/templates/glitch/pixel-sort/build-passes.mjs +162 -0
  182. package/skills/agentvfx/templates/glitch/pixel-sort/demo-chunk.json +1 -0
  183. package/skills/agentvfx/templates/glitch/pixel-sort/demo-hue.json +1 -0
  184. package/skills/agentvfx/templates/glitch/pixel-sort/demo-kick.json +1 -0
  185. package/skills/agentvfx/templates/glitch/pixel-sort/demo-streak.json +1 -0
  186. package/skills/agentvfx/templates/glitch/pixel-sort/demo-vertical.json +1 -0
  187. package/skills/agentvfx/templates/glitch/pixel-sort/fx.json +60 -0
  188. package/skills/agentvfx/templates/glitch/pixel-sort/main.frag +83 -0
  189. package/skills/agentvfx/templates/glitch/pixel-sort/pass0.frag +64 -0
  190. package/skills/agentvfx/templates/glitch/pixel-sort/pass1.frag +66 -0
  191. package/skills/agentvfx/templates/glitch/pixel-sort/pass2.frag +66 -0
  192. package/skills/agentvfx/templates/glitch/pixel-sort/pass3.frag +66 -0
  193. package/skills/agentvfx/templates/glitch/pixel-sort/pass4.frag +66 -0
  194. package/skills/agentvfx/templates/glitch/pixel-sort/pass5.frag +66 -0
  195. package/skills/agentvfx/templates/glitch/pixel-sort/pass6.frag +66 -0
  196. package/skills/agentvfx/templates/glitch/pixel-sort/pass7.frag +66 -0
  197. package/skills/agentvfx/templates/glitch/pixel-sort/pass8.frag +66 -0
  198. package/skills/agentvfx/templates/glitch/rgb-split/demo-diagonal-stutter.json +1 -0
  199. package/skills/agentvfx/templates/glitch/rgb-split/demo-hard.json +1 -0
  200. package/skills/agentvfx/templates/glitch/rgb-split/demo-kick.json +1 -0
  201. package/skills/agentvfx/templates/glitch/rgb-split/demo-radial.json +1 -0
  202. package/skills/agentvfx/templates/glitch/rgb-split/fx.json +46 -0
  203. package/skills/agentvfx/templates/glitch/rgb-split/main.frag +76 -0
  204. package/skills/agentvfx/templates/glitch/vhs/demo-clean.json +1 -0
  205. package/skills/agentvfx/templates/glitch/vhs/demo-crash.json +1 -0
  206. package/skills/agentvfx/templates/glitch/vhs/demo-tape.json +1 -0
  207. package/skills/agentvfx/templates/glitch/vhs/demo-worn.json +1 -0
  208. package/skills/agentvfx/templates/glitch/vhs/fx.json +46 -0
  209. package/skills/agentvfx/templates/glitch/vhs/main.frag +96 -0
  210. package/skills/agentvfx/templates/impact/impact-1bit/demo-onebit-bayer.json +1 -0
  211. package/skills/agentvfx/templates/impact/impact-1bit/fx.json +45 -0
  212. package/skills/agentvfx/templates/impact/impact-1bit/main.frag +64 -0
  213. package/skills/agentvfx/templates/impact/impact-invert/demo-invert-snap.json +1 -0
  214. package/skills/agentvfx/templates/impact/impact-invert/fx.json +37 -0
  215. package/skills/agentvfx/templates/impact/impact-invert/main.frag +44 -0
  216. package/skills/agentvfx/templates/impact/punch-shake/demo-punch-hard.json +1 -0
  217. package/skills/agentvfx/templates/impact/punch-shake/fx.json +36 -0
  218. package/skills/agentvfx/templates/impact/punch-shake/main.frag +62 -0
  219. package/skills/agentvfx/templates/impact/rgb-hit/demo-rgb-slam.json +1 -0
  220. package/skills/agentvfx/templates/impact/rgb-hit/fx.json +39 -0
  221. package/skills/agentvfx/templates/impact/rgb-hit/main.frag +62 -0
  222. package/skills/agentvfx/templates/impact/signal-cut/demo-lime-hold.json +1 -0
  223. package/skills/agentvfx/templates/impact/signal-cut/demo-lime.json +1 -0
  224. package/skills/agentvfx/templates/impact/signal-cut/demo-orange.json +1 -0
  225. package/skills/agentvfx/templates/impact/signal-cut/demo-ultramarine.json +1 -0
  226. package/skills/agentvfx/templates/impact/signal-cut/fx.json +83 -0
  227. package/skills/agentvfx/templates/impact/signal-cut/main.frag +101 -0
  228. package/skills/agentvfx/templates/impact/signal-cut/pass0.frag +68 -0
  229. package/skills/agentvfx/templates/impact/speedline-burst/demo-speed-white.json +1 -0
  230. package/skills/agentvfx/templates/impact/speedline-burst/fx.json +44 -0
  231. package/skills/agentvfx/templates/impact/speedline-burst/main.frag +68 -0
  232. package/skills/agentvfx/templates/impact/strobe-burst/demo-strobe-white-16.json +1 -0
  233. package/skills/agentvfx/templates/impact/strobe-burst/fx.json +42 -0
  234. package/skills/agentvfx/templates/impact/strobe-burst/main.frag +43 -0
  235. package/skills/agentvfx/templates/light/anamorphic-streak/demo-anamorphic-cinema.json +10 -0
  236. package/skills/agentvfx/templates/light/anamorphic-streak/demo-data.json +10 -0
  237. package/skills/agentvfx/templates/light/anamorphic-streak/fx.json +47 -0
  238. package/skills/agentvfx/templates/light/anamorphic-streak/main.frag +45 -0
  239. package/skills/agentvfx/templates/light/anamorphic-streak/pass0.frag +22 -0
  240. package/skills/agentvfx/templates/light/anamorphic-streak/pass1.frag +16 -0
  241. package/skills/agentvfx/templates/light/anamorphic-streak/pass2.frag +16 -0
  242. package/skills/agentvfx/templates/light/anamorphic-streak/pass3.frag +16 -0
  243. package/skills/agentvfx/templates/light/bloom-glow/demo-clip01-low.json +11 -0
  244. package/skills/agentvfx/templates/light/bloom-glow/demo-neon-night.json +10 -0
  245. package/skills/agentvfx/templates/light/bloom-glow/demo-phosphor.json +11 -0
  246. package/skills/agentvfx/templates/light/bloom-glow/fx.json +53 -0
  247. package/skills/agentvfx/templates/light/bloom-glow/main.frag +85 -0
  248. package/skills/agentvfx/templates/light/bloom-glow/pass0.frag +21 -0
  249. package/skills/agentvfx/templates/light/bloom-glow/pass1.frag +50 -0
  250. package/skills/agentvfx/templates/light/bloom-glow/pass2.frag +44 -0
  251. package/skills/agentvfx/templates/light/bloom-glow/pass3.frag +32 -0
  252. package/skills/agentvfx/templates/light/bloom-glow/pass4.frag +48 -0
  253. package/skills/agentvfx/templates/light/bloom-glow/pass5.frag +46 -0
  254. package/skills/agentvfx/templates/light/god-rays/demo-golden-hour.json +10 -0
  255. package/skills/agentvfx/templates/light/god-rays/demo-point-lamp.json +10 -0
  256. package/skills/agentvfx/templates/light/god-rays/fx.json +39 -0
  257. package/skills/agentvfx/templates/light/god-rays/main.frag +54 -0
  258. package/skills/agentvfx/templates/light/lens-flare/demo-clip01-bar.json +11 -0
  259. package/skills/agentvfx/templates/light/lens-flare/demo-eye-bar.json +10 -0
  260. package/skills/agentvfx/templates/light/lens-flare/demo-sun-ghosts.json +10 -0
  261. package/skills/agentvfx/templates/light/lens-flare/fx.json +49 -0
  262. package/skills/agentvfx/templates/light/lens-flare/main.frag +86 -0
  263. package/skills/agentvfx/templates/light/lens-flare/pass0.frag +24 -0
  264. package/skills/agentvfx/templates/light/light-leak/demo-slit.json +10 -0
  265. package/skills/agentvfx/templates/light/light-leak/demo-vintage-burn.json +10 -0
  266. package/skills/agentvfx/templates/light/light-leak/fx.json +40 -0
  267. package/skills/agentvfx/templates/light/light-leak/main.frag +46 -0
  268. package/skills/agentvfx/templates/light/light-stage/fx.json +29 -0
  269. package/skills/agentvfx/templates/light/light-stage/main.frag +56 -0
  270. package/skills/agentvfx/templates/light/light-sweep/demo-hard-bar.json +12 -0
  271. package/skills/agentvfx/templates/light/light-sweep/demo-logo-sweep.json +10 -0
  272. package/skills/agentvfx/templates/light/light-sweep/demo-onset.json +11 -0
  273. package/skills/agentvfx/templates/light/light-sweep/fx.json +45 -0
  274. package/skills/agentvfx/templates/light/light-sweep/main.frag +29 -0
  275. package/skills/agentvfx/templates/light/light-sweep/pass0.frag +17 -0
  276. package/skills/agentvfx/templates/light/light-sweep/pass1.frag +15 -0
  277. package/skills/agentvfx/templates/light/neon-saber/demo-hairline.json +10 -0
  278. package/skills/agentvfx/templates/light/neon-saber/demo-saber-laser.json +10 -0
  279. package/skills/agentvfx/templates/light/neon-saber/fx.json +48 -0
  280. package/skills/agentvfx/templates/light/neon-saber/main.frag +27 -0
  281. package/skills/agentvfx/templates/light/neon-saber/pass0.frag +46 -0
  282. package/skills/agentvfx/templates/light/neon-saber/pass1.frag +15 -0
  283. package/skills/agentvfx/templates/psychedelic/echo-flow/demo-trail.json +1 -0
  284. package/skills/agentvfx/templates/psychedelic/echo-flow/fx.json +38 -0
  285. package/skills/agentvfx/templates/psychedelic/echo-flow/main.frag +19 -0
  286. package/skills/agentvfx/templates/psychedelic/echo-flow/pass0.frag +16 -0
  287. package/skills/agentvfx/templates/psychedelic/echo-flow/pass1.frag +20 -0
  288. package/skills/agentvfx/templates/psychedelic/echo-flow/pass2.frag +11 -0
  289. package/skills/agentvfx/templates/psychedelic/echo-flow/pass3.frag +4 -0
  290. package/skills/agentvfx/templates/psychedelic/hologram-scan/demo-cyan.json +1 -0
  291. package/skills/agentvfx/templates/psychedelic/hologram-scan/fx.json +36 -0
  292. package/skills/agentvfx/templates/psychedelic/hologram-scan/main.frag +30 -0
  293. package/skills/agentvfx/templates/psychedelic/kaleido-fold/demo-six.json +1 -0
  294. package/skills/agentvfx/templates/psychedelic/kaleido-fold/fx.json +37 -0
  295. package/skills/agentvfx/templates/psychedelic/kaleido-fold/main.frag +10 -0
  296. package/skills/agentvfx/templates/psychedelic/kaleido-fold/pass0.frag +34 -0
  297. package/skills/agentvfx/templates/psychedelic/mosh-melt/demo-classic.json +1 -0
  298. package/skills/agentvfx/templates/psychedelic/mosh-melt/fx.json +38 -0
  299. package/skills/agentvfx/templates/psychedelic/mosh-melt/main.frag +20 -0
  300. package/skills/agentvfx/templates/psychedelic/mosh-melt/pass0.frag +22 -0
  301. package/skills/agentvfx/templates/psychedelic/sacred/NOTES.md +13 -0
  302. package/skills/agentvfx/templates/psychedelic/sacred/demo-classic.json +1 -0
  303. package/skills/agentvfx/templates/psychedelic/sacred/demo-halo.json +1 -0
  304. package/skills/agentvfx/templates/psychedelic/sacred/demo-slow-tide.json +1 -0
  305. package/skills/agentvfx/templates/psychedelic/sacred/demo-thermal.json +1 -0
  306. package/skills/agentvfx/templates/psychedelic/sacred/fx.json +263 -0
  307. package/skills/agentvfx/templates/psychedelic/sacred/main.frag +109 -0
  308. package/skills/agentvfx/templates/psychedelic/sacred/pass0.frag +25 -0
  309. package/skills/agentvfx/templates/psychedelic/sacred/pass1.frag +23 -0
  310. package/skills/agentvfx/templates/psychedelic/tidal-bands/demo-classic.json +1 -0
  311. package/skills/agentvfx/templates/psychedelic/tidal-bands/fx.json +37 -0
  312. package/skills/agentvfx/templates/psychedelic/tidal-bands/main.frag +54 -0
  313. package/skills/agentvfx/templates/scope/_chrome.glsl +242 -0
  314. package/skills/agentvfx/templates/scope/goniometer/demo-boot-green.json +1 -0
  315. package/skills/agentvfx/templates/scope/goniometer/demo-field-lime.json +1 -0
  316. package/skills/agentvfx/templates/scope/goniometer/demo-field-ultramarine.json +1 -0
  317. package/skills/agentvfx/templates/scope/goniometer/demo-glow-phosphor.json +1 -0
  318. package/skills/agentvfx/templates/scope/goniometer/demo-hud-black.json +1 -0
  319. package/skills/agentvfx/templates/scope/goniometer/fx.json +635 -0
  320. package/skills/agentvfx/templates/scope/goniometer/main.frag +33 -0
  321. package/skills/agentvfx/templates/scope/goniometer/pass0.frag +58 -0
  322. package/skills/agentvfx/templates/scope/goniometer/pass1.frag +43 -0
  323. package/skills/agentvfx/templates/scope/goniometer/pass2.frag +12 -0
  324. package/skills/agentvfx/templates/scope/goniometer/pass3.frag +12 -0
  325. package/skills/agentvfx/templates/scope/goniometer/pass4.frag +12 -0
  326. package/skills/agentvfx/templates/scope/goniometer/pass5.frag +12 -0
  327. package/skills/agentvfx/templates/scope/goniometer/pass6.frag +251 -0
  328. package/skills/agentvfx/templates/scope/scope-polar/demo-boot-green.json +1 -0
  329. package/skills/agentvfx/templates/scope/scope-polar/demo-field-lime.json +1 -0
  330. package/skills/agentvfx/templates/scope/scope-polar/demo-field-ultramarine.json +1 -0
  331. package/skills/agentvfx/templates/scope/scope-polar/demo-glow-phosphor.json +1 -0
  332. package/skills/agentvfx/templates/scope/scope-polar/demo-hud-black.json +1 -0
  333. package/skills/agentvfx/templates/scope/scope-polar/fx.json +686 -0
  334. package/skills/agentvfx/templates/scope/scope-polar/main.frag +39 -0
  335. package/skills/agentvfx/templates/scope/scope-polar/pass0.frag +84 -0
  336. package/skills/agentvfx/templates/scope/scope-polar/pass1.frag +49 -0
  337. package/skills/agentvfx/templates/scope/scope-polar/pass2.frag +12 -0
  338. package/skills/agentvfx/templates/scope/scope-polar/pass3.frag +12 -0
  339. package/skills/agentvfx/templates/scope/scope-polar/pass4.frag +12 -0
  340. package/skills/agentvfx/templates/scope/scope-polar/pass5.frag +12 -0
  341. package/skills/agentvfx/templates/scope/scope-polar/pass6.frag +251 -0
  342. package/skills/agentvfx/templates/scope/scope-triggered/demo-boot-green.json +1 -0
  343. package/skills/agentvfx/templates/scope/scope-triggered/demo-field-lime.json +1 -0
  344. package/skills/agentvfx/templates/scope/scope-triggered/demo-field-ultramarine.json +1 -0
  345. package/skills/agentvfx/templates/scope/scope-triggered/demo-glow-phosphor.json +1 -0
  346. package/skills/agentvfx/templates/scope/scope-triggered/demo-hud-black.json +1 -0
  347. package/skills/agentvfx/templates/scope/scope-triggered/fx.json +712 -0
  348. package/skills/agentvfx/templates/scope/scope-triggered/main.frag +36 -0
  349. package/skills/agentvfx/templates/scope/scope-triggered/pass0.frag +78 -0
  350. package/skills/agentvfx/templates/scope/scope-triggered/pass1.frag +50 -0
  351. package/skills/agentvfx/templates/scope/scope-triggered/pass2.frag +12 -0
  352. package/skills/agentvfx/templates/scope/scope-triggered/pass3.frag +12 -0
  353. package/skills/agentvfx/templates/scope/scope-triggered/pass4.frag +12 -0
  354. package/skills/agentvfx/templates/scope/scope-triggered/pass5.frag +12 -0
  355. package/skills/agentvfx/templates/scope/scope-triggered/pass6.frag +251 -0
  356. package/skills/agentvfx/templates/scope/spectrogram-scroll/demo-boot-green.json +1 -0
  357. package/skills/agentvfx/templates/scope/spectrogram-scroll/demo-field-lime.json +1 -0
  358. package/skills/agentvfx/templates/scope/spectrogram-scroll/demo-field-ultramarine.json +1 -0
  359. package/skills/agentvfx/templates/scope/spectrogram-scroll/demo-glow-phosphor.json +1 -0
  360. package/skills/agentvfx/templates/scope/spectrogram-scroll/demo-hud-black.json +1 -0
  361. package/skills/agentvfx/templates/scope/spectrogram-scroll/fx.json +595 -0
  362. package/skills/agentvfx/templates/scope/spectrogram-scroll/main.frag +83 -0
  363. package/skills/agentvfx/templates/scope/spectrogram-scroll/pass0.frag +52 -0
  364. package/skills/agentvfx/templates/scope/spectrogram-scroll/pass1.frag +251 -0
  365. package/skills/agentvfx/templates/scope/xy-phosphor/demo-boot-green.json +1 -0
  366. package/skills/agentvfx/templates/scope/xy-phosphor/demo-field-lime.json +1 -0
  367. package/skills/agentvfx/templates/scope/xy-phosphor/demo-field-ultramarine.json +1 -0
  368. package/skills/agentvfx/templates/scope/xy-phosphor/demo-glow-phosphor.json +1 -0
  369. package/skills/agentvfx/templates/scope/xy-phosphor/demo-hud-black.json +1 -0
  370. package/skills/agentvfx/templates/scope/xy-phosphor/fx.json +642 -0
  371. package/skills/agentvfx/templates/scope/xy-phosphor/main.frag +33 -0
  372. package/skills/agentvfx/templates/scope/xy-phosphor/pass0.frag +56 -0
  373. package/skills/agentvfx/templates/scope/xy-phosphor/pass1.frag +44 -0
  374. package/skills/agentvfx/templates/scope/xy-phosphor/pass2.frag +12 -0
  375. package/skills/agentvfx/templates/scope/xy-phosphor/pass3.frag +12 -0
  376. package/skills/agentvfx/templates/scope/xy-phosphor/pass4.frag +12 -0
  377. package/skills/agentvfx/templates/scope/xy-phosphor/pass5.frag +12 -0
  378. package/skills/agentvfx/templates/scope/xy-phosphor/pass6.frag +251 -0
  379. package/skills/agentvfx/templates/spectrum/_src/bars-linear/fx.src.json +61 -0
  380. package/skills/agentvfx/templates/spectrum/_src/bars-linear/main.src.frag +112 -0
  381. package/skills/agentvfx/templates/spectrum/_src/bars-linear/pass0.src.frag +2 -0
  382. package/skills/agentvfx/templates/spectrum/_src/bars-mirror/fx.src.json +51 -0
  383. package/skills/agentvfx/templates/spectrum/_src/bars-mirror/main.src.frag +72 -0
  384. package/skills/agentvfx/templates/spectrum/_src/bars-mirror/pass0.src.frag +2 -0
  385. package/skills/agentvfx/templates/spectrum/_src/bass-circle/fx.src.json +56 -0
  386. package/skills/agentvfx/templates/spectrum/_src/bass-circle/main.src.frag +136 -0
  387. package/skills/agentvfx/templates/spectrum/_src/bass-circle/pass0.src.frag +2 -0
  388. package/skills/agentvfx/templates/spectrum/_src/build.mjs +68 -0
  389. package/skills/agentvfx/templates/spectrum/_src/label-bars/fx.src.json +54 -0
  390. package/skills/agentvfx/templates/spectrum/_src/label-bars/main.src.frag +102 -0
  391. package/skills/agentvfx/templates/spectrum/_src/label-bars/pass0.src.frag +2 -0
  392. package/skills/agentvfx/templates/spectrum/_src/shared/bars-fn.glsl +64 -0
  393. package/skills/agentvfx/templates/spectrum/_src/shared/bars-pass.glsl +22 -0
  394. package/skills/agentvfx/templates/spectrum/_src/shared/clock.glsl +7 -0
  395. package/skills/agentvfx/templates/spectrum/_src/shared/hud.glsl +50 -0
  396. package/skills/agentvfx/templates/spectrum/_src/shared/stage.glsl +74 -0
  397. package/skills/agentvfx/templates/spectrum/_src/spectrum-particles/fx.src.json +58 -0
  398. package/skills/agentvfx/templates/spectrum/_src/spectrum-particles/main.src.frag +119 -0
  399. package/skills/agentvfx/templates/spectrum/_src/spectrum-particles/pass0.src.frag +33 -0
  400. package/skills/agentvfx/templates/spectrum/_src/spectrum-radial/fx.src.json +56 -0
  401. package/skills/agentvfx/templates/spectrum/_src/spectrum-radial/main.src.frag +95 -0
  402. package/skills/agentvfx/templates/spectrum/_src/spectrum-radial/pass0.src.frag +2 -0
  403. package/skills/agentvfx/templates/spectrum/_src/waveform-line/fx.src.json +60 -0
  404. package/skills/agentvfx/templates/spectrum/_src/waveform-line/main.src.frag +98 -0
  405. package/skills/agentvfx/templates/spectrum/_src/waveform-line/pass0.src.frag +43 -0
  406. package/skills/agentvfx/templates/spectrum/bars-linear/demo-graph-acid.json +1 -0
  407. package/skills/agentvfx/templates/spectrum/bars-linear/demo-graph-black.json +1 -0
  408. package/skills/agentvfx/templates/spectrum/bars-linear/fx.json +632 -0
  409. package/skills/agentvfx/templates/spectrum/bars-linear/main.frag +233 -0
  410. package/skills/agentvfx/templates/spectrum/bars-linear/pass0.frag +91 -0
  411. package/skills/agentvfx/templates/spectrum/bars-mirror/demo-field-acid.json +1 -0
  412. package/skills/agentvfx/templates/spectrum/bars-mirror/demo-field-ultramarine.json +1 -0
  413. package/skills/agentvfx/templates/spectrum/bars-mirror/fx.json +539 -0
  414. package/skills/agentvfx/templates/spectrum/bars-mirror/main.frag +193 -0
  415. package/skills/agentvfx/templates/spectrum/bars-mirror/pass0.frag +91 -0
  416. package/skills/agentvfx/templates/spectrum/bass-circle/demo-bracket-black.json +1 -0
  417. package/skills/agentvfx/templates/spectrum/bass-circle/demo-bracket-ultramarine.json +1 -0
  418. package/skills/agentvfx/templates/spectrum/bass-circle/fx.json +552 -0
  419. package/skills/agentvfx/templates/spectrum/bass-circle/main.frag +258 -0
  420. package/skills/agentvfx/templates/spectrum/bass-circle/pass0.frag +91 -0
  421. package/skills/agentvfx/templates/spectrum/label-bars/demo-deck-black.json +1 -0
  422. package/skills/agentvfx/templates/spectrum/label-bars/demo-deck-ultramarine.json +1 -0
  423. package/skills/agentvfx/templates/spectrum/label-bars/fx.json +552 -0
  424. package/skills/agentvfx/templates/spectrum/label-bars/main.frag +223 -0
  425. package/skills/agentvfx/templates/spectrum/label-bars/pass0.frag +91 -0
  426. package/skills/agentvfx/templates/spectrum/spectrum-particles/demo-cloud-black.json +1 -0
  427. package/skills/agentvfx/templates/spectrum/spectrum-particles/demo-cloud-ultramarine.json +1 -0
  428. package/skills/agentvfx/templates/spectrum/spectrum-particles/fx.json +584 -0
  429. package/skills/agentvfx/templates/spectrum/spectrum-particles/main.frag +240 -0
  430. package/skills/agentvfx/templates/spectrum/spectrum-particles/pass0.frag +101 -0
  431. package/skills/agentvfx/templates/spectrum/spectrum-radial/demo-starburst-black.json +1 -0
  432. package/skills/agentvfx/templates/spectrum/spectrum-radial/demo-starburst-ultramarine.json +1 -0
  433. package/skills/agentvfx/templates/spectrum/spectrum-radial/fx.json +566 -0
  434. package/skills/agentvfx/templates/spectrum/spectrum-radial/main.frag +217 -0
  435. package/skills/agentvfx/templates/spectrum/spectrum-radial/pass0.frag +91 -0
  436. package/skills/agentvfx/templates/spectrum/waveform-line/demo-strip-acid.json +1 -0
  437. package/skills/agentvfx/templates/spectrum/waveform-line/demo-strip-black.json +1 -0
  438. package/skills/agentvfx/templates/spectrum/waveform-line/fx.json +481 -0
  439. package/skills/agentvfx/templates/spectrum/waveform-line/main.frag +219 -0
  440. package/skills/agentvfx/templates/spectrum/waveform-line/pass0.frag +48 -0
  441. package/skills/agentvfx/templates/spectrum/waveform-line/pass1.frag +86 -0
  442. package/skills/agentvfx/test/_render_light.mjs +38 -0
  443. package/skills/agentvfx/test/audio-stereo.test.mjs +45 -0
  444. package/skills/agentvfx/test/audio.test.mjs +257 -0
  445. package/skills/agentvfx/test/brutal.test.mjs +190 -0
  446. package/skills/agentvfx/test/concept-collage-layout.test.mjs +236 -0
  447. package/skills/agentvfx/test/concept-collage-scenes.test.mjs +305 -0
  448. package/skills/agentvfx/test/concept-collage-typefx.test.mjs +153 -0
  449. package/skills/agentvfx/test/concept-collage.test.mjs +84 -0
  450. package/skills/agentvfx/test/concept-lattice.test.mjs +133 -0
  451. package/skills/agentvfx/test/concept-signal-cut.test.mjs +172 -0
  452. package/skills/agentvfx/test/credit-overlay.test.mjs +58 -0
  453. package/skills/agentvfx/test/credit.test.mjs +42 -0
  454. package/skills/agentvfx/test/doctor.test.mjs +31 -0
  455. package/skills/agentvfx/test/encode.test.mjs +22 -0
  456. package/skills/agentvfx/test/fixtures/r7-collage-LAYOUTS.json +5780 -0
  457. package/skills/agentvfx/test/fixtures/score-bad-duration.json +7 -0
  458. package/skills/agentvfx/test/fixtures/score-bad-safety.json +8 -0
  459. package/skills/agentvfx/test/fixtures/score-identity-footage.json +8 -0
  460. package/skills/agentvfx/test/fixtures/score-missing-template.json +7 -0
  461. package/skills/agentvfx/test/fixtures/score-ok.json +8 -0
  462. package/skills/agentvfx/test/fixtures/score-strobe-12hz.json +8 -0
  463. package/skills/agentvfx/test/fixtures/score-text-probe.json +1 -0
  464. package/skills/agentvfx/test/flash-audit.test.mjs +150 -0
  465. package/skills/agentvfx/test/flash.test.mjs +83 -0
  466. package/skills/agentvfx/test/footage.test.mjs +88 -0
  467. package/skills/agentvfx/test/glitch.test.mjs +272 -0
  468. package/skills/agentvfx/test/impact.test.mjs +231 -0
  469. package/skills/agentvfx/test/imports.test.mjs +39 -0
  470. package/skills/agentvfx/test/light.test.mjs +200 -0
  471. package/skills/agentvfx/test/loop.test.mjs +80 -0
  472. package/skills/agentvfx/test/psychedelic.test.mjs +99 -0
  473. package/skills/agentvfx/test/render.test.mjs +73 -0
  474. package/skills/agentvfx/test/sacred.test.mjs +175 -0
  475. package/skills/agentvfx/test/schema.test.mjs +103 -0
  476. package/skills/agentvfx/test/scope.test.mjs +306 -0
  477. package/skills/agentvfx/test/spectrum.test.mjs +165 -0
  478. package/skills/agentvfx/test/text.test.mjs +142 -0
  479. package/skills/agentvfx/test/web.test.mjs +397 -0
  480. package/skills/agentvfx/test-media/SOURCES.md +39 -0
  481. package/skills/agentvfx/test-media/beat-132.mp3 +0 -0
  482. package/skills/agentvfx/test-media/clip-01.mp4 +0 -0
  483. package/skills/agentvfx/test-media/clip-02.mp4 +0 -0
@@ -0,0 +1,621 @@
1
+ // Offline audio feature analysis for the agentvfx engine (plan task 7).
2
+ // Decodes with the system ffmpeg to float32 48 kHz stereo, runs an own
3
+ // radix-2 FFT (N=4096, Hann) in plain JS, and emits per-video-frame
4
+ // features in two passes: track statistics first, then normalisation.
5
+ // Deterministic: same input bytes -> byte-identical JSON (floats rounded
6
+ // to 6 decimals, fixed key order).
7
+ //
8
+ // Algorithm notes (ideas only, reimplemented here): log-spaced analysis
9
+ // bands in the spirit of librosa chroma/STFT practice (librosa, ISC);
10
+ // Butterchurn-style (MIT) energy bands 20-320 / 320-2800 / 2800-11025 Hz
11
+ // expressed as a ratio to a ~4 s long average (1 = typical); spectral-flux
12
+ // onset detection and onset-autocorrelation tempo in the style of the
13
+ // librosa/aubio literature. No aubio (GPL) or Essentia (AGPL) code is used.
14
+ import { spawn } from 'node:child_process';
15
+ import { promises as fs } from 'node:fs';
16
+
17
+ const SR = 48000;
18
+ const N = 4096;
19
+ const HALF = N / 2;
20
+ const BIN_HZ = SR / N;
21
+ const NBANDS = 32;
22
+ const BAND_LO = 30;
23
+ const BAND_HI = 16000;
24
+ const DB_RANGE = 70;
25
+ const ATTACK_MS = 10;
26
+ const RELEASE_MS = 150;
27
+ // Onset picking: adaptive threshold (moving median over +/-0.2 s + delta)
28
+ // with a ~0.1 s minimum gap, plus low-band (kick) flux weighting.
29
+ const ONSET_FLOOR = 0.1;
30
+ const ONSET_DELTA = 0.25;
31
+ const ONSET_MED_HALF = 6; // frames each side (~0.2 s at 30 fps)
32
+ const MIN_GAP_SEC = 0.1;
33
+ const KICK_WEIGHT = 0.5;
34
+ // Tempo prior: log-normal centred at 120 BPM, sigma ~1 octave, over 60-180.
35
+ const PRIOR_REF_BPM = 120;
36
+
37
+ // Log-spaced band edges, 30 Hz - 16 kHz, 32 bands.
38
+ const BAND_EDGES = Array.from(
39
+ { length: NBANDS + 1 },
40
+ (_, k) => BAND_LO * Math.pow(BAND_HI / BAND_LO, k / NBANDS),
41
+ );
42
+
43
+ const HANN = new Float64Array(N);
44
+ for (let n = 0; n < N; n++) {
45
+ HANN[n] = 0.5 * (1 - Math.cos((2 * Math.PI * n) / (N - 1)));
46
+ }
47
+
48
+ const REV = new Uint16Array(N);
49
+ {
50
+ const bits = Math.log2(N);
51
+ for (let i = 0; i < N; i++) {
52
+ let r = 0;
53
+ for (let b = 0; b < bits; b++) r |= ((i >>> b) & 1) << (bits - 1 - b);
54
+ REV[i] = r;
55
+ }
56
+ }
57
+ const TW_RE = new Float64Array(HALF);
58
+ const TW_IM = new Float64Array(HALF);
59
+ for (let k = 0; k < HALF; k++) {
60
+ TW_RE[k] = Math.cos((-2 * Math.PI * k) / N);
61
+ TW_IM[k] = Math.sin((-2 * Math.PI * k) / N);
62
+ }
63
+
64
+ // In-place radix-2 DIT FFT on Float64Array pairs.
65
+ function fft(re, im) {
66
+ for (let i = 0; i < N; i++) {
67
+ const j = REV[i];
68
+ if (j > i) {
69
+ const tr = re[i]; re[i] = re[j]; re[j] = tr;
70
+ const ti = im[i]; im[i] = im[j]; im[j] = ti;
71
+ }
72
+ }
73
+ for (let len = 2; len <= N; len <<= 1) {
74
+ const half = len >>> 1;
75
+ const step = N / len;
76
+ for (let base = 0; base < N; base += len) {
77
+ for (let j = 0; j < half; j++) {
78
+ const w = TW_RE[j * step];
79
+ const wi = TW_IM[j * step];
80
+ const a = base + j;
81
+ const b = a + half;
82
+ const tr = re[b] * w - im[b] * wi;
83
+ const ti = re[b] * wi + im[b] * w;
84
+ const ar = re[a];
85
+ const ai = im[a];
86
+ re[a] = ar + tr;
87
+ im[a] = ai + ti;
88
+ re[b] = ar - tr;
89
+ im[b] = ai - ti;
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
96
+ // Power (relative to a full-scale sine peak of 0.25) mapped dB -> 0..1 over 70 dB.
97
+ function db01(power) {
98
+ return clamp01((10 * Math.log10(power + 1e-12) + DB_RANGE) / DB_RANGE);
99
+ }
100
+ const r6 = (v) => {
101
+ const r = Math.round(v * 1e6) / 1e6;
102
+ return r === 0 ? 0 : r;
103
+ };
104
+
105
+ function binRange(loHz, hiHz) {
106
+ const lo = Math.max(1, Math.ceil(loHz / BIN_HZ));
107
+ const hi = Math.min(HALF - 1, Math.floor((hiHz - 1e-9) / BIN_HZ));
108
+ return [lo, hi];
109
+ }
110
+
111
+ function decodeAudio(path, { startSec, durationSec }) {
112
+ return new Promise((resolve, reject) => {
113
+ const args = ['-v', 'error', '-ss', String(startSec), '-i', path];
114
+ if (durationSec != null) args.push('-t', String(durationSec));
115
+ args.push('-ar', String(SR), '-ac', '2', '-f', 'f32le', '-acodec', 'pcm_f32le', 'pipe:1');
116
+ const child = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
117
+ const chunks = [];
118
+ let stderr = '';
119
+ child.stdout.on('data', (c) => chunks.push(c));
120
+ child.stderr.on('data', (c) => { stderr += c; });
121
+ child.on('error', (err) => {
122
+ reject(new Error(`ffmpeg failed to decode ${path}: ${err.message}`));
123
+ });
124
+ child.on('close', (code) => {
125
+ if (code !== 0) {
126
+ reject(new Error(`ffmpeg failed to decode ${path} (exit ${code}): ${stderr.trim()}`));
127
+ return;
128
+ }
129
+ const buf = Buffer.concat(chunks);
130
+ const n = Math.floor(buf.length / 8);
131
+ const left = new Float32Array(n);
132
+ const right = new Float32Array(n);
133
+ for (let i = 0; i < n; i++) {
134
+ left[i] = buf.readFloatLE(i * 8);
135
+ right[i] = buf.readFloatLE(i * 8 + 4);
136
+ }
137
+ resolve({ left, right, samples: n });
138
+ });
139
+ });
140
+ }
141
+
142
+ function windowAt(data, center, out) {
143
+ const start = center - HALF;
144
+ for (let i = 0; i < N; i++) {
145
+ const idx = start + i;
146
+ out[i] = idx >= 0 && idx < data.length ? data[idx] * HANN[i] : 0;
147
+ }
148
+ }
149
+
150
+ // Magnitude-squared spectrum (normalised so a full-scale sine peaks at 0.25).
151
+ function magnitudeSquared(monoWin, re, im, mag2) {
152
+ for (let i = 0; i < N; i++) {
153
+ re[i] = monoWin[i];
154
+ im[i] = 0;
155
+ }
156
+ fft(re, im);
157
+ for (let b = 0; b < HALF; b++) {
158
+ mag2[b] = (re[b] * re[b] + im[b] * im[b]) / (N * N);
159
+ }
160
+ }
161
+
162
+ export async function analyzeAudio(path, { fps = 30, startSec = 0, durationSec = null } = {}) {
163
+ try {
164
+ await fs.access(path);
165
+ } catch {
166
+ throw new Error(`audio file not found: ${path}`);
167
+ }
168
+ const { left, right, samples } = await decodeAudio(path, { startSec, durationSec });
169
+ const hop = SR / fps;
170
+ const totalSec = durationSec != null ? durationSec : samples / SR;
171
+ const nFrames = Math.max(1, Math.floor(totalSec * fps));
172
+
173
+ const attA = 1 - Math.exp(-(1 / fps) / (ATTACK_MS / 1000));
174
+ const relA = 1 - Math.exp(-(1 / fps) / (RELEASE_MS / 1000));
175
+ const smooth = (cur, target) => cur + (target - cur) * (target > cur ? attA : relA);
176
+
177
+ const re = new Float64Array(N);
178
+ const im = new Float64Array(N);
179
+ const mag2 = new Float64Array(HALF);
180
+ const monoWin = new Float64Array(N);
181
+ const stereoL = new Float64Array(N);
182
+ const stereoR = new Float64Array(N);
183
+
184
+ const [bassLo, bassHi] = binRange(20, 320);
185
+ const [midLo, midHi] = binRange(320, 2800);
186
+ const [trebLo, trebHi] = binRange(2800, 11025);
187
+ const bandRanges = Array.from({ length: NBANDS }, (_, k) => {
188
+ const lo = Math.max(1, Math.ceil(BAND_EDGES[k] / BIN_HZ));
189
+ const hi = Math.min(HALF - 1, Math.floor((BAND_EDGES[k + 1] - 1e-9) / BIN_HZ));
190
+ if (lo <= hi) return [lo, hi];
191
+ // A band narrower than one FFT bin takes the bin nearest its centre, so no band is dead.
192
+ const c = Math.min(HALF - 1, Math.max(1, Math.round(Math.sqrt(BAND_EDGES[k] * BAND_EDGES[k + 1]) / BIN_HZ)));
193
+ return [c, c];
194
+ });
195
+
196
+ const frames = [];
197
+ const magStore = new Array(nFrames);
198
+ const kickRaw = new Float64Array(nFrames);
199
+ const bandState = new Float64Array(NBANDS);
200
+ let bassAtt = 0;
201
+ let midAtt = 0;
202
+ let trebAtt = 0;
203
+ const longHist = { bass: [], mid: [], treb: [] };
204
+ const longWin = Math.max(1, Math.round(4 * fps));
205
+
206
+ const monoAt = (idx) => {
207
+ const l = idx >= 0 && idx < left.length ? left[idx] : 0;
208
+ const r = idx >= 0 && idx < right.length ? right[idx] : 0;
209
+ return (l + r) * 0.5;
210
+ };
211
+
212
+ for (let i = 0; i < nFrames; i++) {
213
+ const center = Math.round(i * hop);
214
+ windowAt(left, center, stereoL);
215
+ windowAt(right, center, stereoR);
216
+ for (let k = 0; k < N; k++) monoWin[k] = (stereoL[k] + stereoR[k]) * 0.5;
217
+
218
+ // rms/peak on unwindowed mono over one hop around the frame centre.
219
+ const w0 = Math.round(center - hop / 2);
220
+ const w1 = Math.round(center + hop / 2);
221
+ let sumSq = 0;
222
+ let peak = 0;
223
+ let wcount = 0;
224
+ for (let k = w0; k < w1; k++) {
225
+ const m = monoAt(k);
226
+ sumSq += m * m;
227
+ const a = Math.abs(m);
228
+ if (a > peak) peak = a;
229
+ wcount++;
230
+ }
231
+ let eL = 0;
232
+ let eR = 0;
233
+ let dot = 0;
234
+ // Energy/balance from the unwindowed stereo neighbourhood.
235
+ const s0 = center - HALF;
236
+ for (let k = 0; k < N; k++) {
237
+ const idx = s0 + k;
238
+ const l = idx >= 0 && idx < left.length ? left[idx] : 0;
239
+ const r = idx >= 0 && idx < right.length ? right[idx] : 0;
240
+ eL += l * l;
241
+ eR += r * r;
242
+ dot += l * r;
243
+ }
244
+ const rms = wcount > 0 ? Math.sqrt(sumSq / wcount) : 0;
245
+ const rmsDb = rms > 0 ? 20 * Math.log10(rms) : -120;
246
+
247
+ magnitudeSquared(monoWin, re, im, mag2);
248
+ magStore[i] = Float64Array.from(mag2);
249
+ let kick = 0;
250
+ for (let b = bassLo; b <= bassHi; b++) kick += Math.sqrt(mag2[b]);
251
+ kickRaw[i] = kick;
252
+
253
+ const bands = new Array(NBANDS);
254
+ for (let k = 0; k < NBANDS; k++) {
255
+ const [lo, hi] = bandRanges[k];
256
+ let e = 0;
257
+ for (let b = lo; b <= hi; b++) e += mag2[b];
258
+ bandState[k] = smooth(bandState[k], db01(e));
259
+ bands[k] = r6(bandState[k]);
260
+ }
261
+
262
+ const bandEnergy = (lo, hi) => {
263
+ let e = 0;
264
+ for (let b = lo; b <= hi; b++) e += mag2[b];
265
+ return e;
266
+ };
267
+ const pushLong = (key, e) => {
268
+ const h = longHist[key];
269
+ h.push(e);
270
+ if (h.length > longWin) h.shift();
271
+ let s = 0;
272
+ for (const v of h) s += v;
273
+ return s / h.length;
274
+ };
275
+ const eBass = bandEnergy(bassLo, bassHi);
276
+ const eMid = bandEnergy(midLo, midHi);
277
+ const eTreb = bandEnergy(trebLo, trebHi);
278
+ const avgBass = pushLong('bass', eBass);
279
+ const avgMid = pushLong('mid', eMid);
280
+ const avgTreb = pushLong('treb', eTreb);
281
+ const bass = eBass / (avgBass + 1e-12);
282
+ const mid = eMid / (avgMid + 1e-12);
283
+ const treb = eTreb / (avgTreb + 1e-12);
284
+ bassAtt = smooth(bassAtt, bass);
285
+ midAtt = smooth(midAtt, mid);
286
+ trebAtt = smooth(trebAtt, treb);
287
+
288
+ const denom = Math.sqrt(eL * eR);
289
+ const corr = denom > 1e-12 ? dot / denom : 0;
290
+ const width = clamp01(1 - Math.abs(corr));
291
+ const balance = eL + eR > 1e-12 ? (eR - eL) / (eR + eL) : 0;
292
+
293
+ const spec = new Array(512);
294
+ for (let s = 0; s < 512; s++) {
295
+ let e = 0;
296
+ for (let b = s * 4 + 1; b <= s * 4 + 4 && b < HALF; b++) e += mag2[b];
297
+ spec[s] = r6(db01(e / 4));
298
+ }
299
+
300
+ // Trigger-aligned waveform from unwindowed mono: first rising zero
301
+ // crossing in the 1024 samples before the frame centre, else near it.
302
+ // (Windowed samples would taper the display to near-flat.)
303
+ const segStart = center - 1024;
304
+ const seg = new Float64Array(2048);
305
+ for (let k = 0; k < 2048; k++) seg[k] = monoAt(segStart + k);
306
+ let start = 1024 - 256;
307
+ for (let k = 0; k + 1 < 1536; k++) {
308
+ if (seg[k] <= 0 && seg[k + 1] > 0) {
309
+ start = k + 1;
310
+ break;
311
+ }
312
+ }
313
+ const wave = new Array(512);
314
+ const waveL = new Array(512);
315
+ const waveR = new Array(512);
316
+ for (let k = 0; k < 512; k++) {
317
+ wave[k] = r6(seg[start + k]);
318
+ // Stereo rows at the same trigger index, so scopes and XY see true L/R.
319
+ const idx = segStart + start + k;
320
+ waveL[k] = r6(idx >= 0 && idx < left.length ? left[idx] : 0);
321
+ waveR[k] = r6(idx >= 0 && idx < right.length ? right[idx] : 0);
322
+ }
323
+
324
+ const xy = new Array(2048);
325
+ for (let k = 0; k < 2048; k++) {
326
+ const idx = s0 + HALF - 1024 + k;
327
+ const l = idx >= 0 && idx < left.length ? left[idx] : 0;
328
+ const r = idx >= 0 && idx < right.length ? right[idx] : 0;
329
+ xy[k] = [r6(l), r6(r)];
330
+ }
331
+
332
+ frames.push({
333
+ t: r6(i / fps),
334
+ rms: r6(rms),
335
+ peak: r6(peak),
336
+ rmsDb: r6(rmsDb),
337
+ bands,
338
+ spec,
339
+ bass: r6(bass),
340
+ mid: r6(mid),
341
+ treb: r6(treb),
342
+ bass_att: r6(bassAtt),
343
+ mid_att: r6(midAtt),
344
+ treb_att: r6(trebAtt),
345
+ flux: 0, // filled in pass two
346
+ onset: 0,
347
+ beat: 0,
348
+ beatPhase: 0,
349
+ bpm: 0, // filled in pass two (track bpm copied per frame)
350
+ corr: r6(clamp01(corr * 0.5 + 0.5) * 2 - 1),
351
+ width: r6(width),
352
+ balance: r6(balance),
353
+ wave,
354
+ waveL,
355
+ waveR,
356
+ xy,
357
+ });
358
+ }
359
+
360
+ // Pass two: flux from stored magnitudes (frame 0 uses a forward
361
+ // difference so a clip starting mid-sound has no spurious onset there,
362
+ // while a hit landing exactly at 0 still reads as an onset), normalise
363
+ // by the track 95th percentile, peak-pick onsets with an adaptive
364
+ // threshold and minimum gap, refine tempo off the frame grid, and lay a
365
+ // beat grid on the exact (fractional) period.
366
+ const magOf = (i, b) => Math.sqrt(magStore[i][b]);
367
+ const fluxRaw = new Float64Array(nFrames);
368
+ for (let i = 0; i < nFrames; i++) {
369
+ let f = 0;
370
+ for (let b = 1; b < HALF; b++) {
371
+ const later = i + 1 < nFrames ? magOf(i + 1, b) : 0;
372
+ const d = i === 0 ? magOf(0, b) - later : magOf(i, b) - magOf(i - 1, b);
373
+ if (d > 0) f += d;
374
+ }
375
+ fluxRaw[i] = f;
376
+ }
377
+ const p95of = (arr) => {
378
+ const s = Array.from(arr).sort((a, b) => a - b);
379
+ return s.length ? s[Math.min(s.length - 1, Math.floor(s.length * 0.95))] : 0;
380
+ };
381
+ const fluxNorm = p95of(fluxRaw) > 1e-12 ? p95of(fluxRaw) : 1;
382
+ const kickNorm = p95of(kickRaw) > 1e-12 ? p95of(kickRaw) : 1;
383
+ const flux = new Array(nFrames);
384
+ const score = new Array(nFrames);
385
+ for (let i = 0; i < nFrames; i++) {
386
+ flux[i] = fluxRaw[i] / fluxNorm;
387
+ score[i] = flux[i] + KICK_WEIGHT * (kickRaw[i] / kickNorm);
388
+ }
389
+
390
+ // Adaptive threshold: moving median over +/-0.2 s plus a delta, floored.
391
+ const onsets = [];
392
+ {
393
+ const sorted = [];
394
+ const medAt = (i) => {
395
+ sorted.length = 0;
396
+ for (let j = Math.max(0, i - ONSET_MED_HALF); j <= Math.min(nFrames - 1, i + ONSET_MED_HALF); j++) {
397
+ sorted.push(score[j]);
398
+ }
399
+ sorted.sort((a, b) => a - b);
400
+ return sorted[Math.floor(sorted.length / 2)];
401
+ };
402
+ const gap = Math.max(2, Math.round(MIN_GAP_SEC * fps));
403
+ const cands = [];
404
+ for (let i = 0; i < nFrames; i++) {
405
+ const leftS = i > 0 ? score[i - 1] : -Infinity;
406
+ const rightS = i < nFrames - 1 ? score[i + 1] : -Infinity;
407
+ const thresh = Math.max(ONSET_FLOOR, medAt(i) + ONSET_DELTA);
408
+ if (score[i] > thresh && score[i] >= leftS && score[i] > rightS) {
409
+ cands.push(i);
410
+ }
411
+ }
412
+ cands.sort((a, b) => score[b] - score[a] || a - b);
413
+ const taken = new Array(nFrames).fill(false);
414
+ for (const c of cands) {
415
+ let clash = false;
416
+ for (let j = Math.max(0, c - gap + 1); j <= Math.min(nFrames - 1, c + gap - 1); j++) {
417
+ if (taken[j]) {
418
+ clash = true;
419
+ break;
420
+ }
421
+ }
422
+ if (!clash) {
423
+ taken[c] = true;
424
+ onsets.push(c);
425
+ }
426
+ }
427
+ onsets.sort((a, b) => a - b);
428
+ }
429
+ const onsetSet = new Set(onsets);
430
+ for (let i = 0; i < nFrames; i++) {
431
+ frames[i].flux = r6(flux[i]);
432
+ frames[i].onset = onsetSet.has(i) ? 1 : 0;
433
+ }
434
+
435
+ // Tempo: mean-removed, per-lag normalised autocorrelation with a
436
+ // log-normal prior at 120 BPM (sigma ~1 octave), refined to fractional
437
+ // lags by interpolation plus parabolic interpolation.
438
+ let bpm = 0;
439
+ if (nFrames >= 8) {
440
+ let mean = 0;
441
+ for (let i = 0; i < nFrames; i++) mean += flux[i];
442
+ mean /= nFrames;
443
+ const y = flux.map((v) => v - mean);
444
+ const yAt = (t) => {
445
+ if (t < 0 || t > nFrames - 1) return 0;
446
+ const i0 = Math.floor(t);
447
+ const f = t - i0;
448
+ const a = y[i0];
449
+ const b = i0 + 1 < nFrames ? y[i0 + 1] : 0;
450
+ return a + (b - a) * f;
451
+ };
452
+ const prior = (lag) => {
453
+ const b = (60 * fps) / lag;
454
+ const oct = Math.log2(b / PRIOR_REF_BPM);
455
+ return Math.exp(-0.5 * oct * oct);
456
+ };
457
+ const corrAt = (lag) => {
458
+ const start = Math.ceil(lag);
459
+ let s = 0;
460
+ for (let i = start; i < nFrames; i++) s += y[i] * yAt(i - lag);
461
+ return (s / Math.max(1, nFrames - start)) * prior(lag);
462
+ };
463
+ const lo = Math.max(2, Math.floor((60 * fps) / 180));
464
+ const hi = Math.ceil((60 * fps) / 60);
465
+ let bestLag = 0;
466
+ let bestScore = -Infinity;
467
+ for (let lag = lo; lag <= hi && lag < nFrames; lag++) {
468
+ const s = corrAt(lag);
469
+ if (s > bestScore) {
470
+ bestScore = s;
471
+ bestLag = lag;
472
+ }
473
+ }
474
+ // Comb score: how well a period explains the detected onsets (1 = every
475
+ // onset on the grid). Phase is fitted over integer offsets.
476
+ const combScore = (p) => {
477
+ const K = Math.max(1, Math.ceil(p));
478
+ let bestS = 0;
479
+ for (let off = 0; off < K; off++) {
480
+ let s = 0;
481
+ for (const o of onsets) {
482
+ const k = Math.round((o - off) / p);
483
+ const g = off + k * p;
484
+ const d = o - g;
485
+ s += Math.exp(-(d * d) / 2);
486
+ }
487
+ if (s > bestS) bestS = s;
488
+ }
489
+ return bestS / Math.max(1, onsets.length);
490
+ };
491
+ if (bestScore > 1e-9 && bestLag > 0) {
492
+ // Fractional refinement on a fine grid, then parabolic vertex.
493
+ const step = 0.05;
494
+ const glo = Math.max(lo, bestLag - 1);
495
+ const ghi = Math.min(hi, bestLag + 1);
496
+ let gl = bestLag;
497
+ let gs = -Infinity;
498
+ for (let lag = glo; lag <= ghi + 1e-9; lag += step) {
499
+ const s = corrAt(lag);
500
+ if (s > gs) {
501
+ gs = s;
502
+ gl = lag;
503
+ }
504
+ }
505
+ let lag = gl;
506
+ const s0 = corrAt(gl - step);
507
+ const s1 = gs;
508
+ const s2 = corrAt(gl + step);
509
+ const denom = s0 - 2 * s1 + s2;
510
+ if (denom < 0) {
511
+ const off = 0.5 * (s0 - s2) / denom;
512
+ if (Math.abs(off) <= 0.5) lag = gl + off * step;
513
+ }
514
+ let period = lag;
515
+ // Onset-line fit: when detected onsets are nearly collinear in
516
+ // (index, frame), their slope pins the period far more precisely than
517
+ // the envelope autocorrelation (whose pulse shapes vary with
518
+ // sub-frame hit position). It also corrects octave errors where the
519
+ // autocorrelation locked onto a multiple of the true period.
520
+ if (onsets.length >= 5) {
521
+ const n = onsets.length;
522
+ let sk = 0;
523
+ let so = 0;
524
+ let skk = 0;
525
+ let sko = 0;
526
+ for (let k = 0; k < n; k++) {
527
+ sk += k;
528
+ so += onsets[k];
529
+ skk += k * k;
530
+ sko += k * onsets[k];
531
+ }
532
+ const denomLs = n * skk - sk * sk;
533
+ if (denomLs > 0) {
534
+ const pLs = (n * sko - sk * so) / denomLs;
535
+ const icept = (so - pLs * sk) / n;
536
+ let rss = 0;
537
+ for (let k = 0; k < n; k++) {
538
+ const r = onsets[k] - (icept + pLs * k);
539
+ rss += r * r;
540
+ }
541
+ const resid = Math.sqrt(rss / n);
542
+ const inRange = pLs >= lo && pLs <= hi;
543
+ const close = (a, b, tol) => Math.abs(a - b) / b < tol;
544
+ if (resid < 0.75 && inRange) {
545
+ if (close(pLs, lag, 0.03)) {
546
+ period = pLs;
547
+ } else {
548
+ for (const m of [2, 3]) {
549
+ if (close(lag / m, pLs, 0.04) && combScore(pLs) >= 0.75) {
550
+ period = pLs;
551
+ break;
552
+ }
553
+ }
554
+ }
555
+ }
556
+ }
557
+ }
558
+ bpm = r6((60 * fps) / period);
559
+ }
560
+ }
561
+
562
+ const beats = [];
563
+ if (bpm > 0) {
564
+ // Exact (fractional) period; beat k lands on round(phase + k*period)
565
+ // so the grid cannot drift. Phase is fitted to the detected onsets.
566
+ const period = (60 * fps) / bpm;
567
+ const K = Math.max(1, Math.ceil(period));
568
+ let maxF = 0;
569
+ for (let i = 0; i < nFrames; i++) if (flux[i] > maxF) maxF = flux[i];
570
+ let phase = 0;
571
+ let best = -Infinity;
572
+ for (let off = 0; off < K; off++) {
573
+ let s = 0;
574
+ for (const o of onsets) {
575
+ const k = Math.round((o - off) / period);
576
+ const g = off + k * period;
577
+ s += Math.max(0, 1 - Math.abs(o - g) / 1.5);
578
+ }
579
+ if (s > best) {
580
+ best = s;
581
+ phase = off;
582
+ }
583
+ }
584
+ for (let k = 0; ; k++) {
585
+ const g = Math.round(phase + k * period);
586
+ if (g >= nFrames) break;
587
+ if (flux[g] > 0.05 * maxF) {
588
+ frames[g].beat = 1;
589
+ beats.push(g);
590
+ }
591
+ }
592
+ for (let i = 0; i < nFrames; i++) {
593
+ frames[i].beatPhase = r6(((((i - phase) / period) % 1) + 1) % 1);
594
+ }
595
+ }
596
+ for (const fr of frames) fr.bpm = bpm;
597
+
598
+ const meta = {
599
+ sr: SR,
600
+ fps,
601
+ frames: nFrames,
602
+ bpm,
603
+ onsets,
604
+ beats,
605
+ };
606
+ return { meta, frames };
607
+ }
608
+
609
+ export async function writeFeatures(features, outPath) {
610
+ await fs.writeFile(outPath, JSON.stringify(features));
611
+ return outPath;
612
+ }
613
+
614
+ export function summarize(features) {
615
+ return {
616
+ bpm: features.meta.bpm,
617
+ onsetCount: features.meta.onsets.length,
618
+ beatCount: features.meta.beats.length,
619
+ frames: features.meta.frames,
620
+ };
621
+ }
@@ -0,0 +1,24 @@
1
+ // agentvfx (agentvfx licence, see LICENSE). Adapted from lit-typographic-motion 1.0.45 engine/node/board.mjs: only the
2
+ // tile geometry is kept. Look-gate boards (references beside rendered frames) are composed in Node
3
+ // from RGBA buffers by stills.mjs, so no browser or font is involved.
4
+
5
+ export const BOARD = Object.freeze({ width: 1920, height: 1080, margin: 48, gap: 20, header: 150, footer: 56, caption: 104 });
6
+
7
+ /** Tile geometry: columns, thumbnail size, for n tiles of a frame of size width x height. */
8
+ export function boardGrid(n, width, height) {
9
+ const usableW = BOARD.width - 2 * BOARD.margin;
10
+ const usableH = BOARD.height - BOARD.header - BOARD.footer - BOARD.margin;
11
+ const aspect = width / height;
12
+ const options = [];
13
+ for (let cols = 1; cols <= Math.max(1, n); cols++) {
14
+ const rows = Math.ceil(n / cols);
15
+ const tw = Math.floor((usableW - (cols - 1) * BOARD.gap) / cols);
16
+ const byHeight = Math.floor((usableH - (rows - 1) * BOARD.gap) / rows) - BOARD.caption;
17
+ const th = Math.min(Math.round(tw / aspect), byHeight);
18
+ if (th <= 0) continue;
19
+ options.push({ cols, rows, tw: Math.round(th * aspect), th, empty: rows * cols - n });
20
+ }
21
+ // The largest tiles, unless a grid with fewer empty slots keeps 85 % of their area.
22
+ const largest = Math.max(...options.map((o) => o.tw * o.th));
23
+ return options.filter((o) => o.tw * o.th >= 0.85 * largest).sort((a, b) => a.empty - b.empty || b.tw * b.th - a.tw * a.th)[0];
24
+ }