Merge remote-tracking branch 'upstream/main' into gap-aac-audio

# Conflicts:
#	crates/engine/src/commands.rs
#	docs/gaps.md
This commit is contained in:
vangel76 2026-10-11 01:09:25 +02:00
commit f8f16b6eca
41 changed files with 625 additions and 99 deletions

View File

@ -185,7 +185,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF |
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows and Linux, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
<br>
@ -289,10 +289,10 @@ FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](doc
The biggest gaps today:
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on all three (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
- **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows and Linux: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
- **AI features.** Few so far; speech to text is optional and off by default.

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}");
}
}
}
}

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 {

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)");
}
}

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|aac"?,"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|aac"?,"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
),

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}");

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
@ -107,6 +107,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")]
@ -145,6 +148,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,
@ -168,6 +172,7 @@ impl Format {
Format::JpegSequence => "jpg",
Format::TgaSequence => "tga",
Format::DpxSequence => "dpx",
Format::ExrSequence => "exr",
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aiff",
@ -186,6 +191,7 @@ impl Format {
Format::JpegSequence => "jpg",
Format::TgaSequence => "tga",
Format::DpxSequence => "dpx",
Format::ExrSequence => "exr",
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aif",
@ -208,6 +214,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",
@ -218,7 +225,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 {
@ -245,7 +261,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; 19] = [
pub const ALL: [Format; 20] = [
Format::H264,
Format::Hevc,
Format::Av1,
@ -259,6 +275,7 @@ impl Format {
Format::JpegSequence,
Format::TgaSequence,
Format::DpxSequence,
Format::ExrSequence,
Format::Gif,
Format::Wav,
Format::Aiff,
@ -316,9 +333,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`).
@ -1673,7 +1691,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)
@ -1697,6 +1716,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)?
}

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",

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());
}
}

View File

@ -62,7 +62,7 @@ impl H264Profile {
}
/// Export ▸ Hardware encoding: whether H.264 may be encoded by the system's hardware video encoder
/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows and Linux) instead of FilmCraft's own encoder. Off
/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows) instead of FilmCraft's own encoder. Off
/// by default: hardware encoders make different streams, and exports are otherwise byte-identical
/// from run to run and machine to machine.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -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,
}
@ -490,6 +490,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,
};
@ -573,6 +575,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

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() {
@ -755,7 +798,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() == 19);
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 20);
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");

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)` |

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 [

View File

@ -61,7 +61,7 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
- After a `flush` (which drains the MFT) the MFT only restarts at an IDR picture; the GOP cache
always seeks after a flush, and a caller that continues from the middle of a GOP gets an error
that `HybridDecoder` answers by replaying the run in software.
- **Windows and 64-bit Linux: NVIDIA NVENC H.264 and H.265 (HEVC) encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's
- **Windows and 64-bit Linux: NVIDIA NVENC H.264 encoding, and H.265 (HEVC) on Windows** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's
`nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time,
so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary
CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed.
@ -76,8 +76,8 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems
without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export
ends it with an error, since a hardware stream cannot be finished in software. The same session,
ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding
(NVENC)](#hardware-h265-hevc-encoding-nvenc).
ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
NVENC)](#hardware-h265-hevc-encoding-windows-nvenc).
- **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264
8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at
run time; without them (or without a device) nothing is registered. NVIDIA's own parser takes the
@ -188,7 +188,7 @@ The sample entry's `hvcC` record is the one VideoToolbox wrote for the stream (r
format description's sample description extension atoms, with the VPS / SPS / PPS), so profile,
level and flags are the encoder's own. If it is missing the export stops with that reason.
## Hardware H.265 (HEVC) encoding (NVENC, Windows and Linux)
## Hardware H.265 (HEVC) encoding (Windows, NVENC)
One codec enum (`nvenc::Codec`, chosen by `Profile::HevcMain` the way `VtProfile::HevcMain` does it
for VideoToolbox) parameterises the NVENC session: the codec and profile GUIDs, the capability
@ -295,13 +295,13 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main
| `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 |
| `tests/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right |
| `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters |
| `tests/nvenc_hevc.rs` (Windows / Linux, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames |
| `tests/nvenc_hevc_export.rs` (Windows / Linux, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC |
| `tests/nvenc_rgba_input.rs` (Windows / Linux, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors |
| `tests/nvenc_hevc_probe.rs` (Windows / Linux) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) |
| `tests/nvenc_hevc_warm.rs` (Windows / Linux) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe |
| `tests/nvenc_hevc_main10.rs` (Windows / Linux, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight |
| `tests/nvenc_hevc_hdr_export.rs` (Windows / Linux, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity |
| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames |
| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC |
| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors |
| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) |
| `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe |
| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight |
| `tests/nvenc_hevc_hdr_export.rs` (Windows, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity |
| `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) |
| `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages |
| `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` |
@ -344,7 +344,7 @@ Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on mac
textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in
VideoToolbox; VP9 and AV1 through VA-API (Linux), and VA-API encoding; field-coded H.264; 4:4:4 and 4:2:2 encoding with NVENC, HDR
H.264 with NVENC (HDR H.264 stays with the software encoder), HDR10 MaxCLL / MaxFALL measured from
the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows and Linux vendors, and encoders
the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders
from other vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows.
### Linux NVENC sources and limits

View File

@ -8,7 +8,7 @@
//! Linux with neither) registration does nothing and reports [`Availability::Unavailable`]. It also registers a
//! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an
//! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on
//! macOS and with NVENC ([`nvenc`], Windows and Linux) it also makes the H.265 export format available, and with NVENC
//! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows
//! (Main 10) lets HDR sequences export as HDR H.265.
//!
//! Hardware decoding never makes a file undecodable:
@ -68,6 +68,8 @@ pub enum Availability {
/// harmless). Streams they do not take, and every stream while hardware decoding is Off, keep
/// using FilmCraft's own decoders.
pub fn register() -> Availability {
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
filmcraft_export::register_encoder(nvenc::export::factory);
#[cfg(target_os = "macos")]
{
filmcraft_codecs::register_video_decoder(videotoolbox_factory);
@ -79,9 +81,23 @@ pub fn register() -> Availability {
filmcraft_codecs::hw::set_hw_backend("VideoToolbox");
Availability::Available("VideoToolbox")
}
// Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows, so the format is available
// when a small HEVC session opens. That takes about half a second, so it is asked on a thread of
// its own now rather than by the first draw of the format list.
#[cfg(target_os = "windows")]
{
register_nvenc();
filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available);
// HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere
// (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before
filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available);
nvenc::warm_hevc_probe();
}
#[cfg(target_os = "windows")]
{
static ENCODERS: std::sync::Once = std::sync::Once::new();
// Export ▸ Hardware encoding (NVENC H.264): in front of the software encoder, taking an
// export only when asked for and when NVENC can do it
ENCODERS.call_once(|| filmcraft_export::register_encoder(nvenc::export::factory));
filmcraft_codecs::register_video_decoder(media_foundation_factory);
filmcraft_codecs::hw::set_hw_backend("Media Foundation");
Availability::Available("Media Foundation")
@ -114,11 +130,6 @@ pub fn register() -> Availability {
}
Err(why) => log::info!("no NVDEC hardware decoding: {why}"),
}
// After the VA-API probe, never beside it: libva-nvidia-driver calls `cuInit` from its
// library constructor (under the loader lock) while a first `cuInit` on another thread
// loads libraries under CUDA's own lock, and the two wait for each other forever.
#[cfg(target_pointer_width = "64")]
register_nvenc();
available
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
@ -127,23 +138,6 @@ pub fn register() -> Availability {
}
}
/// NVENC: the Export encoder factory, and what makes the H.265 format (and HDR H.265) available.
#[cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
fn register_nvenc() {
// Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows and Linux, so the format is available
// when a small HEVC session opens. That takes about half a second, so it is asked on a thread of
// its own now rather than by the first draw of the format list.
filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available);
// HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere
// (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before
filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available);
nvenc::warm_hevc_probe();
static ENCODERS: std::sync::Once = std::sync::Once::new();
// Export ▸ Hardware encoding (NVENC H.264): in front of the software encoder, taking an
// export only when asked for and when NVENC can do it
ENCODERS.call_once(|| filmcraft_export::register_encoder(nvenc::export::factory));
}
/// Whether [`register`] has put a hardware decoder factory in front of our decoders.
pub fn registered() -> bool {
#[cfg(target_os = "macos")]

View File

@ -211,7 +211,8 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
/// The Export encoder factory (see the module documentation).
pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
if format == Format::Hevc {
return hevc_factory(w, h, rate, s);
// H.265 through NVENC is Windows-only for now; on Linux this backend takes H.264 only
return if cfg!(target_os = "windows") { hevc_factory(w, h, rate, s) } else { None };
}
factory_with(format, w, h, rate, s, |cfg| open(cfg, &Signal::default()))
}
@ -260,7 +261,7 @@ fn factory_with(
}
}
/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows and Linux, so the Hardware encoding toggle
/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows, so the Hardware encoding toggle
/// does not apply and a request NVENC cannot take is an error, not a fall-through.
fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
let declined = |why: &str| {

View File

@ -1,5 +1,5 @@
//! NVIDIA NVENC hardware encoding: H.264 and H.265 (HEVC) Main / Main 10 (Windows and
//! 64-bit Linux).
//! NVIDIA NVENC hardware encoding: H.264 (Windows and 64-bit Linux) and H.265 (HEVC) Main / Main 10
//! (Windows).
//!
//! The encoder runs on the GPU's NVENC engine through the driver's `nvEncodeAPI64.dll` on Windows
//! (on a Direct3D 11 device) or `libnvidia-encode.so.1` on Linux (on the CUDA driver's primary

View File

@ -2,7 +2,7 @@
//! with our own HEVC decoder to pictures close to the source, keyframes and timestamps are right, the
//! samples carry no parameter sets and the `hvcC` describes the SPS it was built from. Skips
//! without an NVIDIA GPU / driver with HEVC encoding.
#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
#![cfg(target_os = "windows")]
use filmcraft_bitstream::unescape_rbsp;
use filmcraft_hevc::params::Sps;

View File

@ -4,7 +4,7 @@
//! counters; and what NVENC declines (an error that says why, counted, never a crash). Its own test
//! binary: the encoder registry and the counters are process-wide. Skips without an NVIDIA GPU with
//! an HEVC encoder.
#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
#![cfg(target_os = "windows")]
use std::path::Path;
use std::process::Command;

View File

@ -5,7 +5,7 @@
//! our importer reads back as PQ / HLG, 10-bit; whose pixels follow a ProRes HDR export of the same
//! sequence. SDR stays Main 8-bit BT.709, `settings.sdr` forces it. Its own test binary: the encoder
//! registry and the counters are process-wide. Skips (SKIPPED) only without a 10-bit HEVC encoder.
#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
#![cfg(target_os = "windows")]
use std::path::Path;
use std::process::Command;

View File

@ -3,7 +3,7 @@
//! pictures that come out of our own HEVC decoder are 10-bit and close to them (smooth ramps keep far more
//! than 256 levels, bright code values survive), the VUI and the HDR10 SEI messages are right, and
//! hostile inputs are errors. Skips (with SKIPPED) only on a machine without a 10-bit HEVC encoder.
#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
#![cfg(target_os = "windows")]
use filmcraft_bitstream::unescape_rbsp;
use filmcraft_export::{ColorSignal, rgbf_to_yuv420_10};

View File

@ -1,7 +1,7 @@
//! The HEVC availability probe of the NVENC backend: it opens one small encoder session, once. Its own
//! test binary so that "first call" means a fresh process (driver load, Direct3D 11 device, NVENC
//! session and encoder initialisation all included).
#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
#![cfg(target_os = "windows")]
use std::time::{Duration, Instant};

View File

@ -1,7 +1,7 @@
//! `register` asks the NVENC HEVC probe on a thread of its own, so the first draw of Export's format
//! list does not open an encoder session on the UI thread. Its own test binary: anything else asking
//! `hevc_available` first would make the wait meaningless.
#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
#![cfg(target_os = "windows")]
use std::time::{Duration, Instant};

View File

@ -1,7 +1,7 @@
//! NVENC with packed RGBA input (Windows and Linux, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the
//! NVENC with packed RGBA input (Windows, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the
//! export's own BT.709 limited-range conversion (`filmcraft_export::rgba_to_yuv420_8`), for HEVC Main
//! and H.264. Skips without an NVIDIA GPU / driver.
#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
#![cfg(target_os = "windows")]
use filmcraft_isobmff::{AvcConfig, SampleEntry};
use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available};

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,

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 }
}

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");

View File

@ -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;

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

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);

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;

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

View File

@ -3185,6 +3185,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.

View File

@ -3174,6 +3174,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.

View File

@ -3168,6 +3168,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.

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

View File

@ -16,7 +16,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed
| Codec | Premiere | Our decode | Our encode | Fidelity and tests | % | Est. |
|---|---|---|---|---|---|---|
| H.264 / AVC | decode all common profiles incl. High 10, 4:2:2 (XAVC S-I, AVC-Intra); encode 8/10-bit | own decoder, **8-bit 4:2:0 only** (Baseline / Main / High, CABAC / CAVLC); no High 10 / 4:2:2 / 4:4:4, interlaced field / MBAFF, lossless | own encoder (High / Main / Baseline, B-frames, CBR / VBR / 2-pass); 8-bit 4:2:0 only; hardware VideoToolbox / NVENC | decode bit-exact on 37+ conformance streams, 500–600 fps 1080p; encode ffprobe-checked, byte-identical across machines | 65% | 20–35 h |
| HEVC / H.265 | decode Main, Main 10, 4:2:2 10 (XAVC HS); encode 8/10-bit, HDR | own decoder Main / Main 10 / Main Still Picture, bit-exact on 41 fixtures, ~225 fps 1080p; **no 4:2:2 / RExt** | **hardware only** (VideoToolbox 8-bit; NVENC 8-bit and Main 10 HDR on Windows and Linux); no software encoder | NVENC round trip 48.9 dB (8-bit), 71.1 dB (10-bit); ffprobe | 60% | 30–60 h |
| HEVC / H.265 | decode Main, Main 10, 4:2:2 10 (XAVC HS); encode 8/10-bit, HDR | own decoder Main / Main 10 / Main Still Picture, bit-exact on 41 fixtures, ~225 fps 1080p; **no 4:2:2 / RExt** | **hardware only** (VideoToolbox 8-bit; NVENC 8-bit and Main 10 HDR on Windows); no software encoder | NVENC round trip 48.9 dB (8-bit), 71.1 dB (10-bit); ffprobe | 60% | 30–60 h |
| ProRes 422 family / 4444 / XQ | decode + encode all | own decode + encode, all six profiles, alpha | yes (export UI exposes 422 flavours; 4444 / XQ request #342) | ±1 LSB, 318 fps | 85% | 3–6 h |
| ProRes RAW | decode | none (#345) | — | — | 0% | 30–50 h (spec availability uncertain) |
| DNxHD / DNxHR | decode + encode, MXF / MOV | own decode (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2 / 4:4:4, interlaced, alpha) and encode LB / SQ / HQ / HQX / 444 | yes, MOV + MXF | within IDCT precision of ffmpeg on 22 fixtures | 90% | 2–4 h |
@ -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 |

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% |

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 audio, 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); AAC Audio
(audio-only `.m4a`, or ADTS `.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342).
AS-10 / AS-11, XDCAM HD MXF, P2, compressed (ZIP / PIZ / DWA) OpenEXR, MP3 audio, 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); OpenEXR
sequences (half float, linear, uncompressed; #806); AAC Audio (audio-only `.m4a`, or ADTS
`.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342).
- **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 (rounded corners,
constrained lines).
- **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 (rounded corners, constrained lines).
- **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).
- **Estimate:** 60–110 h.

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) |
@ -139,8 +139,8 @@ Evidence for the cross-cutting rows:
- **Hardware ~45%.** Hardware decode exists on all three desktop OSs (VideoToolbox; Media
Foundation / D3D11 incl. VP9 and AV1; VA-API and NVDEC for H.264 / HEVC on Linux). Hardware
encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 and HEVC incl. Main 10 HDR (Windows,
Linux). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS,
encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 (Windows, Linux) and HEVC incl. Main 10 HDR
(Windows). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS,
ProRes hardware, zero-copy upload, ~48 of Premiere's accelerated effects, external video I/O,
control surfaces. Detail: [hardware-parity.md](hardware-parity.md).
- **Performance ~55%.** 4K H.264 / HEVC play with no dropped frames with hardware decoding (M4

View File

@ -77,7 +77,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2).
The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_rgba_input.rs` for
the GPU's RGB → 4:2:0 conversion, `nvenc_hevc.rs`,
`nvenc_hevc_export.rs`, `nvenc_hevc_probe.rs` and `nvenc_hevc_warm.rs` for H.265, `nvenc_hevc_main10.rs` and
`nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows and Linux) skip, printing `SKIPPED`, without an NVIDIA GPU
`nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows only) skip, printing `SKIPPED`, without an NVIDIA GPU
with NVENC (H.265: when the HEVC probe says there is no HEVC encoder; Main 10: when the Main 10 probe says
there is no 10-bit encoder; on a machine that has one, a configuration NVENC refuses fails the test). The FFI layout tests in
`crates/platform/src/nvenc/abi_tests.rs` were generated from a C program built with MSVC