mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 03:27:03 -06:00
- 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>
105 lines
3.8 KiB
Rust
105 lines
3.8 KiB
Rust
//! 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));
|
||
}
|
||
}
|