From 7f5e886d5b49553de3ff7c00bca03d3c40cd6890 Mon Sep 17 00:00:00 2001 From: Ewen Le Bihan Date: Sun, 12 May 2024 17:49:49 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Improve=20progress=20UI,=20fix=20mi?= =?UTF-8?q?di=20audiosync=20importer,=20other=20stuff=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 36 ++++++++++++++++++ Cargo.toml | 2 + src/canvas.rs | 41 ++++++++++---------- src/color.rs | 14 +++++-- src/fill.rs | 4 +- src/filter.rs | 2 +- src/layer.rs | 24 ++++++++---- src/lib.rs | 18 +++++---- src/midi.rs | 83 +++++++++++++++++++++++++--------------- src/objects.rs | 98 ++++++++++++++++++++++++++++++++---------------- src/point.rs | 4 +- src/preview.rs | 6 +-- src/region.rs | 15 ++++++-- src/sync.rs | 4 +- src/transform.rs | 20 ++++++---- src/ui.rs | 49 +++++++++++++++++++++++- src/video.rs | 80 +++++++++++++++++++++++++++------------ src/web.rs | 34 ++++++++--------- 18 files changed, 366 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c1b54a..8c1be90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -282,6 +282,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "hound" version = "3.5.1" @@ -616,6 +622,12 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustversion" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" + [[package]] name = "ryu" version = "1.0.17" @@ -699,6 +711,8 @@ dependencies = [ "serde_cbor", "serde_json", "slug", + "strum", + "strum_macros", "svg", "tiny_http", "wasm-bindgen", @@ -721,6 +735,28 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "svg" version = "0.17.0" diff --git a/Cargo.toml b/Cargo.toml index 42f078a..53cf4e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,8 @@ console = { version = "0.15.8", features = ["windows-console-colors"] } backtrace = "0.3.71" slug = "0.1.5" roxmltree = "0.19.0" +strum = { version = "0.26.2", features = ["strum_macros"] } +strum_macros = "0.26.2" [dev-dependencies] diff --git a/src/canvas.rs b/src/canvas.rs index 7f10c4d..d8b943b 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -1,5 +1,5 @@ use core::panic; -use std::{cmp, collections::HashMap, io::Write as _, ops::Range}; +use std::{collections::HashMap, io::Write as _, ops::Range}; use anyhow::Result; use itertools::Itertools as _; @@ -63,6 +63,10 @@ impl Canvas { } pub fn layer(&mut self, name: &str) -> &mut Layer { + if !self.layer_exists(name) { + panic!("Layer {} does not exist", name); + } + self.layer_safe(name).unwrap() } @@ -72,7 +76,7 @@ impl Canvas { } self.layers.push(Layer::new(name)); - self.layer(name) + self.layers.last_mut().unwrap() } pub fn layer_or_empty(&mut self, name: &str) -> &mut Layer { @@ -96,25 +100,16 @@ impl Canvas { /// puts this layer on top, and the others below, without changing their order pub fn put_layer_on_top(&mut self, name: &str) { self.ensure_layer_exists(name); - self.layers.sort_by(|a, _| { - if a.name == name { - cmp::Ordering::Less - } else { - cmp::Ordering::Greater - } - }) + let target_index = self.layers.iter().position(|l| l.name == name).unwrap(); + self.layers.swap(0, target_index) } /// puts this layer on bottom, and the others above, without changing their order pub fn put_layer_on_bottom(&mut self, name: &str) { self.ensure_layer_exists(name); - self.layers.sort_by(|a, _| { - if a.name == name { - cmp::Ordering::Greater - } else { - cmp::Ordering::Less - } - }) + let target_index = self.layers.iter().position(|l| l.name == name).unwrap(); + let last_index = self.layers.len() - 1; + self.layers.swap(last_index, target_index) } /// re-order layers. The first layer in the list will be on top, the last at the bottom @@ -223,7 +218,7 @@ impl Canvas { ); } Layer { - object_sizes: self.object_sizes.clone(), + object_sizes: self.object_sizes, name: name.to_string(), objects, _render_cache: None, @@ -258,7 +253,7 @@ impl Canvas { ); } Layer { - object_sizes: self.object_sizes.clone(), + object_sizes: self.object_sizes, name: layer_name.to_owned(), objects, _render_cache: None, @@ -433,9 +428,11 @@ impl Canvas { ((resolution as f32 / aspect_ratio) as usize, resolution) }; - let mut spawned = std::process::Command::new("magick") - .args(["-background", "none"]) - .args(["-size", &format!("{}x{}", width, height)]) + let mut spawned = std::process::Command::new("resvg") + .args(["--background", "transparent"]) + .args(["--width", &format!("{width}")]) + .args(["--height", &format!("{height}")]) + .args(["--resources-dir", "."]) .arg("-") .arg(at) .stdin(std::process::Stdio::piped()) @@ -462,7 +459,7 @@ impl Canvas { } pub fn aspect_ratio(&self) -> f32 { - return self.width() as f32 / self.height() as f32; + self.width() as f32 / self.height() as f32 } pub fn remove_all_objects_in(&mut self, region: &Region) { diff --git a/src/color.rs b/src/color.rs index 62cf0ba..d0ea483 100644 --- a/src/color.rs +++ b/src/color.rs @@ -7,10 +7,12 @@ use std::{ use rand::Rng; use serde::Deserialize; +use strum::IntoEnumIterator; +use strum_macros::EnumIter; use wasm_bindgen::prelude::*; #[wasm_bindgen] -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, EnumIter)] pub enum Color { Black, White, @@ -53,6 +55,10 @@ pub fn random_color(except: Option) -> Color { *candidates[rand::thread_rng().gen_range(0..candidates.len())] } +pub fn all_colors() -> Vec { + Color::iter().collect() +} + impl Default for Color { fn default() -> Self { Self::Black @@ -160,7 +166,7 @@ impl ColorMapping { pub fn from_css(content: &str) -> ColorMapping { let mut mapping = ColorMapping::default(); for line in content.lines() { - mapping.from_css_line(&line); + mapping.from_css_line(line); } mapping } @@ -266,8 +272,8 @@ impl ColorMapping { } fn from_css_line(&mut self, line: &str) { - if let Some((name, value)) = line.trim().split_once(":") { - let value = value.trim().trim_end_matches(";").to_owned(); + if let Some((name, value)) = line.trim().split_once(':') { + let value = value.trim().trim_end_matches(';').to_owned(); match name.trim() { "black" => self.black = value, "white" => self.white = value, diff --git a/src/fill.rs b/src/fill.rs index 984ee0a..9bb5d77 100644 --- a/src/fill.rs +++ b/src/fill.rs @@ -58,7 +58,7 @@ impl FillOperations for Fill { match self { Fill::Solid(color) => Fill::Translucent(*color, opacity), Fill::Translucent(color, _) => Fill::Translucent(*color, opacity), - _ => self.clone(), + _ => *self, } } @@ -143,7 +143,7 @@ impl Fill { .set("viewBox", format!("0,0,{},{}", size, size)) .set( "patternTransform", - format!("rotate({})", (angle.clone() - Angle(45.0)).degrees()), + format!("rotate({})", (*angle - Angle(45.0)).degrees()), ) // https://stackoverflow.com/a/55104220/9943464 .add( diff --git a/src/filter.rs b/src/filter.rs index e3a90cc..8ceb48f 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -41,7 +41,7 @@ impl Filter { format!( "filter-{}-{}", self.name(), - self.parameter.to_string().replace(".", "_") + self.parameter.to_string().replace('.', "_") ) } } diff --git a/src/layer.rs b/src/layer.rs index e303a50..7e3cec0 100644 --- a/src/layer.rs +++ b/src/layer.rs @@ -11,6 +11,8 @@ pub struct Layer { pub _render_cache: Option, } +static DISABLE_CACHE: bool = true; + impl Layer { pub fn new(name: &str) -> Self { Layer { @@ -35,16 +37,20 @@ impl Layer { } pub fn object(&mut self, name: &str) -> &mut ColoredObject { - self.objects.get_mut(name).unwrap() + self.safe_object(name).unwrap() + } + + pub fn safe_object(&mut self, name: &str) -> Option<&mut ColoredObject> { + self.objects.get_mut(name) } // Flush the render cache. - pub fn flush(&mut self) -> () { + pub fn flush(&mut self) { self._render_cache = None; } - pub fn replace(&mut self, with: Layer) -> () { - self.objects = with.objects.clone(); + pub fn replace(&mut self, with: Layer) { + self.objects.clone_from(&with.objects); self.flush(); } @@ -55,7 +61,7 @@ impl Layer { pub fn paint_all_objects(&mut self, fill: Fill) { for (_id, obj) in &mut self.objects { - obj.fill = Some(fill.clone()); + obj.fill = Some(fill); } self.flush(); } @@ -119,8 +125,10 @@ impl Layer { cell_size: usize, object_sizes: ObjectSizes, ) -> svg::node::element::Group { - if let Some(cached_svg) = &self._render_cache { - return cached_svg.clone(); + if !DISABLE_CACHE { + if let Some(cached_svg) = &self._render_cache { + return cached_svg.clone(); + } } let mut layer_group = svg::node::element::Group::new() @@ -128,7 +136,7 @@ impl Layer { .set("data-layer", self.name.clone()); for (id, obj) in &self.objects { - layer_group = layer_group.add(obj.render(cell_size, object_sizes, &colormap, &id)); + layer_group = layer_group.add(obj.render(cell_size, object_sizes, &colormap, id)); } self._render_cache = Some(layer_group.clone()); diff --git a/src/lib.rs b/src/lib.rs index d1ed0f5..10a8fd4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ pub use canvas::*; pub use color::*; pub use fill::*; pub use filter::*; +use itertools::Itertools; pub use layer::*; pub use midi::MidiSynchronizer; pub use objects::*; @@ -59,7 +60,14 @@ impl<'a, C> Context<'a, C> { pub fn stem(&self, name: &str) -> StemAtInstant { let stems = &self.syncdata.stems; if !stems.contains_key(name) { - panic!("No stem named {:?} found.", name); + panic!( + "No stem named {:?} found. Available stems:\n{}\n", + name, + stems + .keys() + .sorted() + .fold(String::new(), |acc, k| format!("{acc}\n\t{k}")) + ); } StemAtInstant { amplitude: *stems[name].amplitude_db.get(self.ms).unwrap_or(&0.0), @@ -76,12 +84,8 @@ impl<'a, C> Context<'a, C> { } } - pub fn dump_stems(&self, to: PathBuf) -> Result<()> { - std::fs::create_dir_all(&to)?; - for (name, stem) in self.syncdata.stems.iter() { - fs::write(to.join(name), format!("{:?}", stem))?; - } - Ok(()) + pub fn dump_syncdata(&self, to: PathBuf) -> Result<()> { + Ok(serde_cbor::to_writer(fs::File::create(to)?, self.syncdata)?) } pub fn marker(&self) -> String { diff --git a/src/midi.rs b/src/midi.rs index 3edcaf4..8dd5d8e 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -3,7 +3,7 @@ use itertools::Itertools; use midly::{MetaMessage, MidiMessage, TrackEvent, TrackEventKind}; use std::{collections::HashMap, fmt::Debug, path::PathBuf}; -use crate::{audio, sync::SyncData, Stem, Syncable}; +use crate::{audio, sync::SyncData, ui::Log as _, ui::MaybeProgressBar as _, Stem, Syncable}; pub struct MidiSynchronizer { pub midi_path: PathBuf, @@ -34,6 +34,12 @@ impl Syncable for MidiSynchronizer { stems: HashMap::from_iter(notes_per_instrument.iter().map(|(name, notes)| { let mut notes_per_ms = HashMap::>::new(); + if let Some(pb) = progressbar { + pb.set_length(notes.len() as u64); + pb.set_position(0); + } + progressbar.set_message(format!("Adding loaded notes for {name}")); + for note in notes.iter() { notes_per_ms .entry(note.ms as usize) @@ -43,21 +49,17 @@ impl Syncable for MidiSynchronizer { tick: note.tick, velocity: note.vel, }); + progressbar.inc(1); + } + + let duration_ms = *notes_per_ms.keys().max().unwrap_or(&0); - // if is_kick_channel(name) { - // // kicks might not have a note off event, so we added one manually after 100ms - // notes_per_ms - // .entry((note.ms + 100) as usize) - // .or_default() - // .push(audio::Note { - // pitch: note.key, - // tick: note.tick, - // velocity: 0, - // }); - // } + if let Some(pb) = progressbar { + pb.set_length(duration_ms as u64 - 1); + pb.set_position(0); } + progressbar.set_message(format!("Infering amplitudes for {name}")); - let duration_ms = notes_per_ms.keys().max().unwrap_or(&0).clone(); let mut amplitudes = Vec::::new(); let mut last_amplitude = 0.0; for i in 0..duration_ms { @@ -69,6 +71,7 @@ impl Syncable for MidiSynchronizer { .average(); } amplitudes.push(last_amplitude); + progressbar.inc(1); } ( @@ -76,7 +79,7 @@ impl Syncable for MidiSynchronizer { Stem { amplitude_max: notes.iter().map(|n| n.vel).max().unwrap_or(0) as f32, amplitude_db: amplitudes, - duration_ms: duration_ms, + duration_ms, notes: notes_per_ms, name: name.clone(), }, @@ -96,8 +99,8 @@ struct Note { } struct Now { - ms: f32, - tempo: f32, + ms: usize, + tempo: usize, ticks_per_beat: u16, } @@ -111,8 +114,8 @@ impl Note { } } -fn tempo_to_bpm(µs_per_beat: f32) -> usize { - (60_000_000.0 / µs_per_beat) as usize +fn tempo_to_bpm(µs_per_beat: usize) -> usize { + (60_000_000.0 / µs_per_beat as f32).round() as usize } // fn to_ms(delta: u32, bpm: f32) -> f32 { @@ -152,17 +155,18 @@ fn load_notes<'a>( let midifile = midly::Smf::parse(&raw).unwrap(); let mut timeline = Timeline::new(); + progressbar.set_message(format!("MIDI file has {} tracks", midifile.tracks.len())); + let mut now = Now { - ms: 0.0, - tempo: 500_000.0, + ms: 0, + tempo: 0, ticks_per_beat: match midifile.header.timing { midly::Timing::Metrical(ticks_per_beat) => ticks_per_beat.as_int(), midly::Timing::Timecode(fps, subframe) => (1.0 / fps.as_f32() / subframe as f32) as u16, }, }; - - // Get track names + // Get track names and (initial) BPM let mut track_no = 0; let mut track_names = HashMap::::new(); for track in midifile.tracks.iter() { @@ -173,6 +177,11 @@ fn load_notes<'a>( TrackEventKind::Meta(MetaMessage::TrackName(name_bytes)) => { track_name = String::from_utf8(name_bytes.to_vec()).unwrap_or_default(); } + TrackEventKind::Meta(MetaMessage::Tempo(tempo)) => { + if now.tempo == 0 { + now.tempo = tempo.as_int() as usize; + } + } _ => {} } } @@ -186,6 +195,16 @@ fn load_notes<'a>( ); } + progressbar.log( + "Detected", + &format!( + "MIDI file {} with {} stems and initial tempo of {} BPM", + source.to_str().unwrap(), + track_names.len(), + tempo_to_bpm(now.tempo) + ), + ); + // Convert ticks to absolute let mut track_no = 0; for track in midifile.tracks.iter() { @@ -201,26 +220,25 @@ fn load_notes<'a>( } // Convert ticks to ms - let mut absolute_tick_to_ms = HashMap::::new(); + let mut absolute_tick_to_ms = HashMap::::new(); let mut last_tick = 0; for (tick, tracks) in timeline.iter().sorted_by_key(|(tick, _)| *tick) { for (_, event) in tracks { match event.kind { TrackEventKind::Meta(MetaMessage::Tempo(tempo)) => { - now.tempo = tempo.as_int() as f32; + now.tempo = tempo.as_int() as usize; } _ => {} } } let delta = tick - last_tick; last_tick = *tick; - let delta_µs = now.tempo * delta as f32 / now.ticks_per_beat as f32; - now.ms += delta_µs / 1000.0; + now.ms += midi_tick_to_ms(delta, now.tempo, now.ticks_per_beat as usize); absolute_tick_to_ms.insert(*tick, now.ms); } - if let Some(ref pb) = progressbar { - pb.set_length(midifile.tracks.iter().map(|t| t.len()).sum::() as u64); + if let Some(pb) = progressbar { + pb.set_length(midifile.tracks.iter().map(|t| t.len() as u64).sum::()); pb.set_prefix("Loading"); pb.set_message("parsing MIDI events"); pb.set_position(0); @@ -257,9 +275,7 @@ fn load_notes<'a>( }, _ => {} } - if let Some(ref pb) = progressbar { - pb.inc(1); - } + progressbar.inc(1) } } @@ -276,3 +292,8 @@ fn load_notes<'a>( (now, result) } + +fn midi_tick_to_ms(tick: u32, tempo: usize, ppq: usize) -> usize { + let with_floats = (tempo as f32 / 1e3) / ppq as f32 * tick as f32; + with_floats.round() as usize +} diff --git a/src/objects.rs b/src/objects.rs index 9448856..51790ed 100644 --- a/src/objects.rs +++ b/src/objects.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::{ColorMapping, Fill, Filter, Point, Region, Transformation}; use itertools::Itertools; use wasm_bindgen::prelude::*; @@ -24,6 +26,7 @@ pub enum Object { Rectangle(Point, Point), Image(Region, String), RawSVG(Box), + // Tiling(Region, Box), } impl Object { @@ -72,14 +75,28 @@ impl ColoredObject { ) -> svg::node::element::Group { let mut group = self.object.render(cell_size, object_sizes, id); - match self + for (key, value) in self .transformations - .render_attribute(colormap, !self.object.fillable()) + .render_attributes(colormap, !self.object.fillable()) { - (key, _) if key.is_empty() => (), - (key, value) => group = group.set(key, value), + group = group.set(key, value); } + let start = self.object.region().start.coords(cell_size); + let (w, h) = ( + self.object.region().width() * cell_size, + self.object.region().height() * cell_size, + ); + + group = group.set( + "transform-origin", + format!( + "{} {}", + start.0 + (w as f32 / 2.0), + start.1 + (h as f32 / 2.0) + ), + ); + let mut css = String::new(); if !matches!(self.object, Object::RawSVG(..)) { css = self.fill.render_css(colormap, !self.object.fillable()); @@ -91,7 +108,6 @@ impl ColoredObject { .filters .iter() .map(|f| f.render_fill_css(colormap)) - .into_iter() .join(" ") .as_ref(); @@ -168,16 +184,16 @@ impl Default for ObjectSizes { } } -pub trait RenderAttribute { +pub trait RenderAttributes { const MULTIPLE_VALUES_JOIN_BY: &'static str = ", "; - fn render_fill_attribute(&self, colormap: &ColorMapping) -> (String, String); - fn render_stroke_attribute(&self, colormap: &ColorMapping) -> (String, String); - fn render_attribute( + fn render_fill_attribute(&self, colormap: &ColorMapping) -> HashMap; + fn render_stroke_attribute(&self, colormap: &ColorMapping) -> HashMap; + fn render_attributes( &self, colormap: &ColorMapping, fill_as_stroke_color: bool, - ) -> (String, String) { + ) -> HashMap { if fill_as_stroke_color { self.render_stroke_attribute(colormap) } else { @@ -185,29 +201,39 @@ pub trait RenderAttribute { } } } -impl RenderAttribute for Vec { - fn render_fill_attribute(&self, colormap: &ColorMapping) -> (String, String) { - ( - self.first() - .map(|v| v.render_fill_attribute(colormap).0) - .unwrap_or_default() - .clone(), - self.iter() - .map(|v| v.render_fill_attribute(colormap).1.clone()) - .join(", "), - ) - } - - fn render_stroke_attribute(&self, colormap: &ColorMapping) -> (String, String) { - ( - self.first() - .map(|v| v.render_stroke_attribute(colormap).0) - .unwrap_or_default() - .clone(), - self.iter() - .map(|v| v.render_stroke_attribute(colormap).1.clone()) - .join(", "), - ) +impl RenderAttributes for Vec { + fn render_fill_attribute(&self, colormap: &ColorMapping) -> HashMap { + let mut attrs = HashMap::::new(); + for attrmap in self.iter().map(|v| v.render_fill_attribute(colormap)) { + for (key, value) in attrmap { + if attrs.contains_key(&key) { + attrs.insert( + key.clone(), + format!("{}{}{}", attrs[&key], T::MULTIPLE_VALUES_JOIN_BY, value), + ); + } else { + attrs.insert(key, value); + } + } + } + attrs + } + + fn render_stroke_attribute(&self, colormap: &ColorMapping) -> HashMap { + let mut attrs = HashMap::::new(); + for attrmap in self.iter().map(|v| v.render_stroke_attribute(colormap)) { + for (key, value) in attrmap { + if attrs.contains_key(&key) { + attrs.insert( + key.clone(), + format!("{}{}{}", attrs[&key], T::MULTIPLE_VALUES_JOIN_BY, value), + ); + } else { + attrs.insert(key, value); + } + } + } + attrs } } @@ -293,10 +319,16 @@ impl Object { LineSegment::InwardCurve(anchor) | LineSegment::OutwardCurve(anchor) | LineSegment::Straight(anchor) => { + // println!( + // "extending region {} with {}", + // region, + // Region::from((start, anchor)) + // ); region = *region.max(&(start, anchor).into()) } } } + // println!("region for {:?} -> {}", self, region); region } Object::Line(start, end, _) diff --git a/src/point.rs b/src/point.rs index ea138ce..bf128e9 100644 --- a/src/point.rs +++ b/src/point.rs @@ -17,8 +17,8 @@ impl Point { pub fn region(&self) -> Region { Region { - start: self.clone(), - end: self.clone(), + start: *self, + end: *self, } } diff --git a/src/preview.rs b/src/preview.rs index 3be82c9..2945f4c 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -96,10 +96,10 @@ fn get_request_params(url: &str) -> (usize, usize) { let mut first_frame_ms = 0; let mut num_frames = 1; - let (_, querystring) = url.split_once("?").unwrap_or(("", "")); + let (_, querystring) = url.split_once('?').unwrap_or(("", "")); for (key, value) in querystring - .split("&") - .map(|pair| pair.split_once("=").unwrap_or(("", ""))) + .split('&') + .map(|pair| pair.split_once('=').unwrap_or(("", ""))) { match key { "from" => first_frame_ms = value.parse().unwrap_or(0), diff --git a/src/region.rs b/src/region.rs index a40d44a..1ac1c2b 100644 --- a/src/region.rs +++ b/src/region.rs @@ -73,8 +73,8 @@ impl Iterator for RegionIterator { impl From<&Region> for RegionIterator { fn from(region: &Region) -> Self { Self { - region: region.clone(), - current: region.start.clone(), + region: *region, + current: region.start, } } } @@ -82,8 +82,8 @@ impl From<&Region> for RegionIterator { impl From<(&Point, &Point)> for Region { fn from(value: (&Point, &Point)) -> Self { Self { - start: value.0.clone(), - end: value.1.clone(), + start: *value.0, + end: *value.1, } } } @@ -155,6 +155,13 @@ impl Region { Point(self.end.0, self.start.1) } + pub fn center(&self) -> Point { + Point( + (self.start.0 + self.end.0) / 2, + (self.start.1 + self.end.1) / 2, + ) + } + pub fn max<'a>(&'a self, other: &'a Region) -> &'a Region { if self.within(other) { other diff --git a/src/sync.rs b/src/sync.rs index 12e7096..5fa2c0c 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +use serde::{Deserialize, Serialize}; + use crate::Stem; pub type TimestampMS = usize; @@ -9,7 +11,7 @@ pub trait Syncable { fn load(&self, progress: Option<&indicatif::ProgressBar>) -> SyncData; } -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize, Deserialize)] pub struct SyncData { pub stems: HashMap, pub markers: HashMap, diff --git a/src/transform.rs b/src/transform.rs index 2e42699..d24e700 100644 --- a/src/transform.rs +++ b/src/transform.rs @@ -1,7 +1,11 @@ +use std::{ + collections::{HashMap}, +}; + use slug::slugify; use wasm_bindgen::prelude::*; -use crate::RenderAttribute; +use crate::RenderAttributes; #[wasm_bindgen] #[derive(Debug, Clone, Copy, PartialEq)] @@ -70,12 +74,13 @@ impl Transformation { } } -impl RenderAttribute for Transformation { +impl RenderAttributes for Transformation { const MULTIPLE_VALUES_JOIN_BY: &'static str = " "; - fn render_fill_attribute(&self, _colormap: &crate::ColorMapping) -> (String, String) { - ( - "transform".to_owned(), + fn render_fill_attribute(&self, _colormap: &crate::ColorMapping) -> HashMap { + let mut attrs = HashMap::new(); + attrs.insert( + "transform".to_string(), match self { Transformation::Scale(x, y) => format!("scale({} {})", x, y), Transformation::Rotate(angle) => format!("rotate({})", angle), @@ -84,10 +89,11 @@ impl RenderAttribute for Transformation { format!("matrix({}, {}, {}, {}, {}, {})", a, b, c, d, e, f) } }, - ) + ); + attrs } - fn render_stroke_attribute(&self, colormap: &crate::ColorMapping) -> (String, String) { + fn render_stroke_attribute(&self, colormap: &crate::ColorMapping) -> HashMap { self.render_fill_attribute(colormap) } } diff --git a/src/ui.rs b/src/ui.rs index b79f6a8..f34cdb1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,5 +1,6 @@ use console::Style; use indicatif::{ProgressBar, ProgressStyle}; +use std::borrow::Cow; use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time; @@ -14,9 +15,13 @@ pub struct Spinner { } impl Spinner { - pub fn start(message: &str) -> Self { + pub fn start(verb: &'static str, message: &str) -> Self { let spinner = ProgressBar::new(0).with_style( - ProgressStyle::with_template(&("{spinner:.cyan} ".to_owned() + message)).unwrap(), + ProgressStyle::with_template(&format_log_msg_cyan( + &verb, + &(message.to_owned() + " {spinner:.cyan}"), + )) + .unwrap(), ); spinner.tick(); @@ -39,6 +44,7 @@ impl Spinner { } pub fn end(self, message: &str) { + self.spinner.finish_and_clear(); *self.finished.lock().unwrap() = true; self.thread.join().unwrap(); println!("{}", message); @@ -64,8 +70,47 @@ pub fn format_log_msg(verb: &'static str, message: &str) -> String { format!("{} {}", style.apply_to(format!("{verb:>12}")), message) } +pub fn format_log_msg_cyan(verb: &'static str, message: &str) -> String { + let style = Style::new().bold().cyan(); + format!("{} {}", style.apply_to(format!("{verb:>12}")), message) +} + impl Log for ProgressBar { fn log(&self, verb: &'static str, message: &str) { self.println(format_log_msg(verb, message)); } } + +impl Log for Option<&ProgressBar> { + fn log(&self, verb: &'static str, message: &str) { + if let Some(pb) = self { + pb.println(format_log_msg(verb, message)); + } + } +} + +pub trait MaybeProgressBar<'a> { + fn set_message(&'a self, message: impl Into>); + fn inc(&'a self, n: u64); + fn println(&'a self, message: impl AsRef); +} + +impl<'a> MaybeProgressBar<'a> for Option<&'a ProgressBar> { + fn set_message(&'a self, message: impl Into>) { + if let Some(pb) = self { + pb.set_message(message); + } + } + + fn inc(&'a self, n: u64) { + if let Some(pb) = self { + pb.inc(n); + } + } + + fn println(&'a self, message: impl AsRef) { + if let Some(pb) = self { + pb.println(message); + } + } +} diff --git a/src/video.rs b/src/video.rs index 3eabd2c..b7476bc 100644 --- a/src/video.rs +++ b/src/video.rs @@ -17,7 +17,7 @@ use indicatif::{ProgressBar, ProgressIterator}; use crate::{ preview, sync::SyncData, - ui::{self, setup_progress_bar, Log as _}, + ui::{self, format_log_msg, setup_progress_bar, Log as _}, Canvas, ColoredObject, Context, LayerAnimationUpdateFunction, MidiSynchronizer, MusicalDurationUnit, Syncable, }; @@ -117,6 +117,17 @@ impl Video { let loader = MidiSynchronizer::new(sync_data_path); let syncdata = loader.load(Some(&self.progress_bar)); self.progress_bar.finish(); + self.progress_bar.log( + "Loaded", + &format!( + "{} notes from {sync_data_path}", + syncdata + .stems + .values() + .map(|v| v.notes.len()) + .sum::(), + ), + ); return Self { syncdata, ..self }; } @@ -141,18 +152,29 @@ impl Video { .args([ "-ss", &format!("{}", self.start_rendering_at as f32 / 1000.0), - ]) - .args(["-i", self.audiofile.to_str().unwrap()]) + ]); + + if !self.audiofile.to_str().unwrap().is_empty() { + if !self.audiofile.exists() { + return Err(anyhow::format_err!( + "Audio file {} does not exist", + self.audiofile.to_str().unwrap() + )); + } + command.args(["-i", self.audiofile.to_str().unwrap()]); + // so that vscode can read the video file with sound lmao + command.args(["-acodec", "mp3"]); + } + + command .args(["-t", &format!("{}", self.duration_ms() as f32 / 1000.0)]) .args(["-c:v", "libx264"]) .args(["-pix_fmt", "yuv420p"]) .arg("-y") .arg(render_to); - println!("Running command: {:?}", command); - match command.output() { - Err(e) => Err(anyhow::format_err!("Failed to execute ffmpeg: {}", e).into()), + Err(e) => Err(anyhow::format_err!("Failed to execute ffmpeg: {}", e)), Ok(r) => { println!("{}", std::str::from_utf8(&r.stdout).unwrap()); println!("{}", std::str::from_utf8(&r.stderr).unwrap()); @@ -338,7 +360,7 @@ impl Video { }), render_function: Box::new(move |canvas, ctx| { let object = create_object(canvas, ctx)?; - canvas.layer(&layer_name).set_object(object_name, object); + canvas.layer(layer_name).set_object(object_name, object); Ok(()) }), }) @@ -569,7 +591,7 @@ impl Video { context.timestamp = milliseconds_to_timestamp(context.ms).to_string(); context.beat_fractional = (context.bpm * context.ms) as f32 / (1000.0 * 60.0); context.beat = context.beat_fractional as usize; - context.frame = ((self.fps * context.ms) as f64 / 1000.0) as usize; + context.frame = self.fps * context.ms / 1000; progress_bar.set_message(context.timestamp.clone()); @@ -596,16 +618,8 @@ impl Video { } } - for hook in &self.hooks { - if (hook.when)( - &canvas, - &context, - previous_rendered_beat, - previous_rendered_frame, - ) { - (hook.render_function)(&mut canvas, &mut context)?; - } - } + // 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 + // 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. let mut later_hooks_to_delete: Vec = vec![]; @@ -626,6 +640,17 @@ impl Video { } } + for hook in &self.hooks { + if (hook.when)( + &canvas, + &context, + previous_rendered_beat, + previous_rendered_frame, + ) { + (hook.render_function)(&mut canvas, &mut context)?; + } + } + if context.frame != previous_rendered_frame { let rendered = canvas.render(render_background)?; @@ -653,6 +678,7 @@ impl Video { let mut frame_writer_threads = vec![]; let mut frames_to_write: Vec<(String, usize, usize)> = vec![]; + create_dir_all(self.frames_output_directory)?; remove_dir_all(self.frames_output_directory)?; create_dir(self.frames_output_directory)?; create_dir_all(Path::new(&output_file).parent().unwrap())?; @@ -671,8 +697,8 @@ impl Video { } self.progress_bar.log( - "Finished", - &format!("rendering {} frames to SVG", frames_to_write.len()), + "Rendered", + &format!("{} frames to SVG", frames_to_write.len()), ); frames_to_write.retain(|(_, _, ms)| *ms >= self.start_rendering_at); @@ -686,7 +712,7 @@ impl Video { for (frame, no, _) in &frames_to_write { std::fs::write( format!("{}/{}.svg", self.frames_output_directory, no), - &frame, + frame, )?; } @@ -723,12 +749,18 @@ impl Video { handle.join().unwrap(); } - self.progress_bar.log("Rendered", "SVG frames to PNG"); + self.progress_bar.log( + "Converted", + &format!("{} SVG frames to PNG", self.progress_bar.position()), + ); self.progress_bar.finish_and_clear(); - let spinner = ui::Spinner::start("Building video…"); + let spinner = ui::Spinner::start("Building", "video"); let result = self.build_video(&output_file); - spinner.end(&format!("Built video to {}", output_file)); + spinner.end(&format_log_msg( + "Built", + &format!("video to {}", output_file), + )); result } diff --git a/src/web.rs b/src/web.rs index d0719c2..1f78226 100644 --- a/src/web.rs +++ b/src/web.rs @@ -59,13 +59,13 @@ pub fn render_image(opacity: f32, color: Color) -> Result<(), JsValue> { pub fn map_to_midi_controller() {} #[wasm_bindgen] -pub fn render_canvas_into(selector: String) -> () { +pub fn render_canvas_into(selector: String) { let svgstring = canvas().render(false).unwrap_throw(); append_new_div_inside(svgstring, selector) } #[wasm_bindgen] -pub fn render_canvas_at(selector: String) -> () { +pub fn render_canvas_at(selector: String) { let svgstring = canvas().render(false).unwrap_throw(); replace_content_with(svgstring, selector) } @@ -130,14 +130,14 @@ impl From<(MidiEvent, MidiEventData)> for MidiMessage { } #[wasm_bindgen] -pub fn render_canvas(render_background: Option) -> () { +pub fn render_canvas(render_background: Option) { canvas() .render(render_background.unwrap_or(false)) .unwrap_throw(); } #[wasm_bindgen] -pub fn set_palette(palette: ColorMapping) -> () { +pub fn set_palette(palette: ColorMapping) { canvas().colormap = palette; } @@ -182,14 +182,14 @@ fn query_selector(selector: String) -> web_sys::Element { .expect_throw("could not get the element, but is was found (shouldn't happen)") } -fn append_new_div_inside(content: String, selector: String) -> () { +fn append_new_div_inside(content: String, selector: String) { let output = document().create_element("div").unwrap(); output.set_class_name("frame"); output.set_inner_html(&content); query_selector(selector).append_child(&output).unwrap(); } -fn replace_content_with(content: String, selector: String) -> () { +fn replace_content_with(content: String, selector: String) { query_selector(selector).set_inner_html(&content); } @@ -206,15 +206,15 @@ impl LayerWeb { canvas().render(false).unwrap_throw() } - pub fn render_into(&self, selector: String) -> () { + pub fn render_into(&self, selector: String) { append_new_div_inside(self.render(), selector) } - pub fn render_at(self, selector: String) -> () { + pub fn render_at(self, selector: String) { replace_content_with(self.render(), selector) } - pub fn paint_all(&self, color: Color, opacity: Option, filter: Filter) -> () { + pub fn paint_all(&self, color: Color, opacity: Option, filter: Filter) { canvas() .layer(&self.name) .paint_all_objects(Fill::Translucent(color, opacity.unwrap_or(1.0))); @@ -236,7 +236,7 @@ impl LayerWeb { end: Point, thickness: f32, color: Color, - ) -> () { + ) { canvas().layer(name).add_object( name, ( @@ -253,7 +253,7 @@ impl LayerWeb { end: Point, thickness: f32, color: Color, - ) -> () { + ) { canvas().layer(name).add_object( name, Object::CurveOutward(start, end, thickness).color(Fill::Solid(color)), @@ -266,23 +266,23 @@ impl LayerWeb { end: Point, thickness: f32, color: Color, - ) -> () { + ) { canvas().layer(name).add_object( name, Object::CurveInward(start, end, thickness).color(Fill::Solid(color)), ) } - pub fn new_small_circle(&self, name: &str, center: Point, color: Color) -> () { + pub fn new_small_circle(&self, name: &str, center: Point, color: Color) { canvas() .layer(name) .add_object(name, Object::SmallCircle(center).color(Fill::Solid(color))) } - pub fn new_dot(&self, name: &str, center: Point, color: Color) -> () { + pub fn new_dot(&self, name: &str, center: Point, color: Color) { canvas() .layer(name) .add_object(name, Object::Dot(center).color(Fill::Solid(color))) } - pub fn new_big_circle(&self, name: &str, center: Point, color: Color) -> () { + pub fn new_big_circle(&self, name: &str, center: Point, color: Color) { canvas() .layer(name) .add_object(name, Object::BigCircle(center).color(Fill::Solid(color))) @@ -294,7 +294,7 @@ impl LayerWeb { text: String, font_size: f32, color: Color, - ) -> () { + ) { canvas().layer(name).add_object( name, Object::Text(anchor, text, font_size).color(Fill::Solid(color)), @@ -306,7 +306,7 @@ impl LayerWeb { topleft: Point, bottomright: Point, color: Color, - ) -> () { + ) { canvas().layer(name).add_object( name, Object::Rectangle(topleft, bottomright).color(Fill::Solid(color)),