framerudiments 0.4.4__tar.gz → 0.4.5__tar.gz

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 (259) hide show
  1. {framerudiments-0.4.4/src/framerudiments.egg-info → framerudiments-0.4.5}/PKG-INFO +3 -1
  2. {framerudiments-0.4.4 → framerudiments-0.4.5}/README.md +2 -0
  3. {framerudiments-0.4.4 → framerudiments-0.4.5}/RELEASE_NOTES.md +16 -0
  4. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/project-format.md +4 -1
  5. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/rich-lyric-effects.md +9 -1
  6. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/visualiser-routing.md +8 -0
  7. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/visualisers.md +27 -0
  8. {framerudiments-0.4.4 → framerudiments-0.4.5}/pyproject.toml +1 -1
  9. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/__init__.py +1 -1
  10. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/compositor.py +5 -0
  11. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/lyric_indicator.py +46 -2
  12. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/projectm_backend.py +19 -0
  13. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/timeline.py +15 -0
  14. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_routing.py +26 -4
  15. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualisers.py +92 -12
  16. {framerudiments-0.4.4 → framerudiments-0.4.5/src/framerudiments.egg-info}/PKG-INFO +3 -1
  17. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_rich_subtitles.py +24 -0
  18. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_visualiser_routing.py +56 -1
  19. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_visualisers.py +53 -0
  20. {framerudiments-0.4.4 → framerudiments-0.4.5}/AGENTS.md +0 -0
  21. {framerudiments-0.4.4 → framerudiments-0.4.5}/LICENSE +0 -0
  22. {framerudiments-0.4.4 → framerudiments-0.4.5}/MANIFEST.in +0 -0
  23. {framerudiments-0.4.4 → framerudiments-0.4.5}/analysis-releases.json +0 -0
  24. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/ECOSYSTEM.md +0 -0
  25. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/FrameRudiments-PATCH_WORKFLOW.md +0 -0
  26. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/annular-modulation.md +0 -0
  27. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/authoring-workspace.md +0 -0
  28. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/beat-alignment.md +0 -0
  29. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/controlled-audio-response.md +0 -0
  30. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/expanded-effects.md +0 -0
  31. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/extraction.md +0 -0
  32. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/full-preset-analysis.md +0 -0
  33. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/hybrid-preset-analysis.md +0 -0
  34. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/keyframed-mattes.md +0 -0
  35. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/beat-alignment-evidence.json +0 -0
  36. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/beat-alignment.mp4 +0 -0
  37. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/beat-alignment.png +0 -0
  38. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/demo-transcript.md +0 -0
  39. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/effects-pipeline.svg +0 -0
  40. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/full-frame-comparison.png +0 -0
  41. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/full-frame-demo.mp4 +0 -0
  42. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/media-evidence.json +0 -0
  43. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/natural-materials.mp4 +0 -0
  44. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/natural-materials.png +0 -0
  45. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/preset-test-cards.png +0 -0
  46. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/rangoli-transcript.md +0 -0
  47. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/rangoli.mp4 +0 -0
  48. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/rangoli.png +0 -0
  49. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/test-card-inputs/colour-bars.png +0 -0
  50. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/test-card-inputs/grid-rings.png +0 -0
  51. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/media/test-card-inputs/text-card.png +0 -0
  52. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/multi-stem-modulation.md +0 -0
  53. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/music-reactive-3d.md +0 -0
  54. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/natural-materials.md +0 -0
  55. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/particle-smoke.md +0 -0
  56. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/phase3-compositing.md +0 -0
  57. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/preset-behaviour.md +0 -0
  58. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/preset-profiling.md +0 -0
  59. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/projectm-local-runtime.md +0 -0
  60. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/quality-effects.md +0 -0
  61. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/rangoli.md +0 -0
  62. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/rudiment-renderers.md +0 -0
  63. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/scoped-post-effects.md +0 -0
  64. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/solid-spatial-renderers.md +0 -0
  65. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/spatial-renderers.md +0 -0
  66. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/spatiotemporal-supersampling.md +0 -0
  67. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/spectrum.md +0 -0
  68. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/sprite-visualisations.md +0 -0
  69. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/stem-sources-and-masks.md +0 -0
  70. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/variable-bpm-word-subtitles.md +0 -0
  71. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/visualisation-atlas.md +0 -0
  72. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/visualisation-coverage-matrix.md +0 -0
  73. {framerudiments-0.4.4 → framerudiments-0.4.5}/docs/workflow-media.md +0 -0
  74. {framerudiments-0.4.4 → framerudiments-0.4.5}/integration/test_fire_volume_gpu.py +0 -0
  75. {framerudiments-0.4.4 → framerudiments-0.4.5}/integration/test_music_spatial_gpu.py +0 -0
  76. {framerudiments-0.4.4 → framerudiments-0.4.5}/integration/test_preset_analysis.py +0 -0
  77. {framerudiments-0.4.4 → framerudiments-0.4.5}/integration/test_released_preset_analysis.py +0 -0
  78. {framerudiments-0.4.4 → framerudiments-0.4.5}/integration/test_visualisers_gpu.py +0 -0
  79. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/Run-ControlledPresetAnalysis.ps1 +0 -0
  80. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/Run-FullPresetAnalysis.ps1 +0 -0
  81. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/Run-HybridPresetAnalysis.ps1 +0 -0
  82. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/Run-PresetBehaviourAnalysis.ps1 +0 -0
  83. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/authoring_preview.py +0 -0
  84. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/bake_mantaflow_fire.py +0 -0
  85. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/build_sprite_assets.py +0 -0
  86. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/check_projectm_runtime.py +0 -0
  87. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/check_release_packages.py +0 -0
  88. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/controlled_preset_analysis.py +0 -0
  89. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/ensure_projectm_runtime.py +0 -0
  90. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/full_preset_analysis.py +0 -0
  91. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/generate_particle_gallery.py +0 -0
  92. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/generate_post_effect_gallery.py +0 -0
  93. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/generate_quality_gallery.py +0 -0
  94. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/generate_rich_lyric_gallery.py +0 -0
  95. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/generate_sprite_gallery.py +0 -0
  96. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/generate_supersampling_gallery.py +0 -0
  97. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/generate_timing_gallery.py +0 -0
  98. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/generate_visualisation_gallery.py +0 -0
  99. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/generate_visualiser_routing_gallery.py +0 -0
  100. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/install_analysis_releases.py +0 -0
  101. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/prepare_fire_atlas.py +0 -0
  102. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/prepare_pixelcue_taggers.py +0 -0
  103. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/preset_behaviour_pipeline.py +0 -0
  104. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/profile_visualisation_coverage.py +0 -0
  105. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/profile_visualisers.py +0 -0
  106. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/projectm_dll_diagnostics.py +0 -0
  107. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/render_natural_material_demo.py +0 -0
  108. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/repair_projectm_mingw.py +0 -0
  109. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/select_projectm_generator.py +0 -0
  110. {framerudiments-0.4.4 → framerudiments-0.4.5}/scripts/validate_patch.py +0 -0
  111. {framerudiments-0.4.4 → framerudiments-0.4.5}/setup.cfg +0 -0
  112. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/__main__.py +0 -0
  113. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/advanced_renderers.py +0 -0
  114. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/atlas.py +0 -0
  115. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/audio_probes.py +0 -0
  116. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/beat_alignment.py +0 -0
  117. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/beat_timeline.py +0 -0
  118. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/choreography.py +0 -0
  119. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/circular_renderers.py +0 -0
  120. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/cli.py +0 -0
  121. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/controlled_profiling.py +0 -0
  122. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/demo.py +0 -0
  123. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/effect_catalogue.py +0 -0
  124. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/effect_renderers.py +0 -0
  125. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/effects.py +0 -0
  126. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/encoding.py +0 -0
  127. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/fire_assets/mantaflow/NOTICE.md +0 -0
  128. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/fire_assets/mantaflow/atlas.json +0 -0
  129. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/fire_assets/mantaflow/flames.png +0 -0
  130. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/fire_assets/marburg/NOTICE.md +0 -0
  131. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/fire_assets/marburg/atlas.json +0 -0
  132. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/fire_assets/marburg/flames.png +0 -0
  133. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/fire_assets.py +0 -0
  134. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/fire_fluid.py +0 -0
  135. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/fire_renderer.py +0 -0
  136. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/fire_volume.py +0 -0
  137. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/flight.py +0 -0
  138. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/gpu_spatial_backend.py +0 -0
  139. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/io.py +0 -0
  140. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/keyframes.py +0 -0
  141. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/layer_animation.py +0 -0
  142. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/layer_displacement.py +0 -0
  143. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/layer_masks.py +0 -0
  144. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/modulation.py +0 -0
  145. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/motion.py +0 -0
  146. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/music_spatial_renderers.py +0 -0
  147. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/natural_materials.py +0 -0
  148. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/output_quality.py +0 -0
  149. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/particle_engine.py +0 -0
  150. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/post_effects.py +0 -0
  151. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/preset_library.py +0 -0
  152. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/preset_profiling.py +0 -0
  153. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/profile_analysis.py +0 -0
  154. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/project.py +0 -0
  155. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/quality_effects.py +0 -0
  156. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/rangoli_renderer.py +0 -0
  157. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/render.py +0 -0
  158. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/rich_subtitles.py +0 -0
  159. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/rudiment_renderers.py +0 -0
  160. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/scoped_effects.py +0 -0
  161. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/solid_spatial_renderers.py +0 -0
  162. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/spatial_renderers.py +0 -0
  163. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/spectrum.py +0 -0
  164. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/arcade.png +0 -0
  165. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/crystal.png +0 -0
  166. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/fire.png +0 -0
  167. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/generated-neon.png +0 -0
  168. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/glass.png +0 -0
  169. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/hud.png +0 -0
  170. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/ice.png +0 -0
  171. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/lcd-amber.png +0 -0
  172. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/lcd-green.png +0 -0
  173. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/lcd.png +0 -0
  174. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/led.png +0 -0
  175. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/manifest.json +0 -0
  176. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/metal.png +0 -0
  177. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/mono.png +0 -0
  178. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/neon.png +0 -0
  179. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/pixel.png +0 -0
  180. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/plasma.png +0 -0
  181. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_assets/vfd.png +0 -0
  182. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_catalogue.py +0 -0
  183. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/sprite_renderers.py +0 -0
  184. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/stem_blend.py +0 -0
  185. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/stemlab.py +0 -0
  186. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/subtitles.py +0 -0
  187. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/super_sampling.py +0 -0
  188. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/terrain_field.py +0 -0
  189. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visual_style.py +0 -0
  190. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualisation_catalogue.py +0 -0
  191. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/butterchurn-page.js +0 -0
  192. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/butterchurn-worker.mjs +0 -0
  193. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/feedback.json +0 -0
  194. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/feedback.milk +0 -0
  195. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/julia.json +0 -0
  196. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/julia.milk +0 -0
  197. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/kaleidoscope.json +0 -0
  198. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/kaleidoscope.milk +0 -0
  199. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/package-lock.json +0 -0
  200. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/package.json +0 -0
  201. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_assets/rangoli-background.milk +0 -0
  202. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/visualiser_setup.py +0 -0
  203. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments/word_timing.py +0 -0
  204. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments.egg-info/SOURCES.txt +0 -0
  205. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments.egg-info/dependency_links.txt +0 -0
  206. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments.egg-info/entry_points.txt +0 -0
  207. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments.egg-info/requires.txt +0 -0
  208. {framerudiments-0.4.4 → framerudiments-0.4.5}/src/framerudiments.egg-info/top_level.txt +0 -0
  209. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/conftest.py +0 -0
  210. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_adapters.py +0 -0
  211. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_advanced_renderers.py +0 -0
  212. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_authoring_preview.py +0 -0
  213. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_beat_alignment.py +0 -0
  214. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_behaviour_pipeline.py +0 -0
  215. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_checkpoint_batches.py +0 -0
  216. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_controlled_audio.py +0 -0
  217. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_coverage_matrix.py +0 -0
  218. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_default_antialias.py +0 -0
  219. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_effect_batch.py +0 -0
  220. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_extracted_helpers.py +0 -0
  221. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_fire_methods.py +0 -0
  222. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_flight.py +0 -0
  223. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_full_preset_analysis.py +0 -0
  224. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_gpu_ci_isolation.py +0 -0
  225. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_hybrid_launcher.py +0 -0
  226. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_keyframes.py +0 -0
  227. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_layer_masks.py +0 -0
  228. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_lyrics.py +0 -0
  229. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_music_spatial_pipeline.py +0 -0
  230. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_music_spatial_renderers.py +0 -0
  231. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_natural_materials.py +0 -0
  232. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_particle_engine.py +0 -0
  233. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_patch_validation.py +0 -0
  234. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_phase3_animation.py +0 -0
  235. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_phase3_gpu_injection.py +0 -0
  236. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_pipeline.py +0 -0
  237. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_preset_profiling.py +0 -0
  238. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_projectm_dll_diagnostics.py +0 -0
  239. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_projectm_generator.py +0 -0
  240. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_projectm_runtime_preflight.py +0 -0
  241. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_pytest_temp_isolation.py +0 -0
  242. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_quality_effects.py +0 -0
  243. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_rangoli_renderer.py +0 -0
  244. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_release_metadata.py +0 -0
  245. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_repair_projectm_mingw.py +0 -0
  246. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_rudiment_renderers.py +0 -0
  247. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_scoped_effects.py +0 -0
  248. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_solid_spatial_integration.py +0 -0
  249. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_solid_spatial_renderers.py +0 -0
  250. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_spatial_renderers.py +0 -0
  251. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_spectrum.py +0 -0
  252. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_sprite_renderers.py +0 -0
  253. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_stem_blend.py +0 -0
  254. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_stem_source_integration.py +0 -0
  255. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_supersampling.py +0 -0
  256. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_timeline.py +0 -0
  257. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_variable_bpm_words.py +0 -0
  258. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_visualisation_modulation.py +0 -0
  259. {framerudiments-0.4.4 → framerudiments-0.4.5}/tests/test_windows_launcher_splitpath.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: framerudiments
3
- Version: 0.4.4
3
+ Version: 0.4.5
4
4
  Summary: Deterministic Shorts and lyric visualisers using DanceRudiments and StemLab evidence.
5
5
  Author: Kieran Simkin
6
6
  License-Expression: MIT
@@ -204,6 +204,8 @@ text. Existing plain text remains untouched unless configured. Set `word_indicat
204
204
  native 1-dimensional DanceRudiment-driven bouncing sprite that tracks the active word of
205
205
  the full line. Its particle field can be continuous, word/cue-triggered, or hybrid;
206
206
  trigger windows independently change colour, density, size, glow and burst count.
207
+ An optional between-word travel arc rises before the next timestamp and lands
208
+ exactly on its encoded word frame.
207
209
  See [rich lyric reference](docs/rich-lyric-effects.md) and the
208
210
  [CI-generated demo](https://kieransimkin.github.io/FrameRudiments/rich-lyrics/).
209
211
 
@@ -173,6 +173,8 @@ text. Existing plain text remains untouched unless configured. Set `word_indicat
173
173
  native 1-dimensional DanceRudiment-driven bouncing sprite that tracks the active word of
174
174
  the full line. Its particle field can be continuous, word/cue-triggered, or hybrid;
175
175
  trigger windows independently change colour, density, size, glow and burst count.
176
+ An optional between-word travel arc rises before the next timestamp and lands
177
+ exactly on its encoded word frame.
176
178
  See [rich lyric reference](docs/rich-lyric-effects.md) and the
177
179
  [CI-generated demo](https://kieransimkin.github.io/FrameRudiments/rich-lyrics/).
178
180
 
@@ -1,3 +1,19 @@
1
+ # FrameRudiments 0.4.5
2
+
3
+ - Word indicators can now travel between encoded word hits with a configurable
4
+ arc and land on the next word's authoritative frame, while retaining the
5
+ existing after-hit mode.
6
+ - projectM stages accept source-time preset timelines and perform bounded soft
7
+ transitions on the existing feedback instance, with every switch recorded in
8
+ render evidence.
9
+ - Lyric layers can feed the visualiser only during their final bounded pips when
10
+ a real blank gap follows, allowing the departing line to dissolve into the
11
+ background without contaminating immediate line-to-line changes.
12
+ - Project-format and visualiser documentation describe the new contracts;
13
+ focused regression coverage preserves clock, preset trust, routing and
14
+ evidence behaviour. Private campaign artwork and audio remain outside the
15
+ distribution.
16
+
1
17
  # FrameRudiments 0.4.4
2
18
 
3
19
  - Spectrum views accept a validated `style.grid` boolean. Set it to `false` to remove the three fixed dB guide lines while retaining calibrated bars and moving peak holds; label visibility remains independent.
@@ -23,6 +23,7 @@ the inputs are checked again before and after a render.
23
23
  | `layers` | Ordered sprite, legacy bar and modular rudiment layers described below. |
24
24
  | Native radial petals | Use `type: "rudiment", renderer: "rangoli"` with independent scale/spin/lift axes; see [rangoli configuration and actual demo](rangoli.md). |
25
25
  | `visualiser_routing` | Optional per-element GPU input/overlay policies. Feed continuously, only for first N native pips after visibility onset, or never; see [routing](visualiser-routing.md). |
26
+ | `effects[].preset_timeline` | Optional ordered projectM preset changes at canonical source seconds, using the native soft transition and retaining one feedback instance. |
26
27
  | `lyric_art` | `{items: [{text, asset}], placement: <sprite fields>, fallback: "text" or "error"}`. Exact text lookup preserves canonical lyric association. `error` requires every visible lyric to have an asset. |
27
28
  | `stemlab_loop` | `{file: <pinned loops report>, id: <accepted loop id>}`; imports native bounds/rate, verifies source SHA, rejects conflicting explicit audio values. Does not approve a seam. |
28
29
 
@@ -148,7 +149,9 @@ Set `effects` and optional checksum-pinned `font` on any `captions`, `word_capti
148
149
  emboss, inner/outer glow and drop shadow. `word_indicator` needs both line and word LRC,
149
150
  a full-line captions block and a selected native one-axis Rudiment bounce driver. Its
150
151
  particle options support constant emission, timed word/cue bursts and trigger-driven
151
- appearance changes; see [full schema and examples](rich-lyric-effects.md).
152
+ appearance changes. `travel_mode: "between_words"` can use the pinned next-word
153
+ timestamp to follow a smooth pre-hit arc and land exactly on the word frame;
154
+ see [full schema and examples](rich-lyric-effects.md).
152
155
 
153
156
  ## Optional full-frame effects
154
157
 
@@ -92,6 +92,8 @@ DanceRudiment axis to the y-coordinate. The cursor may use a `spark`, `orb`,
92
92
  "glow": 5,
93
93
  "opacity": 1,
94
94
  "travel_frames": 10,
95
+ "travel_mode": "between_words",
96
+ "travel_arc": 24,
95
97
  "offset_x": 0,
96
98
  "offset_y": 14,
97
99
  "bounce": {
@@ -123,7 +125,13 @@ The `sprite` field is optional. For custom files, the supplied SHA-256 is
123
125
  mandatory; changing images requires changing the project reference. The same
124
126
  built-in transparent glyphs work without external assets. `position` accepts
125
127
  `above`, `below` or `on`. `travel_frames` (0–120) determines the smoothstep
126
- movement to the next word. Selecting `bounce.axis` projects one native
128
+ movement window. The default `travel_mode: "after_hit"` preserves the original
129
+ behaviour, moving from the preceding word after the new timestamp. Use
130
+ `travel_mode: "between_words"` to move during the final `travel_frames` before
131
+ the next encoded word timestamp; `travel_arc` (0–250 pixels) adds a sine-shaped
132
+ lift that is zero at both ends, so the sprite lands exactly on the next word
133
+ frame. This uses only the pinned word ledger and does not infer timing from the
134
+ audio. Selecting `bounce.axis` projects one native
127
135
  DanceRudiment dimension only; captured recordings retain their own source
128
136
  clock, and rhythmic named/table motions use the corrected BPM map if enabled.
129
137
 
@@ -55,6 +55,7 @@ misspellings are errors rather than silent omissions.
55
55
  | --- | --- |
56
56
  | `continuous` | Inject this element on every output frame where it has visible alpha pixels |
57
57
  | `first_pips` | Inject from the first visible frame until `duration_pips` source-clock pips have elapsed, using the half-open interval `[appearance_pip, appearance_pip + duration_pips)` |
58
+ | `last_pips` | With `retrigger: lyric`, inject the visible lyric element for the final `duration_pips` before the next effective lyric state becomes blank. A same-frame clear followed by a new nonblank line is an immediate handoff and does not trigger this window. |
58
59
  | `never` | No pixels from this element enter the first GPU stage; it can still be overlaid |
59
60
 
60
61
  `duration_pips` must be a finite number between 0 and 65,536. One beat is
@@ -64,6 +65,13 @@ beat count over repeated audio excerpts. Therefore a 64-pip window may be
64
65
  shorter or longer than half a second as the music accelerates or slows.
65
66
  A zero-pip window never injects the element.
66
67
 
68
+ `last_pips` is designed for lyric tails that should enter feedback only before
69
+ a genuine silent display gap. It requires `retrigger: lyric`, an active nonblank
70
+ lyric, and a known next effective lyric event. The route report records
71
+ `remaining_pips` and `lyric_gap_exit` so the decision is auditable. With
72
+ `overlay: true`, the readable original remains visible while its copy enters the
73
+ feedback pipeline; at the blank event both disappear from the clean layer.
74
+
67
75
  `retrigger` controls how the window starts:
68
76
 
69
77
  - `reappearance` (default): reset the window when an element becomes visible
@@ -25,6 +25,33 @@ that two independent MilkDrop implementations produce identical pixels.
25
25
  ## Per-element feed and clean overlay routing
26
26
 
27
27
  FrameRudiments supports an optional project-level `visualiser_routing` object.
28
+
29
+ ## Source-timed projectM preset changes
30
+
31
+ A projectM stage may retain one feedback instance while changing presets at
32
+ canonical source-audio times. The native projectM soft transition blends the
33
+ old and new preset; no frame is skipped and the existing PCM/output clock stays
34
+ authoritative.
35
+
36
+ ```json
37
+ {
38
+ "id": "section-visualiser",
39
+ "engine": "projectm",
40
+ "preset": "feedback",
41
+ "preset_timeline": [
42
+ {"at_source_seconds": 62.46, "preset": "kaleidoscope", "transition_seconds": 0.42},
43
+ {"at_source_seconds": 128.60, "preset": "julia", "transition_seconds": 0.42}
44
+ ]
45
+ }
46
+ ```
47
+
48
+ Times are measured in the unchanged source recording. Entries must be strictly
49
+ increasing. Built-in names retain their package hashes; external `.milk` files
50
+ still require `trusted_preset: true` on the individual timeline entry. Timeline
51
+ switches currently use projectM because its pinned native API provides an
52
+ auditable soft-cut transition on the same renderer instance. The manifest
53
+ records the scheduled boundary, actual frame/source time, preset hash and blend
54
+ duration.
28
55
  It controls the **input RGBA texture** entering the first Butterchurn or
29
56
  projectM stage and independently decides which original layers are composited
30
57
  cleanly above the **last** visualiser result. The subsequent stages remain
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "framerudiments"
7
- version = "0.4.4"
7
+ version = "0.4.5"
8
8
  description = "Deterministic Shorts and lyric visualisers using DanceRudiments and StemLab evidence."
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  """Offline frame composition on a traceable musical clock."""
2
2
 
3
- __version__ = "0.4.4"
3
+ __version__ = "0.4.5"
4
4
 
5
5
  from .project import Project
6
6
  from .render import render, preflight
@@ -33,6 +33,8 @@ class FrameContext:
33
33
  lyric: dict | None
34
34
  cue: dict | None
35
35
  word: dict | None = None
36
+ lyric_next: dict | None = None
37
+ lyric_next_beat: float | None = None
36
38
 
37
39
 
38
40
  class TextBlock:
@@ -413,6 +415,7 @@ class Compositor:
413
415
  def context(self, frame):
414
416
  p = self.project
415
417
  lyric = self.lyrics.at(frame)
418
+ lyric_next = self.lyrics.next_effective(frame)
416
419
  word = self.words.at(frame, lyric if p.lyrics else None) if self.words else None
417
420
  return FrameContext(
418
421
  frame,
@@ -422,6 +425,8 @@ class Compositor:
422
425
  lyric,
423
426
  self.cues.at(frame),
424
427
  word,
428
+ lyric_next,
429
+ p.continuous_beat(lyric_next["frame"]) if lyric_next is not None else None,
425
430
  )
426
431
 
427
432
  def _caption_draw(self, image, text, ctx):
@@ -107,6 +107,8 @@ class WordIndicator:
107
107
  "offset_x",
108
108
  "offset_y",
109
109
  "travel_frames",
110
+ "travel_mode",
111
+ "travel_arc",
110
112
  "bounce",
111
113
  "rotation",
112
114
  "particles",
@@ -142,6 +144,25 @@ class WordIndicator:
142
144
  self.travel = integer(
143
145
  config.get("travel_frames", 8), "indicator transition frames", maximum=120 * time_factor
144
146
  )
147
+ self.travel_mode = config.get("travel_mode", "after_hit")
148
+ if self.travel_mode not in {"after_hit", "between_words"}:
149
+ raise ValueError("Indicator travel_mode must be after_hit or between_words")
150
+ self.travel_arc = number(
151
+ config.get("travel_arc", 0), "indicator travel arc", minimum=0, maximum=250
152
+ )
153
+ self._next_words = {}
154
+ timed_words = [
155
+ row for row in project.words if row.get("text") and row.get("word_index") is not None
156
+ ]
157
+ for current, following in zip(timed_words, timed_words[1:]):
158
+ if (
159
+ current.get("cycle") == following.get("cycle")
160
+ and current.get("line_frame") == following.get("line_frame")
161
+ and current.get("line_order") == following.get("line_order")
162
+ and following["frame"] > current["frame"]
163
+ ):
164
+ key = (current["frame"], current.get("source_order"), current.get("cycle"))
165
+ self._next_words[key] = following
145
166
  self.style = dict(
146
167
  indicator_size=number(config.get("size", 18), "indicator size", minimum=2, maximum=128),
147
168
  indicator_glow=number(config.get("glow", 4), "indicator glow", minimum=0, maximum=24),
@@ -282,7 +303,13 @@ class WordIndicator:
282
303
  font=self.font_ref,
283
304
  glyph=self.glyph,
284
305
  mode=self.particle_mode,
285
- source_clock="encoded words/cues + native bounce axis",
306
+ source_clock="encoded words/cues + optional native bounce axis",
307
+ travel={
308
+ "mode": self.travel_mode,
309
+ "frames": self.travel,
310
+ "arc_pixels": self.travel_arc,
311
+ "landing_authority": "next encoded word frame" if self.travel_mode == "between_words" else None,
312
+ },
286
313
  bounce=self.bounce.evidence if self.bounce else None,
287
314
  modulations=self.modulations.evidence,
288
315
  cue_file=self.cue_ref,
@@ -323,7 +350,21 @@ class WordIndicator:
323
350
  if not 0 <= i < len(positions):
324
351
  return None
325
352
  x, y = positions[i]
326
- if self.travel and i:
353
+ arc = 0.0
354
+ if self.travel_mode == "between_words" and self.travel:
355
+ key = (word["frame"], word.get("source_order"), word.get("cycle"))
356
+ following = self._next_words.get(key)
357
+ if following is not None:
358
+ target_index = following["word_index"]
359
+ start = max(word["frame"], following["frame"] - self.travel)
360
+ if frame >= start and 0 <= target_index < len(positions):
361
+ u = _unit((frame - start) / max(1, following["frame"] - start))
362
+ smooth = u * u * (3 - 2 * u)
363
+ target = positions[target_index]
364
+ x = x + (target[0] - x) * smooth
365
+ y = y + (target[1] - y) * smooth
366
+ arc = math.sin(math.pi * u) * self.travel_arc
367
+ elif self.travel and i:
327
368
  u = _unit((frame - word["frame"]) / self.travel)
328
369
  smooth = u * u * (3 - 2 * u)
329
370
  before = positions[i - 1]
@@ -337,6 +378,7 @@ class WordIndicator:
337
378
  y -= h / 2 + style["offset_y"]
338
379
  else:
339
380
  y += self.block.maximum * 0.5
381
+ y -= arc
340
382
  if self.bounce:
341
383
  values = self.bounce.sample(ctx.source_seconds, continuous_beat=ctx.beat)
342
384
  y -= values[self.bounce_axis] * style["bounce_amplitude"]
@@ -543,5 +585,7 @@ class WordIndicator:
543
585
  particles_active=active,
544
586
  cue_trigger=ctx.frame in self._events["cue"],
545
587
  word_trigger=ctx.frame in self._events["word"],
588
+ travel_mode=self.travel_mode,
589
+ landed=ctx.frame == ctx.word["frame"],
546
590
  )
547
591
  return layer.getchannel("A").getbbox()
@@ -169,6 +169,7 @@ class ProjectMBackend:
169
169
  "set_preset_locked": (None, [h, C.c_bool]),
170
170
  "set_hard_cut_enabled": (None, [h, C.c_bool]),
171
171
  "set_preset_start_clean": (None, [h, C.c_bool]),
172
+ "set_soft_cut_duration": (None, [h, C.c_double]),
172
173
  "load_preset_file": (None, [h, C.c_char_p, C.c_bool]),
173
174
  "set_preset_switch_failed_event_callback": (None, [h, C.c_void_p, h]),
174
175
  "pcm_add_float": (None, [h, C.POINTER(C.c_float), C.c_uint, C.c_int]),
@@ -202,6 +203,24 @@ class ProjectMBackend:
202
203
  def advance(self, image, context, pcm, *, capture=False):
203
204
  return self.process(image, context, pcm, capture=capture)
204
205
 
206
+ def switch_preset(self, preset, transition_seconds):
207
+ """Change preset on the existing feedback instance with projectM's soft transition."""
208
+ self.glfw.make_context_current(self.window)
209
+ self.lib.projectm_set_soft_cut_duration(self.handle, float(transition_seconds))
210
+ self.lib.projectm_load_preset_file(
211
+ self.handle,
212
+ os.fsencode(preset),
213
+ bool(transition_seconds),
214
+ )
215
+ self._check()
216
+ self.evidence.setdefault("preset_switches", []).append(
217
+ dict(
218
+ preset_path=str(Path(preset).resolve()),
219
+ preset_sha256=sha256(preset),
220
+ transition_seconds=float(transition_seconds),
221
+ )
222
+ )
223
+
205
224
  def process(self, image, context, pcm, *, capture=True):
206
225
  gl = self.gl
207
226
  self.glfw.make_context_current(self.window)
@@ -111,6 +111,21 @@ class EventTrack:
111
111
  index = bisect_right(self.frames, frame) - 1
112
112
  return self.events[index] if index >= 0 else None
113
113
 
114
+ def next_effective(self, frame):
115
+ """Return the rendered state at the next distinct event frame.
116
+
117
+ Several lyric ledgers deliberately write a blank clear followed by the
118
+ next line at the same frame. The last event at that frame is the state
119
+ a viewer actually sees, so a same-frame clear must not be mistaken for
120
+ a real lyric gap.
121
+ """
122
+ index = bisect_right(self.frames, frame)
123
+ if index >= len(self.events):
124
+ return None
125
+ next_frame = self.events[index]["frame"]
126
+ end = bisect_right(self.frames, next_frame)
127
+ return self.events[end - 1]
128
+
114
129
  def neighbours(self, frame):
115
130
  index = bisect_right(self.frames, frame) - 1
116
131
  current = self.events[index] if index >= 0 else None
@@ -13,7 +13,7 @@ from PIL import Image
13
13
  from .io import fields, number
14
14
 
15
15
  MAX_TARGETS = 256
16
- MODES = frozenset({"continuous", "first_pips", "never"})
16
+ MODES = frozenset({"continuous", "first_pips", "last_pips", "never"})
17
17
  RETRIGGERS = frozenset({"reappearance", "once", "lyric", "cue"})
18
18
 
19
19
 
@@ -27,12 +27,12 @@ def _policy(config, label, inherited=None):
27
27
  fields(feed, {"mode", "duration_pips"}, label + ".feed")
28
28
  mode = feed.get("mode")
29
29
  if mode not in MODES:
30
- raise ValueError(f"{label}.feed.mode must be continuous, first_pips or never")
31
- if mode == "first_pips":
30
+ raise ValueError(f"{label}.feed.mode must be continuous, first_pips, last_pips or never")
31
+ if mode in {"first_pips", "last_pips"}:
32
32
  duration = number(feed.get("duration_pips"), label + ".feed.duration_pips", minimum=0, maximum=65536)
33
33
  feed = dict(mode=mode, duration_pips=duration)
34
34
  elif "duration_pips" in feed:
35
- raise ValueError(f"{label}.feed.duration_pips requires first_pips")
35
+ raise ValueError(f"{label}.feed.duration_pips requires first_pips or last_pips")
36
36
  else:
37
37
  feed = dict(mode=mode)
38
38
  overlay = config.get("overlay", parent["overlay"])
@@ -41,6 +41,8 @@ def _policy(config, label, inherited=None):
41
41
  retrigger = config.get("retrigger", parent["retrigger"])
42
42
  if retrigger not in RETRIGGERS:
43
43
  raise ValueError(f"{label}.retrigger must be reappearance, once, lyric or cue")
44
+ if feed["mode"] == "last_pips" and retrigger != "lyric":
45
+ raise ValueError(f"{label}.feed.mode last_pips requires retrigger=lyric")
44
46
  return dict(feed=feed, overlay=overlay, retrigger=retrigger)
45
47
 
46
48
 
@@ -128,6 +130,20 @@ class VisualiserLayerRouting:
128
130
  state["lyric"], state["cue"] = lyric, cue
129
131
  mode = policy["feed"]["mode"]
130
132
  elapsed = max(0.0, pip - state["onset"]) if state["onset"] is not None else None
133
+ lyric_next = getattr(context, "lyric_next", None)
134
+ lyric_next_beat = getattr(context, "lyric_next_beat", None)
135
+ remaining = (
136
+ max(0.0, float(lyric_next_beat) * 64.0 - pip)
137
+ if lyric_next_beat is not None and math.isfinite(float(lyric_next_beat))
138
+ else None
139
+ )
140
+ lyric_gap_exit = bool(
141
+ trigger == "lyric"
142
+ and getattr(context, "lyric", None)
143
+ and getattr(context, "lyric", {}).get("text")
144
+ and lyric_next is not None
145
+ and not lyric_next.get("text")
146
+ )
131
147
  feeding = bool(
132
148
  visible
133
149
  and (
@@ -135,6 +151,10 @@ class VisualiserLayerRouting:
135
151
  or mode == "first_pips"
136
152
  and elapsed is not None
137
153
  and elapsed < policy["feed"]["duration_pips"]
154
+ or mode == "last_pips"
155
+ and lyric_gap_exit
156
+ and remaining is not None
157
+ and 0.0 < remaining <= policy["feed"]["duration_pips"]
138
158
  )
139
159
  )
140
160
  on_top = bool(visible and policy["overlay"])
@@ -149,6 +169,8 @@ class VisualiserLayerRouting:
149
169
  overlay=on_top,
150
170
  first_pip=state["onset"],
151
171
  elapsed_pips=elapsed,
172
+ remaining_pips=remaining,
173
+ lyric_gap_exit=lyric_gap_exit,
152
174
  policy=policy["feed"]["mode"],
153
175
  )
154
176
  self.next_frame += 1
@@ -18,31 +18,88 @@ ASSETS = Path(__file__).with_name("visualiser_assets")
18
18
  PRESETS = ("feedback", "kaleidoscope", "julia")
19
19
 
20
20
 
21
+ def _preset_ref(project, engine, preset, *, trusted=False):
22
+ if isinstance(preset, str):
23
+ if preset not in PRESETS:
24
+ raise ValueError(f"Unknown built-in visualiser preset: {preset}")
25
+ path = ASSETS / f"{preset}.{'json' if engine == 'butterchurn' else 'milk'}"
26
+ return project.ref(dict(path=str(path), sha256=sha256(path)))
27
+ if trusted is not True:
28
+ raise ValueError(
29
+ "External presets contain executable equations/shaders: set trusted_preset true after review"
30
+ )
31
+ return project.ref(preset)
32
+
33
+
21
34
  def effect_specs(project):
22
35
  configs = project.data.get("effects", [])
23
36
  if not isinstance(configs, list) or len(configs) > 8:
24
37
  raise ValueError("effects must be an ordered list with at most eight stages")
25
38
  specs = []
26
39
  for cfg in configs:
27
- fields(cfg, {"id", "engine", "preset", "input_mix", "seed", "trusted_preset"}, "full-frame effect")
40
+ fields(
41
+ cfg,
42
+ {
43
+ "id",
44
+ "engine",
45
+ "preset",
46
+ "input_mix",
47
+ "seed",
48
+ "trusted_preset",
49
+ "preset_timeline",
50
+ },
51
+ "full-frame effect",
52
+ )
28
53
  name = cfg.get("id")
29
54
  if not isinstance(name, str) or not name or name in [item["id"] for item in specs]:
30
55
  raise ValueError("Full-frame effect ids must be nonempty and unique")
31
56
  engine = cfg.get("engine")
32
57
  if engine not in {"butterchurn", "projectm"}:
33
58
  raise ValueError("Full-frame engine must be butterchurn or projectm")
34
- preset = cfg.get("preset", "feedback")
35
- if isinstance(preset, str):
36
- if preset not in PRESETS:
37
- raise ValueError(f"Unknown built-in visualiser preset: {preset}")
38
- path = ASSETS / f"{preset}.{'json' if engine == 'butterchurn' else 'milk'}"
39
- ref = project.ref(dict(path=str(path), sha256=sha256(path)))
40
- else:
41
- if cfg.get("trusted_preset") is not True:
42
- raise ValueError(
43
- "External presets contain executable equations/shaders: set trusted_preset true after review"
59
+ ref = _preset_ref(
60
+ project,
61
+ engine,
62
+ cfg.get("preset", "feedback"),
63
+ trusted=cfg.get("trusted_preset", False),
64
+ )
65
+ timeline = cfg.get("preset_timeline", [])
66
+ if timeline and engine != "projectm":
67
+ raise ValueError("preset_timeline currently requires projectM")
68
+ if not isinstance(timeline, list) or len(timeline) > 64:
69
+ raise ValueError("preset_timeline must be a list with at most 64 entries")
70
+ resolved_timeline = []
71
+ previous = -1.0
72
+ for index, entry in enumerate(timeline):
73
+ fields(
74
+ entry,
75
+ {"at_source_seconds", "preset", "transition_seconds", "trusted_preset"},
76
+ f"preset_timeline[{index}]",
77
+ )
78
+ at = number(
79
+ entry.get("at_source_seconds"),
80
+ f"preset_timeline[{index}].at_source_seconds",
81
+ minimum=0,
82
+ )
83
+ if at <= previous:
84
+ raise ValueError("preset_timeline source times must be strictly increasing")
85
+ previous = at
86
+ resolved_timeline.append(
87
+ dict(
88
+ at_source_seconds=at,
89
+ preset=_preset_ref(
90
+ project,
91
+ engine,
92
+ entry.get("preset"),
93
+ trusted=entry.get("trusted_preset", False),
94
+ ),
95
+ transition_seconds=number(
96
+ entry.get("transition_seconds", 0.5),
97
+ f"preset_timeline[{index}].transition_seconds",
98
+ minimum=0,
99
+ maximum=30,
100
+ ),
44
101
  )
45
- ref = project.ref(preset)
102
+ )
46
103
  specs.append(
47
104
  dict(
48
105
  id=name,
@@ -50,6 +107,7 @@ def effect_specs(project):
50
107
  preset=ref,
51
108
  input_mix=number(cfg.get("input_mix", 0.65), "input mix", minimum=0.001, maximum=1),
52
109
  seed=integer(cfg.get("seed", 0), "visualiser seed", maximum=2**32 - 1),
110
+ preset_timeline=resolved_timeline,
53
111
  )
54
112
  )
55
113
  return specs
@@ -243,6 +301,8 @@ class EffectChain:
243
301
  self.closed = False
244
302
  self.specs = specs
245
303
  self.routing = routing
304
+ self.preset_timeline_positions = [0 for _ in specs]
305
+ self.preset_switches = []
246
306
  self.stage_injections = {}
247
307
  requested = getattr(project, "data", {}).get("visualiser_stage_injections", [])
248
308
  if not isinstance(requested, list) or len(requested) > 32 or (requested and not specs):
@@ -298,6 +358,25 @@ class EffectChain:
298
358
  if self.stage_injections and isolated_layers is None:
299
359
  raise ValueError("Intermediate stage injection needs isolated layer surfaces")
300
360
  for index, backend in enumerate(self.backends):
361
+ timeline = self.specs[index].get("preset_timeline", [])
362
+ position = self.preset_timeline_positions[index]
363
+ while position < len(timeline) and context.source_seconds >= timeline[position]["at_source_seconds"]:
364
+ event = timeline[position]
365
+ if not hasattr(backend, "switch_preset"):
366
+ raise ValueError("Scheduled preset changes require a backend with switch_preset support")
367
+ backend.switch_preset(event["preset"]["path"], event["transition_seconds"])
368
+ self.preset_switches.append(
369
+ dict(
370
+ frame=context.frame,
371
+ source_seconds=context.source_seconds,
372
+ stage=index,
373
+ scheduled_at_source_seconds=event["at_source_seconds"],
374
+ transition_seconds=event["transition_seconds"],
375
+ preset=event["preset"],
376
+ )
377
+ )
378
+ position += 1
379
+ self.preset_timeline_positions[index] = position
301
380
  if index in self.stage_injections:
302
381
  image = image.convert("RGBA")
303
382
  for names, opacity in self.stage_injections[index]:
@@ -330,6 +409,7 @@ class EffectChain:
330
409
  pcm=self.audio.evidence,
331
410
  routing=self.routing.evidence if self.routing is not None else None,
332
411
  stage_injections={index: entries for index, entries in self.stage_injections.items()},
412
+ preset_switches=self.preset_switches,
333
413
  stages=[dict(spec, runtime=backend.evidence) for spec, backend in zip(self.specs, self.backends)],
334
414
  limits="GPU/driver and engine noise can vary; not a cross-platform bit-exact contract. Text bounds refer to pre-effect composition.",
335
415
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: framerudiments
3
- Version: 0.4.4
3
+ Version: 0.4.5
4
4
  Summary: Deterministic Shorts and lyric visualisers using DanceRudiments and StemLab evidence.
5
5
  Author: Kieran Simkin
6
6
  License-Expression: MIT
@@ -204,6 +204,8 @@ text. Existing plain text remains untouched unless configured. Set `word_indicat
204
204
  native 1-dimensional DanceRudiment-driven bouncing sprite that tracks the active word of
205
205
  the full line. Its particle field can be continuous, word/cue-triggered, or hybrid;
206
206
  trigger windows independently change colour, density, size, glow and burst count.
207
+ An optional between-word travel arc rises before the next timestamp and lands
208
+ exactly on its encoded word frame.
207
209
  See [rich lyric reference](docs/rich-lyric-effects.md) and the
208
210
  [CI-generated demo](https://kieransimkin.github.io/FrameRudiments/rich-lyrics/).
209
211
 
@@ -226,6 +226,30 @@ def test_bounce_uses_single_selected_native_rudiment_axis_and_replays(project):
226
226
  assert obj.bounce_axis == 0
227
227
 
228
228
 
229
+ def test_between_word_arc_lands_on_the_next_word_frame(project):
230
+ settings = {
231
+ "glyph": "star",
232
+ "size": 17,
233
+ "travel_frames": 20,
234
+ "travel_mode": "between_words",
235
+ "travel_arc": 24,
236
+ }
237
+ obj = WordIndicator(project, settings, project.context, block(project, {}))
238
+ first = obj._centre(5)
239
+ midpoint = obj._centre(25)
240
+ before_landing = obj._centre(34)
241
+ landed = obj._centre(35)
242
+ assert first and midpoint and before_landing and landed
243
+ assert first[0] < midpoint[0] < landed[0]
244
+ assert midpoint[1] < first[1] and midpoint[1] < landed[1]
245
+ assert before_landing[0] < landed[0]
246
+ assert landed[1] == pytest.approx(first[1])
247
+ frame = image()
248
+ obj.draw(frame, project.context(35))
249
+ assert obj.frame_report["landed"] is True
250
+ assert obj.frame_report["travel_mode"] == "between_words"
251
+
252
+
229
253
  def test_constant_particles_stay_on_and_word_events_trigger_bursts(project):
230
254
  obj = indicator(
231
255
  project,