This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

🚧 Preview server

+1092 -605
-1
.gitignore
··· 13 13 fixtures/ 14 14 !fixtures/schedule-hell* 15 15 *.exe 16 - preview.html 17 16 out.png 18 17 stems_data/ 19 18 street
+196
Cargo.lock
··· 156 156 checksum = "a8ab6b55fe97976e46f91ddbed8d147d966475dc29b2032757ba47e02376fbc3" 157 157 158 158 [[package]] 159 + name = "atomic-waker" 160 + version = "1.1.2" 161 + source = "registry+https://github.com/rust-lang/crates.io-index" 162 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 163 + 164 + [[package]] 159 165 name = "atomic_float" 160 166 version = "0.1.0" 161 167 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 183 189 version = "1.5.0" 184 190 source = "registry+https://github.com/rust-lang/crates.io-index" 185 191 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 192 + 193 + [[package]] 194 + name = "axum" 195 + version = "0.8.6" 196 + source = "registry+https://github.com/rust-lang/crates.io-index" 197 + checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" 198 + dependencies = [ 199 + "axum-core", 200 + "bytes 1.10.1", 201 + "form_urlencoded", 202 + "futures-util", 203 + "http", 204 + "http-body", 205 + "http-body-util", 206 + "hyper", 207 + "hyper-util", 208 + "itoa", 209 + "matchit", 210 + "memchr", 211 + "mime", 212 + "percent-encoding", 213 + "pin-project-lite", 214 + "serde_core", 215 + "serde_json", 216 + "serde_path_to_error", 217 + "serde_urlencoded", 218 + "sync_wrapper", 219 + "tokio", 220 + "tower", 221 + "tower-layer", 222 + "tower-service", 223 + "tracing", 224 + ] 225 + 226 + [[package]] 227 + name = "axum-core" 228 + version = "0.5.5" 229 + source = "registry+https://github.com/rust-lang/crates.io-index" 230 + checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" 231 + dependencies = [ 232 + "bytes 1.10.1", 233 + "futures-core", 234 + "http", 235 + "http-body", 236 + "http-body-util", 237 + "mime", 238 + "pin-project-lite", 239 + "sync_wrapper", 240 + "tower-layer", 241 + "tower-service", 242 + "tracing", 243 + ] 186 244 187 245 [[package]] 188 246 name = "backtrace" ··· 2484 2542 ] 2485 2543 2486 2544 [[package]] 2545 + name = "http-body" 2546 + version = "1.0.1" 2547 + source = "registry+https://github.com/rust-lang/crates.io-index" 2548 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 2549 + dependencies = [ 2550 + "bytes 1.10.1", 2551 + "http", 2552 + ] 2553 + 2554 + [[package]] 2555 + name = "http-body-util" 2556 + version = "0.1.3" 2557 + source = "registry+https://github.com/rust-lang/crates.io-index" 2558 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 2559 + dependencies = [ 2560 + "bytes 1.10.1", 2561 + "futures-core", 2562 + "http", 2563 + "http-body", 2564 + "pin-project-lite", 2565 + ] 2566 + 2567 + [[package]] 2487 2568 name = "httparse" 2488 2569 version = "1.10.1" 2489 2570 source = "registry+https://github.com/rust-lang/crates.io-index" 2490 2571 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 2491 2572 2492 2573 [[package]] 2574 + name = "httpdate" 2575 + version = "1.0.3" 2576 + source = "registry+https://github.com/rust-lang/crates.io-index" 2577 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 2578 + 2579 + [[package]] 2580 + name = "hyper" 2581 + version = "1.7.0" 2582 + source = "registry+https://github.com/rust-lang/crates.io-index" 2583 + checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" 2584 + dependencies = [ 2585 + "atomic-waker", 2586 + "bytes 1.10.1", 2587 + "futures-channel", 2588 + "futures-core", 2589 + "http", 2590 + "http-body", 2591 + "httparse", 2592 + "httpdate", 2593 + "itoa", 2594 + "pin-project-lite", 2595 + "pin-utils", 2596 + "smallvec", 2597 + "tokio", 2598 + ] 2599 + 2600 + [[package]] 2601 + name = "hyper-util" 2602 + version = "0.1.17" 2603 + source = "registry+https://github.com/rust-lang/crates.io-index" 2604 + checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" 2605 + dependencies = [ 2606 + "bytes 1.10.1", 2607 + "futures-core", 2608 + "http", 2609 + "http-body", 2610 + "hyper", 2611 + "pin-project-lite", 2612 + "tokio", 2613 + "tower-service", 2614 + ] 2615 + 2616 + [[package]] 2493 2617 name = "iana-time-zone" 2494 2618 version = "0.1.64" 2495 2619 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3049 3173 ] 3050 3174 3051 3175 [[package]] 3176 + name = "matchit" 3177 + version = "0.8.4" 3178 + source = "registry+https://github.com/rust-lang/crates.io-index" 3179 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 3180 + 3181 + [[package]] 3052 3182 name = "matrixmultiply" 3053 3183 version = "0.3.10" 3054 3184 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3129 3259 "quote", 3130 3260 "syn 2.0.108", 3131 3261 ] 3262 + 3263 + [[package]] 3264 + name = "mime" 3265 + version = "0.3.17" 3266 + source = "registry+https://github.com/rust-lang/crates.io-index" 3267 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 3132 3268 3133 3269 [[package]] 3134 3270 name = "minimal-lexical" ··· 4140 4276 "pico-args", 4141 4277 "rand 0.9.2", 4142 4278 "shapemaker", 4279 + "tokio", 4143 4280 ] 4144 4281 4145 4282 [[package]] ··· 4326 4463 ] 4327 4464 4328 4465 [[package]] 4466 + name = "serde_path_to_error" 4467 + version = "0.1.20" 4468 + source = "registry+https://github.com/rust-lang/crates.io-index" 4469 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 4470 + dependencies = [ 4471 + "itoa", 4472 + "serde", 4473 + "serde_core", 4474 + ] 4475 + 4476 + [[package]] 4329 4477 name = "serde_spanned" 4330 4478 version = "0.6.9" 4331 4479 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4341 4489 checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" 4342 4490 dependencies = [ 4343 4491 "serde_core", 4492 + ] 4493 + 4494 + [[package]] 4495 + name = "serde_urlencoded" 4496 + version = "0.7.1" 4497 + source = "registry+https://github.com/rust-lang/crates.io-index" 4498 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 4499 + dependencies = [ 4500 + "form_urlencoded", 4501 + "itoa", 4502 + "ryu", 4503 + "serde", 4344 4504 ] 4345 4505 4346 4506 [[package]] ··· 4392 4552 version = "1.2.2" 4393 4553 dependencies = [ 4394 4554 "anyhow", 4555 + "axum", 4395 4556 "backtrace", 4396 4557 "cargo", 4397 4558 "chrono", ··· 4691 4852 ] 4692 4853 4693 4854 [[package]] 4855 + name = "sync_wrapper" 4856 + version = "1.0.2" 4857 + source = "registry+https://github.com/rust-lang/crates.io-index" 4858 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 4859 + 4860 + [[package]] 4694 4861 name = "synstructure" 4695 4862 version = "0.13.2" 4696 4863 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4887 5054 "mio 1.1.0", 4888 5055 "pin-project-lite", 4889 5056 "signal-hook-registry", 5057 + "socket2", 4890 5058 "tokio-macros", 4891 5059 "windows-sys 0.61.2", 4892 5060 ] ··· 4989 5157 version = "1.0.4" 4990 5158 source = "registry+https://github.com/rust-lang/crates.io-index" 4991 5159 checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" 5160 + 5161 + [[package]] 5162 + name = "tower" 5163 + version = "0.5.2" 5164 + source = "registry+https://github.com/rust-lang/crates.io-index" 5165 + checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" 5166 + dependencies = [ 5167 + "futures-core", 5168 + "futures-util", 5169 + "pin-project-lite", 5170 + "sync_wrapper", 5171 + "tokio", 5172 + "tower-layer", 5173 + "tower-service", 5174 + "tracing", 5175 + ] 5176 + 5177 + [[package]] 5178 + name = "tower-layer" 5179 + version = "0.3.3" 5180 + source = "registry+https://github.com/rust-lang/crates.io-index" 5181 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 5182 + 5183 + [[package]] 5184 + name = "tower-service" 5185 + version = "0.3.3" 5186 + source = "registry+https://github.com/rust-lang/crates.io-index" 5187 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 4992 5188 4993 5189 [[package]] 4994 5190 name = "tracing"
+3 -1
Cargo.toml
··· 30 30 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 31 31 32 32 [features] 33 - default = ["cli", "vst", "mp4"] 33 + default = ["cli", "vst", "mp4", "video-server"] 34 34 vst = [ 35 35 "cli", 36 36 "rand/thread_rng", ··· 49 49 ] 50 50 web = ["dep:wasm-bindgen", "dep:web-sys"] 51 51 mp4 = ["dep:env_logger"] 52 + video-server = ["dep:axum"] 52 53 53 54 [dependencies] 54 55 nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", features = [ ··· 105 106 serde = { version = "1.0.228", features = ["derive"] } 106 107 url = "2.5.7" 107 108 tungstenite = { version = "0.28.0", optional = true } 109 + axum = { version = "0.8.6", optional = true, features = ["json"] } 108 110 109 111 110 112 [dev-dependencies]
+1 -1
examples/schedule-hell-backbone/src/main.rs
··· 43 43 // canvas.render_to_svg_file(&format!("framedump-{}.svg", ctx.frame))?; 44 44 // Ok(()) 45 45 // }) 46 - .render("schedule-hell-backbone.mp4") 46 + .encode("schedule-hell-backbone.mp4") 47 47 .unwrap(); 48 48 } 49 49
+1
examples/schedule-hell/Cargo.toml
··· 9 9 pico-args = { version = "0.5.0", features = ["combined-flags", "eq-separator"] } 10 10 rand = "0.9.0" 11 11 shapemaker = { path = "../..", features = ["mp4"] } 12 + tokio = "1.48.0"
+10 -5
examples/schedule-hell/src/main.rs
··· 21 21 } 22 22 } 23 23 24 - pub fn main() -> Result<()> { 24 + #[tokio::main] 25 + pub async fn main() -> Result<()> { 25 26 let mut canvas = Canvas::new(vec![]); 26 27 27 28 canvas.set_grid_size(16, 9); ··· 253 254 Ok(()) 254 255 }); 255 256 256 - video.render( 257 - args.free_from_str() 258 - .unwrap_or(String::from("schedule-hell.mp4")), 259 - )?; 257 + if args.contains("--serve") { 258 + video.serve("localhost:8000").await; 259 + } else { 260 + video.encode( 261 + args.free_from_str() 262 + .unwrap_or(String::from("schedule-hell.mp4")), 263 + )?; 264 + } 260 265 261 266 Ok(()) 262 267 }
+1 -1
src/main.rs
··· 144 144 ); 145 145 Ok(()) 146 146 }) 147 - .render(args.arg_file) 147 + .encode(args.arg_file) 148 148 }
+2 -2
src/video/animation.rs
··· 4 4 5 5 /// Arguments: animation progress (from 0.0 to 1.0), canvas, current ms 6 6 pub type AnimationUpdateFunction = 7 - dyn Fn(f32, &mut Canvas, usize) -> anyhow::Result<()>; 7 + dyn Fn(f32, &mut Canvas, usize) -> anyhow::Result<()> + Send + Sync; 8 8 9 9 /// An animation that only manipulates a single layer. The layer's render cache is automatically flushed at the end. See `AnimationUpdateFunction` for more information. 10 10 pub type LayerAnimationUpdateFunction = 11 - dyn Fn(f32, &mut Layer, usize) -> anyhow::Result<()>; 11 + dyn Fn(f32, &mut Layer, usize) -> anyhow::Result<()> + Send + Sync; 12 12 13 13 pub struct Animation { 14 14 pub name: String,
+1 -1
src/video/context.rs
··· 1 1 use super::animation::{AnimationUpdateFunction, LayerAnimationUpdateFunction}; 2 - use super::engine::{LaterHook, LaterRenderFunction}; 2 + use super::hooks::{LaterHook, LaterRenderFunction}; 3 3 use super::Animation; 4 4 use crate::synchronization::audio::{Note, StemAtInstant}; 5 5 use crate::synchronization::sync::SyncData;
+6 -125
src/video/encoding.rs
··· 1 - use super::{context::Context, engine::milliseconds_to_timestamp, Video}; 2 - use crate::rendering::stringify_svg; 3 - use crate::{Canvas, SVGRenderable}; 1 + use super::{hooks::milliseconds_to_timestamp, Video}; 2 + use crate::Canvas; 4 3 use anyhow::Result; 5 - use indicatif::ProgressIterator; 6 4 use measure_time::debug_time; 7 5 use std::fs::File; 8 - use std::io::{Read, Write}; 9 - use std::sync::mpsc::{Sender, SyncSender}; 6 + use std::io::Write; 10 7 use std::thread; 11 8 use std::time::Duration; 12 9 use std::{fs::create_dir_all, path::PathBuf}; ··· 53 50 .spawn()?) 54 51 } 55 52 56 - pub fn render_frames( 57 - &self, 58 - output: SyncSender<(Duration, String)>, 59 - ) -> Result<usize> { 60 - debug_time!("render_frames"); 61 - let mut written_frames_count: usize = 0; 62 - let mut context = Context { 63 - frame: 0, 64 - beat: 0, 65 - beat_fractional: 0.0, 66 - timestamp: "00:00:00.000".to_string(), 67 - ms: 0, 68 - bpm: self.syncdata.bpm, 69 - syncdata: &self.syncdata, 70 - extra: AdditionalContext::default(), 71 - later_hooks: vec![], 72 - audiofile: self.audiofile.clone(), 73 - duration_override: self.duration_override, 74 - }; 75 - 76 - let mut canvas = self.initial_canvas.clone(); 77 - 78 - let mut previous_rendered_beat = 0; 79 - let mut previous_rendered_frame = 0; 80 - 81 - let render_ms_range = self.start_rendering_at + 0..self.duration_ms(); 82 - 83 - self.progress_bar.set_length(render_ms_range.len() as u64); 84 - 85 - for _ in render_ms_range { 86 - context.ms += 1_usize; 87 - context.timestamp = milliseconds_to_timestamp(context.ms).to_string(); 88 - context.beat_fractional = 89 - (context.bpm * context.ms) as f32 / (1000.0 * 60.0); 90 - context.beat = context.beat_fractional as usize; 91 - context.frame = self.fps * context.ms / 1000; 92 - 93 - if context.marker() != "" { 94 - self.progress_bar.println(format!( 95 - "{}: marker {}", 96 - context.timestamp, 97 - context.marker() 98 - )); 99 - } 100 - 101 - if context.marker().starts_with(':') { 102 - let marker_text = context.marker(); 103 - let commandline = marker_text.trim_start_matches(':').to_string(); 104 - 105 - for command in &self.commands { 106 - if commandline.starts_with(&command.name) { 107 - let args = commandline 108 - .trim_start_matches(&command.name) 109 - .trim() 110 - .to_string(); 111 - (command.action)(args, &mut canvas, &mut context)?; 112 - } 113 - } 114 - } 115 - 116 - // Render later hooks first, so that for example animations that aren't finished yet get overwritten by next frame's hook, if the next frames touches the same object 117 - // This is way better to cancel early animations such as fading out an object that appears on every note of a stem, if the next note is too close for the fade-out to finish. 118 - 119 - let mut later_hooks_to_delete: Vec<usize> = vec![]; 120 - 121 - for (i, hook) in context.later_hooks.iter().enumerate() { 122 - if (hook.when)(&canvas, &context, previous_rendered_beat) { 123 - (hook.render_function)(&mut canvas, context.ms)?; 124 - if hook.once { 125 - later_hooks_to_delete.push(i); 126 - } 127 - } else if !hook.once { 128 - later_hooks_to_delete.push(i); 129 - } 130 - } 131 - 132 - for i in later_hooks_to_delete { 133 - if i < context.later_hooks.len() { 134 - context.later_hooks.remove(i); 135 - } 136 - } 137 - 138 - for hook in &self.hooks { 139 - if (hook.when)( 140 - &canvas, 141 - &context, 142 - previous_rendered_beat, 143 - previous_rendered_frame, 144 - ) { 145 - (hook.render_function)(&mut canvas, &mut context)?; 146 - } 147 - } 148 - 149 - if context.frame != previous_rendered_frame { 150 - output.send(( 151 - Duration::from_millis(context.ms as _), 152 - stringify_svg(canvas.render_to_svg( 153 - canvas.colormap.clone(), 154 - canvas.cell_size, 155 - canvas.object_sizes, 156 - "", 157 - )?), 158 - ))?; 159 - 160 - written_frames_count += 1; 161 - 162 - previous_rendered_beat = context.beat; 163 - previous_rendered_frame = context.frame; 164 - } 165 - } 166 - 167 - output.send((Duration::from_millis(context.ms as _), "".to_string()))?; 168 - 169 - Ok(written_frames_count) 170 - } 171 - 172 - pub fn render(&mut self, output_file: impl Into<PathBuf>) -> Result<()> { 173 - debug_time!("render"); 53 + pub fn encode(&mut self, output_file: impl Into<PathBuf>) -> Result<()> { 54 + debug_time!("encode"); 174 55 175 56 let output_file: PathBuf = output_file.into(); 176 57 ··· 220 101 encoder.stdin.take().unwrap().flush().unwrap(); 221 102 }); 222 103 223 - self.render_frames(tx)?; 104 + self.render_all_frames(tx)?; 224 105 225 106 encoder_thread.join().expect("Encoder thread panicked"); 226 107
+221 -467
src/video/engine.rs
··· 1 - use super::animation::LayerAnimationUpdateFunction; 2 - use super::context::Context; 3 - use crate::synchronization::audio::MusicalDurationUnit; 4 - use crate::synchronization::midi::MidiSynchronizer; 5 - use crate::synchronization::sync::{SyncData, Syncable}; 6 - use crate::ui::{self, setup_progress_bar, Log as _}; 7 - use crate::{Canvas, ColoredObject}; 8 - use anyhow::Result; 9 - use chrono::{DateTime, NaiveDateTime}; 10 - use indicatif::ProgressBar; 11 - use measure_time::debug_time; 12 - use std::{fmt::Formatter, panic, path::PathBuf}; 13 - 14 - pub type BeatNumber = usize; 15 - pub type FrameNumber = usize; 16 - pub type Millisecond = usize; 17 - 18 - pub type RenderFunction<C> = 19 - dyn Fn(&mut Canvas, &mut Context<C>) -> anyhow::Result<()>; 20 - 21 - pub type CommandAction<C> = 22 - dyn Fn(String, &mut Canvas, &mut Context<C>) -> anyhow::Result<()>; 23 - 24 - /// Arguments: canvas, context, previous rendered beat, previous rendered frame 25 - pub type HookCondition<C> = 26 - dyn Fn(&Canvas, &Context<C>, BeatNumber, FrameNumber) -> bool; 27 - 28 - /// Arguments: canvas, context, current milliseconds timestamp 29 - pub type LaterRenderFunction = 30 - dyn Fn(&mut Canvas, Millisecond) -> anyhow::Result<()>; 31 - 32 - /// Arguments: canvas, context, previous rendered beat 33 - pub type LaterHookCondition<C> = dyn Fn(&Canvas, &Context<C>, BeatNumber) -> bool; 34 - 35 - pub struct Video<C> { 36 - pub fps: usize, 37 - pub initial_canvas: Canvas, 38 - pub hooks: Vec<Hook<C>>, 39 - pub commands: Vec<Box<Command<C>>>, 40 - pub frames: Vec<Canvas>, 41 - pub frames_output_directory: &'static str, 42 - pub syncdata: SyncData, 43 - pub audiofile: PathBuf, 44 - pub resolution: u32, 45 - pub duration_override: Option<usize>, 46 - pub start_rendering_at: usize, 47 - pub progress_bar: indicatif::ProgressBar, 48 - } 49 - 50 - pub struct Hook<C> { 51 - pub when: Box<HookCondition<C>>, 52 - pub render_function: Box<RenderFunction<C>>, 53 - } 54 - 55 - pub struct LaterHook<C> { 56 - pub when: Box<LaterHookCondition<C>>, 57 - pub render_function: Box<LaterRenderFunction>, 58 - /// Whether the hook should be run only once 59 - pub once: bool, 60 - } 61 - 62 - impl<C> std::fmt::Debug for Hook<C> { 63 - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 64 - f.debug_struct("Hook") 65 - .field("when", &"Box<HookCondition>") 66 - .field("render_function", &"Box<RenderFunction>") 67 - .finish() 68 - } 69 - } 70 - 71 - pub struct Command<C> { 72 - pub name: String, 73 - pub action: Box<CommandAction<C>>, 74 - } 75 - 76 - impl<C> std::fmt::Debug for Command<C> { 77 - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 78 - f.debug_struct("Command") 79 - .field("name", &self.name) 80 - .field("action", &"Box<CommandAction>") 81 - .finish() 82 - } 83 - } 84 - 85 - impl<AdditionalContext: Default> Default for Video<AdditionalContext> { 86 - fn default() -> Self { 87 - Self::new(Canvas::new(vec!["root"])) 88 - } 89 - } 90 - 91 - impl<AdditionalContext: Default> Video<AdditionalContext> { 92 - pub fn new(canvas: Canvas) -> Self { 93 - Self { 94 - fps: 30, 95 - initial_canvas: canvas, 96 - hooks: vec![], 97 - commands: vec![], 98 - frames: vec![], 99 - frames_output_directory: "frames/", 100 - resolution: 1920, 101 - syncdata: SyncData::default(), 102 - audiofile: PathBuf::new(), 103 - duration_override: None, 104 - start_rendering_at: 0, 105 - progress_bar: setup_progress_bar(0, ""), 106 - } 107 - } 108 - 109 - pub fn sync_audio_with(self, sync_data_path: &str) -> Self { 110 - debug_time!("sync_audio_with"); 111 - if sync_data_path.ends_with(".mid") || sync_data_path.ends_with(".midi") { 112 - let loader = MidiSynchronizer::new(sync_data_path); 113 - let syncdata = loader.load(Some(&self.progress_bar)); 114 - self.progress_bar.finish(); 115 - self.progress_bar.log( 116 - "Loaded", 117 - &format!( 118 - "{} notes from {sync_data_path}", 119 - syncdata 120 - .stems 121 - .values() 122 - .map(|v| v.notes.len()) 123 - .sum::<usize>(), 124 - ), 125 - ); 126 - return Self { syncdata, ..self }; 127 - } 128 - 129 - panic!("Unsupported sync data format"); 130 - } 131 - 132 - pub fn with_hook(self, hook: Hook<AdditionalContext>) -> Self { 133 - let mut hooks = self.hooks; 134 - hooks.push(hook); 135 - Self { hooks, ..self } 136 - } 137 - 138 - pub fn init( 139 - self, 140 - render_function: &'static RenderFunction<AdditionalContext>, 141 - ) -> Self { 142 - self.with_hook(Hook { 143 - when: Box::new(move |_, context, _, _| context.frame == 0), 144 - render_function: Box::new(render_function), 145 - }) 146 - } 147 - 148 - // TODO The &'static requirement might be possibly liftable, see https://users.rust-lang.org/t/how-to-store-functions-in-structs/58089 149 - pub fn on( 150 - self, 151 - marker_text: &'static str, 152 - render_function: &'static RenderFunction<AdditionalContext>, 153 - ) -> Self { 154 - self.with_hook(Hook { 155 - when: Box::new(move |_, context, _, _| { 156 - context.marker() == marker_text 157 - }), 158 - render_function: Box::new(render_function), 159 - }) 160 - } 161 - 162 - pub fn each_beat( 163 - self, 164 - render_function: &'static RenderFunction<AdditionalContext>, 165 - ) -> Self { 166 - self.with_hook(Hook { 167 - when: Box::new( 168 - move |_, 169 - context, 170 - previous_rendered_beat, 171 - previous_rendered_frame| { 172 - previous_rendered_frame != context.frame 173 - && (context.ms == 0 174 - || previous_rendered_beat != context.beat) 175 - }, 176 - ), 177 - render_function: Box::new(render_function), 178 - }) 179 - } 180 - 181 - pub fn every( 182 - self, 183 - amount: f32, 184 - unit: MusicalDurationUnit, 185 - render_function: &'static RenderFunction<AdditionalContext>, 186 - ) -> Self { 187 - let beats = match unit { 188 - MusicalDurationUnit::Beats => amount, 189 - MusicalDurationUnit::Halfs => amount / 2.0, 190 - MusicalDurationUnit::Quarters => amount / 4.0, 191 - MusicalDurationUnit::Eighths => amount / 8.0, 192 - MusicalDurationUnit::Sixteenths => amount / 16.0, 193 - MusicalDurationUnit::Thirds => amount / 3.0, 194 - }; 195 - 196 - self.with_hook(Hook { 197 - when: Box::new(move |_, context, _, _| { 198 - context.beat_fractional % beats < 0.01 199 - }), 200 - render_function: Box::new(render_function), 201 - }) 202 - } 203 - 204 - pub fn each_frame( 205 - self, 206 - render_function: &'static RenderFunction<AdditionalContext>, 207 - ) -> Self { 208 - self.each_n_frame(1, render_function) 209 - } 210 - 211 - pub fn each_n_frame( 212 - self, 213 - n: usize, 214 - render_function: &'static RenderFunction<AdditionalContext>, 215 - ) -> Self { 216 - self.with_hook(Hook { 217 - when: Box::new(move |_, context, _, previous_rendered_frame| { 218 - context.frame != previous_rendered_frame && context.frame % n == 0 219 - }), 220 - render_function: Box::new(render_function), 221 - }) 222 - } 223 - 224 - /// threshold is a value between 0 and 1: current amplitude / max amplitude of stem 225 - pub fn on_stem( 226 - self, 227 - stem_name: &'static str, 228 - threshold: f32, 229 - above_amplitude: &'static RenderFunction<AdditionalContext>, 230 - below_amplitude: &'static RenderFunction<AdditionalContext>, 231 - ) -> Self { 232 - self.with_hook(Hook { 233 - when: Box::new(move |_, context, _, _| { 234 - context.stem(stem_name).amplitude_relative() > threshold 235 - }), 236 - render_function: Box::new(above_amplitude), 237 - }) 238 - .with_hook(Hook { 239 - when: Box::new(move |_, context, _, _| { 240 - context.stem(stem_name).amplitude_relative() <= threshold 241 - }), 242 - render_function: Box::new(below_amplitude), 243 - }) 244 - } 245 - 246 - /// Triggers when a note starts on one of the stems in the comma-separated list of stem names `stems`. 247 - pub fn on_note( 248 - self, 249 - stems: &'static str, 250 - render_function: &'static RenderFunction<AdditionalContext>, 251 - ) -> Self { 252 - self.with_hook(Hook { 253 - when: Box::new(move |_, ctx, _, _| { 254 - stems 255 - .split(',') 256 - .map(|stem_name| ctx.stem(stem_name.trim())) 257 - .any(|stem| stem.notes.iter().any(|note| note.is_on())) 258 - }), 259 - render_function: Box::new(render_function), 260 - }) 261 - } 262 - 263 - /// Triggers when a note stops on one of the stems in the comma-separated list of stem names `stems`. 264 - pub fn on_note_end( 265 - self, 266 - stems: &'static str, 267 - render_function: &'static RenderFunction<AdditionalContext>, 268 - ) -> Self { 269 - self.with_hook(Hook { 270 - when: Box::new(move |_, ctx, _, _| { 271 - stems 272 - .split(',') 273 - .map(|n| ctx.stem(n.trim())) 274 - .any(|stem| stem.notes.iter().any(|note| note.is_off())) 275 - }), 276 - render_function: Box::new(render_function), 277 - }) 278 - } 279 - 280 - // Adds an object using object_creation on note start and removes it on note end 281 - pub fn with_note( 282 - self, 283 - stems: &'static str, 284 - cutoff_amplitude: f32, 285 - layer_name: &'static str, 286 - object_name: &'static str, 287 - create_object: &'static dyn Fn( 288 - &Canvas, 289 - &mut Context<AdditionalContext>, 290 - ) -> Result<ColoredObject>, 291 - ) -> Self { 292 - self.with_hook(Hook { 293 - when: Box::new(move |_, ctx, _, _| { 294 - stems.split(',').any(|stem_name| { 295 - ctx.stem(stem_name).notes.iter().any(|note| note.is_on()) 296 - }) 297 - }), 298 - render_function: Box::new(move |canvas, ctx| { 299 - let object = create_object(canvas, ctx)?; 300 - canvas.layer(layer_name).set(object_name, object); 301 - Ok(()) 302 - }), 303 - }) 304 - .with_hook(Hook { 305 - when: Box::new(move |_, ctx, _, _| { 306 - stems.split(',').any(|stem_name| { 307 - ctx.stem(stem_name).amplitude_relative() < cutoff_amplitude 308 - || ctx 309 - .stem(stem_name) 310 - .notes 311 - .iter() 312 - .any(|note| note.is_off()) 313 - }) 314 - }), 315 - render_function: Box::new(move |canvas, _| { 316 - canvas.remove_object(object_name); 317 - Ok(()) 318 - }), 319 - }) 320 - } 321 - 322 - pub fn at_frame( 323 - self, 324 - frame: usize, 325 - render_function: &'static RenderFunction<AdditionalContext>, 326 - ) -> Self { 327 - self.with_hook(Hook { 328 - when: Box::new(move |_, context, _, _| context.frame == frame), 329 - render_function: Box::new(render_function), 330 - }) 331 - } 332 - 333 - pub fn when_remaining( 334 - self, 335 - seconds: usize, 336 - render_function: &'static RenderFunction<AdditionalContext>, 337 - ) -> Self { 338 - self.with_hook(Hook { 339 - when: Box::new(move |_, ctx, _, _| { 340 - ctx.ms >= ctx.duration_ms().max(seconds * 1000) - seconds * 1000 341 - }), 342 - render_function: Box::new(render_function), 343 - }) 344 - } 345 - 346 - pub fn at_timestamp( 347 - self, 348 - timestamp: &'static str, 349 - render_function: &'static RenderFunction<AdditionalContext>, 350 - ) -> Self { 351 - let hook = Hook { 352 - when: Box::new(move |_, context, _, previous_rendered_frame| { 353 - if previous_rendered_frame == context.frame { 354 - return false; 355 - } 356 - let (precision, criteria_time): (&str, NaiveDateTime) = 357 - if let Ok(criteria_time_parsed) = 358 - NaiveDateTime::parse_from_str(timestamp, "%H:%M:%S%.3f") 359 - { 360 - ("milliseconds", criteria_time_parsed) 361 - } else if let Ok(criteria_time_parsed) = 362 - NaiveDateTime::parse_from_str(timestamp, "%M:%S%.3f") 363 - { 364 - ("milliseconds", criteria_time_parsed) 365 - } else if let Ok(criteria_time_parsed) = 366 - NaiveDateTime::parse_from_str(timestamp, "%S%.3f") 367 - { 368 - ("milliseconds", criteria_time_parsed) 369 - } else if let Ok(criteria_time_parsed) = 370 - NaiveDateTime::parse_from_str(timestamp, "%S") 371 - { 372 - ("seconds", criteria_time_parsed) 373 - } else if let Ok(criteria_time_parsed) = 374 - NaiveDateTime::parse_from_str(timestamp, "%M:%S") 375 - { 376 - ("seconds", criteria_time_parsed) 377 - } else if let Ok(criteria_time_parsed) = 378 - NaiveDateTime::parse_from_str(timestamp, "%H:%M:%S") 379 - { 380 - ("seconds", criteria_time_parsed) 381 - } else { 382 - panic!("Unhandled timestamp format: {}", timestamp); 383 - }; 384 - match precision { 385 - "milliseconds" => { 386 - let current_time: NaiveDateTime = 387 - NaiveDateTime::parse_from_str( 388 - timestamp, 389 - "%H:%M:%S%.3f", 390 - ) 391 - .unwrap(); 392 - current_time == criteria_time 393 - } 394 - "seconds" => { 395 - let current_time: NaiveDateTime = 396 - NaiveDateTime::parse_from_str(timestamp, "%H:%M:%S") 397 - .unwrap(); 398 - current_time == criteria_time 399 - } 400 - _ => panic!("Unknown precision"), 401 - } 402 - }), 403 - render_function: Box::new(render_function), 404 - }; 405 - self.with_hook(hook) 406 - } 407 - 408 - pub fn command( 409 - self, 410 - command_name: &'static str, 411 - action: &'static CommandAction<AdditionalContext>, 412 - ) -> Self { 413 - let mut commands = self.commands; 414 - commands.push(Box::new(Command { 415 - name: command_name.to_string(), 416 - action: Box::new(action), 417 - })); 418 - Self { commands, ..self } 419 - } 420 - 421 - pub fn bind_amplitude( 422 - self, 423 - layer: &'static str, 424 - stem: &'static str, 425 - update: &'static LayerAnimationUpdateFunction, 426 - ) -> Self { 427 - self.with_hook(Hook { 428 - when: Box::new(move |_, _, _, _| true), 429 - render_function: Box::new(move |canvas, context| { 430 - let amplitude = context.stem(stem).amplitude_relative(); 431 - update(amplitude, canvas.layer(layer), context.ms)?; 432 - canvas.layer(layer).flush(); 433 - Ok(()) 434 - }), 435 - }) 436 - } 437 - 438 - pub fn total_frames(&self) -> usize { 439 - self.fps * (self.duration_ms() + self.start_rendering_at) / 1000 440 - } 441 - 442 - pub fn duration_ms(&self) -> usize { 443 - if let Some(duration_override) = self.duration_override { 444 - return duration_override; 445 - } 446 - 447 - self.syncdata 448 - .stems 449 - .values() 450 - .map(|stem| stem.duration_ms) 451 - .max() 452 - .expect("No audio sync data provided. Use .sync_audio_with() to load a MIDI file, or provide a duration override.") 453 - } 454 - 455 - pub fn setup_progress_bar(&self) -> ProgressBar { 456 - ui::setup_progress_bar(self.total_frames() as u64, "Rendering") 457 - } 458 - } 459 - 460 - pub fn milliseconds_to_timestamp(ms: usize) -> String { 461 - format!( 462 - "{}", 463 - DateTime::from_timestamp_millis(ms as i64) 464 - .unwrap() 465 - .format("%H:%M:%S%.3f") 466 - ) 467 - } 1 + use super::{context::Context, hooks::milliseconds_to_timestamp, Video}; 2 + use crate::rendering::stringify_svg; 3 + use crate::SVGRenderable; 4 + use anyhow::Result; 5 + use measure_time::debug_time; 6 + use std::sync::mpsc::SyncSender; 7 + use std::time::Duration; 8 + 9 + impl<AdditionalContext: Default> Video<AdditionalContext> { 10 + pub fn render( 11 + &self, 12 + output: SyncSender<(Duration, String)>, 13 + controller: impl Fn(usize) -> EngineControl, 14 + ) -> Result<usize> { 15 + debug_time!("render"); 16 + 17 + let mut rendered_frames_count: usize = 0; 18 + let mut context = Context { 19 + frame: 0, 20 + beat: 0, 21 + beat_fractional: 0.0, 22 + timestamp: "00:00:00.000".to_string(), 23 + ms: 0, 24 + bpm: self.syncdata.bpm, 25 + syncdata: &self.syncdata, 26 + extra: AdditionalContext::default(), 27 + later_hooks: vec![], 28 + audiofile: self.audiofile.clone(), 29 + duration_override: self.duration_override, 30 + }; 31 + 32 + let mut canvas = self.initial_canvas.clone(); 33 + 34 + let mut previous_rendered_beat = 0; 35 + let mut previous_rendered_frame = 0; 36 + 37 + let render_ms_range = self.start_rendering_at + 0..self.duration_ms(); 38 + 39 + self.progress_bar.set_length(render_ms_range.len() as u64); 40 + 41 + for _ in render_ms_range { 42 + context.ms += 1_usize; 43 + context.timestamp = milliseconds_to_timestamp(context.ms).to_string(); 44 + context.beat_fractional = 45 + (context.bpm * context.ms) as f32 / (1000.0 * 60.0); 46 + context.beat = context.beat_fractional as usize; 47 + context.frame = self.fps * context.ms / 1000; 48 + 49 + let control = controller(context.frame); 50 + 51 + if control.stop_rendering_beforehand() { 52 + println!( 53 + "Stopping rendering as requested before frame {}", 54 + context.frame 55 + ); 56 + break; 57 + } 58 + 59 + if context.marker() != "" { 60 + self.progress_bar.println(format!( 61 + "{}: marker {}", 62 + context.timestamp, 63 + context.marker() 64 + )); 65 + } 66 + 67 + if context.marker().starts_with(':') { 68 + let marker_text = context.marker(); 69 + let commandline = marker_text.trim_start_matches(':').to_string(); 70 + 71 + for command in &self.commands { 72 + if commandline.starts_with(&command.name) { 73 + let args = commandline 74 + .trim_start_matches(&command.name) 75 + .trim() 76 + .to_string(); 77 + (command.action)(args, &mut canvas, &mut context)?; 78 + } 79 + } 80 + } 81 + 82 + // Render later hooks first, so that for example animations that aren't finished yet get overwritten by next frame's hook, if the next frames touches the same object 83 + // This is way better to cancel early animations such as fading out an object that appears on every note of a stem, if the next note is too close for the fade-out to finish. 84 + 85 + let mut later_hooks_to_delete: Vec<usize> = vec![]; 86 + 87 + for (i, hook) in context.later_hooks.iter().enumerate() { 88 + if (hook.when)(&canvas, &context, previous_rendered_beat) { 89 + (hook.render_function)(&mut canvas, context.ms)?; 90 + if hook.once { 91 + later_hooks_to_delete.push(i); 92 + } 93 + } else if !hook.once { 94 + later_hooks_to_delete.push(i); 95 + } 96 + } 97 + 98 + for i in later_hooks_to_delete { 99 + if i < context.later_hooks.len() { 100 + context.later_hooks.remove(i); 101 + } 102 + } 103 + 104 + for hook in &self.hooks { 105 + if (hook.when)( 106 + &canvas, 107 + &context, 108 + previous_rendered_beat, 109 + previous_rendered_frame, 110 + ) { 111 + (hook.render_function)(&mut canvas, &mut context)?; 112 + } 113 + } 114 + 115 + if control.render_this_one() 116 + && context.frame != previous_rendered_frame 117 + { 118 + output.send(( 119 + Duration::from_millis(context.ms as _), 120 + stringify_svg(canvas.render_to_svg( 121 + canvas.colormap.clone(), 122 + canvas.cell_size, 123 + canvas.object_sizes, 124 + "", 125 + )?), 126 + ))?; 127 + 128 + rendered_frames_count += 1; 129 + 130 + previous_rendered_beat = context.beat; 131 + previous_rendered_frame = context.frame; 132 + } 133 + 134 + if control.stop_rendering_afterwards() { 135 + println!( 136 + "Stopping rendering as requested after frame {}", 137 + context.frame 138 + ); 139 + break; 140 + } 141 + } 142 + 143 + output.send((Duration::from_millis(context.ms as _), "".to_string()))?; 144 + 145 + println!("Rendered {rendered_frames_count} frames"); 146 + Ok(rendered_frames_count) 147 + } 148 + 149 + pub fn render_single_frame( 150 + &self, 151 + frame_no: usize, 152 + ) -> Result<(Duration, String)> { 153 + let (tx, rx) = std::sync::mpsc::sync_channel::<(Duration, String)>(2); 154 + 155 + self.render(tx, |n| { 156 + if n == frame_no { 157 + println!("Rendering frame #{n}"); 158 + EngineControl::Finish 159 + } else if n < frame_no { 160 + EngineControl::Skip 161 + } else { 162 + EngineControl::Stop 163 + } 164 + })?; 165 + 166 + println!("Waiting for rendered frame..."); 167 + for (timecode, svg) in rx.iter() { 168 + if svg.is_empty() { 169 + continue; 170 + } 171 + 172 + return Ok((timecode, svg)); 173 + } 174 + 175 + return Err(anyhow::format_err!( 176 + "Renderer did not output any non-empty frames" 177 + )); 178 + } 179 + 180 + pub fn render_all_frames( 181 + &self, 182 + output: SyncSender<(Duration, String)>, 183 + ) -> Result<usize> { 184 + self.render(output, |_| EngineControl::Render) 185 + } 186 + } 187 + 188 + /// Tells the rendering engine what to do with a frame 189 + pub enum EngineControl { 190 + /// Skip to the next frame, don't render this one 191 + Skip, 192 + /// Render this frame as usual 193 + Render, 194 + /// Render this frame and stop rendering afterwards 195 + Finish, 196 + /// Don't render this frame and stop rendering 197 + Stop, 198 + } 199 + 200 + impl EngineControl { 201 + pub fn render_this_one(&self) -> bool { 202 + match self { 203 + EngineControl::Render | EngineControl::Finish => true, 204 + EngineControl::Skip | EngineControl::Stop => false, 205 + } 206 + } 207 + 208 + pub fn stop_rendering_beforehand(&self) -> bool { 209 + match self { 210 + EngineControl::Stop => true, 211 + _ => false, 212 + } 213 + } 214 + 215 + pub fn stop_rendering_afterwards(&self) -> bool { 216 + match self { 217 + EngineControl::Finish => true, 218 + _ => false, 219 + } 220 + } 221 + }
+471
src/video/hooks.rs
··· 1 + use super::animation::LayerAnimationUpdateFunction; 2 + use super::context::Context; 3 + use crate::synchronization::audio::MusicalDurationUnit; 4 + use crate::synchronization::midi::MidiSynchronizer; 5 + use crate::synchronization::sync::{SyncData, Syncable}; 6 + use crate::ui::{self, setup_progress_bar, Log as _}; 7 + use crate::{Canvas, ColoredObject, Object}; 8 + use anyhow::Result; 9 + use chrono::{DateTime, NaiveDateTime}; 10 + use indicatif::ProgressBar; 11 + use measure_time::debug_time; 12 + use std::{fmt::Formatter, panic, path::PathBuf}; 13 + 14 + pub type BeatNumber = usize; 15 + pub type FrameNumber = usize; 16 + pub type Millisecond = usize; 17 + 18 + pub type RenderFunction<C> = 19 + dyn Fn(&mut Canvas, &mut Context<C>) -> anyhow::Result<()> + Send + Sync; 20 + 21 + pub type CommandAction<C> = dyn Fn(String, &mut Canvas, &mut Context<C>) -> anyhow::Result<()> 22 + + Send 23 + + Sync; 24 + 25 + /// Arguments: canvas, context, previous rendered beat, previous rendered frame 26 + pub type HookCondition<C> = 27 + dyn Fn(&Canvas, &Context<C>, BeatNumber, FrameNumber) -> bool + Send + Sync; 28 + 29 + /// Arguments: canvas, context, current milliseconds timestamp 30 + pub type LaterRenderFunction = 31 + dyn Fn(&mut Canvas, Millisecond) -> anyhow::Result<()> + Send + Sync; 32 + 33 + /// Arguments: canvas, context, previous rendered beat 34 + pub type LaterHookCondition<C> = 35 + dyn Fn(&Canvas, &Context<C>, BeatNumber) -> bool + Send + Sync; 36 + 37 + pub struct Video<C> { 38 + pub fps: usize, 39 + pub initial_canvas: Canvas, 40 + pub hooks: Vec<Hook<C>>, 41 + pub commands: Vec<Box<Command<C>>>, 42 + pub frames: Vec<Canvas>, 43 + pub frames_output_directory: &'static str, 44 + pub syncdata: SyncData, 45 + pub audiofile: PathBuf, 46 + pub resolution: u32, 47 + pub duration_override: Option<usize>, 48 + pub start_rendering_at: usize, 49 + pub progress_bar: indicatif::ProgressBar, 50 + } 51 + 52 + pub struct Hook<C> { 53 + pub when: Box<HookCondition<C>>, 54 + pub render_function: Box<RenderFunction<C>>, 55 + } 56 + 57 + pub struct LaterHook<C> { 58 + pub when: Box<LaterHookCondition<C>>, 59 + pub render_function: Box<LaterRenderFunction>, 60 + /// Whether the hook should be run only once 61 + pub once: bool, 62 + } 63 + 64 + impl<C> std::fmt::Debug for Hook<C> { 65 + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 66 + f.debug_struct("Hook") 67 + .field("when", &"Box<HookCondition>") 68 + .field("render_function", &"Box<RenderFunction>") 69 + .finish() 70 + } 71 + } 72 + 73 + pub struct Command<C> { 74 + pub name: String, 75 + pub action: Box<CommandAction<C>>, 76 + } 77 + 78 + impl<C> std::fmt::Debug for Command<C> { 79 + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 80 + f.debug_struct("Command") 81 + .field("name", &self.name) 82 + .field("action", &"Box<CommandAction>") 83 + .finish() 84 + } 85 + } 86 + 87 + impl<AdditionalContext: Default> Default for Video<AdditionalContext> { 88 + fn default() -> Self { 89 + Self::new(Canvas::new(vec!["root"])) 90 + } 91 + } 92 + 93 + impl<AdditionalContext: Default> Video<AdditionalContext> { 94 + pub fn new(canvas: Canvas) -> Self { 95 + Self { 96 + fps: 30, 97 + initial_canvas: canvas, 98 + hooks: vec![], 99 + commands: vec![], 100 + frames: vec![], 101 + frames_output_directory: "frames/", 102 + resolution: 1920, 103 + syncdata: SyncData::default(), 104 + audiofile: PathBuf::new(), 105 + duration_override: None, 106 + start_rendering_at: 0, 107 + progress_bar: setup_progress_bar(0, ""), 108 + } 109 + } 110 + 111 + pub fn sync_audio_with(self, sync_data_path: &str) -> Self { 112 + debug_time!("sync_audio_with"); 113 + if sync_data_path.ends_with(".mid") || sync_data_path.ends_with(".midi") { 114 + let loader = MidiSynchronizer::new(sync_data_path); 115 + let syncdata = loader.load(Some(&self.progress_bar)); 116 + self.progress_bar.finish(); 117 + self.progress_bar.log( 118 + "Loaded", 119 + &format!( 120 + "{} notes from {sync_data_path}", 121 + syncdata 122 + .stems 123 + .values() 124 + .map(|v| v.notes.len()) 125 + .sum::<usize>(), 126 + ), 127 + ); 128 + return Self { syncdata, ..self }; 129 + } 130 + 131 + panic!("Unsupported sync data format"); 132 + } 133 + 134 + pub fn with_hook(self, hook: Hook<AdditionalContext>) -> Self { 135 + let mut hooks = self.hooks; 136 + hooks.push(hook); 137 + Self { hooks, ..self } 138 + } 139 + 140 + pub fn init( 141 + self, 142 + render_function: &'static RenderFunction<AdditionalContext>, 143 + ) -> Self { 144 + self.with_hook(Hook { 145 + when: Box::new(move |_, context, _, _| context.frame == 0), 146 + render_function: Box::new(render_function), 147 + }) 148 + } 149 + 150 + // TODO The &'static requirement might be possibly liftable, see https://users.rust-lang.org/t/how-to-store-functions-in-structs/58089 151 + pub fn on( 152 + self, 153 + marker_text: &'static str, 154 + render_function: &'static RenderFunction<AdditionalContext>, 155 + ) -> Self { 156 + self.with_hook(Hook { 157 + when: Box::new(move |_, context, _, _| { 158 + context.marker() == marker_text 159 + }), 160 + render_function: Box::new(render_function), 161 + }) 162 + } 163 + 164 + pub fn each_beat( 165 + self, 166 + render_function: &'static RenderFunction<AdditionalContext>, 167 + ) -> Self { 168 + self.with_hook(Hook { 169 + when: Box::new( 170 + move |_, 171 + context, 172 + previous_rendered_beat, 173 + previous_rendered_frame| { 174 + previous_rendered_frame != context.frame 175 + && (context.ms == 0 176 + || previous_rendered_beat != context.beat) 177 + }, 178 + ), 179 + render_function: Box::new(render_function), 180 + }) 181 + } 182 + 183 + pub fn every( 184 + self, 185 + amount: f32, 186 + unit: MusicalDurationUnit, 187 + render_function: &'static RenderFunction<AdditionalContext>, 188 + ) -> Self { 189 + let beats = match unit { 190 + MusicalDurationUnit::Beats => amount, 191 + MusicalDurationUnit::Halfs => amount / 2.0, 192 + MusicalDurationUnit::Quarters => amount / 4.0, 193 + MusicalDurationUnit::Eighths => amount / 8.0, 194 + MusicalDurationUnit::Sixteenths => amount / 16.0, 195 + MusicalDurationUnit::Thirds => amount / 3.0, 196 + }; 197 + 198 + self.with_hook(Hook { 199 + when: Box::new(move |_, context, _, _| { 200 + context.beat_fractional % beats < 0.01 201 + }), 202 + render_function: Box::new(render_function), 203 + }) 204 + } 205 + 206 + pub fn each_frame( 207 + self, 208 + render_function: &'static RenderFunction<AdditionalContext>, 209 + ) -> Self { 210 + self.each_n_frame(1, render_function) 211 + } 212 + 213 + pub fn each_n_frame( 214 + self, 215 + n: usize, 216 + render_function: &'static RenderFunction<AdditionalContext>, 217 + ) -> Self { 218 + self.with_hook(Hook { 219 + when: Box::new(move |_, context, _, previous_rendered_frame| { 220 + context.frame != previous_rendered_frame && context.frame % n == 0 221 + }), 222 + render_function: Box::new(render_function), 223 + }) 224 + } 225 + 226 + /// threshold is a value between 0 and 1: current amplitude / max amplitude of stem 227 + pub fn on_stem( 228 + self, 229 + stem_name: &'static str, 230 + threshold: f32, 231 + above_amplitude: &'static RenderFunction<AdditionalContext>, 232 + below_amplitude: &'static RenderFunction<AdditionalContext>, 233 + ) -> Self { 234 + self.with_hook(Hook { 235 + when: Box::new(move |_, context, _, _| { 236 + context.stem(stem_name).amplitude_relative() > threshold 237 + }), 238 + render_function: Box::new(above_amplitude), 239 + }) 240 + .with_hook(Hook { 241 + when: Box::new(move |_, context, _, _| { 242 + context.stem(stem_name).amplitude_relative() <= threshold 243 + }), 244 + render_function: Box::new(below_amplitude), 245 + }) 246 + } 247 + 248 + /// Triggers when a note starts on one of the stems in the comma-separated list of stem names `stems`. 249 + pub fn on_note( 250 + self, 251 + stems: &'static str, 252 + render_function: &'static RenderFunction<AdditionalContext>, 253 + ) -> Self { 254 + self.with_hook(Hook { 255 + when: Box::new(move |_, ctx, _, _| { 256 + stems 257 + .split(',') 258 + .map(|stem_name| ctx.stem(stem_name.trim())) 259 + .any(|stem| stem.notes.iter().any(|note| note.is_on())) 260 + }), 261 + render_function: Box::new(render_function), 262 + }) 263 + } 264 + 265 + /// Triggers when a note stops on one of the stems in the comma-separated list of stem names `stems`. 266 + pub fn on_note_end( 267 + self, 268 + stems: &'static str, 269 + render_function: &'static RenderFunction<AdditionalContext>, 270 + ) -> Self { 271 + self.with_hook(Hook { 272 + when: Box::new(move |_, ctx, _, _| { 273 + stems 274 + .split(',') 275 + .map(|n| ctx.stem(n.trim())) 276 + .any(|stem| stem.notes.iter().any(|note| note.is_off())) 277 + }), 278 + render_function: Box::new(render_function), 279 + }) 280 + } 281 + 282 + // Adds an object using object_creation on note start and removes it on note end 283 + pub fn with_note<ObjectCreator>( 284 + self, 285 + stems: &'static str, 286 + cutoff_amplitude: f32, 287 + layer_name: &'static str, 288 + object_name: &'static str, 289 + create_object: &'static ObjectCreator, 290 + ) -> Self 291 + where 292 + ObjectCreator: Fn(&Canvas, &mut Context<AdditionalContext>) -> Result<ColoredObject> 293 + + Send 294 + + Sync, 295 + { 296 + self.with_hook(Hook { 297 + when: Box::new(move |_, ctx, _, _| { 298 + stems.split(',').any(|stem_name| { 299 + ctx.stem(stem_name).notes.iter().any(|note| note.is_on()) 300 + }) 301 + }), 302 + render_function: Box::new(move |canvas, ctx| { 303 + let object = create_object(canvas, ctx)?; 304 + canvas.layer(layer_name).set(object_name, object); 305 + Ok(()) 306 + }), 307 + }) 308 + .with_hook(Hook { 309 + when: Box::new(move |_, ctx, _, _| { 310 + stems.split(',').any(|stem_name| { 311 + ctx.stem(stem_name).amplitude_relative() < cutoff_amplitude 312 + || ctx 313 + .stem(stem_name) 314 + .notes 315 + .iter() 316 + .any(|note| note.is_off()) 317 + }) 318 + }), 319 + render_function: Box::new(move |canvas, _| { 320 + canvas.remove_object(object_name); 321 + Ok(()) 322 + }), 323 + }) 324 + } 325 + 326 + pub fn at_frame( 327 + self, 328 + frame: usize, 329 + render_function: &'static RenderFunction<AdditionalContext>, 330 + ) -> Self { 331 + self.with_hook(Hook { 332 + when: Box::new(move |_, context, _, _| context.frame == frame), 333 + render_function: Box::new(render_function), 334 + }) 335 + } 336 + 337 + pub fn when_remaining( 338 + self, 339 + seconds: usize, 340 + render_function: &'static RenderFunction<AdditionalContext>, 341 + ) -> Self { 342 + self.with_hook(Hook { 343 + when: Box::new(move |_, ctx, _, _| { 344 + ctx.ms >= ctx.duration_ms().max(seconds * 1000) - seconds * 1000 345 + }), 346 + render_function: Box::new(render_function), 347 + }) 348 + } 349 + 350 + pub fn at_timestamp( 351 + self, 352 + timestamp: &'static str, 353 + render_function: &'static RenderFunction<AdditionalContext>, 354 + ) -> Self { 355 + let hook = Hook { 356 + when: Box::new(move |_, context, _, previous_rendered_frame| { 357 + if previous_rendered_frame == context.frame { 358 + return false; 359 + } 360 + let (precision, criteria_time): (&str, NaiveDateTime) = 361 + if let Ok(criteria_time_parsed) = 362 + NaiveDateTime::parse_from_str(timestamp, "%H:%M:%S%.3f") 363 + { 364 + ("milliseconds", criteria_time_parsed) 365 + } else if let Ok(criteria_time_parsed) = 366 + NaiveDateTime::parse_from_str(timestamp, "%M:%S%.3f") 367 + { 368 + ("milliseconds", criteria_time_parsed) 369 + } else if let Ok(criteria_time_parsed) = 370 + NaiveDateTime::parse_from_str(timestamp, "%S%.3f") 371 + { 372 + ("milliseconds", criteria_time_parsed) 373 + } else if let Ok(criteria_time_parsed) = 374 + NaiveDateTime::parse_from_str(timestamp, "%S") 375 + { 376 + ("seconds", criteria_time_parsed) 377 + } else if let Ok(criteria_time_parsed) = 378 + NaiveDateTime::parse_from_str(timestamp, "%M:%S") 379 + { 380 + ("seconds", criteria_time_parsed) 381 + } else if let Ok(criteria_time_parsed) = 382 + NaiveDateTime::parse_from_str(timestamp, "%H:%M:%S") 383 + { 384 + ("seconds", criteria_time_parsed) 385 + } else { 386 + panic!("Unhandled timestamp format: {}", timestamp); 387 + }; 388 + match precision { 389 + "milliseconds" => { 390 + let current_time: NaiveDateTime = 391 + NaiveDateTime::parse_from_str( 392 + timestamp, 393 + "%H:%M:%S%.3f", 394 + ) 395 + .unwrap(); 396 + current_time == criteria_time 397 + } 398 + "seconds" => { 399 + let current_time: NaiveDateTime = 400 + NaiveDateTime::parse_from_str(timestamp, "%H:%M:%S") 401 + .unwrap(); 402 + current_time == criteria_time 403 + } 404 + _ => panic!("Unknown precision"), 405 + } 406 + }), 407 + render_function: Box::new(render_function), 408 + }; 409 + self.with_hook(hook) 410 + } 411 + 412 + pub fn command( 413 + self, 414 + command_name: &'static str, 415 + action: &'static CommandAction<AdditionalContext>, 416 + ) -> Self { 417 + let mut commands = self.commands; 418 + commands.push(Box::new(Command { 419 + name: command_name.to_string(), 420 + action: Box::new(action), 421 + })); 422 + Self { commands, ..self } 423 + } 424 + 425 + pub fn bind_amplitude( 426 + self, 427 + layer: &'static str, 428 + stem: &'static str, 429 + update: &'static LayerAnimationUpdateFunction, 430 + ) -> Self { 431 + self.with_hook(Hook { 432 + when: Box::new(move |_, _, _, _| true), 433 + render_function: Box::new(move |canvas, context| { 434 + let amplitude = context.stem(stem).amplitude_relative(); 435 + update(amplitude, canvas.layer(layer), context.ms)?; 436 + canvas.layer(layer).flush(); 437 + Ok(()) 438 + }), 439 + }) 440 + } 441 + 442 + pub fn total_frames(&self) -> usize { 443 + self.fps * (self.duration_ms() + self.start_rendering_at) / 1000 444 + } 445 + 446 + pub fn duration_ms(&self) -> usize { 447 + if let Some(duration_override) = self.duration_override { 448 + return duration_override; 449 + } 450 + 451 + self.syncdata 452 + .stems 453 + .values() 454 + .map(|stem| stem.duration_ms) 455 + .max() 456 + .expect("No audio sync data provided. Use .sync_audio_with() to load a MIDI file, or provide a duration override.") 457 + } 458 + 459 + pub fn setup_progress_bar(&self) -> ProgressBar { 460 + ui::setup_progress_bar(self.total_frames() as u64, "Rendering") 461 + } 462 + } 463 + 464 + pub fn milliseconds_to_timestamp(ms: usize) -> String { 465 + format!( 466 + "{}", 467 + DateTime::from_timestamp_millis(ms as i64) 468 + .unwrap() 469 + .format("%H:%M:%S%.3f") 470 + ) 471 + }
+5 -1
src/video/mod.rs
··· 1 1 pub mod animation; 2 2 pub mod context; 3 3 pub mod engine; 4 + pub mod hooks; 4 5 5 6 #[cfg(feature = "mp4")] 6 7 pub mod encoding; 7 8 9 + #[cfg(feature = "video-server")] 10 + pub mod server; 11 + 8 12 pub use animation::Animation; 9 - pub use engine::Video; 13 + pub use hooks::Video;
+119
src/video/preview.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Shapemaker preview</title> 7 + <script type="importmap"> 8 + { 9 + "imports": { 10 + "debounce": "https://unpkg.com/throttle-debounce@5.0.2/esm/index.js" 11 + } 12 + } 13 + </script> 14 + </head> 15 + <body> 16 + <div id="frame_monitor"></div> 17 + <div class="controls"> 18 + <button style="font-family: monospace" id="play_pause">|&gt;</button> 19 + <input type="range" value="0" id="requested_frame" min="1" max="300" /> 20 + <code id="requested_frame_number"></code> 21 + </div> 22 + <script type="module"> 23 + import { debounce } from "debounce" 24 + 25 + const FPS = 26 + parseInt(new URLSearchParams(window.location.search).get("fps")) || 30 27 + const cache = new Map() 28 + 29 + let playLoop 30 + play_pause.onclick = () => { 31 + if (playLoop) { 32 + clearInterval(playLoop) 33 + playLoop = null 34 + play_pause.innerText = "|>" 35 + } else { 36 + play_pause.innerText = "||" 37 + playLoop = setInterval(() => { 38 + requested_frame.value = requested_frame.valueAsNumber + 1 39 + render(requested_frame.valueAsNumber) 40 + }, 1000 / FPS) 41 + } 42 + } 43 + 44 + requested_frame.oninput = debounce(10, ({ target }) => { 45 + render(target.valueAsNumber) 46 + }) 47 + 48 + function render(frameNo) { 49 + requested_frame_number.innerText = `(${frameNo})` 50 + 51 + if (cache.has(frameNo)) { 52 + requested_frame_number.innerText = frameNo 53 + requested_frame_number.style.color = "magenta" 54 + frame_monitor.innerHTML = cache.get(frameNo) 55 + frame_monitor.style.color = "initial" 56 + frame_monitor.style.opacity = "1" 57 + return 58 + } 59 + 60 + frame_monitor.style.opacity = "0.5" 61 + 62 + const start = performance.now() 63 + 64 + fetch(`/frame/${frameNo}.svg`) 65 + .then((response) => 66 + response.text().then((text) => ({ 67 + renderTime: Math.round(performance.now() - start), 68 + ok: response.ok, 69 + text, 70 + })) 71 + ) 72 + .then(({ ok, text, renderTime }) => { 73 + if (ok) cache.set(frameNo, text) 74 + 75 + if (frameNo !== requested_frame.valueAsNumber) return 76 + 77 + requested_frame_number.innerText = `${frameNo} (in ${renderTime}ms)` 78 + requested_frame_number.style.color = "initial" 79 + frame_monitor.style.opacity = "1" 80 + 81 + if (ok) { 82 + frame_monitor.innerHTML = text 83 + frame_monitor.style.color = "initial" 84 + } else { 85 + frame_monitor.innerText = text 86 + frame_monitor.style.color = "red" 87 + } 88 + }) 89 + } 90 + </script> 91 + <style> 92 + #frame_monitor { 93 + width: 100vw; 94 + height: calc(9 / 16 * 100vw); 95 + 96 + svg { 97 + width: 100%; 98 + height: 100%; 99 + } 100 + } 101 + 102 + #requested_frame_number { 103 + width: 20ch; 104 + } 105 + 106 + .controls { 107 + display: flex; 108 + align-items: center; 109 + justify-content: center; 110 + gap: 1em; 111 + margin-top: 3rem; 112 + } 113 + 114 + body { 115 + margin: 0; 116 + } 117 + </style> 118 + </body> 119 + </html>
+55
src/video/server.rs
··· 1 + use crate::Video; 2 + use axum::{extract::Path, response::Html, routing, Router}; 3 + use std::sync::Arc; 4 + 5 + pub struct VideoServer { 6 + pub router: Router, 7 + } 8 + 9 + const PREVIEW_HTML: &str = include_str!("preview.html"); 10 + 11 + impl VideoServer { 12 + pub fn new<C: 'static + Default>(video: Arc<Video<C>>) -> Self { 13 + video.progress_bar.finish(); 14 + 15 + let router = Router::new() 16 + .route("/", routing::get(async || Html(PREVIEW_HTML))) 17 + .route("/frame/{number_dot_svg}", 18 + routing::get(async move |Path(number_dot_svg): Path<String>| { 19 + let number: usize = number_dot_svg 20 + .strip_suffix(".svg") 21 + .expect("Expecting /frame/{number}.svg, didn't find .svg at the end") 22 + .parse() 23 + .expect("Expecting /frame/{number}.svg, couldn't parse {number} to an integer"); 24 + 25 + println!(""); 26 + println!("Frame number requested: {number}"); 27 + 28 + match video.render_single_frame(number) { 29 + Ok((timecode, svg)) => svg.replace( 30 + "</svg>", 31 + &format!(r#"<meta name="shapemaker:timecode" content="{timecode:?}" /></svg>"#) 32 + ), 33 + Err(err) => format!("{err:?}"), 34 + } 35 + }), 36 + ); 37 + 38 + Self { router } 39 + } 40 + 41 + pub async fn start(self, address: &str) { 42 + axum::serve( 43 + tokio::net::TcpListener::bind(address).await.unwrap(), 44 + self.router, 45 + ) 46 + .await 47 + .unwrap(); 48 + } 49 + } 50 + 51 + impl<C: 'static + Default> Video<C> { 52 + pub async fn serve(self, address: &str) { 53 + VideoServer::new(Arc::new(self)).start(address).await; 54 + } 55 + }