export: 32-bit integer and 32-bit float audio; Sample Size lists every size and greys out the unsupported ones

- AudioSettings gains `float`; `bits` takes 16, 24 or 32. `SampleFormat` (Int16 / Int24 /
  Int32 / Float32) and `ExportSettings::supports_sample_format` decide what each codec and
  container stores: WAV, AIFF and QuickTime PCM all four, MXF integers, FLAC integers.
  `validate` refuses float without 32 bits and float in FLAC or MXF.
- WAV float is WAVE_FORMAT_IEEE_FLOAT (EXTENSIBLE for more than two channels) with a `fact`
  chunk; AIFF float is AIFF-C `fl32`; QuickTime float is `lpcm` with the float flag; MXF writes
  32-bit integers. Float keeps the mix as it is, peaks above 0 dBFS included; 16 and 24 bit
  write the same bytes as before.
- FLAC encoder: 8-32 bits. 32-bit stereo is coded independently (a side channel would need 33
  bits), the sample range check no longer overflows, and 32-bit frames point at STREAMINFO for
  the size, which symphonia 0.5 (FilmCraft's own FLAC import) and ffmpeg both read.
- Export mode: Sample Size shows 16 bit, 24 bit, 32 bit and 32 bit float like Audio Format
  does, greying out what the format cannot store with the reason on hover; a size that stops
  being available (float after switching to FLAC) becomes 32-bit integer. es, fr, pt-br, id,
  ja and zh-cn strings.
- Tests: writer layouts and ffprobe codecs for every new format, round trips through ffmpeg
  and FilmCraft's own readers in WAV, AIFF, MOV, MXF and FLAC, headroom of a +6 dB tone,
  refused combinations, full-scale 32-bit FLAC through ffmpeg (MD5 too) and symphonia, and a
  UI test of the selector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nzevSsTNZxHPpUngfGTAw
This commit is contained in:
Claude committed 2026-10-12 05:42:44 +00:00
1 parent 78fe0102bd
commit 6c13dda2bf
19 files changed
+698 -124

No files matched your search

+12 -14
View File
@@ -363,18 +363,9 @@ impl Exporter {
}
}
None => {
let bps = if self.settings.audio.bits >= 24 { 3 } else { 2 };
let mut pcm = Vec::with_capacity(n * buf.len() * bps);
for i in 0..n {
for c in &buf {
let s = c[i].clamp(-1.0, 1.0);
if bps == 3 {
pcm.extend_from_slice(&((s * 8_388_607.0).round() as i32).to_le_bytes()[..3]);
} else {
pcm.extend_from_slice(&((s * 32767.0).round() as i16).to_le_bytes());
}
}
}
// little-endian, like the track's `lpcm` / `sowt` description says
let mut pcm = Vec::new();
crate::pcm::encode(crate::pcm::PcmContainer::Wav, &crate::audio_out::interleave(&buf), self.settings.audio.sample_format(), &mut pcm);
mux.write_sample(at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true })
.map_err(|e| ExportError::Io(e.to_string()))?;
}
@@ -428,8 +419,15 @@ impl Exporter {
Some(mux.add_track(c).map_err(|e| ExportError::Io(e.to_string()))?)
}
(Some(a), None) => {
let bits = if self.settings.audio.bits >= 24 { 24 } else { 16 };
let pcm = PcmConfig { bits, float: false, big_endian: false, signed: true, channels: a.channels as _, sample_rate: a.sr as f64 };
let f = self.settings.audio.sample_format();
let pcm = PcmConfig {
bits: f.bits() as _,
float: f.is_float(),
big_endian: false,
signed: true,
channels: a.channels as _,
sample_rate: a.sr as f64,
};
Some(mux.add_track(TrackConfig::new(SampleEntry::pcm(pcm), a.sr)).map_err(|e| ExportError::Io(e.to_string()))?)
}
};
+19 -11
View File
@@ -625,6 +625,14 @@ impl ExportSettings {
if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) {
return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into()));
}
if self.audio.float && self.audio.bits != 32 {
return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into()));
}
let sample_format = self.audio.sample_format();
if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) {
let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() };
return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label())));
}
if self.field_order != FieldOrder::Progressive && self.has_video() {
return Err(ExportError::Unsupported(format!("{} field order: FilmCraft's encoders write progressive frames", self.field_order.label())));
}
@@ -1108,19 +1116,19 @@ impl AudioEncoder for AacEncoder {
/// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit.
pub(crate) struct FlacAudio {
enc: filmcraft_flac::Encoder,
bits: u16,
format: SampleFormat,
/// Samples of the frame `flush` returns (shorter than a full block).
tail: u32,
}
impl FlacAudio {
pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result<Self> {
let bits = if s.audio.bits >= 24 { 24 } else { 16 };
let format = s.audio.sample_format();
let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?;
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, bits as u8);
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, format.bits() as u8);
cfg.level = s.audio.flac_level.min(8);
let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?;
Ok(FlacAudio { enc, bits, tail: 0 })
Ok(FlacAudio { enc, format, tail: 0 })
}
}
@@ -1129,7 +1137,7 @@ impl AudioEncoder for FlacAudio {
SampleEntry::flac(&self.enc.streaminfo())
}
fn encode(&mut self, planar: &[Vec<f32>]) -> Result<Vec<Vec<u8>>> {
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect();
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| self.format.quantise(s)).collect()).collect();
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))
}
@@ -1851,12 +1859,12 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?,
Format::Wav | Format::Aiff | Format::Flac => {
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
let (ch, sr, fmt) = (a.channels as u16, a.sr, settings.audio.sample_format());
let frames = a.remaining();
// the sizes are known up front: refuse an AIFF that cannot hold them before measuring
let mut writer = match settings.format {
Format::Flac => {
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 });
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, fmt.bits() as u8);
cfg.level = settings.audio.flac_level.min(8);
AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?))
}
@@ -1864,7 +1872,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
_ => AudioWriter::Pcm(pcm::PcmContainer::Aiff),
};
let head = match &writer {
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?,
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, fmt, frames).map_err(ExportError::Unsupported)?,
AudioWriter::Flac(enc) => enc.header(),
};
// one second per chunk: memory stays flat however long the range is
@@ -1885,9 +1893,9 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
buf.clear();
match &mut writer {
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf),
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), fmt, &mut buf),
AudioWriter::Flac(enc) => {
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect();
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| fmt.quantise(s)).collect()).collect();
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?;
}
@@ -1898,7 +1906,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
}
match &mut writer {
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?,
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, fmt)).map_err(io)?,
AudioWriter::Flac(enc) => {
out.write_all(&enc.finish()).map_err(io)?;
// the frame sizes and MD5 are known now: fill them into STREAMINFO
+2 -1
View File
@@ -108,7 +108,8 @@ impl MxfMux {
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let seed = format!("{}|{now}", settings.path);
let ids = PackageIds::from_seed(&seed, &s.name);
let bits = if settings.audio.bits >= 24 { 24 } else { 16 };
// integers only: `ExportSettings::validate` refuses float PCM in MXF
let bits = u32::from(settings.audio.sample_format().bits());
let mut cfg = WriterConfig::new(if atom { Pattern::OpAtom } else { Pattern::Op1a }, rate, ids.clone());
cfg.picture = Some(picture(settings, s.width, s.height));
cfg.modified = Timestamp::from_unix((now / 1_000_000_000) as i64);
+190 -59
View File
@@ -1,10 +1,13 @@
//! Audio-only files: WAV (RIFF, little-endian PCM; RF64 past 4 GiB) and AIFF (big-endian PCM),
//! 16 or 24 bit, plus image-sequence file naming.
//! 16, 24 or 32-bit integer or 32-bit float ([`SampleFormat`]), plus image-sequence file naming.
//! Float WAV is `WAVE_FORMAT_IEEE_FLOAT` with a `fact` chunk; float AIFF is AIFF-C (`fl32`).
//!
//! The header is written first from the known length ([`header`]), then the samples in chunks
//! ([`encode`]) and the pad byte ([`pad`]), so an export streams to disk instead of building the
//! file in memory.
use crate::SampleFormat;
/// The container of an audio-only export.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PcmContainer {
@@ -12,47 +15,55 @@ pub enum PcmContainer {
Aiff,
}
/// Quantise one sample to a signed integer of `bits` (16 or 24).
pub(crate) fn quantise(s: f32, bits: u16) -> i32 {
let max = if bits >= 24 { 8_388_607.0 } else { 32_767.0 };
(s.clamp(-1.0, 1.0) * max).round() as i32
}
/// The sample size written for a requested `bits`: 24, else 16.
fn sample_bits(bits: u16) -> u16 {
if bits >= 24 { 24 } else { 16 }
}
/// Bytes of sample data for `frames` frames, or None when that does not fit 64 bits.
fn data_len(frames: u64, channels: u16, bits: u16) -> Option<u64> {
frames.checked_mul(u64::from(channels))?.checked_mul(u64::from(sample_bits(bits) / 8))
fn data_len(frames: u64, channels: u16, format: SampleFormat) -> Option<u64> {
frames.checked_mul(u64::from(channels))?.checked_mul(format.bytes() as u64)
}
/// The pad byte after an odd-sized data chunk (RIFF and AIFF chunks are word-aligned).
pub fn pad(frames: u64, channels: u16, bits: u16) -> &'static [u8] {
if data_len(frames, channels, bits).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
pub fn pad(frames: u64, channels: u16, format: SampleFormat) -> &'static [u8] {
if data_len(frames, channels, format).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
}
/// `KSDATAFORMAT_SUBTYPE_PCM` / `_IEEE_FLOAT` after their first two bytes (the format tag).
const SUBTYPE_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
/// AIFF-C format version 1 (`FVER`).
const AIFC_VERSION_1: u32 = 0xA280_5140;
/// The AIFF-C compression name of `fl32`, as a Pascal string of even length.
const FL32_NAME: &[u8] = b"\x1532-bit floating point";
/// Everything before the samples of a file of `frames` frames. A WAV whose RIFF size would not
/// fit 32 bits (4 GiB: 24-bit 5.1 at 96 kHz after about 41 minutes) is written as RF64 (EBU Tech
/// 3306), with the 64-bit sizes in a `ds64` chunk. AIFF has no 64-bit form, so a longer AIFF is
/// an error naming WAV instead.
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u16, frames: u64) -> Result<Vec<u8>, String> {
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, format: SampleFormat, frames: u64) -> Result<Vec<u8>, String> {
let too_long = || "the audio is too long to write".to_string();
let bits = sample_bits(bits);
let bits = format.bits();
let bps = bits / 8;
let data = data_len(frames, channels, bits).ok_or_else(too_long)?;
let float = format.is_float();
let data = data_len(frames, channels, format).ok_or_else(too_long)?;
let block_align = channels.checked_mul(bps).ok_or("too many audio channels")?;
match container {
PcmContainer::Wav => {
let byte_rate = u32::try_from(u64::from(sample_rate) * u64::from(block_align)).map_err(|_| "the audio sample rate is too high".to_string())?;
let ext = channels > 2;
let fmt_len: u32 = if ext { 40 } else { 16 };
// plain PCM has no cbSize; IEEE float has an empty one
let fmt_len: u32 = if ext {
40
} else if float {
18
} else {
16
};
// every format but integer PCM has a `fact` chunk (the frame count; RF64: in `ds64`)
let fact_len: u64 = if float { 12 } else { 0 };
let padded = data.checked_add(data & 1).ok_or_else(too_long)?;
// "WAVE" + fmt chunk + data chunk header + samples
let riff = (4 + 8 + u64::from(fmt_len) + 8).checked_add(padded).ok_or_else(too_long)?;
// "WAVE" + fmt chunk + fact chunk + data chunk header + samples
let riff = (4 + 8 + u64::from(fmt_len) + fact_len + 8).checked_add(padded).ok_or_else(too_long)?;
let rf64 = riff > u64::from(u32::MAX);
let mut v = Vec::with_capacity(80);
let mut v = Vec::with_capacity(100);
if rf64 {
const DS64: u64 = 8 + 28;
v.extend_from_slice(b"RF64");
@@ -68,21 +79,30 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
v.extend_from_slice(&(riff as u32).to_le_bytes());
v.extend_from_slice(b"WAVE");
}
let tag: u16 = if float { 3 } else { 1 };
v.extend_from_slice(b"fmt ");
v.extend_from_slice(&fmt_len.to_le_bytes());
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes());
v.extend_from_slice(&(if ext { 0xFFFE } else { tag }).to_le_bytes());
v.extend_from_slice(&channels.to_le_bytes());
v.extend_from_slice(&sample_rate.to_le_bytes());
v.extend_from_slice(&byte_rate.to_le_bytes());
v.extend_from_slice(&block_align.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
if ext {
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM / _IEEE_FLOAT
v.extend_from_slice(&22u16.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
let mask: u32 = if channels == 6 { 0x3F } else { 0 };
v.extend_from_slice(&mask.to_le_bytes());
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
v.extend_from_slice(&tag.to_le_bytes());
v.extend_from_slice(&SUBTYPE_TAIL);
} else if float {
v.extend_from_slice(&0u16.to_le_bytes());
}
if float {
v.extend_from_slice(b"fact");
v.extend_from_slice(&4u32.to_le_bytes());
v.extend_from_slice(&(if rf64 { u32::MAX } else { u32::try_from(frames).unwrap_or(u32::MAX) }).to_le_bytes());
}
v.extend_from_slice(b"data");
v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes());
@@ -93,16 +113,31 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
let frames32 = u32::try_from(frames).map_err(|_| aiff_too_long())?;
// SSND: offset + block size + samples
let ssnd = u32::try_from(data.checked_add(8).ok_or_else(aiff_too_long)?).map_err(|_| aiff_too_long())?;
let form = (4u32 + (8 + 18)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
let mut v = Vec::with_capacity(54);
// floats need AIFF-C: a version chunk, and the compression type and name in COMM
let comm: u32 = if float { 18 + 4 + FL32_NAME.len() as u32 } else { 18 };
let fver: u32 = if float { 8 + 4 } else { 0 };
let form =
(4u32 + fver + (8 + comm)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
let mut v = Vec::with_capacity(90);
v.extend_from_slice(b"FORM");
v.extend_from_slice(&form.to_be_bytes());
v.extend_from_slice(b"AIFFCOMM");
v.extend_from_slice(&18u32.to_be_bytes());
if float {
v.extend_from_slice(b"AIFCFVER");
v.extend_from_slice(&4u32.to_be_bytes());
v.extend_from_slice(&AIFC_VERSION_1.to_be_bytes());
} else {
v.extend_from_slice(b"AIFF");
}
v.extend_from_slice(b"COMM");
v.extend_from_slice(&comm.to_be_bytes());
v.extend_from_slice(&channels.to_be_bytes());
v.extend_from_slice(&frames32.to_be_bytes());
v.extend_from_slice(&bits.to_be_bytes());
v.extend_from_slice(&extended(sample_rate));
if float {
v.extend_from_slice(b"fl32");
v.extend_from_slice(FL32_NAME);
}
v.extend_from_slice(b"SSND");
v.extend_from_slice(&ssnd.to_be_bytes());
v.extend_from_slice(&0u32.to_be_bytes()); // offset
@@ -112,13 +147,12 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
}
}
/// Append interleaved samples as `bits`-bit PCM (little-endian for WAV, big-endian for AIFF).
pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut Vec<u8>) {
let bits = sample_bits(bits);
let bps = usize::from(bits / 8);
/// Append interleaved samples in `format` (little-endian for WAV, big-endian for AIFF).
pub fn encode(container: PcmContainer, interleaved: &[f32], format: SampleFormat, out: &mut Vec<u8>) {
let bps = format.bytes();
out.reserve(interleaved.len().saturating_mul(bps));
for &s in interleaved {
let q = quantise(s, bits);
let q = if format.is_float() { SampleFormat::float(s).to_bits() as i32 } else { format.quantise(s) };
match container {
PcmContainer::Wav => out.extend_from_slice(q.to_le_bytes().get(..bps).unwrap_or_default()),
PcmContainer::Aiff => out.extend_from_slice(q.to_be_bytes().get(4 - bps..).unwrap_or_default()),
@@ -127,19 +161,19 @@ pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut
}
/// A whole file in memory (short audio; exports stream with [`header`] and [`encode`]).
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
let frames = (interleaved.len() / usize::from(channels.max(1))) as u64;
let mut v = header(container, channels, sample_rate, bits, frames)?;
encode(container, interleaved, bits, &mut v);
v.extend_from_slice(pad(frames, channels, bits));
let mut v = header(container, channels, sample_rate, format, frames)?;
encode(container, interleaved, format, &mut v);
v.extend_from_slice(pad(frames, channels, format));
Ok(v)
}
/// A RIFF/WAVE file of interleaved samples. More than two channels are written as
/// `WAVE_FORMAT_EXTENSIBLE` with the speaker mask of the layout (6 channels: L, R, C, LFE, Ls, Rs
/// = `0x3F`).
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits)
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, format)
}
/// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`).
@@ -156,9 +190,9 @@ fn extended(rate: u32) -> [u8; 10] {
out
}
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`) of interleaved samples.
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, bits)
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`; AIFF-C for floats) of interleaved samples.
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, format)
}
/// File name of frame `index` of an image sequence of `count` frames exported to `path`:
@@ -181,6 +215,11 @@ pub fn image_sequence_path(path: &str, index: u64, count: u64) -> String {
mod tests {
use super::*;
const I16: SampleFormat = SampleFormat::Int16;
const I24: SampleFormat = SampleFormat::Int24;
const I32: SampleFormat = SampleFormat::Int32;
const F32: SampleFormat = SampleFormat::Float32;
#[test]
fn extended_sample_rates() {
assert_eq!(extended(44_100), [0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]);
@@ -198,10 +237,10 @@ mod tests {
#[test]
fn wav_and_aiff_layout() {
let s = [0.5f32, -0.5, 1.0, -1.0];
let w = write_wav(&s, 2, 48_000, 24).unwrap();
let w = write_wav(&s, 2, 48_000, I24).unwrap();
assert_eq!(w.len(), 44 + 12);
assert_eq!(&w[34..36], &24u16.to_le_bytes());
let a = write_aiff(&s, 2, 48_000, 16).unwrap();
let a = write_aiff(&s, 2, 48_000, I16).unwrap();
assert_eq!(&a[..4], b"FORM");
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
assert_eq!(&a[a.len() - 2..], &(-32767i16).to_be_bytes());
@@ -220,7 +259,7 @@ mod tests {
#[test]
fn wav_past_4_gib_is_rf64() {
let frames = 96_000u64 * 60 * 60; // an hour: 6.2 GB
let h = header(PcmContainer::Wav, 6, 96_000, 24, frames).unwrap();
let h = header(PcmContainer::Wav, 6, 96_000, I24, frames).unwrap();
let data = frames * 6 * 3;
assert_eq!(&h[..4], b"RF64");
assert_eq!(le32(&h, 4), u32::MAX);
@@ -238,37 +277,129 @@ mod tests {
fn wav_up_to_4_gib_stays_riff() {
// the longest stereo 16-bit RIFF: 36 bytes of header after the size field
let max = (u64::from(u32::MAX) - 36) / 4;
let h = header(PcmContainer::Wav, 2, 48_000, 16, max).unwrap();
let h = header(PcmContainer::Wav, 2, 48_000, I16, max).unwrap();
assert_eq!(&h[..4], b"RIFF");
assert_eq!(u64::from(le32(&h, 4)), 36 + max * 4);
assert_eq!(u64::from(le32(&h, 40)), max * 4);
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, 16, max + 1).unwrap()[..4], b"RF64");
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, I16, max + 1).unwrap()[..4], b"RF64");
}
#[test]
fn aiff_past_4_gib_is_refused_not_wrapped() {
let e = header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60 * 8).unwrap_err();
let e = header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60 * 8).unwrap_err();
assert!(e.contains("export WAV"), "{e}");
assert!(header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60).is_ok());
assert!(header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60).is_ok());
}
#[test]
fn hostile_sizes_are_errors() {
for c in [PcmContainer::Wav, PcmContainer::Aiff] {
assert!(header(c, 2, 48_000, 24, u64::MAX).is_err());
assert!(header(c, u16::MAX, 48_000, 24, 1).is_err());
assert!(header(c, 2, 48_000, I24, u64::MAX).is_err());
assert!(header(c, u16::MAX, 48_000, I24, 1).is_err());
}
assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 1).is_err());
assert!(header(PcmContainer::Wav, 6, u32::MAX, I24, 1).is_err());
}
#[test]
fn odd_data_chunks_are_padded() {
// one 24-bit mono frame: 3 bytes of data, one pad byte, counted in the RIFF size
let w = write_wav(&[0.25], 1, 48_000, 24).unwrap();
let w = write_wav(&[0.25], 1, 48_000, I24).unwrap();
assert_eq!((w.len(), le32(&w, 4) as usize, le32(&w, 40)), (48, 40, 3));
let a = write_aiff(&[0.25], 1, 48_000, 24).unwrap();
let a = write_aiff(&[0.25], 1, 48_000, I24).unwrap();
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
assert!(pad(2, 1, 24).is_empty());
assert!(pad(2, 1, I24).is_empty());
}
fn be32(b: &[u8], at: usize) -> u32 {
u32::from_be_bytes(b[at..at + 4].try_into().unwrap())
}
#[test]
fn integer_samples_clip_at_full_scale() {
assert_eq!([1.0, -1.0, 2.0, -2.0, f32::NAN, 0.5].map(|s| I32.quantise(s)), [i32::MAX, -i32::MAX, i32::MAX, -i32::MAX, 0, 1 << 30]);
assert_eq!([1.0, -1.0, 4.0].map(|s| I24.quantise(s)), [8_388_607, -8_388_607, 8_388_607]);
assert_eq!([1.0, -1.0, f32::INFINITY].map(|s| I16.quantise(s)), [32_767, -32_767, 32_767]);
assert_eq!(F32.quantise(0.5), 0);
assert_eq!([2.5, f32::NAN, f32::INFINITY, f32::NEG_INFINITY].map(SampleFormat::float), [2.5, 0.0, f32::MAX, f32::MIN]);
}
#[test]
fn wav_and_aiff_32_bit_integer() {
let w = write_wav(&[1.0, -1.0], 1, 48_000, I32).unwrap();
assert_eq!(w.len(), 44 + 8);
assert_eq!(u16::from_le_bytes([w[20], w[21]]), 1, "integer PCM");
assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]])), (4, 32));
assert_eq!(&w[44..], [i32::MAX.to_le_bytes(), (-i32::MAX).to_le_bytes()].concat());
let a = write_aiff(&[1.0, -1.0], 1, 48_000, I32).unwrap();
assert_eq!(&a[8..12], b"AIFF");
assert_eq!(u16::from_be_bytes([a[26], a[27]]), 32);
assert_eq!(&a[a.len() - 8..], [i32::MAX.to_be_bytes(), (-i32::MAX).to_be_bytes()].concat());
}
/// Float keeps the mix as it is: peaks above full scale survive.
#[test]
fn wav_32_bit_float() {
let s = [0.25f32, -2.0, 1.5, f32::NAN];
let w = write_wav(&s, 2, 48_000, F32).unwrap();
assert_eq!(&w[12..16], b"fmt ");
assert_eq!(le32(&w, 16), 18);
assert_eq!(u16::from_le_bytes([w[20], w[21]]), 3, "WAVE_FORMAT_IEEE_FLOAT");
assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]]), u16::from_le_bytes([w[36], w[37]])), (8, 32, 0));
assert_eq!((&w[38..42], le32(&w, 42), le32(&w, 46)), (&b"fact"[..], 4, 2));
assert_eq!((&w[50..54], le32(&w, 54)), (&b"data"[..], 16));
assert_eq!(le32(&w, 4) as usize, w.len() - 8);
let back: Vec<f32> = w[58..].chunks(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect();
assert_eq!(back, [0.25, -2.0, 1.5, 0.0]);
// 5.1: WAVE_FORMAT_EXTENSIBLE with the IEEE float sub-format, still with `fact`
let w = write_wav(&[0.5; 12], 6, 48_000, F32).unwrap();
assert_eq!((le32(&w, 16), u16::from_le_bytes([w[20], w[21]])), (40, 0xFFFE));
assert_eq!(&w[44..60], [&3u16.to_le_bytes()[..], &SUBTYPE_TAIL].concat());
assert_eq!((&w[60..64], le32(&w, 68)), (&b"fact"[..], 2));
// RF64: the frame count is in `ds64`
let h = header(PcmContainer::Wav, 2, 48_000, F32, 48_000 * 60 * 60 * 4).unwrap();
assert_eq!(&h[..4], b"RF64");
let fact = h.windows(4).position(|c| c == b"fact").unwrap();
assert_eq!(le32(&h, fact + 8), u32::MAX);
}
#[test]
fn aiff_32_bit_float_is_aifc() {
let a = write_aiff(&[0.25, -3.0], 1, 44_100, F32).unwrap();
assert_eq!((&a[..4], be32(&a, 4) as usize, &a[8..12]), (&b"FORM"[..], a.len() - 8, &b"AIFC"[..]));
assert_eq!((&a[12..16], be32(&a, 16), be32(&a, 20)), (&b"FVER"[..], 4, AIFC_VERSION_1));
assert_eq!((&a[24..28], be32(&a, 28)), (&b"COMM"[..], 44));
assert_eq!((u16::from_be_bytes([a[32], a[33]]), be32(&a, 34), u16::from_be_bytes([a[38], a[39]])), (1, 2, 32));
assert_eq!(&a[50..54], b"fl32");
assert_eq!(&a[54..76], FL32_NAME);
assert_eq!((&a[76..80], be32(&a, 80)), (&b"SSND"[..], 8 + 8));
assert_eq!(&a[92..], [0.25f32.to_be_bytes(), (-3.0f32).to_be_bytes()].concat());
}
/// ffprobe (an external oracle; skipped where it is not installed) reads every new format.
#[test]
fn ffprobe_reads_32_bit_formats() {
let dir = std::env::temp_dir().join(format!("filmcraft-pcm32-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let s: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.01).sin() * 1.5).collect();
let cases = [
("i32.wav", write_wav(&s, 2, 48_000, I32).unwrap(), "pcm_s32le"),
("f32.wav", write_wav(&s, 2, 48_000, F32).unwrap(), "pcm_f32le"),
("f32-6.wav", write_wav(&s, 6, 48_000, F32).unwrap(), "pcm_f32le"),
("i32.aiff", write_aiff(&s, 2, 48_000, I32).unwrap(), "pcm_s32be"),
("f32.aifc", write_aiff(&s, 2, 48_000, F32).unwrap(), "pcm_f32be"),
];
for (name, bytes, codec) in cases {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
let out = std::process::Command::new("ffprobe")
.args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,bits_per_sample", "-of", "default=nw=1"])
.arg(&path)
.output();
let Ok(out) = out else { break };
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains(&format!("codec_name={codec}")) && text.contains("bits_per_sample=32"), "{name}: {text}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it).
@@ -278,8 +409,8 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.wav");
let frames = 48_000u64 * 60 * 60 * 5; // 5 hours of 24-bit stereo: 5.2 GB
let mut f = header(PcmContainer::Wav, 2, 48_000, 24, frames).unwrap();
encode(PcmContainer::Wav, &[0.0; 9_600], 24, &mut f);
let mut f = header(PcmContainer::Wav, 2, 48_000, I24, frames).unwrap();
encode(PcmContainer::Wav, &[0.0; 9_600], I24, &mut f);
std::fs::write(&path, &f).unwrap();
let out = std::process::Command::new("ffprobe")
.args(["-v", "error", "-show_entries", "format=format_name,duration", "-of", "default=nw=1"])
+101 -10
View File
@@ -223,15 +223,96 @@ pub struct AudioSettings {
pub channels: u32,
/// AAC bitrate.
pub bitrate_kbps: u32,
/// PCM and FLAC sample size: 16 or 24.
/// PCM and FLAC sample size: 16, 24 or 32 (other values round down to one of them, 16 at least).
pub bits: u16,
/// 32-bit floating-point PCM instead of integers (needs `bits` 32; WAV, AIFF and QuickTime).
pub float: bool,
/// FLAC compression level, 0 (fastest) to 8 (smallest). Every level is lossless.
pub flac_level: u8,
}
impl Default for AudioSettings {
fn default() -> Self {
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 }
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, float: false, flac_level: 5 }
}
}
impl AudioSettings {
/// The sample format [`AudioSettings::bits`] and [`AudioSettings::float`] ask for.
pub fn sample_format(&self) -> SampleFormat {
match self.bits {
32.. if self.float => SampleFormat::Float32,
32.. => SampleFormat::Int32,
24.. => SampleFormat::Int24,
_ => SampleFormat::Int16,
}
}
/// Ask for `f` ([`AudioSettings::bits`] and [`AudioSettings::float`]).
pub fn set_sample_format(&mut self, f: SampleFormat) {
self.bits = f.bits();
self.float = f.is_float();
}
}
/// How PCM and FLAC audio stores its samples. The mix is 32-bit float: the integer formats
/// clip it at full scale, 32-bit float keeps it as it is (peaks above 0 dBFS included).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SampleFormat {
Int16,
Int24,
Int32,
Float32,
}
impl SampleFormat {
pub const ALL: [SampleFormat; 4] = [SampleFormat::Int16, SampleFormat::Int24, SampleFormat::Int32, SampleFormat::Float32];
pub fn bits(self) -> u16 {
match self {
SampleFormat::Int16 => 16,
SampleFormat::Int24 => 24,
SampleFormat::Int32 | SampleFormat::Float32 => 32,
}
}
/// Bytes per sample.
pub fn bytes(self) -> usize {
usize::from(self.bits() / 8)
}
pub fn is_float(self) -> bool {
self == SampleFormat::Float32
}
/// The name the Export settings show.
pub fn label(self) -> &'static str {
match self {
SampleFormat::Int16 => "16 bit",
SampleFormat::Int24 => "24 bit",
SampleFormat::Int32 => "32 bit",
SampleFormat::Float32 => "32 bit float",
}
}
/// One mix sample as a signed integer of this size, clipped at full scale (0 for
/// [`SampleFormat::Float32`], which is never quantised). Not-a-number becomes silence.
pub fn quantise(self, s: f32) -> i32 {
// f32 products for 16 and 24 bit (the bytes every earlier export wrote); f64 for 32 bit,
// whose full scale 2^31 - 1 f32 cannot hold. NaN casts to 0, the casts saturate.
let s = s.clamp(-1.0, 1.0);
match self {
SampleFormat::Int16 => (s * 32_767.0).round() as i32,
SampleFormat::Int24 => (s * 8_388_607.0).round() as i32,
SampleFormat::Int32 => (f64::from(s) * 2_147_483_647.0).round() as i32,
SampleFormat::Float32 => 0,
}
}
/// One mix sample as stored by [`SampleFormat::Float32`]: as it is, NaN as silence and
/// infinities as the largest finite values.
pub fn float(s: f32) -> f32 {
if s.is_nan() { 0.0 } else { s.clamp(f32::MIN, f32::MAX) }
}
}
@@ -473,6 +554,16 @@ impl ExportSettings {
Resolved { width: w, height: h, rate, sample_rate: self.audio.sample_rate.unwrap_or(seq_sr), channels, target_kbps: target, max_kbps: max, keyint }
}
/// Whether the audio codec and container of this export store samples as `f`: PCM in WAV,
/// AIFF and QuickTime takes all four, MXF integers only; FLAC integers only; AAC none.
pub fn supports_sample_format(&self, f: SampleFormat) -> bool {
match self.audio_codec() {
AudioCodec::Aac => false,
AudioCodec::Flac => !f.is_float(),
AudioCodec::Pcm | AudioCodec::Auto => !(f.is_float() && self.format.is_mxf()),
}
}
/// The audio codec actually used.
pub fn audio_codec(&self) -> AudioCodec {
match (self.format, self.audio.codec) {
@@ -548,7 +639,7 @@ impl ExportSettings {
Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
};
let audio_bps = if self.has_audio() || self.format.is_audio_only() {
let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 };
let pcm = r.sample_rate as f64 * r.channels as f64 * f64::from(self.audio.sample_format().bits());
match self.audio_codec() {
AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0,
// typical of music and dialogue; noise compresses less, silence far more
@@ -649,13 +740,13 @@ impl ExportSettings {
};
match self.audio_codec() {
AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate),
AudioCodec::Flac => format!(
"FLAC (lossless) {}-bit, level {}, {} Hz, {ch}",
if self.audio.bits >= 24 { 24 } else { 16 },
self.audio.flac_level.min(8),
r.sample_rate
),
_ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate),
AudioCodec::Flac => {
format!("FLAC (lossless) {}-bit, level {}, {} Hz, {ch}", self.audio.sample_format().bits(), self.audio.flac_level.min(8), r.sample_rate)
}
_ => {
let f = self.audio.sample_format();
format!("Uncompressed {}-bit{} PCM, {} Hz, {ch}", f.bits(), if f.is_float() { " float" } else { "" }, r.sample_rate)
}
}
} else {
"No audio".to_string()
+199
View File
@@ -1135,3 +1135,202 @@ fn loudness_normalization_corrects_limiter_loss_and_bounds_impossible_targets()
assert_eq!(chunks.get(), 3);
assert_eq!(reads_at_cancel.get(), Some(reads.load(Ordering::Relaxed)));
}
/// 32-bit integer and float audio in every container that takes them, read back by ffprobe (an
/// external oracle) and, where FilmCraft imports the format, by FilmCraft's own readers.
#[test]
fn thirty_two_bit_audio_in_every_container() {
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
let dir = Scratch::new("pcm32");
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 2)));
let cases: [(Format, SampleFormat, &str, &str, &str); 8] = [
(Format::Wav, SampleFormat::Float32, "f.wav", "pcm_f32le", "32"),
(Format::Wav, SampleFormat::Int32, "i.wav", "pcm_s32le", "32"),
(Format::Aiff, SampleFormat::Float32, "f.aif", "pcm_f32be", "32"),
(Format::Aiff, SampleFormat::Int32, "i.aif", "pcm_s32be", "32"),
(Format::ProRes, SampleFormat::Float32, "f.mov", "pcm_f32le", "32"),
(Format::ProRes, SampleFormat::Int32, "i.mov", "pcm_s32le", "32"),
(Format::MxfOp1a, SampleFormat::Int32, "i.mxf", "pcm_s32le", "32"),
(Format::Flac, SampleFormat::Int32, "i.flac", "flac", "32"),
];
for (format, sf, name, codec, bits) in cases {
let path = dir.path(name);
let mut s = ExportSettings { format, path: path.clone(), range, ..Default::default() };
s.audio.set_sample_format(sf);
assert!(s.supports_sample_format(sf), "{name}");
export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{name}: {e}"));
let bytes = std::fs::read(&path).unwrap();
// FilmCraft's own importer: the -6 dB tone at a peak of 0.5
if !matches!(format, Format::MxfOp1a) {
let src = filmcraft_codecs::open_bytes(name, bytes.into()).unwrap_or_else(|e| panic!("{name}: {e}"));
let a = src.audio(0, 24_000, 48_000).unwrap();
let peak = a.channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs()));
assert!((peak - 0.5).abs() < 0.01, "{name}: peak {peak}");
}
if let Some(j) = ffprobe_json(&["-show_streams"], &path) {
let st = j["streams"].as_array().unwrap().iter().find(|s| s["codec_type"] == "audio").unwrap().clone();
assert_eq!(st["codec_name"].as_str(), Some(codec), "{name}");
let depth = if codec == "flac" { &st["bits_per_raw_sample"] } else { &st["bits_per_sample"] };
assert_eq!(depth.as_str().map(str::to_string).or_else(|| depth.as_u64().map(|v| v.to_string())).as_deref(), Some(bits), "{name}");
}
}
// every 32-bit file holds the same samples (float exactly, integers within one step of 2^-31)
if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("32-bit audio") {
let raw = |name: &str| {
let out = std::process::Command::new(&ffmpeg)
.args(["-v", "error", "-i", &dir.path(name), "-map", "0:a", "-f", "f32le", "-c:a", "pcm_f32le", "-"])
.output()
.unwrap();
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
out.stdout.chunks_exact(4).map(|b| f32::from_le_bytes(b.try_into().unwrap())).collect::<Vec<f32>>()
};
let reference = raw("f.wav");
assert_eq!(reference.len(), 24_000 * 2);
for name in ["f.aif", "f.mov"] {
assert!(raw(name) == reference, "{name}: float samples differ from the WAV's");
}
for name in ["i.wav", "i.aif", "i.mov", "i.mxf", "i.flac"] {
let got = raw(name);
assert_eq!(got.len(), reference.len(), "{name}");
let worst = got.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst < 1e-6, "{name}: differs by {worst}");
}
}
}
/// The mix is 32-bit float: 32-bit float export keeps a +6 dB tone's peaks of 2.0, integers clip
/// them at full scale.
#[test]
fn float_export_keeps_peaks_above_full_scale() {
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(6.0));
let dir = Scratch::new("pcm-hot");
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 4)));
let peak = |sf: SampleFormat, name: &str| {
let path = dir.path(name);
let mut s = ExportSettings { format: Format::Wav, path: path.clone(), range, ..Default::default() };
s.audio.set_sample_format(sf);
export(&p, seq, &s, &m, &Progress::default()).unwrap();
let src = filmcraft_codecs::open_bytes(name, std::fs::read(&path).unwrap().into()).unwrap();
src.audio(0, 12_000, 48_000).unwrap().channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs()))
};
let float = peak(SampleFormat::Float32, "f.wav");
assert!((float - 2.0).abs() < 0.02, "float keeps +6 dBFS: {float}");
for (sf, name) in [(SampleFormat::Int32, "i32.wav"), (SampleFormat::Int24, "i24.wav"), (SampleFormat::Int16, "i16.wav")] {
let p = peak(sf, name);
assert!(p <= 1.0 && p > 0.99, "{name} clips at full scale: {p}");
}
}
#[test]
fn sample_formats_each_codec_and_container_stores() {
use SampleFormat::*;
let supported = |format: Format, codec: AudioCodec| {
let s = ExportSettings { format, audio: AudioSettings { codec, ..Default::default() }, ..Default::default() };
SampleFormat::ALL.iter().filter(|&&f| s.supports_sample_format(f)).copied().collect::<Vec<_>>()
};
assert_eq!(supported(Format::Wav, AudioCodec::Auto), [Int16, Int24, Int32, Float32]);
assert_eq!(supported(Format::Aiff, AudioCodec::Auto), [Int16, Int24, Int32, Float32]);
assert_eq!(supported(Format::ProRes, AudioCodec::Pcm), [Int16, Int24, Int32, Float32]);
assert_eq!(supported(Format::MxfOp1a, AudioCodec::Auto), [Int16, Int24, Int32]);
assert_eq!(supported(Format::MxfOpAtom, AudioCodec::Auto), [Int16, Int24, Int32]);
assert_eq!(supported(Format::Flac, AudioCodec::Auto), [Int16, Int24, Int32]);
assert_eq!(supported(Format::ProRes, AudioCodec::Flac), [Int16, Int24, Int32]);
assert!(supported(Format::H264, AudioCodec::Auto).is_empty(), "MP4 is AAC");
// `bits` rounds down to a size; `float` only with 32
let fmt = |bits, float| AudioSettings { bits, float, ..Default::default() }.sample_format();
assert_eq!(
[fmt(0, false), fmt(20, false), fmt(24, false), fmt(31, false), fmt(32, false), fmt(64, false), fmt(32, true)],
[Int16, Int16, Int24, Int24, Int32, Int32, Float32]
);
}
/// Hostile or contradictory settings (CLI, control channel, MCP, old presets) are errors, never
/// a panic or a silently different file.
#[test]
fn unsupported_sample_formats_are_refused() {
let refused = |format: Format, codec: AudioCodec, bits: u16, float: bool| {
let s = ExportSettings { format, path: "x".into(), audio: AudioSettings { codec, bits, float, ..Default::default() }, ..Default::default() };
s.validate().err().map(|e| e.to_string())
};
assert!(refused(Format::Wav, AudioCodec::Auto, 24, true).is_some_and(|e| e.contains("32")));
assert!(refused(Format::Flac, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("FLAC")));
assert!(refused(Format::ProRes, AudioCodec::Flac, 32, true).is_some_and(|e| e.contains("FLAC")));
assert!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("MXF")));
for (format, codec) in [(Format::Wav, AudioCodec::Auto), (Format::Aiff, AudioCodec::Auto), (Format::ProRes, AudioCodec::Pcm)] {
assert_eq!(refused(format, codec, 32, true), None, "{format:?}");
}
assert_eq!(refused(Format::Flac, AudioCodec::Auto, 32, false), None);
assert_eq!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, false), None);
// AAC has no sample size: the PCM / FLAC choice is ignored, as before
assert_eq!(refused(Format::H264, AudioCodec::Auto, 32, true), None);
// no audio, nothing to refuse
let s = ExportSettings {
format: Format::MxfOp1a,
include_audio: false,
audio: AudioSettings { bits: 32, float: true, ..Default::default() },
..Default::default()
};
assert!(s.validate().is_ok());
}
#[test]
fn sample_format_settings_round_trip_summary_and_estimate() {
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "wav", "audio": {"bits": 32, "float": true}})).unwrap();
assert_eq!(s.audio.sample_format(), SampleFormat::Float32);
let back: ExportSettings = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
assert_eq!(back.audio.sample_format(), SampleFormat::Float32);
// settings saved before 32-bit audio read integers
let old: AudioSettings = serde_json::from_value(serde_json::json!({"bits": 24})).unwrap();
assert_eq!((old.float, old.sample_format()), (false, SampleFormat::Int24));
let sum = |sf: SampleFormat, format: Format| {
let mut s = ExportSettings { format, ..Default::default() };
s.audio.set_sample_format(sf);
(
s.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).audio,
s.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)),
)
};
let (f, f_bytes) = sum(SampleFormat::Float32, Format::Wav);
assert!(f.starts_with("Uncompressed 32-bit float PCM, 48000 Hz"), "{f}");
let (i, _) = sum(SampleFormat::Int32, Format::Wav);
assert!(i.starts_with("Uncompressed 32-bit PCM"), "{i}");
let (_, i16_bytes) = sum(SampleFormat::Int16, Format::Wav);
assert!(f_bytes > i16_bytes * 19 / 10 && f_bytes < i16_bytes * 21 / 10, "{f_bytes} vs {i16_bytes}");
let (flac, _) = sum(SampleFormat::Int32, Format::Flac);
assert!(flac.starts_with("FLAC (lossless) 32-bit"), "{flac}");
}
/// FilmCraft imports its own 32-bit FLAC (symphonia rejected the frame header's 32-bit size code
/// as reserved: "unsupported media"), at full scale and with noise, sample for sample.
#[test]
fn own_importer_reads_32_bit_flac_at_full_scale() {
let n = 20_000usize;
let mut seed = 7u64;
let noise: Vec<i32> = (0..n)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 32) as u32 as i32
})
.collect();
let extremes: Vec<i32> = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN + 1 }).collect();
for level in [0u8, 5, 8] {
let mut enc = filmcraft_flac::Encoder::new(filmcraft_flac::EncoderConfig { level, ..filmcraft_flac::EncoderConfig::new(48_000, 2, 32) }).unwrap();
let mut file = enc.header();
file.extend(enc.encode(&[&extremes, &noise]).unwrap());
file.extend(enc.finish());
let at = filmcraft_flac::STREAMINFO_OFFSET as usize;
file[at..at + 34].copy_from_slice(&enc.streaminfo());
let src = filmcraft_codecs::open_bytes("x.flac", file.into()).unwrap_or_else(|e| panic!("level {level}: {e}"));
let a = src.audio(0, n, 48_000).unwrap();
for (c, want) in [&extremes, &noise].iter().enumerate() {
let got = &a.channels[c];
assert_eq!(got.len(), n, "level {level} channel {c}");
for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() {
let w = (f64::from(w) / 2_147_483_648.0) as f32;
assert!((g - w).abs() <= 1e-6, "level {level} channel {c} sample {i}: {g} vs {w}");
}
}
}
}
+5 -4
View File
@@ -13,7 +13,7 @@ symphonia bootstrap decoder in `filmcraft-codecs`.
```rust
use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET};
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24)
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–32)
cfg.level = 8; // 0 fastest … 8 smallest; 5 by default
let mut enc = Encoder::new(cfg)?;
let mut file = enc.header(); // fLaC + STREAMINFO + VORBIS_COMMENT (vendor)
@@ -37,10 +37,11 @@ let last_len = enc.pending_samples(); // duration
| Subframes | constant, verbatim, fixed predictors (order 0–4) and linear prediction (up to order 12), whichever is smallest |
| Linear prediction | Tukey(0.5) windowed autocorrelation, Levinson–Durbin, 12/15-bit quantised coefficients with error feedback |
| Residual | partitioned Rice codes (4- or 5-bit parameters), the partition order chosen per subframe |
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest |
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest (32-bit: independent only, a side channel would need 33 bits) |
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples,
seek tables.
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, seek tables.
32-bit samples (frame headers point at STREAMINFO for the size) decode only in recent decoders (libFLAC 1.4+,
ffmpeg 6+).
| Level | LPC order | Rice partition order | Stereo decorrelation |
|---|---|---|---|
+2
View File
@@ -53,6 +53,8 @@ fn sample_size_code(bps: u8) -> u8 {
16 => 4,
20 => 5,
24 => 6,
// 32 bits has code 7 since RFC 9639, which earlier decoders (symphonia 0.5, FilmCraft's own
// FLAC import) reject as reserved; 0 ("as STREAMINFO says") decodes everywhere
_ => 0,
}
}
+10 -7
View File
@@ -1,6 +1,6 @@
//! Clean-room FLAC encoder (RFC 9639, Free Lossless Audio Codec).
//!
//! - [`Encoder`]: planar integer samples (8–24 bit, 1–8 channels) → a `.flac` stream: the
//! - [`Encoder`]: planar integer samples (8–32 bit, 1–8 channels) → a `.flac` stream: the
//! [`Encoder::header`] (`fLaC`, STREAMINFO, a VORBIS_COMMENT naming the encoder), then frames of
//! a fixed block size. Each channel of each frame is coded as constant, verbatim, a fixed
//! polynomial predictor or linear prediction, whichever is smallest, with partitioned Rice codes;
@@ -61,7 +61,8 @@ pub struct EncoderConfig {
pub sample_rate: u32,
/// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1).
pub channels: u8,
/// 8–24.
/// 8–32. At 32 bits stereo channels are coded independently: the side channel of
/// left/side, side/right and mid/side coding would need 33 bits.
pub bits_per_sample: u8,
/// Samples per channel per frame, 16–65535 (4096 by default).
pub block_size: u16,
@@ -92,8 +93,8 @@ impl Encoder {
if !(1..=8).contains(&cfg.channels) {
return Err(Error::InvalidConfig("1 to 8 channels"));
}
if !(8..=24).contains(&cfg.bits_per_sample) {
return Err(Error::InvalidConfig("8 to 24 bits per sample"));
if !(8..=32).contains(&cfg.bits_per_sample) {
return Err(Error::InvalidConfig("8 to 32 bits per sample"));
}
if !(1..=1_048_575).contains(&cfg.sample_rate) {
return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz"));
@@ -170,8 +171,9 @@ impl Encoder {
if planar.iter().any(|c| c.len() != n) {
return Err(Error::UnequalChannels);
}
let max = (1i32 << (self.cfg.bits_per_sample - 1)) - 1;
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| s > max || s < -max - 1) {
// i64: at 32 bits the range is all of i32 (1 << 31 overflows i32)
let max = (1i64 << (self.cfg.bits_per_sample - 1)) - 1;
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| i64::from(s) > max || i64::from(s) < -max - 1) {
return Err(Error::SampleRange(bad));
}
for (p, c) in self.pending.iter_mut().zip(planar) {
@@ -217,7 +219,8 @@ impl Encoder {
let effort = self.effort;
let code = |x: &[i32], b: u8| predict::choose(x, b, effort);
let data = match block {
[l, r] if effort.stereo => {
// the side channel takes one bit more than the samples: at most 32 (i32)
[l, r] if effort.stereo && bps < 32 => {
let side: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| a - b).collect();
let mid: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect();
let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps));
+29 -7
View File
@@ -24,7 +24,7 @@ impl Rng {
/// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise,
/// a full-scale square, silence and a lone click.
fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
let max = f64::from((1i32 << (bps - 1)) - 1);
let max = ((1i64 << (bps - 1)) - 1) as f64;
let mut r = Rng(seed | 1);
(0..n)
.map(|i| {
@@ -43,7 +43,7 @@ fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
"click" => f64::from(u8::from(i == n / 2)),
_ => 0.0,
};
((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32)
((v * max).round() as i64).clamp(-(max as i64) - 1, max as i64) as i32
})
.collect()
}
@@ -93,13 +93,14 @@ fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize)
}
// STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16
// and 24 bit compare
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16,
// 24 and 32 bit compare
let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34];
let pcm = match bps {
8 => Some("s8"),
16 => Some("s16le"),
24 => Some("s24le"),
32 => Some("s32le"),
_ => None,
};
if let Some(pcm) = pcm {
@@ -118,7 +119,7 @@ fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize)
#[test]
fn every_signal_round_trips_exactly() {
for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] {
for bps in [16u8, 24] {
for bps in [16u8, 24, 32] {
let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)];
if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() {
return;
@@ -129,11 +130,13 @@ fn every_signal_round_trips_exactly() {
#[test]
fn channel_layouts_rates_and_sizes() {
let cases: [(&str, u8, u8, u32, usize); 6] = [
let cases: [(&str, u8, u8, u32, usize); 8] = [
("mono-8bit", 1, 8, 8_000, 3_001),
("mono-24-44k1", 1, 24, 44_100, 9_999),
("stereo-20bit", 2, 20, 96_000, 10_000),
("5.1-24", 6, 24, 48_000, 12_345),
("mono-32", 1, 32, 48_000, 7_777),
("5.1-32", 6, 32, 96_000, 12_345),
("odd-rate", 2, 16, 37_800, 5_000),
("tiny", 2, 16, 48_000, 1),
];
@@ -167,7 +170,8 @@ fn levels_and_block_sizes_trade_time_for_size() {
fn bad_input_is_an_error_not_a_panic() {
assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 33)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 7)).is_err());
assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err());
assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err());
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap();
@@ -179,3 +183,21 @@ fn bad_input_is_an_error_not_a_panic() {
assert!(enc.finish().is_empty());
assert_eq!(&enc.header()[..4], b"fLaC");
}
/// 32-bit stereo at the extremes of i32: left minus right would need 33 bits, so the encoder must
/// not code a side channel (it overflowed `i32` and wrote garbage); every level decodes exactly.
#[test]
fn full_scale_32_bit_stereo_round_trips() {
let n = 9_000;
let left: Vec<i32> = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN }).collect();
let right: Vec<i32> = left.iter().map(|&v| if v == i32::MAX { i32::MIN } else { i32::MAX }).collect();
let planar = vec![left, right];
for level in [0u8, 5, 8] {
let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 32) };
if round_trip(&format!("extremes-32-level{level}"), &planar, cfg, 4096).is_none() {
return;
}
}
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 32)).unwrap();
assert!(enc.encode(&[&[i32::MIN, i32::MAX], &[0, -1]]).is_ok());
}
+4
View File
@@ -2922,6 +2922,10 @@
Sample Size Tamaño de muestra
16 bit 16 bits
24 bit 24 bits
32 bit 32 bits
32 bit float 32 bits de coma flotante
FLAC stores whole-number samples only FLAC solo guarda muestras enteras
MXF export writes integer PCM only La exportación MXF solo escribe PCM entero
Size / Opacity Tamaño / opacidad
Image Overlay Superposición de imagen
PNG or JPEG file Archivo PNG o JPEG
Can't render this file because it contains an unexpected character in line 3 and column 95.
+4
View File
@@ -2922,6 +2922,10 @@
Sample Size Taille d'échantillon
16 bit 16 bits
24 bit 24 bits
32 bit 32 bits
32 bit float 32 bits à virgule flottante
FLAC stores whole-number samples only FLAC ne stocke que des échantillons entiers
MXF export writes integer PCM only L’export MXF n’écrit que du PCM entier
Size / Opacity Taille / opacité
Image Overlay Superposition d'image
PNG or JPEG file Fichier PNG ou JPEG
Can't render this file because it contains an unexpected character in line 5 and column 83.
+4
View File
@@ -2927,6 +2927,10 @@
Sample Size Ukuran Sampel
16 bit 16 bit
24 bit 24 bit
32 bit 32 bit
32 bit float 32 bit titik mengambang
FLAC stores whole-number samples only FLAC hanya menyimpan sampel bilangan bulat
MXF export writes integer PCM only Ekspor MXF hanya menulis PCM bilangan bulat
Size / Opacity Ukuran / Opasitas
Image Overlay Overlay Gambar
PNG or JPEG file Berkas PNG atau JPEG
Can't render this file because it contains an unexpected character in line 6 and column 14.
+4
View File
@@ -2911,6 +2911,10 @@
Sample Size サンプルサイズ
16 bit 16 ビット
24 bit 24 ビット
32 bit 32 ビット
32 bit float 32 ビット浮動小数点
FLAC stores whole-number samples only FLAC は整数サンプルのみ保存できます
MXF export writes integer PCM only MXF 書き出しは整数 PCM のみです
Size / Opacity サイズ / 不透明度
Image Overlay 画像のオーバーレイ
PNG or JPEG file PNG または JPEG ファイル
Can't render this file because it contains an unexpected character in line 11 and column 59.
+4
View File
@@ -2924,6 +2924,10 @@
Sample Size Tamanho da amostra
16 bit 16 bits
24 bit 24 bits
32 bit 32 bits
32 bit float 32 bits de ponto flutuante
FLAC stores whole-number samples only O FLAC só armazena amostras inteiras
MXF export writes integer PCM only A exportação MXF só grava PCM inteiro
Size / Opacity Tamanho / opacidade
Image Overlay Sobreposição de imagem
PNG or JPEG file Arquivo PNG ou JPEG
Can't render this file because it contains an unexpected character in line 6 and column 4.
+4
View File
@@ -2905,6 +2905,10 @@
Sample Size 采样大小
16 bit 16 位
24 bit 24 位
32 bit 32 位
32 bit float 32 位浮点
FLAC stores whole-number samples only FLAC 只能存储整数采样
MXF export writes integer PCM only MXF 导出仅写入整数 PCM
Size / Opacity 大小 / 不透明度
Image Overlay 图像叠加
PNG or JPEG file PNG 或 JPEG 文件
Can't render this file because it contains an unexpected character in line 3 and column 95.
+31 -8
View File
@@ -24,7 +24,7 @@ use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key};
use filmcraft_engine::export::{
AudioCodec, BitrateMode, ExportSettings, ExrCompression, FieldOrder, Format, GpuRendering, H264Profile, HardwareEncoding, Multiplexer, MxfVideoCodec,
Placement, Scaling, TextOverlay, builtin_presets, format_bytes,
Placement, SampleFormat, Scaling, TextOverlay, builtin_presets, format_bytes,
};
use filmcraft_engine::time::{FrameRate, Tick};
use serde::{Deserialize, Serialize};
@@ -310,11 +310,22 @@ fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui
/// A combo box over `options` (label, enabled); returns the chosen index.
fn combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, selected: &str, options: &[(String, bool)], width: f32) -> Option<usize> {
let why = tl!("Not supported by FilmCraft's encoders yet");
let options: Vec<(String, Option<&str>)> = options.iter().map(|(l, on)| (l.clone(), (!on).then_some(why))).collect();
combo_why(ui, reg, id, selected, &options, width)
}
/// [`combo`] whose options carry why they are greyed out (label, `Some(reason)` when disabled).
fn combo_why(ui: &mut egui::Ui, reg: &mut Reg, id: &str, selected: &str, options: &[(String, Option<&str>)], width: f32) -> Option<usize> {
let mut chosen = None;
let r = egui::ComboBox::from_id_salt(id).selected_text(crate::i18n::t(selected)).width(width).show_ui(ui, |ui| {
for (i, (label, enabled)) in options.iter().enumerate() {
let resp = ui.add_enabled(*enabled, egui::Button::selectable(label == selected, crate::i18n::t(label)));
let resp = if *enabled { resp } else { resp.on_disabled_hover_text(tl!("Not supported by FilmCraft's encoders yet")) };
for (i, (label, why)) in options.iter().enumerate() {
let enabled = why.is_none();
let resp = ui.add_enabled(enabled, egui::Button::selectable(label == selected, crate::i18n::t(label)));
let resp = match why {
Some(why) => resp.on_disabled_hover_text(*why),
None => resp,
};
reg.add(format!("{id}.option.{i}"), resp.rect, label.clone());
if resp.clicked() {
chosen = Some(i);
@@ -902,10 +913,22 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
});
} else {
row(ui, t, tl!("Sample Size"), |ui| {
let sizes = [tl!("16 bit"), tl!("24 bit")];
let cur = if s.audio.bits >= 24 { sizes[1] } else { sizes[0] };
if let Some(i) = combo(ui, reg, "export.audio.bits", cur, &opts(&sizes), 120.0) {
s.audio.bits = if i == 0 { 16 } else { 24 };
// every size, the ones this codec and container cannot store greyed out; a size the
// audio format no longer takes (float after switching to FLAC) becomes 32-bit integer
if !s.supports_sample_format(s.audio.sample_format()) {
s.audio.set_sample_format(SampleFormat::Int32);
}
let sizes = [tl!("16 bit"), tl!("24 bit"), tl!("32 bit"), tl!("32 bit float")];
let why = |f: SampleFormat| match (s.supports_sample_format(f), s.audio_codec()) {
(true, _) => None,
(false, AudioCodec::Flac) => Some(tl!("FLAC stores whole-number samples only")),
(false, _) if s.format.is_mxf() => Some(tl!("MXF export writes integer PCM only")),
(false, _) => Some(tl!("Not supported by FilmCraft's encoders yet")),
};
let labels: Vec<(String, Option<&str>)> = SampleFormat::ALL.iter().zip(sizes).map(|(&f, l)| (l.to_string(), why(f))).collect();
let cur = SampleFormat::ALL.iter().position(|&f| f == s.audio.sample_format()).and_then(|i| sizes.get(i).copied()).unwrap_or(sizes[0]);
if let Some(&f) = combo_why(ui, reg, "export.audio.bits", cur, &labels, 120.0).and_then(|i| SampleFormat::ALL.get(i)) {
s.audio.set_sample_format(f);
}
});
}
+69
View File
@@ -441,3 +441,72 @@ fn export_mode_file_name_can_be_emptied_and_retyped() {
d.frames(2);
assert_eq!(d.app().ui.export.file_name, "Final");
}
/// Sample Size lists every size (16, 24, 32 bit and 32 bit float) the way Audio Format lists every
/// codec: what the chosen audio format and container cannot store is shown greyed out and cannot
/// be picked, and a size that stops being available (float after switching to FLAC) becomes
/// 32-bit integer.
#[test]
fn sample_size_lists_every_size_and_greys_out_what_the_format_cannot_store() {
use filmcraft_engine::export::{AudioCodec, ExportSettings, Format, SampleFormat};
let mut d = Driver::new("sample-size");
d.ok("ui.set", json!({"mode": "export"}));
d.frames(4);
// collapse Video so the Audio section sits under the format
d.click("export.section.video");
d.app().ui.export.settings = ExportSettings { format: Format::Wav, ..Default::default() };
d.frames(4);
let sizes = ["16 bit", "24 bit", "32 bit", "32 bit float"];
let open_sizes = |d: &mut Driver| {
if !d.has("export.audio.bits.option.0") {
d.click("export.audio.bits");
}
for (i, label) in sizes.iter().enumerate() {
let id = format!("export.audio.bits.option.{i}");
let e = d.ok("ui.elements", json!({"prefix": id}));
assert_eq!(e.as_array().and_then(|a| a.iter().find(|e| e["id"] == json!(id))).map(|e| e["label"].clone()), Some(json!(label)), "{id}");
}
};
// WAV: all four; pick 32 bit float
open_sizes(&mut d);
d.snapshot("export-sample-size-wav");
d.click("export.audio.bits.option.3");
assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Float32);
// FLAC: float is greyed out, so the float choice becomes 32-bit integer and clicking float
// changes nothing
d.app().ui.export.settings.format = Format::Flac;
d.frames(4);
assert_eq!(d.app().ui.export.settings.audio_codec(), AudioCodec::Flac);
assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Int32);
open_sizes(&mut d);
d.snapshot("export-sample-size-flac");
d.click("export.audio.bits.option.3");
assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Int32, "32 bit float is not available for FLAC");
if d.has("export.audio.bits.option.0") {
d.click("export.audio.bits");
}
d.click("export.audio.bits");
d.click("export.audio.bits.option.1");
assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Int24);
// MXF: integer PCM only
d.app().ui.export.settings = ExportSettings { format: Format::MxfOp1a, ..Default::default() };
d.frames(4);
open_sizes(&mut d);
d.snapshot("export-sample-size-mxf");
d.click("export.audio.bits.option.3");
assert!(!d.app().ui.export.settings.audio.float, "32 bit float is not available for MXF");
if d.has("export.audio.bits.option.0") {
d.click("export.audio.bits");
}
// QuickTime PCM (ProRes): all four again
d.app().ui.export.settings = ExportSettings { format: Format::ProRes, ..Default::default() };
d.frames(4);
open_sizes(&mut d);
d.click("export.audio.bits.option.3");
assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Float32);
d.snapshot("export-sample-size-prores");
}
+5 -3
View File
@@ -668,11 +668,11 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
| `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) |
| `mjpeg` | built in | MOV |
| `aac` | `filmcraft-aac` (AAC-LC, the audio settings' bitrate) | audio only: MPEG-4 audio `.m4a` (`isobmff`, priming in an edit list), or raw ADTS when the path ends in `.aac` |
| `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) |
| `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24/32-bit integer | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) |
| `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`<stem>_A1.mxf` …, `Report::extra_files`) |
| `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
| `tga`, `dpx`, `exr` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268; OpenEXR half-float linear RGB(A), uncompressed scan lines) | numbered stills, as above |
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) |
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1; `WAVE_FORMAT_IEEE_FLOAT` + `fact` for float) / AIFF (16-, 24- or 32-bit PCM; 32-bit float as AIFF-C `fl32`) |
- **Reproducible.** The same project and settings give the same file on every machine. Frames render
in batches of one per core, but the muxer gets them in fixed groups of 16 output frames
@@ -682,7 +682,9 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
- **Settings.** `ExportSettings` (serde, camelCase, every field optional) holds Video (frame size or
Match Source, frame rate, Scale to Fit / Fill / Stretch, pixel aspect, field order — progressive
only, profile / level, bitrate encoding, target / maximum / adaptive bitrate, keyframe distance,
maximum render quality), Audio (codec, sample rate, channels, AAC bitrate, PCM sample size),
maximum render quality), Audio (codec, sample rate, channels, AAC bitrate, PCM / FLAC sample size: `bits` 16, 24 or 32
and `float` for 32-bit float PCM in WAV, AIFF and QuickTime — `supports_sample_format` says
which a codec and container store, and Export mode greys out the rest),
Multiplexer (audio channels: mono, stereo or 5.1, §5.1.1), Captions (burn-in or SRT / WebVTT sidecar), Effects (image / name / timecode
overlays, video limiter, loudness normalization), Metadata (`udta` `©nam`, `©ART`, `©cpy`, `©des`,
`©cmt`). `settings.summary()` / `estimate_bytes()` feed Export mode's Summary.