mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 18:31:30 -06:00
Merge remote-tracking branch 'upstream/main' into m2915-merge
This commit is contained in:
@@ -7,6 +7,7 @@ mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod mov_audio_sample_positions;
|
||||
mod movie_bytes;
|
||||
mod movie_color;
|
||||
mod ms_rounded_footage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_audio_sample_positions;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! H.264, HEVC and AV1 MP4s describe the frames the render path made (#2879): the `colr` box, read
|
||||
//! back with FilmCraft's demuxer, and the bitstream's own colour description (the SPS VUI, AV1's
|
||||
//! `color_config`), which ffprobe reports for the first frame of the bare elementary stream when
|
||||
//! ffmpeg can copy it out and decode it.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::{ColorInfo, TrackKind};
|
||||
|
||||
/// (name, Output color space, H.273 primaries / transfer / matrix, the same as ffprobe names them)
|
||||
const ROWS: &[(&str, Option<ColorSpace>, [u16; 3], [&str; 3])] = &[
|
||||
("default", None, [1, 1, 1], ["bt709", "bt709", "bt709"]),
|
||||
("rec2020", Some(ColorSpace::Rec2020), [9, 1, 1], ["bt2020", "bt709", "bt709"]),
|
||||
("p3", Some(ColorSpace::DisplayP3), [12, 13, 1], ["smpte432", "iec61966-2-1", "bt709"]),
|
||||
("pq", Some(ColorSpace::Rec2100Pq), [9, 16, 1], ["bt2020", "smpte2084", "bt709"]),
|
||||
("hlg", Some(ColorSpace::Rec2100Hlg), [9, 18, 1], ["bt2020", "arib-std-b67", "bt709"]),
|
||||
];
|
||||
|
||||
fn render(output: Option<ColorSpace>, format: OutputFormat, path: &Path) {
|
||||
let mut p = Project::default();
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
p.settings.output_space = output;
|
||||
let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.2));
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.75, 0.25, 0.5], width: 64, height: 48, pixel_aspect: 1.0 }));
|
||||
comp.layers.push(build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 48), None));
|
||||
let comp = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let om = OutputModule::for_format(format);
|
||||
let out = path.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp,
|
||||
settings: &settings,
|
||||
output: &om,
|
||||
path: &out,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
export(&job, &mut |_| true).expect("export");
|
||||
}
|
||||
|
||||
/// ffprobe's primaries, transfer and matrix of the first frame of the movie's video copied out as a
|
||||
/// bare elementary stream (`h264`, `hevc` or `obu`), so no container metadata can stand in for the
|
||||
/// bitstream's own; `None` without ffmpeg or when its build can't copy or decode the stream.
|
||||
fn bitstream_tags(path: &Path, format: &str) -> Option<Vec<String>> {
|
||||
let es = path.with_extension(format);
|
||||
let copied =
|
||||
Command::new("ffmpeg").args(["-v", "error", "-y", "-i"]).arg(path).args(["-map", "0:v:0", "-c", "copy", "-f", format]).arg(&es).status().ok()?;
|
||||
if !copied.success() {
|
||||
return None;
|
||||
}
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-read_intervals", "%+#1", "-show_entries", "frame=color_primaries,color_transfer,color_space", "-of", "default=nw=1"])
|
||||
.arg(&es)
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let tag = |key: &str| text.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix('=')).map(str::to_string);
|
||||
Some(vec![tag("color_primaries")?, tag("color_transfer")?, tag("color_space")?])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_tags_describe_the_frames() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/movie_color");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
for (format, codec, stream) in [(OutputFormat::H264, "h264", "h264"), (OutputFormat::Hevc, "hevc", "hevc"), (OutputFormat::Av1, "av1", "obu")] {
|
||||
for &(name, output, [primaries, transfer, matrix], names) in ROWS {
|
||||
let path = dir.join(format!("{codec}_{name}.mp4"));
|
||||
render(output, format, &path);
|
||||
|
||||
let bytes = std::fs::read(&path).expect("read movie");
|
||||
let mp4 = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
|
||||
let track = mp4.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track");
|
||||
let colr = track.entries[0].video.as_ref().and_then(|v| v.color.clone());
|
||||
assert_eq!(colr, Some(ColorInfo::Nclx { primaries, transfer, matrix, full_range: false }), "{codec} {name}: colr");
|
||||
|
||||
match bitstream_tags(&path, stream) {
|
||||
Some(tags) => assert_eq!(tags, names, "{codec} {name}: the bitstream's colour description"),
|
||||
None => eprintln!("{codec} {name}: ffprobe can't decode it here, bitstream not checked"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
//! ProRes `colr` atom (#2801): it describes the frames the render path made, read back with
|
||||
//! FilmCraft's demuxer and, when installed, ffprobe as an external oracle.
|
||||
//! FilmCraft's demuxer and, when installed, ffprobe as an external oracle. A PQ or HLG render
|
||||
//! read back into a managed project has the colours it was rendered from (#2849).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::render_queue::{Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_render::{FootageSource, NoFootage};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::{ColorInfo, TrackKind};
|
||||
|
||||
@@ -55,25 +57,32 @@ const ROWS: &[Row] = &[
|
||||
Row { name: "alpha_matte", working: SRGB, output: PQ, channels: Channels::Alpha, codes: [1, 1, 1], names: BT709 },
|
||||
];
|
||||
|
||||
/// The solid every row renders; with the default output it comes out as these values.
|
||||
const SOLID: [f32; 3] = [0.75, 0.25, 0.5];
|
||||
|
||||
fn render(row: &Row, path: &Path) {
|
||||
let mut p = Project::default();
|
||||
p.settings.working_space = row.working;
|
||||
p.settings.output_space = row.output;
|
||||
let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.2));
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.75, 0.25, 0.5], width: 64, height: 48, pixel_aspect: 1.0 }));
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: SOLID, width: 64, height: 48, pixel_aspect: 1.0 }));
|
||||
comp.layers.push(build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 48), None));
|
||||
let comp = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let output = OutputModule { channels: row.channels, ..OutputModule::for_format(OutputFormat::ProRes) };
|
||||
run(&p, comp, &NoFootage, &output, path);
|
||||
}
|
||||
|
||||
fn run(p: &Project, comp: effectcraft_project::ItemId, footage: &dyn FootageSource, output: &OutputModule, path: &Path) {
|
||||
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let out = path.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
project: p,
|
||||
footage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp,
|
||||
settings: &settings,
|
||||
output: &output,
|
||||
output,
|
||||
path: &out,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
@@ -116,3 +125,34 @@ fn prores_colr_describes_the_frames() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2849: a PQ or HLG render, imported into a project with an sRGB working space, gets the
|
||||
/// Rec. 2100 profile its `colr` atom names and shows the solid it was rendered from.
|
||||
#[test]
|
||||
fn hdr_renders_reimport_with_their_colours() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/prores_hdr_reimport");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
let want = SOLID.map(|v| (v * 255.0 + 0.5) as i32);
|
||||
for (name, space) in [("pq", ColorSpace::Rec2100Pq), ("hlg", ColorSpace::Rec2100Hlg)] {
|
||||
let row = ROWS.iter().find(|r| r.name == name).expect("row");
|
||||
let movie = dir.join(format!("{name}.mov"));
|
||||
render(row, &movie);
|
||||
|
||||
let footage = effectcraft_media::probe(&movie).expect("probe");
|
||||
let profile = footage.color_profile;
|
||||
let mut p = Project::default();
|
||||
p.settings.working_space = SRGB;
|
||||
let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.1));
|
||||
let fid = p.add_item(name, Label::SeaFoam, None, ItemKind::Footage(footage));
|
||||
comp.layers.push(build::layer(&mut p, &comp, name, LayerSource::Footage { item: fid }, (64, 48), None));
|
||||
let comp = p.add_item("Reimport", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let pattern = dir.join(format!("{name}_[#####].png")).to_string_lossy().to_string();
|
||||
run(&p, comp, &MediaPool::new(), &OutputModule::for_format(OutputFormat::PngSequence), Path::new(&pattern));
|
||||
|
||||
let png = image::open(sequence_path(&pattern, 0)).expect("png").to_rgb8();
|
||||
let got = png.get_pixel(32, 24).0.map(i32::from);
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 2), "{name}: centre {got:?}, want {want:?} (profile {profile:?})");
|
||||
assert_eq!(profile, Some(space), "{name}: profile");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,8 @@ fn exr_sequence_writes_scan_lines() {
|
||||
let om = OutputModule::for_format(OutputFormat::ExrSequence);
|
||||
run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr"));
|
||||
let bytes = std::fs::read(d.join("f_00000.exr")).expect("exr");
|
||||
// Version field: 2, then the flags byte whose 0x02 bit marks a single-part tiled file.
|
||||
// Version field (little-endian u32 at bytes 4..8): version 2, then the flags byte. Its 0x02
|
||||
// bit is the tiled bit, 0x200 of the field, so a scan-line file has 0 there.
|
||||
assert_eq!(&bytes[4..6], &[2, 0], "{:02x?}", &bytes[..8]);
|
||||
let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes), false).expect("exr header");
|
||||
assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines);
|
||||
|
||||
Reference in New Issue
Block a user