Anamorphic media: pixel aspect ratio in placement, monitors and New Sequence From Clip

A 1440x1080 clip with 4:3 pixels showed squeezed: the demuxers read its
pixel aspect ratio, but nothing after import used it. New Sequence From
Clip made a 1440x1080 sequence with square pixels, the Motion transform
sized clips from storage pixels (pillarboxed at 1440 wide in a 1920x1080
sequence), and the monitors drew the frame at its storage aspect.

- project: checked_par / sane_par / par_ratio / conformed_size treat
  file and project ratios as hostile (a zero term, or more than 8:1
  either way, is square). MediaClip::pixel_aspect (Interpret Footage
  wins over the file), Project::source_par (subclips follow the parent,
  nested sequences have their own, graphics and adjustment layers have
  none) and Project::conformed_source_size.
- render: motion_matrix takes the source's ratio and works in square
  display units (anchor in source pixels, position in sequence pixels,
  scale and rotation at the display aspect); Scale to Frame Size fits
  the display size. CPU, GPU plan, adjustment regions and the mask /
  graphics overlays share it.
- engine: New Sequence From Clip, New Sequence from an item and
  multicam sequences copy the clip's ratio; default media scaling and
  Fit / Fill frame use the display size; generators made for a
  non-square sequence get its pixels; transcodes render pixel for
  pixel. clip.interpretFootage takes pixelAspect ([num, den] or "file").
- ui: the Source and Program monitors draw the picture at its display
  aspect; overlays map both axes; Sequence Settings shows the display
  aspect (16:9 for 1440x1080 at 4:3).

Tests: render par_tests (fill, GPU plan parity, Scale to Frame Size,
non-square sequences, rotation, Interpret Footage, hostile ratios),
engine par_tests (New Sequence From Clip, overwrite into 1920x1080,
default scaling, Fit / Fill, Interpret Footage with undo and refused
values, hostile file ratios, generators, and ffmpeg-made MP4 and MKV
files), project unit tests, and the headless par_ui monitor test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lloyd
2026-10-09 18:28:56 +02:00
co-authored by Claude Opus 5.5
parent ef6391df90
commit ff8728af08
21 changed files with 933 additions and 51 deletions
+12 -5
View File
@@ -639,7 +639,7 @@ fn sequence_from_clip(s: &mut Session, p: &Value) -> Result<Value> {
let pi = s.project.item(first).ok_or_else(|| bad("file.newSequenceFromClip", "no such item"))?.clone();
let mut settings = match &pi.kind {
ItemKind::Sequence(q) => q.settings.clone(),
_ => media_root(&s.project, first).map(|(_, m, _)| crate::commands::default_seq_settings_for(&m.info)).unwrap_or_default(),
_ => media_root(&s.project, first).map(|(_, m, _)| crate::commands::default_seq_settings_for(m)).unwrap_or_default(),
};
if let ItemKind::AdjustmentLayer { width, height, rate, .. } = &pi.kind {
settings.width = *width;
@@ -1512,17 +1512,24 @@ fn fit_fill(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
let clips = video_clips(s, p);
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (fw, fh) = (q.settings.width as f64, q.settings.height as f64);
let sizes: Vec<(ClipId, (u32, u32))> =
clips.iter().filter_map(|c| q.find_item(*c).and_then(|(_, i)| filmcraft_render::source_size(&s.project, i.item)).map(|sz| (*c, sz))).collect();
let frame_par = q.settings.par;
// (clip, storage size for the anchor, size at the display aspect in sequence pixels for the scale)
let sizes: Vec<(ClipId, (u32, u32), (f64, f64))> = clips
.iter()
.filter_map(|c| {
let (_, i) = q.find_item(*c)?;
Some((*c, filmcraft_render::source_size(&s.project, i.item)?, s.project.conformed_source_size(i.item, frame_par)?))
})
.collect();
if sizes.is_empty() {
return Err(bad(if fill { "clip.fillFrame" } else { "clip.fitToFrame" }, "select a video clip"));
}
let label = if fill { "Fill Frame" } else { "Fit to Frame" };
let out = s.edit_sequence(label, |q, _, _| {
let mut out = Vec::new();
for (c, (w, h)) in &sizes {
for (c, (w, h), (dw, dh)) in &sizes {
let Some((_, it)) = q.find_item_mut(*c) else { continue };
let (sx, sy) = (fw / (*w).max(1) as f64, fh / (*h).max(1) as f64);
let (sx, sy) = (fw / dw.max(1.0), fh / dh.max(1.0));
let pct = (if fill { sx.max(sy) } else { sx.min(sy) }) * 100.0;
it.scale_to_frame = false;
let Some(m) = it.effect_mut("motion") else { continue };
+39 -7
View File
@@ -40,7 +40,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
"clip.interpretFootage",
"Interpret Footage…",
&["Clip", "Modify"],
r#"{"items":[id]?,"colorSpace":"auto"|"<color space id>"}"#,
r#"{"items":[id]?,"colorSpace":"auto"|"<color space id>"?,"pixelAspect":[num,den]|"file"?}"#,
has_footage,
interpret,
),
@@ -292,25 +292,57 @@ pub(crate) fn footage_targets(s: &Session, p: &Value) -> Vec<ItemId> {
v
}
/// `clip.interpretFootage`: the colour space (`colorSpace`) and / or the pixel aspect ratio
/// (`pixelAspect`) the footage is interpreted with. `"auto"` / `"file"` go back to the file's
/// metadata; a parameter left out keeps its current interpretation.
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "clip.interpretFootage";
let items = footage_targets(s, p);
if items.is_empty() {
return Err(bad("clip.interpretFootage", "no footage selected"));
return Err(bad(CMD, "no footage selected"));
}
let cs = match str_p(p, "colorSpace") {
None => return Err(bad("clip.interpretFootage", "need `colorSpace` (auto or a colour space id)")),
Some("auto") | Some("") => None,
Some(c) => Some(ColorSpace::parse(c).ok_or_else(|| bad("clip.interpretFootage", format!("unknown colour space `{c}`")))?),
None => None,
Some("auto") | Some("") => Some(None),
Some(c) => Some(Some(ColorSpace::parse(c).ok_or_else(|| bad(CMD, format!("unknown colour space `{c}`")))?)),
};
let par =
match p.get("pixelAspect") {
None | Some(Value::Null) => None,
Some(Value::String(f)) if f == "file" || f == "auto" => Some(None),
Some(v) => Some(Some(pixel_aspect_p(v).ok_or_else(|| {
bad(CMD, format!("pixelAspect must be [num, den] between 1:{0} and {0}:1, or \"file\"", filmcraft_project::MAX_PIXEL_ASPECT))
})?)),
};
if cs.is_none() && par.is_none() {
return Err(bad(CMD, "need `colorSpace` (auto or a colour space id) or `pixelAspect`"));
}
s.edit("Interpret Footage", |pr, _| {
for i in &items {
if let Some(ItemKind::Media(m)) = pr.item_mut(*i).map(|it| &mut it.kind) {
m.interpret.color_space = cs;
if let Some(cs) = cs {
m.interpret.color_space = cs;
}
if let Some(par) = par {
m.interpret.par = par;
}
}
}
Ok(())
})?;
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "colorSpace": cs.map(|c| c.id()).unwrap_or("auto")}))
let first = items.first().and_then(|i| s.project.item(*i)).and_then(|i| i.as_media());
Ok(json!({
"items": items.iter().map(|i| i.0).collect::<Vec<_>>(),
"colorSpace": first.and_then(|m| m.interpret.color_space).map(|c| c.id()).unwrap_or("auto"),
"pixelAspect": first.map(|m| m.pixel_aspect()),
}))
}
/// `[num, den]` as a pixel aspect ratio FilmCraft can use ([`filmcraft_project::checked_par`]).
fn pixel_aspect_p(v: &Value) -> Option<(u32, u32)> {
let [n, d] = v.as_array()?.as_slice() else { return None };
let (n, d) = (u32::try_from(n.as_u64()?).ok()?, u32::try_from(d.as_u64()?).ok()?);
filmcraft_project::checked_par((n, d))
}
fn spaces(_: &mut Session, _: &Value) -> Result<Value> {
+21 -4
View File
@@ -357,12 +357,17 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
out
}
pub(crate) fn default_seq_settings_for(info: &filmcraft_media::MediaInfo) -> SequenceSettings {
/// Sequence settings matching a media clip (New Sequence From Clip, New Sequence from an item):
/// its frame size, rate and pixel aspect ratio (Interpret Footage's, else the file's), so a
/// 1440 x 1080 clip with 4:3 pixels makes a 1440 x 1080 sequence that displays 16:9.
pub(crate) fn default_seq_settings_for(media: &MediaClip) -> SequenceSettings {
let info = &media.info;
let mut st = SequenceSettings::default();
if let Some(v) = &info.video {
st.width = v.width;
st.height = v.height;
st.frame_rate = v.frame_rate;
st.par = media.pixel_aspect();
st.preset = format!("{}x{} {}", v.width, v.height, v.frame_rate.label());
}
if let Some(a) = &info.audio {
@@ -455,8 +460,15 @@ fn new_generator(s: &mut Session, g: Generator, name: &str, label: Label, p: &Va
let name = str_p(p, "name").unwrap_or(name).to_string();
let src = GeneratorSource::new(g, w, h, st.frame_rate, Tick::from_seconds_f64(secs));
let pool = s.media.clone();
let par = filmcraft_project::sane_par(st.par);
let id = s.edit(&format!("New {name}"), |pr, st| {
let id = crate::demo::add_generator(pr, &pool, src, &name, label, None);
// made for the sequence: its pixels have the sequence's aspect, as in Premiere
if let Some(ItemKind::Media(m)) = pr.item_mut(id).map(|i| &mut i.kind)
&& let Some(v) = m.info.video.as_mut()
{
v.par = par;
}
st.project_selection = vec![id];
Ok(id)
})?;
@@ -521,6 +533,8 @@ pub(crate) fn place_item(
_ => None,
}
.unwrap_or((1920, 1080));
// `None`: drawn in the pixels of the sequence it is placed in
let src_par = s.project.source_par(item);
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let media = s.media.clone();
let scaling = s.prefs.media.default_media_scaling.clone();
@@ -529,6 +543,7 @@ pub(crate) fn place_item(
let seq = p.sequence(seq_id).ok_or(EngineError::NoSequence)?;
let rate = seq.settings.frame_rate;
let frame = (seq.settings.width, seq.settings.height);
let seq_par = seq.settings.par;
let mut placements = Vec::new();
let link = if has_v && has_a { Some(p.alloc_id()) } else { None };
if let Some(vdest) = vdest.filter(|_| has_v) {
@@ -537,8 +552,10 @@ pub(crate) fn place_item(
for e in &mut v.effects {
resolve_auto_points(e, frame, src_size);
}
if is_media && src_size != frame {
crate::settings::apply_media_scaling(&mut v, &scaling, frame, src_size);
// the picture's size at its display aspect, in sequence pixels
let shown = filmcraft_project::conformed_size(src_size, src_par.unwrap_or(seq_par), seq_par);
if is_media && shown != (f64::from(frame.0), f64::from(frame.1)) {
crate::settings::apply_media_scaling(&mut v, &scaling, frame, shown);
}
placements.push((vdest, v));
}
@@ -677,7 +694,7 @@ fn build() -> Vec<CommandSpec> {
|s, p| {
let mut st = SequenceSettings::default();
if let Some(from) = item_p(p, "fromItem").and_then(|i| s.project.item(i)).and_then(|i| i.as_media()) {
st = default_seq_settings_for(&from.info);
st = default_seq_settings_for(from);
}
if let Some(w) = checked_u32_p(p, "width", "file.newSequence")? {
st.width = w;
+2
View File
@@ -1063,6 +1063,8 @@ mod nesting_tests;
#[cfg(test)]
mod panels_tests;
#[cfg(test)]
mod par_tests;
#[cfg(test)]
mod presets_tests;
#[cfg(test)]
mod previews_tests;
+4 -4
View File
@@ -475,12 +475,12 @@ fn sync_items(s: &Session, items: &[ItemId], cmd: &str) -> Result<Vec<SyncClip>>
}
fn settings_for(s: &Session, item: ItemId) -> SequenceSettings {
let info = match s.project.item(item).map(|i| &i.kind) {
Some(ItemKind::Media(m)) => Some(m.info.clone()),
Some(ItemKind::Subclip { parent, .. }) => s.project.item(*parent).and_then(|i| i.as_media()).map(|m| m.info.clone()),
let media = match s.project.item(item).map(|i| &i.kind) {
Some(ItemKind::Media(m)) => Some(m),
Some(ItemKind::Subclip { parent, .. }) => s.project.item(*parent).and_then(|i| i.as_media()),
_ => None,
};
let mut st = info.as_ref().map(crate::commands::default_seq_settings_for).unwrap_or_default();
let mut st = media.map(crate::commands::default_seq_settings_for).unwrap_or_default();
if let Some(m) = s.project.item(item).and_then(|i| i.as_media())
&& let Some(r) = m.interpret.frame_rate
{
+231
View File
@@ -0,0 +1,231 @@
//! Anamorphic media: the pixel aspect ratio reaches New Sequence From Clip, the placement of clips
//! (default scaling, Fit / Fill) and the rendered frame, Interpret Footage overrides it, and
//! hostile ratios are square. Synthetic media (a Color Matte given 4:3 pixels) runs everywhere;
//! ffmpeg-made 1440 x 1080-style files (at a tenth of the size) check the MP4 and Matroska
//! demuxers end to end when ffmpeg is installed.
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use crate::Session;
use filmcraft_project::{ItemId, ItemKind, ParamValue};
/// A 144 x 108 Color Matte (a 1440 x 1080 stand-in) whose pixels are `par`.
fn matte(s: &mut Session, par: (u32, u32)) -> ItemId {
let r = s.execute("file.newColorMatte", json!({"color": "#ff0000", "width": 144, "height": 108, "seconds": 2.0})).unwrap();
let id = ItemId(r["item"].as_u64().unwrap());
if let Some(ItemKind::Media(m)) = Arc::make_mut(&mut s.project).item_mut(id).map(|i| &mut i.kind) {
m.info.video.as_mut().unwrap().par = par;
}
id
}
/// The alpha of the middle row of the program frame at columns `xs`.
fn coverage(s: &Session, xs: &[usize]) -> Vec<f32> {
let img = s.render_program(1.0).unwrap();
xs.iter().map(|&x| img.get(x, img.h / 2)[3]).collect()
}
/// A 192 x 108 square-pixel sequence (a 1920 x 1080 stand-in) with `item` overwritten at 0.
fn overwrite_into_square_hd(s: &mut Session, item: ItemId) -> filmcraft_project::ClipId {
s.execute("file.newSequence", json!({"name": "HD", "width": 192, "height": 108, "fps": 24})).unwrap();
s.execute("source.open", json!({"item": item.0})).unwrap();
s.execute("playhead.set", json!({"seconds": 0.0})).unwrap();
let r = s.execute("source.overwrite", json!({})).unwrap();
filmcraft_project::ClipId(r["clips"][0].as_u64().unwrap())
}
fn motion_scale(s: &Session, clip: filmcraft_project::ClipId) -> f64 {
let (_, it) = s.active_sequence().unwrap().find_item(clip).unwrap();
match it.effect("motion").unwrap().params["scale"].value {
ParamValue::Float(v) => v,
ref v => panic!("{v:?}"),
}
}
#[test]
fn new_sequence_from_clip_carries_the_pixel_aspect() {
let mut s = Session::default();
let item = matte(&mut s, (4, 3));
let r = s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
let st = s.project.sequence(ItemId(r["sequence"].as_u64().unwrap())).unwrap().settings.clone();
assert_eq!((st.width, st.height, st.par), (144, 108, (4, 3)));
assert!(st.validate().is_ok());
// the clip maps pixel for pixel onto its own sequence: the whole frame
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
assert!(coverage(&s, &[0, 1, 72, 142, 143]).iter().all(|a| *a > 0.99));
// New Sequence from the item too
s.execute("file.newSequence", json!({"name": "From item", "fromItem": item.0})).unwrap();
assert_eq!(s.active_sequence().unwrap().settings.par, (4, 3));
// a ratio written in other terms is reduced
let other = matte(&mut s, (16, 12));
s.execute("file.newSequenceFromClip", json!({"items": [other.0]})).unwrap();
assert_eq!(s.active_sequence().unwrap().settings.par, (4, 3));
}
#[test]
fn an_anamorphic_clip_fills_a_square_pixel_sequence() {
let mut s = Session::default();
let item = matte(&mut s, (4, 3));
let clip = overwrite_into_square_hd(&mut s, item);
// displayed 192 wide at 100 %: the whole frame (it was pillarboxed at 144 wide)
assert_eq!(motion_scale(&s, clip), 100.0);
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
assert!(coverage(&s, &[1, 10, 96, 181, 190]).iter().all(|a| *a > 0.99), "{:?}", coverage(&s, &[1, 10, 96, 181, 190]));
// Fit / Fill fit the display size: already the frame size
s.execute("clip.fitToFrame", json!({"clips": [clip.0]})).unwrap();
assert!((motion_scale(&s, clip) - 100.0).abs() < 1e-9);
s.execute("clip.fillFrame", json!({"clips": [clip.0]})).unwrap();
assert!((motion_scale(&s, clip) - 100.0).abs() < 1e-9);
assert!(coverage(&s, &[1, 190]).iter().all(|a| *a > 0.99));
// a square-pixel matte of the same size is pillarboxed, and Fit to Frame leaves it so
let square = matte(&mut s, (1, 1));
let clip = overwrite_into_square_hd(&mut s, square);
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
let c = coverage(&s, &[1, 20, 30, 96, 165, 175, 190]);
assert!(c[0] < 0.01 && c[1] < 0.01 && c[2] > 0.99 && c[3] > 0.99 && c[4] > 0.99 && c[5] < 0.01 && c[6] < 0.01, "{c:?}");
s.execute("clip.fitToFrame", json!({"clips": [clip.0]})).unwrap();
assert!((motion_scale(&s, clip) - 100.0).abs() < 1e-9);
}
#[test]
fn default_media_scaling_uses_the_display_size() {
let mut s = Session::default();
let item = matte(&mut s, (4, 3));
// Set to Frame Size: 1920 x 1080 display in 1920 x 1080 needs no scaling
s.prefs.media.default_media_scaling = "setToFrameSize".into();
let clip = overwrite_into_square_hd(&mut s, item);
assert_eq!(motion_scale(&s, clip), 100.0);
// in a 96 x 108 frame it is halved (display 192 wide), not scaled to 96 / 144
s.execute("file.newSequence", json!({"name": "Narrow", "width": 96, "height": 108, "fps": 24})).unwrap();
s.execute("source.open", json!({"item": item.0})).unwrap();
let r = s.execute("source.overwrite", json!({})).unwrap();
let clip = filmcraft_project::ClipId(r["clips"][0].as_u64().unwrap());
assert_eq!(motion_scale(&s, clip), 50.0);
// Scale to Frame Size: the renderer fits the display size
s.prefs.media.default_media_scaling = "scaleToFrameSize".into();
s.execute("file.newSequence", json!({"name": "Wide", "width": 384, "height": 108, "fps": 24})).unwrap();
s.execute("source.open", json!({"item": item.0})).unwrap();
s.execute("playhead.set", json!({"seconds": 0.0})).unwrap();
s.execute("source.overwrite", json!({})).unwrap();
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
let c = coverage(&s, &[90, 100, 192, 283, 293]);
assert!(c[0] < 0.01 && c[1] > 0.99 && c[2] > 0.99 && c[3] > 0.99 && c[4] < 0.01, "192 wide, centred: {c:?}");
}
#[test]
fn interpret_footage_pixel_aspect_wins_over_the_file_and_undoes() {
let mut s = Session::default();
let item = matte(&mut s, (4, 3));
let r = s.execute("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": [2, 1]})).unwrap();
assert_eq!(r["pixelAspect"], json!([2, 1]));
let media = |s: &Session| s.project.item(item).unwrap().as_media().unwrap().clone();
assert_eq!((media(&s).interpret.par, media(&s).pixel_aspect()), (Some((2, 1)), (2, 1)));
// the colour interpretation is left as it was
assert_eq!(media(&s).interpret.color_space, None);
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
assert_eq!(s.active_sequence().unwrap().settings.par, (2, 1));
// a 2:1 picture 144 wide shows 288 wide: wider than the 192-wide square frame at 100 %
let clip = overwrite_into_square_hd(&mut s, item);
assert_eq!(motion_scale(&s, clip), 100.0);
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
assert!(coverage(&s, &[1, 190]).iter().all(|a| *a > 0.99));
s.execute("clip.fitToFrame", json!({"clips": [clip.0]})).unwrap();
assert!((motion_scale(&s, clip) - 100.0 * 192.0 / 288.0).abs() < 1e-9);
// "file" goes back to the file's ratio; undo restores the override
s.execute("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": "file"})).unwrap();
assert_eq!((media(&s).interpret.par, media(&s).pixel_aspect()), (None, (4, 3)));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(media(&s).interpret.par, Some((2, 1)));
// hostile values are refused and change nothing
let rev = s.revision;
for bad in [
json!([0, 1]),
json!([1, 0]),
json!([9, 1]),
json!([1, 9]),
json!([4]),
json!([4, 3, 1]),
json!([-4, 3]),
json!([4.5, 3]),
json!([u64::MAX, 1]),
json!("wide"),
json!({"n": 4}),
] {
assert!(s.execute("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": bad})).is_err(), "{bad}");
}
assert!(s.execute("clip.interpretFootage", json!({"items": [item.0]})).is_err(), "nothing to change");
assert_eq!((s.revision, media(&s).interpret.par), (rev, Some((2, 1))));
}
#[test]
fn hostile_file_pixel_aspects_make_square_sequences() {
for bad in [(0, 0), (0, 3), (4, 0), (u32::MAX, 1), (1, u32::MAX), (9, 1)] {
let mut s = Session::default();
let item = matte(&mut s, bad);
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
let st = s.active_sequence().unwrap().settings.clone();
assert_eq!(st.par, (1, 1), "{bad:?}");
assert!(st.validate().is_ok());
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
assert!(coverage(&s, &[0, 143]).iter().all(|a| *a > 0.99), "{bad:?}");
let clip = overwrite_into_square_hd(&mut s, item);
assert_eq!(motion_scale(&s, clip), 100.0);
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
let c = coverage(&s, &[1, 96, 190]);
assert!(c[0] < 0.01 && c[1] > 0.99 && c[2] < 0.01, "{bad:?}: square, pillarboxed: {c:?}");
}
}
#[test]
fn generators_made_for_a_non_square_sequence_have_its_pixels() {
let mut s = Session::default();
let item = matte(&mut s, (4, 3));
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
// a Color Matte made now is 144 x 108 with the sequence's 4:3 pixels: it fills the frame
let r = s.execute("file.newColorMatte", json!({"color": "#00ff00"})).unwrap();
let matte2 = ItemId(r["item"].as_u64().unwrap());
let m = s.project.item(matte2).unwrap().as_media().unwrap();
assert_eq!((m.info.video.as_ref().unwrap().width, m.pixel_aspect()), (144, (4, 3)));
}
/// ffmpeg-made 144 x 108 H.264 with 4:3 samples (`setsar=4/3`): MP4 (`pasp`) and Matroska
/// (DisplayWidth / DisplayHeight).
fn anamorphic_fixtures() -> Option<Vec<PathBuf>> {
let ffmpeg = filmcraft_testkit::oracle::ffmpeg_or_skip("par")?;
let dir = filmcraft_testkit::fixtures_dir("engine/par");
let mut out = Vec::new();
for ext in ["mp4", "mkv"] {
let path = dir.join(format!("anamorphic_144x108_sar43.{ext}"));
out.push(filmcraft_testkit::fixtures::generate(&path, |tmp| {
std::process::Command::new(&ffmpeg)
.args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=144x108:rate=24:duration=1"])
.args(["-vf", "setsar=4/3", "-c:v", "libx264", "-preset", "fast", "-pix_fmt", "yuv420p"])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
})?);
}
Some(out)
}
#[test]
fn ffmpeg_anamorphic_files_fill_a_square_frame_and_make_anamorphic_sequences() {
let Some(files) = anamorphic_fixtures() else { return };
for file in files {
let mut s = Session::default();
let r = s.execute("file.import", json!({"paths": [file.to_string_lossy()]})).unwrap();
let item = ItemId(r["items"][0].as_u64().unwrap());
let v = s.project.item(item).unwrap().as_media().unwrap().info.video.clone().unwrap();
assert_eq!((v.width, v.height, v.par), (144, 108, (4, 3)), "{}", file.display());
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
assert_eq!(s.active_sequence().unwrap().settings.par, (4, 3));
overwrite_into_square_hd(&mut s, item);
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
// testsrc2 is opaque everywhere: covered edge to edge
let c = coverage(&s, &[1, 10, 181, 190]);
assert!(c.iter().all(|a| *a > 0.99), "{}: {c:?}", file.display());
}
}
+2
View File
@@ -103,6 +103,8 @@ pub fn transcode(
if let Some(v) = &info.video {
st.width = v.width;
st.height = v.height;
// pixels in = pixels out: the sequence has the clip's pixel aspect, so nothing is stretched
st.par = clip.pixel_aspect();
}
st.frame_rate = rate;
st.sample_rate = info.audio.as_ref().map_or(48_000, |a| a.sample_rate.max(8000));
+7 -6
View File
@@ -1211,17 +1211,18 @@ pub fn map_output(left: &[f32], right: &[f32], out: &mut [f32], ch: usize, map:
}
}
/// Settings ▸ Media ▸ Default Media Scaling for a clip of `src` size placed in a `frame`-sized
/// sequence: `scaleToFrameSize` turns on Scale to Frame Size (rasterised at frame size),
/// `setToFrameSize` sets Motion ▸ Scale so the picture fits the frame.
pub fn apply_media_scaling(ti: &mut filmcraft_project::TrackItem, scaling: &str, frame: (u32, u32), src: (u32, u32)) {
if src.0 == 0 || src.1 == 0 {
/// Settings ▸ Media ▸ Default Media Scaling for a clip placed in a `frame`-sized sequence. `src`
/// is the clip's size at its display aspect in sequence pixels
/// ([`filmcraft_project::conformed_size`]): `scaleToFrameSize` turns on Scale to Frame Size
/// (rasterised at frame size), `setToFrameSize` sets Motion ▸ Scale so the picture fits the frame.
pub fn apply_media_scaling(ti: &mut filmcraft_project::TrackItem, scaling: &str, frame: (u32, u32), src: (f64, f64)) {
if !(src.0 >= 1e-9 && src.1 >= 1e-9 && src.0.is_finite() && src.1.is_finite()) {
return;
}
match scaling {
"scaleToFrameSize" => ti.scale_to_frame = true,
"setToFrameSize" => {
let fit = (frame.0 as f64 / src.0 as f64).min(frame.1 as f64 / src.1 as f64);
let fit = (frame.0 as f64 / src.0).min(frame.1 as f64 / src.1);
if let Some(m) = ti.effects.iter_mut().find(|e| e.effect == "motion") {
m.params.insert("scale".into(), filmcraft_project::Param::new(filmcraft_project::ParamValue::Float((fit * 1000.0).round() / 10.0)));
}
+137
View File
@@ -247,6 +247,12 @@ impl MediaClip {
pub fn frame_rate(&self) -> FrameRate {
self.interpret.frame_rate.unwrap_or_else(|| self.info.frame_rate())
}
/// Pixel aspect ratio of the picture, in lowest terms: Modify ▸ Interpret Footage's override,
/// else the file's. A ratio that makes no sense ([`checked_par`]) is ignored: an override in
/// favour of the file's value, the file's in favour of square pixels.
pub fn pixel_aspect(&self) -> (u32, u32) {
self.interpret.par.and_then(checked_par).or_else(|| self.info.video.as_ref().and_then(|v| checked_par(v.par))).unwrap_or((1, 1))
}
pub fn duration(&self) -> Tick {
self.info.duration
}
@@ -877,6 +883,51 @@ pub fn validate_frame_size(width: u32, height: u32) -> Result<(), String> {
Ok(())
}
/// The most a pixel aspect ratio may stretch a picture either way (8:1 or 1:8). Real footage stays
/// well inside (2:1 for 2x anamorphic lenses, 4:3 for HDV, 0.9 for NTSC DV); a ratio beyond it is
/// garbage from a damaged file and is treated as square.
pub const MAX_PIXEL_ASPECT: f64 = 8.0;
/// A pixel aspect ratio fit to compute with, in lowest terms, or `None` when it is unset (a zero
/// term, as decoders report "not signalled") or stretches more than [`MAX_PIXEL_ASPECT`] either
/// way. Ratios come from media, project and interchange files, so they are untrusted.
pub fn checked_par(par: (u32, u32)) -> Option<(u32, u32)> {
let (n, d) = par;
if n == 0 || d == 0 {
return None;
}
let r = f64::from(n) / f64::from(d);
if !(1.0 / MAX_PIXEL_ASPECT..=MAX_PIXEL_ASPECT).contains(&r) {
return None;
}
let (mut a, mut b) = (n, d);
while b != 0 {
(a, b) = (b, a % b);
}
let g = a.max(1);
Some((n / g, d / g))
}
/// [`checked_par`], with square pixels for a ratio that is unset or makes no sense.
pub fn sane_par(par: (u32, u32)) -> (u32, u32) {
checked_par(par).unwrap_or((1, 1))
}
/// The width of one pixel over its height ([`sane_par`] first): 1.333… for 4:3 pixels.
pub fn par_ratio(par: (u32, u32)) -> f64 {
let (n, d) = sane_par(par);
f64::from(n) / f64::from(d)
}
/// The size of a `size` picture with `par` pixels in the pixels of a frame whose pixel aspect is
/// `frame_par`: the width scales by par / frame_par, the height stays. A 1440 x 1080 picture with
/// 4:3 pixels covers 1920 x 1080 square pixels.
pub fn conformed_size(size: (u32, u32), par: (u32, u32), frame_par: (u32, u32)) -> (f64, f64) {
let (par, frame_par) = (sane_par(par), sane_par(frame_par));
let w = if par == frame_par { f64::from(size.0) } else { f64::from(size.0) * par_ratio(par) / par_ratio(frame_par) };
(w, f64::from(size.1))
}
impl SequenceSettings {
pub fn validate(&self) -> Result<(), String> {
validate_frame_size(self.width, self.height)?;
@@ -1609,6 +1660,35 @@ impl Project {
None
}
/// Pixel aspect ratio of what an item shows, in lowest terms: a media clip's
/// ([`MediaClip::pixel_aspect`], Interpret Footage first), a nested sequence's, a subclip its
/// parent's. `None` for items drawn in the pixels of whatever sequence they are in (graphics,
/// adjustment layers) and for an item that doesn't exist.
pub fn source_par(&self, item: ItemId) -> Option<(u32, u32)> {
let mut id = item;
// subclips of subclips: bounded, as in `source_size`
for _ in 0..16 {
return match &self.item(id)?.kind {
ItemKind::Media(m) => Some(m.pixel_aspect()),
ItemKind::Sequence(q) => Some(sane_par(q.settings.par)),
ItemKind::AdjustmentLayer { .. } | ItemKind::Graphic { .. } => None,
ItemKind::Subclip { parent, .. } => {
id = *parent;
continue;
}
};
}
None
}
/// [`Project::source_size`] in the pixels of a frame whose pixel aspect is `frame_par`
/// ([`conformed_size`]): what Scale to Frame Size, Set to Frame Size and Fit / Fill fit into
/// the frame. Items without a pixel aspect of their own take the frame's.
pub fn conformed_source_size(&self, item: ItemId, frame_par: (u32, u32)) -> Option<(f64, f64)> {
let size = self.source_size(item)?;
Some(conformed_size(size, self.source_par(item).unwrap_or(frame_par), frame_par))
}
/// Resolve the "auto" (NaN) points of the clips in the sequences `sequences` selects, for
/// clips that did not go through a placing command (an interchange import builds them with
/// the effect defaults). Points measured in the frame use the sequence's size. `anchor` is
@@ -1950,4 +2030,61 @@ mod tests {
nest.frame_hold = Some(f(100));
assert_eq!(p.nest_overhang(&nest), Some(nest.range()));
}
#[test]
fn pixel_aspect_ratios_are_reduced_and_hostile_ones_are_square() {
assert_eq!(checked_par((4, 3)), Some((4, 3)));
assert_eq!(checked_par((40, 30)), Some((4, 3)));
assert_eq!(checked_par((u32::MAX, u32::MAX)), Some((1, 1)));
assert_eq!(checked_par((u32::MAX, u32::MAX - 1)), Some((u32::MAX, u32::MAX - 1)));
assert_eq!(checked_par((8, 1)), Some((8, 1)));
assert_eq!(checked_par((1, 8)), Some((1, 8)));
for bad in [(0, 0), (0, 1), (1, 0), (9, 1), (1, 9), (u32::MAX, 1), (1, u32::MAX)] {
assert_eq!(checked_par(bad), None, "{bad:?}");
assert_eq!(sane_par(bad), (1, 1), "{bad:?}");
assert_eq!(par_ratio(bad), 1.0, "{bad:?}");
}
assert!((par_ratio((4, 3)) - 4.0 / 3.0).abs() < 1e-12);
// 1440 x 1080 with 4:3 pixels is 1920 x 1080 square pixels, and itself in a 4:3 frame
assert_eq!(conformed_size((1440, 1080), (4, 3), (1, 1)), (1920.0, 1080.0));
assert_eq!(conformed_size((1440, 1080), (4, 3), (8, 6)), (1440.0, 1080.0));
assert_eq!(conformed_size((1920, 1080), (1, 1), (4, 3)), (1440.0, 1080.0));
assert_eq!(conformed_size((u32::MAX, u32::MAX), (8, 1), (1, 8)), (u32::MAX as f64 * 64.0, u32::MAX as f64));
assert_eq!(conformed_size((1440, 1080), (0, 0), (u32::MAX, 1)), (1440.0, 1080.0));
}
#[test]
fn source_par_follows_interpret_footage_subclips_and_sequences() {
let (mut p, clip, seq) = demo_project();
assert_eq!(p.source_par(clip), Some((1, 1)));
let sub = p.add_item("Sub", Label::Iris, ItemKind::Subclip { parent: clip, range: TimeRange::new(Tick::ZERO, Tick(100)), restrict_trims: false }, None);
fn media(p: &mut Project, id: ItemId) -> &mut MediaClip {
match &mut p.item_mut(id).unwrap().kind {
ItemKind::Media(m) => m,
_ => unreachable!(),
}
}
// the file's ratio, reduced
media(&mut p, clip).info.video.as_mut().unwrap().par = (40, 30);
assert_eq!((p.source_par(clip), p.source_par(sub)), (Some((4, 3)), Some((4, 3))));
assert_eq!(p.conformed_source_size(clip, (1, 1)), Some((1920.0 * 4.0 / 3.0, 1080.0)));
// Interpret Footage wins over the file
media(&mut p, clip).interpret.par = Some((2, 1));
assert_eq!(p.source_par(sub), Some((2, 1)));
// a nonsensical override falls back to the file, a nonsensical file value to square
media(&mut p, clip).interpret.par = Some((0, 7));
assert_eq!(p.source_par(clip), Some((4, 3)));
media(&mut p, clip).info.video.as_mut().unwrap().par = (u32::MAX, 1);
assert_eq!(p.source_par(clip), Some((1, 1)));
// a nested sequence has its own ratio; graphics and missing items have none
p.sequence_mut(seq).unwrap().settings.par = (0, 0);
assert_eq!(p.source_par(seq), Some((1, 1)));
p.sequence_mut(seq).unwrap().settings.par = (8, 6);
assert_eq!(p.source_par(seq), Some((4, 3)));
assert_eq!(p.conformed_source_size(seq, (1, 1)), Some((2560.0, 1080.0)));
let adj = p.add_item("Adj", Label::Iris, ItemKind::AdjustmentLayer { width: 1440, height: 1080, rate: FrameRate::FPS_24, duration: Tick(100) }, None);
assert_eq!(p.source_par(adj), None);
assert_eq!(p.conformed_source_size(adj, (4, 3)), Some((1440.0, 1080.0)));
assert_eq!(p.source_par(ItemId(987_654_321)), None);
}
}
+37 -5
View File
@@ -282,7 +282,7 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
/// it covers the whole output.
fn adjustment_region(seq: &Sequence, item: &TrackItem, project: &Project, mt: Tick, scale: f32, w: usize, h: usize) -> Option<Image> {
let size = source_size(project, item.item).unwrap_or((seq.settings.width, seq.settings.height));
let motion = motion_matrix(seq, item, size, mt);
let motion = motion_matrix(seq, item, size, source_par(project, item.item), mt);
let s = scale as f64;
let (fw, fh) = (((size.0 as f64 * s).round() as usize).max(1), ((size.1 as f64 * s).round() as usize).max(1));
let m = Affine::scale(s, s).then_apply(&motion).then_apply(&Affine::scale(1.0 / s, 1.0 / s));
@@ -332,10 +332,26 @@ pub fn source_size(project: &Project, item: ItemId) -> Option<(u32, u32)> {
project.source_size(item)
}
/// Pixel aspect ratio of an item's source, for [`motion_matrix`]: Interpret Footage's override,
/// else the media's; `None` for items drawn in sequence pixels (graphics, adjustment layers).
pub fn source_par(project: &Project, item: ItemId) -> Option<(u32, u32)> {
project.source_par(item)
}
/// The Motion transform of an item at media time `mt`, mapping full-res source pixels to
/// full-res sequence pixels.
pub fn motion_matrix(seq: &Sequence, item: &TrackItem, src: (u32, u32), mt: Tick) -> Affine {
///
/// `src` is the source's size in its own (storage) pixels and `src_par` their pixel aspect ratio
/// (`None`: the sequence's, for graphics and adjustment layers). Motion works in square display
/// units, as Premiere's does: the Anchor Point is in source pixels and the Position in sequence
/// pixels, but Scale and Rotation see the picture at its display aspect, so a 1440 x 1080 clip with
/// 4:3 pixels covers a 1920 x 1080 square-pixel frame at 100 %, and a square-pixel clip in a 4:3
/// sequence is narrowed to its display width. Scale to Frame Size fits the display size too.
pub fn motion_matrix(seq: &Sequence, item: &TrackItem, src: (u32, u32), src_par: Option<(u32, u32)>, mt: Tick) -> Affine {
let (sw, sh) = (seq.settings.width as f64, seq.settings.height as f64);
let seq_par = filmcraft_project::sane_par(seq.settings.par);
let src_par = src_par.map_or(seq_par, filmcraft_project::sane_par);
let (p, q) = (filmcraft_project::par_ratio(src_par), filmcraft_project::par_ratio(seq_par));
let mut pos = Vec2::new(sw / 2.0, sh / 2.0);
let mut anchor = Vec2::new(src.0 as f64 / 2.0, src.1 as f64 / 2.0);
let mut scale = Vec2::new(1.0, 1.0);
@@ -356,10 +372,22 @@ pub fn motion_matrix(seq: &Sequence, item: &TrackItem, src: (u32, u32), mt: Tick
rot = m.f64_at("rotation", mt);
}
if item.scale_to_frame {
let fit = (sw / src.0 as f64).min(sh / src.1 as f64);
// the display size fitted in the frame's display size
let fit = (sw * q / (src.0.max(1) as f64 * p)).min(sh / src.1.max(1) as f64);
scale = scale * fit;
}
Affine::motion(pos, scale, rot, anchor)
// source px → (× p) display units → Scale, Rotation → (÷ q) sequence px. Without rotation the
// two stretches merge into one horizontal factor, exactly 1 when the ratios are the same.
if rot == 0.0 {
let r = if src_par == seq_par { 1.0 } else { p / q };
return Affine::motion(pos, Vec2::new(scale.x * r, scale.y), rot, anchor);
}
Affine::translate(pos.x, pos.y).then_apply(&Affine::scale(1.0 / q, 1.0)).then_apply(&Affine::motion(
Vec2::ZERO,
Vec2::new(scale.x * p, scale.y),
rot,
anchor,
))
}
/// Render one track item's layer at timeline time `t` into a canvas-sized image.
@@ -403,7 +431,7 @@ pub(crate) fn item_layer_ex(
// time inside a frame hold without Hold Filters).
let mt = item.effect_time_at(t);
let src_size = source_size(project, item.item)?;
let motion = motion_matrix(seq, item, src_size, mt);
let motion = motion_matrix(seq, item, src_size, source_par(project, item.item), mt);
// How many output pixels one source pixel covers → request a reduced frame when possible.
let lin = ((motion.a * motion.a + motion.b * motion.b).sqrt()).max((motion.c * motion.c + motion.d * motion.d).sqrt());
let want = (lin * opts.scale as f64).clamp(1.0 / 64.0, 1.0) as f32;
@@ -678,3 +706,7 @@ mod preview_tests;
#[cfg(test)]
#[path = "region_tests.rs"]
mod region_tests;
#[cfg(test)]
#[path = "par_tests.rs"]
mod par_tests;
+270
View File
@@ -0,0 +1,270 @@
//! Pixel aspect ratio: a clip with non-square pixels is placed at its display aspect (a 1440 x 1080
//! picture with 4:3 pixels covers a 1920 x 1080 square-pixel frame), a sequence with non-square
//! pixels holds clips at its own aspect, Interpret Footage overrides the file's ratio, and hostile
//! ratios are square. Small in-memory frames stand in for 1440 x 1080 / 1920 x 1080 at a tenth of
//! the size: 144 x 108 with 4:3 pixels shows 192 x 108.
use super::*;
use filmcraft_frame::AudioBuffer;
use filmcraft_media::{MediaInfo, MediaKind, MediaSource, VideoStreamInfo};
use filmcraft_project::{Label, MediaClip, MediaRef, SequenceSettings, TrackKind, resolve_auto_points};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, TimeRange};
const FPS: FrameRate = FrameRate::FPS_24;
/// One still picture: the left half red, the right half blue (opaque).
struct Halves {
info: MediaInfo,
frame: Arc<VideoFrame>,
}
impl MediaSource for Halves {
fn info(&self) -> &MediaInfo {
&self.info
}
fn video_frame(&self, _req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
Ok(self.frame.clone())
}
fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
Ok(AudioBuffer::silence(sample_rate, 2, frames))
}
}
fn halves(w: u32, h: u32, par: (u32, u32)) -> Halves {
let px: Vec<u8> = (0..w * h).flat_map(|i| if i % w < w / 2 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }).collect();
let frame = Arc::new(VideoFrame { par, ..VideoFrame::rgba8(w, h, px) });
let info = MediaInfo {
name: "halves".into(),
kind: MediaKind::Movie,
duration: Tick(10 * TICKS_PER_SECOND),
video: Some(VideoStreamInfo {
width: w,
height: h,
frame_rate: FPS,
par,
codec: "test".into(),
pixel_format: "RGBA 8-bit".into(),
color: filmcraft_color::ColorInfo::SRGB_FULL,
has_alpha: false,
bitrate: None,
hdr: None,
}),
audio: None,
container: "test".into(),
start_timecode: None,
file_size: None,
};
Halves { info, frame }
}
/// A project with a `src`-sized clip of `src_par` pixels on V1 of a `seq`-sized sequence with
/// `seq_par` pixels, placed as the timeline places media (Motion's points resolved, 100 %).
fn project(src: (u32, u32), src_par: (u32, u32), seq: (u32, u32), seq_par: (u32, u32), scale_to_frame: bool) -> (Project, ItemId, ItemId, SourceMap) {
let mut p = Project::new("par");
let source = halves(src.0, src.1, src_par);
let clip = MediaClip {
media: MediaRef::File { path: "halves.mov".into() },
info: source.info.clone(),
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
};
let item = p.add_item("halves", Label::Iris, ItemKind::Media(clip), None);
let settings = SequenceSettings { width: seq.0, height: seq.1, par: seq_par, frame_rate: FPS, ..Default::default() };
let sid = p.new_sequence("s", settings, 1, 0, None);
let mut ti = p.make_track_item(item, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, FPS.tick_of(24)), FPS).unwrap();
for e in &mut ti.effects {
resolve_auto_points(e, seq, src);
}
ti.scale_to_frame = scale_to_frame;
p.sequence_mut(sid).unwrap().video_tracks[0].items.push(ti);
let mut map = SourceMap::default();
map.0.insert(item, Arc::new(source) as SharedSource);
(p, sid, item, map)
}
fn render(p: &Project, seq: ItemId, map: &SourceMap) -> Image {
render_sequence(p, seq, Tick::ZERO, RenderOptions::default(), map)
}
/// Middle-row colour of column `x`: 'r', 'b' or '.' (transparent).
fn col(img: &Image, x: usize) -> char {
let c = img.get(x, img.h / 2);
match c {
_ if c[3] < 0.01 => '.',
_ if c[3] > 0.99 && c[0] > 0.99 && c[2] < 0.01 => 'r',
_ if c[3] > 0.99 && c[2] > 0.99 && c[0] < 0.01 => 'b',
_ => '?',
}
}
/// The middle row as a string of `col`s, one per column.
fn row(img: &Image) -> String {
(0..img.w).map(|x| col(img, x)).collect()
}
/// `got` is `want` except where bilinear filtering mixes neighbours: next to a change in `want`
/// and at the frame's edges.
#[track_caller]
fn assert_row(got: &str, want: &str) {
let (g, w): (Vec<char>, Vec<char>) = (got.chars().collect(), want.chars().collect());
assert_eq!(g.len(), w.len(), "\n got {got}\nwant {want}");
for i in 1..w.len().saturating_sub(1) {
if w[i - 1] == w[i] && w[i + 1] == w[i] {
assert_eq!(g[i], w[i], "column {i}\n got {got}\nwant {want}");
}
}
}
#[test]
fn an_anamorphic_clip_fills_a_square_pixel_frame_at_100_percent() {
// 1440 x 1080 with 4:3 pixels in 1920 x 1080: the whole frame, split in the middle
let (p, seq, _, map) = project((144, 108), (4, 3), (192, 108), (1, 1), false);
let img = render(&p, seq, &map);
assert_row(&row(&img), &format!("{}{}", "r".repeat(96), "b".repeat(96)));
// every row is covered, top to bottom
for y in [1, 53, 106] {
assert!(img.get(1, y)[3] > 0.99 && img.get(190, y)[3] > 0.99, "row {y}");
}
// the same picture with square pixels is pillarboxed at 1440 (144) wide: what the bug showed
let (p, seq, _, map) = project((144, 108), (1, 1), (192, 108), (1, 1), false);
assert_row(&row(&render(&p, seq, &map)), &format!("{}{}{}{}", ".".repeat(24), "r".repeat(72), "b".repeat(72), ".".repeat(24)));
}
#[test]
fn the_gpu_plan_places_an_anamorphic_clip_like_the_cpu() {
let (p, seq, _, map) = project((144, 108), (4, 3), (192, 108), (1, 1), false);
for scale in [1.0, 0.5] {
let opts = RenderOptions { scale, ..Default::default() };
let want = render_sequence(&p, seq, Tick::ZERO, opts, &map);
let got = plan::execute_cpu(&plan::plan_frame(&p, seq, Tick::ZERO, opts, &map));
assert_eq!((got.w, got.h), (want.w, want.h));
assert_row(&row(&got), &row(&want));
}
}
#[test]
fn scale_to_frame_size_fits_the_display_size() {
// 1920-wide display picture in a 3840 x 1080 frame: fitted by height at 1:1, 1920 wide
let (p, seq, _, map) = project((144, 108), (4, 3), (384, 108), (1, 1), true);
assert_row(&row(&render(&p, seq, &map)), &format!("{}{}{}{}", ".".repeat(96), "r".repeat(96), "b".repeat(96), ".".repeat(96)));
// fitted by width: 1920 x 1080 display in 960 x 1080 → 960 x 540, letterboxed
let (p, seq, _, map) = project((144, 108), (4, 3), (96, 108), (1, 1), true);
let img = render(&p, seq, &map);
assert_row(&row(&img), &format!("{}{}", "r".repeat(48), "b".repeat(48)));
assert!(img.get(48, 10)[3] < 0.01 && img.get(48, 54)[3] > 0.99, "letterboxed to 54 rows");
}
#[test]
fn a_sequence_with_the_clips_pixel_aspect_shows_it_pixel_for_pixel() {
// New Sequence From Clip: 1440 x 1080, 4:3 pixels; the clip maps 1:1 onto the frame
let (p, seq, _, map) = project((144, 108), (4, 3), (144, 108), (4, 3), false);
let m = motion_matrix(p.sequence(seq).unwrap(), &p.sequence(seq).unwrap().video_tracks[0].items[0], (144, 108), Some((4, 3)), Tick::ZERO);
assert!(identity_placement(&m), "{m:?}");
let img = render(&p, seq, &map);
assert_eq!(row(&img), format!("{}{}", "r".repeat(72), "b".repeat(72)));
// the same ratio written differently is the same ratio
let (p, seq, _, map) = project((144, 108), (8, 6), (144, 108), (4, 3), false);
assert_eq!(render(&p, seq, &map).px, img.px);
}
#[test]
fn square_pixel_clips_are_narrowed_in_a_non_square_sequence() {
// 1920 x 1080 square in 1440 x 1080 with 4:3 pixels: exactly the frame
let (p, seq, _, map) = project((192, 108), (1, 1), (144, 108), (4, 3), false);
assert_row(&row(&render(&p, seq, &map)), &format!("{}{}", "r".repeat(72), "b".repeat(72)));
// 1440 x 1080 square (4:3 picture) in it: 1080 sequence pixels wide, pillarboxed
let (p, seq, _, map) = project((144, 108), (1, 1), (144, 108), (4, 3), false);
assert_row(&row(&render(&p, seq, &map)), &format!("{}{}{}{}", ".".repeat(18), "r".repeat(54), "b".repeat(54), ".".repeat(18)));
}
#[test]
fn rotation_happens_at_the_display_aspect() {
// a 100 x 100 square-pixel source in a 200 x 100 sequence with 2:1 pixels shows 50 x 100
// sequence pixels (a square on screen); turned 90° it is still that square
let settings = SequenceSettings { width: 200, height: 100, par: (2, 1), ..Default::default() };
let mut p = Project::new("rot");
let sid = p.new_sequence("s", settings, 1, 0, None);
let adj = p.add_item("a", Label::Iris, ItemKind::AdjustmentLayer { width: 100, height: 100, rate: FPS, duration: Tick(TICKS_PER_SECOND) }, None);
let mut ti = p.make_track_item(adj, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, FPS.tick_of(1)), FPS).unwrap();
for e in &mut ti.effects {
resolve_auto_points(e, (200, 100), (100, 100));
}
let seq = p.sequence(sid).unwrap();
let r = filmcraft_geom::Rect { x: 0.0, y: 0.0, w: 100.0, h: 100.0 };
for rot in [0.0, 90.0, -270.0] {
ti.effect_mut("motion").unwrap().params.get_mut("rotation").unwrap().value = ParamValue::Float(rot);
let b = motion_matrix(seq, &ti, (100, 100), Some((1, 1)), Tick::ZERO).bounds(&r);
assert!((b.w - 50.0).abs() < 1e-9 && (b.h - 100.0).abs() < 1e-9 && (b.x - 75.0).abs() < 1e-9, "{rot}°: {b:?}");
}
// a 45° turn: the square's diagonal, √2 · 100 display units, both ways
ti.effect_mut("motion").unwrap().params.get_mut("rotation").unwrap().value = ParamValue::Float(45.0);
let b = motion_matrix(seq, &ti, (100, 100), Some((1, 1)), Tick::ZERO).bounds(&r);
let d = 100.0 * std::f64::consts::SQRT_2;
assert!((b.w - d / 2.0).abs() < 1e-9 && (b.h - d).abs() < 1e-9, "{b:?}");
// graphics and adjustment layers (no ratio of their own) are in sequence pixels
ti.effect_mut("motion").unwrap().params.get_mut("rotation").unwrap().value = ParamValue::Float(0.0);
let b = motion_matrix(seq, &ti, (100, 100), None, Tick::ZERO).bounds(&r);
assert!((b.w - 100.0).abs() < 1e-9, "{b:?}");
}
#[test]
fn interpret_footage_overrides_the_files_pixel_aspect() {
// the file says square; Interpret Footage says 4:3
let (mut p, seq, item, map) = project((144, 108), (1, 1), (192, 108), (1, 1), false);
match &mut p.item_mut(item).unwrap().kind {
ItemKind::Media(m) => m.interpret.par = Some((4, 3)),
_ => unreachable!(),
}
assert_row(&row(&render(&p, seq, &map)), &format!("{}{}", "r".repeat(96), "b".repeat(96)));
// and the other way round: a 4:3 file interpreted as square is pillarboxed
let (mut p, seq, item, map) = project((144, 108), (4, 3), (192, 108), (1, 1), false);
match &mut p.item_mut(item).unwrap().kind {
ItemKind::Media(m) => m.interpret.par = Some((1, 1)),
_ => unreachable!(),
}
assert!(row(&render(&p, seq, &map)).starts_with(&".".repeat(24)));
}
#[test]
fn hostile_pixel_aspect_ratios_render_square_without_panicking() {
let pillarboxed = format!("{}{}{}{}", ".".repeat(24), "r".repeat(72), "b".repeat(72), ".".repeat(24));
let filled = format!("{}{}", "r".repeat(96), "b".repeat(96));
let hostile = [(0, 0), (0, 3), (4, 0), (u32::MAX, 1), (1, u32::MAX), (9, 1), (1, 9)];
for bad in hostile {
// a clip's ratio: square
let r = std::panic::catch_unwind(|| {
let (p, seq, _, map) = project((144, 108), bad, (192, 108), (1, 1), false);
row(&render(&p, seq, &map))
});
assert_row(&r.unwrap_or_default(), &pillarboxed);
// a sequence's ratio (a damaged project): square, so a 4:3-pixel clip fills it
let r = std::panic::catch_unwind(|| {
let (p, seq, _, map) = project((144, 108), (4, 3), (192, 108), bad, true);
let plan = plan::execute_cpu(&plan::plan_frame(&p, seq, Tick::ZERO, RenderOptions::default(), &map));
(row(&render(&p, seq, &map)), row(&plan))
});
let (cpu, gpu) = r.unwrap_or_default();
assert_row(&cpu, &filled);
assert_row(&gpu, &filled);
// an Interpret Footage override that makes no sense: the file's ratio
let r = std::panic::catch_unwind(|| {
let (mut p, seq, item, map) = project((144, 108), (4, 3), (192, 108), (1, 1), false);
if let ItemKind::Media(m) = &mut p.item_mut(item).unwrap().kind {
m.interpret.par = Some(bad);
}
row(&render(&p, seq, &map))
});
assert_row(&r.unwrap_or_default(), &filled);
}
// extreme but valid ratios on degenerate sizes stay finite
for (src, par) in [((1, 1), (8, 1)), ((1, 32_768), (1, 8)), ((32_768, 1), (8, 1))] {
let (p, seq, _, map) = project(src, par, (16, 16), (1, 8), true);
let img = render(&p, seq, &map);
assert!(img.px.iter().all(|v| v.is_finite()), "{src:?} {par:?}");
}
}
+4 -4
View File
@@ -340,7 +340,7 @@ fn push_item(
&& let Some(angle) = item.multicam_angle(nested)
&& !(opts.effects && item.has_standard_effects())
&& (nested.settings.width, nested.settings.height) == (seq.settings.width, seq.settings.height)
&& near_identity(&motion_matrix(seq, item, (nested.settings.width, nested.settings.height), mt))
&& near_identity(&motion_matrix(seq, item, (nested.settings.width, nested.settings.height), Some(nested.settings.par), mt))
&& let Some(tr) = nested.angle_video_track_index(angle).and_then(|i| nested.video_tracks.get(i))
&& !tr.transitions.iter().any(|x| x.range().contains(ft))
&& tr.item_at(ft).is_none_or(|i| crate::opacity_blend(i, i.effect_time_at(ft)).1 != Blend::Dissolve)
@@ -362,7 +362,7 @@ fn push_item(
&& !item.has_opacity_masks()
&& bl == Blend::Normal
&& extra_opacity * op >= 1.0 - 1e-6
&& near_identity(&motion_matrix(seq, item, (nested.settings.width, nested.settings.height), mt))
&& near_identity(&motion_matrix(seq, item, (nested.settings.width, nested.settings.height), Some(nested.settings.par), mt))
&& nest_is_plain(project, seq, nested, ft)
{
push_tracks(project, nested, ft, opts, sources, nest + 1, out);
@@ -384,7 +384,7 @@ fn push_item(
{
let Some(size) = crate::source_size(project, item.item) else { return };
let (w, h) = output_size(seq, opts.scale);
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion_matrix(seq, item, size, mt));
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion_matrix(seq, item, size, None, mt));
if let Some((img, x, y)) = crate::graphic_clip::render_graphic_tight(item, mt, size, &m, w, h) {
out.push(PlanLayer::new(cpu_frame(img), Affine::translate(x as f64, y as f64), op * extra_opacity, bl));
}
@@ -393,7 +393,7 @@ fn push_item(
if let Some(chain) = gpu_chain(project, item, opts) {
let Some(src) = sources.source(item.item) else { return };
let Some(size) = crate::source_size(project, item.item) else { return };
let motion = motion_matrix(seq, item, size, mt);
let motion = motion_matrix(seq, item, size, crate::source_par(project, item.item), mt);
let lin = ((motion.a * motion.a + motion.b * motion.b).sqrt()).max((motion.c * motion.c + motion.d * motion.d).sqrt());
let want = (lin * opts.scale as f64).clamp(1.0 / 64.0, 1.0) as f32;
let time = crate::video_source_time(item, t, src.info().frame_rate());
+1 -1
View File
@@ -411,7 +411,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
return Ok(json!({"dialog": "audioGain"}));
}
// Colour dialogs from the menus; with params the engine command applies directly.
"clip.interpretFootage" if params.get("colorSpace").is_none() => {
"clip.interpretFootage" if params.get("colorSpace").is_none() && params.get("pixelAspect").is_none() => {
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
crate::panels::color_dialogs::open_interpret(app, &params);
return Ok(json!({"dialog": "interpretFootage"}));
+11 -6
View File
@@ -87,11 +87,17 @@ pub fn selected_graphic(app: &FilmcraftApp) -> Option<(ClipId, TrackItem)> {
/// Layers of the graphic clips visible at the playhead, front-most first. A layer whose
/// visibility is off is not in the picture, so it has no box and cannot be clicked either.
/// Sequence pixels → screen points on the monitor picture `pic`. The picture shows the frame at its
/// display aspect, so with non-square sequence pixels the two axes scale differently.
fn frame_to_screen(pic: Rect, frame: (u32, u32)) -> Affine {
let (kx, ky) = (pic.width() as f64 / frame.0.max(1) as f64, pic.height() as f64 / frame.1.max(1) as f64);
Affine::translate(pic.min.x as f64, pic.min.y as f64).then_apply(&Affine::scale(kx, ky))
}
pub fn visible_layers(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Vec<LayerView> {
let Some(seq) = app.session.active_sequence() else { return Vec::new() };
let t = app.session.playhead();
let k = pic.width() as f64 / frame.0.max(1) as f64;
let screen = Affine::translate(pic.min.x as f64, pic.min.y as f64).then_apply(&Affine::scale(k, k));
let screen = frame_to_screen(pic, frame);
let mut out = Vec::new();
for tr in seq.video_tracks.iter().rev() {
if !tr.enabled {
@@ -104,7 +110,7 @@ pub fn visible_layers(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Vec<L
let Some(ItemKind::Graphic { width, height, .. }) = app.session.project.item(it.item).map(|p| &p.kind) else { continue };
let size = (*width, *height);
let mt = it.source_time_at(t);
let base = screen.then_apply(&filmcraft_render::motion_matrix(seq, it, size, mt));
let base = screen.then_apply(&filmcraft_render::motion_matrix(seq, it, size, None, mt));
let idx = layer_indices(&it.effects);
for (li, &ei) in idx.iter().enumerate().rev() {
if let Some(spec) = eval_layer(&it.effects[ei], mt, size).filter(|s| s.enabled) {
@@ -119,15 +125,14 @@ pub fn visible_layers(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Vec<L
/// Screen → graphic-canvas mapping for new layers: the selected graphic clip under the playhead
/// (its Motion applies), else the sequence frame.
fn canvas_target(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> (Option<ClipId>, Affine) {
let k = pic.width() as f64 / frame.0.max(1) as f64;
let screen = Affine::translate(pic.min.x as f64, pic.min.y as f64).then_apply(&Affine::scale(k, k));
let screen = frame_to_screen(pic, frame);
let t = app.session.playhead();
if let Some((c, it)) = selected_graphic(app)
&& it.range().contains(t)
&& let Some(seq) = app.session.active_sequence()
&& let Some(ItemKind::Graphic { width, height, .. }) = app.session.project.item(it.item).map(|p| &p.kind)
{
let m = screen.then_apply(&filmcraft_render::motion_matrix(seq, &it, (*width, *height), it.source_time_at(t)));
let m = screen.then_apply(&filmcraft_render::motion_matrix(seq, &it, (*width, *height), None, it.source_time_at(t)));
return (Some(c), m);
}
(None, screen)
+1 -1
View File
@@ -163,7 +163,7 @@ pub fn effect_rows(
fn clip_to_screen(app: &FilmcraftApp, it: &TrackItem, mt: Tick, pic: Rect, frame: (u32, u32)) -> Option<Affine> {
let seq = app.session.active_sequence()?;
let size = filmcraft_render::source_size(&app.session.project, it.item).unwrap_or(frame);
let motion = filmcraft_render::motion_matrix(seq, it, size, mt);
let motion = filmcraft_render::motion_matrix(seq, it, size, filmcraft_render::source_par(&app.session.project, it.item), mt);
let view = Affine::translate(pic.min.x as f64, pic.min.y as f64)
.then_apply(&Affine::scale(pic.width() as f64 / frame.0 as f64, pic.height() as f64 / frame.1 as f64));
Some(view.then_apply(&motion))
+7 -5
View File
@@ -157,12 +157,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
} else {
(None, video_area)
};
// ---- picture
// ---- picture, at its display aspect (non-square pixels are stretched on screen)
let ppp = ctx.pixels_per_point();
let display_w = (frame_size.0 as f64 * monitor_view::pixel_aspect(app, which)) as f32;
let pic = if compare {
fit(video_area, frame_size.0 as f32, frame_size.1 as f32)
fit(video_area, display_w, frame_size.1 as f32)
} else if show_picture {
monitor_view::picture_rect(video_area, frame_size.0 as f32, frame_size.1 as f32, &mv, ppp)
monitor_view::picture_rect(video_area, display_w, frame_size.1 as f32, &mv, ppp)
} else {
video_area
};
@@ -172,7 +173,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
ui.painter().rect_filled(pic, 0.0, t.monitor_bg);
let playing = if which == Which::Program { app.playback.playing } else { app.source_playback.clock.playing };
let res = mv.effective_res(playing);
let screen_scale = (pic.width() * ppp / frame_size.0 as f32).min(1.0);
// frame pixels per screen pixel along the axis that needs the most of them
let screen_scale = (pic.width() * ppp / frame_size.0.max(1) as f32).max(pic.height() * ppp / frame_size.1.max(1) as f32).min(1.0);
let scale = quantize_scale(res.scale().min(screen_scale.max(1.0 / 32.0)));
let frame = rate.frame_at(time);
let rev = match target {
@@ -240,7 +242,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
let rf = rate.frame_at(Tick(mv.compare_ref.unwrap_or(time.0)));
let rkey = FrameKey { target: cpu_target, frame: rf, size: size_key, revision: rev, draft: false };
app.frames.request(rkey, rate.tick_of(rf), scale, &project, 1);
let rpic = fit(ra, frame_size.0 as f32, frame_size.1 as f32);
let rpic = fit(ra, display_w, frame_size.1 as f32);
ui.painter().rect_filled(rpic, 0.0, t.monitor_bg);
if let (Some(tex), _) = cpu_texture(app, &ctx, &format!("monitor-{prefix}-ref"), rkey, &[], DisplayMode::Composite) {
ui.painter().image(tex, rpic, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
+11
View File
@@ -97,6 +97,17 @@ fn frame_size(app: &FilmcraftApp, w: Which) -> Option<(u32, u32)> {
}
}
/// Width over height of one pixel of a monitor's picture: the active sequence's pixel aspect
/// ratio (Program) or the Source item's (Interpret Footage first). Monitors show the picture at
/// its display aspect, so a 1440 x 1080 frame with 4:3 pixels is drawn 16:9.
pub fn pixel_aspect(app: &FilmcraftApp, w: Which) -> f64 {
let par = match w {
Which::Program => app.session.active_sequence().map(|q| q.settings.par),
Which::Source => app.session.state.source_item.and_then(|i| app.session.project.source_par(i)),
};
par.map_or(1.0, filmcraft_project::par_ratio)
}
/// Handle a `view.*` command (None for other ids).
pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
let rest = id.strip_prefix("view.")?;
+14 -2
View File
@@ -12,7 +12,7 @@
//!
//! Premiere settings FilmCraft has no equivalent for yet are shown greyed out with their current
//! value and a "not supported yet" hint, rather than editable and silently ignored: Editing Mode,
//! Pixel Aspect Ratio (stored, not rendered), Fields, the Frames and Feet + Frames display
//! Pixel Aspect Ratio (rendered, and set by New Sequence From Clip, but not editable here yet), Fields, the Frames and Feet + Frames display
//! formats, Number of Channels, audio Display Format, the Video Previews format, codec and size,
//! Maximum Bit Depth, and the VR Properties tab. Composite in Linear Color is shown on and greyed:
//! FilmCraft always composites in linear light.
@@ -174,6 +174,12 @@ fn aspect(w: u32, h: u32) -> String {
}
}
/// The width a `w` x `h` frame with `par` pixels shows at, in square pixels (rounded).
fn display_width(w: u32, h: u32, par: (u32, u32)) -> u32 {
let dw = filmcraft_project::conformed_size((w, h), par, (1, 1)).0.round();
if dw >= f64::from(u32::MAX) { u32::MAX } else { dw.max(0.0) as u32 }
}
fn gcd(mut a: u32, mut b: u32) -> u32 {
while b != 0 {
(a, b) = (b, a % b);
@@ -331,7 +337,8 @@ fn general(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, cur: &SequenceSetti
let h = ui.add(egui::DragValue::new(&mut d.height).range(1..=filmcraft_project::MAX_FRAME_SIDE).speed(1.0));
elems.push(("sequenceSettings.height".into(), h.rect, d.height.to_string()));
ui.label(tl!("vertical"));
let a = aspect(d.width, d.height);
// the display aspect: 1440 x 1080 with 4:3 pixels is 16:9
let a = aspect(display_width(d.width, d.height, cur.par), d.height);
let ar = ui.label(&a);
elems.push(("sequenceSettings.aspect".into(), ar.rect, a));
});
@@ -562,6 +569,11 @@ mod tests {
assert_eq!(aspect(1080, 1920), "9:16");
assert_eq!(aspect(1440, 1080), "4:3");
assert_eq!(aspect(4096, 2160), "1.90:1");
// non-square pixels: the display aspect
assert_eq!(aspect(display_width(1440, 1080, (4, 3)), 1080), "16:9");
assert_eq!(aspect(display_width(1280, 1080, (3, 2)), 1080), "16:9");
assert_eq!(display_width(u32::MAX, 1, (8, 1)), u32::MAX);
assert_eq!(display_width(1440, 1080, (0, 0)), 1440);
}
#[test]
+111
View File
@@ -0,0 +1,111 @@
//! Monitors show pictures at their display aspect: a 1440 x 1080 clip with 4:3 pixels (here a
//! 144 x 108 Color Matte given 4:3 pixels) is drawn 16:9 in the Source monitor, and so is the
//! sequence New Sequence From Clip makes of it in the Program monitor. Square pixels stay 4:3.
//! Driven headless through the control channel.
//!
//! With `FILMCRAFT_UI_SHOTS=<dir>` the test also renders the UI with wgpu and writes `par-*.png`.
use std::sync::Arc;
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_engine::project::{ItemId, ItemKind};
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
shots: Option<std::path::PathBuf>,
}
impl Driver {
fn new(session: Session) -> Self {
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from);
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
if shots.is_some() {
b = b.wgpu().with_pixels_per_point(1.0);
}
let harness = b.build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, shots };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(r) = reply.try_recv() {
assert_eq!(r["ok"], true, "{method} {params}: {r}");
return r["result"].clone();
}
}
panic!("no reply to {method} {params}");
}
fn exec(&mut self, command: &str, params: Value) -> Value {
let r = self.ok("engine.execute", json!({"command": command, "params": params}));
self.frames(3);
r
}
fn shot(&mut self, name: &str) {
let Some(dir) = self.shots.clone() else { return };
// give the frame workers time to deliver the pictures
for _ in 0..8 {
self.frames(3);
std::thread::sleep(std::time::Duration::from_millis(150));
}
let img = self.harness.render().expect("wgpu render");
std::fs::create_dir_all(&dir).unwrap();
img.save(dir.join(format!("par-{name}.png"))).unwrap();
}
/// Width over height of an element's rect.
fn aspect(&mut self, id: &str) -> f64 {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone();
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
r[2] / r[3]
}
}
/// A session with a 144 x 108 Color Matte whose pixels are `par`.
fn session_with_matte(par: (u32, u32)) -> (Session, ItemId) {
let mut s = Session::default();
let r = s.execute("file.newColorMatte", json!({"color": "#3366ff", "width": 144, "height": 108, "seconds": 2.0})).unwrap();
let id = ItemId(r["item"].as_u64().unwrap());
if let Some(ItemKind::Media(m)) = Arc::make_mut(&mut s.project).item_mut(id).map(|i| &mut i.kind) {
m.info.video.as_mut().unwrap().par = par;
}
(s, id)
}
#[test]
fn monitors_show_non_square_pixels_at_the_display_aspect() {
for (par, want) in [((4, 3), 16.0 / 9.0), ((1, 1), 4.0 / 3.0), ((0, 0), 4.0 / 3.0), ((u32::MAX, 1), 4.0 / 3.0)] {
let (s, item) = session_with_matte(par);
let mut d = Driver::new(s);
d.exec("file.newSequenceFromClip", json!({"items": [item.0]}));
d.exec("source.open", json!({"item": item.0}));
let (src, prg) = (d.aspect("source.picture"), d.aspect("program.picture"));
assert!((src - want).abs() < 0.01, "{par:?}: Source picture {src}");
assert!((prg - want).abs() < 0.01, "{par:?}: Program picture {prg}");
d.shot(&format!("monitors-{}x{}", par.0, par.1));
// Interpret Footage changes the Source monitor's picture at once
d.exec("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": [2, 1]}));
let src = d.aspect("source.picture");
assert!((src - 8.0 / 3.0).abs() < 0.01, "{par:?}: interpreted 2:1, Source picture {src}");
}
}
+10
View File
@@ -220,6 +220,16 @@ file ──► codecs (MP4/MOV, MKV, audio) demux + decode, GOP-aware see
`audio(start, frames, rate)` in media time. Sources are `Send + Sync` and shared by monitors,
thumbnails, playback and export. The engine's `MediaPool` creates one per project item, lazily,
through registered openers (`codecs::openers()`: MP4/MOV, MKV/WebM, audio files).
- **Pixel aspect ratio.** Demuxers report it in `VideoStreamInfo::par` (MP4/MOV `pasp`, Matroska
display size, MXF aspect ratio, MPEG sequence header / VUI); Interpret Footage's
`Interpretation::par` overrides it (`clip.interpretFootage {pixelAspect}`), and
`SequenceSettings::par` is the sequence's. Both go through `project::checked_par` (a zero term
or more than 8:1 either way = square). `render::motion_matrix` works in square display units,
so a 1440 x 1080 clip with 4:3 pixels fills a 1920 x 1080 square-pixel sequence at 100 %
(Scale to Frame Size, Set to Frame Size and Fit / Fill use `Project::conformed_source_size`);
graphics and adjustment layers are in sequence pixels. New Sequence From Clip copies the clip's
ratio, and the Source and Program monitors draw the picture at its display aspect. The
renderer does not read decoded frames' own `VideoFrame::par`.
- **Offline media and proxies.** The pool caches each item's source per reference (path, offline
flag), so relinking and undo take effect at once. Media that can't be opened renders the offline
slate (`render::offline`) instead of failing. With proxies enabled, an item with a proxy reads
+1 -1
View File
@@ -118,7 +118,7 @@ no media outside its range, so there a kept In is moved to the subclip's first o
the result's `sourceInClamped: [ids]` (present with `keepSourceIn`) names the clips it was moved for.
**Colour** (`crates/engine/src/color.rs`): `sequence.colorSettings {workingSpace: rec709|rec2100-pq|
rec2100-hlg, wideGamut, autoToneMap}`, `clip.interpretFootage {items?, colorSpace: auto|<id>}`,
rec2100-hlg, wideGamut, autoToneMap}`, `clip.interpretFootage {items?, colorSpace?: auto|<id>, pixelAspect?: [num, den]|file}`,
`color.spaces`, `media.colorInfo {item}`; LUTs: `lut.import {path, name?}`, `lut.list`,
`lut.remove {id}`, `lut.export {lut, path, format?}`, `lumetri.setInputLut` / `lumetri.setLook
{clip?, lut: lib:<id>|builtin:<id>|"", path?}`, `lumetri.setSection {clip?, section, on?}`,