Files
deckcraft/crates/media/src/clip.rs
T
Brandon ThomasandClaude Opus 5.5 4375fe7b89 Media playback: slidecraft-media (probe, audio decode, H.264/HEVC/VP9/AV1 video, mixer and clock), cpal output, editor control bar, slide show playback, media.* commands
- crates/media: probe (duration, rate/channels, size/fps; WMA/WMV recognised), audio decode to
  f32 PCM (MP3, AAC M4A/ADTS, ALAC, WAV, AIFF, CAF, FLAC, Vorbis via symphonia; Opus with our
  decoder in Ogg/WebM/MKV), video decode (MP4/MOV/WebM/MKV) with YUV->RGBA, poster frames,
  trim/fade/volume, Player mixer + per-voice clock (device-clocked or wall-clocked), VideoFeed.
- Codec crates ported from FilmCraft: bitstream, h264, hevc, vp9, av1, isobmff, matroska, ogg, opus.
- insert.audio/insert.video probe and store duration/size and a poster frame; unplayable media
  is embedded with a warning. New media.play/pause/toggle/stop/seek/info/posterFrame.
- UI: MediaHost (cpal output injected by the desktop app), control bar under media, video frames
  on canvas and in the show; autoplay, click to play, loop, rewind, across slides, hide, full screen.
- Tests with in-code WAV/AIFF and CC0 self-generated fixtures (make.sh, ATTRIBUTION.md rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:35:09 -04:00

105 lines
3.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Trim, fade and volume: the playback range of a clip and its gain over time.
/// How a clip plays (PowerPoint's Playback tab). Times in seconds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ClipParams {
/// Trimmed from the start.
pub trim_start: f64,
/// Trimmed from the end.
pub trim_end: f64,
pub fade_in: f64,
pub fade_out: f64,
/// 0..1.
pub volume: f32,
}
impl Default for ClipParams {
fn default() -> Self {
ClipParams { trim_start: 0.0, trim_end: 0.0, fade_in: 0.0, fade_out: 0.0, volume: 1.0 }
}
}
impl ClipParams {
/// From the model's millisecond fields.
pub fn from_ms(trim_start: u32, trim_end: u32, fade_in: u32, fade_out: u32, volume: f64) -> ClipParams {
ClipParams {
trim_start: trim_start as f64 / 1000.0,
trim_end: trim_end as f64 / 1000.0,
fade_in: fade_in as f64 / 1000.0,
fade_out: fade_out as f64 / 1000.0,
volume: volume.clamp(0.0, 1.0) as f32,
}
}
/// The played range `[start, end)` in media seconds for a clip `duration` long. Trims that
/// would leave nothing are ignored (the whole clip plays); an unknown duration (0) gives an
/// open end (`f64::INFINITY`).
pub fn range(&self, duration: f64) -> (f64, f64) {
if duration.is_nan() || duration <= 0.0 {
return (self.trim_start.max(0.0), f64::INFINITY);
}
let start = self.trim_start.clamp(0.0, duration);
let end = (duration - self.trim_end.max(0.0)).clamp(0.0, duration);
if end - start < 0.01 { (0.0, duration) } else { (start, end) }
}
/// Gain at media time `t`: volume × fade-in ramp × fade-out ramp (linear). Fades longer than
/// half the played range are shortened so they never overlap.
pub fn gain(&self, t: f64, duration: f64) -> f32 {
let (start, end) = self.range(duration);
let len = end - start;
let half = if len.is_finite() { len / 2.0 } else { f64::INFINITY };
let fi = self.fade_in.max(0.0).min(half);
let fo = self.fade_out.max(0.0).min(half);
let mut g = 1.0;
if fi > 0.0 {
g *= ((t - start) / fi).clamp(0.0, 1.0);
}
if fo > 0.0 && end.is_finite() {
g *= ((end - t) / fo).clamp(0.0, 1.0);
}
self.volume * g as f32
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn range_applies_trims() {
let c = ClipParams { trim_start: 1.0, trim_end: 2.0, ..Default::default() };
assert_eq!(c.range(10.0), (1.0, 8.0));
// Trims longer than the clip are ignored.
let c = ClipParams { trim_start: 6.0, trim_end: 6.0, ..Default::default() };
assert_eq!(c.range(10.0), (0.0, 10.0));
// Unknown duration: open end.
assert_eq!(ClipParams::default().range(0.0), (0.0, f64::INFINITY));
}
#[test]
fn gain_fades_and_volume() {
let c = ClipParams { trim_start: 1.0, fade_in: 2.0, fade_out: 1.0, volume: 0.5, ..Default::default() };
let d = 10.0;
assert_eq!(c.gain(1.0, d), 0.0);
assert!((c.gain(2.0, d) - 0.25).abs() < 1e-6);
assert!((c.gain(5.0, d) - 0.5).abs() < 1e-6);
assert!((c.gain(9.5, d) - 0.25).abs() < 1e-6);
assert_eq!(c.gain(10.0, d), 0.0);
}
#[test]
fn long_fades_never_overlap() {
let c = ClipParams { fade_in: 5.0, fade_out: 5.0, ..Default::default() };
// 2 s clip: each fade is capped at 1 s, so the middle reaches full volume.
assert!((c.gain(1.0, 2.0) - 1.0).abs() < 1e-6);
assert!((c.gain(0.5, 2.0) - 0.5).abs() < 1e-6);
}
#[test]
fn from_ms_converts() {
let c = ClipParams::from_ms(1500, 250, 0, 1000, 2.0);
assert_eq!((c.trim_start, c.trim_end, c.fade_out, c.volume), (1.5, 0.25, 1.0, 1.0));
}
}