summaryrefslogtreecommitdiff
path: root/rust/crates/audio/src/clips.rs
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
committercashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
commit32438ce25f69de99d2523b98237f62cfa90890d7 (patch)
tree3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/audio/src/clips.rs
parentf5a82152394fde4d8f740f664ab7737b82861618 (diff)
downloadopencut-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.rs169
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
+}