diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-28 20:14:51 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-28 20:14:51 +0200 |
| commit | 32438ce25f69de99d2523b98237f62cfa90890d7 (patch) | |
| tree | 3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/audio/src/clips.rs | |
| parent | f5a82152394fde4d8f740f664ab7737b82861618 (diff) | |
| download | opencut-32438ce25f69de99d2523b98237f62cfa90890d7.tar.gz | |
feat: port domain crates to Rust (session 1 complete)
All green: cargo test --workspace (~300 tests, 0 failures).
- timeline (45): placement, resize/ripple, split, snap, retime,
audio separation, scene helpers
- animation (73): bezier solver, channel sampling, keyframe ops,
split/clamp/clone, param definitions
- commands (9): Command trait, BatchCommand, history, 19 concrete
commands, freeze-frame port 1:1
- audio (22): audible collection, range mixing, mastering limiter,
rms/peak buckets
- text (19): cosmic-text layout/measure/raster, backgrounds,
decorations
- graphics (52): full CSS gradient parser, tiny-skia raster,
4 shape definitions
- effect-defs (16): blur definition, registry, pass resolution
- mask-defs (19): all 9 mask renderers via tiny-skia
- renderer (6): node model + scene builder port
- desktop: GPUI projects screen wired to ProjectStore
- scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps)
Diffstat (limited to 'rust/crates/audio/src/clips.rs')
| -rw-r--r-- | rust/crates/audio/src/clips.rs | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/rust/crates/audio/src/clips.rs b/rust/crates/audio/src/clips.rs new file mode 100644 index 00000000..1f4c839c --- /dev/null +++ b/rust/crates/audio/src/clips.rs @@ -0,0 +1,169 @@ +use std::path::PathBuf; + +use animation::{has_keyframes_for_path, resolve_number_at_time}; +use scene::{ + AudioElement, BaseTimelineElement, Element, ElementAnimations, ParamValue, RetimeConfig, + SceneTracks, Track, +}; +use time::MediaTime; + +pub const MIN_RETIME_RATE: f64 = 0.01; +pub const MAX_RETIME_RATE: f64 = 5.0; +pub const VOLUME_DB_MIN: f64 = -60.0; +pub const VOLUME_DB_MAX: f64 = 20.0; + +pub fn clamp_retime_rate(rate: f64) -> f64 { + if !rate.is_finite() || rate <= 0.0 { + return 1.0; + } + rate.clamp(MIN_RETIME_RATE, MAX_RETIME_RATE) +} + +pub fn source_time_at_clip_time(clip_time: f64, retime: Option<&RetimeConfig>) -> f64 { + clip_time * clamp_retime_rate(retime.map(|config| config.rate).unwrap_or(1.0)) +} + +pub fn clamp_db(value: f64) -> f64 { + if !value.is_finite() { + return 0.0; + } + value.clamp(VOLUME_DB_MIN, VOLUME_DB_MAX) +} + +pub fn db_to_linear(db: f64) -> f64 { + 10_f64.powf(clamp_db(db) / 20.0) +} + +#[derive(Debug, Clone)] +pub struct AudibleClip { + pub element_id: String, + pub path: PathBuf, + pub start_time: MediaTime, + pub duration: MediaTime, + pub trim_start: MediaTime, + pub trim_end: MediaTime, + pub volume_db: f64, + pub retime: Option<RetimeConfig>, + pub animations: Option<ElementAnimations>, +} + +impl AudibleClip { + pub fn end_time(&self) -> MediaTime { + self.start_time + self.duration + } + + pub fn effective_rate(&self) -> f64 { + clamp_retime_rate(self.retime.map(|config| config.rate).unwrap_or(1.0)) + } + + pub fn requires_maintain_pitch(&self) -> bool { + self.retime.is_some_and(|config| { + config.maintain_pitch == Some(true) + && config.rate.is_finite() + && config.rate > 0.0 + && clamp_retime_rate(config.rate) != 1.0 + }) + } + + pub fn has_animated_volume(&self) -> bool { + has_keyframes_for_path(self.animations.as_ref(), "volume") + } + + pub fn gain_at(&self, local_time: MediaTime) -> f64 { + db_to_linear(resolve_number_at_time( + self.volume_db, + self.animations.as_ref(), + "volume", + local_time, + )) + } + + pub fn static_gain(&self) -> f64 { + db_to_linear(self.volume_db) + } +} + +fn track_audio_muted(track: &Track) -> bool { + match track { + Track::Video { muted, .. } | Track::Audio { muted, .. } => *muted, + _ => false, + } +} + +fn element_source(element: &Element) -> Option<(&BaseTimelineElement, &str, Option<RetimeConfig>)> { + match element { + Element::Audio(AudioElement::Upload(upload)) => { + Some((&upload.base, upload.media_id.as_str(), upload.retime)) + } + Element::Audio(AudioElement::Library(library)) => { + Some((&library.base, library.source_url.as_str(), library.retime)) + } + Element::Video(video) => { + if video.hidden == Some(true) || video.is_source_audio_enabled == Some(false) { + return None; + } + Some((&video.base, video.media_id.as_str(), video.retime)) + } + _ => None, + } +} + +fn collect_from_track( + track: &Track, + media: &dyn Fn(&str) -> Option<PathBuf>, + range: (MediaTime, MediaTime), + clips: &mut Vec<AudibleClip>, +) { + if track_audio_muted(track) { + return; + } + for element in track.elements() { + let Some((base, source_key, retime)) = element_source(element) else { + continue; + }; + if base.duration <= MediaTime::ZERO { + continue; + } + let end_time = base.start_time + base.duration; + if end_time <= range.0 || base.start_time >= range.1 { + continue; + } + if base.params.get("muted") == Some(&ParamValue::Bool(true)) { + continue; + } + let volume_db = match base.params.get("volume") { + Some(ParamValue::Number(value)) => *value, + _ => 0.0, + }; + let Some(path) = media(source_key) else { + continue; + }; + clips.push(AudibleClip { + element_id: base.id.clone(), + path, + start_time: base.start_time, + duration: base.duration, + trim_start: base.trim_start, + trim_end: base.trim_end, + volume_db, + retime, + animations: base.animations.clone(), + }); + } +} + +pub fn collect_audible_elements( + tracks: &SceneTracks, + media: &dyn Fn(&str) -> Option<PathBuf>, + range: (MediaTime, MediaTime), +) -> Vec<AudibleClip> { + let mut clips = Vec::new(); + for track in &tracks.overlay { + collect_from_track(track, media, range, &mut clips); + } + collect_from_track(&tracks.main, media, range, &mut clips); + for track in &tracks.audio { + collect_from_track(track, media, range, &mut clips); + } + clips +} |
