mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 19:26:59 -04:00
Merge remote-tracking branch 'upstream/main' into gap-mask-rounded-corners
# Conflicts: # docs/gaps.md
This commit is contained in:
commit
2ca656684e
28 files changed
+572
-41
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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)?
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)` |
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user