dsh-drawai 0.3.4 → 0.4.1

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 (538) hide show
  1. package/LICENSE +4154 -0
  2. package/README.md +49 -11
  3. package/lib/audio-media.js +150 -0
  4. package/lib/audio-service.js +66 -0
  5. package/lib/canvas-format.js +134 -0
  6. package/lib/client.js +5090 -184
  7. package/lib/comfyui-default-workflow.mjs +1825 -0
  8. package/lib/comfyui-workflow.mjs +426 -0
  9. package/lib/document-commands.js +12 -0
  10. package/lib/document-core.js +29 -0
  11. package/lib/drawing-runner.js +6 -4
  12. package/lib/drawing-tasks.js +26 -0
  13. package/lib/ffmpeg-manager.js +1056 -0
  14. package/lib/imagegen.mjs +596 -0
  15. package/lib/index.js +1762 -129
  16. package/lib/media.js +26 -13
  17. package/lib/mgstyle-core.js +75 -0
  18. package/lib/motion/apply.js +181 -0
  19. package/lib/motion/asset-pack-lock.js +10 -0
  20. package/lib/motion/asset-pack.js +335 -0
  21. package/lib/motion/assets.js +291 -0
  22. package/lib/motion/audio.js +103 -0
  23. package/lib/motion/audiolevel.js +63 -0
  24. package/lib/motion/audiomix.js +144 -0
  25. package/lib/motion/beats.js +338 -0
  26. package/lib/motion/builtin-assets.js +62 -0
  27. package/lib/motion/canvas-spec.js +442 -0
  28. package/lib/motion/capture.js +95 -0
  29. package/lib/motion/cards.js +80 -0
  30. package/lib/motion/collect.js +449 -0
  31. package/lib/motion/delivery.js +140 -0
  32. package/lib/motion/edge-speech.py +90 -0
  33. package/lib/motion/entry.jsx +73 -0
  34. package/lib/motion/export.js +200 -0
  35. package/lib/motion/ffmpeg.js +43 -0
  36. package/lib/motion/fonts.js +71 -0
  37. package/lib/motion/jobs.js +117 -0
  38. package/lib/motion/json.js +4 -0
  39. package/lib/motion/media-catalog.js +59 -0
  40. package/lib/motion/mgstyles/BRIEF-TEMPLATE.md +63 -0
  41. package/lib/motion/mgstyles/briefs/01-flat-vector.md +104 -0
  42. package/lib/motion/mgstyles/briefs/02-line-art.md +97 -0
  43. package/lib/motion/mgstyles/briefs/03-isometric.md +108 -0
  44. package/lib/motion/mgstyles/briefs/05-cel-boil.md +145 -0
  45. package/lib/motion/mgstyles/briefs/06-collage.md +149 -0
  46. package/lib/motion/mgstyles/briefs/07-liquid.md +106 -0
  47. package/lib/motion/mgstyles/briefs/08-morph.md +111 -0
  48. package/lib/motion/mgstyles/briefs/09-bauhaus.md +150 -0
  49. package/lib/motion/mgstyles/briefs/10-synthwave.md +151 -0
  50. package/lib/motion/mgstyles/briefs/12-aurora-glass.md +147 -0
  51. package/lib/motion/mgstyles/briefs/18-hanazi.md +140 -0
  52. package/lib/motion/mgstyles/briefs/19-paperclip.md +156 -0
  53. package/lib/motion/mgstyles/briefs/20-pixel.md +118 -0
  54. package/lib/motion/mgstyles/briefs/22-hud.md +118 -0
  55. package/lib/motion/mgstyles/catalog.d/01-flat-vector.json +34 -0
  56. package/lib/motion/mgstyles/catalog.d/02-line-art.json +34 -0
  57. package/lib/motion/mgstyles/catalog.d/03-isometric.json +35 -0
  58. package/lib/motion/mgstyles/catalog.d/05-cel-boil.json +28 -0
  59. package/lib/motion/mgstyles/catalog.d/06-collage.json +28 -0
  60. package/lib/motion/mgstyles/catalog.d/07-liquid.json +28 -0
  61. package/lib/motion/mgstyles/catalog.d/08-morph.json +28 -0
  62. package/lib/motion/mgstyles/catalog.d/09-bauhaus.json +28 -0
  63. package/lib/motion/mgstyles/catalog.d/10-synthwave.json +28 -0
  64. package/lib/motion/mgstyles/catalog.d/12-aurora-glass.json +28 -0
  65. package/lib/motion/mgstyles/catalog.d/18-hanazi.json +28 -0
  66. package/lib/motion/mgstyles/catalog.d/19-paperclip.json +28 -0
  67. package/lib/motion/mgstyles/catalog.d/20-pixel.json +28 -0
  68. package/lib/motion/mgstyles/catalog.d/22-hud.json +28 -0
  69. package/lib/motion/mgstyles/catalog.json +1356 -0
  70. package/lib/motion/mgstyles/upstream/README.md +147 -0
  71. package/lib/motion/mgstyles/upstream/README.zh-CN.md +147 -0
  72. package/lib/motion/mgstyles/upstream/demos/01-flat-vector/cues.json +1 -0
  73. package/lib/motion/mgstyles/upstream/demos/01-flat-vector/index.html +712 -0
  74. package/lib/motion/mgstyles/upstream/demos/02-line-art/cues.json +331 -0
  75. package/lib/motion/mgstyles/upstream/demos/02-line-art/film.js +676 -0
  76. package/lib/motion/mgstyles/upstream/demos/02-line-art/index.html +18 -0
  77. package/lib/motion/mgstyles/upstream/demos/02-line-art/tools/dump_timing.mjs +26 -0
  78. package/lib/motion/mgstyles/upstream/demos/03-isometric/cues.json +109 -0
  79. package/lib/motion/mgstyles/upstream/demos/03-isometric/index.html +25 -0
  80. package/lib/motion/mgstyles/upstream/demos/03-isometric/js/anim.js +82 -0
  81. package/lib/motion/mgstyles/upstream/demos/03-isometric/js/main.js +219 -0
  82. package/lib/motion/mgstyles/upstream/demos/03-isometric/js/night5.js +180 -0
  83. package/lib/motion/mgstyles/upstream/demos/03-isometric/js/render.js +202 -0
  84. package/lib/motion/mgstyles/upstream/demos/03-isometric/js/title.js +167 -0
  85. package/lib/motion/mgstyles/upstream/demos/03-isometric/js/title5.js +294 -0
  86. package/lib/motion/mgstyles/upstream/demos/03-isometric/js/ui.js +141 -0
  87. package/lib/motion/mgstyles/upstream/demos/03-isometric/js/world.js +1399 -0
  88. package/lib/motion/mgstyles/upstream/demos/05-cel-boil/cues.json +157 -0
  89. package/lib/motion/mgstyles/upstream/demos/05-cel-boil/index.html +34 -0
  90. package/lib/motion/mgstyles/upstream/demos/05-cel-boil/js/engine.js +326 -0
  91. package/lib/motion/mgstyles/upstream/demos/05-cel-boil/js/scene.js +2036 -0
  92. package/lib/motion/mgstyles/upstream/demos/06-collage/assets/gen/layout.json +157 -0
  93. package/lib/motion/mgstyles/upstream/demos/06-collage/cues.json +80 -0
  94. package/lib/motion/mgstyles/upstream/demos/06-collage/index.html +889 -0
  95. package/lib/motion/mgstyles/upstream/demos/07-liquid/cues.json +100 -0
  96. package/lib/motion/mgstyles/upstream/demos/07-liquid/index.html +108 -0
  97. package/lib/motion/mgstyles/upstream/demos/07-liquid/js/engine.js +153 -0
  98. package/lib/motion/mgstyles/upstream/demos/07-liquid/js/links.js +64 -0
  99. package/lib/motion/mgstyles/upstream/demos/07-liquid/js/lookdev.js +46 -0
  100. package/lib/motion/mgstyles/upstream/demos/07-liquid/js/scene.js +963 -0
  101. package/lib/motion/mgstyles/upstream/demos/07-liquid/js/shaders.js +647 -0
  102. package/lib/motion/mgstyles/upstream/demos/07-liquid/js/textsdf.js +114 -0
  103. package/lib/motion/mgstyles/upstream/demos/07-liquid/js/type.js +50 -0
  104. package/lib/motion/mgstyles/upstream/demos/08-morph/cues.json +131 -0
  105. package/lib/motion/mgstyles/upstream/demos/08-morph/glyphs.json +1 -0
  106. package/lib/motion/mgstyles/upstream/demos/08-morph/index.html +901 -0
  107. package/lib/motion/mgstyles/upstream/demos/09-bauhaus/cues.json +270 -0
  108. package/lib/motion/mgstyles/upstream/demos/09-bauhaus/film.js +697 -0
  109. package/lib/motion/mgstyles/upstream/demos/09-bauhaus/index.html +12 -0
  110. package/lib/motion/mgstyles/upstream/demos/10-synthwave/cues.json +44 -0
  111. package/lib/motion/mgstyles/upstream/demos/10-synthwave/index.html +1028 -0
  112. package/lib/motion/mgstyles/upstream/demos/12-aurora-glass/cues.json +67 -0
  113. package/lib/motion/mgstyles/upstream/demos/12-aurora-glass/index.html +16 -0
  114. package/lib/motion/mgstyles/upstream/demos/12-aurora-glass/main.js +967 -0
  115. package/lib/motion/mgstyles/upstream/demos/12-aurora-glass/shaders.js +468 -0
  116. package/lib/motion/mgstyles/upstream/demos/18-hanazi/cues.json +117 -0
  117. package/lib/motion/mgstyles/upstream/demos/18-hanazi/index.html +541 -0
  118. package/lib/motion/mgstyles/upstream/demos/19-paperclip/assets/countries-110m.json +1 -0
  119. package/lib/motion/mgstyles/upstream/demos/19-paperclip/assets/stickers/meta.json +34 -0
  120. package/lib/motion/mgstyles/upstream/demos/19-paperclip/cues.json +285 -0
  121. package/lib/motion/mgstyles/upstream/demos/19-paperclip/index.html +599 -0
  122. package/lib/motion/mgstyles/upstream/demos/20-pixel/cues.json +42 -0
  123. package/lib/motion/mgstyles/upstream/demos/20-pixel/film.js +914 -0
  124. package/lib/motion/mgstyles/upstream/demos/20-pixel/index.html +18 -0
  125. package/lib/motion/mgstyles/upstream/demos/22-hud/cues.json +108 -0
  126. package/lib/motion/mgstyles/upstream/demos/22-hud/index.html +125 -0
  127. package/lib/motion/mgstyles/upstream/demos/22-hud/js/holo.js +275 -0
  128. package/lib/motion/mgstyles/upstream/demos/22-hud/js/hud.js +855 -0
  129. package/lib/motion/mgstyles/upstream/demos/22-hud/js/post.js +150 -0
  130. package/lib/motion/mgstyles/upstream/demos/22-hud/js/util.js +65 -0
  131. package/lib/motion/mgstyles/upstream/demos/22-hud/js/v5.js +142 -0
  132. package/lib/motion/mgstyles/upstream/harness/render.mjs +319 -0
  133. package/lib/motion/mgstyles/upstream/manifest.json +269 -0
  134. package/lib/motion/mgstyles/upstream/prompts/01-flat-vector.md +98 -0
  135. package/lib/motion/mgstyles/upstream/prompts/02-line-art.md +95 -0
  136. package/lib/motion/mgstyles/upstream/prompts/03-isometric.md +97 -0
  137. package/lib/motion/mgstyles/upstream/prompts/05-cel-boil.md +95 -0
  138. package/lib/motion/mgstyles/upstream/prompts/06-collage.md +95 -0
  139. package/lib/motion/mgstyles/upstream/prompts/07-liquid.md +98 -0
  140. package/lib/motion/mgstyles/upstream/prompts/08-morph.md +95 -0
  141. package/lib/motion/mgstyles/upstream/prompts/09-bauhaus.md +96 -0
  142. package/lib/motion/mgstyles/upstream/prompts/10-synthwave.md +95 -0
  143. package/lib/motion/mgstyles/upstream/prompts/12-aurora-glass.md +96 -0
  144. package/lib/motion/mgstyles/upstream/prompts/18-hanazi.md +95 -0
  145. package/lib/motion/mgstyles/upstream/prompts/19-paperclip.md +96 -0
  146. package/lib/motion/mgstyles/upstream/prompts/20-pixel.md +96 -0
  147. package/lib/motion/mgstyles/upstream/prompts/22-hud.md +95 -0
  148. package/lib/motion/mgstyles/upstream/rubric.md +51 -0
  149. package/lib/motion/mgstyles/upstream/template.md +36 -0
  150. package/lib/motion/pixelqa.js +198 -0
  151. package/lib/motion/preview.js +97 -0
  152. package/lib/motion/project.js +395 -0
  153. package/lib/motion/qa.js +290 -0
  154. package/lib/motion/reference-media.jsx +10 -0
  155. package/lib/motion/render-settings.js +112 -0
  156. package/lib/motion/render.js +161 -0
  157. package/lib/motion/runtime/package-lock.json +3058 -0
  158. package/lib/motion/runtime/package.json +16 -0
  159. package/lib/motion/scene-kit.js +183 -0
  160. package/lib/motion/sfx.js +232 -0
  161. package/lib/motion/shotcraft/ClipCard.tsx +204 -0
  162. package/lib/motion/shotcraft/catalog.json +2461 -0
  163. package/lib/motion/shotcraft/upstream/SKILL.md +286 -0
  164. package/lib/motion/shotcraft/upstream/assets/lib/Caption.tsx +54 -0
  165. package/lib/motion/shotcraft/upstream/assets/lib/ClipCard.tsx +200 -0
  166. package/lib/motion/shotcraft/upstream/assets/lib/DigitRoll.tsx +45 -0
  167. package/lib/motion/shotcraft/upstream/assets/lib/FlashCut.tsx +20 -0
  168. package/lib/motion/shotcraft/upstream/assets/lib/FlatPanel.tsx +65 -0
  169. package/lib/motion/shotcraft/upstream/assets/lib/PageCam.tsx +151 -0
  170. package/lib/motion/shotcraft/upstream/assets/lib/VerticalTicker.tsx +129 -0
  171. package/lib/motion/shotcraft/upstream/assets/lib/helpers/camera.tsx +75 -0
  172. package/lib/motion/shotcraft/upstream/assets/lib/helpers/motion.ts +40 -0
  173. package/lib/motion/shotcraft/upstream/assets/lib/helpers/rand.ts +11 -0
  174. package/lib/motion/shotcraft/upstream/assets/lib/helpers/shake.ts +10 -0
  175. package/lib/motion/shotcraft/upstream/assets/scripts/__pycache__/sfx-source.cpython-313.pyc +0 -0
  176. package/lib/motion/shotcraft/upstream/assets/scripts/build-readme-cover.mjs +49 -0
  177. package/lib/motion/shotcraft/upstream/assets/scripts/capture-template.mjs +210 -0
  178. package/lib/motion/shotcraft/upstream/assets/scripts/sfx-source.py +423 -0
  179. package/lib/motion/shotcraft/upstream/assets/scripts/smoke-render-demos.py +170 -0
  180. package/lib/motion/shotcraft/upstream/assets/scripts/test_sfx_source.py +157 -0
  181. package/lib/motion/shotcraft/upstream/demos/README.md +120 -0
  182. package/lib/motion/shotcraft/upstream/demos/_fixtures/Brand.tsx +112 -0
  183. package/lib/motion/shotcraft/upstream/demos/_fixtures/Fixtures.tsx +647 -0
  184. package/lib/motion/shotcraft/upstream/demos/_fixtures/Look.tsx +316 -0
  185. package/lib/motion/shotcraft/upstream/demos/_fixtures/Motion.tsx +82 -0
  186. package/lib/motion/shotcraft/upstream/demos/_fixtures/PageCam2D.tsx +115 -0
  187. package/lib/motion/shotcraft/upstream/demos/_fixtures/Polish.tsx +243 -0
  188. package/lib/motion/shotcraft/upstream/demos/_textures/live-layout.json +389 -0
  189. package/lib/motion/shotcraft/upstream/demos/camera/basic-3d-scene/Basic3DScene.tsx +250 -0
  190. package/lib/motion/shotcraft/upstream/demos/camera/crash-zoom-punch/CrashImpactReal.tsx +305 -0
  191. package/lib/motion/shotcraft/upstream/demos/camera/crash-zoom-punch/CrashZoomReal.tsx +246 -0
  192. package/lib/motion/shotcraft/upstream/demos/camera/cursor-flyover/CursorFlyover.tsx +390 -0
  193. package/lib/motion/shotcraft/upstream/demos/camera/depth-layer-moves/MultiplaneReal.tsx +138 -0
  194. package/lib/motion/shotcraft/upstream/demos/camera/graze-face-tour/GrazeFaceTour.tsx +542 -0
  195. package/lib/motion/shotcraft/upstream/demos/camera/overhead-camera-moves/OverheadTabletopDrop.tsx +315 -0
  196. package/lib/motion/shotcraft/upstream/demos/camera/overhead-camera-moves/TiltReveal.tsx +299 -0
  197. package/lib/motion/shotcraft/upstream/demos/camera/space-camera-moves/DroneDiveLanding.tsx +173 -0
  198. package/lib/motion/shotcraft/upstream/demos/camera/space-camera-moves/ExplodedView.tsx +348 -0
  199. package/lib/motion/shotcraft/upstream/demos/camera/steep-tilt-glide/SteepTiltGlide.tsx +366 -0
  200. package/lib/motion/shotcraft/upstream/demos/camera/tension-camera-moves/BulletTimeFreezeOrbit.tsx +279 -0
  201. package/lib/motion/shotcraft/upstream/demos/camera/tension-camera-moves/PullBackIsolation.tsx +184 -0
  202. package/lib/motion/shotcraft/upstream/demos/camera/terminal-3d/Terminal3D.tsx +390 -0
  203. package/lib/motion/shotcraft/upstream/demos/data/before-after-slider-scrub/BeforeAfterSliderScrub.tsx +155 -0
  204. package/lib/motion/shotcraft/upstream/demos/data/chart-live-moves/AxisRescaleShockV2.tsx +303 -0
  205. package/lib/motion/shotcraft/upstream/demos/data/chart-live-moves/OscilloscopeStreamV2.tsx +290 -0
  206. package/lib/motion/shotcraft/upstream/demos/data/chart-live-moves/UnitDotSwarmRegroupV2.tsx +278 -0
  207. package/lib/motion/shotcraft/upstream/demos/data/gauge-readout-moves/NeedleSweepSelftest.tsx +307 -0
  208. package/lib/motion/shotcraft/upstream/demos/data/gauge-readout-moves/TapeScrollFixedPointer.tsx +215 -0
  209. package/lib/motion/shotcraft/upstream/demos/data/scroll-brake-moves/BrakeReticleLock.tsx +299 -0
  210. package/lib/motion/shotcraft/upstream/demos/data/scroll-brake-moves/ChangelogScrollBrake.tsx +232 -0
  211. package/lib/motion/shotcraft/upstream/demos/data/timeline-travel/TimelineTravel.tsx +290 -0
  212. package/lib/motion/shotcraft/upstream/demos/effects/assemble-then-type-flyin/AssembleThenTypeFlyin.tsx +273 -0
  213. package/lib/motion/shotcraft/upstream/demos/effects/aurora-bloom-bg-flip/AuroraBloomBgFlip.tsx +167 -0
  214. package/lib/motion/shotcraft/upstream/demos/effects/brand-frame-snap/BrandFrameSnap.tsx +536 -0
  215. package/lib/motion/shotcraft/upstream/demos/effects/fui-hud-moves/ReticleLockOn.tsx +164 -0
  216. package/lib/motion/shotcraft/upstream/demos/effects/glow-flyline-moves/FlylineArc.tsx +175 -0
  217. package/lib/motion/shotcraft/upstream/demos/effects/glow-flyline-moves/GlowOrbAmbient.tsx +197 -0
  218. package/lib/motion/shotcraft/upstream/demos/effects/glow-flyline-moves/OrbFlylineRelay.tsx +241 -0
  219. package/lib/motion/shotcraft/upstream/demos/effects/icon-performance-moves/AttentionBounce.tsx +279 -0
  220. package/lib/motion/shotcraft/upstream/demos/effects/impact-feedback/AnimeImpact.tsx +230 -0
  221. package/lib/motion/shotcraft/upstream/demos/effects/impact-feedback/HitCounter.tsx +345 -0
  222. package/lib/motion/shotcraft/upstream/demos/effects/light-play-moves/SheenSweepRetry.tsx +275 -0
  223. package/lib/motion/shotcraft/upstream/demos/effects/light-play-moves/SpotlightSweepReveal.tsx +229 -0
  224. package/lib/motion/shotcraft/upstream/demos/effects/radial-ripple-phone-chips/RadialRipplePhoneChips.tsx +312 -0
  225. package/lib/motion/shotcraft/upstream/demos/effects/riso-print-hits/RisoMisregistrationHit.tsx +222 -0
  226. package/lib/motion/shotcraft/upstream/demos/effects/scan-bracket-sweep/ScanBracketSweep.tsx +295 -0
  227. package/lib/motion/shotcraft/upstream/demos/effects/scanline-annotate-focus/ScanlineAnnotateFocus.tsx +329 -0
  228. package/lib/motion/shotcraft/upstream/demos/effects/scanline-assemble-flyin/ScanlineAssembleFlyin.tsx +315 -0
  229. package/lib/motion/shotcraft/upstream/demos/effects/slam-entrance-moves/ImpactBurstKit.tsx +264 -0
  230. package/lib/motion/shotcraft/upstream/demos/effects/slam-entrance-moves/KanadaPerspectiveSnap.tsx +260 -0
  231. package/lib/motion/shotcraft/upstream/demos/effects/slam-entrance-moves/ScoreSlam.tsx +263 -0
  232. package/lib/motion/shotcraft/upstream/demos/effects/spotlight-sweep-moves/CornerSpotlightReveal.tsx +123 -0
  233. package/lib/motion/shotcraft/upstream/demos/effects/spotlight-sweep-moves/GlowWakeSleepPanel.tsx +187 -0
  234. package/lib/motion/shotcraft/upstream/demos/effects/spotlight-sweep-moves/SlideSpotlightPan.tsx +175 -0
  235. package/lib/motion/shotcraft/upstream/demos/interaction/autolayout-gap-dial/AutolayoutGapDial.tsx +305 -0
  236. package/lib/motion/shotcraft/upstream/demos/interaction/canvas-materialize-moves/DiagramCascadeBuild.tsx +350 -0
  237. package/lib/motion/shotcraft/upstream/demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.tsx +352 -0
  238. package/lib/motion/shotcraft/upstream/demos/interaction/chip-grid-single-select-blackout/ChipGridSingleSelectBlackout.tsx +218 -0
  239. package/lib/motion/shotcraft/upstream/demos/interaction/collab-cursor-moves/CursorCastEnsemble.tsx +297 -0
  240. package/lib/motion/shotcraft/upstream/demos/interaction/glass-pill-dictation-typing/GlassPillDictationTyping.tsx +157 -0
  241. package/lib/motion/shotcraft/upstream/demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx +218 -0
  242. package/lib/motion/shotcraft/upstream/demos/interaction/picker-carousel-feature-cycle/PickerCarouselFeatureCycle.tsx +202 -0
  243. package/lib/motion/shotcraft/upstream/demos/interaction/segmented-thumb-hero/SegmentedThumbHero.tsx +265 -0
  244. package/lib/motion/shotcraft/upstream/demos/interaction/theme-switch-moves/PaletteThemeRipple.tsx +457 -0
  245. package/lib/motion/shotcraft/upstream/demos/interaction/theme-switch-moves/ThemeSweepToggle.tsx +446 -0
  246. package/lib/motion/shotcraft/upstream/demos/interaction/type-and-filter/TypeAndFilter.tsx +249 -0
  247. package/lib/motion/shotcraft/upstream/demos/interaction/voice-waveform-live/VoiceWaveformLive.tsx +278 -0
  248. package/lib/motion/shotcraft/upstream/demos/opening/crane-rise-reveal/CraneRiseReveal.tsx +332 -0
  249. package/lib/motion/shotcraft/upstream/demos/opening/dataviz-landscape-open/DatavizLandscapeOpen.tsx +358 -0
  250. package/lib/motion/shotcraft/upstream/demos/opening/fracture/Fracture.tsx +201 -0
  251. package/lib/motion/shotcraft/upstream/demos/opening/icon-field-colorize/IconFieldColorize.tsx +200 -0
  252. package/lib/motion/shotcraft/upstream/demos/opening/letterspace-materialize/LetterspaceMaterialize.tsx +208 -0
  253. package/lib/motion/shotcraft/upstream/demos/opening/magician-card-flourish/MagicianCardFlourish.tsx +341 -0
  254. package/lib/motion/shotcraft/upstream/demos/opening/orbit-ring-title-open/OrbitRingTitleOpen.tsx +389 -0
  255. package/lib/motion/shotcraft/upstream/demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx +293 -0
  256. package/lib/motion/shotcraft/upstream/demos/opening/stroke-segment-build/StrokeSegmentBuild.tsx +183 -0
  257. package/lib/motion/shotcraft/upstream/demos/opening/text-as-mask/TextAsMask.tsx +245 -0
  258. package/lib/motion/shotcraft/upstream/demos/outro/grain-dissolve/GrainDissolve.tsx +222 -0
  259. package/lib/motion/shotcraft/upstream/demos/outro/logo-shrink-wordmark-lockup/LogoShrinkWordmarkLockup.tsx +223 -0
  260. package/lib/motion/shotcraft/upstream/demos/outro/neon-triple-marquee/NeonTripleMarquee.tsx +217 -0
  261. package/lib/motion/shotcraft/upstream/demos/outro/outro-group-photo-launch/OutroGroupPhotoLaunch.tsx +343 -0
  262. package/lib/motion/shotcraft/upstream/demos/outro/ui-strip-away-outro/UiStripAwayOutro.tsx +357 -0
  263. package/lib/motion/shotcraft/upstream/demos/outro/ui-to-brand-morph/IconFlipBloomLogo.tsx +187 -0
  264. package/lib/motion/shotcraft/upstream/demos/outro/ui-to-brand-morph/InputMorphsIntoLogo.tsx +333 -0
  265. package/lib/motion/shotcraft/upstream/demos/rhythm/beat-cut-moves/PaparazziFlash.tsx +274 -0
  266. package/lib/motion/shotcraft/upstream/demos/rhythm/beat-step-list-theme-cycle/BeatStepListThemeCycle.tsx +270 -0
  267. package/lib/motion/shotcraft/upstream/demos/rhythm/panel-grid-moves/ComicPanelSplit.tsx +430 -0
  268. package/lib/motion/shotcraft/upstream/demos/rhythm/quad-split-parallel-scenes/QuadSplitParallelScenes.tsx +252 -0
  269. package/lib/motion/shotcraft/upstream/demos/rhythm/rhythm-interrupt-moves/JumpCutPunchIn.tsx +106 -0
  270. package/lib/motion/shotcraft/upstream/demos/rhythm/sakuga-timing-shift/SakugaTimingShift.tsx +288 -0
  271. package/lib/motion/shotcraft/upstream/demos/rhythm/speed-ramp-freeze/FreezeAnnotateReal.tsx +362 -0
  272. package/lib/motion/shotcraft/upstream/demos/rhythm/trailer-grammar-moves/TrailerBumper.tsx +168 -0
  273. package/lib/motion/shotcraft/upstream/demos/transition/bottom-push-stack-wipe/BottomPushStackWipe.tsx +310 -0
  274. package/lib/motion/shotcraft/upstream/demos/transition/card-flip-reveal/CardFlipReveal.tsx +261 -0
  275. package/lib/motion/shotcraft/upstream/demos/transition/card-flock-tumble/CardFlockTumble.tsx +459 -0
  276. package/lib/motion/shotcraft/upstream/demos/transition/circle-match-iris/CircleMatchIris.tsx +294 -0
  277. package/lib/motion/shotcraft/upstream/demos/transition/gradient-transition/GradientTransition.tsx +161 -0
  278. package/lib/motion/shotcraft/upstream/demos/transition/line-carry-transition/LineCarryTransition.tsx +248 -0
  279. package/lib/motion/shotcraft/upstream/demos/transition/mosaic-reframe/MosaicReframe.tsx +359 -0
  280. package/lib/motion/shotcraft/upstream/demos/transition/page-turn-transitions/BarnDoorSplit.tsx +227 -0
  281. package/lib/motion/shotcraft/upstream/demos/transition/print-texture-transitions/InkBleedReveal.tsx +162 -0
  282. package/lib/motion/shotcraft/upstream/demos/transition/shot-transitions/PortalWipeV2.tsx +248 -0
  283. package/lib/motion/shotcraft/upstream/demos/transition/shot-transitions/WhipBrakeReal.tsx +186 -0
  284. package/lib/motion/shotcraft/upstream/demos/transition/tear-streak-transitions/GlitchDisplace.tsx +355 -0
  285. package/lib/motion/shotcraft/upstream/demos/transition/transition-hidden-cut/InvisibleCut.tsx +272 -0
  286. package/lib/motion/shotcraft/upstream/demos/transition/transition-hidden-cut/LightLeakBurn.tsx +262 -0
  287. package/lib/motion/shotcraft/upstream/demos/transition/transition-hidden-cut/VersusSlam.tsx +217 -0
  288. package/lib/motion/shotcraft/upstream/demos/transition/transition-travel/LetterformZoom.tsx +285 -0
  289. package/lib/motion/shotcraft/upstream/demos/transition/white-flash-logo-simplify-cut/WhiteFlashLogoSimplifyCut.tsx +205 -0
  290. package/lib/motion/shotcraft/upstream/demos/transition/wipe-transitions/BlindsSlice.tsx +178 -0
  291. package/lib/motion/shotcraft/upstream/demos/typography/blur-slide/BlurSlide.tsx +147 -0
  292. package/lib/motion/shotcraft/upstream/demos/typography/brace-expand/BraceExpand.tsx +143 -0
  293. package/lib/motion/shotcraft/upstream/demos/typography/brand-ink-open/BrandInkOpen.tsx +134 -0
  294. package/lib/motion/shotcraft/upstream/demos/typography/countdown-arc-scatter/CountdownArcScatter.tsx +217 -0
  295. package/lib/motion/shotcraft/upstream/demos/typography/flying-words/FlyingWords.tsx +198 -0
  296. package/lib/motion/shotcraft/upstream/demos/typography/glitch-cycle/GlitchCycle.tsx +221 -0
  297. package/lib/motion/shotcraft/upstream/demos/typography/gradient-word-sweep/GradientWordSweep.tsx +312 -0
  298. package/lib/motion/shotcraft/upstream/demos/typography/lead-word-zoom-assemble/LeadWordZoomAssemble.tsx +261 -0
  299. package/lib/motion/shotcraft/upstream/demos/typography/marker-underline-title/MarkerUnderlineTitle.tsx +121 -0
  300. package/lib/motion/shotcraft/upstream/demos/typography/outline-word-fill/OutlineWordFill.tsx +153 -0
  301. package/lib/motion/shotcraft/upstream/demos/typography/paper-title-card/PaperTitleCard.tsx +179 -0
  302. package/lib/motion/shotcraft/upstream/demos/typography/pill-chip-slot-cycle-handled/PillChipSlotCycleHandled.tsx +140 -0
  303. package/lib/motion/shotcraft/upstream/demos/typography/pill-slot-cycle/PillSlotCycle.tsx +309 -0
  304. package/lib/motion/shotcraft/upstream/demos/typography/text-column-converge/TextColumnConverge.tsx +191 -0
  305. package/lib/motion/shotcraft/upstream/demos/typography/title-demote-to-label/TitleDemoteToLabel.tsx +292 -0
  306. package/lib/motion/shotcraft/upstream/demos/typography/type-assembly-moves/LetterformDriftAssembly.tsx +135 -0
  307. package/lib/motion/shotcraft/upstream/demos/typography/type-assembly-moves/SplitTextStagger.tsx +124 -0
  308. package/lib/motion/shotcraft/upstream/demos/typography/type-assembly-moves/TextOnPath.tsx +255 -0
  309. package/lib/motion/shotcraft/upstream/demos/typography/type-entrance-moves/LetterDropPhysics.tsx +173 -0
  310. package/lib/motion/shotcraft/upstream/demos/typography/type-rhythm-sync/FontWeightPump.tsx +225 -0
  311. package/lib/motion/shotcraft/upstream/demos/typography/type-rhythm-sync/KaraokeFillSync.tsx +141 -0
  312. package/lib/motion/shotcraft/upstream/demos/typography/typewriter-moves/TypewriterErrorRetype.tsx +137 -0
  313. package/lib/motion/shotcraft/upstream/demos/typography/vertical-word-roll-blur-cycle/VerticalWordRollBlurCycle.tsx +147 -0
  314. package/lib/motion/shotcraft/upstream/demos/typography/word-relay-filmstrip/WordRelayFilmstrip.tsx +409 -0
  315. package/lib/motion/shotcraft/upstream/demos/typography/word-relay-geometry/WordRelayGeometry.tsx +280 -0
  316. package/lib/motion/shotcraft/upstream/demos/ui-entrance/avatar-bracket-carousel/AvatarBracketCarousel.tsx +271 -0
  317. package/lib/motion/shotcraft/upstream/demos/ui-entrance/bezier-source-converge-merge/BezierSourceConvergeMerge.tsx +385 -0
  318. package/lib/motion/shotcraft/upstream/demos/ui-entrance/card-stack/CardStack.tsx +312 -0
  319. package/lib/motion/shotcraft/upstream/demos/ui-entrance/carousel-3d/Carousel3D.tsx +303 -0
  320. package/lib/motion/shotcraft/upstream/demos/ui-entrance/clipcard-looping/ClipCardLooping.tsx +77 -0
  321. package/lib/motion/shotcraft/upstream/demos/ui-entrance/cloner-depth-echo/ClonerDepthEcho.tsx +311 -0
  322. package/lib/motion/shotcraft/upstream/demos/ui-entrance/deck-deal-flyin/DeckDealFlyin.tsx +307 -0
  323. package/lib/motion/shotcraft/upstream/demos/ui-entrance/doc-park-left-pill-deal/DocParkLeftPillDeal.tsx +349 -0
  324. package/lib/motion/shotcraft/upstream/demos/ui-entrance/document-typewriter-reveal/DocumentTypewriterReveal.tsx +255 -0
  325. package/lib/motion/shotcraft/upstream/demos/ui-entrance/draw-svg-trace/DrawSvgTrace.tsx +244 -0
  326. package/lib/motion/shotcraft/upstream/demos/ui-entrance/element-body-moves/ContactShadowLift.tsx +217 -0
  327. package/lib/motion/shotcraft/upstream/demos/ui-entrance/integration-hub-map/IntegrationHubMap.tsx +652 -0
  328. package/lib/motion/shotcraft/upstream/demos/ui-entrance/list-reveal/ListReveal.tsx +153 -0
  329. package/lib/motion/shotcraft/upstream/demos/ui-entrance/list-stack-press/ListStackPress.tsx +238 -0
  330. package/lib/motion/shotcraft/upstream/demos/ui-entrance/neon-frame-forerun/NeonFrameForerun.tsx +324 -0
  331. package/lib/motion/shotcraft/upstream/demos/ui-entrance/neon-frame-orbit-drop/NeonFrameForerunOrbit.tsx +354 -0
  332. package/lib/motion/shotcraft/upstream/demos/ui-entrance/page-waterfall-wall/PageWaterfallWall.tsx +101 -0
  333. package/lib/motion/shotcraft/upstream/demos/ui-entrance/page-waterfall-wall/VerticalTicker.tsx +129 -0
  334. package/lib/motion/shotcraft/upstream/demos/ui-entrance/product-card-progressive-assemble/ProductCardProgressiveAssemble.tsx +297 -0
  335. package/lib/motion/shotcraft/upstream/demos/ui-entrance/radial-wave/RadialWave.tsx +57 -0
  336. package/lib/motion/shotcraft/upstream/demos/ui-entrance/research-card-stack-scroll/ResearchCardStackScroll.tsx +155 -0
  337. package/lib/motion/shotcraft/upstream/demos/ui-entrance/row-embed/RowEmbed.tsx +175 -0
  338. package/lib/motion/shotcraft/upstream/demos/ui-entrance/value-stagger-gradient/ValueStaggerGradient.tsx +153 -0
  339. package/lib/motion/shotcraft/upstream/demos/ui-entrance/wall-reveal-moves/BentoLightUp.tsx +146 -0
  340. package/lib/motion/shotcraft/upstream/demos/ui-entrance/wall-reveal-moves/WireframeDrawOn.tsx +226 -0
  341. package/lib/motion/shotcraft/upstream/gallery/api/library.json +1 -0
  342. package/lib/motion/shotcraft/upstream/manifest.json +2715 -0
  343. package/lib/motion/shotcraft/upstream/references/aesthetic-rules.md +193 -0
  344. package/lib/motion/shotcraft/upstream/references/final-review.md +121 -0
  345. package/lib/motion/shotcraft/upstream/references/guided-free-creation.md +199 -0
  346. package/lib/motion/shotcraft/upstream/references/jianying-export.md +183 -0
  347. package/lib/motion/shotcraft/upstream/references/music-beat-sync.md +257 -0
  348. package/lib/motion/shotcraft/upstream/references/pipeline.md +382 -0
  349. package/lib/motion/shotcraft/upstream/references/publish-upload-post.md +111 -0
  350. package/lib/motion/shotcraft/upstream/references/sequences/promo-energy-arc.md +50 -0
  351. package/lib/motion/shotcraft/upstream/references/sfx-sourcing.md +95 -0
  352. package/lib/motion/shotcraft/upstream/references/shots/camera/basic-3d-scene.md +41 -0
  353. package/lib/motion/shotcraft/upstream/references/shots/camera/crash-zoom-punch.md +52 -0
  354. package/lib/motion/shotcraft/upstream/references/shots/camera/cursor-flyover.md +39 -0
  355. package/lib/motion/shotcraft/upstream/references/shots/camera/depth-layer-moves.md +39 -0
  356. package/lib/motion/shotcraft/upstream/references/shots/camera/graze-face-tour.md +57 -0
  357. package/lib/motion/shotcraft/upstream/references/shots/camera/overhead-camera-moves.md +51 -0
  358. package/lib/motion/shotcraft/upstream/references/shots/camera/space-camera-moves.md +46 -0
  359. package/lib/motion/shotcraft/upstream/references/shots/camera/steep-tilt-glide.md +52 -0
  360. package/lib/motion/shotcraft/upstream/references/shots/camera/tension-camera-moves.md +43 -0
  361. package/lib/motion/shotcraft/upstream/references/shots/camera/terminal-3d.md +42 -0
  362. package/lib/motion/shotcraft/upstream/references/shots/data/before-after-slider-scrub.md +48 -0
  363. package/lib/motion/shotcraft/upstream/references/shots/data/chart-live-moves.md +52 -0
  364. package/lib/motion/shotcraft/upstream/references/shots/data/gauge-readout-moves.md +45 -0
  365. package/lib/motion/shotcraft/upstream/references/shots/data/scroll-brake-moves.md +46 -0
  366. package/lib/motion/shotcraft/upstream/references/shots/data/timeline-travel.md +44 -0
  367. package/lib/motion/shotcraft/upstream/references/shots/effects/assemble-then-type-flyin.md +63 -0
  368. package/lib/motion/shotcraft/upstream/references/shots/effects/aurora-bloom-bg-flip.md +54 -0
  369. package/lib/motion/shotcraft/upstream/references/shots/effects/brand-frame-snap.md +58 -0
  370. package/lib/motion/shotcraft/upstream/references/shots/effects/fui-hud-moves.md +35 -0
  371. package/lib/motion/shotcraft/upstream/references/shots/effects/glow-flyline-moves.md +53 -0
  372. package/lib/motion/shotcraft/upstream/references/shots/effects/icon-performance-moves.md +42 -0
  373. package/lib/motion/shotcraft/upstream/references/shots/effects/impact-feedback.md +50 -0
  374. package/lib/motion/shotcraft/upstream/references/shots/effects/light-play-moves.md +46 -0
  375. package/lib/motion/shotcraft/upstream/references/shots/effects/radial-ripple-phone-chips.md +56 -0
  376. package/lib/motion/shotcraft/upstream/references/shots/effects/riso-print-hits.md +49 -0
  377. package/lib/motion/shotcraft/upstream/references/shots/effects/scan-bracket-sweep.md +50 -0
  378. package/lib/motion/shotcraft/upstream/references/shots/effects/scanline-annotate-focus.md +53 -0
  379. package/lib/motion/shotcraft/upstream/references/shots/effects/scanline-assemble-flyin.md +53 -0
  380. package/lib/motion/shotcraft/upstream/references/shots/effects/slam-entrance-moves.md +55 -0
  381. package/lib/motion/shotcraft/upstream/references/shots/effects/spotlight-sweep-moves.md +52 -0
  382. package/lib/motion/shotcraft/upstream/references/shots/interaction/autolayout-gap-dial.md +60 -0
  383. package/lib/motion/shotcraft/upstream/references/shots/interaction/canvas-materialize-moves.md +57 -0
  384. package/lib/motion/shotcraft/upstream/references/shots/interaction/chip-grid-single-select-blackout.md +51 -0
  385. package/lib/motion/shotcraft/upstream/references/shots/interaction/collab-cursor-moves.md +41 -0
  386. package/lib/motion/shotcraft/upstream/references/shots/interaction/glass-pill-dictation-typing.md +53 -0
  387. package/lib/motion/shotcraft/upstream/references/shots/interaction/hashtag-to-pill-materialize.md +60 -0
  388. package/lib/motion/shotcraft/upstream/references/shots/interaction/picker-carousel-feature-cycle.md +55 -0
  389. package/lib/motion/shotcraft/upstream/references/shots/interaction/segmented-thumb-hero.md +57 -0
  390. package/lib/motion/shotcraft/upstream/references/shots/interaction/theme-switch-moves.md +47 -0
  391. package/lib/motion/shotcraft/upstream/references/shots/interaction/type-and-filter.md +37 -0
  392. package/lib/motion/shotcraft/upstream/references/shots/interaction/voice-waveform-live.md +54 -0
  393. package/lib/motion/shotcraft/upstream/references/shots/opening/brand-ink-open.md +36 -0
  394. package/lib/motion/shotcraft/upstream/references/shots/opening/crane-rise-reveal.md +56 -0
  395. package/lib/motion/shotcraft/upstream/references/shots/opening/dataviz-landscape-open.md +56 -0
  396. package/lib/motion/shotcraft/upstream/references/shots/opening/fracture.md +40 -0
  397. package/lib/motion/shotcraft/upstream/references/shots/opening/icon-field-colorize.md +60 -0
  398. package/lib/motion/shotcraft/upstream/references/shots/opening/letterspace-materialize.md +57 -0
  399. package/lib/motion/shotcraft/upstream/references/shots/opening/magician-card-flourish.md +73 -0
  400. package/lib/motion/shotcraft/upstream/references/shots/opening/orbit-ring-title-open.md +87 -0
  401. package/lib/motion/shotcraft/upstream/references/shots/opening/spotlight-hero-card.md +55 -0
  402. package/lib/motion/shotcraft/upstream/references/shots/opening/stroke-segment-build.md +50 -0
  403. package/lib/motion/shotcraft/upstream/references/shots/opening/text-as-mask.md +59 -0
  404. package/lib/motion/shotcraft/upstream/references/shots/outro/grain-dissolve.md +38 -0
  405. package/lib/motion/shotcraft/upstream/references/shots/outro/logo-shrink-wordmark-lockup.md +38 -0
  406. package/lib/motion/shotcraft/upstream/references/shots/outro/neon-triple-marquee.md +59 -0
  407. package/lib/motion/shotcraft/upstream/references/shots/outro/outro-group-photo-launch.md +39 -0
  408. package/lib/motion/shotcraft/upstream/references/shots/outro/ui-strip-away-outro.md +61 -0
  409. package/lib/motion/shotcraft/upstream/references/shots/outro/ui-to-brand-morph.md +52 -0
  410. package/lib/motion/shotcraft/upstream/references/shots/rhythm/beat-cut-moves.md +50 -0
  411. package/lib/motion/shotcraft/upstream/references/shots/rhythm/beat-step-list-theme-cycle.md +66 -0
  412. package/lib/motion/shotcraft/upstream/references/shots/rhythm/panel-grid-moves.md +43 -0
  413. package/lib/motion/shotcraft/upstream/references/shots/rhythm/quad-split-parallel-scenes.md +38 -0
  414. package/lib/motion/shotcraft/upstream/references/shots/rhythm/rhythm-interrupt-moves.md +38 -0
  415. package/lib/motion/shotcraft/upstream/references/shots/rhythm/sakuga-timing-shift.md +56 -0
  416. package/lib/motion/shotcraft/upstream/references/shots/rhythm/speed-ramp-freeze.md +44 -0
  417. package/lib/motion/shotcraft/upstream/references/shots/rhythm/trailer-grammar-moves.md +38 -0
  418. package/lib/motion/shotcraft/upstream/references/shots/transition/bottom-push-stack-wipe.md +63 -0
  419. package/lib/motion/shotcraft/upstream/references/shots/transition/card-flip-reveal.md +57 -0
  420. package/lib/motion/shotcraft/upstream/references/shots/transition/card-flock-tumble.md +63 -0
  421. package/lib/motion/shotcraft/upstream/references/shots/transition/circle-match-iris.md +58 -0
  422. package/lib/motion/shotcraft/upstream/references/shots/transition/gradient-transition.md +45 -0
  423. package/lib/motion/shotcraft/upstream/references/shots/transition/line-carry-transition.md +55 -0
  424. package/lib/motion/shotcraft/upstream/references/shots/transition/mosaic-reframe.md +49 -0
  425. package/lib/motion/shotcraft/upstream/references/shots/transition/page-turn-transitions.md +42 -0
  426. package/lib/motion/shotcraft/upstream/references/shots/transition/print-texture-transitions.md +42 -0
  427. package/lib/motion/shotcraft/upstream/references/shots/transition/shot-transitions.md +45 -0
  428. package/lib/motion/shotcraft/upstream/references/shots/transition/tear-streak-transitions.md +48 -0
  429. package/lib/motion/shotcraft/upstream/references/shots/transition/transition-hidden-cut.md +55 -0
  430. package/lib/motion/shotcraft/upstream/references/shots/transition/transition-travel.md +43 -0
  431. package/lib/motion/shotcraft/upstream/references/shots/transition/white-flash-logo-simplify-cut.md +41 -0
  432. package/lib/motion/shotcraft/upstream/references/shots/transition/wipe-transitions.md +42 -0
  433. package/lib/motion/shotcraft/upstream/references/shots/typography/blur-slide.md +59 -0
  434. package/lib/motion/shotcraft/upstream/references/shots/typography/brace-expand.md +55 -0
  435. package/lib/motion/shotcraft/upstream/references/shots/typography/countdown-arc-scatter.md +65 -0
  436. package/lib/motion/shotcraft/upstream/references/shots/typography/document-typewriter-reveal.md +42 -0
  437. package/lib/motion/shotcraft/upstream/references/shots/typography/flying-words.md +64 -0
  438. package/lib/motion/shotcraft/upstream/references/shots/typography/glitch-cycle.md +64 -0
  439. package/lib/motion/shotcraft/upstream/references/shots/typography/gradient-word-sweep.md +54 -0
  440. package/lib/motion/shotcraft/upstream/references/shots/typography/lead-word-zoom-assemble.md +91 -0
  441. package/lib/motion/shotcraft/upstream/references/shots/typography/marker-underline-title.md +51 -0
  442. package/lib/motion/shotcraft/upstream/references/shots/typography/outline-word-fill.md +66 -0
  443. package/lib/motion/shotcraft/upstream/references/shots/typography/paper-title-card.md +38 -0
  444. package/lib/motion/shotcraft/upstream/references/shots/typography/pill-chip-slot-cycle-handled.md +66 -0
  445. package/lib/motion/shotcraft/upstream/references/shots/typography/pill-slot-cycle.md +58 -0
  446. package/lib/motion/shotcraft/upstream/references/shots/typography/text-column-converge.md +61 -0
  447. package/lib/motion/shotcraft/upstream/references/shots/typography/title-demote-to-label.md +64 -0
  448. package/lib/motion/shotcraft/upstream/references/shots/typography/type-assembly-moves.md +45 -0
  449. package/lib/motion/shotcraft/upstream/references/shots/typography/type-entrance-moves.md +41 -0
  450. package/lib/motion/shotcraft/upstream/references/shots/typography/type-rhythm-sync.md +51 -0
  451. package/lib/motion/shotcraft/upstream/references/shots/typography/typewriter-moves.md +42 -0
  452. package/lib/motion/shotcraft/upstream/references/shots/typography/vertical-word-roll-blur-cycle.md +63 -0
  453. package/lib/motion/shotcraft/upstream/references/shots/typography/word-relay-filmstrip.md +57 -0
  454. package/lib/motion/shotcraft/upstream/references/shots/typography/word-relay-geometry.md +75 -0
  455. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/avatar-bracket-carousel.md +59 -0
  456. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/bezier-source-converge-merge.md +67 -0
  457. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/card-stack.md +60 -0
  458. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/carousel-3d.md +66 -0
  459. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/cloner-depth-echo.md +40 -0
  460. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/deck-deal-flyin.md +45 -0
  461. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/doc-park-left-pill-deal.md +62 -0
  462. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/draw-svg-trace.md +55 -0
  463. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/element-body-moves.md +37 -0
  464. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/integration-hub-map.md +55 -0
  465. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/list-reveal.md +50 -0
  466. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/list-stack-press.md +43 -0
  467. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/neon-frame-forerun.md +55 -0
  468. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/neon-frame-orbit-drop.md +51 -0
  469. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/page-waterfall-wall.md +51 -0
  470. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/product-card-progressive-assemble.md +56 -0
  471. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/radial-wave.md +49 -0
  472. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/research-card-stack-scroll.md +57 -0
  473. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/row-embed.md +36 -0
  474. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/value-stagger-gradient.md +64 -0
  475. package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/wall-reveal-moves.md +47 -0
  476. package/lib/motion/shotcraft/upstream/references/sound-design.md +170 -0
  477. package/lib/motion/shotcraft/upstream/references/workbench.md +157 -0
  478. package/lib/motion/shotcraft/upstream/template/TEMPLATE.md +103 -0
  479. package/lib/motion/shotcraft/upstream/template/THEMES.md +44 -0
  480. package/lib/motion/shotcraft/upstream/template/package.json +20 -0
  481. package/lib/motion/shotcraft/upstream/template/remotion.config.ts +6 -0
  482. package/lib/motion/shotcraft/upstream/template/src/Root.tsx +17 -0
  483. package/lib/motion/shotcraft/upstream/template/src/aifl/Caption.tsx +65 -0
  484. package/lib/motion/shotcraft/upstream/template/src/aifl/DigitRoll.tsx +48 -0
  485. package/lib/motion/shotcraft/upstream/template/src/aifl/FlashCut.tsx +24 -0
  486. package/lib/motion/shotcraft/upstream/template/src/aifl/Main.tsx +192 -0
  487. package/lib/motion/shotcraft/upstream/template/src/aifl/PaperTitleCard.tsx +111 -0
  488. package/lib/motion/shotcraft/upstream/template/src/aifl/live/PageCam.tsx +150 -0
  489. package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneDetail.tsx +158 -0
  490. package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneFlyIn.tsx +468 -0
  491. package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneOpen.tsx +552 -0
  492. package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneOutroLive.tsx +411 -0
  493. package/lib/motion/shotcraft/upstream/template/src/aifl/live/ScenePapers.tsx +200 -0
  494. package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneWbr.tsx +324 -0
  495. package/lib/motion/shotcraft/upstream/template/src/aifl/live-layout.json +389 -0
  496. package/lib/motion/shotcraft/upstream/template/src/index.ts +4 -0
  497. package/lib/motion/shotcraft/upstream/template/src/themes/ThemedFilm.tsx +7 -0
  498. package/lib/motion/shotcraft/upstream/template/src/themes/manifest.tsx +68 -0
  499. package/lib/motion/shotcraft/upstream/template/src/themes/palette-assets.json +1 -0
  500. package/lib/motion/shotcraft/upstream/template/src/themes/palette-assets.ts +24 -0
  501. package/lib/motion/shotcraft/upstream/template/src/themes/palettes.json +74 -0
  502. package/lib/motion/shotcraft/upstream/template/src/themes/visual-theme.tsx +91 -0
  503. package/lib/motion/shotcraft/upstream/template/src/workbench.ts +213 -0
  504. package/lib/motion/shotcraft/upstream/template/tsconfig.json +15 -0
  505. package/lib/motion/spec.js +113 -0
  506. package/lib/motion/style-engine.js +153 -0
  507. package/lib/motion/style-frames.js +271 -0
  508. package/lib/motion/sync.js +214 -0
  509. package/lib/motion/theme.js +121 -0
  510. package/lib/motion/video-runtime.js +192 -0
  511. package/lib/motion/voice.js +417 -0
  512. package/lib/motion-agent-files.js +82 -0
  513. package/lib/motion-agent-protocol.js +66 -0
  514. package/lib/motion-agent-tools.js +196 -0
  515. package/lib/motion-agent.js +171 -0
  516. package/lib/motion-capture.js +93 -0
  517. package/lib/motion-core.js +238 -0
  518. package/lib/motion-film-review.js +71 -0
  519. package/lib/motion-library.js +210 -0
  520. package/lib/motion-planner.js +254 -0
  521. package/lib/motion-project.js +87 -0
  522. package/lib/motion-render-worker.js +153 -0
  523. package/lib/motion-session.js +32 -0
  524. package/lib/motion-source.js +49 -0
  525. package/lib/motion-tasks.js +452 -0
  526. package/lib/motion-vision.js +44 -0
  527. package/lib/mxfile.js +27 -7
  528. package/lib/routing/node-runtime.js +14 -0
  529. package/lib/routing/router.js +211 -0
  530. package/lib/routing/vendor/README.md +96 -0
  531. package/lib/routing/vendor/libavoid-routing.js +753 -0
  532. package/lib/routing/vendor/libavoid.min.js +59 -0
  533. package/lib/shotcard-core.js +39 -0
  534. package/lib/shotcard-reference.js +56 -0
  535. package/lib/style-kernel.js +37 -3
  536. package/lib/task-patches.js +26 -2
  537. package/lib/video-export.js +49 -22
  538. package/package.json +34 -4
@@ -0,0 +1,2036 @@
1
+ // 05-cel-boil — 「手作」 a matchstick, a flame spirit, and a title written by sparks.
2
+ import * as G from './engine.js';
3
+ const { PAL, K, E, hs, hr, lerp, clamp, inv, sstep, spline, resample, xf, circlePts, boil, outline, fillPoly,
4
+ inkStroke, inkLoop, fillShape, hatch, Layers, blit, prefix, arclen, polyPath } = G;
5
+
6
+ export const W = 1920, H = 1080, FPS = 24;
7
+ // ---- reel v5 (?v5=1): title 逐帧手绘 / FRAME BY FRAME, window.linkAt(t), ?nolink=dot. Without v5 every code path is unchanged.
8
+ const QS = new URLSearchParams(typeof location !== 'undefined' ? location.search : '');
9
+ export const V5 = QS.get('v5') === '1';
10
+ const NOLINK = new Set(V5 ? (QS.get('nolink') || '').split(',').map((q) => q.trim()).filter(Boolean) : []);
11
+ const NODOT = NOLINK.has('dot');
12
+ let ctx, MAIN, SHEETC, NIGHTC, LY, IMG = {}, DPR = 1, FONT_TAG = null, CUES = null, TOTAL = 146;
13
+ const REG = { dx: -5, dy: 4 }; // colour-to-line registration offset (hand colouring)
14
+
15
+ // ------------------------------------------------------------------ exposure sheet (frames)
16
+ // [from, to, exposure] 2 = on twos, 1 = on ones, 3 = on threes
17
+ const SCHED = [[0, 8, 2], [8, 24, 1], [24, 48, 2], [48, 60, 1], [60, 108, 2], [108, 120, 3], [120, 132, 2],
18
+ [132, 168, 1], [168, 224, 2], [224, 230, 1], [230, 240, 3]] // R2: pull-back f218, riffle f224-229, end hold on 3s from f230;
19
+ export function drawing(f) {
20
+ let id = 0;
21
+ for (const [a, b, s] of SCHED) {
22
+ if (f >= b) { id += Math.ceil((b - a) / s); continue; }
23
+ const k = Math.floor((f - a) / s);
24
+ return { fq: a + k * s, id: id + k, step: s };
25
+ }
26
+ return { fq: 239, id, step: 3 };
27
+ }
28
+ // boil variant: static things cycle through 4 drawings (classic boil loop), 3 on the hold
29
+ const bv = (D) => (D.step === 3 ? D.id % 3 : D.id % 4);
30
+
31
+ // ------------------------------------------------------------------ helpers
32
+ const dens = (poly, step = 4) => resample(poly, step, true);
33
+ function affineTo(c, O, X, Y, w, h) { // map text box (0..w, 0..h) to parallelogram O->X (x axis), O->Y (y axis)
34
+ c.transform((X[0] - O[0]) / w, (X[1] - O[1]) / w, (Y[0] - O[0]) / h, (Y[1] - O[1]) / h, O[0], O[1]);
35
+ }
36
+ function camApply(c, cam) {
37
+ c.translate(cam.x, cam.y);
38
+ }
39
+ const STAGE = { s: 1.4, px: 930, py: 880, X: 960, Y: 1002 };
40
+ const CLOSE = { s: 2.6, px: 872, py: 790, X: 960, Y: 600 }; // shot A: close-up on the striker (frames 0–11)
41
+ const MED = { s: 2.05, px: 845, py: 650, X: 960, Y: 560 }; // shot C: cut-in on the ta-da (frames 108–131)
42
+ const WAKE = { s: 1.75, px: 1150, py: 520, X: 1270, Y: 500 }; // step-in when the eyes pop (re-framed drawings, 2 drawings on 2s)
43
+ const LOW = { s: 1.5, px: 830, py: 700, X: 800, Y: 848 }; // R2s2 (P4): low re-staged angle; flame x=640 at 3.0, match + smoke curl fill the right third
44
+ const JT = [0, 25, 50, 68, 78, 80, 76, 64, 48, 30, 14, 4]; // R1s2: tilt follows only ~40% of the rise so the arc reads on screen // camera tilt following the jump arc (on 1s)
45
+ const mixShot = (A, B, u) => ({ s: lerp(A.s, B.s, u), px: lerp(A.px, B.px, u), py: lerp(A.py, B.py, u), X: lerp(A.X, B.X, u), Y: lerp(A.Y, B.Y, u) });
46
+ function shotFor(f) {
47
+ if (f < 12) return CLOSE;
48
+ if (f >= 30 && f < 48) return f < 32 ? mixShot(STAGE, WAKE, 0.6) : WAKE;
49
+ if (f >= 48 && f < 60) return { ...STAGE, Y: STAGE.Y + JT[f - 48] };
50
+ if (f >= 60 && f < 108) return LOW;
51
+ if (f >= 108 && f < 132) return MED;
52
+ return STAGE;
53
+ }
54
+ let SHOT = STAGE;
55
+ function stageApply(c) { c.translate(SHOT.X, SHOT.Y); c.scale(SHOT.s, SHOT.s); c.translate(-SHOT.px, -SHOT.py); }
56
+ function edge(c, a, b, seed, o = {}) { // hand-inked straight edge with corner overshoot
57
+ const L = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1, ux = (b[0] - a[0]) / L, uy = (b[1] - a[1]) / L;
58
+ const e0 = o.over ?? 7, e1 = o.over ?? 7;
59
+ const s0 = e0 * (0.4 + hs(seed, 1)), s1 = e1 * (0.4 + hs(seed, 2));
60
+ const A = [a[0] - ux * s0, a[1] - uy * s0], B = [b[0] + ux * s1, b[1] + uy * s1];
61
+ const M = [lerp(A[0], B[0], 0.5) + (hs(seed, 3) - 0.5) * 2.5 * -uy * (L / 300), lerp(A[1], B[1], 0.5) + (hs(seed, 3) - 0.5) * 2.5 * ux * (L / 300)];
62
+ inkStroke(c, [A, M, B], { seed, w: o.w ?? 6, t0: 12, t1: 16, jit: 0.3, heavy: 0, amp: o.amp ?? 1.8, min: 0.2 });
63
+ }
64
+
65
+ // ------------------------------------------------------------------ shapes
66
+ function starPts(n, rOut, rIn, seed, rot = 0, jit = 0.25) {
67
+ const P = [];
68
+ for (let i = 0; i < n; i++) {
69
+ const a = rot + (i / n) * Math.PI * 2 + (hs(seed, i, 1) - 0.5) * (Math.PI / n) * 0.6;
70
+ const ro = rOut * (1 - jit + jit * 2 * hs(seed, i, 2));
71
+ P.push([Math.cos(a) * ro, Math.sin(a) * ro, 1]);
72
+ const b = a + Math.PI / n + (hs(seed, i, 3) - 0.5) * (Math.PI / n) * 0.3;
73
+ const ri = rIn * (0.85 + 0.3 * hs(seed, i, 4));
74
+ P.push([Math.cos(b) * ri, Math.sin(b) * ri]);
75
+ }
76
+ return P;
77
+ }
78
+ function twinklePts(R, seed, rot = 0) {
79
+ const P = [];
80
+ for (let i = 0; i < 4; i++) {
81
+ const a = rot + (i * Math.PI) / 2;
82
+ const r = R * (i % 2 ? 0.78 : 1) * (0.92 + 0.16 * hs(seed, i));
83
+ P.push([Math.cos(a) * r, Math.sin(a) * r, 1]);
84
+ const b = a + Math.PI / 4;
85
+ P.push([Math.cos(b) * R * 0.2, Math.sin(b) * R * 0.2]);
86
+ }
87
+ return P;
88
+ }
89
+ function drawTwinkle(c, x, y, R, seed, o = {}) {
90
+ if (R < 2) return;
91
+ const P = xf(twinklePts(R, seed, o.rot ?? 0), { x, y });
92
+ const d = spline(P, true, 3);
93
+ fillShape(c, d, o.fill ?? PAL.yel, { seed, amp: 1.2, dx: REG.dx * 0.6, dy: REG.dy * 0.6 });
94
+ inkLoop(c, d, { seed, w: o.w ?? Math.max(2.5, R * 0.09), amp: 1.2, t0: 4, t1: 8, heavy: 0.2 });
95
+ }
96
+ // teardrop spark oriented along (vx,vy)
97
+ function drawSpark(c, x, y, vx, vy, size, seed, o = {}) {
98
+ const sp = Math.hypot(vx, vy) || 1, ux = vx / sp, uy = vy / sp;
99
+ const len = size * (1.4 + Math.min(5, sp / 450));
100
+ const r = size * 0.55;
101
+ const ang = Math.atan2(uy, ux);
102
+ const P = [[r, 0], [r * 0.7, r * 0.72], [0, r], [-len, 0, 1], [0, -r], [r * 0.7, -r * 0.72]];
103
+ const d = spline(xf(P, { x, y, r: ang }), true, 3);
104
+ if (o.inkOnly) { fillShape(c, d, PAL.ink, { seed, amp: 1 }); return; }
105
+ fillShape(c, d, o.fill ?? PAL.yel, { seed, amp: 1.2, dx: REG.dx * 0.5, dy: REG.dy * 0.5 });
106
+ inkLoop(c, d, { seed, w: Math.max(2.2, size * 0.16), amp: 1, t0: 3, t1: 10, heavy: 0.2 });
107
+ }
108
+
109
+ // ------------------------------------------------------------------ paper + tooth
110
+ function drawPaper(c, cam) {
111
+ c.save();
112
+ const s = 1.045;
113
+ c.drawImage(IMG.paper, cam.x + (W - W * s) / 2, cam.y + (H - H * s) / 2, W * s, H * s);
114
+ c.restore();
115
+ }
116
+ function drawTooth(c, D) {
117
+ // multiply pass of the paper tooth over everything (boils with the drawing: every cel is a new sheet)
118
+ const v = bv(D);
119
+ const ox = Math.floor(hs(v, 51) * 470), oy = Math.floor(hs(v, 52) * 310);
120
+ c.save();
121
+ c.setTransform(DPR, 0, 0, DPR, 0, 0);
122
+ c.globalCompositeOperation = 'multiply';
123
+ c.globalAlpha = 0.5;
124
+ c.drawImage(IMG.tooth, ox, oy, W, H, 0, 0, W, H);
125
+ // scanner dust / graphite specks that change with every drawing
126
+ c.globalCompositeOperation = 'source-over';
127
+ c.globalAlpha = 0.22;
128
+ const dm = IMG.dust[v % 4];
129
+ c.save(); c.translate(W / 2, H / 2); c.scale(v % 2 ? -1 : 1, v % 3 ? 1 : -1); c.drawImage(dm, -W / 2, -H / 2, W, H); c.restore();
130
+ c.restore();
131
+ }
132
+
133
+ // ------------------------------------------------------------------ MATCHBOX
134
+ const BOXP = { px: 930, py: 880 };
135
+ function boxState(t) {
136
+ let sq = 0;
137
+ for (const tl of [1.0, 2.5, 3.0, 3.5, 4.0, 5.0, 5.25]) {
138
+ const d = t - tl;
139
+ if (d >= 0 && d < 0.3) sq += (tl === 2.5 ? 0.06 : 0.035) * Math.exp(-d * 12) * Math.cos(d * 28);
140
+ }
141
+ const st = { x: 0, y: 0, r: 0, sy: 1 - sq, sx: 1 + sq * 0.4, vis: true, blown: 0 };
142
+ if (t >= 6.54) {
143
+ const u = t - 6.54;
144
+ st.x = -1500 * u - 1400 * u * u; st.y = 380 * u + 2600 * u * u; st.r = -4.2 * u - 3 * u * u; st.blown = u;
145
+ if (u > 0.5) st.vis = false;
146
+ }
147
+ return st;
148
+ }
149
+ function drawBox(c, t, D, st) {
150
+ if (!st.vis) return;
151
+ const sd = 100 + bv(D);
152
+ const T = (P) => xf(P, { x: BOXP.px + st.x, y: BOXP.py + st.y, r: st.r, sx: st.sx, sy: st.sy });
153
+ const low = SHOT === LOW; // R2s2 (P4): real low eye line for the dance shot - more front face, top plane foreshortened to ~62 %
154
+ const x0 = -370, x1 = 370, y1 = low ? -112 : -90, y2 = 0, dx = low ? 40 : 46, dy = low ? -64 : -104;
155
+ const a = [x0, y1], b = [x1, y1], cc = [x1 + dx, y1 + dy], d = [x0 + dx, y1 + dy], e = [x0, y2], g = [x1, y2], h = [x1 + dx, y2 + dy];
156
+ const top = T([a, b, cc, d]), front = T([a, b, g, e]), right = T([b, cc, h, g]);
157
+ // cast shadow (hatched) — only while resting
158
+ if (!st.blown) {
159
+ const S = [x0 + 30, y2], S2 = [x1, y2], S3 = [x1 + dx, y2 + dy], S4 = [x1 + dx + (low ? 34 : 70), y2 + dy + 40], S5 = [x1 + 60, y2 + 34], S6 = [x0 + 60, y2 + 30];
160
+ const sh = T([S, S2, S3, S4, S5, S6]);
161
+ hatch(c, dens(sh, 6), { seed: sd * 3, gap: 13, ang: -0.95, w: 2.6, amp: 1.2 });
162
+ }
163
+ const o = (k) => ({ seed: sd * 10 + k, amp: 1.8, dx: REG.dx, dy: REG.dy });
164
+ fillShape(c, dens(top), PAL.red, o(1));
165
+ fillShape(c, dens(front), PAL.yel, o(2));
166
+ fillShape(c, dens(right), PAL.red, o(3));
167
+ // right face shading hatch
168
+ hatch(c, dens(right, 6), { seed: sd * 5, gap: 11, ang: 1.15, w: 2.2, amp: 1.0 });
169
+ // striker band
170
+ const band = T([[x0 + 22, y1 + 20], [x1 - 22, y1 + 20], [x1 - 22, y2 - 20], [x0 + 22, y2 - 20]]);
171
+ fillShape(c, dens(band), PAL.ink, { seed: sd * 10 + 4, amp: 1.4 });
172
+ // grit specks
173
+ c.fillStyle = PAL.cream;
174
+ for (let i = 0; i < 90; i++) {
175
+ const px = lerp(x0 + 30, x1 - 30, hs(i, 3)), py = lerp(y1 + 26, y2 - 26, hs(i, 4));
176
+ const [q] = boil(T([[px, py]]), sd, 1.0, 40);
177
+ c.beginPath(); c.arc(q[0], q[1], 0.9 + 1.4 * hs(i, 5), 0, 7); c.fill();
178
+ }
179
+ // strike scratch (after the strike passes)
180
+ const sp = matchStrikeProgress(t);
181
+ if (sp > 0) {
182
+ const L = T([[x0 + 40, (y1 + y2) / 2 + 2], [lerp(x0 + 40, x1 - 30, sp * 0.5), (y1 + y2) / 2 - 3], [lerp(x0 + 40, x1 - 30, sp), (y1 + y2) / 2 + 1]]);
183
+ inkStroke(c, L, { seed: sd + 7, w: 4.5, t0: 30, t1: 60, amp: 1.0, color: PAL.cream, heavy: 0 });
184
+ }
185
+ // label on the top face (星火牌 安全火柴)
186
+ const LP = (u, v) => [x0 + u * (x1 - x0) + v * dx, y1 + v * dy];
187
+ const u0 = 0.25, u1 = 0.78, v0 = 0.2, v1 = 0.82;
188
+ const lab = T([LP(u0, v0), LP(u1, v0), LP(u1, v1), LP(u0, v1)]);
189
+ fillShape(c, dens(lab), PAL.hi, { seed: sd * 10 + 5, amp: 1.4, dx: REG.dx * 0.6, dy: REG.dy * 0.6 });
190
+ const [O] = T([LP(u0, v1)]), [X] = T([LP(u1, v1)]), [Y] = T([LP(u0, v0)]);
191
+ c.save();
192
+ affineTo(c, O, X, Y, 540, 120);
193
+ const jx = (hs(bv(D), 61) - 0.5) * 1.6, jy = (hs(bv(D), 62) - 0.5) * 1.6;
194
+ c.translate(jx, jy);
195
+ // little flame mark
196
+ const fl = spline(xf(flameUnit(4, 0.1), { x: 62, y: 104, sx: 72, sy: 90 }), true, 3);
197
+ fillShape(c, fl, PAL.red, { seed: 3, amp: 0.8 });
198
+ c.fillStyle = PAL.ink;
199
+ c.font = '64px "LXGW Marker Gothic"';
200
+ c.textBaseline = 'alphabetic';
201
+ c.fillText('星火牌', 118, 84);
202
+ c.font = '30px "LXGW Marker Gothic"';
203
+ c.fillText('安 全 火 柴', 330, 82);
204
+ c.fillRect(118, 98, 400, 5);
205
+ c.restore();
206
+ inkLoop(c, dens(lab), { seed: sd * 10 + 6, w: 3.4, amp: 1.2, heavy: 0.2 });
207
+ // edges
208
+ const E9 = [[a, b], [b, cc], [cc, d], [d, a], [a, e], [e, g], [g, b], [g, h], [h, cc]];
209
+ E9.forEach(([p, q], i) => { const [P2, Q2] = T([p, q]); edge(c, P2, Q2, sd * 20 + i, { w: 5.2 }); });
210
+ }
211
+
212
+ // ------------------------------------------------------------------ MATCH
213
+ // head centre (hx,hy), rotation r (0 = upright: stick hangs below the head), bend (px)
214
+ function matchStrikeProgress(t) {
215
+ return clamp(K(t, [[0.333, 0], [0.375, 0.3, 'lin'], [0.417, 0.64, 'lin'], [0.458, 0.9, 'lin'], [0.5, 1.0, 'out2']]));
216
+ }
217
+ const STICK = 262, BUTT = [1150, 742];
218
+ function matchState(t) {
219
+ const s = { hx: 0, hy: 0, r: 0, bend: 0, burnt: t >= 2.0, vis: true, smear: 0 };
220
+ if (t < 0.333) { // anticipation: rear back, bow the stick
221
+ const u = t / 0.333;
222
+ s.hx = K(t, [[0, 660], [0.25, 590, 'out2'], [0.333, 586]]);
223
+ s.hy = K(t, [[0, 800], [0.25, 752, 'out2'], [0.333, 760]]);
224
+ s.r = K(t, [[0, 2.3], [0.25, 2.62, 'out2'], [0.333, 2.56]]);
225
+ s.bend = K(t, [[0, -4], [0.25, -20], [0.333, -24]]);
226
+ s.hx += Math.sin(u * 40) * 1.5; // nervous tremble
227
+ } else if (t < 0.5) { // strike drag across the striker (on 1s)
228
+ const p = matchStrikeProgress(t);
229
+ s.hx = lerp(612, 1284, p); s.hy = 836 - 4 * Math.sin(p * Math.PI);
230
+ s.r = lerp(2.2, 2.05, p); s.bend = 22; s.smear = t < 0.44 ? 1 : 0;
231
+ } else if (t < 1.0) { // flip up + land upright on the box top
232
+ const rE = -0.12, endX = BUTT[0] + Math.sin(rE) * STICK, endY = BUTT[1] - Math.cos(rE) * STICK;
233
+ // tossed up spinning about its middle (~600 deg), rise ease-out, one hang drawing at the apex (f18), fall ease-in
234
+ const fi = clamp(t * FPS - 12, 0, 12), H2 = STICK / 2;
235
+ const c0x = 1290 - Math.sin(2.05) * H2, c0y = 832 + Math.cos(2.05) * H2;
236
+ const c1x = endX - Math.sin(rE) * H2, c1y = endY + Math.cos(rE) * H2;
237
+ const CY = [0, 110, 200, 270, 315, 340, 350, 351, 359, 375, 399, 434, 481].map((d) => c0y - d); // relative climb (stage units)
238
+ const CYF = CY.map((y, i) => i <= 6 ? y : lerp(CY[6], c1y, [0, 0.004, 0.03, 0.11, 0.27, 0.55, 1][i - 6])); // R2: screen spacing ~7/21/43/75/120 px
239
+ const i0 = Math.floor(fi), i1 = Math.min(12, i0 + 1), fr = fi - i0;
240
+ const cy = lerp(CYF[i0], CYF[i1], fr), cx = lerp(c0x, c1x, fi / 12);
241
+ const RT = [0, 0.115, 0.23, 0.34, 0.45, 0.555, 0.655, 0.745, 0.83, 0.9, 0.955, 0.99, 1]; // R2: spin decelerates -> falls near upright
242
+ s.r = lerp(2.05, rE + Math.PI * 4, lerp(RT[i0], RT[i1], fr));
243
+ if (i0 === 11) s.sy = 1.05; // last airborne drawing stretched along the fall
244
+ s.hx = cx + Math.sin(s.r) * H2; s.hy = cy - Math.cos(s.r) * H2;
245
+ s.bend = lerp(10, -6, fi / 12); s.toss = true;
246
+ } else {
247
+ const d = t - 1.0;
248
+ const k = Math.round(d * 12); // R2: landing wobble about the butt on 2s: +8 deg, -4 deg, +1.5 deg, rest
249
+ s.r = k < 3 ? [0.14, -0.07, 0.025][k] : 0;
250
+ s.bend = k < 3 ? [7, -4, 1.5][k] : 0;
251
+ if (k === 0) s.sy = 0.96;
252
+ s.hx = BUTT[0] + Math.sin(s.r) * STICK; s.hy = BUTT[1] - Math.cos(s.r) * STICK; // rotate about the butt on the box
253
+ }
254
+ if (t >= 6.54) { // blown away
255
+ const u = t - 6.54;
256
+ s.hx += 1700 * u + 600 * u * u; s.hy += -900 * u + 1800 * u * u; s.r += 13 * u; s.bend = 10;
257
+ if (u > 0.45) s.vis = false;
258
+ }
259
+ return s;
260
+ }
261
+ function matchLocal(bend) {
262
+ // stick centreline from y=18 (under the head) to y=318 (butt)
263
+ const cl = [];
264
+ for (let i = 0; i <= 8; i++) { const u = i / 8; cl.push([bend * Math.sin(Math.PI * u), 18 + (STICK - 18) * u]); }
265
+ return cl;
266
+ }
267
+ function drawMatchStick(c, s, D, seedBase) {
268
+ if (!s.vis) return;
269
+ const sd = seedBase + bv(D);
270
+ const cl = matchLocal(s.bend);
271
+ const T = (P) => xf(P, { x: s.hx, y: s.hy, r: s.r, sy: s.sy ?? 1 });
272
+ // stick outline polygon (local)
273
+ const L = [], R = [];
274
+ cl.forEach(([x, y], i) => { const w = 10 + (i / 8) * 1.8; L.push([x - w, y]); R.push([x + w, y]); });
275
+ const poly = T([...L, ...R.reverse()]);
276
+ const d = dens(poly, 4);
277
+ fillShape(c, d, PAL.hi, { seed: sd * 3, amp: 1.4, dx: REG.dx * 0.7, dy: REG.dy * 0.7 });
278
+ // grain line
279
+ inkStroke(c, T(cl.slice(2, 7).map(([x, y]) => [x + 3, y])), { seed: sd * 3 + 1, w: 2.2, t0: 20, t1: 30, amp: 1.2, heavy: 0 });
280
+ inkLoop(c, d, { seed: sd * 3 + 2, w: 4.4, amp: 1.5, t0: 8, t1: 16, heavy: 0.3 });
281
+ }
282
+ // SMEAR / MULTIPLES frames of the strike (on 1s): f9 = stretch smear, f10 = multiples
283
+ function stickEnds(s) { return [[s.hx, s.hy], [s.hx - Math.sin(s.r) * STICK, s.hy + Math.cos(s.r) * STICK]]; }
284
+ function drawStrikeSmear(c, s, D, t) {
285
+ const f = Math.round(t * FPS);
286
+ const pv = matchState(t - 1 / FPS);
287
+ const [H1, B1] = stickEnds(s), [H0, B0] = stickEnds(pv);
288
+ const sd = 330 + f;
289
+ if (f === 9) {
290
+ // stick smear: the swept area, streaked
291
+ const quad = [B0, H0, H1, B1];
292
+ fillShape(c, dens(quad, 5), PAL.hi, { seed: sd, amp: 1.5, dx: REG.dx, dy: REG.dy });
293
+ for (let k = 0; k < 6; k++) {
294
+ const u = 0.12 + k * 0.15 + hs(sd, k) * 0.05;
295
+ const a = [lerp(H0[0], B0[0], u), lerp(H0[1], B0[1], u)], b = [lerp(H1[0], B1[0], u), lerp(H1[1], B1[1], u)];
296
+ inkStroke(c, [a, b], { seed: sd * 3 + k, w: 3, t0: 60, t1: 12, min: 0.05, amp: 1.2, heavy: 0 });
297
+ }
298
+ drawMatchStick(c, { ...s, smear: 0 }, D, 200);
299
+ // head stretched ~300% back along the path
300
+ const dx = H1[0] - H0[0], dy = H1[1] - H0[1], L = Math.hypot(dx, dy), ux = dx / L, uy = dy / L;
301
+ if (!NODOT) {
302
+ const back = L * 1.1, r = 27;
303
+ const P = [[r, 0], [r * 0.6, r * 0.85], [-back * 0.45, r * 0.55], [-back, 0, 1], [-back * 0.45, -r * 0.55], [r * 0.6, -r * 0.85]];
304
+ const d = spline(xf(P, { x: H1[0], y: H1[1], r: Math.atan2(uy, ux) }), true, 4);
305
+ fillShape(c, d, PAL.red, { seed: sd + 1, amp: 1.5, dx: REG.dx, dy: REG.dy });
306
+ inkLoop(c, d, { seed: sd + 2, w: 5.5, amp: 1.5 });
307
+ }
308
+ } else {
309
+ // multiples: three drawn echoes between the previous and current pose
310
+ for (let k = 0; k < 3; k++) {
311
+ const u = 0.25 + k * 0.25;
312
+ const e = { ...s, hx: lerp(pv.hx, s.hx, u), hy: lerp(pv.hy, s.hy, u), r: lerp(pv.r, s.r, u), bend: s.bend, smear: 0 };
313
+ const [He, Be] = stickEnds(e);
314
+ inkStroke(c, [Be, He], { seed: sd + 10 + k, w: 3.2, t0: 40, t1: 10, min: 0.1, amp: 1.2, heavy: 0 });
315
+ const hd = headPts(e);
316
+ if (!NODOT) {
317
+ fillShape(c, hd, PAL.red, { seed: sd + 20 + k, amp: 1.2, dx: REG.dx, dy: REG.dy });
318
+ inkLoop(c, hd, { seed: sd + 30 + k, w: 3.6, amp: 1.2 });
319
+ }
320
+ }
321
+ drawMatchStick(c, { ...s, smear: 0 }, D, 200);
322
+ const hd = headPts(s);
323
+ if (!NODOT) {
324
+ fillShape(c, hd, PAL.red, { seed: sd + 40, amp: 1.4, dx: REG.dx, dy: REG.dy });
325
+ inkLoop(c, hd, { seed: sd + 41, w: 5.5, amp: 1.4, heavy: 0.4 });
326
+ }
327
+ }
328
+ // speed lines trailing along the striker
329
+ for (let k = 0; k < 4; k++) {
330
+ const yy = s.hy - 26 + k * 17 + hs(sd, k) * 5;
331
+ const x1 = Math.min(H0[0], H1[0]) - 30 - hs(sd, k, 2) * 50;
332
+ inkStroke(c, [[x1 - 200 - hs(sd, k, 3) * 160, yy], [x1, yy]], { seed: sd + 50 + k, w: 4.2, t0: 120, t1: 8, min: 0.05, amp: 1.2, heavy: 0, color: PAL.hi });
333
+ }
334
+ }
335
+ const HEAD_R = 27; // V5: the round match head (reported as the dot, r = 27 stage units)
336
+ function headPts(s, stretch = 1) {
337
+ const P = (V5 ? circlePts(0, -2, 28, 28, 26) : circlePts(0, -2, 30, 28, 22)).map(([x, y]) => [x, y < 0 ? y * stretch : y]);
338
+ return xf(P, { x: s.hx, y: s.hy, r: s.r });
339
+ }
340
+ function drawMatchHead(c, s, D, seedBase, t) {
341
+ if (!s.vis) return;
342
+ const sd = seedBase + bv(D);
343
+ if (s.smear && t >= 0.36) { drawStrikeSmear(c, s, D, t); return; }
344
+ if (t >= 0.44 && t < 0.5) { // last drag frame: speed lines + the phosphor starting to spit
345
+ const sd2 = 340 + Math.round(t * FPS);
346
+ for (let k = 0; k < 4; k++) {
347
+ const yy = s.hy - 26 + k * 17 + hs(sd2, k) * 5, x1 = s.hx - 60 - hs(sd2, k, 2) * 40;
348
+ inkStroke(c, [[x1 - 260 - hs(sd2, k, 3) * 160, yy], [x1, yy]], { seed: sd2 + 50 + k, w: 4.2, t0: 120, t1: 8, min: 0.05, amp: 1.2, heavy: 0, color: PAL.hi });
349
+ }
350
+ for (let k = 0; k < 5; k++) {
351
+ const a = Math.PI + (k - 2) * 0.35, dd = 40 + 25 * hs(sd2, k, 4);
352
+ drawSpark(c, s.hx + Math.cos(a) * dd, s.hy + Math.sin(a) * dd - 10, Math.cos(a) * 700, Math.sin(a) * 700, 6, sd2 * 3 + k);
353
+ }
354
+ }
355
+ if (NODOT) return; // v5 ?nolink=dot: the match head is the reel's dot
356
+ const d = headPts(s);
357
+ fillShape(c, d, s.burnt ? PAL.ink : PAL.red, { seed: sd * 7, amp: 1.4, dx: REG.dx * 0.8, dy: REG.dy * 0.8 });
358
+ inkLoop(c, d, { seed: sd * 7 + 1, w: 5.5, amp: 1.4, heavy: 0.4 });
359
+ // highlight / ember
360
+ const hl = xf([[-11, -14], [-5, -20]], { x: s.hx, y: s.hy, r: s.r });
361
+ inkStroke(c, hl, { seed: sd + 5, w: 5, t0: 3, t1: 5, color: PAL.hi, amp: 0.8, heavy: 0 });
362
+ if (s.burnt) {
363
+ const em = xf([[6, -16]], { x: s.hx, y: s.hy, r: s.r })[0];
364
+ c.fillStyle = PAL.red; c.beginPath(); c.arc(em[0], em[1], 4.5, 0, 7); c.fill();
365
+ }
366
+ }
367
+
368
+ // ------------------------------------------------------------------ FLAME SPIRIT
369
+ // flame silhouette in unit space (bottom centre at 0,0; height 1 up = -y)
370
+ function flameUnit(seed, wild = 0.15, lick = 1) {
371
+ const j = (k, a) => (hs(seed, k) - 0.5) * 2 * a;
372
+ const hr_ = 0.66 + j(1, 0.06 + wild * 0.3) * lick;
373
+ const hl_ = 0.76 + j(2, 0.06 + wild * 0.3) * lick;
374
+ const tipx = j(3, 0.04 + wild * 0.2), tipy = -1.0 + j(4, 0.03 + wild * 0.12);
375
+ return [
376
+ [0, 0.0],
377
+ [0.3, -0.035],
378
+ [0.43, -0.2],
379
+ [0.415, -0.4],
380
+ [0.3 + j(5, 0.03 + wild * 0.1), -hr_, 1],
381
+ [0.17, -0.55 + j(6, 0.04)],
382
+ [tipx, tipy, 1],
383
+ [-0.15, -0.6 + j(7, 0.04)],
384
+ [-0.34 + j(8, 0.03 + wild * 0.1), -hl_, 1],
385
+ [-0.44, -0.46],
386
+ [-0.44, -0.24],
387
+ [-0.31, -0.04],
388
+ ];
389
+ }
390
+ function innerUnit(seed, wild) {
391
+ const j = (k, a) => (hs(seed, k + 20) - 0.5) * 2 * a;
392
+ return [
393
+ [0, 0.0], [0.24, -0.04], [0.33, -0.18], [0.3, -0.36],
394
+ [0.02 + j(1, 0.04 + wild * 0.1), -0.66 + j(2, 0.04), 1],
395
+ [-0.3, -0.38], [-0.34, -0.18], [-0.24, -0.04],
396
+ ];
397
+ }
398
+ // warp: shear + curl (top bends more) + squash/stretch about the base
399
+ function spiritXf(P, s) {
400
+ const Hh = s.H;
401
+ return P.map(([x, y, cr]) => {
402
+ const up = -y; // 0..1
403
+ let X = x * Hh * s.sx, Y = y * Hh * s.sy;
404
+ X += (s.kx * up + s.curl * up * up) * Hh;
405
+ if (s.spinW !== undefined) X *= s.spinW;
406
+ const c = Math.cos(s.r || 0), sn = Math.sin(s.r || 0);
407
+ return [s.x + X * c - Y * sn, s.y + X * sn + Y * c, cr];
408
+ });
409
+ }
410
+ function spiritPt(s, x, y) { return spiritXf([[x, y]], s)[0]; }
411
+
412
+ function drawSpirit(c, s, D, t) {
413
+ if (!s || s.H < 3) return;
414
+ const sd = s.seed ?? (500 + D.id);
415
+ const wild = s.wild ?? 0.12;
416
+ if (s.smearUp) { // take-off smear: speed lines left behind under the stretched body
417
+ for (let k = 0; k < 5; k++) {
418
+ const x = s.x + (k - 2) * s.H * 0.11 + (hs(sd, k) - 0.5) * 10;
419
+ const y0 = s.y + 10 + hs(sd, k, 2) * 30, L = s.H * (0.45 + 0.5 * hs(sd, k, 3));
420
+ inkStroke(c, [[x, y0 + L], [x, y0]], { seed: sd + 200 + k, w: 4.5, t0: 60, t1: 8, min: 0.05, amp: 1, heavy: 0 });
421
+ }
422
+ }
423
+ const outer = spline(spiritXf(flameUnit(sd, wild, s.lick ?? 1), s), true, 4);
424
+ const inner = spline(spiritXf(innerUnit(sd, wild).map(([x, y, k]) => [x, y * 0.98 - 0.02, k]), s), true, 4);
425
+ // detached flame droplet (fire FX): a lick breaks off and rises on some drawings
426
+ const dph = Math.abs(Math.round(sd)) % 3;
427
+ if (s.drops !== false && s.H > 120 && dph !== 2) { // lick cycle: low -> high -> gone (reads as fire, not a stuck speck)
428
+ const k = dph * 0.8, hx = (dph ? 0.1 : -0.12) + (hs(sd, 92) - 0.5) * 0.08;
429
+ const up = 1.08 + k * 0.28, sz = 0.07 * (1 - k * 0.6);
430
+ const P = [[0, 0.06], [0.05, 0.0], [0, -0.13, 1], [-0.05, 0.0]].map(([x, y, cr]) => [hx + x * sz / 0.07, -up + y * sz / 0.07, cr]);
431
+ const dd = spline(spiritXf(P, s), true, 3);
432
+ fillShape(c, dd, PAL.red, { seed: sd + 3, amp: 1, dx: REG.dx * 0.5, dy: REG.dy * 0.5 });
433
+ inkLoop(c, dd, { seed: sd + 4, w: Math.min(3.4, s.H * 0.016), amp: 1, t0: 3, t1: 6 });
434
+ }
435
+ fillShape(c, outer, PAL.red, { seed: sd * 2, amp: 1.8, dx: REG.dx, dy: REG.dy });
436
+ fillShape(c, inner, PAL.yel, { seed: sd * 2 + 1, amp: 1.6, dx: REG.dx, dy: REG.dy });
437
+ // paper-white glint on the inner flame
438
+ if (s.H > 60) {
439
+ const g = spiritXf([[-0.2, -0.16], [-0.23, -0.28], [-0.19, -0.4]], s);
440
+ inkStroke(c, g, { seed: sd + 8, w: Math.max(3, s.H * 0.035), t0: 6, t1: 12, color: PAL.hi, amp: 1, heavy: 0 });
441
+ }
442
+ const lw = Math.max(3.0, Math.min(7, s.H * 0.024));
443
+ inkLoop(c, outer, { seed: sd * 2 + 2, w: lw, amp: 1.8, splits: 2, heavy: 0.45, t0: 10, t1: 20 });
444
+ // back-facing (during spin) -> no face
445
+ if (s.face) drawFace(c, s, sd, lw);
446
+ if (s.arms) drawArms(c, s, sd, lw);
447
+ }
448
+ function drawFace(c, s, sd, lw) {
449
+ const f = s.face;
450
+ const Hh = s.H;
451
+ const lx = (f.look ?? 0) * 0.045, ly = (f.lookY ?? 0) * 0.03;
452
+ const ex = 0.1 * (f.wide ?? 1), ey = -0.37 + (f.fy ?? 0);
453
+ const eyeRX = 0.047, eyeRY = 0.078 * (f.pop ?? 1);
454
+ for (const side of [-1, 1]) {
455
+ const cx = side * ex + lx, cy = ey + ly;
456
+ const mode = f.eyes ?? 'open';
457
+ if (mode === 'open' || mode === 'fierce' || (mode === 'wink' && side < 0)) {
458
+ if (mode === 'fierce') inkStroke(c, spiritXf([[cx + side * eyeRX * 1.5, cy - 0.12], [cx - side * eyeRX * 1.1, cy - 0.065]], s), { seed: sd + 46 + side, w: lw * 1.0, t0: 3, t1: 3, amp: 0.6, heavy: 0 });
459
+ const P = circlePts(cx, cy, eyeRY, 18, eyeRX * (f.pop ?? 1)).map(([x, y]) => [x, y]);
460
+ const d = spline(spiritXf(P, s), true, 3);
461
+ fillShape(c, d, PAL.ink, { seed: sd + 30 + side, amp: 0.9 });
462
+ const g = spiritPt(s, cx - eyeRX * 0.35, cy - eyeRY * 0.45);
463
+ c.fillStyle = PAL.hi; c.beginPath(); c.arc(g[0], g[1], Math.max(1.6, Hh * 0.013), 0, 7); c.fill();
464
+ } else if (mode === 'closed' || mode === 'wink') {
465
+ inkStroke(c, spiritXf([[cx - eyeRX * 1.1, cy + 0.01], [cx, cy + 0.02], [cx + eyeRX * 1.1, cy + 0.01]], s), { seed: sd + 40 + side, w: lw * 0.9, t0: 4, t1: 4, amp: 0.8, heavy: 0 });
466
+ } else if (mode === 'happy') { // ^ ^
467
+ inkStroke(c, spiritXf([[cx - eyeRX * 1.2, cy + 0.02], [cx, cy - 0.04], [cx + eyeRX * 1.2, cy + 0.02]], s), { seed: sd + 40 + side, w: lw * 0.95, t0: 4, t1: 4, amp: 0.8, heavy: 0 });
468
+ } else if (mode === 'squeeze') { // > <
469
+ const k = side < 0 ? 1 : -1;
470
+ inkStroke(c, spiritXf([[cx - eyeRX * k, cy - 0.035], [cx + eyeRX * k * 0.9, cy], [cx - eyeRX * k, cy + 0.035]], s), { seed: sd + 40 + side, w: lw * 0.9, t0: 4, t1: 4, amp: 0.8, heavy: 0 });
471
+ } else if (mode === 'squint') {
472
+ const P = circlePts(cx, cy + 0.015, eyeRY * 0.55, 14, eyeRX);
473
+ fillShape(c, spline(spiritXf(P, s), true, 3), PAL.ink, { seed: sd + 30 + side, amp: 0.9 });
474
+ inkStroke(c, spiritXf([[cx - eyeRX * 1.3, cy - 0.06 - side * 0.012], [cx + eyeRX * 1.3, cy - 0.06 + side * 0.012]], s), { seed: sd + 44 + side, w: lw * 0.8, t0: 3, t1: 3, amp: 0.6, heavy: 0 });
475
+ }
476
+ }
477
+ const m = f.mouth ?? 'smile';
478
+ const my = -0.245 + (f.fy ?? 0) + ly * 0.6, mx = lx * 0.8;
479
+ if (m === 'smile') {
480
+ inkStroke(c, spiritXf([[mx - 0.06, my - 0.01], [mx, my + 0.025], [mx + 0.06, my - 0.01]], s), { seed: sd + 50, w: lw * 0.85, t0: 3, t1: 3, amp: 0.7, heavy: 0 });
481
+ } else if (m === 'o' || m === 'grin' || m === 'open') {
482
+ const P = m === 'o' ? circlePts(mx, my + 0.005, 0.03, 14, 0.024)
483
+ : [[mx - 0.075, my - 0.02], [mx + 0.075, my - 0.02], [mx + 0.05, my + 0.04], [mx, my + 0.06], [mx - 0.05, my + 0.04]];
484
+ const d = spline(spiritXf(P, s), true, 3);
485
+ fillShape(c, d, PAL.ink, { seed: sd + 52, amp: 0.8 });
486
+ if (m !== 'o') {
487
+ const tg = spline(spiritXf(circlePts(mx + 0.01, my + 0.04, 0.02, 12, 0.03), s), true, 3);
488
+ c.save(); c.beginPath(); polyPath(c, d); c.clip();
489
+ fillShape(c, tg, PAL.red, { seed: sd + 53, amp: 0.6 });
490
+ c.restore();
491
+ }
492
+ } else if (m === 'grit') {
493
+ inkStroke(c, spiritXf([[mx - 0.055, my], [mx + 0.055, my]], s), { seed: sd + 54, w: lw * 0.9, t0: 3, t1: 3, amp: 0.6, heavy: 0 });
494
+ } else if (m === 'puff') {
495
+ const d = spline(spiritXf(circlePts(mx, my, 0.02, 12, 0.045), s), true, 3);
496
+ fillShape(c, d, PAL.ink, { seed: sd + 55, amp: 0.6 });
497
+ }
498
+ // cheeks
499
+ if (f.cheeks) {
500
+ for (const side of [-1, 1]) {
501
+ const d = spline(spiritXf(circlePts(side * 0.2 + lx, -0.27 + (f.fy ?? 0), 0.025, 12, 0.04), s), true, 3);
502
+ fillShape(c, d, PAL.red, { seed: sd + 60 + side, amp: 0.6 });
503
+ }
504
+ }
505
+ }
506
+ // rubber-hose arms with paper-white gloves
507
+ function drawArms(c, s, sd, lw) {
508
+ const A = s.arms;
509
+ for (const side of [-1, 1]) {
510
+ const a = side < 0 ? A.L : A.R;
511
+ if (!a) continue;
512
+ const sh = [side * 0.4, -0.36];
513
+ const ang = a.ang, len = (a.len ?? 0.42);
514
+ // direction in unit space: outward = side*x, positive ang = up
515
+ const dx = side * Math.cos(ang), dy = -Math.sin(ang);
516
+ const bx = -dy * (a.bend ?? 0.3) * side, by = dx * (a.bend ?? 0.3) * side;
517
+ const mid = [sh[0] + dx * len * 0.5 + bx * len * 0.35, sh[1] + dy * len * 0.5 + by * len * 0.35];
518
+ const hand = [sh[0] + dx * len, sh[1] + dy * len];
519
+ // arms don't squash with the body: build in a non-squashed frame
520
+ const s2 = { ...s, sx: (s.sx + 1) / 2, sy: (s.sy + 1) / 2 };
521
+ const P = spiritXf([sh, mid, hand], s2);
522
+ inkStroke(c, P, { seed: sd + 70 + side, w: lw * 1.05, t0: 4, t1: 2, min: 0.6, jit: 0.2, amp: 1.2, heavy: 0 });
523
+ const hp = P[2], R = s.H * 0.082 * (a.big ?? 1);
524
+ const g = circlePts(hp[0], hp[1], R, 16, R * 1.08);
525
+ fillShape(c, g, PAL.hi, { seed: sd + 80 + side, amp: 1, dx: REG.dx * 0.5, dy: REG.dy * 0.5 });
526
+ inkLoop(c, g, { seed: sd + 82 + side, w: lw * 0.9, amp: 1, t0: 4, t1: 8 });
527
+ // two finger lines
528
+ const fa = Math.atan2(P[2][1] - P[1][1], P[2][0] - P[1][0]);
529
+ for (const k of [-1, 1]) {
530
+ const a0 = fa + k * 0.35;
531
+ inkStroke(c, [[hp[0] + Math.cos(a0) * R * 0.2, hp[1] + Math.sin(a0) * R * 0.2], [hp[0] + Math.cos(a0) * R * 0.8, hp[1] + Math.sin(a0) * R * 0.8]], { seed: sd + 90 + k + side * 3, w: lw * 0.55, t0: 2, t1: 3, amp: 0.5, heavy: 0 });
532
+ }
533
+ }
534
+ }
535
+
536
+ // ------------------------------------------------------------------ choreography of the spirit
537
+ function hopEnv(t, hops) { // returns { y offset, sq (sy multiplier), spin }
538
+ let y = 0, sq = 1, spin = 0;
539
+ for (const h of hops) {
540
+ const { t0, t1, h: hh } = h;
541
+ if (t > t0 && t < t1) { const p = inv(t0, t1, t); y -= hh * 4 * p * (1 - p); if (h.spin) spin = p; }
542
+ // anticipation squash before take-off, stretch after, squash on landing, overshoot
543
+ sq *= 1 + K(t, [[t0 - 0.125, 0], [t0 - 0.02, -0.2, 'out2'], [t0 + 0.04, 0.22, 'out2'], [lerp(t0, t1, 0.5), 0.0], [t1 - 0.04, 0.14], [t1, -0.3, 'in2'], [t1 + 0.085, 0.1, 'out2'], [t1 + 0.18, 0]]) * ((t > t0 - 0.13 && t < t1 + 0.2) ? 1 : 0);
544
+ }
545
+ return { y, sq, spin };
546
+ }
547
+ const GROUND = 745;
548
+ const HOPS = [
549
+ { t0: 2.833, t1: 3.0, h: 120 },
550
+ { t0: 3.333, t1: 3.5, h: 115 },
551
+ { t0: 3.75, t1: 4.0, h: 175, spin: true },
552
+ ];
553
+ function spiritState(t, D, m) {
554
+ if (t < 0.5 || t >= 6.5) return null;
555
+ const s = { x: 0, y: 0, H: 100, sx: 1, sy: 1, kx: 0, curl: 0, r: 0, wild: 0.12, face: null, arms: null };
556
+ if (t < 2.0) {
557
+ // sitting on the match head
558
+ const pm = matchState(Math.max(0, t - 1 / FPS));
559
+ s.x = m.hx; s.y = m.hy + 18;
560
+ s.H = K(t, [[0.5, 40], [0.542, 180, 'out3'], [0.625, 225, 'out2'], [0.792, 168], [1.0, 172], [1.25, 188, 'out2'], [1.75, 190]]);
561
+ s.wild = K(t, [[0.5, 1.2], [0.8, 0.5], [1.1, 0.15]]);
562
+ const vx = (m.hx - pm.hx) * FPS, vy = (m.hy - pm.hy) * FPS;
563
+ s.kx = clamp(-vx * 0.00035, -0.7, 0.7);
564
+ const sp = Math.hypot(vx, vy);
565
+ s.sy = 1 + Math.min(0.45, sp * 0.00012); s.sx = 1 / Math.sqrt(s.sy);
566
+ if (t >= 1.25) {
567
+ const pop = K(t, [[1.25, 1.35], [1.333, 1.0, 'out2']]);
568
+ const blink = t >= CUES.blink - 0.01 && t < CUES.blink + 0.07;
569
+ const look = K(t, [[1.458, 0], [1.5, -1, 'out3'], [1.625, -1], [1.667, 1, 'out3'], [1.75, 1], [1.833, 0, 'out2']]);
570
+ s.face = { eyes: blink ? 'closed' : 'open', look, pop, mouth: t < 1.5 ? 'o' : 'smile', fy: -0.1 };
571
+ s.kx += look * 0.06;
572
+ if (t >= 1.75) { // crouch: anticipation for the jump
573
+ const q = K(t, [[1.75, 1], [1.917, 0.64, 'out2'], [2.0, 0.6]]);
574
+ s.sy *= q; s.sx *= 1 / Math.pow(q, 0.8);
575
+ s.face.eyes = 'fierce'; s.face.mouth = 'grit';
576
+ s.x += (hs(D.id, 3) - 0.5) * 4;
577
+ }
578
+ }
579
+ return s;
580
+ }
581
+ if (t < 2.5) { // JUMP (on 1s)
582
+ const fi = clamp(Math.round(t * FPS) - 48, 0, 11);
583
+ s.x = lerp(1150, 840, fi / 12);
584
+ // R1s2 screen spacing: rise 80/52/27/10, hang (95% squash), fall 15/40/70/95/120/140 (ease-in)
585
+ s.y = [478, 403, 348, 316, 301, 300, 314, 351, 412, 493, 590, 697][fi];
586
+ s.H = K(t, [[2.0, 190], [2.4, 228, 'out2']]);
587
+ s.sy = [2.6, 1.3, 1.18, 1.06, 1.0, 0.95, 1.0, 1.06, 1.12, 1.18, 1.24, 1.26][fi];
588
+ if (fi === 0) s.smearUp = true;
589
+ s.sx = 1 / Math.pow(s.sy, 0.8);
590
+ s.kx = K(t, [[2.0, 0.25], [2.25, 0.05], [2.458, -0.15]]);
591
+ s.r = K(t, [[2.0, -0.25], [2.25, 0.1], [2.458, 0.05]]);
592
+ s.wild = 0.35;
593
+ s.face = { eyes: t < 2.2 ? 'squeeze' : 'open', mouth: t < 2.2 ? 'grin' : 'o', pop: 1.1 };
594
+ return s;
595
+ }
596
+ // ---- on the matchbox: landing, dance, spin, squash
597
+ s.H = 250; s.y = GROUND;
598
+ const xk = [[2.5, 840], [2.833, 840], [3.0, 690, 'io2'], [3.333, 690], [3.5, 965, 'io2'], [3.75, 965], [4.0, 810, 'io2'], [5.5, 820], [6.0, 860, 'io2']];
599
+ s.x = K(t, xk);
600
+ const vx = (K(t + 0.02, xk) - K(t - 0.02, xk)) / 0.04;
601
+ const he = hopEnv(t, HOPS);
602
+ s.y += he.y;
603
+ // landing from the big jump
604
+ let sq = he.sq * (1 + K(t, [[2.5, -0.44], [2.583, 0.14, 'out2'], [2.667, -0.05], [2.75, 0]]));
605
+ // groove bounce on the beats while grounded
606
+ if (t >= 4.0 && t < 4.5) sq *= 1 + 0.06 * Math.cos((t - 4.0) * Math.PI * 8);
607
+ sq = Math.max(0.62, sq);
608
+ s.sy = sq; s.sx = 1 / Math.pow(sq, 0.75);
609
+ s.kx = clamp(-vx * 0.0006, -0.35, 0.35);
610
+ s.curl = clamp(-vx * 0.0004, -0.3, 0.3);
611
+ s.wild = 0.14;
612
+ // arms pop out after landing
613
+ const armOn = t >= 2.583;
614
+ let AL = { ang: -0.6, bend: 0.3 }, AR = { ang: -0.6, bend: 0.3 };
615
+ let face = { eyes: 'open', mouth: 'smile' };
616
+ if (t < 2.75) { face = { eyes: t < 2.583 ? 'squeeze' : 'open', mouth: t < 2.583 ? 'grin' : 'open', pop: t < 2.667 ? 1.2 : 1 }; }
617
+ // dance arms: swing with the hops
618
+ const beatPh = (t - 2.5) * 2; // beats
619
+ const swing = Math.sin(beatPh * Math.PI);
620
+ AL = { ang: 0.2 + 0.9 * swing, bend: 0.5 }; AR = { ang: 0.2 - 0.9 * swing, bend: 0.5 };
621
+ if (t >= 2.5 && t < 2.83) { AL = { ang: K(t, [[2.583, 1.3], [2.75, 0.5]]), bend: 0.3 }; AR = { ang: K(t, [[2.583, 1.3], [2.75, 0.5]]), bend: 0.3 }; }
622
+ face.look = clamp(vx * 0.004, -1, 1);
623
+ // twirl in hop 3
624
+ if (he.spin > 0 && he.spin < 1) {
625
+ s.spinW = Math.cos(he.spin * Math.PI * 2);
626
+ if (Math.abs(s.spinW) < 0.35) s.spinW = Math.sign(s.spinW || 1) * 0.35;
627
+ face = s.spinW > 0 ? { eyes: 'happy', mouth: 'open' } : null;
628
+ AL = { ang: 1.4, bend: 0.2 }; AR = { ang: 1.4, bend: 0.2 };
629
+ }
630
+ if (t >= 4.0 && t < 4.5) { // shimmy: alternating lean every drawing
631
+ const k = Math.round((t - 4.0) * 12) % 2 ? 1 : -1;
632
+ s.kx += k * 0.14; s.curl += k * 0.08;
633
+ AL = { ang: 1.0 + k * 0.35, bend: -0.4 * k }; AR = { ang: 1.0 - k * 0.35, bend: 0.4 * k };
634
+ face = { eyes: 'happy', mouth: 'open', look: k * 0.5 };
635
+ }
636
+ if (t >= 4.5 && t < 5.0) { // TA-DA (held on 3s)
637
+ const q = K(t, [[4.5, 1.18], [4.625, 1.08, 'out2']]);
638
+ s.sy = q; s.sx = 1 / Math.pow(q, 0.8);
639
+ s.kx = 0; s.curl = 0;
640
+ AL = { ang: 0.85, bend: -0.15, len: 0.48, big: 1.15 }; AR = { ang: 0.85, bend: -0.15, len: 0.48, big: 1.15 };
641
+ face = { eyes: 'happy', mouth: 'grin', cheeks: true };
642
+ }
643
+ if (t >= 5.0 && t < 5.5) { // gathering: dips on 5.0 and 5.25, arms pulled in
644
+ const q = 1 + K(t, [[5.0, -0.2], [5.083, 0.05, 'out2'], [5.25, -0.24, 'in2'], [5.333, 0.08, 'out2'], [5.5, 0]]);
645
+ s.sy = q; s.sx = 1 / Math.pow(q, 0.8);
646
+ AL = { ang: K(t, [[5.0, 0.4], [5.4, -0.9]]), bend: 0.8 }; AR = { ang: K(t, [[5.0, 0.4], [5.4, -0.9]]), bend: 0.8 };
647
+ face = { eyes: 'fierce', mouth: 'smile' };
648
+ }
649
+ if (t >= 5.5 && t < 6.25) { // SPIN wind-up (on 1s)
650
+ const u = inv(5.5, 6.25, t);
651
+ const ph = 1.3 * u + 3.2 * u * u; // turns
652
+ s.spinW = Math.cos(ph * Math.PI * 2);
653
+ const back = s.spinW < 0;
654
+ if (Math.abs(s.spinW) < 0.22) s.spinW = (s.spinW < 0 ? -1 : 1) * 0.22;
655
+ s.y -= 70 * E.out2(u);
656
+ s.H = lerp(250, 330, E.io2(u));
657
+ s.wild = lerp(0.2, 0.7, u);
658
+ s.kx = 0.12 * Math.sin(ph * Math.PI * 2); s.curl = 0.25 * Math.sin(ph * Math.PI * 2 + 1);
659
+ s.sy = 1 + 0.08 * Math.sin(ph * 12); s.sx = 1;
660
+ face = back ? null : { eyes: 'fierce', mouth: 'grit' };
661
+ AL = null; AR = null;
662
+ s.spinU = u; s.spinPh = ph;
663
+ }
664
+ if (t >= 6.25) { // crouch, trembling, before the burst
665
+ const u = inv(6.25, 6.5, t);
666
+ s.y -= 70;
667
+ s.H = 330 * (1 + 0.1 * u);
668
+ const q = lerp(0.78, 0.6, E.out2(u));
669
+ s.sy = q; s.sx = 1 / Math.pow(q, 0.7);
670
+ s.x += (hs(D.id, 5) - 0.5) * 10 * (0.5 + u); s.y += (hs(D.id, 6) - 0.5) * 6;
671
+ s.wild = 0.9;
672
+ face = { eyes: 'squeeze', mouth: 'puff', cheeks: true };
673
+ AL = null; AR = null;
674
+ }
675
+ s.face = face;
676
+ s.arms = armOn && (AL || AR) ? { L: AL, R: AR } : null;
677
+ return s;
678
+ }
679
+
680
+ // ------------------------------------------------------------------ small cel FX
681
+ function drawPuff(c, x, y, r, seed, o = {}) { // one little cloud puff (3–5 circles)
682
+ const n = o.n ?? 4;
683
+ const cs = [];
684
+ for (let i = 0; i < n; i++) {
685
+ const a = (i / n) * Math.PI * 2 + hs(seed, i) * 1.2;
686
+ const d = i === 0 ? 0 : r * 0.55;
687
+ cs.push({ x: x + Math.cos(a) * d * (o.wide ?? 1), y: y + Math.sin(a) * d * 0.7 * (o.flat ?? 1), r: r * (i === 0 ? 0.8 : 0.5 + 0.25 * hs(seed, i, 2)) });
688
+ }
689
+ cloud(c, cs, { seed, fill: o.fill ?? PAL.hi, w: o.w ?? Math.max(3, r * 0.08), holes: o.holes });
690
+ }
691
+ // union-of-circles cloud with a single boiling ink outline + optional dissipation holes
692
+ function cloud(c, cs, o) {
693
+ const { seed = 1, fill = PAL.hi, w = 6, amp = 1.8 } = o;
694
+ const L = LY.get(0);
695
+ L.save(); L.setTransform(c.getTransform());
696
+ const circ = (k, x, y, r, rx) => boil(circlePts(x, y, Math.max(1, r), Math.max(12, Math.round((rx ?? r) / 3)), rx), seed + k * 7, amp, 60);
697
+ // outer ink (radius varies per circle with a shadow-side bias)
698
+ L.fillStyle = PAL.ink;
699
+ cs.forEach((q, k) => {
700
+ if (q.r < 1) return;
701
+ const qrx = q.rx ?? q.r;
702
+ const P = circlePts(0, 0, 1, Math.max(12, Math.round(Math.max(q.r, qrx) / 3))).map(([ca, sa]) => {
703
+ const a = Math.atan2(sa, ca);
704
+ const ww = w * (0.75 + 0.55 * Math.max(0, Math.cos(a - 0.9)) + 0.25 * G.N3(ca * 2, sa * 2, seed + k));
705
+ return [q.x + ca * (qrx + ww * 0.5), q.y + sa * (q.r + ww * 0.5)];
706
+ });
707
+ L.beginPath(); polyPath(L, boil(P, seed + k * 7, amp, 60)); L.fill();
708
+ });
709
+ // colour (off-register) goes UNDER the ink: draw it on main first
710
+ c.save();
711
+ cs.forEach((q, k) => { if (q.r >= 1) fillShape(c, circlePts(q.x, q.y, q.r, Math.max(12, Math.round((q.rx ?? q.r) / 3)), q.rx), fill, { seed: seed + k * 7 + 3, amp, dx: REG.dx, dy: REG.dy }); });
712
+ c.restore();
713
+ // cut the inside of the ring
714
+ L.globalCompositeOperation = 'destination-out';
715
+ cs.forEach((q, k) => { if (q.r >= 1) { L.beginPath(); polyPath(L, circ(k, q.x, q.y, q.r - w * 0.5, q.rx !== undefined ? q.rx - w * 0.5 : undefined)); L.fill(); } });
716
+ L.globalCompositeOperation = 'source-over';
717
+ L.restore();
718
+ if (o.holes && o.holes.length) {
719
+ // dissipation: holes eat the colour + get their own inked rim
720
+ c.save();
721
+ // cut holes in colour on main by painting paper back is impossible -> use a second layer for the whole puff
722
+ c.restore();
723
+ }
724
+ blit(c, L);
725
+ }
726
+
727
+ // ------------------------------------------------------------------ SMOKE group with dissipation (own layer)
728
+ function smokeGroup(c, puffs, o) {
729
+ // puffs: [{x,y,r,holes:[{x,y,r}]}]; drawn onto layer 1 (fill + ink), holes cut + rimmed there, then composited
730
+ const { seed = 1, w = 6, fill = PAL.hi } = o;
731
+ const L = LY.get(1);
732
+ L.save(); L.setTransform(c.getTransform());
733
+ const ring = LY.get(2); ring.save(); ring.setTransform(c.getTransform());
734
+ for (const [k, p] of puffs.entries()) {
735
+ if (p.r < 2) continue;
736
+ const cs = p.cs || [{ x: p.x, y: p.y, r: p.r }];
737
+ cs.forEach((q, j) => {
738
+ if (q.r < 1.5) return;
739
+ const n = Math.max(12, Math.round(q.r / 3));
740
+ fillShape(L, circlePts(q.x, q.y, q.r, n), fill, { seed: seed + k * 31 + j * 5, amp: 1.6, dx: REG.dx, dy: REG.dy });
741
+ ring.fillStyle = PAL.ink;
742
+ const P = circlePts(q.x, q.y, q.r, n).map(([x, y]) => {
743
+ const a = Math.atan2(y - q.y, x - q.x);
744
+ const ww = w * (0.7 + 0.6 * Math.max(0, Math.cos(a - 0.9)));
745
+ return [q.x + Math.cos(a) * (q.r + ww * 0.5), q.y + Math.sin(a) * (q.r + ww * 0.5)];
746
+ });
747
+ ring.beginPath(); polyPath(ring, boil(P, seed + k * 31 + j * 5, 1.6, 60)); ring.fill();
748
+ });
749
+ }
750
+ ring.globalCompositeOperation = 'destination-out';
751
+ for (const [k, p] of puffs.entries()) {
752
+ const cs = p.cs || [{ x: p.x, y: p.y, r: p.r }];
753
+ cs.forEach((q, j) => { if (q.r >= 1.5) { ring.beginPath(); polyPath(ring, boil(circlePts(q.x, q.y, q.r - w * 0.5, Math.max(12, Math.round(q.r / 3))), seed + k * 31 + j * 5 + 1, 1.6, 60)); ring.fill(); } });
754
+ }
755
+ ring.globalCompositeOperation = 'source-over';
756
+ ring.restore();
757
+ // combine ring over fill in layer 1
758
+ L.save(); L.setTransform(1, 0, 0, 1, 0, 0); L.drawImage(ring.canvas, 0, 0); L.restore();
759
+ // volume: one inked shade arc inside each puff (lower-right), like a hand-drawn smoke cel
760
+ for (const [k, p] of puffs.entries()) {
761
+ if (p.r < 20) continue;
762
+ const q = (p.cs || [p])[0];
763
+ const P = [];
764
+ const a0 = 0.15 + hs(seed, k, 5) * 0.3, a1 = a0 + 1.1 + hs(seed, k, 6) * 0.5;
765
+ for (let i = 0; i <= 10; i++) { const a = lerp(a0, a1, i / 10); P.push([q.x + Math.cos(a) * q.r * 0.66, q.y + Math.sin(a) * q.r * 0.62]); }
766
+ inkStroke(L, P, { seed: seed + 40 + k, w: w * 0.75, t0: 14, t1: 20, min: 0.1, amp: 1.2, heavy: 0 });
767
+ }
768
+ // holes: rim (source-atop) then cut
769
+ for (const [k, p] of puffs.entries()) {
770
+ for (const [j, hq] of (p.holes || []).entries()) {
771
+ if (hq.r < 1) continue;
772
+ const n = Math.max(10, Math.round(hq.r / 3));
773
+ L.globalCompositeOperation = 'source-atop';
774
+ L.fillStyle = PAL.ink; L.beginPath(); polyPath(L, boil(circlePts(hq.x, hq.y, hq.r + w * 0.9, n), seed + 900 + k * 17 + j, 1.4, 60)); L.fill();
775
+ L.globalCompositeOperation = 'destination-out';
776
+ L.beginPath(); polyPath(L, boil(circlePts(hq.x, hq.y, hq.r, n), seed + 950 + k * 17 + j, 1.4, 60)); L.fill();
777
+ }
778
+ }
779
+ L.globalCompositeOperation = 'source-over';
780
+ L.restore();
781
+ blit(c, L);
782
+ }
783
+
784
+ // ------------------------------------------------------------------ smoke wisp from the burnt match
785
+ function drawWisp(c, x, y, t, D, amt = 1, big = 0) {
786
+ if (amt <= 0) return;
787
+ const ph = D.fq / FPS * 5;
788
+ const Hh = 170 * (1 + 0.22 * big), dr = 250 * big; // R2s2 (P4): in the low shot the curl rises higher and drifts right into the empty third
789
+ const strand = (sd, off, w, k) => {
790
+ const P = [];
791
+ for (let i = 0; i <= 8; i++) {
792
+ const u = i / 8;
793
+ const v = u * (off ? 0.72 : 1);
794
+ P.push([x + off * v + Math.sin(v * 5 + ph + k) * (6 + (26 + 24 * big) * v) * amt + dr * v * v * amt, y - v * Hh * amt - off * 0.3 * v]);
795
+ }
796
+ inkStroke(c, P, { seed: sd + D.id, w, t0: 20, t1: 40, amp: 1.2, heavy: 0 });
797
+ return P;
798
+ };
799
+ const P = strand(700, 0, 5, 0);
800
+ if (big > 0) strand(710, 34 * big, 3.6, 1.7); // second, thinner strand
801
+ // curl at the top
802
+ const tp = P[P.length - 1];
803
+ const r = (16 + 24 * big) * amt;
804
+ const cp = [];
805
+ for (let i = 0; i <= 12; i++) { const a = -Math.PI / 2 + i * 0.55 + ph; const rr = r * (1 - i / 16); cp.push([tp[0] + Math.cos(a) * rr, tp[1] - r + Math.sin(a) * rr]); }
806
+ inkStroke(c, cp, { seed: 720 + D.id, w: 4 + big, t0: 6, t1: 20, amp: 1, heavy: 0 });
807
+ }
808
+
809
+ // ------------------------------------------------------------------ TITLE 手作
810
+ // skeletons in unit char space (0..1, y down); type: h (横) v (竖) p (撇) g (竖钩)
811
+ const CHAR_SHOU = [
812
+ { k: 'p', P: [[0.76, 0.055], [0.56, 0.13], [0.27, 0.185]] },
813
+ { k: 'h', P: [[0.17, 0.385], [0.5, 0.36], [0.83, 0.335]] },
814
+ { k: 'h', P: [[0.05, 0.61], [0.5, 0.58], [0.95, 0.545]] },
815
+ { k: 'g', P: [[0.52, 0.15], [0.527, 0.55], [0.515, 0.875, 1], [0.32, 0.765]] },
816
+ ];
817
+ const CHAR_ZUO = [
818
+ { k: 'p', P: [[0.34, 0.04], [0.25, 0.26], [0.05, 0.48]] },
819
+ { k: 'v', P: [[0.215, 0.31], [0.22, 0.62], [0.215, 0.95]] },
820
+ { k: 'p', P: [[0.6, 0.05], [0.53, 0.2], [0.41, 0.35]] },
821
+ { k: 'h', P: [[0.5, 0.235], [0.72, 0.225], [0.94, 0.215]] },
822
+ { k: 'v', P: [[0.575, 0.25], [0.58, 0.6], [0.575, 0.95]] },
823
+ { k: 'h', P: [[0.585, 0.47], [0.74, 0.465], [0.88, 0.46]] },
824
+ { k: 'h', P: [[0.585, 0.7], [0.75, 0.695], [0.9, 0.69]] },
825
+ ];
826
+ const TITLE = V5 ? { cx: 960, cy: 432, S: 300, gap: 8, w: 0.1, ink: 5.6 } : { cx: 960, cy: 440, S: 352, gap: 18 };
827
+ const V5CH = '逐帧手绘';
828
+ function charXf(i) {
829
+ const S = TITLE.S;
830
+ if (V5) { const J = [[-6, -0.04], [8, 0.03], [-5, -0.03], [7, 0.035]][i]; return { x: TITLE.cx + (i - 1.5) * (S + TITLE.gap), y: TITLE.cy + J[0], r: J[1], S }; }
831
+ return i === 0
832
+ ? { x: TITLE.cx - S / 2 - TITLE.gap / 2 - 8, y: TITLE.cy - 6, r: -0.045, S }
833
+ : { x: TITLE.cx + S / 2 + TITLE.gap / 2 + 8, y: TITLE.cy + 8, r: 0.035, S: S * 1.0 };
834
+ }
835
+ function strokeList() {
836
+ const L = [];
837
+ [CHAR_SHOU, CHAR_ZUO].forEach((ch, ci) => ch.forEach((st, si) => L.push({ ...st, ci, si })));
838
+ return L;
839
+ }
840
+ let STROKES = strokeList(); // V5: replaced in init() by the 32 strokes of 逐帧手绘 (Make Me a Hanzi medians, real stroke order)
841
+ // V5 stroke kinds from the median shape: d (点), g (hook at the end), p (撇, ends down-left), n (捺, ends down-right), b (bone)
842
+ function v5Kind(P) {
843
+ const seg = [], L = [0];
844
+ for (let i = 1; i < P.length; i++) { seg.push([P[i][0] - P[i - 1][0], P[i][1] - P[i - 1][1]]); L.push(L[i - 1] + Math.hypot(...seg[i - 1])); }
845
+ const tot = L.at(-1);
846
+ if (tot < 0.13) return { k: 'd' };
847
+ for (let i = seg.length - 1; i >= 1; i--) { // last sharp reversal near the end = hook
848
+ const a = Math.atan2(seg[i - 1][1], seg[i - 1][0]), b = Math.atan2(seg[i][1], seg[i][0]);
849
+ let dA = Math.abs(b - a); if (dA > Math.PI) dA = 2 * Math.PI - dA;
850
+ const tail = tot - L[i];
851
+ if (tail > 0.3 * tot) break;
852
+ if (dA > 1.75 && tail > 0.03) return { k: 'g', h: L[i] / tot };
853
+ }
854
+ let j = 0; while (j < L.length - 1 && L[j] < 0.7 * tot) j++;
855
+ const ex = P.at(-1)[0] - P[Math.min(j, P.length - 2)][0], ey = P.at(-1)[1] - P[Math.min(j, P.length - 2)][1], el = Math.hypot(ex, ey) || 1;
856
+ if (ex < -0.3 * el && ey > 0.1 * el) return { k: 'p' };
857
+ const sx = P.at(-1)[0] - P[0][0], sy = P.at(-1)[1] - P[0][1];
858
+ if (ex > 0.4 * el && ey > 0.15 * el && sy > 0.12 && sx > 0.12 && tot > 0.25) return { k: 'n' };
859
+ return { k: 'b' };
860
+ }
861
+ async function loadV5Strokes() {
862
+ const out = [], per = 0.125, dur = 0.14, cStart = [7.25, 7.292, 7.333, 7.375]; // 4 fuses, one per character, on 1s; strokes on 16ths (+1-frame flam per character)
863
+ for (const [ci, ch] of [...V5CH].entries()) {
864
+ const d = await (await fetch(`/demos/05-cel-boil/assets/hanzi/${encodeURIComponent(ch)}.json`)).json();
865
+ d.medians.forEach((m, si) => {
866
+ const P = m.map(([x, y]) => [x / 1024, (900 - y) / 1024]);
867
+ for (let i = 1; i < P.length - 1; i++) { // keep real corners (横折) sharp
868
+ const ax = P[i][0] - P[i - 1][0], ay = P[i][1] - P[i - 1][1], bx = P[i + 1][0] - P[i][0], by = P[i + 1][1] - P[i][1];
869
+ if (Math.hypot(ax, ay) < 0.035 || Math.hypot(bx, by) < 0.035) continue;
870
+ let dA = Math.abs(Math.atan2(by, bx) - Math.atan2(ay, ax)); if (dA > Math.PI) dA = 2 * Math.PI - dA;
871
+ if (dA > 1.0) P[i][2] = 1;
872
+ }
873
+ out.push({ P, ...v5Kind(P), ci, si, first: si === 0, t0: cStart[ci] + si * per, dur });
874
+ });
875
+ }
876
+ return out;
877
+ }
878
+ function strokeWorld(st) {
879
+ const X = charXf(st.ci);
880
+ return xf(st.P.map(([x, y, cr]) => [(x - 0.5) * X.S, (y - 0.5) * X.S, cr]), { x: X.x, y: X.y, r: X.r });
881
+ }
882
+ function strokeWidth(k, u, W0, st) {
883
+ if (V5 && st) {
884
+ if (k === 'd') return W0 * (0.95 + 0.45 * Math.sin(Math.PI * Math.min(1, u * 1.1)));
885
+ if (k === 'n') return W0 * (u < 0.82 ? 0.9 + 0.4 * sstep(0, 0.82, u) : lerp(1.3, 0.6, (u - 0.82) / 0.18));
886
+ if (k === 'g') return W0 * (u < st.h ? 1.0 + 0.06 * Math.sin(u * Math.PI / st.h) : lerp(1.0, 0.45, (u - st.h) / (1 - st.h)));
887
+ }
888
+ if (k === 'p') return W0 * (1.12 - 0.62 * Math.pow(u, 1.4));
889
+ if (k === 'g') return W0 * (u < 0.8 ? 1.0 + 0.08 * Math.sin(u * Math.PI) : lerp(1.0, 0.5, (u - 0.8) / 0.2));
890
+ // bone: a touch fatter at both ends
891
+ return W0 * (0.94 + 0.16 * Math.pow(Math.abs(u - 0.5) * 2, 2));
892
+ }
893
+ function titleDense(st, seed, amp) {
894
+ const pts = resample(spline(strokeWorld(st), false, 5), 3.5);
895
+ return boil(pts, seed, amp, 80);
896
+ }
897
+ // draws the title strokes (each with progress 0..1) with union ink outline, off-register fill, drop shadow
898
+ function drawTitle(c, prog, D, o) {
899
+ const W0 = TITLE.S * (TITLE.w ?? 0.165), ink = TITLE.ink ?? 6.8;
900
+ const sv = bv(D);
901
+ const polys = (grow, seedOff, amp, prg = prog) => STROKES.map((st, i) => {
902
+ const p = prg[i]; if (p <= 0) return null;
903
+ const full = titleDense(st, 3000 + i * 11 + seedOff + sv * 101, amp);
904
+ const part = prefix(full, E.out2(p) * 0.999 + 0.001 * p);
905
+ if (part.length < 2) return null;
906
+ const tot = arclen(full).at(-1);
907
+ return outline(part, (u, s) => strokeWidth(st.k, s / tot, W0, st) + grow(s, i), true);
908
+ }).filter(Boolean);
909
+ // drop shadow (ink block shadow, pops at the settle)
910
+ if (o.shadow > 0) {
911
+ const sx = 9 * o.shadow, sy = 12 * o.shadow;
912
+ c.save(); c.translate(sx, sy);
913
+ for (const p of polys(() => ink * 2, 0, 1.6)) fillPoly(c, p, PAL.ink);
914
+ c.restore();
915
+ }
916
+ // fill (off-register, independently boiled)
917
+ c.save(); c.translate(REG.dx * 0.9, REG.dy * 0.9);
918
+ for (const p of polys(() => 1.5, 55, 1.8)) fillPoly(c, p, PAL.yel);
919
+ c.restore();
920
+ // highlights
921
+ if (o.shine > 0) {
922
+ STROKES.forEach((st, i) => {
923
+ if (prog[i] < 0.6) return;
924
+ const full = titleDense(st, 5000 + i * 7 + sv * 13, 1.2);
925
+ const n = full.length;
926
+ const a = Math.floor(n * 0.14), b = Math.floor(n * (st.k === 'p' ? 0.4 : 0.36));
927
+ const seg = full.slice(a, b).map((q, j, arr) => {
928
+ const q0 = arr[Math.max(0, j - 1)], q1 = arr[Math.min(arr.length - 1, j + 1)];
929
+ let tx = q1[0] - q0[0], ty = q1[1] - q0[1]; const tl = Math.hypot(tx, ty) || 1; tx /= tl; ty /= tl;
930
+ // offset toward upper-left side
931
+ let nx = -ty, ny = tx; if (nx + ny > 0) { nx = -nx; ny = -ny; }
932
+ return [q[0] + nx * W0 * 0.24, q[1] + ny * W0 * 0.24];
933
+ });
934
+ if (seg.length > 2) inkStroke(c, seg, { seed: 5100 + i + sv, w: W0 * 0.16 * o.shine, t0: 10, t1: 18, color: PAL.hi, amp: 0.6, heavy: 0, dense: true });
935
+ });
936
+ }
937
+ // ink ring = fat minus thin (union outline)
938
+ const L = LY.get(0);
939
+ L.save(); L.setTransform(c.getTransform());
940
+ for (const p of polys((s, i) => ink * 2 * (0.8 + 0.35 * G.N3(s / 50, i, sv * 3.3)), 0, 1.6)) fillPoly(L, p, PAL.ink);
941
+ L.globalCompositeOperation = 'destination-out';
942
+ for (const p of polys(() => 0, 0, 1.6)) fillPoly(L, p, '#000');
943
+ L.restore();
944
+ blit(c, L);
945
+ }
946
+
947
+ // ------------------------------------------------------------------ hand-lettered HAND MADE (monoline marker caps)
948
+ const LET = {
949
+ H: { w: 0.62, s: [[[0, 0], [0, 1]], [[0.62, 0], [0.62, 1]], [[0, 0.5], [0.62, 0.48]]] },
950
+ A: { w: 0.7, s: [[[0, 1], [0.35, 0, 1], [0.7, 1]], [[0.15, 0.64], [0.55, 0.62]]] },
951
+ N: { w: 0.62, s: [[[0, 1], [0, 0, 1], [0.62, 1, 1], [0.62, 0]]] },
952
+ D: { w: 0.62, s: [[[0, 0], [0, 1]], [[0, 0, 1], [0.34, 0.02], [0.6, 0.28], [0.62, 0.52], [0.56, 0.8], [0.32, 0.99], [0, 1, 1]]] },
953
+ M: { w: 0.8, s: [[[0, 1], [0.03, 0, 1], [0.4, 0.68, 1], [0.77, 0, 1], [0.8, 1]]] },
954
+ E: { w: 0.54, s: [[[0.54, 0], [0, 0, 1], [0, 1, 1], [0.56, 1]], [[0, 0.5], [0.44, 0.49]]] },
955
+ // V5 only (FRAME BY FRAME)
956
+ F: { w: 0.54, s: [[[0.55, 0], [0, 0, 1], [0, 1]], [[0, 0.49], [0.42, 0.48]]] },
957
+ R: { w: 0.6, s: [[[0, 1], [0, 0]], [[0, 0, 1], [0.34, 0.01], [0.56, 0.1], [0.6, 0.26], [0.5, 0.43], [0.28, 0.5], [0, 0.5, 1]], [[0.26, 0.5], [0.62, 1]]] },
958
+ B: { w: 0.6, s: [[[0, 0], [0, 1]], [[0, 0, 1], [0.3, 0.01], [0.5, 0.09], [0.53, 0.25], [0.4, 0.43], [0.18, 0.48], [0, 0.48, 1]], [[0, 0.48, 1], [0.34, 0.5], [0.58, 0.61], [0.62, 0.8], [0.5, 0.95], [0.28, 1], [0, 1, 1]]] },
959
+ Y: { w: 0.64, s: [[[0, 0], [0.32, 0.5, 1], [0.64, 0]], [[0.32, 0.5], [0.32, 1]]] },
960
+ };
961
+ function drawWord(c, word, x0, y0, h, D, o = {}) {
962
+ const tr = o.track ?? 0.36;
963
+ let x = x0;
964
+ const out = [];
965
+ for (let i = 0; i < word.length; i++) {
966
+ const ch = word[i];
967
+ if (ch === ' ') { x += h * 0.55; continue; }
968
+ const L = LET[ch];
969
+ out.push({ ch, x, L, i });
970
+ x += (L.w + tr) * h;
971
+ }
972
+ const total = x - x0 - tr * h;
973
+ const shift = o.center ? -total / 2 : 0;
974
+ const SL = [];
975
+ for (const g of out) {
976
+ const pop = o.pop ? o.pop(g.i) : 1;
977
+ if (pop <= 0) continue;
978
+ const bounce = Math.sin(g.i * 2.1) * h * 0.06;
979
+ const rot = (hs(g.i, 77) - 0.5) * 0.12;
980
+ for (const [k, S] of g.L.s.entries()) {
981
+ const P = xf(S.map(([u, v, cr]) => [(u - g.L.w / 2) * h, (v - 0.5) * h, cr]), { x: g.x + shift + g.L.w * h / 2, y: y0 + bounce, r: rot, sx: pop, sy: pop });
982
+ SL.push({ P, st: { seed: 8000 + g.i * 13 + k + bv(D) * 101, w: h * (o.wk ?? 0.15), t0: 6, t1: 6, min: 0.75, jit: 0.15, heavy: 0, amp: 1.3, step: 3 } });
983
+ }
984
+ }
985
+ if (o.outline) {
986
+ const ow = o.outline * 2, sh = o.shadow ?? 0;
987
+ if (sh) SL.forEach(({ P, st }) => inkStroke(c, P.map(([x, y, cr]) => [x + sh, y + sh, cr]), { ...st, w: st.w + ow, color: PAL.ink }));
988
+ SL.forEach(({ P, st }) => inkStroke(c, P, { ...st, w: st.w + ow, color: PAL.ink }));
989
+ }
990
+ SL.forEach(({ P, st }) => inkStroke(c, P, { ...st, color: o.color ?? PAL.ink }));
991
+ }
992
+
993
+ // ------------------------------------------------------------------ tagline from real glyph outlines (boiled)
994
+ function glyphPolys(font, text, size, tracking) {
995
+ const polys = [];
996
+ let x = 0;
997
+ for (const ch of text) {
998
+ const g = font.charToGlyph(ch);
999
+ const path = g.getPath(x, 0, size);
1000
+ let cur = [], lx = 0, ly = 0;
1001
+ for (const cmd of path.commands) {
1002
+ if (cmd.type === 'M') { if (cur.length > 2) polys.push(cur); cur = [[cmd.x, cmd.y]]; lx = cmd.x; ly = cmd.y; }
1003
+ else if (cmd.type === 'L') { cur.push([cmd.x, cmd.y]); lx = cmd.x; ly = cmd.y; }
1004
+ else if (cmd.type === 'Q') { for (let k = 1; k <= 6; k++) { const u = k / 6, a = (1 - u) * (1 - u), b = 2 * u * (1 - u), cc = u * u; cur.push([a * lx + b * cmd.x1 + cc * cmd.x, a * ly + b * cmd.y1 + cc * cmd.y]); } lx = cmd.x; ly = cmd.y; }
1005
+ else if (cmd.type === 'C') { for (let k = 1; k <= 8; k++) { const u = k / 8, a = (1 - u) ** 3, b = 3 * u * (1 - u) ** 2, cc = 3 * u * u * (1 - u), d = u ** 3; cur.push([a * lx + b * cmd.x1 + cc * cmd.x2 + d * cmd.x, a * ly + b * cmd.y1 + cc * cmd.y2 + d * cmd.y]); } lx = cmd.x; ly = cmd.y; }
1006
+ else if (cmd.type === 'Z') { if (cur.length > 2) polys.push(cur); cur = []; }
1007
+ }
1008
+ if (cur.length > 2) polys.push(cur);
1009
+ x += g.advanceWidth * (size / font.unitsPerEm) + tracking;
1010
+ }
1011
+ return { polys, width: x - tracking };
1012
+ }
1013
+ let TAG = null;
1014
+ function drawTagline(c, D, reveal, x, y) {
1015
+ if (!TAG || reveal <= 0) return;
1016
+ const sv = bv(D);
1017
+ const total = TAG.a.width + TAG.gap + TAG.b.width;
1018
+ const x0 = x - total / 2;
1019
+ c.save();
1020
+ // brush-reveal mask stepping left→right
1021
+ c.beginPath(); c.rect(x0 - 20, y - 90, (total + 40) * reveal, 160); c.clip();
1022
+ c.beginPath();
1023
+ for (const [run, ox] of [[TAG.a, x0], [TAG.b, x0 + TAG.a.width + TAG.gap]]) {
1024
+ for (const P of run.polys) {
1025
+ const Q = boil(resample(P.map(([px, py]) => [px + ox, py + y]), 2.2, true), 6000 + sv * 17, 0.9, 30);
1026
+ polyPath(c, Q);
1027
+ }
1028
+ }
1029
+ c.fillStyle = PAL.ink; c.fill('nonzero');
1030
+ c.restore();
1031
+ const tx = x0 + TAG.a.width + TAG.gap / 2;
1032
+ if (reveal > (tx - x0 + 10) / (total + 40)) drawTwinkle(c, tx, y - 21, 20 + 3 * (sv % 2), 6100 + sv, { w: 3 });
1033
+ }
1034
+
1035
+ // ------------------------------------------------------------------ BURST / BADGE choreography
1036
+ const C0 = [862, 614]; // burst centre (where the spirit crouches, in screen space)
1037
+ const BADGE = V5 ? { x: 960, y: 460, rx: 700, ry: 300 } : { x: 960, y: 464, rx: 520, ry: 262 }; // V5: wider badge for 4 characters
1038
+ let BADGE_PERIM = null;
1039
+ // R2 (REVIEW_2 P2): fireball -> badge in 4 drawings on 2s (f168/170/172/174), ease-out spacing .32/.28/.22/.18
1040
+ function morphP(t) { return t < 6.99 ? 0 : t < 7.07 ? 0.32 : t < 7.15 ? 0.6 : t < 7.24 ? 0.82 : 1; }
1041
+ function badgeCircles(t, D) {
1042
+ // fire circles: explode out of C0, then migrate into a scalloped badge around the title
1043
+ const N = 22;
1044
+ if (!BADGE_PERIM) {
1045
+ const dense = circlePts(0, 0, BADGE.ry, 600, BADGE.rx);
1046
+ const per = arclen(dense, true).at(-1);
1047
+ BADGE_PERIM = resample(dense, per / N, true).slice(0, N);
1048
+ }
1049
+ const out = [];
1050
+ const tb = t - 6.583;
1051
+ for (let k = 0; k < N; k++) {
1052
+ const [px, py] = BADGE_PERIM[k];
1053
+ const a0 = Math.atan2(py / BADGE.ry, px / BADGE.rx);
1054
+ // exploded state (fireball lumps)
1055
+ const rr = 200 + 240 * hs(k, 2);
1056
+ const e = E.out3(clamp(tb / 0.4));
1057
+ const ex = C0[0] + Math.cos(a0 + 0.25) * rr * e, ey = C0[1] + Math.sin(a0 + 0.25) * rr * 0.8 * e;
1058
+ const er = (58 + 70 * hs(k, 3)) * E.outBack(clamp(tb / 0.25), 1.4);
1059
+ // badge state: evenly spaced scallops, alternating size
1060
+ const bx = BADGE.x + px, by = BADGE.y + py;
1061
+ const br = (k % 2 ? 84 : 100) + 14 * hs(k, 6);
1062
+ const sk = 0.14 * hs(k, 7); // small per-scallop stagger; all home on f174
1063
+ const mu = clamp((morphP(t) - sk) / (1 - sk));
1064
+ out.push({ x: lerp(ex, bx, mu), y: lerp(ey, by, mu), r: Math.max(0, lerp(er, br, mu)), mu });
1065
+ }
1066
+ // core: grows out of the starburst into the badge body (an ellipse)
1067
+ const coreU = E.out3(inv(6.62, 6.9, t));
1068
+ const mv = morphP(t);
1069
+ const cx = lerp(C0[0], BADGE.x, mv), cy = lerp(C0[1], BADGE.y, mv);
1070
+ const core = [{ x: cx, y: cy, r: lerp(260, BADGE.ry * 1.0, mv) * coreU * (1 + 0.06 * Math.sin(Math.PI * mv)), rx: lerp(260, BADGE.rx * 1.0, mv) * coreU }];
1071
+ // R1s3: while migrating, clamp every scallop so it overlaps the core ellipse by >= 0.5 r (no channel / bite / paper hole).
1072
+ // Weighted by mu so the exploded fireball lumps (mu = 0) stay free; at rest (centre on the rim) the clamp is a no-op.
1073
+ {
1074
+ const erx = core[0].rx, ery = core[0].r;
1075
+ if (erx > 1 && ery > 1) for (const s of out) {
1076
+ const w = clamp(s.mu / 0.3);
1077
+ if (w <= 0) continue;
1078
+ const dx = s.x - cx, dy = s.y - cy, d = Math.hypot(dx, dy);
1079
+ if (d < 1) continue;
1080
+ const ca = dx / d, sa = dy / d, Re = 1 / Math.hypot(ca / erx, sa / ery);
1081
+ const dt = Re + 0.5 * s.r;
1082
+ if (d > dt) { const dn = lerp(d, dt, w); s.x = cx + ca * dn; s.y = cy + sa * dn; }
1083
+ }
1084
+ }
1085
+ for (let k = 0; k < 6; k++) {
1086
+ const a = k * 1.047 + 0.4;
1087
+ const d = lerp(170, 120, mv);
1088
+ core.push({ x: cx + Math.cos(a) * d * lerp(1, 2.2, mv), y: cy + Math.sin(a) * d * lerp(1, 0.9, mv), r: 150 * coreU });
1089
+ }
1090
+ // R1s2: while the scallops migrate, bridge each neighbour pair with a circle pulled toward the core -> the union has no
1091
+ // inward bite (jury: notch at 7.20). At rest the bridges sit inside the badge ellipse, so the final outline is unchanged.
1092
+ const brg = [];
1093
+ for (let k = 0; k < N; k++) {
1094
+ const a = out[k], b = out[(k + 1) % N], w = clamp((Math.min(a.mu, b.mu) - 0.2) / 0.3);
1095
+ if (w <= 0) continue;
1096
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
1097
+ brg.push({ x: lerp(mx, cx, 0.3), y: lerp(my, cy, 0.3), r: (a.r + b.r) * 0.5 * w });
1098
+ }
1099
+ const all = [...core, ...out, ...brg];
1100
+ if (t > 6.6 && t < 7.45) all.push(...holeFillers(all));
1101
+ return all;
1102
+ }
1103
+ // R1s3: coarse raster of the circle union; flood the outside from the border; any uncovered cell NOT reached is an
1104
+ // enclosed paper hole -> plug it with a small circle (appended last, so no other circle's boil seed changes).
1105
+ function holeFillers(cs) {
1106
+ const S = 6;
1107
+ let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
1108
+ for (const q of cs) { if (q.r < 1) continue; const rx = q.rx ?? q.r; x0 = Math.min(x0, q.x - rx); x1 = Math.max(x1, q.x + rx); y0 = Math.min(y0, q.y - q.r); y1 = Math.max(y1, q.y + q.r); }
1109
+ if (x1 <= x0) return [];
1110
+ x0 -= 2 * S; y0 -= 2 * S;
1111
+ const W = Math.ceil((x1 - x0) / S) + 4, H = Math.ceil((y1 - y0) / S) + 4;
1112
+ const g = new Uint8Array(W * H); // 1 = covered, 2 = outside
1113
+ for (let j = 0; j < H; j++) for (let i = 0; i < W; i++) {
1114
+ const px = x0 + (i + 0.5) * S, py = y0 + (j + 0.5) * S;
1115
+ for (const q of cs) {
1116
+ if (q.r < 1) continue;
1117
+ const rx = q.rx ?? q.r, u = (px - q.x) / rx, v = (py - q.y) / q.r;
1118
+ if (u * u + v * v <= 1) { g[j * W + i] = 1; break; }
1119
+ }
1120
+ }
1121
+ const st = [0];
1122
+ g[0] = 2;
1123
+ while (st.length) {
1124
+ const p = st.pop(), i = p % W, j = (p - i) / W;
1125
+ for (const [a, b] of [[i + 1, j], [i - 1, j], [i, j + 1], [i, j - 1]]) {
1126
+ if (a < 0 || b < 0 || a >= W || b >= H) continue;
1127
+ const n = b * W + a;
1128
+ if (g[n] === 0) { g[n] = 2; st.push(n); }
1129
+ }
1130
+ }
1131
+ const f = [];
1132
+ for (let j = 0; j < H; j++) for (let i = 0; i < W; i++) if (g[j * W + i] === 0) f.push({ x: x0 + (i + 0.5) * S, y: y0 + (j + 0.5) * S, r: S * 1.3 + 6 });
1133
+ return f;
1134
+ }
1135
+ function drawBadge(c, t, D) {
1136
+ if (t < 6.583) return;
1137
+ const sv = bv(D);
1138
+ const cs = badgeCircles(t, D);
1139
+ // settle boing at 8.5
1140
+ const sc = 1 + K(t, [[8.5, 0], [8.54, 0.045, 'out2'], [8.625, -0.02], [8.75, 0.008], [8.875, 0]]);
1141
+ const ox = t >= 7.24 && t < 7.32 ? 1.03 : 1, oy = t >= 7.24 && t < 7.32 ? 0.99 : 1; // R2: +3 % width overshoot on f174
1142
+ const cs2 = cs.map((q) => ({ x: BADGE.x + (q.x - BADGE.x) * sc * ox, y: BADGE.y + (q.y - BADGE.y) * sc * oy, r: q.r * sc * oy, rx: (q.rx ?? q.r) * sc * ox }));
1143
+ cloud(c, cs2, { seed: 1200 + sv * 57 + (t < 7.0 ? D.id : 0), fill: PAL.red, w: 8.5, amp: 2.0 });
1144
+ const q = cs[0];
1145
+ // R2 (REVIEW_2 P2): 6 big fire lumps (90-140 px, tomato-on-tomato, ink C-curl, yellow highlight) slide OUT into the
1146
+ // scallops with the morph; 3 faint lumps keep boiling inside the badge 7.2-8.5
1147
+ if (t >= 6.7 && t < 7.3 && q.r > 20) {
1148
+ const lu = morphP(t);
1149
+ for (let k = 0; k < 6; k++) {
1150
+ const a = k * 1.05 + 0.5 + hs(k, 81) * 0.4, d0 = 0.34 + 0.2 * hs(k, 82);
1151
+ const rim = BADGE_PERIM[(k * 4 + 1) % BADGE_PERIM.length];
1152
+ const x = lerp(q.x + Math.cos(a) * (q.rx ?? q.r) * d0, BADGE.x + rim[0] * 0.9, lu);
1153
+ const y = lerp(q.y + Math.sin(a) * q.r * d0, BADGE.y + rim[1] * 0.9, lu);
1154
+ const r = (46 + 24 * hs(k, 83)) * (1 - 0.3 * lu) * E.outBack(clamp((t - 6.7) / 0.1), 1.5);
1155
+ fillShape(c, circlePts(x, y, r, 30), '#c9311f', { seed: 1340 + k + D.id, amp: 2.5 });
1156
+ fillShape(c, circlePts(x - r * 0.26, y - r * 0.28, r * 0.5, 20), PAL.yel, { seed: 1345 + k + D.id, amp: 1.5 });
1157
+ const P = [], c0 = hs(k, 84) * 6.28 + D.id * 0.3;
1158
+ for (let i = 0; i <= 14; i++) { const aa = c0 + (i / 14) * 3.9, rr = r * (0.95 - 0.4 * i / 14); P.push([x + Math.cos(aa) * rr, y + Math.sin(aa) * rr * 0.85]); }
1159
+ inkStroke(c, P, { seed: 1350 + k * 3 + D.id, w: 6, t0: 6, t1: 22, min: 0.2, amp: 1.2, heavy: 0 });
1160
+ }
1161
+ }
1162
+ if (t >= 7.2 && t < 8.5) {
1163
+ [[540, 470, 50], [1378, 410, 44], [1380, 548, 34]].forEach(([x, y, r0], k) => {
1164
+ const rr = r0 * E.outBack(clamp((t - 7.2) / 0.1), 1.4) * (1 + 0.08 * Math.sin(D.id * 1.9 + k * 2.1));
1165
+ fillShape(c, circlePts(x + 5 * Math.sin(D.id * 0.7 + k), y + 4 * Math.cos(D.id * 0.9 + k), rr, 26), '#d8392a', { seed: 1370 + k * 5 + bv(D), amp: 3 });
1166
+ });
1167
+ }
1168
+ // R2 (REVIEW_2 P2): yellow core holds in the fireball through f170, then travels in 2 drawings (f172, f174) to the start
1169
+ // of 手's first stroke, 100 -> 45 -> 20 %, with a 3-dot comet trail, and lights the fuse on arrival
1170
+ if (t < 7.3 && q.r > 20) {
1171
+ const p0 = strokeWorld(STROKES[0])[0];
1172
+ const stp = t < 7.12 ? 0 : t < 7.2 ? 1 : 2;
1173
+ const e = [0, 0.55, 1][stp], R = Math.min(q.r * 0.8, 240) * [1, 0.45, 0.2][stp];
1174
+ const ax = q.x + 6, ay = q.y + 4;
1175
+ const mx = (ax + p0[0]) / 2 - (p0[1] - ay) * 0.35, my = (ay + p0[1]) / 2 + (p0[0] - ax) * 0.35;
1176
+ const at = (u) => [(1 - u) * (1 - u) * ax + 2 * u * (1 - u) * mx + u * u * p0[0], (1 - u) * (1 - u) * ay + 2 * u * (1 - u) * my + u * u * p0[1]];
1177
+ const [gx, gy] = at(e), gs = stp === 0 && !V5 ? (q.rx ?? q.r) / q.r : 1; // V5: the core stays round (it is the reel's dot here)
1178
+ if (!NODOT) {
1179
+ if (stp > 0) for (let j = 1; j <= 3; j++) { // comet trail
1180
+ const [tx, ty] = at(Math.max(0, e - 0.13 * j)), tr = R * [0.62, 0.45, 0.3][j - 1];
1181
+ const T = circlePts(tx, ty, tr, 16);
1182
+ fillShape(c, T, PAL.yel, { seed: 1360 + j + D.id, amp: 1 });
1183
+ inkLoop(c, T, { seed: 1364 + j + D.id, w: 3, amp: 1, t0: 3, t1: 6 });
1184
+ }
1185
+ const P = spline(xf(starPts(9, R, R * 0.62, 1300 + D.id, D.id * 0.5, 0.3), { x: gx, y: gy, sx: gs }), true, 5);
1186
+ fillShape(c, P, PAL.yel, { seed: 1310 + D.id, amp: 2 });
1187
+ if (stp > 0) inkLoop(c, P, { seed: 1315 + D.id, w: 4, amp: 1.5, t0: 3, t1: 6 });
1188
+ const Q = spline(xf(starPts(7, R * 0.45, R * 0.28, 1320 + D.id, D.id * 0.9, 0.3), { x: gx, y: gy }), true, 4);
1189
+ fillShape(c, Q, PAL.hi, { seed: 1330 + D.id, amp: 2 });
1190
+ }
1191
+ if (stp === 2) drawTwinkle(c, p0[0], p0[1], 58, 1335 + D.id, { rot: 0.3, fill: PAL.hi }); // ignition
1192
+ }
1193
+ }
1194
+ function drawStarburst(c, t, D) {
1195
+ if (t < 6.583 || t > 6.88) return;
1196
+ const R = K(t, [[6.583, 1320], [6.625, 1240], [6.875, 250, 'in2']]);
1197
+ const sd = 1400 + D.id;
1198
+ const rot = D.id * 0.37;
1199
+ const ri = lerp(0.52, 0.3, inv(6.708, 6.875, t));
1200
+ const outer = spline(xf(starPts(15, R, R * ri, sd, rot, 0.28), { x: C0[0], y: C0[1] }), true, 6);
1201
+ fillShape(c, outer, PAL.yel, { seed: sd, amp: 2.5, dx: REG.dx, dy: REG.dy });
1202
+ const mid = spline(xf(starPts(11, R * 0.66, R * 0.36, sd + 1, rot + 0.2, 0.3), { x: C0[0], y: C0[1] }), true, 5);
1203
+ fillShape(c, mid, PAL.red, { seed: sd + 2, amp: 2.5, dx: REG.dx, dy: REG.dy });
1204
+ const core = spline(xf(starPts(9, R * 0.34, R * 0.2, sd + 3, rot + 0.5, 0.3), { x: C0[0], y: C0[1] }), true, 4);
1205
+ fillShape(c, core, PAL.hi, { seed: sd + 4, amp: 2 });
1206
+ inkLoop(c, outer, { seed: sd + 5, w: 9, amp: 2.5, heavy: 0.3, splits: 2 });
1207
+ }
1208
+ function speedLinesRadial(c, cx, cy, r0, r1, n, seed, w = 7, dir = 1) {
1209
+ for (let k = 0; k < n; k++) {
1210
+ const a = (k / n) * Math.PI * 2 + hs(seed, k) * 0.5;
1211
+ const a0 = r0 * (0.9 + 0.3 * hs(seed, k, 2)), a1 = r1 * (0.8 + 0.4 * hs(seed, k, 3));
1212
+ const P = [[cx + Math.cos(a) * a0, cy + Math.sin(a) * a0], [cx + Math.cos(a) * a1, cy + Math.sin(a) * a1]];
1213
+ inkStroke(c, dir > 0 ? P : P.reverse(), { seed: seed * 7 + k, w: w * (0.6 + 0.8 * hs(seed, k, 4)), t0: 12, t1: 90, min: 0.05, amp: 1.5, heavy: 0 });
1214
+ }
1215
+ }
1216
+ // sparks from the burst: some of them become the fuse heads that write the strokes
1217
+ const NSPARK = 30;
1218
+ const SMOKE = (() => { // 7 clusters, irregular angles; lead puff class + 1-2 satellites
1219
+ const cl = [[-2.62, 1], [-1.72, 2], [-0.86, 0], [0.12, 2], [0.8, 1], [1.62, 0], [2.4, 2]];
1220
+ const out = [];
1221
+ cl.forEach(([a, c0], i) => {
1222
+ const n = 2 + (i % 2);
1223
+ for (let j = 0; j < n; j++) {
1224
+ const cls = j === 0 ? c0 : (c0 + 1 + j) % 3;
1225
+ out.push({ k: out.length, lead: j === 0, a: a + (j ? (j === 1 ? 0.2 : -0.24) : 0) + (hs(i, j, 42) - 0.5) * 0.12,
1226
+ dist: j ? 0.78 + 0.25 * hs(i, j, 43) : 1, R: [60, 120, 240][cls] * (0.85 + 0.3 * hs(i, j, 41)) * (j ? 0.8 : 1),
1227
+ td: 7.25 + (2 / 24) * ((i * 2 + j * 3) % 5) });
1228
+ }
1229
+ });
1230
+ return out;
1231
+ })();
1232
+ function sparkState(k, t) {
1233
+ const a = (k / NSPARK) * Math.PI * 2 + (hs(k, 11) - 0.5) * 0.87;
1234
+ const v0 = 1700 + 1500 * hs(k, 12);
1235
+ const u = Math.max(0, t - 6.583);
1236
+ const drag = 4.2;
1237
+ const d = 110 + 90 * hs(k, 14) + (v0 / drag) * (1 - Math.exp(-drag * u));
1238
+ const x = C0[0] + Math.cos(a) * d, y = C0[1] + Math.sin(a) * d * 0.85 + 260 * u * u;
1239
+ const sp = v0 * Math.exp(-drag * u);
1240
+ return { x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp * 0.85 + 520 * u, size: [9, 15, 24][k % 3] * (0.85 + 0.3 * hs(k, 13)) };
1241
+ }
1242
+
1243
+ // ------------------------------------------------------------------ light of the flame: stepped (cel) glow pool, multiplied into the paper
1244
+ function drawLightPool(c, tq, D, ss) {
1245
+ if (tq < 0.5 || tq >= 6.5 || !ss) return;
1246
+ let R = K(tq, [[0.5, 60], [0.542, 330, 'out3'], [0.625, 380], [1.0, 310], [2.0, 315], [2.5, 345], [5.5, 350], [6.25, 430, 'io2'], [6.5, 470]]);
1247
+ const fv = bv(D) % 3; // 3 boil variants, radius flickers +-3 % on 2s
1248
+ R *= 1 + 0.03 * (fv - 1);
1249
+ const cx = ss.x, cy = ss.y - ss.H * 0.42 * ss.sy;
1250
+ const scal = (r, n, ph) => { // hand-cut scalloped edge (scissor bites)
1251
+ const P = [];
1252
+ for (let i = 0; i < 120; i++) { const a = (i / 120) * Math.PI * 2; const q = Math.abs(Math.sin(a * n / 2 + ph)); P.push([cx + Math.cos(a) * r * (0.975 + 0.035 * Math.sqrt(q)), cy + Math.sin(a) * r * (0.975 + 0.035 * Math.sqrt(q))]); }
1253
+ return P;
1254
+ };
1255
+ c.save();
1256
+ c.globalCompositeOperation = 'multiply';
1257
+ c.globalAlpha = 0.72; // R2s2 (P4): pool 28 % lighter so the character, not the disc, owns the frame
1258
+ fillShape(c, scal(R * 1.25, 22, fv * 0.7), '#fff7df', { seed: 8100 + bv(D), amp: 4, scale: 90 });
1259
+ fillShape(c, scal(R, 18, 1 + fv * 0.9), '#fff0b4', { seed: 8200 + bv(D), amp: 4, scale: 90 });
1260
+ c.restore();
1261
+ }
1262
+ function hopTrails(c, tq, D) {
1263
+ for (const h of HOPS) { // red-pencil dotted arc guide for the whole hop (the animator's path), R2s2: drawn on ahead of the hop
1264
+ if (tq < h.t0 - 0.25 || tq > h.t1 + 0.2) continue;
1265
+ const src = [];
1266
+ for (let i = 0; i <= 48; i++) { const st = spiritState(lerp(h.t0, h.t1, i / 48), D, null); src.push([st.x, st.y - st.H * 0.42 * st.sy]); }
1267
+ const step = 22 / SHOT.s, grow = clamp((Math.floor((tq - h.t0 + 0.25) * 12) + 1) / 3); // appears in 3 drawings (on 2s)
1268
+ let acc = 0, k = 0, tot = 0;
1269
+ for (let i = 1; i < src.length; i++) tot += Math.hypot(src[i][0] - src[i - 1][0], src[i][1] - src[i - 1][1]);
1270
+ let run = 0;
1271
+ for (let i = 1; i < src.length; i++) {
1272
+ const L = Math.hypot(src[i][0] - src[i - 1][0], src[i][1] - src[i - 1][1]);
1273
+ acc += L; run += L;
1274
+ while (acc >= step) {
1275
+ acc -= step; k++;
1276
+ if (run - acc > tot * grow) break;
1277
+ const f2 = 1 - acc / L, x = lerp(src[i - 1][0], src[i][0], f2) + (hs(k, D.id, 5) - 0.5) * 1.5, y = lerp(src[i - 1][1], src[i][1], f2) + (hs(k, D.id, 6) - 0.5) * 1.5;
1278
+ c.save(); c.globalAlpha = 0.8;
1279
+ fillShape(c, circlePts(x, y, (1.9 + 0.5 * hs(k, 7)) * 1.6 / SHOT.s * 1.1, 8), PAL.pencil, { seed: 8400 + k * 3 + bv(D), amp: 0.35 });
1280
+ c.restore();
1281
+ }
1282
+ }
1283
+ }
1284
+ for (const h of HOPS) {
1285
+ if (tq <= (h.t0 + h.t1) / 2 || tq > h.t1 + 0.05) continue;
1286
+ const b = Math.min(tq, h.t1) - 0.03, a = b - 0.16;
1287
+ const pts = [];
1288
+ for (let i = 0; i <= 6; i++) {
1289
+ const tt = lerp(a, b, i / 6);
1290
+ const st = spiritState(tt, D, null);
1291
+ pts.push([st.x, st.y - st.H * 0.4 * st.sy]);
1292
+ }
1293
+ const cur = spiritState(tq, D, null);
1294
+ const back = pts.filter(([x, y]) => Math.hypot(x - cur.x, y - (cur.y - cur.H * 0.4 * cur.sy)) > cur.H * 0.42);
1295
+ if (back.length < 3) continue;
1296
+ [-0.22, 0.05, 0.3].forEach((off, j) => {
1297
+ const P = back.map(([x, y], i, arr) => {
1298
+ const q = arr[Math.min(arr.length - 1, i + 1)], r = arr[Math.max(0, i - 1)];
1299
+ let tx = q[0] - r[0], ty = q[1] - r[1]; const tl = Math.hypot(tx, ty) || 1;
1300
+ return [x - ty / tl * off * cur.H, y + tx / tl * off * cur.H];
1301
+ });
1302
+ inkStroke(c, P, { seed: 8300 + D.id * 3 + j, w: 4.2, t0: 50, t1: 12, min: 0.05, amp: 1.2, heavy: 0 });
1303
+ });
1304
+ }
1305
+ }
1306
+
1307
+ // ------------------------------------------------------------------ FRAME
1308
+ let CAM = { x: 0, y: 0 };
1309
+ function camera(f) {
1310
+ const t = f / FPS;
1311
+ let x = 0, y = 0;
1312
+ if (t >= 6.583 && t < 7.2) { const a = 18 * Math.pow(1 - (t - 6.583) / 0.62, 2); x = (hs(f, 1) - 0.5) * 2 * a; y = (hs(f, 2) - 0.5) * 2 * a; }
1313
+ if (t >= 0.5 && t < 0.62) { const a = 5; x = (hs(f, 3) - 0.5) * 2 * a; y = (hs(f, 4) - 0.5) * 2 * a; }
1314
+ if (t >= 2.5 && t < 2.6) { y = 5 * (1 - (t - 2.5) / 0.1); }
1315
+ return { x, y };
1316
+ }
1317
+
1318
+ export async function init(canvas, dpr) {
1319
+ DPR = dpr;
1320
+ canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
1321
+ ctx = MAIN = canvas.getContext('2d');
1322
+ const off = () => { const cv = document.createElement('canvas'); cv.width = canvas.width; cv.height = canvas.height; return cv.getContext('2d'); };
1323
+ SHEETC = off(); NIGHTC = off();
1324
+ TOTAL = drawing(239).id + 1 - RIFFLE.length; // unique drawings in the film (the riffle re-shows old ones)
1325
+ LY = new Layers(W, H, dpr);
1326
+ const load = (src) => new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = src; });
1327
+ IMG.paper = await load('/demos/05-cel-boil/assets/paper_base.jpg');
1328
+ IMG.tooth = await load('/demos/05-cel-boil/assets/paper_tooth.png');
1329
+ IMG.dust = [];
1330
+ for (let k = 1; k <= 4; k++) { const im = await load(`/demos/05-cel-boil/assets/dust_ink_0${k}.png`); await im.decode(); IMG.dust.push(im); }
1331
+ await IMG.paper.decode(); await IMG.tooth.decode();
1332
+ CUES = await (await fetch('/demos/05-cel-boil/cues.json')).json();
1333
+ const opentype = (await import('opentype')).default || (await import('opentype'));
1334
+ const buf = await (await fetch('/assets/fonts/cjk/lxgwmarkergothic/LXGWMarkerGothic-Regular.ttf')).arrayBuffer();
1335
+ const font = opentype.parse(buf);
1336
+ TAG = { a: glyphPolys(font, '一帧一帧', 60, 13), b: glyphPolys(font, '亲手点亮', 60, 13), gap: 92 };
1337
+ if (V5) STROKES = await loadV5Strokes();
1338
+ }
1339
+
1340
+ // ------------------------------------------------------------------ END: pull back to the sheet on the peg bar; thumb riffles the stack
1341
+ const RIFFLE = [150, 121, 108, 61, 30, 13]; // earlier drawings flashed by the riffle (backwards through the stack)
1342
+ const PIV = [960, 540];
1343
+ function pullScale(fq) { return fq < 218 ? 1 : fq < 220 ? 0.9 : fq < 222 ? 0.86 : 0.84; } // R2: starts f218 (full lockup held from f212) // 3 drawings on 2s, ease-out
1344
+ export function render(t) {
1345
+ const f = Math.min(239, Math.max(0, Math.round(t * FPS)));
1346
+ const D = drawing(f);
1347
+ const sc = pullScale(D.fq);
1348
+ if (sc >= 1) { ctx = MAIN; drawFrame(f); return; }
1349
+ const rif = f >= 224 && f <= 229 ? f - 224 : -1;
1350
+ const part = V5 ? rif >= 0 : rif === 0 || rif === 5; // R2: on these the top sheet stays; only the lifted corner shows the drawing below (V5: on all six, so the title never leaves)
1351
+ ctx = SHEETC; drawFrame(rif >= 0 && !part ? RIFFLE[rif] : f); ctx = MAIN;
1352
+ drawDesk(MAIN, D, f, sc, rif, part);
1353
+ }
1354
+ const rectPts = (x, y, w, h) => [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
1355
+ let ONION = null;
1356
+ function onionGhost() { // R2: two earlier poses of the tiny spirit, ink only, as blue col-erase pencil (cached: the sheets below do not boil)
1357
+ if (ONION) return ONION;
1358
+ const RX = 1250, RY = 0, RW = 420, RH = 420, cw = Math.round(RW * DPR), ch = Math.round(RH * DPR);
1359
+ ONION = [9.135, 9.25].map((tq) => {
1360
+ const sc = document.createElement('canvas'); sc.width = Math.round(W * DPR); sc.height = Math.round(H * DPR);
1361
+ const g = sc.getContext('2d'); g.setTransform(DPR, 0, 0, DPR, 0, 0);
1362
+ const D = drawing(Math.round(tq * FPS));
1363
+ drawSpirit(g, tinySpirit(tq, D), D, tq);
1364
+ const im = g.getImageData(Math.round(RX * DPR), Math.round(RY * DPR), cw, ch), d = im.data;
1365
+ for (let i = 0; i < d.length; i += 4) {
1366
+ const lum = (0.3 * d[i] + 0.59 * d[i + 1] + 0.11 * d[i + 2]) / 255, a = d[i + 3] / 255;
1367
+ const k = a * clamp((0.42 - lum) / 0.25);
1368
+ d[i] = 0x5a; d[i + 1] = 0x7b; d[i + 2] = 0xb5; d[i + 3] = Math.round(255 * k);
1369
+ }
1370
+ const oc = document.createElement('canvas'); oc.width = cw; oc.height = ch; oc.getContext('2d').putImageData(im, 0, 0);
1371
+ return { cv: oc, RX, RY, RW, RH };
1372
+ });
1373
+ return ONION;
1374
+ }
1375
+ function drawDesk(c, D, f, s, rif, part) {
1376
+ const v = bv(D);
1377
+ c.setTransform(DPR, 0, 0, DPR, 0, 0); c.globalAlpha = 1; c.globalCompositeOperation = 'source-over';
1378
+ drawPaper(c, { x: 0, y: 0 });
1379
+ c.fillStyle = 'rgba(28,22,19,0.86)'; c.fillRect(0, 0, W, H); // the desk
1380
+ const x0 = PIV[0] - PIV[0] * s, y0 = PIV[1] - PIV[1] * s, w = W * s, h = H * s;
1381
+ // R2 (REVIEW_2 P3): the desk is a LIGHT TABLE - warm underlight glowing round the stack in hard cel steps
1382
+ [[124, 0.14], [80, 0.24], [44, 0.38]].forEach(([m, a], k) => {
1383
+ fillShape(c, dens(rectPts(x0 - m, y0 - m * 0.7, w + 2 * m + 18, h + 1.5 * m + 13), 24), `rgba(255,240,180,${a})`, { seed: 9170 + k * 3 + v, amp: 3 });
1384
+ });
1385
+ // peg bar taped to the desk; its pegs come up through the sheet's holes
1386
+ const by = y0 - 30, bar = dens(rectPts(PIV[0] - 700, by, 1400, 70), 6);
1387
+ fillShape(c, bar, '#4a3f38', { seed: 9100 + v, amp: 1 });
1388
+ inkLoop(c, bar, { seed: 9110 + v, w: 3.5, amp: 1 });
1389
+ inkStroke(c, [[PIV[0] - 680, by + 10], [PIV[0] + 680, by + 10]], { seed: 9120 + v, w: 3, t0: 40, t1: 40, min: 0.1, amp: 1, heavy: 0, color: PAL.hi });
1390
+ for (const sx of [-1, 1]) { // masking-tape tabs holding the bar
1391
+ const T = dens(xf(rectPts(-46, -24, 92, 48), { x: PIV[0] + sx * 700, y: by + 12, r: sx * 0.12 }), 6);
1392
+ fillShape(c, T, '#e9dcc0', { seed: 9125 + v + sx, amp: 1.2 }); inkLoop(c, T, { seed: 9127 + v + sx, w: 2.5, amp: 1 });
1393
+ }
1394
+ // hard (cel) shadow of the stack, then 3 sheets beneath, offset and each 3 % darker
1395
+ fillShape(c, dens(rectPts(x0 + 30, y0 + 24, w, h), 20), 'rgba(0,0,0,0.42)', { seed: 9130 + v, amp: 1.5 });
1396
+ [[18, 13, 0.09], [12, 9, 0.06], [6, 4, 0.03]].forEach(([dx, dy, dk], k) => {
1397
+ c.save(); c.beginPath(); c.rect(x0 + dx, y0 + dy, w, h); c.clip();
1398
+ c.drawImage(IMG.paper, x0 + dx, y0 + dy, w, h);
1399
+ c.fillStyle = `rgba(28,22,19,${0.02 + dk})`; c.fillRect(x0 + dx, y0 + dy, w, h);
1400
+ c.restore();
1401
+ c.globalAlpha = 0.55; inkLoop(c, dens(rectPts(x0 + dx, y0 + dy, w, h), 10), { seed: 9140 + k * 11 + v, w: 2, amp: 0.8 }); c.globalAlpha = 1;
1402
+ });
1403
+ // the top sheet = the film (on a riffle drawing it is lifted and slightly skewed)
1404
+ const ox = rif >= 0 ? (hs(f, 1) - 0.5) * 12 : 0, oy = rif >= 0 ? -6 - 8 * hs(f, 2) : 0, rot = rif >= 0 ? (hs(f, 3) - 0.5) * 0.014 : 0;
1405
+ c.save();
1406
+ c.translate(PIV[0] + ox, PIV[1] + oy); c.rotate(rot); c.translate(-PIV[0], -PIV[1]);
1407
+ c.drawImage(SHEETC.canvas, 0, 0, SHEETC.canvas.width, SHEETC.canvas.height, x0, y0, w, h);
1408
+ // R2: underlight through the sheet (+ tooth lifted 6 %) and the two drawings beneath as blue onion skin
1409
+ c.save(); c.beginPath(); c.rect(x0, y0, w, h); c.clip();
1410
+ c.globalCompositeOperation = 'screen'; c.fillStyle = 'rgba(255,240,180,0.16)'; c.fillRect(x0, y0, w, h);
1411
+ c.globalCompositeOperation = 'multiply'; c.globalAlpha = 0.06;
1412
+ c.drawImage(IMG.tooth, Math.floor(hs(v, 53) * 470), Math.floor(hs(v, 54) * 310), W, H, x0, y0, w, h);
1413
+ if (!(rif >= 0 && !part)) {
1414
+ onionGhost().forEach((O, k) => {
1415
+ const [dx, dy] = k ? [-5, 7] : [8, -4];
1416
+ c.globalAlpha = k ? 0.16 : 0.12;
1417
+ c.drawImage(O.cv, x0 + (O.RX + dx) * s, y0 + (O.RY + dy) * s, O.RW * s, O.RH * s);
1418
+ });
1419
+ }
1420
+ c.restore();
1421
+ c.globalAlpha = 0.6; inkLoop(c, dens(rectPts(x0, y0, w, h), 10), { seed: 9150 + v, w: 2.2, amp: 0.8 }); c.globalAlpha = 1;
1422
+ // Acme peg holes (round centre + two slots) with the pegs showing through
1423
+ const hy = y0 + 40 * s;
1424
+ const holes = [circlePts(PIV[0], hy, 15 * s, 24), ...[-1, 1].map((sx) => {
1425
+ const cx = PIV[0] + sx * 600 * s, hw = 62 * s, hr = 14 * s, P = [];
1426
+ for (let i = 0; i <= 12; i++) { const a = -Math.PI / 2 + (i / 12) * Math.PI; P.push([cx + hw + Math.cos(a) * hr, hy + Math.sin(a) * hr]); }
1427
+ for (let i = 0; i <= 12; i++) { const a = Math.PI / 2 + (i / 12) * Math.PI; P.push([cx - hw + Math.cos(a) * hr, hy + Math.sin(a) * hr]); }
1428
+ return P;
1429
+ })];
1430
+ holes.forEach((P, k) => {
1431
+ const Q = dens(P, 3);
1432
+ fillShape(c, Q, '#4a3f38', { seed: 9160 + k + v, amp: 0.6 });
1433
+ inkLoop(c, Q, { seed: 9165 + k + v, w: 2.6, amp: 0.6, t0: 3, t1: 6 });
1434
+ });
1435
+ // R2 (REVIEW_2 P3): riffle = the lower-right corner lifts and curls as a flap (on 1s)
1436
+ let tip = [x0 + w, y0 + h];
1437
+ if (rif >= 0) {
1438
+ const cx = x0 + w, cy = y0 + h;
1439
+ const a = [350, 530, 475, 560, 495, 300][rif] * s, b = [250, 380, 340, 405, 358, 214][rif] * s;
1440
+ const P1 = [cx - a, cy], P2 = [cx, cy - b];
1441
+ const dx = P2[0] - P1[0], dy = P2[1] - P1[1], L2 = dx * dx + dy * dy;
1442
+ const tt = ((cx - P1[0]) * dx + (cy - P1[1]) * dy) / L2, fx = P1[0] + dx * tt, fy = P1[1] + dy * tt;
1443
+ const Cf = [lerp(fx, 2 * fx - cx, 0.8), lerp(fy, 2 * fy - cy, 0.8)]; // corner reflected over the fold, curled back 20 %
1444
+ const Q1 = [lerp(P1[0], Cf[0], 0.74) - 10, lerp(P1[1], Cf[1], 0.74) + 6], Q2 = [lerp(P2[0], Cf[0], 0.74) + 6, lerp(P2[1], Cf[1], 0.74) - 10]; // R2s2: wider free edge -> trapezoid flap, not a narrow curl
1445
+ tip = [Q2[0] + 8, Q2[1] + 4]; // the thumb pinches the free edge from the right
1446
+ const bow = (A, B, k) => { const mx = (A[0] + B[0]) / 2, my = (A[1] + B[1]) / 2, nx = A[1] - B[1], ny = B[0] - A[0], l = Math.hypot(nx, ny) || 1; return [mx + nx / l * k, my + ny / l * k]; };
1447
+ // (1) what the lifted corner uncovers
1448
+ c.save(); c.beginPath(); c.moveTo(P1[0], P1[1]); c.lineTo(cx, cy); c.lineTo(P2[0], P2[1]); c.closePath(); c.clip();
1449
+ if (part) { ctx = SHEETC; drawFrame(RIFFLE[rif]); ctx = MAIN; c.drawImage(SHEETC.canvas, 0, 0, SHEETC.canvas.width, SHEETC.canvas.height, x0, y0, w, h); }
1450
+ else { c.drawImage(IMG.paper, x0, y0, w, h); c.fillStyle = 'rgba(28,22,19,0.06)'; c.fillRect(x0, y0, w, h); }
1451
+ c.restore();
1452
+ const F = dens([P1, bow(P1, Q1, 16), Q1, bow(Q1, Q2, 12), Q2, bow(Q2, P2, 16), P2], 4);
1453
+ // (2) hard cast shadow of the flap (#1c1613 @ 18 %), kept on the sheet
1454
+ c.save(); c.beginPath(); c.rect(x0, y0, w, h); c.clip();
1455
+ fillShape(c, xf(F, { x: 22 * s, y: 18 * s }), 'rgba(28,22,19,0.18)', { seed: 9180 + f, amp: 1.2 });
1456
+ c.restore();
1457
+ // (3) the flap: back of the page, a shade band along the fold, a heavy 12 px ink free edge
1458
+ fillShape(c, F, '#eadfc6', { seed: 9182 + f, amp: 1.2 });
1459
+ fillShape(c, dens([P1, P2, [lerp(P2[0], Cf[0], 0.18), lerp(P2[1], Cf[1], 0.18)], [lerp(P1[0], Cf[0], 0.18), lerp(P1[1], Cf[1], 0.18)]], 4), 'rgba(28,22,19,0.1)', { seed: 9184 + f, amp: 1 });
1460
+ inkLoop(c, F, { seed: 9186 + f, w: 4, amp: 1.2 });
1461
+ inkStroke(c, [Q1, bow(Q1, Q2, 12), Q2], { seed: 9188 + f, w: 12, t0: 16, t1: 16, min: 0.3, amp: 1, heavy: 0 });
1462
+ // (4) edge smear: dry streaks the free edge leaves behind as it sweeps up
1463
+ c.globalAlpha = 0.45;
1464
+ for (let k = 0; k < 3; k++) {
1465
+ const u = 0.22 + 0.28 * k, E0 = [lerp(Q1[0], Q2[0], u), lerp(Q1[1], Q2[1], u)], l = (60 + 50 * hs(f, k, 7)) * s;
1466
+ inkStroke(c, [[E0[0] + 14, E0[1] + 12], [E0[0] + 14 + l * 0.78, E0[1] + 12 + l * 0.62]], { seed: 9190 + f * 5 + k, w: 5, t0: 3, t1: 24, min: 0.1, amp: 1, heavy: 0 });
1467
+ }
1468
+ c.globalAlpha = 1;
1469
+ }
1470
+ c.restore();
1471
+ if (f >= 222 && f <= 232) drawThumb(c, D, f, tip[0], tip[1], rif);
1472
+ }
1473
+ function drawThumb(c, D, f, cx, cy, rif) {
1474
+ // white cartoon glove thumb at the stack's corner; lifted page corners fan up while it riffles
1475
+ const v = bv(D);
1476
+ const enter = f === 222 ? 1 : f === 223 ? 0.25 : f >= 230 ? [0.35, 0.8, 1.4][f - 230] : 0;
1477
+ const bob = rif >= 0 ? (rif % 2 ? -10 : 4) : 0;
1478
+ const tx = cx - 34 + enter * 190, ty = cy - 16 + bob + enter * 140;
1479
+ const ang = rif >= 0 ? -2.95 : -2.45; // R2: on the riffle the thumb comes in from the right so the flap stays readable
1480
+ c.save(); c.translate(tx, ty); c.scale(1.4, 1.4); c.translate(-tx, -ty); // R2: thumb 1.4x (reads at phone size)
1481
+ const thumb = dens(xf(circlePts(0, 0, 44, 60, 118), { x: tx - Math.cos(ang) * 96, y: ty - Math.sin(ang) * 96, r: ang }), 3);
1482
+ fillShape(c, thumb, PAL.hi, { seed: 9250 + v, amp: 1.2, dx: REG.dx, dy: REG.dy });
1483
+ inkLoop(c, thumb, { seed: 9255 + v, w: 6, amp: 1.2, heavy: 0.4 });
1484
+ // nail + knuckle crease
1485
+ const nx = tx - Math.cos(ang) * 38, ny = ty - Math.sin(ang) * 38;
1486
+ const nail = dens(xf(circlePts(0, 0, 20, 24, 30), { x: nx, y: ny, r: ang }), 3);
1487
+ inkLoop(c, nail, { seed: 9260 + v, w: 3, amp: 1, t0: 3, t1: 6 });
1488
+ const kx = tx - Math.cos(ang) * 120, ky = ty - Math.sin(ang) * 120;
1489
+ inkStroke(c, [[kx - Math.sin(ang) * 26, ky + Math.cos(ang) * 26], [kx + Math.cos(ang) * 8, ky + Math.sin(ang) * 8], [kx + Math.sin(ang) * 26, ky - Math.cos(ang) * 26]], { seed: 9265 + v, w: 3.5, t0: 8, t1: 8, amp: 1, heavy: 0 });
1490
+ c.restore();
1491
+ }
1492
+ function shakeMarks(c, ms, D, color, w) {
1493
+ for (const side of [-1, 1]) {
1494
+ for (let k = 0; k < 3; k++) {
1495
+ const a = (side < 0 ? Math.PI : 0) + (k - 1) * 0.5 + (hs(D.id, k, side) - 0.5) * 0.2;
1496
+ const r0 = 48 + (D.id % 2) * 5, r1 = r0 + 24;
1497
+ inkStroke(c, [[ms.hx + Math.cos(a) * r0, ms.hy + Math.sin(a) * r0], [ms.hx + Math.cos(a) * r1, ms.hy + Math.sin(a) * r1]], { seed: 60 + D.id * 7 + k + side, w, t0: 6, t1: 6, amp: 0.8, heavy: 0, color });
1498
+ }
1499
+ }
1500
+ }
1501
+ // true night before the strike: ink wash + hand-cut vignette; only the line work catches a red rim light; chalk shake marks
1502
+ function nightPass(c, D, f, ms) {
1503
+ const N = NIGHTC, cw = N.canvas.width, ch = N.canvas.height;
1504
+ N.setTransform(1, 0, 0, 1, 0, 0);
1505
+ N.globalCompositeOperation = 'copy'; N.drawImage(c.canvas, 0, 0);
1506
+ // R1s2: rim-light mask from luminance only -> just the ink line work catches the red (no green/blue tints from fills)
1507
+ N.globalCompositeOperation = 'saturation'; N.fillStyle = '#808080'; N.fillRect(0, 0, cw, ch);
1508
+ N.globalCompositeOperation = 'difference'; N.fillStyle = '#ffffff'; N.fillRect(0, 0, cw, ch);
1509
+ N.globalCompositeOperation = 'multiply'; N.drawImage(N.canvas, 0, 0); N.drawImage(N.canvas, 0, 0); // lum^4
1510
+ N.fillStyle = PAL.red; N.fillRect(0, 0, cw, ch);
1511
+ N.globalCompositeOperation = 'source-over';
1512
+ const dark = f <= 8 ? 0.62 : [0.56, 0.5, 0.42][f - 9];
1513
+ c.save();
1514
+ c.setTransform(DPR, 0, 0, DPR, 0, 0);
1515
+ // R1s2: kill the plate's colour first (the yellow strip went olive, the paper went grey)
1516
+ c.globalCompositeOperation = 'saturation'; c.globalAlpha = 0.85; c.fillStyle = '#808080'; c.fillRect(0, 0, W, H);
1517
+ c.globalCompositeOperation = 'multiply'; c.globalAlpha = 1; c.fillStyle = '#6b5446'; c.fillRect(0, 0, W, H);
1518
+ c.globalCompositeOperation = 'source-over';
1519
+ c.globalAlpha = dark; c.fillStyle = PAL.ink; c.fillRect(0, 0, W, H);
1520
+ c.globalAlpha = 0.6; c.beginPath(); c.rect(0, 0, W, H);
1521
+ polyPath(c, boil(circlePts(1010, 610, 470, 90, 860), 9400 + bv(D), 7, 140)); c.fill('evenodd');
1522
+ c.setTransform(1, 0, 0, 1, 0, 0);
1523
+ c.globalCompositeOperation = 'screen'; c.globalAlpha = 1; c.drawImage(N.canvas, 0, 0); c.globalAlpha = f <= 8 ? 0.6 : 0.8; c.drawImage(N.canvas, 0, 0);
1524
+ c.globalCompositeOperation = 'source-over'; c.globalAlpha = 1;
1525
+ c.setTransform(DPR, 0, 0, DPR, 0, 0);
1526
+ if (f < 8) { camApply(c, CAM); SHOT = CLOSE; stageApply(c); shakeMarks(c, ms, D, PAL.hi, 5.5); }
1527
+ c.restore();
1528
+ }
1529
+ function flashFrame(c, ms) { // f12: a full-frame paper-white flash, star centred where the spark sits in the wide
1530
+ const hx = lerp(1296, ms.hx, 0.6), hy = lerp(828, ms.hy, 0.6);
1531
+ const X = lerp(STAGE.X + (hx - STAGE.px) * STAGE.s, 960, 0.35), Y = lerp(STAGE.Y + (hy - STAGE.py) * STAGE.s, 540, 0.35);
1532
+ c.save(); c.setTransform(DPR, 0, 0, DPR, 0, 0);
1533
+ c.fillStyle = PAL.hi; c.fillRect(0, 0, W, H);
1534
+ fillShape(c, spline(xf(starPts(12, 760, 330, 4401, 0.2, 0.3), { x: X, y: Y }), true, 6), PAL.yel, { seed: 4402, amp: 2 });
1535
+ fillShape(c, spline(xf(starPts(9, 330, 170, 4403, 0.5, 0.3), { x: X, y: Y }), true, 5), PAL.hi, { seed: 4404, amp: 2 });
1536
+ speedLinesRadial(c, X, Y, 800, 1500, 16, 4405, 8, 1);
1537
+ c.restore();
1538
+ }
1539
+ function drawFrame(f) {
1540
+ const D = drawing(f);
1541
+ const tq = D.fq / FPS; // main drawing time (on 2s / 1s / 3s)
1542
+ const t1 = f / FPS; // on-1s time for fast FX
1543
+ const c = ctx;
1544
+ CAM = camera(f);
1545
+ c.setTransform(DPR, 0, 0, DPR, 0, 0);
1546
+ c.globalCompositeOperation = 'source-over'; c.globalAlpha = 1;
1547
+ drawPaper(c, CAM);
1548
+ c.save(); camApply(c, CAM);
1549
+
1550
+ const bs = boxState(tq);
1551
+ const ms = matchState(tq);
1552
+ const ss = spiritState(tq, D, ms);
1553
+
1554
+ // ---------------- stage (before the burst)
1555
+ SHOT = shotFor(f);
1556
+ const zr = Math.min(1, STAGE.s / SHOT.s);
1557
+ G.GLOBAL.ink = SHOT === CLOSE ? 0.58 : SHOT === MED ? 0.7 : 0.2 + 0.8 * zr;
1558
+ G.GLOBAL.amp = SHOT === CLOSE ? 0.55 : SHOT === MED ? 0.66 : 0.2 + 0.8 * zr;
1559
+ if (f >= 132) { // camera push on the spin (on 1s, accelerating), zooming about the spirit -> burst centre
1560
+ const u = E.in2(inv(5.5, 6.5, t1));
1561
+ const sc = lerp(STAGE.s, 2.05, u);
1562
+ SHOT = { s: sc, px: 860, py: 606, X: 862, Y: 618 };
1563
+ G.GLOBAL.ink = lerp(1, 0.8, u); G.GLOBAL.amp = lerp(1, 0.75, u);
1564
+ }
1565
+ c.save(); stageApply(c);
1566
+ drawLightPool(c, tq, D, ss);
1567
+ drawBox(c, tq, D, bs);
1568
+ if (ms.vis) {
1569
+ if (!(ms.smear && tq >= 0.36)) drawMatchStick(c, ms, D, 200);
1570
+ drawMatchHead(c, ms, D, 300, tq);
1571
+ if (f >= 24 && f < 28) { // R2: landing dust tick at the butt (woodblock at 1.0)
1572
+ const k = f < 26 ? 0 : 1;
1573
+ for (const sg of [-1, 1]) {
1574
+ drawPuff(c, BUTT[0] + sg * (30 + 18 * k), BUTT[1] - 8 - 5 * k, (15 + 4 * k) * (k ? 0.75 : 1), 7700 + (sg + 1) * 3 + k, { w: 3.5 });
1575
+ inkStroke(c, [[BUTT[0] + sg * 20, BUTT[1] - 20], [BUTT[0] + sg * (40 + 12 * k), BUTT[1] - 40 - 10 * k]], { seed: 7710 + sg + k * 5, w: 4, t0: 4, t1: 8, min: 0.3, amp: 0.8, heavy: 0 });
1576
+ }
1577
+ }
1578
+ if (ms.toss && f > 12 && f < 24) {
1579
+ for (let k = 1; k <= 4; k++) {
1580
+ const pm = matchState((f - k * 0.7) / FPS), ends = stickEnds(pm);
1581
+ ends.forEach(([x, y], e) => fillShape(c, circlePts(x, y, (6.5 - k * 1.1) * (e ? 0.8 : 1), 10), PAL.ink, { seed: 4500 + f * 9 + k * 2 + e, amp: 0.6 }));
1582
+ }
1583
+ }
1584
+ if (ss && tq < 2.0) drawSpirit(c, ss, D, tq);
1585
+ // (nervous shake marks are drawn in chalk after the night pass)
1586
+ if (ms.burnt && tq < 6.5) drawWisp(c, ms.hx + 4, ms.hy - 30, tq, D, sstep(2.0, 2.4, tq), SHOT === LOW ? 1 : 0);
1587
+ }
1588
+ // ignition burst on the head (on 1s)
1589
+ if (t1 >= 0.5 && t1 < 0.625) {
1590
+ const u = inv(0.5, 0.625, t1);
1591
+ const R = lerp(150, 215, E.out2(u)) * (u > 0.7 ? 0.75 : 1); // pops in big on the flash frame
1592
+ const hx = lerp(1296, ms.hx, 0.6), hy = lerp(828, ms.hy, 0.6);
1593
+ const P = spline(xf(starPts(10, R, R * 0.45, 40 + f, f * 0.4, 0.35), { x: hx, y: hy }), true, 4);
1594
+ if (f <= 13) speedLinesRadial(c, hx, hy, R * 1.12, R * (f === 12 ? 2.1 : 2.5), 13, 300 + f, 5);
1595
+ if (u < 0.75) {
1596
+ fillShape(c, P, PAL.yel, { seed: 41 + f, amp: 1.5, dx: REG.dx, dy: REG.dy });
1597
+ if (f <= 13) { // two-tone core on the first two drawings
1598
+ const Q = spline(xf(starPts(8, R * 0.5, R * 0.26, 45 + f, f * 0.4 + 0.3, 0.35), { x: hx, y: hy }), true, 4);
1599
+ fillShape(c, Q, f === 12 ? PAL.hi : PAL.red, { seed: 46 + f, amp: 1.5, dx: REG.dx, dy: REG.dy });
1600
+ }
1601
+ inkLoop(c, P, { seed: 42 + f, w: 6, amp: 1.5 });
1602
+ }
1603
+ for (let k = 0; k < 9; k++) {
1604
+ const a = -Math.PI * 0.95 + k * 0.33 + hs(f, k) * 0.2, d = R * (0.9 + u * 1.6) + hs(k, 3) * 60;
1605
+ drawSpark(c, hx + Math.cos(a) * d, hy + Math.sin(a) * d, Math.cos(a) * 900, Math.sin(a) * 900, 7 + 5 * hs(k, 4), 60 + f * 13 + k);
1606
+ }
1607
+ }
1608
+ // spirit on stage
1609
+ if (ss && tq >= 2.0) {
1610
+ // spin: whirlwind wrap lines, back halves behind the body, front halves over it (on 1s)
1611
+ const spinRings = [];
1612
+ if (tq >= 5.5 && tq < 6.25) {
1613
+ const u = inv(5.5, 6.25, t1);
1614
+ const n = 2 + Math.floor(u * 5);
1615
+ for (let k = 0; k < n; k++) {
1616
+ const hk = 0.1 + 0.8 * ((k + 0.5) / n) + (hs(f, k) - 0.5) * 0.08;
1617
+ const y = ss.y - ss.H * hk * ss.sy;
1618
+ const rx = ss.H * (0.5 + 0.12 * Math.sin(hk * 3)) * (1 + 0.25 * u), ry = rx * 0.16;
1619
+ const a0 = hs(f, k, 2) * 6.28, len = 2.2 + 2.5 * hs(f, k, 3);
1620
+ spinRings.push({ y, rx, ry, a0, len, k });
1621
+ }
1622
+ const arc = (R, front) => {
1623
+ const P = [];
1624
+ for (let i = 0; i <= 24; i++) { const a = R.a0 + (i / 24) * R.len; const sn = Math.sin(a); if ((sn > 0) === front) P.push([ss.x + Math.cos(a) * R.rx, R.y + sn * R.ry]); else if (P.length) break; }
1625
+ return P;
1626
+ };
1627
+ spinRings.forEach((R) => { const P = arc(R, false); if (P.length > 3) inkStroke(c, P, { seed: 900 + f * 5 + R.k, w: 5, t0: 10, t1: 40, min: 0.1, amp: 1.0, heavy: 0, dense: false }); });
1628
+ spinRings.front = (cc) => spinRings.forEach((R) => { const P = arc(R, true); if (P.length > 3) inkStroke(cc, P, { seed: 950 + f * 5 + R.k, w: 5.5, t0: 10, t1: 40, min: 0.1, amp: 1.0, heavy: 0 }); });
1629
+ }
1630
+ hopTrails(c, tq, D);
1631
+ // hatched contact shadow on the box top (shrinks as the spirit leaves the ground)
1632
+ if (tq >= 2.5 && bs.vis) {
1633
+ const lift = clamp((GROUND - ss.y) / 220);
1634
+ const rx = ss.H * 0.46 * (1 - 0.55 * lift) * Math.sqrt(ss.sx), ry = rx * 0.2;
1635
+ if (rx > 8) hatch(c, circlePts(ss.x + 14, GROUND + 3, ry, 40, rx), { seed: 7700 + bv(D), gap: 9, ang: -0.95, w: 2.4, amp: 0.9 });
1636
+ }
1637
+ drawSpirit(c, ss, D, tq);
1638
+ if (spinRings.front) spinRings.front(c);
1639
+ // dust puffs on landings
1640
+ for (const tl of [2.5, 3.0, 3.5, 4.0]) {
1641
+ const u = inv(tl, tl + 0.33, tq);
1642
+ if (tq > tl + 0.01 && u < 1) {
1643
+ const big = tl === 2.5 ? 1 : 0.6;
1644
+ if (tl === 2.5 && u >= 0.75) continue;
1645
+ for (const side of [-1, 1]) {
1646
+ for (let j = 0; j < 3; j++) {
1647
+ const uu = tl === 2.5 ? inv(2.5, 2.75, tq) : u;
1648
+ const d = ss.H * 0.36 + [40, 105, 190][j] * big * E.out2(Math.min(1, uu + 0.25));
1649
+ const x = ss.x + side * d, y = GROUND - 8 - [8, 24, 44][j] * big * (0.4 + uu);
1650
+ const r = [22, 36, 54][j] * big * (1 - uu * 0.55);
1651
+ const sd = 1000 + Math.round(tl * 10) * 7 + side * 3 + j * 11 + D.id;
1652
+ drawPuff(c, x, y, r, sd, { n: 4, w: 3.8, fill: PAL.cream, flat: 0.7, wide: 1.2 });
1653
+ const P = [], c0 = side > 0 ? 3.6 : -0.5;
1654
+ for (let i = 0; i <= 10; i++) { const a = c0 + side * (i / 10) * 3.4, rr = r * 0.5 * (1 - 0.5 * i / 10); P.push([x + Math.cos(a) * rr, y + Math.sin(a) * rr * 0.8]); }
1655
+ inkStroke(c, P, { seed: sd + 5, w: 3.2, t0: 4, t1: 10, min: 0.2, amp: 0.8, heavy: 0 });
1656
+ }
1657
+ }
1658
+ }
1659
+ }
1660
+ // ta-da sparkles (on 3s hold, popping)
1661
+ if (tq >= 4.5 && tq < 5.1) {
1662
+ const sp = [[-0.95, -1.1, 0.0], [0.98, -1.18, 0.083], [-1.2, -0.55, 0.208], [1.18, -0.62, 0.333], [0.1, -1.45, 0.167]];
1663
+ sp.forEach(([ux, uy, dt], k) => {
1664
+ const u = inv(4.5 + dt, 4.5 + dt + 0.4, tq);
1665
+ if (tq < 4.5 + dt || u >= 1) return;
1666
+ const R = 34 * Math.sin(Math.PI * Math.min(1, u * 1.15)) * (k === 4 ? 1.3 : 1);
1667
+ drawTwinkle(c, ss.x + ux * ss.H * 0.55, ss.y + uy * ss.H * 0.55, R, 1100 + k * 3 + D.id);
1668
+ });
1669
+ }
1670
+ // anticipation lines converging before the burst (on 1s)
1671
+ if (t1 >= 6.25 && t1 < 6.5) {
1672
+ const u = inv(6.25, 6.5, t1);
1673
+ speedLinesRadial(c, ss.x, ss.y - ss.H * 0.3, lerp(560, 330, u), lerp(900, 620, u), 18, 1500 + f, 6, -1);
1674
+ }
1675
+ }
1676
+ c.restore(); // stage
1677
+ G.GLOBAL.ink = 1; G.GLOBAL.amp = 1;
1678
+
1679
+ // ---------------- BURST (hero)
1680
+ if (t1 >= 6.583) {
1681
+ // cream smoke: clusters of 2-3 puffs in 3 size classes; each dissipates over 4 drawings on 2s
1682
+ // (solid -> hole -> bigger hole -> broken ring -> gone)
1683
+ const puffs = [], tails = [];
1684
+ const u = tq - 6.583;
1685
+ for (const P of SMOKE) {
1686
+ const st = tq < P.td ? 0 : Math.floor((tq - P.td) / (2 / FPS)) + 1;
1687
+ if (st >= 4) continue;
1688
+ const dist = (330 + 600 * (1 - Math.exp(-3.2 * u)) * (0.8 + 0.4 * hs(P.k, 22))) * P.dist;
1689
+ const px = C0[0] + Math.cos(P.a) * dist * 1.25, py = C0[1] + Math.sin(P.a) * dist * 0.8 - 60 * u;
1690
+ const r = P.R * E.outBack(clamp(u / 0.3), 1.5) * (1 + 0.3 * u) * (1 - 0.08 * st);
1691
+ const cs = [0, 1, 2, 3, 4].map((j) => {
1692
+ const aa = j * 1.35 + P.k * 2.1 + hs(P.k, j, 31) * 0.8;
1693
+ return { x: px + Math.cos(aa) * r * (j ? 0.5 + 0.25 * hs(P.k, j, 32) : 0), y: py + Math.sin(aa) * r * (j ? 0.38 : 0), r: r * (j ? 0.38 + 0.3 * hs(P.k, j, 33) : 0.8) };
1694
+ });
1695
+ const ha = hs(P.k, 26) * 6.28;
1696
+ const holes = st === 0 ? [] : st === 1 ? [{ x: px + Math.cos(ha) * r * 0.25, y: py + Math.sin(ha) * r * 0.2, r: r * 0.3 }]
1697
+ : st === 2 ? [{ x: px + Math.cos(ha) * r * 0.15, y: py + Math.sin(ha) * r * 0.12, r: r * 0.62 }]
1698
+ : [{ x: px, y: py, r: r * 0.92 }, { x: px + Math.cos(ha + 2.6) * r * 0.9, y: py + Math.sin(ha + 2.6) * r * 0.6, r: r * 0.45 }, { x: px + Math.cos(ha - 0.6) * r * 0.95, y: py + Math.sin(ha - 0.6) * r * 0.5, r: r * 0.32 }];
1699
+ puffs.push({ x: px, y: py, r, cs, holes });
1700
+ if (P.lead && st <= 1 && r > 40) tails.push({ px, py, r, k: P.k });
1701
+ }
1702
+ // R2 (REVIEW_2 P7): dry-brush soot = 3 wide brush strokes (24-40 px), each 6-10 broken bristle sub-strokes (2-5 px),
1703
+ // ink @ 70 %, tapered frayed tails; grow on 1s, fade over 3 drawings on 2s (7.0 / 7.083 / 7.167)
1704
+ if (t1 >= 6.625 && tq < 7.25) {
1705
+ const fade = t1 < 7.0 ? 1 : [0.85, 0.55, 0.28][Math.min(2, Math.round((tq - 7.0) * 12))];
1706
+ const sv = t1 < 7 ? f : bv(D);
1707
+ c.save(); c.globalAlpha = 0.7 * fade;
1708
+ [[-0.42, 36], [2.45, 40], [4.05, 26]].forEach(([a0, BW], i) => {
1709
+ const g = E.out2(clamp((t1 - 6.625) / 0.2));
1710
+ const nb = 6 + Math.floor(hs(i, 50) * 5);
1711
+ const r0 = 340 + 40 * hs(i, 52), L = (320 + 170 * hs(i, 53)) * g, bend = (hs(i, 55) - 0.5) * 0.35;
1712
+ const at = (v, off) => {
1713
+ const ang = a0 + bend * v * v, rr = r0 + L * v, nx = -Math.sin(ang), ny = Math.cos(ang), o2 = off * (1 + 0.2 * v);
1714
+ return [C0[0] + Math.cos(ang) * rr * 1.1 + nx * o2, C0[1] + Math.sin(ang) * rr * 0.85 + ny * o2];
1715
+ };
1716
+ for (let j = 0; j < nb; j++) {
1717
+ const off = (j / (nb - 1) - 0.5) * BW + (hs(i, j, 51) - 0.5) * 4, edgeK = Math.abs(off) / (BW * 0.5);
1718
+ const vs = 0.05 * hs(i, j, 56), ve = 1 - (0.08 + 0.3 * edgeK) * hs(i, j, 57); // outer bristles run out first
1719
+ // broken dry-brush gaps: 1-2 gaps per bristle
1720
+ const cuts = [vs];
1721
+ const ng = 1 + (hs(i, j, 58) > 0.55 ? 1 : 0);
1722
+ for (let q = 0; q < ng; q++) { const m = lerp(vs, ve, (q + 1) / (ng + 1) + (hs(i, j, q, 59) - 0.5) * 0.2), gw = 0.03 + 0.05 * hs(i, j, q, 60); cuts.push(m - gw / 2, m + gw / 2); }
1723
+ cuts.push(ve);
1724
+ for (let q = 0; q + 1 < cuts.length; q += 2) {
1725
+ const va = cuts[q], vb = cuts[q + 1], last = q + 2 >= cuts.length;
1726
+ if (vb - va < 0.02) continue;
1727
+ const P = [0, 0.25, 0.5, 0.75, 1].map((u) => at(lerp(va, vb, u), off));
1728
+ inkStroke(c, P, { seed: 1900 + i * 97 + j * 7 + q + sv * 3, w: 2 + 3 * hs(i, j, 54), t0: 3, t1: last ? 45 : 5, min: last ? 0.05 : 0.4, amp: 1.2, heavy: 0 });
1729
+ }
1730
+ }
1731
+ });
1732
+ c.restore();
1733
+ }
1734
+ // radial speed lines, badge (fire), starburst, smoke
1735
+ if (t1 < 6.92) speedLinesRadial(c, C0[0], C0[1], lerp(560, 760, inv(6.583, 6.92, t1)), 1350, 26, 1700 + f, 9, 1);
1736
+ drawBadge(c, t1 < 7.0 ? t1 : tq, D);
1737
+ drawStarburst(c, t1, D);
1738
+ smokeGroup(c, puffs, { seed: 1600 + (t1 < 7.0 ? f : bv(D)) * 7, w: 6 });
1739
+ // inked curl tails on the lead puffs (point back at the blast)
1740
+ for (const T of tails) {
1741
+ const bx = C0[0] - T.px, by = C0[1] - T.py, bl = Math.hypot(bx, by) || 1, ux = bx / bl, uy = by / bl;
1742
+ const P = [];
1743
+ for (let i = 0; i <= 16; i++) {
1744
+ const v = i / 16, d = T.r * (0.85 + 0.75 * v), cr = T.r * 0.28 * v * v;
1745
+ const ca = 5.2 * v * v;
1746
+ P.push([T.px + ux * d + (-uy * Math.sin(ca) + ux * (Math.cos(ca) - 1)) * cr, T.py + uy * d + (ux * Math.sin(ca) + uy * (Math.cos(ca) - 1)) * cr]);
1747
+ }
1748
+ inkStroke(c, P, { seed: 1650 + T.k * 7 + (t1 < 7.0 ? f : bv(D)), w: 5.5, t0: 6, t1: 30, min: 0.15, amp: 1.3, heavy: 0 });
1749
+ }
1750
+ // flying sparks (on 1s)
1751
+ if (t1 < 7.35) {
1752
+ for (let k = 0; k < NSPARK; k++) {
1753
+ const s = sparkState(k, t1);
1754
+ const fade = 1 - inv(7.0, 7.35, t1);
1755
+ if (fade <= 0) continue;
1756
+ if (k % 4 === 3) drawTwinkle(c, s.x, s.y, s.size * 1.6 * fade, 1800 + k * 7 + f, { rot: f * 0.3 + k });
1757
+ else { const j = (hs(k, 15) - 0.5) * 0.87, cj = Math.cos(j), sj = Math.sin(j); drawSpark(c, s.x, s.y, s.vx * cj - s.vy * sj, s.vx * sj + s.vy * cj, s.size * fade, 1800 + k * 7 + f, { inkOnly: k % 4 === 1 }); }
1758
+ }
1759
+ }
1760
+ }
1761
+ // ---------------- TITLE
1762
+ if (t1 >= 7.2) {
1763
+ const st = CUES.strokes;
1764
+ const prog = V5 ? STROKES.map((S) => inv(S.t0, S.t0 + S.dur, t1)) : STROKES.map((S, i) => inv(st[i] - 0.04, st[i] + (S.k === 'p' ? 0.1 : 0.14), t1));
1765
+ const settle = K(t1, [[8.5, 0], [8.583, 1.25, 'out2'], [8.667, 0.9], [8.75, 1.0]]);
1766
+ // title bounce with the badge
1767
+ const sc = 1 + K(t1, [[8.5, 0], [8.54, 0.05, 'out2'], [8.625, -0.02], [8.75, 0.008], [8.875, 0]]);
1768
+ c.save(); c.translate(BADGE.x, BADGE.y); c.scale(sc, sc); c.translate(-BADGE.x, -BADGE.y);
1769
+ drawTitle(c, prog, t1 < 8.5 ? { id: f, step: 1 } : D, { shadow: settle, shine: inv(8.5, 8.6, t1) });
1770
+ // fuse heads writing the strokes
1771
+ STROKES.forEach((S, i) => {
1772
+ const p = prog[i];
1773
+ if (p <= 0 || p >= 1) return;
1774
+ const full = titleDense(S, 3000 + i * 11, 1.5);
1775
+ const part = prefix(full, E.out2(p));
1776
+ const tip = part[part.length - 1];
1777
+ const sb = spline(xf(starPts(8, 34 + 8 * hs(f, i), 16, 1990 + f + i, f * 0.7, 0.35), { x: tip[0], y: tip[1] }), true, 3);
1778
+ fillShape(c, sb, PAL.yel, { seed: 1995 + f + i, amp: 1 });
1779
+ inkLoop(c, sb, { seed: 1996 + f + i, w: 3.2, amp: 1, t0: 3, t1: 6 });
1780
+ drawTwinkle(c, tip[0], tip[1], 26 + 6 * hs(f, i), 2000 + f * 3 + i, { rot: f * 0.5, fill: PAL.hi });
1781
+ for (let k = 0; k < 3; k++) {
1782
+ const a = hs(f, i, k) * 6.28, d = 20 + 30 * hs(f, i, k, 2);
1783
+ drawSpark(c, tip[0] + Math.cos(a) * d, tip[1] + Math.sin(a) * d, Math.cos(a) * 600, Math.sin(a) * 600, 5, 2100 + f * 5 + k + i);
1784
+ }
1785
+ });
1786
+ c.restore();
1787
+ // incoming fuse sparks: travel from their burst position to the stroke starts (on 1s)
1788
+ STROKES.forEach((S, i) => {
1789
+ const tS = V5 ? S.t0 : st[i] - 0.04, tA = Math.max(7.0, tS - 0.3);
1790
+ if ((V5 ? !S.first || S.ci === 0 : i === 0) || t1 < tA || t1 >= tS) return; // V5: one incoming fuse per character
1791
+ const k = (i * 3 + 1) % NSPARK;
1792
+ const p0 = sparkState(k, tA), p1 = strokeWorld(S)[0];
1793
+ const u = E.io2(inv(tA, tS, t1));
1794
+ const mx = (p0.x + p1[0]) / 2 + (p1[1] - p0.y) * 0.3, my = (p0.y + p1[1]) / 2 - (p1[0] - p0.x) * 0.3;
1795
+ const x = (1 - u) * (1 - u) * p0.x + 2 * u * (1 - u) * mx + u * u * p1[0];
1796
+ const y = (1 - u) * (1 - u) * p0.y + 2 * u * (1 - u) * my + u * u * p1[1];
1797
+ const dx = 2 * (1 - u) * (mx - p0.x) + 2 * u * (p1[0] - mx), dy = 2 * (1 - u) * (my - p0.y) + 2 * u * (p1[1] - my);
1798
+ drawSpark(c, x, y, dx * 2, dy * 2, 11, 2200 + f * 7 + i);
1799
+ });
1800
+ }
1801
+ // ---------------- HAND MADE + tagline + tiny spirit
1802
+ if (t1 >= 8.5) {
1803
+ const pop = (i) => { const t0 = CUES.letters[Math.min(3, Math.floor(i / 2))] - 0.001; return tq < t0 ? 0 : K(tq, [[t0, 1.35], [t0 + 0.083, 0.92], [t0 + 0.167, 1.0]]); };
1804
+ if (V5) { // V5: FRAME BY FRAME in the same marker caps, popping in 4 groups on the same cues
1805
+ const popV = (i) => { const t0 = CUES.letters[Math.min(3, Math.floor(i / 3.5))] - 0.001; return tq < t0 ? 0 : K(tq, [[t0, 1.35], [t0 + 0.083, 0.92], [t0 + 0.167, 1.0]]); };
1806
+ drawWord(c, 'FRAME BY FRAME', BADGE.x, 690, 58, D, { center: true, pop: popV, color: PAL.hi, track: 0.34, wk: 0.16, outline: 3.0, shadow: 4.5 });
1807
+ } else
1808
+ drawWord(c, 'HAND MADE', BADGE.x, 716, 78, D, { center: true, pop, color: PAL.hi, track: 0.37, wk: 0.155, outline: 3.2, shadow: 5 });
1809
+ drawTagline(c, D, inv(CUES.tagline[0], CUES.tagline[1], tq), 960, 930);
1810
+ // twinkles popping around the lockup (held on 3s)
1811
+ const TW = [[392, 250, 8.583, 30], V5 ? [1470, 104, 8.75, 34] : [1640, 190, 8.75, 34], [1560, 748, 8.917, 26], [372, 712, 9.0, 24], [690, 140, 9.25, 20], [1640, 532, 9.375, 18]];
1812
+ TW.forEach(([x, y, t0, R], k) => {
1813
+ if (tq < t0) return;
1814
+ const cyc = [1.0, 0.55, 0.8][Math.floor((tq - t0) * 8) % 3];
1815
+ const pop = E.outBack(clamp((tq - t0) / 0.12), 2.2);
1816
+ drawTwinkle(c, x, y, R * pop * cyc, 6200 + k * 13 + bv(D), { rot: 0.1 * k });
1817
+ });
1818
+ // tiny spirit reborn on top of the badge
1819
+ const SP = CUES.spirit_pop, WK = CUES.wink;
1820
+ if (tq >= SP - 0.001) {
1821
+ drawSpirit(c, tinySpirit(tq, D), D, tq);
1822
+ if (V5 && !NODOT) { const em = emberState(tq, D); if (em) drawEmber(c, em, D); }
1823
+ }
1824
+ }
1825
+ c.restore();
1826
+ // ---------------- before the match is lit the sheet is in shadow; the strike flashes it on
1827
+ if (f < 12) nightPass(c, D, f, ms);
1828
+ else if (f === 12) flashFrame(c, ms);
1829
+ // ---------------- pencil annotations (on the sheet)
1830
+ drawPencil(c, D, f);
1831
+ drawTooth(c, D);
1832
+ // ---------------- IMPACT FRAMES (hero): two full-frame flat colour frames
1833
+ if (f === 156 || f === 157) impactFrame(c, f);
1834
+ }
1835
+
1836
+ function tinySpirit(tq, D) { // tiny spirit reborn on top of the badge
1837
+ const SP = CUES.spirit_pop, WK = CUES.wink;
1838
+ const u = tq - SP;
1839
+ const sq = 1 + K(tq, [[SP, -0.45], [SP + 0.125, 0.22, 'out2'], [SP + 0.25, -0.06], [SP + 0.375, 0]]);
1840
+ return { x: V5 ? 1666 : 1430, y: (V5 ? 294 : 262) - K(tq, [[SP, 0], [SP + 0.125, 24, 'out2'], [SP + 0.25, 0]]), H: 178 * E.outBack(clamp((u + 0.04) / 0.16), 2), sx: 1 / Math.pow(sq, 0.8), sy: sq, kx: 0, curl: 0, r: 0.08, wild: 0.15,
1841
+ drops: false, face: { eyes: tq >= WK - 0.001 && tq < WK + 0.2 ? 'wink' : 'open', mouth: tq >= WK - 0.001 ? 'grin' : 'smile', look: -0.6 }, seed: 3000 + D.id,
1842
+ arms: u > 0.1 ? { L: { ang: -0.5, bend: 0.4 }, R: V5 ? { ang: 1.05, bend: -0.3 } : { ang: tq >= WK - 0.13 ? 1.25 + 0.35 * Math.sin(tq * 26) : 0.2, bend: -0.3 } } : null };
1843
+ }
1844
+ // V5: the tiny spirit holds up an ember (a fire lump from the badge: tomato disc, yellow highlight, boiling ink) = the film's closing dot
1845
+ function armHand(s, side, a) {
1846
+ const sh = [side * 0.4, -0.36], len = a.len ?? 0.42, dx = side * Math.cos(a.ang), dy = -Math.sin(a.ang);
1847
+ return spiritXf([[sh[0] + dx * len, sh[1] + dy * len]], { ...s, sx: (s.sx + 1) / 2, sy: (s.sy + 1) / 2 })[0];
1848
+ }
1849
+ const EMBER_R = 46;
1850
+ function emberState(tq, D) {
1851
+ const s = tinySpirit(tq, D), a = s.arms && s.arms.R;
1852
+ if (!a || s.H < 20) return null;
1853
+ const hp = armHand(s, 1, a), gR = s.H * 0.082 * (a.big ?? 1);
1854
+ const r = EMBER_R * E.outBack(clamp((tq - (CUES.spirit_pop + 0.075)) / 0.12), 2);
1855
+ return { x: hp[0] + 6, y: hp[1] - gR - r * 0.72, r };
1856
+ }
1857
+ function drawEmber(c, e, D) {
1858
+ const sv = bv(D), d = circlePts(e.x, e.y, e.r, 30);
1859
+ fillShape(c, d, PAL.red, { seed: 9600 + sv, amp: 1.5, dx: REG.dx * 0.7, dy: REG.dy * 0.7 });
1860
+ fillShape(c, circlePts(e.x - e.r * 0.26, e.y - e.r * 0.28, e.r * 0.5, 20), PAL.yel, { seed: 9605 + sv, amp: 1.3 });
1861
+ inkLoop(c, dens(d, 4), { seed: 9610 + sv, w: 5, amp: 1.4, heavy: 0.35, t0: 4, t1: 8 });
1862
+ inkStroke(c, [[e.x - e.r * 0.5, e.y - e.r * 0.06], [e.x - e.r * 0.36, e.y - e.r * 0.42]], { seed: 9615 + sv, w: 5, t0: 3, t1: 5, color: PAL.hi, amp: 0.8, heavy: 0 });
1863
+ [-2.35, -1.6, -0.85].forEach((a, k) => { // glow ticks
1864
+ const r0 = e.r * (1.3 + 0.08 * ((sv + k) % 2)), r1 = r0 + e.r * 0.34;
1865
+ inkStroke(c, [[e.x + Math.cos(a) * r0, e.y + Math.sin(a) * r0], [e.x + Math.cos(a) * r1, e.y + Math.sin(a) * r1]], { seed: 9620 + k + sv * 3, w: 4.5, t0: 4, t1: 5, amp: 0.8, heavy: 0 });
1866
+ });
1867
+ }
1868
+ function impactFrame(c, f) {
1869
+ c.save();
1870
+ c.setTransform(DPR, 0, 0, DPR, 0, 0);
1871
+ c.fillStyle = f === 156 ? PAL.ink : PAL.red;
1872
+ c.fillRect(0, 0, W, H);
1873
+ const P = spline(xf(starPts(14, f === 156 ? 520 : 700, f === 156 ? 250 : 330, 77 + f, f * 0.3, 0.3), { x: C0[0], y: C0[1] }), true, 6);
1874
+ if (f === 156) {
1875
+ fillShape(c, P, PAL.hi, { seed: f, amp: 2 });
1876
+ const Q = spline(xf(starPts(10, 260, 130, 99, 0.4, 0.3), { x: C0[0], y: C0[1] }), true, 5);
1877
+ fillShape(c, Q, PAL.yel, { seed: f + 1, amp: 2 });
1878
+ } else {
1879
+ speedLinesRadial(c, C0[0], C0[1], 380, 1300, 30, 2400, 16, 1);
1880
+ fillShape(c, P, PAL.yel, { seed: f, amp: 2 });
1881
+ inkLoop(c, P, { seed: f + 3, w: 12, amp: 2 });
1882
+ const Q = spline(xf(starPts(10, 360, 190, 98, 0.7, 0.3), { x: C0[0], y: C0[1] }), true, 5);
1883
+ fillShape(c, Q, PAL.hi, { seed: f + 1, amp: 2 });
1884
+ }
1885
+ c.restore();
1886
+ }
1887
+
1888
+ function drawPencil(c, D, f) {
1889
+ c.save();
1890
+ c.setTransform(DPR, 0, 0, DPR, 0, 0);
1891
+ const jx = (hs(D.id, 71) - 0.5) * 1.2, jy = (hs(D.id, 72) - 0.5) * 1.2;
1892
+ const ex = D.step === 1 ? 'on 1s' : D.step === 2 ? 'on 2s' : 'on 3s';
1893
+ if (D.fq < 218) {
1894
+ if (f > 12) { // R2: hand-cut paper knock-out, always on, so the notes never sit on a coloured cel
1895
+ fillShape(c, dens(rectPts(W - 290, H - 156, 226, 126), 8), PAL.cream, { seed: 9500 + bv(D), amp: 2.5 });
1896
+ }
1897
+ c.globalAlpha = 0.8; c.fillStyle = PAL.pencil; c.textAlign = 'right';
1898
+ c.font = '600 42px "Caveat"';
1899
+ c.fillText('No. ' + String(D.id + 1).padStart(3, '0'), W - 88 + jx, H - 56 + jy);
1900
+ c.font = '500 30px "Caveat"';
1901
+ c.fillText(ex, W - 88 + jx, H - 98 + jy);
1902
+ } else {
1903
+ const x1 = W - 128 + jx, yb = H - 70 + jy;
1904
+ c.globalAlpha = 0.93; c.fillStyle = PAL.pencil; c.textAlign = 'right';
1905
+ c.font = '400 86px "Long Cang"'; c.fillText('张', x1, yb + 2); // R1s2: hero note 80->92 px (~77 px on screen after the 84 % pull-back)
1906
+ const zw = c.measureText('张').width;
1907
+ c.font = '700 92px "Caveat"'; const txt = String(TOTAL); c.fillText(txt, x1 - zw - 14, yb); // R2: '第 146 张' (was 'No. 146 张')
1908
+ const nw = c.measureText(txt).width;
1909
+ c.font = '400 86px "Long Cang"'; c.fillText('第', x1 - zw - 14 - nw - 16, yb + 2);
1910
+ const tw = c.measureText('第').width + 16 + nw + 14 + zw;
1911
+ const cx = x1 - tw / 2 - 4, cy = yb - 27, rx = tw / 2 + 40, ry = 68;
1912
+ const p = D.fq >= 220 ? 1 : 0.5; // R2: loop starts with the pull (f218), closes f220
1913
+ c.globalAlpha = 1;
1914
+ if (p > 0) { // red loop, drawn on across 2 drawings, overshooting its start
1915
+ const P = [], n = 44, m = Math.round(n * 1.16 * p);
1916
+ for (let i = 0; i <= m; i++) { const a = -2.7 + (i / n) * Math.PI * 2, g = 1 + 0.07 * (i / n); P.push([cx + Math.cos(a) * rx * g, cy + Math.sin(a) * ry * g - 5 * (i / n)]); }
1917
+ inkStroke(c, xf(P.map(([x, y]) => [x - cx, y - cy]), { x: cx, y: cy, r: -0.05 }), { seed: 9300 + (p < 1 ? 0 : bv(D)), w: 5.5, t0: 10, t1: 26, min: 0.35, amp: 1.2, heavy: 0, color: PAL.pencil });
1918
+ }
1919
+ if (D.fq >= 222) { // arrow to the tagline
1920
+ const A = [cx - rx - 8, cy - 22], B = [1318, 918];
1921
+ const M = [(A[0] + B[0]) / 2 + 6, Math.min(A[1], B[1]) - 46];
1922
+ inkStroke(c, [A, M, B], { seed: 9320 + bv(D), w: 5, t0: 8, t1: 6, min: 0.4, amp: 1.2, heavy: 0, color: PAL.pencil });
1923
+ const d = Math.atan2(B[1] - M[1], B[0] - M[0]);
1924
+ for (const sg of [-1, 1]) {
1925
+ const a = d + Math.PI + sg * 0.55;
1926
+ inkStroke(c, [B, [B[0] + Math.cos(a) * 30, B[1] + Math.sin(a) * 30]], { seed: 9330 + sg + bv(D), w: 5, t0: 4, t1: 8, min: 0.4, amp: 1, heavy: 0, color: PAL.pencil });
1927
+ }
1928
+ }
1929
+ c.globalAlpha = 0.78; c.font = '500 32px "Caveat"'; c.fillText(ex, W - 96 + jx, H - 160 + jy);
1930
+ }
1931
+ c.globalAlpha = 0.78; c.fillStyle = PAL.pencil; c.textAlign = 'left';
1932
+ c.font = '600 34px "Caveat"';
1933
+ if (f > 12 && D.fq < 218) { c.globalAlpha = 1; fillShape(c, dens(rectPts(80, 72, 150, 58), 8), PAL.cream, { seed: 9510 + bv(D), amp: 2 }); c.globalAlpha = 0.78; }
1934
+ c.fillStyle = PAL.pencil; // R2s2 fix: fillShape left fillStyle = cream, so the slate text was invisible
1935
+ c.fillText('SC. 05', 100 + jx, 114 + jy); // R2: moved top-left (was bottom-left, sat on the box corner)
1936
+ c.restore();
1937
+ }
1938
+
1939
+ // ------------------------------------------------------------------ reel v5: link elements, a pure function of t (1920×1080 output px)
1940
+ function shotAt(f) { // the stage transform drawFrame() uses for frame f (no globals touched)
1941
+ if (f >= 132) { const u = E.in2(inv(5.5, 6.5, f / FPS)); return { s: lerp(STAGE.s, 2.05, u), px: 860, py: 606, X: 862, Y: 618 }; }
1942
+ return shotFor(f);
1943
+ }
1944
+ export function linkAt(t) {
1945
+ const f = Math.min(239, Math.max(0, Math.round(t * FPS)));
1946
+ const D = drawing(f), tq = D.fq / FPS, t1 = f / FPS, cam = camera(f);
1947
+ const sc = pullScale(D.fq), rif = f >= 224 && f <= 229 ? f - 224 : -1;
1948
+ const ox = rif >= 0 ? (hs(f, 1) - 0.5) * 12 : 0, oy = rif >= 0 ? -6 - 8 * hs(f, 2) : 0, rot = rif >= 0 ? (hs(f, 3) - 0.5) * 0.014 : 0;
1949
+ const sheet = ([x, y]) => { // sheet px -> frame px (end pull-back onto the light table + riffle lift)
1950
+ if (sc >= 1) return [x, y];
1951
+ const X = (x - PIV[0]) * sc, Y = (y - PIV[1]) * sc, c = Math.cos(rot), s = Math.sin(rot);
1952
+ return [PIV[0] + ox + X * c - Y * s, PIV[1] + oy + X * s + Y * c];
1953
+ };
1954
+ const sh = shotAt(f);
1955
+ const stage = ([x, y]) => sheet([cam.x + sh.X + (x - sh.px) * sh.s, cam.y + sh.Y + (y - sh.py) * sh.s]);
1956
+ const onSheet = ([x, y]) => sheet([x + cam.x, y + cam.y]);
1957
+ const R1 = (v) => Math.round(v * 10) / 10;
1958
+ const out = [];
1959
+ // ---- dot 1: the match head (0 – 6.49 s). Red until 2.0 (night-darkened before 0.5), burnt ink-black after; hidden under the flame 0.5–2.0
1960
+ const ms = matchState(tq);
1961
+ if (ms.vis && tq < 6.5 && f !== 156 && f !== 157) {
1962
+ const [hc] = xf([[0, -2]], { x: ms.hx, y: ms.hy, r: ms.r });
1963
+ const [x, y] = stage(hc);
1964
+ const a = f <= 8 || f === 11 ? 1 : f === 9 || f === 10 ? 0.4 : f === 12 ? 0 : f < 49 ? 0 : 1;
1965
+ const night = f < 12;
1966
+ out.push({ id: 'dot', type: 'circle', x: R1(x), y: R1(y), r: R1(HEAD_R * sh.s * sc), a, look: 'ink',
1967
+ fill: ms.burnt ? PAL.ink : night ? '#44241b' : PAL.red, stroke: PAL.ink, sw: R1(5.5 * sh.s * (f < 12 ? 0.58 : 1)),
1968
+ note: ms.burnt ? 'burnt match head (ink disc, red ember speck), boiling ink outline' : night ? 'match head in the dark close-up (red, night-darkened), trembling' : a ? 'match head' : 'match head under the flame spirit / smear' });
1969
+ }
1970
+ // ---- dot 2: the explosion core (6.92 – 7.29 s): yellow starburst ball with a paper-white core; travels to 逐's first stroke and lights the fuse
1971
+ const tb = t1 < 7.0 ? t1 : tq;
1972
+ if (t1 > 6.88 && tb < 7.3) {
1973
+ const coreU = E.out3(inv(6.62, 6.9, tb)), mv = morphP(tb);
1974
+ const qx = lerp(C0[0], BADGE.x, mv), qy = lerp(C0[1], BADGE.y, mv), qr = lerp(260, BADGE.ry, mv) * coreU * (1 + 0.06 * Math.sin(Math.PI * mv));
1975
+ if (qr > 20) {
1976
+ const p0 = strokeWorld(STROKES[0])[0], stp = tb < 7.12 ? 0 : tb < 7.2 ? 1 : 2;
1977
+ const e = [0, 0.55, 1][stp], R = Math.min(qr * 0.8, 240) * [1, 0.45, 0.2][stp];
1978
+ const ax = qx + 6, ay = qy + 4, mx = (ax + p0[0]) / 2 - (p0[1] - ay) * 0.35, my = (ay + p0[1]) / 2 + (p0[0] - ax) * 0.35;
1979
+ const gx = (1 - e) * (1 - e) * ax + 2 * e * (1 - e) * mx + e * e * p0[0], gy = (1 - e) * (1 - e) * ay + 2 * e * (1 - e) * my + e * e * p0[1];
1980
+ const [x, y] = onSheet([gx, gy]);
1981
+ out.push({ id: 'dot', type: 'circle', x: R1(x), y: R1(y), r: R1(R * 0.8), a: 1, look: 'ink', fill: PAL.yel, stroke: stp ? PAL.ink : PAL.yel, sw: stp ? 4 : 0,
1982
+ grad: [PAL.hi, PAL.yel], note: `explosion core (9-point yellow star ball, paper-white centre; r = mean star radius)${stp ? ' flying to the first stroke, lights the fuse' : ''}` });
1983
+ }
1984
+ }
1985
+ // ---- dot 3 (V5): the ember the tiny spirit holds up at the end (9.25 – 10 s)
1986
+ if (tq >= CUES.spirit_pop - 0.001) {
1987
+ const em = emberState(tq, D);
1988
+ if (em && em.r > 1) {
1989
+ const [x, y] = onSheet([em.x, em.y]);
1990
+ out.push({ id: 'dot', type: 'circle', x: R1(x), y: R1(y), r: R1((em.r + 2.5) * sc), a: 1, look: 'ink', fill: PAL.red, stroke: PAL.ink, sw: R1(5 * sc),
1991
+ grad: [PAL.yel, PAL.red], note: 'ember held up by the tiny flame spirit (tomato disc, yellow highlight up-left, boiling ink outline, 3 glow ticks)' });
1992
+ }
1993
+ }
1994
+ // ---- other link candidates
1995
+ const ss = spiritState(tq, D, ms);
1996
+ if (ss && tq >= 0.5 && tq < 6.5 && f !== 156 && f !== 157) { // the stepped light pool behind the spirit (big pale disc)
1997
+ let R = K(tq, [[0.5, 60], [0.542, 330, 'out3'], [0.625, 380], [1.0, 310], [2.0, 315], [2.5, 345], [5.5, 350], [6.25, 430, 'io2'], [6.5, 470]]);
1998
+ R *= 1 + 0.03 * (bv(D) % 3 - 1);
1999
+ const [x, y] = stage([ss.x, ss.y - ss.H * 0.42 * ss.sy]);
2000
+ out.push({ id: 'pool', type: 'circle', x: R1(x), y: R1(y), r: R1(R * sh.s), a: f === 12 ? 0 : 1, look: 'flat', fill: '#f0d794', stroke: '#f0d794', sw: 0,
2001
+ note: 'flickering light pool of the flame (inner step; outer pale step at 1.25 r), multiplied into the paper' });
2002
+ }
2003
+ if (ms.vis && tq >= 1.0 && tq < 6.5 && f !== 156 && f !== 157) { // the (burnt) match stick, vertical
2004
+ const [H0, B0] = stickEnds(ms), [x0, y0] = stage(H0), [x1, y1] = stage(B0);
2005
+ out.push({ id: 'stick', type: 'line', x0: R1(x0), y0: R1(y0), x1: R1(x1), y1: R1(y1), w: R1(21 * sh.s), a: 1, note: 'match stick (head to butt)' });
2006
+ }
2007
+ const bs = boxState(tq);
2008
+ if (bs.vis && !bs.blown && f > 12 && tq < 6.5 && f !== 156 && f !== 157) { // matchbox top front edge = the stage line
2009
+ const y1 = shotFor(f) === LOW && f < 132 ? -112 : -90;
2010
+ const T = (P) => xf(P, { x: BOXP.px + bs.x, y: BOXP.py + bs.y, r: bs.r, sx: bs.sx, sy: bs.sy });
2011
+ const [A, B] = T([[-370, y1], [370, y1]]), [x0, y0] = stage(A), [x1, y1b] = stage(B);
2012
+ out.push({ id: 'box', type: 'line', x0: R1(x0), y0: R1(y0), x1: R1(x1), y1: R1(y1b), w: R1(5.2 * sh.s), a: 1, note: 'matchbox front top edge (red top / yellow front)' });
2013
+ }
2014
+ if (V5 && tq >= 7.33) { // the settled scalloped fire badge + the title block
2015
+ const bb = K(t1, [[8.5, 0], [8.54, 0.045, 'out2'], [8.625, -0.02], [8.75, 0.008], [8.875, 0]]) + 1;
2016
+ const [x, y] = onSheet([BADGE.x, BADGE.y]);
2017
+ out.push({ id: 'badge', type: 'rect', x: R1(x), y: R1(y), w: R1((BADGE.rx + 110) * 2 * bb * sc), h: R1((BADGE.ry + 100) * 2 * bb * sc), rot: R1(rot * 1000) / 1000, a: 1,
2018
+ fill: PAL.red, note: 'scalloped tomato fire badge (a boiling blob of circles) behind the title' });
2019
+ if (t1 >= 8.375) {
2020
+ const tb2 = TITLE_BOX(), ts = 1 + K(t1, [[8.5, 0], [8.54, 0.05, 'out2'], [8.625, -0.02], [8.75, 0.008], [8.875, 0]]);
2021
+ const [cx, cy] = onSheet([BADGE.x + (tb2.x - BADGE.x) * ts, BADGE.y + (tb2.y - BADGE.y) * ts]);
2022
+ out.push({ id: 'title', type: 'rect', x: R1(cx), y: R1(cy), w: R1(tb2.w * ts * sc), h: R1(tb2.h * ts * sc), rot: R1(rot * 1000) / 1000, a: 1,
2023
+ fill: PAL.yel, note: 'title 逐帧手绘 (yellow, ink outline, block shadow)' });
2024
+ }
2025
+ }
2026
+ return out;
2027
+ }
2028
+ let TBOX = null;
2029
+ function TITLE_BOX() { // bounds of the written title (stroke centre-lines ± half width + ink ring)
2030
+ if (TBOX) return TBOX;
2031
+ let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
2032
+ const m = TITLE.S * (TITLE.w ?? 0.165) * 0.65 + (TITLE.ink ?? 6.8);
2033
+ for (const st of STROKES) for (const [x, y] of spline(strokeWorld(st), false, 5)) { x0 = Math.min(x0, x - m); x1 = Math.max(x1, x + m); y0 = Math.min(y0, y - m); y1 = Math.max(y1, y + m); }
2034
+ TBOX = { x: (x0 + x1) / 2, y: (y0 + y1) / 2, w: x1 - x0, h: y1 - y0 };
2035
+ return TBOX;
2036
+ }