Merge remote-tracking branch 'upstream/main' into gap-mask-rounded-corners

# Conflicts:
#	docs/gaps.md
This commit is contained in:
vangel76 committed 2026-10-11 01:00:22 +02:00
commit 2ca656684e
28 files changed
+572 -41

No files matched your search

+72 -2
View File
@@ -20,6 +20,9 @@ enum Inner {
Aac { dec: Box<filmcraft_aac::Decoder>, asc: Vec<u8>, up: Option<Box<Upsample2x>> },
/// AAC whose configuration arrives with the first frame (ADTS / LATM).
AacPending,
/// MPEG audio at this rate whose layer arrives with the first frame (MP4 / QuickTime name the
/// codec, not the layer, and symphonia's decoder reads only the layer it was made for).
MpegPending(u32),
/// Our own Opus decoder (always 48 kHz output; pre-skip is left to container timestamps).
/// `order` maps output channel → decoded channel (Vorbis → WAV/SMPTE order for surround).
Opus { dec: Box<filmcraft_opus::Decoder>, order: Option<&'static [usize]> },
@@ -133,6 +136,12 @@ impl PacketDecoder {
};
Self::new(codec, sample_rate, None)
}
/// MPEG-1/2 audio of a layer the frame headers name (see [`mpeg_audio_layer`]): MP4's
/// `esds` object types 0x69 / 0x6B and QuickTime's `.mp3` / `.mp2` cover layers I–III alike
/// (ffmpeg stores MP2 as `mp4a` + 0x6B).
pub fn lazy_mpeg_audio(sample_rate: u32) -> Self {
Self { inner: Inner::MpegPending(sample_rate), channels: 0 }
}
/// AAC configured by [`Self::ensure_aac`] from in-band headers (ADTS, LATM).
pub fn lazy_aac() -> Self {
Self { inner: Inner::AacPending, channels: 0 }
@@ -160,7 +169,7 @@ impl PacketDecoder {
use filmcraft_isobmff::CodecConfig as C;
match c {
C::Aac(a) => Self::aac(&a.asc, if rate > 0 { rate } else { a.sample_rate }),
C::Mp3 => Self::new(CODEC_TYPE_MP3, rate, None),
C::Mp3 => Ok(Self::lazy_mpeg_audio(rate)),
C::Ac3 { .. } | C::Eac3 { .. } => Self::ac3(),
C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())),
C::Flac(_) => Self::new(CODEC_TYPE_FLAC, rate, None),
@@ -170,6 +179,10 @@ impl PacketDecoder {
}
/// Decode one packet into planar channels.
pub fn decode(&mut self, data: &[u8], ts: u64) -> Result<Vec<Vec<f32>>> {
if let Inner::MpegPending(rate) = self.inner {
let layer = mpeg_audio_layer(data).ok_or_else(|| CodecError::Decode("MPEG audio: no frame header".into()))?;
self.inner = Self::mpeg_audio(layer, rate)?.inner;
}
let dec = match &mut self.inner {
Inner::Aac { dec, up, .. } => {
let mut out = dec.decode(data).map_err(|e| CodecError::Decode(e.to_string()))?;
@@ -192,6 +205,7 @@ impl PacketDecoder {
}
Inner::Symphonia(d) => d,
Inner::AacPending => return Err(CodecError::Decode("AAC: no configuration yet".into())),
Inner::MpegPending(_) => return Err(CodecError::Decode("MPEG audio: no frame header".into())),
Inner::Ac3(d) => {
// a packet may hold several syncframes (MP4 / Matroska); E-AC-3 dependent
// substreams and further programs decode to no channels and are skipped
@@ -245,11 +259,21 @@ impl PacketDecoder {
Inner::Opus { dec, .. } => dec.reset(),
Inner::Symphonia(d) => d.reset(),
Inner::Ac3(d) => d.reset(),
Inner::AacPending => {}
Inner::AacPending | Inner::MpegPending(_) => {}
}
}
}
/// The layer (1, 2 or 3) named by the MPEG-1/2 audio frame header that starts `frame`: the 12-bit
/// sync word (11 bits for MPEG-2.5), then `ID` and the 2-bit `layer` field, 3 for layer I down to 1
/// for layer III (ISO/IEC 11172-3 §2.4.2.3). `None` without a sync word or for the reserved layer 0.
pub fn mpeg_audio_layer(frame: &[u8]) -> Option<u8> {
match *frame {
[0xFF, b1, ..] if b1 & 0xE0 == 0xE0 && (b1 >> 1) & 3 != 0 => Some(4 - ((b1 >> 1) & 3)),
_ => None,
}
}
/// The output rate of an AAC stream: twice the core rate for HE-AAC (ISO/IEC 14496-3 §1.6.5).
/// Explicit signalling names the rate in the `AudioSpecificConfig`; with implicit signalling (an
/// AAC-LC config at 24 kHz or less) SBR is only visible as fill-element data in the access units,
@@ -1000,3 +1024,49 @@ mod packet_time_tests {
assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &[0x0B, 0x77], 48_000), None);
}
}
#[cfg(test)]
mod mpeg_audio_tests {
use super::*;
/// A silent mono 48 kHz MPEG-1 frame of `layer` (2: 64 kbps, 192 bytes; 3: 56 kbps, 168
/// bytes): header, then all-zero bit allocation / side information.
fn silent_frame(layer: u8) -> Vec<u8> {
let (b1, len) = if layer == 2 { (0xFD, 192) } else { (0xFB, 168) };
let mut f = vec![0xFF, b1, 0x44, 0xC0];
f.resize(len, 0);
f
}
#[test]
fn layer_comes_from_the_frame_header() {
assert_eq!(mpeg_audio_layer(&[0xFF, 0xFF, 0x10, 0xC0]), Some(1));
assert_eq!(mpeg_audio_layer(&silent_frame(2)), Some(2));
assert_eq!(mpeg_audio_layer(&silent_frame(3)), Some(3));
// MPEG-2 layer III, MPEG-2.5 layer II
assert_eq!(mpeg_audio_layer(&[0xFF, 0xF3, 0x90, 0x44]), Some(3));
assert_eq!(mpeg_audio_layer(&[0xFF, 0xE5, 0x90, 0x44]), Some(2));
// reserved layer, no sync word, too short
for bad in [&[0xFF, 0xF9, 0x44, 0xC0][..], &[0xFF, 0x1D], &[0x00, 0xFD], &[0xFF], &[]] {
assert_eq!(mpeg_audio_layer(bad), None, "{bad:?}");
}
}
/// #800: ffmpeg stores MP2 in MP4 as `mp4a` + object type 0x6B, which names no layer. Handing
/// it to the layer III decoder rejected every frame and the track played as silence.
#[test]
fn mp4_mpeg_audio_decodes_the_layer_its_frames_name() {
for layer in [2u8, 3] {
let mut d = PacketDecoder::for_isobmff(&filmcraft_isobmff::CodecConfig::Mp3, 48_000).unwrap();
// a packet without a frame header is an error, not a crash, and configures nothing
assert!(d.decode(&[0u8; 16], 0).is_err());
d.reset();
for k in 0..3u64 {
let out = d.decode(&silent_frame(layer), k * 1152).unwrap_or_else(|e| panic!("layer {layer}: {e}"));
assert_eq!(out.len(), 1, "layer {layer}");
assert_eq!(out[0].len(), 1152, "layer {layer}");
assert!(out[0].iter().all(|&s| s == 0.0), "layer {layer}");
}
}
}
}
+12 -1
View File
@@ -547,7 +547,18 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option
CodecConfig::Opus(o) => (crate::audio::OPUS_RATE, (o.output_channels as u32).max(1), None),
_ => (if ap.sample_rate > 0.0 { ap.sample_rate as u32 } else { t.timescale }, ap.channels.max(1), None),
};
Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec: codec_label(&entry.codec), bits_per_sample: bits })
let codec = match &entry.codec {
// these entries name no layer (ffmpeg stores MP2 as `mp4a` + 0x6B): the first frame does
CodecConfig::Mp3 => {
let head = t.samples.first().and_then(|s| filmcraft_media::reader::read_range(&*bytes.0, s.offset, (s.size as usize).min(4)).ok());
match head.as_deref().and_then(crate::audio::mpeg_audio_layer) {
Some(1 | 2) => "MPEG Audio".into(),
_ => codec_label(&entry.codec),
}
}
c => codec_label(c),
};
Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec, bits_per_sample: bits })
}
fn codec_label(c: &CodecConfig) -> String {
+32
View File
@@ -215,3 +215,35 @@ fn open_gop_leading_pictures_decode_alone() {
}
}
}
/// #800: MP2 in MP4 (`mp4a` + object type 0x6B, which names no layer) and in MOV (QuickTime
/// `.mp2`) decodes sample-exactly as ffmpeg does: same length, the encoder delay (481 samples)
/// trimmed by the edit list, and the same samples from the first one on, also after a seek. It
/// used to go to the layer III decoder (MP4: silence) or not decode at all (MOV).
#[test]
fn mp2_in_mp4_and_mov_matches_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (name, rate) in [("mp2_stereo_224k_48k.mp4", 48_000u32), ("mp2_stereo_224k_44k.mov", 44_100)] {
let input = format!("anoisesrc=d=1:c=pink:r={rate}:a=0.3");
let Some(f) = fixture(&ff, name, &["-f", "lavfi", "-i", &input, "-ac", "2", "-c:a", "mp2", "-b:a", "224k"]) else {
eprintln!("SKIPPED ({name}): this ffmpeg can't encode it");
continue;
};
let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap();
let a = src.info().audio().cloned().unwrap();
assert_eq!((a.sample_rate, a.channels, a.codec.as_str()), (rate, 2, "MPEG Audio"), "{name}");
let want = ffmpeg_audio_f32(&ff, &f, &[]);
let n = want.len() / 2;
assert!(n > rate as usize * 9 / 10, "{name}: ffmpeg decoded {n} samples");
let worst = |from: usize, len: usize| {
let got = src.audio(from as i64, len, rate).unwrap();
(0..len).map(|k| (0..2).map(|c| (got.channels[c][k] - want[2 * (from + k) + c]).abs()).fold(0f32, f32::max)).fold(0f32, f32::max)
};
let level = want.iter().fold(0f32, |m, v| m.max(v.abs()));
assert!(level > 0.1, "{name}: the source is silent ({level})");
// a seek into the middle first, then the whole track from the start
let (mid, all) = (worst(n / 2 + 77, 3000), worst(0, n));
println!("{name}: {n} samples, max error mid {mid:.2e}, whole {all:.2e}");
assert!(mid <= 1e-4 && all <= 1e-4, "{name}: differs from ffmpeg by {mid} (mid) / {all} (whole)");
}
}
+1 -1
View File
@@ -1285,7 +1285,7 @@ fn build() -> Vec<CommandSpec> {
"Media…",
["File", "Export"],
None,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
has_seq,
crate::export_tools::export_media
),
+2 -1
View File
@@ -137,7 +137,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
Some(a) => (a[0].as_u64().unwrap(), a[1].as_u64().unwrap()),
None => (1920, 1080),
};
if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx") {
if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx" | "exr") {
let files = files_in(&sub);
let ext = Path::new(&out).extension().unwrap().to_string_lossy().to_string();
assert_eq!(frames, 7, "frames 23..=29 at 23.976 fps");
@@ -151,6 +151,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
"jpg" => "mjpeg",
"tga" => "targa",
"dpx" => "dpx",
"exr" => "exr",
_ => "bmp",
};
assert_eq!(v["codec_name"], codec, "{name}");
+27 -7
View File
@@ -6,7 +6,7 @@
//!
//! Video encoders implement [`VideoEncoder`]; codec crates register theirs with
//! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP / JPEG /
//! Targa / DPX sequences, GIF, WAV and AIFF.
//! Targa / DPX / OpenEXR sequences, GIF, WAV and AIFF.
//!
//! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio
//! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the
@@ -106,6 +106,9 @@ pub enum Format {
/// Numbered DPX stills (SMPTE ST 268, 10-bit RGB, BT.709).
#[serde(rename = "dpx", alias = "DpxSequence")]
DpxSequence,
/// Numbered OpenEXR stills (half-float scene-linear RGB, RGBA with [`ExportSettings::alpha`]).
#[serde(rename = "exr", alias = "ExrSequence")]
ExrSequence,
#[serde(rename = "gif", alias = "Gif")]
Gif,
#[serde(rename = "wav", alias = "Wav")]
@@ -140,6 +143,7 @@ impl Format {
"jpg" | "jpgsequence" | "jpegsequence" => Format::JpegSequence,
"tga" | "targa" | "tgasequence" | "targasequence" => Format::TgaSequence,
"dpx" | "dpxsequence" => Format::DpxSequence,
"exr" | "openexr" | "exrsequence" | "openexrsequence" => Format::ExrSequence,
"gif" | "animatedgif" => Format::Gif,
"wav" | "waveform" | "waveformaudio" => Format::Wav,
"aif" | "aiff" | "aifc" => Format::Aiff,
@@ -162,6 +166,7 @@ impl Format {
Format::JpegSequence => "jpg",
Format::TgaSequence => "tga",
Format::DpxSequence => "dpx",
Format::ExrSequence => "exr",
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aiff",
@@ -179,6 +184,7 @@ impl Format {
Format::JpegSequence => "jpg",
Format::TgaSequence => "tga",
Format::DpxSequence => "dpx",
Format::ExrSequence => "exr",
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aif",
@@ -200,6 +206,7 @@ impl Format {
Format::JpegSequence => "JPEG",
Format::TgaSequence => "Targa",
Format::DpxSequence => "DPX",
Format::ExrSequence => "OpenEXR",
Format::Gif => "Animated GIF",
Format::Wav => "Waveform Audio",
Format::Aiff => "AIFF",
@@ -209,7 +216,16 @@ impl Format {
}
/// Numbered stills, one per frame ([`image_sequence_path`]).
pub fn is_image_sequence(self) -> bool {
matches!(self, Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::JpegSequence | Format::TgaSequence | Format::DpxSequence)
matches!(
self,
Format::PngSequence
| Format::TiffSequence
| Format::BmpSequence
| Format::JpegSequence
| Format::TgaSequence
| Format::DpxSequence
| Format::ExrSequence
)
}
/// An MXF container format.
pub fn is_mxf(self) -> bool {
@@ -232,7 +248,7 @@ impl Format {
pub fn is_mp4_with(self, mux: Multiplexer) -> bool {
self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4)
}
pub const ALL: [Format; 18] = [
pub const ALL: [Format; 19] = [
Format::H264,
Format::Hevc,
Format::Av1,
@@ -246,6 +262,7 @@ impl Format {
Format::JpegSequence,
Format::TgaSequence,
Format::DpxSequence,
Format::ExrSequence,
Format::Gif,
Format::Wav,
Format::Aiff,
@@ -302,9 +319,10 @@ pub struct ExportSettings {
/// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI).
#[serde(default)]
pub sdr: bool,
/// Keep the alpha channel in PNG, TIFF and Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies
/// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over
/// black (#160). Other formats ignore it.
/// Keep the alpha channel in PNG, TIFF, Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ
/// QuickTime movies (straight alpha, Premiere's "Include Alpha Channel"; OpenEXR stores it
/// premultiplied, as the format defines). Off, every frame is flattened over black (#160).
/// Other formats ignore it.
#[serde(default)]
pub alpha: bool,
/// Output frame size (None = Match Source: the sequence size times `scale`).
@@ -1659,7 +1677,8 @@ pub fn encode_png(rgba: Vec<u8>, w: u32, h: u32) -> Result<Vec<u8>> {
}
/// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha
/// channel it is given; `alpha` keeps it in TIFF and Targa too, which are otherwise written as RGB.
/// channel it is given; `alpha` keeps it in TIFF, Targa and OpenEXR too, which are otherwise written
/// as RGB.
/// JPEG uses the default export quality ([`encode_still_with_quality`] picks another).
pub fn encode_still(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality)
@@ -1683,6 +1702,7 @@ pub fn encode_still_with_quality(format: Format, rgba: Vec<u8>, w: u32, h: u32,
}
Format::TgaSequence => return raster::tga(&rgba, w, h, alpha),
Format::DpxSequence => return raster::dpx(&rgba, w, h),
Format::ExrSequence => return raster::exr(&rgba, w, h, alpha),
Format::PngSequence => {
image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)?
}
+6
View File
@@ -160,6 +160,12 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
ExportSettings { format: Format::TgaSequence, ..Default::default() },
),
p("DPX Sequence", "Image Sequence", "Numbered DPX stills (10-bit RGB)", ExportSettings { format: Format::DpxSequence, ..Default::default() }),
p(
"OpenEXR Sequence",
"Image Sequence",
"Numbered OpenEXR stills (half float, linear, uncompressed)",
ExportSettings { format: Format::ExrSequence, ..Default::default() },
),
p(
"Animated GIF 640×360",
"Animated GIF",
+226 -1
View File
@@ -1,9 +1,12 @@
//! Uncompressed still formats written directly from RGBA8 frames: Targa and DPX.
//! Uncompressed still formats written directly from RGBA8 frames: Targa, DPX and OpenEXR.
//!
//! Targa follows the Truevision TGA File Format Specification 2.0 (image type 2, uncompressed
//! true-colour, bottom-left origin, the layout every reader accepts). DPX follows SMPTE ST 268
//! (version 2.0 header): one RGB image element, 10 bits per component packed into 32-bit
//! big-endian words (method A, two padding bits at the bottom), BT.709 transfer and colorimetry.
//! OpenEXR follows the public OpenEXR file layout (version 2, single-part scan-line file): half-float
//! B, G, R (and A) channels, no compression, one scan line per chunk, scene-linear Rec.709 values
//! with premultiplied alpha, as EXR readers expect.
use crate::{ExportError, Result};
/// `w * h * 4` when `rgba` holds exactly that many bytes.
@@ -129,6 +132,120 @@ pub(crate) fn dpx(rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>> {
Ok(out)
}
/// IEEE 754 binary16 bits of `v`, rounded to nearest even (overflow → infinity, NaN → quiet NaN).
fn half(v: f32) -> u16 {
let b = v.to_bits();
let sign = ((b >> 16) & 0x8000) as u16;
let exp = ((b >> 23) & 0xFF) as i32;
let man = b & 0x7F_FFFF;
if exp == 0xFF {
return sign | 0x7C00 | if man != 0 { 0x200 } else { 0 };
}
// rounds `m >> shift` to nearest, ties to even (`shift` is 1..=24)
let round = |m: u32, shift: u32| {
let (q, rem, mid) = (m >> shift, m & ((1 << shift) - 1), 1 << (shift - 1));
if rem > mid || (rem == mid && q & 1 == 1) { q + 1 } else { q }
};
let e = exp - 127 + 15;
if e >= 0x1F {
sign | 0x7C00
} else if e <= 0 {
if e < -10 {
return sign;
}
// subnormal half: the 24-bit significand scaled by 2^(e - 14); rounding up may carry
// into the smallest normal, which is the right encoding
sign | round(man | 0x80_0000, (14 - e) as u32) as u16
} else {
// a carry out of the mantissa bumps the exponent (up to infinity), as it should
sign | round(((e as u32) << 23) | man, 13) as u16
}
}
/// An OpenEXR file: half-float scene-linear Rec.709 RGB, or RGBA with `alpha` (premultiplied, as
/// EXR defines alpha; the frame's straight alpha is multiplied in after linearisation).
pub(crate) fn exr(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
let n = checked_len(rgba, w, h)?;
let (Ok(x_max), Ok(y_max)) = (i32::try_from(w - 1), i32::try_from(h - 1)) else {
return Err(ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large")));
};
// channels in the alphabetical order the file layout requires
let names: &[u8] = if alpha { b"ABGR" } else { b"BGR" };
let line = (w as usize).checked_mul(names.len() * 2).ok_or_else(|| ExportError::Encode("frame size".into()))?;
let line_size = i32::try_from(line).map_err(|_| ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large")))?;
let mut hd = Vec::with_capacity(512);
hd.extend_from_slice(&[0x76, 0x2F, 0x31, 0x01]); // magic number 20000630
hd.extend_from_slice(&[2, 0, 0, 0]); // version 2, single-part scan-line file, short names
let attr = |hd: &mut Vec<u8>, name: &str, ty: &str, value: &[u8]| {
for s in [name, ty] {
hd.extend_from_slice(s.as_bytes());
hd.push(0);
}
hd.extend_from_slice(&(value.len() as i32).to_le_bytes());
hd.extend_from_slice(value);
};
let le = |v: &[f32]| v.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>();
let mut channels = Vec::new();
for &c in names {
channels.extend_from_slice(&[c, 0]);
channels.extend_from_slice(&1_i32.to_le_bytes()); // pixel type HALF
channels.extend_from_slice(&[0, 0, 0, 0]); // pLinear 0, reserved
channels.extend_from_slice(&1_i32.to_le_bytes()); // x sampling
channels.extend_from_slice(&1_i32.to_le_bytes()); // y sampling
}
channels.push(0);
let window: Vec<u8> = [0, 0, x_max, y_max].iter().flat_map(|v| v.to_le_bytes()).collect();
attr(&mut hd, "channels", "chlist", &channels);
attr(&mut hd, "chromaticities", "chromaticities", &le(&[0.64, 0.33, 0.30, 0.60, 0.15, 0.06, 0.3127, 0.3290]));
attr(&mut hd, "compression", "compression", &[0]); // NO_COMPRESSION
attr(&mut hd, "dataWindow", "box2i", &window);
attr(&mut hd, "displayWindow", "box2i", &window);
attr(&mut hd, "lineOrder", "lineOrder", &[0]); // INCREASING_Y
attr(&mut hd, "pixelAspectRatio", "float", &le(&[1.0]));
attr(&mut hd, "screenWindowCenter", "v2f", &le(&[0.0, 0.0]));
attr(&mut hd, "screenWindowWidth", "float", &le(&[1.0]));
hd.push(0); // end of header
// offset table (one chunk per scan line), then the chunks: y, data size, pixel data
let chunk = line + 8;
let table = (h as usize).checked_mul(8);
let body = (h as usize).checked_mul(chunk);
let size = table.zip(body).and_then(|(t, b)| hd.len().checked_add(t)?.checked_add(b)).ok_or_else(|| ExportError::Encode("frame size".into()))?;
let mut out = Vec::with_capacity(size);
out.extend_from_slice(&hd);
let first = (hd.len() + h as usize * 8) as u64;
for y in 0..h as u64 {
out.extend_from_slice(&(first + y * chunk as u64).to_le_bytes());
}
let lin = filmcraft_color::srgb_u8_to_linear_table();
let opaque: [u16; 256] = std::array::from_fn(|i| half(lin[i]));
let row = w as usize * 4;
let mut px = vec![0_u16; w as usize * names.len()];
for (y, src) in rgba[..n].chunks_exact(row).enumerate() {
// planar per line: every pixel of the first channel, then the next channel …
let wn = w as usize;
for (x, &[r, g, b, a]) in src.as_chunks::<4>().0.iter().enumerate() {
let rgb = [b, g, r];
if alpha {
let k = f32::from(a) / 255.0;
px[x] = half(k);
for (c, &v) in rgb.iter().enumerate() {
px[(c + 1) * wn + x] = if a == 255 { opaque[v as usize] } else { half(lin[v as usize] * k) };
}
} else {
for (c, &v) in rgb.iter().enumerate() {
px[c * wn + x] = opaque[v as usize];
}
}
}
out.extend_from_slice(&(y as i32).to_le_bytes());
out.extend_from_slice(&line_size.to_le_bytes());
out.extend(px.iter().flat_map(|v| v.to_le_bytes()));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -167,6 +284,111 @@ mod tests {
assert_eq!(&f[18..26], &[255, 0, 0, 128, 0, 0, 255, 255]);
}
#[test]
fn half_floats_round_to_nearest_even() {
for (v, h) in [
(0.0, 0x0000),
(-0.0, 0x8000),
(1.0, 0x3C00),
(0.5, 0x3800),
(-2.0, 0xC000),
(65504.0, 0x7BFF),
(65520.0, 0x7C00), // halfway past the largest half: ties to even = infinity
(1.0e9, 0x7C00), // overflow
(6.103_515_6e-5, 0x0400), // smallest normal
(5.960_464_5e-8, 0x0001), // smallest subnormal, 2^-24
(2.980_232_2e-8, 0x0000), // 2^-25: halfway to zero, ties to even
(1.0e-10, 0x0000),
(1.000_488_3, 0x3C00), // 1 + 2^-11: halfway, ties to even (down)
(1.001_464_8, 0x3C02), // 1 + 3·2^-11: halfway, ties to even (up)
(f32::INFINITY, 0x7C00),
] {
assert_eq!(half(v), h, "{v}");
}
assert_eq!(half(f32::NAN) & 0x7E00, 0x7E00);
}
fn le32(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
/// Where `needle` (an attribute name, NUL-terminated) starts in the header.
fn find(b: &[u8], needle: &[u8]) -> usize {
b.windows(needle.len()).position(|w| w == needle).unwrap()
}
#[test]
fn exr_layout() {
// 2×1: opaque white, then (255, 0, 0) at alpha 0.5
let px = [255, 255, 255, 255, 255, 0, 0, 128];
for alpha in [false, true] {
let f = exr(&px, 2, 1, alpha).unwrap();
let nch = if alpha { 4 } else { 3 };
assert_eq!(&f[..8], &[0x76, 0x2F, 0x31, 0x01, 2, 0, 0, 0]);
let ch = find(&f, b"channels\0chlist\0") + 16;
assert_eq!(le32(&f, ch) as usize, nch * 18 + 1);
let names: Vec<u8> = (0..nch).map(|i| f[ch + 4 + i * 18]).collect();
assert_eq!(names, if alpha { b"ABGR".to_vec() } else { b"BGR".to_vec() });
let dw = find(&f, b"dataWindow\0box2i\0") + 17;
assert_eq!((le32(&f, dw), le32(&f, dw + 4), le32(&f, dw + 8), le32(&f, dw + 12)), (16, 0, 0, 1));
let cmp = find(&f, b"compression\0compression\0") + 24;
assert_eq!((le32(&f, cmp), f[cmp + 4]), (1, 0));
// header ends with a NUL; one offset (scan line 0) points at the only chunk
let end = f.len() - (8 + 2 * nch * 2) - 8;
assert_eq!(f[end - 1], 0);
let off = u64::from_le_bytes(f[end..end + 8].try_into().unwrap()) as usize;
assert_eq!(off, end + 8);
assert_eq!((le32(&f, off), le32(&f, off + 4) as usize), (0, 2 * nch * 2));
let hv = |c: usize, x: usize| u16::from_le_bytes(f[off + 8 + (c * 2 + x) * 2..][..2].try_into().unwrap());
let (b, g, r) = if alpha { (1, 2, 3) } else { (0, 1, 2) };
// white is 1.0 everywhere; red is linear 1.0 · alpha (premultiplied) or 1.0 flat
assert_eq!((hv(b, 0), hv(g, 0), hv(r, 0)), (0x3C00, 0x3C00, 0x3C00));
assert_eq!((hv(b, 1), hv(g, 1)), (0, 0));
if alpha {
assert_eq!((hv(0, 0), hv(0, 1)), (0x3C00, half(128.0 / 255.0)));
assert_eq!(hv(r, 1), half(128.0 / 255.0));
} else {
assert_eq!(hv(r, 1), 0x3C00);
}
}
}
/// ffmpeg (external test oracle) decodes our OpenEXR files to the values we meant: linear
/// light, premultiplied alpha, rows top to bottom.
#[test]
fn exr_decodes_in_ffmpeg() {
let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("OpenEXR stills") else { return };
let (w, h) = (5_u32, 3_u32);
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i * 17) as u8, (255 - i * 13) as u8, (i * 40 % 256) as u8, [255, 128, 0, 64][i as usize % 4]]).collect();
let dir = filmcraft_testkit::fixtures::workspace_root().join("target").join("export-tests").join(format!("exr-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let lin = filmcraft_color::srgb_u8_to_linear_table();
for alpha in [false, true] {
let path = dir.join(format!("f{alpha}.exr"));
std::fs::write(&path, exr(&px, w, h, alpha).unwrap()).unwrap();
let out = std::process::Command::new(&ffmpeg)
.args(["-v", "error", "-i"])
.arg(&path)
.args(["-f", "rawvideo", "-pix_fmt", "gbrapf32le", "-"])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let n = (w * h) as usize;
assert_eq!(out.stdout.len(), n * 4 * 4);
let plane = |p: usize, i: usize| f32::from_le_bytes(out.stdout[(p * n + i) * 4..][..4].try_into().unwrap());
for (i, &[r, g, b, a]) in px.as_chunks::<4>().0.iter().enumerate() {
let k = if alpha { f32::from(a) / 255.0 } else { 1.0 };
// gbrap planes: G, B, R, A
let want = [lin[g as usize] * k, lin[b as usize] * k, lin[r as usize] * k, k];
for (p, want) in want.into_iter().enumerate() {
let got = plane(p, i);
assert!((got - want).abs() <= 1e-3 + want * 1e-3, "alpha {alpha}, pixel {i}, plane {p}: {got} vs {want}");
}
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hostile_sizes_are_errors() {
assert!(dpx(&[0; 7], 2, 1).is_err());
@@ -175,5 +397,8 @@ mod tests {
assert!(tga(&[0; 4], u32::MAX, u32::MAX, false).is_err());
assert!(rgb(&[0; 3], 1, 1).is_err());
assert!(dpx(&[0; 4], u32::MAX, u32::MAX).is_err());
assert!(exr(&[0; 4], u32::MAX, u32::MAX, true).is_err());
assert!(exr(&[0; 7], 2, 1, false).is_err());
assert!(exr(&[], 0, 1, false).is_err());
}
}
+6 -3
View File
@@ -378,11 +378,11 @@ impl ExportSettings {
if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format }
}
/// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF and
/// Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies.
/// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF,
/// Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ QuickTime movies.
pub fn supports_alpha(&self) -> bool {
match self.format {
Format::PngSequence | Format::TiffSequence | Format::TgaSequence => true,
Format::PngSequence | Format::TiffSequence | Format::TgaSequence | Format::ExrSequence => true,
Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444,
_ => false,
}
@@ -488,6 +488,8 @@ impl ExportSettings {
Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps,
// 10-bit RGB in one 32-bit word per pixel
Format::DpxSequence => px * 4.0 * 8.0 * fps,
// two bytes per half-float channel
Format::ExrSequence => px * if self.keeps_alpha() { 8.0 } else { 6.0 } * 8.0 * fps,
Format::Gif => px * 0.6 * 8.0 * fps,
Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
};
@@ -571,6 +573,7 @@ impl ExportSettings {
Format::Apv => v += &format!(", {}", crate::apv_profile(&self.apv_profile).name()),
Format::Mjpeg | Format::JpegSequence => v += &format!(", quality {}", self.quality),
Format::DpxSequence => v += ", 10-bit RGB",
Format::ExrSequence => v += ", 16-bit half float, linear",
_ => {}
}
v
+44 -1
View File
@@ -160,6 +160,49 @@ fn jpeg_targa_and_dpx_sequences() {
assert!(jpeg_sizes[0] > jpeg_sizes[1], "quality 95 must be larger than quality 20: {jpeg_sizes:?}");
}
/// OpenEXR sequences (#806): numbered like the others, half-float linear light, RGBA with
/// premultiplied alpha when asked; ffprobe / ffmpeg (external test oracles) read every still.
#[test]
fn openexr_sequences() {
let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None);
for alpha in [false, true] {
let dir = Scratch::new(&format!("seq-exr-{alpha}"));
let mut s = ExportSettings { format: Format::ExrSequence, path: dir.path("Shot.exr"), alpha, ..Default::default() };
s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(3)));
assert_eq!(Format::from_name("OpenEXR"), Some(Format::ExrSequence));
assert!(s.is_image_sequence() && s.supports_alpha() && s.keeps_alpha() == alpha && !s.has_audio());
let r = export(&p, seq, &s, &m, &Progress::default()).unwrap();
assert_eq!(r.frames, 3);
let want: Vec<String> = (0..3).map(|i| format!("Shot{i:03}.exr")).collect();
assert_eq!(files_in(&dir.0), want);
let first = dir.0.join(&want[0]);
let bytes = std::fs::read(&first).unwrap();
assert_eq!(&bytes[..4], &[0x76, 0x2F, 0x31, 0x01]);
let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("OpenEXR sequences") else { continue };
let out = std::process::Command::new(ffmpeg)
.args(["-v", "error", "-i"])
.arg(&first)
.args(["-f", "rawvideo", "-pix_fmt", "gbrapf32le", "-"])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let n = 64 * 36;
assert_eq!(out.stdout.len(), n * 16);
let plane = |p: usize| -> Vec<f32> { out.stdout[p * n * 4..(p + 1) * n * 4].as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() };
let (g, b, r, a) = (plane(0), plane(1), plane(2), plane(3));
for i in 0..n {
assert!(g[i].abs() < 1e-3 && r[i].abs() < 1e-3, "pixel {i}: no red or green");
if alpha {
// pure blue (linear 1.0) at 50 % opacity, premultiplied
assert!((a[i] - 0.5).abs() < 0.01 && (b[i] - a[i]).abs() < 2e-3, "pixel {i}: {} / {}", b[i], a[i]);
} else {
// flattened over black, then linearised: well below the encoded half level
assert!(a[i] == 1.0 && b[i] > 0.1 && b[i] < 0.5, "pixel {i}: {} / {}", b[i], a[i]);
}
}
}
}
/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160).
#[test]
fn image_sequences_keep_alpha_when_asked() {
@@ -658,7 +701,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() {
}
assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc));
assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)"));
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18);
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 19);
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap();
assert_eq!(s.format, Format::Hevc);
assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc");
+1
View File
@@ -60,6 +60,7 @@ Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are suppor
| `jpeg mjpa mjpb` | `Jpeg { fourcc }` |
| `AVdn AVdh` | `Dnx { fourcc }` |
| `mp4a` + `esds` (in `wave` too) | `Aac(AacConfig)`: ASC bytes + decoded object type/rate/channels; `Mp3` for OTI 0x69/0x6B |
| `.mp3`/`.mp2` | `Mp3`: MPEG-1/2 audio of any layer (each frame header names it) |
| PCM fourccs | `Pcm(PcmConfig)`: bits, float, endianness (`enda`, lpcm flags, `pcmC`), channels, rate |
| `alac` | `Alac { cookie }` |
| `Opus` + `dOps` | `Opus(OpusConfig)` |
+18 -2
View File
@@ -181,7 +181,8 @@ pub enum CodecConfig {
/// Advanced Professional Video (`apv1` + `apvC`).
Apv(ApvConfig),
Aac(AacConfig),
/// MPEG-1/2 audio layer III (esds object type 0x69/0x6B, or QuickTime `.mp3`).
/// MPEG-1/2 audio (esds object type 0x69/0x6B, or QuickTime `.mp3` / `.mp2`). Usually layer
/// III, but these name no layer: ffmpeg stores MP2 as `mp4a` + 0x6B. Each frame header has it.
Mp3,
Pcm(PcmConfig),
/// ALAC magic cookie (ALACSpecificConfig, 24 bytes, optionally followed by a channel layout).
@@ -883,6 +884,7 @@ fn is_audio_fourcc(f: &[u8; 4]) -> bool {
| b"ac-3"
| b"ec-3"
| b".mp3"
| b".mp2"
| b"enca"
| b"ulaw"
| b"alaw"
@@ -1028,7 +1030,7 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc
Some(p) => parse_esds(p)?,
None => unknown(),
},
b".mp3" => CodecConfig::Mp3,
b".mp3" | b".mp2" => CodecConfig::Mp3,
b"alac" => match get(b"alac") {
Some(p) if p.len() >= 4 => CodecConfig::Alac { cookie: p[4..].to_vec() },
_ => unknown(),
@@ -1643,6 +1645,20 @@ mod tests {
assert_eq!(roundtrip(&e, true, b"soun"), e);
}
/// QuickTime's `.mp2` entry (what ffmpeg writes for MP2 in MOV) is MPEG audio, as `.mp3` is,
/// even under a handler that does not say "sound" (#800).
#[test]
fn quicktime_mpeg_audio_entries() {
for fourcc in [*b".mp3", *b".mp2"] {
let e = SampleEntry::audio(FourCc(fourcc), CodecConfig::Mp3, 2, 48000.0, 16);
for handler in [b"soun", b"\0\0\0\0"] {
let back = roundtrip(&e, true, handler);
assert_eq!(back.codec, CodecConfig::Mp3, "{fourcc:?} {handler:?}");
assert_eq!(back.audio.map(|a| (a.channels, a.sample_rate)), Some((2, 48000.0)), "{fourcc:?} {handler:?}");
}
}
}
#[test]
fn pcm_entries() {
for (bits, float, be, qt) in [
+1 -1
View File
@@ -159,7 +159,7 @@ fn cpu_frame(img: crate::Image) -> Arc<VideoFrame> {
}
fn simple_transition(id: &str) -> bool {
matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "morph_cut")
matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white")
}
/// The standard effects of a media clip if the GPU can draw it (any blend mode, no opacity masks,
+1 -1
View File
@@ -487,7 +487,7 @@ fn estimate_cost(project: &Project, seq: &Sequence, layers: &[Layer]) -> f64 {
total += match l {
Layer::Item(_, it) => layer_cost(project, it, px, false),
Layer::Transition(_, x, a, b) => {
let simple = matches!(x.effect.effect.as_str(), "cross_dissolve" | "dip_to_black" | "dip_to_white" | "non_additive_dissolve" | "morph_cut");
let simple = matches!(x.effect.effect.as_str(), "cross_dissolve" | "dip_to_black" | "dip_to_white" | "non_additive_dissolve");
let sides: f64 = [a, b].iter().filter_map(|s| s.map(|it| layer_cost(project, it, px, !simple))).sum();
sides + if simple { 1.0 } else { 12.0 * px }
}
+26
View File
@@ -250,6 +250,32 @@ fn push_plan_preserves_reference_track_order_and_reverse() {
}
}
#[test]
fn morph_cut_frames_render_on_the_cpu_with_the_morph() {
// the GPU compositor can only cross-fade layers: a Morph Cut frame is planned whole on the
// CPU, so preview and export show the optical-flow morph, not a dissolve
let (mut p, red, ocean, seq, map) = setup();
let a = place(&mut p, seq, 0, red, 0, 24);
let b = place(&mut p, seq, 0, ocean, 24, 24);
let rate = FrameRate::FPS_24;
p.sequence_mut(seq).unwrap().video_tracks[0].transitions.push(filmcraft_project::Transition {
id: filmcraft_project::TransitionId(998),
effect: filmcraft_project::find_effect("morph_cut").unwrap().instance(),
start: rate.tick_of(18),
duration: rate.tick_of(12),
from: Some(a),
to: Some(b),
align: Default::default(),
reverse: false,
});
let opts = RenderOptions { scale: 0.5, ..Default::default() };
assert!(plan::is_cpu_frame(&p, seq, rate.tick_of(24)));
assert!(!plan::is_cpu_frame(&p, seq, rate.tick_of(10)), "outside the transition the frame stays on the GPU");
let plan = plan::plan_frame(&p, seq, rate.tick_of(24), opts, &map);
assert!(matches!(plan, plan::FramePlan::Image(_)));
assert_eq!(plan::execute_cpu(&plan).px, render_sequence(&p, seq, rate.tick_of(24), opts, &map).px);
}
/// A project with a 4 s bars-and-tone clip (1 kHz at −20 dBFS) on A1, with extra audio effects.
fn tone_with(effects: &[(&str, &[(&str, f64)])]) -> (Project, ItemId, SourceMap) {
let mut p = Project::new("fx");
@@ -54,6 +54,11 @@ pub(super) fn apply(id: &str, t: &Tx) -> Option<Image> {
}
})
}
// a jump cut smoothed by morphing: both frames are warped along the dense optical flow
// between them and blended, so the subject moves from its place in the outgoing shot to
// its place in the incoming one instead of showing twice; where the frames do not
// correspond (no consistent motion) it is the cross dissolve
"morph_cut" => crate::flow::interpolate(t.a, t.b, p),
"luma_fade" => {
let soft = t.frac("softness").clamp(0.005, 1.0) * 0.5;
let from_b = t.choice("source") == 1;
+3 -3
View File
@@ -49,9 +49,9 @@ film_dissolve 0.75 1933591e335936415928335919426e1e426e9fa16e28426e1950821e50824
luma_fade 0.25 5c2320892320b62320e223205c3620893620b63620e236205c4920894920b64920e249205c5c20895c20b65c20e05c22
luma_fade 0.50 472835462f4c4547621a38702540681d438199cb8f0e46951352a80e53b43b81b60d53b60d60d40d60d60d60d60d60d6
luma_fade 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6
morph_cut 0.25 4929356a2935912e35ad2935493a3d6a3a3daf5d3dad3a3d494c456a4c45975745ad4c45495d4d6a5d4d8b5d4dad5d4d
morph_cut 0.50 352e4b4b2e4b6c394b782e4b353e5b4b3e5ba8855b783e5b354e6b4b4e6b78656b784e6b355e7b4b5e7b615e7b785e7b
morph_cut 0.75 2134602c34604744604234602142782c4278a1ac784242782150902c50905a7390425090215fa82c5fa8375fa8425fa8
morph_cut 0.25 472a36582c40892d3a563c75483b43683a3e9f5a44684671434c51604d528a685b7f5271465d556c5c44925c3fb9593d
morph_cut 0.50 372e473e31536a364a3f3b763e3d51503e559e7d5b444780324e71484e6f6972803e57a6375e77505d6e6b5c6b9c5557
morph_cut 0.75 263357273563493f5c2a3a752f406737416e99a27729478d21508e344f88537b9b2e53a5295e98355d9b3560aa495da6
mosaic_transition 0.25 7f3633973633cb3633f336337f3633973633cb3633f336337f3633973633cb3633f3363359650d70650da4650dcd650d
mosaic_transition 0.50 0d4a9f0d4a9fcc3b20cc3b200d4a9f0d4a9fcc3b20cc3b200d4a9f0d4a9fcc3b20cc3b2036566f36566f774e77774e77
mosaic_transition 0.75 0d46963d7696a6df964680960d46963d7696a6df964680960d46963d7696a6df964680960d66e50d66e50d66e50d66e5
+1 -2
View File
@@ -56,7 +56,6 @@ pub(super) fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Option<
id,
"cross_dissolve"
| "cross_dissolve_legacy"
| "morph_cut"
| "film_dissolve"
| "film_dissolve_legacy"
| "additive_dissolve"
@@ -93,7 +92,7 @@ fn legacy(id: &str, e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image {
let aspect = (a.w as f32) / (a.h as f32).max(1.0);
let pd = p as f64;
match id {
"cross_dissolve" | "morph_cut" => masked(a, b, |_, _| p),
"cross_dissolve" => masked(a, b, |_, _| p),
"film_dissolve" => {
// blend in a gamma-2.2-like space for a filmic, less-dippy dissolve
let mut out = Image::new(a.w, a.h);
+2 -2
View File
@@ -18,8 +18,8 @@
//!
//! Immersive (VR) transitions are flat approximations on the equirectangular frame: sampling wraps
//! horizontally and the iris uses great-circle distance, but there is no sphere re-projection.
//! Morph Cut is a cross dissolve (no face tracking), and the Smart Tools / Text transitions are
//! procedural stand-ins (no content analysis, no typed text).
//! Morph Cut morphs with dense optical flow ([`crate::flow`]) but has no face tracking, and the
//! Smart Tools / Text transitions are procedural stand-ins (no content analysis, no typed text).
use filmcraft_project::{EffectInstance, ParamValue};
use filmcraft_time::Tick;
+58
View File
@@ -248,6 +248,64 @@ fn modern_and_legacy_ids_are_distinct_renders() {
assert!(mean_abs(&m, &want) < 1e-6);
}
/// A jump cut (256×144): a static background with a 64×72 subject whose left edge is at `x0`;
/// flat colours, or a smooth texture on both (the subject's own texture moves with it).
fn jump_cut_frame(x0: f32, textured: bool) -> Image {
let tex = |u: f32, v: f32, k: f32| {
0.35 + 0.15 * (u * 0.21 * k).sin() * (v * 0.17).cos() + 0.1 * ((u + v) * 0.11 * k).sin() + 0.05 * (u * 0.53 - v * 0.31 * k).cos()
};
paint(256, 144, |x, y| {
let inside = (x0..x0 + 64.0).contains(&x) && (36.0..108.0).contains(&y);
let l = match (textured, inside) {
(false, true) => 0.95,
(false, false) => 0.3,
(true, true) => 0.4 + tex(x - x0, y, 0.6),
(true, false) => 0.6 * tex(x, y, 1.0),
};
[l, l * 0.9, l * 0.8, 1.0]
})
}
#[test]
fn morph_cut_moves_the_subject_instead_of_double_exposing() {
// the subject jumps 12 px between the shots; at the midpoint Morph Cut shows it once, 6 px
// along, where a cross dissolve shows two half-bright copies
let (a, b) = (jump_cut_frame(96.0, false), jump_cut_frame(108.0, false));
let want = jump_cut_frame(102.0, false);
let morph = apply(&filmcraft_project::find_effect("morph_cut").unwrap().instance(), &a, &b, 0.5);
let dissolve = apply(&filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), &a, &b, 0.5);
// mean red error down a column through the subject's rows
let column_err = |img: &Image, x: usize| (40..104).map(|y| (img.get(x, y)[0] - want.get(x, y)[0]).abs()).sum::<f32>() / 64.0;
// uncovered background, both edges, the middle, background still to be covered
for x in [98usize, 103, 134, 165, 170] {
let (m, d) = (column_err(&morph, x), column_err(&dissolve, x));
assert!(m < 0.12, "x={x}: morph off by {m} (dissolve {d})");
}
for x in [98usize, 170] {
assert!(column_err(&dissolve, x) > 0.3, "x={x}: the dissolve ghosts here");
}
let (e_morph, e_dissolve) = (mean_abs(&morph, &want), mean_abs(&dissolve, &want));
assert!(e_morph * 10.0 < e_dissolve, "morph {e_morph} vs dissolve {e_dissolve}");
// the background away from the subject is left alone
for (x, y) in [(8, 8), (40, 70), (220, 130)] {
assert!(morph.get(x, y).iter().zip(a.get(x, y)).all(|(p, q)| (p - q).abs() < 1e-4), "({x}, {y})");
}
}
#[test]
fn morph_cut_tracks_textured_subjects_and_follows_the_progress() {
// detail on the subject and behind it: the morph lands much closer than the dissolve, at
// the midpoint and a quarter of the way through
for (p, from, to, at) in [(0.5f32, 96.0f32, 108.0f32, 102.0f32), (0.25, 96.0, 112.0, 100.0)] {
let (a, b) = (jump_cut_frame(from, true), jump_cut_frame(to, true));
let want = jump_cut_frame(at, true);
let morph = apply(&filmcraft_project::find_effect("morph_cut").unwrap().instance(), &a, &b, p);
let dissolve = a.clone().lerp(&b, p);
let (e_morph, e_dissolve) = (mean_abs(&morph, &want), mean_abs(&dissolve, &want));
assert!(e_morph * 2.0 < e_dissolve, "p={p}: morph {e_morph} vs dissolve {e_dissolve}");
}
}
// ---------------------------------------------------------------------------------------------
// Golden fingerprints
+3
View File
@@ -3182,6 +3182,9 @@
Numbered JPEG stills (quality 90) Imágenes fijas JPEG numeradas (calidad 90)
Numbered Targa stills (24-bit, uncompressed) Imágenes fijas Targa numeradas (24 bits, sin comprimir)
Numbered DPX stills (10-bit RGB) Imágenes fijas DPX numeradas (RGB de 10 bits)
OpenEXR OpenEXR
OpenEXR Sequence Secuencia OpenEXR
Numbered OpenEXR stills (half float, linear, uncompressed) Imágenes fijas OpenEXR numeradas (coma flotante de 16 bits, lineal, sin comprimir)
Numbered PNG stills at the sequence's frame size Imágenes fijas PNG numeradas al tamaño de fotograma de la secuencia
Numbered TIFF stills (RGB, 8-bit) Imágenes fijas TIFF numeradas (RGB, 8 bits)
Opacity 0 → 100 % over the first second Opacidad de 0 a 100 % durante el primer segundo
Can't render this file because it contains an unexpected character in line 3 and column 95.
+3
View File
@@ -3171,6 +3171,9 @@
Numbered JPEG stills (quality 90) 連番 JPEG 静止画 (品質 90)
Numbered Targa stills (24-bit, uncompressed) 連番 Targa 静止画 (24-bit、非圧縮)
Numbered DPX stills (10-bit RGB) 連番 DPX 静止画 (10-bit RGB)
OpenEXR OpenEXR
OpenEXR Sequence OpenEXR 連番画像
Numbered OpenEXR stills (half float, linear, uncompressed) 連番 OpenEXR 静止画 (16-bit 浮動小数点、リニア、非圧縮)
Numbered PNG stills at the sequence's frame size シーケンスと同じフレームサイズの連番 PNG 静止画
Numbered TIFF stills (RGB, 8-bit) 連番 TIFF 静止画 (RGB、8-bit)
Opacity 0 → 100 % over the first second 最初の 1 秒で不透明度を 0 → 100 % に変化
Can't render this file because it contains an unexpected character in line 11 and column 59.
+3
View File
@@ -3165,6 +3165,9 @@
Numbered JPEG stills (quality 90) 带编号的 JPEG 静帧(质量 90)
Numbered Targa stills (24-bit, uncompressed) 带编号的 Targa 静帧(24 位,未压缩)
Numbered DPX stills (10-bit RGB) 带编号的 DPX 静帧(10 位 RGB)
OpenEXR OpenEXR
OpenEXR Sequence OpenEXR 序列
Numbered OpenEXR stills (half float, linear, uncompressed) 带编号的 OpenEXR 静帧(16 位半精度浮点,线性,未压缩)
Numbered PNG stills at the sequence's frame size 带编号的 PNG 静帧(采用序列帧大小)
Numbered TIFF stills (RGB, 8-bit) 带编号的 TIFF 静帧(RGB,8 位)
Opacity 0 → 100 % over the first second 在最初一秒内不透明度从 0 → 100%
Can't render this file because it contains an unexpected character in line 3 and column 95.
+1 -1
View File
@@ -668,7 +668,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
| `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-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` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268) | numbered stills, as above |
| `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) |
- **Reproducible.** The same project and settings give the same file on every machine. Frames render
+1 -1
View File
@@ -42,7 +42,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed
| AAC (LC, HE, LATM) | decode + encode | own decoder (LC, HE core), LATM | own LC encoder | loudness oracle vs ffmpeg | 90% | 3–5 h |
| AC-3 | decode (licensed) | own decoder (ATSC A/52) | none | conformance | 80% | — |
| E-AC-3 (Dolby Digital Plus) | decode | own decoder (ATSC A/52 Annex E, #647): independent substream, 1–6 blocks, spectral extension; **no** adaptive hybrid transform, enhanced coupling or 7.1 dependent substreams (plays the 5.1 core) | none | SNR vs ffmpeg 65–91 dB (raw, MP4, MOV, Matroska, TS) | 75% | 10–20 h |
| MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) | **none** | — | 60% | 10–20 h (MP3 encoder) |
| MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) in raw, MP4, MOV (#800), Matroska, TS, PS; **not** MP2 mono above 192 kbit/s or stereo below 64 kbit/s (ffmpeg's mono default; symphonia rejects them) | **none** | MP2 sample-exact vs ffmpeg (raw, MP4, MOV, TS, PS) | 60% | 10–20 h (MP3 encoder) |
| Opus | — (not a Premiere import format) | own decoder, RFC 8251 range-exact | none | conformance vectors | ahead | — |
| FLAC, ALAC, Vorbis | partial | decode (symphonia); FLAC in MP4 silent (#603) | none | — | 70% | 3–6 h |
| WMA | decode on Windows | none | none | — | 0% | 10–20 h |
+1 -1
View File
@@ -55,7 +55,7 @@ only). "Tested" names the evidence.
| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP / JPEG sequences, GIF | 90% |
| WebP | — | yes | no | ahead |
| **PSD** (layers as sequence) | read (layered) | no | — | 0% |
| **OpenEXR** | read + write | no | no | 0% |
| **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% |
| **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% |
| Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% |
| HEIF / HEIC | read | no | — | 0% |
+15 -9
View File
@@ -156,9 +156,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
## G10. Container and codec gaps on import
- **Have:** E-AC-3 (Dolby Digital Plus) in MP4, MOV, Matroska and MPEG-TS (#647): independent
substream, 1–6 blocks per syncframe, spectral extension.
- **Missing:** E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1
dependent substreams (played as the 5.1 core), AVI (#598; listed as an extension but no
substream, 1–6 blocks per syncframe, spectral extension. MP2 (MPEG audio layer II) in MP4 and
MOV, sample-exact against ffmpeg (#800).
- **Missing:** MP2 at bit rates ISO/IEC 11172-3 disallows: mono above 192 kbit/s (ffmpeg's default
for mono MP2 is 384 kbit/s) and stereo below 64 kbit/s; the bootstrap decoder (symphonia, also
0.6) rejects every such frame, so the track plays silent: needs our own layer I/II decoder.
E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 dependent
substreams (played as the 5.1 core), AVI (#598; listed as an extension but no
demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D
incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 /
JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS;
@@ -187,10 +191,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
## G13. Delivery formats
- **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF,
AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio,
AS-10 / AS-11, XDCAM HD MXF, P2, compressed (ZIP / PIZ / DWA) OpenEXR, MP3 and AAC-only audio,
ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding,
smart render, HEVC software encoder (HEVC exists only where a hardware encoder does).
- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779).
- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779); OpenEXR
sequences (half float, linear, uncompressed; #806).
- **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md).
## G14. Localization: 8 of the 12 key languages missing
@@ -227,11 +232,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
## G18. Effects depth: approximations and missing behaviour
- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, Lumetri
HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (constrained
lines; an on-monitor corner-roundness handle).
- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking
(and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles,
redesigned 26.0 mask tools (constrained lines; an on-monitor corner-roundness handle).
- **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense
coarse-to-fine Lucas–Kanade, CPU; #780); Transform shutter angle motion blur (#415); Ultra Key
coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow
instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key
Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners
with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe;
#813).
+1 -1
View File
@@ -106,7 +106,7 @@ agent-hours to close the area to Premiere, sequential.
| Area | Weight | Breadth | Ready | Hours | Evidence | Detail |
|---|---|---|---|---|---|---|
| Editing, timeline, trimming, multicam | 20% | ~90% | ~70% | 80–140 | Edit algebra, trim modes, Trim Monitor, multicam with audio sync, Premiere default keyboard (143 commands), Source playback and range dragging (10-08). Open: ripple-delete of a gap by column (#668), Backspace on gaps (#648), clip stretching (#653), deselect on empty space (#683), new tracks by dragging media (#483), Escape cancelling drags (#580), time remapping (#510), effects on a whole track (#623) | [ui-parity.md](ui-parity.md) |
| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Morph Cut and Auto Reframe approximate; Optical Flow time interpolation is motion compensated on the CPU (#780). 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). | [hardware-parity.md](hardware-parity.md) |
| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Auto Reframe approximate, Morph Cut morphs with optical flow but without face tracking (#805); Optical Flow time interpolation is motion compensated on the CPU (#780). 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). | [hardware-parity.md](hardware-parity.md) |
| Media import and codecs | 12% | ~75% | ~60% | 150–250 | Own bit-exact decoders for H.264, HEVC, VP9, AV1, ProRes, DNx, APV, MPEG-2, AAC, AC-3, Opus. But H.264 only 8-bit 4:2:0 and HEVC no 4:2:2 in software (most 10-bit cameras, #626); no camera RAW (R3D, ARRIRAW, BRAW, Sony RAW, Canon RAW, ProRes RAW, #345, #383); no E-AC-3 (#647), AVI (#598), WMV, DV, XAVC / P2 metadata; Sony start timecode (#460); WebM black (#432) | [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md) |
| Audio | 10% | ~88% | ~65% | 50–90 | Mixer graph, automation, all 53 effects, Essential Sound, 5.1, voice-over, Remix, multi-stream container audio (#361). Device and playback reports: crackle / cut-outs (#336, #410, #602, #605, #617); Enhance Speech is DSP, not a model; ALSA fallback fixed (#254) | |
| Panels and UI fidelity | 10% | ~88% | ~60% | 120–200 | All main panels. Missing: on-screen transform handles in the Program monitor (#639), Effect Controls keyframe zoom / divider / row lines (#640–#645), docking rearrangement (#493), multi-monitor windows (#466), filmstrip thumbnails (#614), marquee in Project panel (#578) | [ui-parity.md](ui-parity.md) |