From ff8728af089dd6e21f3a526e752482f8bce6ebcc Mon Sep 17 00:00:00 2001 From: lloyd Date: Fri, 9 Oct 2026 18:28:56 +0200 Subject: [PATCH 01/14] 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 --- crates/engine/src/clip_ops.rs | 17 +- crates/engine/src/color.rs | 46 ++- crates/engine/src/commands.rs | 25 +- crates/engine/src/lib.rs | 2 + crates/engine/src/multicam.rs | 8 +- crates/engine/src/par_tests.rs | 231 +++++++++++++++ crates/engine/src/proxies.rs | 2 + crates/engine/src/settings.rs | 13 +- crates/project/src/lib.rs | 137 +++++++++ crates/render/src/lib.rs | 42 ++- crates/render/src/par_tests.rs | 270 ++++++++++++++++++ crates/render/src/plan.rs | 8 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/graphics.rs | 17 +- crates/ui-egui/src/panels/masks.rs | 2 +- crates/ui-egui/src/panels/monitor.rs | 12 +- crates/ui-egui/src/panels/monitor_view.rs | 11 + .../ui-egui/src/panels/sequence_settings.rs | 16 +- crates/ui-egui/tests/par_ui.rs | 111 +++++++ docs/architecture.md | 10 + docs/control-protocol.md | 2 +- 21 files changed, 933 insertions(+), 51 deletions(-) create mode 100644 crates/engine/src/par_tests.rs create mode 100644 crates/render/src/par_tests.rs create mode 100644 crates/ui-egui/tests/par_ui.rs diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index 1946e5cb..cf0e22da 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -639,7 +639,7 @@ fn sequence_from_clip(s: &mut Session, p: &Value) -> Result { 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 { 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 }; diff --git a/crates/engine/src/color.rs b/crates/engine/src/color.rs index 56049a7e..71261bca 100644 --- a/crates/engine/src/color.rs +++ b/crates/engine/src/color.rs @@ -40,7 +40,7 @@ pub(crate) fn commands() -> Vec { "clip.interpretFootage", "Interpret Footage…", &["Clip", "Modify"], - r#"{"items":[id]?,"colorSpace":"auto"|""}"#, + r#"{"items":[id]?,"colorSpace":"auto"|""?,"pixelAspect":[num,den]|"file"?}"#, has_footage, interpret, ), @@ -292,25 +292,57 @@ pub(crate) fn footage_targets(s: &Session, p: &Value) -> Vec { 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 { + 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::>(), "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::>(), + "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 { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cd02fab2..f18e41ee 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -357,12 +357,17 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { 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 { |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; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3720264c..b8812150 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/multicam.rs b/crates/engine/src/multicam.rs index e5027ca0..23824942 100644 --- a/crates/engine/src/multicam.rs +++ b/crates/engine/src/multicam.rs @@ -475,12 +475,12 @@ fn sync_items(s: &Session, items: &[ItemId], cmd: &str) -> Result> } 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 { diff --git a/crates/engine/src/par_tests.rs b/crates/engine/src/par_tests.rs new file mode 100644 index 00000000..096fbf22 --- /dev/null +++ b/crates/engine/src/par_tests.rs @@ -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 { + 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> { + 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()); + } +} diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs index 4786f17b..b4da00be 100644 --- a/crates/engine/src/proxies.rs +++ b/crates/engine/src/proxies.rs @@ -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)); diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 1e035035..598df631 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -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))); } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index eec6c21f..ddcafb70 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -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); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 78004218..1d6ff3c4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 { 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; diff --git a/crates/render/src/par_tests.rs b/crates/render/src/par_tests.rs new file mode 100644 index 00000000..bca5931c --- /dev/null +++ b/crates/render/src/par_tests.rs @@ -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, +} + +impl MediaSource for Halves { + fn info(&self) -> &MediaInfo { + &self.info + } + fn video_frame(&self, _req: FrameRequest) -> filmcraft_media::Result> { + Ok(self.frame.clone()) + } + fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + Ok(AudioBuffer::silence(sample_rate, 2, frames)) + } +} + +fn halves(w: u32, h: u32, par: (u32, u32)) -> Halves { + let px: Vec = (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, Vec) = (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:?}"); + } +} diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 628e7e10..f986711c 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -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()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6046935..bf18d006 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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, ¶ms); return Ok(json!({"dialog": "interpretFootage"})); diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 46feeb4c..2b2065e0 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -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 { 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 Vec (Option, 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) diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 1d50f9ab..4ffba906 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -163,7 +163,7 @@ pub fn effect_rows( fn clip_to_screen(app: &FilmcraftApp, it: &TrackItem, mt: Tick, pic: Rect, frame: (u32, u32)) -> Option { 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)) diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 39034bfa..c87a90c6 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -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); diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index 0f866a1e..dd3db365 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -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> { let rest = id.strip_prefix("view.")?; diff --git a/crates/ui-egui/src/panels/sequence_settings.rs b/crates/ui-egui/src/panels/sequence_settings.rs index 7c3e77a3..d7fe5d02 100644 --- a/crates/ui-egui/src/panels/sequence_settings.rs +++ b/crates/ui-egui/src/panels/sequence_settings.rs @@ -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] diff --git a/crates/ui-egui/tests/par_ui.rs b/crates/ui-egui/tests/par_ui.rs new file mode 100644 index 00000000..c656b245 --- /dev/null +++ b/crates/ui-egui/tests/par_ui.rs @@ -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=` 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, + shots: Option, +} + +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 = 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}"); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 055033cb..967fb160 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7bca6de0..a860e269 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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|}`, +rec2100-hlg, wideGamut, autoToneMap}`, `clip.interpretFootage {items?, colorSpace?: auto|, 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:|builtin:|"", path?}`, `lumetri.setSection {clip?, section, on?}`, From e90508f16f121dc969d7cb07763d1563689ae3d6 Mon Sep 17 00:00:00 2001 From: MrCloudy2 Date: Sat, 10 Oct 2026 00:50:03 +0200 Subject: [PATCH 02/14] #288: MP4 / MOV pictures are cropped as the file says (H.264 / HEVC cropping, clean aperture) The MP4 source reported the sample entry's size, only swapped for the display rotation, so two crops were lost: the codec's own (H.264 SPS frame cropping, HEVC conformance window), which the decoder applies but the reported size and sequences made from the clip didn't, and the clean aperture (`clap`), parsed but never used. An iPhone clip (1920x1440 coded, 1744x1308 clean aperture, -90 degree display matrix) showed 1440x1920 instead of 1308x1744. H.264 / HEVC sizes now come from the stream's parameter sets; the clean aperture is a rectangle inside that picture (ignored when it makes no sense), reported as the size and cut from every frame by the GOP cache before the display rotation, through the new VideoFrame::cropped. Tests: bit-exact against ffmpeg (which applies both crops) for an H.264 SPS crop, an HEVC conformance window, centred and off-centre clean apertures and a clean aperture with a rotation; clean apertures that make no sense are ignored without a panic; VideoFrame::cropped on every pixel format with odd sizes and misaligned corners. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/gop.rs | 18 ++++- crates/codecs/src/mp4.rs | 92 ++++++++++++++++++++- crates/codecs/tests/mp4_oracle.rs | 100 +++++++++++++++++++++++ crates/frame/src/lib.rs | 128 ++++++++++++++++++++++++++++++ crates/isobmff/README.md | 3 +- 5 files changed, 334 insertions(+), 7 deletions(-) diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index 3972998e..b23b130e 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -12,7 +12,7 @@ use std::time::Duration; use web_time::Instant; use filmcraft_color::ColorInfo; -use filmcraft_frame::VideoFrame; +use filmcraft_frame::{Region, VideoFrame}; use crate::CodecError; use crate::video::VideoDecoder; @@ -410,6 +410,8 @@ pub struct GopCache { explicit_color: Option, /// Clockwise quarter turns from the container's display matrix, applied to every frame. rotation: u8, + /// The container's clean aperture, cut from every frame before the rotation. + crop: Option, budget: usize, } @@ -448,7 +450,7 @@ impl GopCache { pool: pool.clone(), }); pool.register(&shared); - Self { shared, explicit_color, rotation: 0, budget: 384 << 20 } + Self { shared, explicit_color, rotation: 0, crop: None, budget: 384 << 20 } } /// Turn every decoded frame clockwise by `quarter_turns` × 90° (the container's display @@ -458,14 +460,24 @@ impl GopCache { self } + /// Cut every decoded frame to `region` (the container's clean aperture, in stored picture + /// coordinates) before the rotation, as cropping and the display matrix compose. + pub fn with_crop(mut self, region: Option) -> Self { + self.crop = region; + self + } + /// Colour signalled by the container wins over the bitstream's (YUV frames only); the - /// container's display rotation is applied. + /// container's clean aperture and then its display rotation are applied. fn finish(&self, mut f: VideoFrame) -> VideoFrame { if let Some(c) = self.explicit_color && !matches!(f.data, filmcraft_frame::PixelData::Rgba8(_) | filmcraft_frame::PixelData::RgbaF32(_)) { f.color = c; } + if let Some(r) = self.crop { + f = f.cropped(r); + } if self.rotation != 0 { f.rotated(self.rotation) } else { f } } diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index e48670df..b24d9867 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -8,8 +8,8 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer}; -use filmcraft_frame::{AudioBuffer, VideoFrame}; -use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind}; +use filmcraft_frame::{AudioBuffer, Region, VideoFrame}; +use filmcraft_isobmff::{CleanAperture, CodecConfig, Mp4File, TrackKind}; use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo}; use filmcraft_time::{FrameRate, Tick}; @@ -104,6 +104,28 @@ fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u }) } +/// The clean aperture (`clap`, ISO/IEC 14496-12 / QuickTime) of a `w`×`h` decoded picture as a +/// pixel rectangle: its size, centred on the picture and moved by its offsets, rounded to whole +/// samples. `None` (the whole picture is shown) when it is the whole picture or makes no sense: a +/// zero denominator, an empty or larger-than-the-picture size, a rectangle outside the picture. +/// [`VideoFrame::cropped`] moves an odd corner onto the chroma grid. +fn clean_aperture(c: CleanAperture, (w, h): (u32, u32)) -> Option { + let q = |(n, d): (u32, u32)| (d != 0).then(|| f64::from(n) / f64::from(d)); + let qs = |(n, d): (i32, u32)| (d != 0).then(|| f64::from(n) / f64::from(d)); + let (cw, ch, dx, dy) = (q(c.width)?.round(), q(c.height)?.round(), qs(c.horiz_offset)?, qs(c.vert_offset)?); + let (fw, fh) = (f64::from(w), f64::from(h)); + if !(cw >= 1.0 && ch >= 1.0 && cw <= fw && ch <= fh) { + return None; + } + let (x, y) = (((fw - cw) / 2.0 + dx).round(), ((fh - ch) / 2.0 + dy).round()); + // also false for NaN + if !(x >= 0.0 && y >= 0.0 && x + cw <= fw && y + ch <= fh) { + return None; + } + let r = Region { x: x as usize, y: y as usize, w: cw as usize, h: ch as usize }; + (!r.is_full(w as usize, h as usize)).then_some(r) +} + fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo { let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited }; // VP9 and APV carry their colour description in vpcC / apvC (used when there is no colr box). @@ -171,11 +193,21 @@ impl Mp4Source { let mut explicit_color = None; // display rotation from the track matrix (portrait phone video is stored landscape) let rotation = vtrack.and_then(|i| file.tracks[i].display_rotation()).unwrap_or(0); + // the clean aperture ('clap') cut from every frame, before the rotation + let mut aperture = None; let video = vtrack.map(|i| { let t = &file.tracks[i]; let entry = &t.entries[0]; let vp = entry.video.clone().unwrap_or_default(); let (w, h) = (if vp.width > 0 { vp.width as u32 } else { t.width }, if vp.height > 0 { vp.height as u32 } else { t.height }); + // H.264 and HEVC say their own output size (frame cropping / conformance window), which + // is what the decoder returns; a sample entry can give the coded size instead (#288) + let (w, h) = match crate::hw::NalStreamInfo::from_entry(entry) { + Some(Ok(n)) if n.crop.2 > 0 && n.crop.3 > 0 => (n.crop.2, n.crop.3), + _ => (w, h), + }; + aperture = vp.clean_aperture.and_then(|c| clean_aperture(c, (w, h))); + let (w, h) = aperture.map_or((w, h), |r| (r.w as u32, r.h as u32)); // frame rate from the median sample duration let mut durs: Vec = t.samples.iter().take(240).map(|s| s.duration).collect(); durs.sort_unstable(); @@ -276,7 +308,7 @@ impl Mp4Source { Some(Mp4Audio { track: i, state, starts, offset, preroll }) }) .collect(); - Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation), audios }) + Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation).with_crop(aperture), audios }) } fn read(&self, track: usize, i: usize) -> crate::Result> { @@ -743,6 +775,60 @@ mod tests { assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((64, 32))); } + /// A one-frame 25 fps ProRes MOV (64×32, left half bright) with clean aperture `clap`. + fn clap_mov(clap: filmcraft_isobmff::CleanAperture) -> Arc<[u8]> { + let (w, h) = (64u32, 32u32); + let mut fr = filmcraft_prores::Frame::new(w, h, filmcraft_prores::ChromaFormat::Yuv422, 10, false); + for (i, y) in fr.y.iter_mut().enumerate() { + *y = if (i as u32 % w) < w / 2 { 800 } else { 100 }; + } + let data = filmcraft_prores::Encoder::new(filmcraft_prores::Profile::Hq, w, h).encode(&fr).expect("encode"); + let mut entry = SampleEntry::prores(FourCc(*b"apch"), w as u16, h as u16); + entry.video.as_mut().expect("video").clean_aperture = Some(clap); + let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer"); + let t = mux.add_track(TrackConfig::new(entry, 25)).expect("track"); + mux.write_sample(t, WriteSample { data: &data, duration: 1, composition_offset: 0, is_sync: true }).expect("sample"); + mux.finish().expect("finish").into_inner().into() + } + + #[test] + fn clean_aperture_crops_frames_and_reported_size() { + use filmcraft_isobmff::CleanAperture; + let clap = |w: (u32, u32), h: (u32, u32), dx: (i32, u32), dy: (i32, u32)| CleanAperture { width: w, height: h, horiz_offset: dx, vert_offset: dy }; + // 32x16 moved 8 left of centre: columns 8..40 (24 bright, then 8 dark), rows 8..24 + let s = Mp4Source::open("clap.mov", clap_mov(clap((32, 1), (16, 1), (-8, 1), (0, 1)))).expect("open"); + assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((32, 16))); + let f = s.video_frame(FrameRequest::full(Tick::ZERO)).expect("frame"); + assert_eq!((f.width, f.height), (32, 16)); + let l = f.luma8(); + let col = |x: usize| (0..16).map(|y| u32::from(l[y * 32 + x])).sum::() / 16; + assert!(col(0) > col(31) + 100 && col(23) > col(24) + 100, "bright up to column 23: {} {} {} {}", col(0), col(23), col(24), col(31)); + + // rational sizes and offsets (64/2 = 32): the same rectangle + let s = Mp4Source::open("clap2.mov", clap_mov(clap((64, 2), (48, 3), (-16, 2), (0, 7)))).expect("open"); + assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((32, 16))); + + // a clean aperture that makes no sense is ignored (the whole picture), never a panic + for c in [ + clap((0, 1), (16, 1), (0, 1), (0, 1)), + clap((32, 0), (16, 1), (0, 1), (0, 1)), + clap((32, 1), (16, 1), (0, 0), (0, 1)), + clap((65, 1), (16, 1), (0, 1), (0, 1)), + clap((32, 1), (33, 1), (0, 1), (0, 1)), + clap((32, 1), (16, 1), (17, 1), (0, 1)), + clap((32, 1), (16, 1), (0, 1), (-9, 1)), + clap((32, 1), (16, 1), (i32::MIN, 1), (i32::MAX, 1)), + clap((u32::MAX, 1), (u32::MAX, 1), (0, 1), (0, 1)), + clap((1, u32::MAX), (16, 1), (0, u32::MAX), (0, 1)), + clap((64, 1), (32, 1), (0, 1), (0, 1)), + ] { + let s = Mp4Source::open("bad.mov", clap_mov(c)).expect("open"); + assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((64, 32)), "{c:?}"); + let f = s.video_frame(FrameRequest::full(Tick::ZERO)).expect("frame"); + assert_eq!((f.width, f.height), (64, 32), "{c:?}"); + } + } + #[test] fn mirrored_or_scaled_matrices_are_not_mistaken_for_rotations() { const ONE: i32 = 0x10000; diff --git a/crates/codecs/tests/mp4_oracle.rs b/crates/codecs/tests/mp4_oracle.rs index 94ce49d4..403da23f 100644 --- a/crates/codecs/tests/mp4_oracle.rs +++ b/crates/codecs/tests/mp4_oracle.rs @@ -53,3 +53,103 @@ fn bframe_mp4_duration_is_the_decoded_frame_count() { let src = filmcraft_codecs::open_bytes("bframes_x264.mp4", bytes(&f)).unwrap(); assert_eq!(src.info().duration, filmcraft_time::FrameRate::FPS_30.tick_of(n as i64)); } + +/// `mp4` (moov after mdat, as ffmpeg writes it) with a clean aperture (`clap`) of `w`×`h` moved by +/// (`dx`, `dy`) from the centre, inserted at the end of the first video sample entry. +fn with_clap(mp4: &[u8], (w, h, dx, dy): (u32, u32, i32, i32)) -> Vec { + let be = |b: &[u8], at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) as usize; + // the chain moov > trak > mdia > minf > stbl > stsd > avc1 (stsd has 8 bytes before its entries) + let mut chain = Vec::new(); + let (mut off, mut end) = (0, mp4.len()); + for (want, skip) in [(b"moov", 8), (b"trak", 8), (b"mdia", 8), (b"minf", 8), (b"stbl", 8), (b"stsd", 16), (b"avc1", 0)] { + loop { + assert!(off + 8 <= end, "no {}", String::from_utf8_lossy(want)); + let size = be(mp4, off); + if &mp4[off + 4..off + 8] == want { + chain.push(off); + end = off + size; + off += skip; + break; + } + off += size; + } + } + let entry = *chain.last().unwrap(); + assert!(mp4.windows(4).position(|x| x == b"mdat").unwrap() < chain[0], "moov must follow mdat"); + let mut clap = Vec::new(); + for v in [40u32, u32::from_be_bytes(*b"clap"), w, 1, h, 1, dx as u32, 1, dy as u32, 1] { + clap.extend_from_slice(&v.to_be_bytes()); + } + let mut out = mp4.to_vec(); + let at = entry + be(mp4, entry); + out.splice(at..at, clap); + for &b in &chain { + let size = (be(&out, b) + 40) as u32; + out[b..b + 4].copy_from_slice(&size.to_be_bytes()); + } + out +} + +/// Cropped pictures are the size and the samples ffmpeg decodes (#288): H.264 frame cropping (SPS) +/// and an HEVC conformance window while the sample entry gives the uncropped size, and a clean +/// aperture (`clap`, as iPhones write), centred, off-centre, and with a 90° display rotation +/// applied after it. +#[test] +fn cropped_pictures_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let src = ["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=1,noise=alls=10:allf=t", "-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p"]; + let Some(plain) = fixture(&ff, "crop_plain_x264.mp4", &src) else { + eprintln!("SKIPPED: ffmpeg without libx264"); + return; + }; + let sps = + fixture(&ff, "crop_sps_x264.mp4", &[&src[..], &["-bsf:v", "h264_metadata=crop_left=16:crop_right=16:crop_top=8:crop_bottom=8"]].concat()).unwrap(); + // the display matrix added without re-encoding (an encode would turn the pixels instead) + let rotated = fixture(&ff, "crop_rotated_x264.mp4", &["-display_rotation", "90", "-i", plain.to_str().unwrap(), "-c", "copy"]).unwrap(); + let dir = dir(); + let mut cases = vec![("SPS crop", sps, (288, 224))]; + // HEVC: the conformance window + let hevc = [ + "-f", + "lavfi", + "-i", + "testsrc2=size=320x240:rate=30:duration=1,noise=alls=10:allf=t", + "-c:v", + "libx265", + "-x265-params", + "log-level=error:bframes=3", + "-tag:v", + "hvc1", + "-pix_fmt", + "yuv420p", + ]; + match fixture(&ff, "crop_conformance_x265.mp4", &[&hevc[..], &["-bsf:v", "hevc_metadata=crop_left=16:crop_right=16:crop_top=8:crop_bottom=8"]].concat()) { + Some(p) => cases.push(("HEVC conformance window", p, (288, 224))), + None => eprintln!("SKIPPED (HEVC case): ffmpeg without libx265"), + } + for (name, base, clap, size) in [ + ("centred clap", &plain, (280, 200, 0, 0), (280, 200)), + ("off-centre clap", &plain, (200, 150, -30, 22), (200, 150)), + ("clap then rotation", &rotated, (280, 200, 0, 0), (200, 280)), + ] { + let path = dir.join(format!("crop_{}.mp4", name.replace(' ', "_"))); + std::fs::write(&path, with_clap(&bytes(base), clap)).unwrap(); + cases.push((name, path, size)); + } + for (name, path, (w, h)) in cases { + let src = filmcraft_codecs::open_bytes(path.file_name().unwrap().to_str().unwrap(), bytes(&path)).unwrap(); + let v = src.info().video.clone().unwrap(); + assert_eq!((v.width, v.height), (w, h), "{name}: reported size"); + let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize); + let raw = ffmpeg_frames(&ff, &path, "yuv420p"); + let frame_bytes = (w * h) as usize + 2 * cw * ch; + assert_eq!(raw.len() % frame_bytes, 0, "{name}: ffmpeg's frames are {w}x{h}"); + let n = raw.len() / frame_bytes; + assert_eq!(n, 30, "{name}"); + for (i, want) in raw.chunks(frame_bytes).enumerate() { + let f = src.video_frame(filmcraft_media::FrameRequest::full(v.frame_rate.tick_of(i as i64))).unwrap(); + assert_eq!((f.width, f.height), (w, h), "{name}: frame {i} size"); + assert!(planes(&f) == raw_planes(want, w as usize, h as usize, cw, ch, 1), "{name}: frame {i} differs from ffmpeg's"); + } + } +} diff --git a/crates/frame/src/lib.rs b/crates/frame/src/lib.rs index 0b4afcf3..4be8cfb1 100644 --- a/crates/frame/src/lib.rs +++ b/crates/frame/src/lib.rs @@ -179,6 +179,61 @@ impl VideoFrame { Some(VideoFrame { width: ow as u32, height: oh as u32, data, color: self.color, par: self.par, pts: self.pts }) } + /// The part of the frame inside `region` (a container's clean aperture). The region is clipped + /// to the frame; on Y'CbCr frames its corner is moved up / left onto the chroma grid (at most + /// one sample, keeping its size where the frame allows), so chroma is cut where luma is. An empty + /// region, or one covering the whole frame, leaves the frame as it is. + pub fn cropped(&self, region: Region) -> VideoFrame { + let (w, h) = (self.width as usize, self.height as usize); + let (sx, sy) = match &self.data { + PixelData::Yuv8 { chroma, .. } | PixelData::Yuv16 { chroma, .. } => chroma.shifts(), + PixelData::Rgba8(_) | PixelData::RgbaF32(_) => (0, 0), + }; + let (mx, my) = ((1usize << sx) - 1, (1usize << sy) - 1); + let (x, y) = (region.x.min(w) & !mx, region.y.min(h) & !my); + let r = Region { x, y, w: region.w, h: region.h }.clip(w, h); + if r.w == 0 || r.h == 0 || (r.w == w && r.h == h) { + return self.clone(); + } + let chroma_rect = |cw: usize, ch: usize| { + (r.x >> sx, r.y >> sy, r.w.div_ceil(1 << sx).min(cw - (r.x >> sx).min(cw)), r.h.div_ceil(1 << sy).min(ch - (r.y >> sy).min(ch))) + }; + let data = match &self.data { + PixelData::Rgba8(d) => PixelData::Rgba8(Arc::new(crop_plane(d, w, h, 4, r))), + PixelData::RgbaF32(d) => PixelData::RgbaF32(Arc::new(crop_plane(d, w, h, 4, r))), + PixelData::Yuv8 { planes, chroma, alpha } => { + let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy)); + let (cx, cy, cr_w, cr_h) = chroma_rect(cw, ch); + let c = Region { x: cx, y: cy, w: cr_w, h: cr_h }; + PixelData::Yuv8 { + planes: [ + Arc::new(crop_plane(&planes[0], w, h, 1, r)), + Arc::new(crop_plane(&planes[1], cw, ch, 1, c)), + Arc::new(crop_plane(&planes[2], cw, ch, 1, c)), + ], + chroma: *chroma, + alpha: alpha.as_ref().map(|a| Arc::new(crop_plane(a, w, h, 1, r))), + } + } + PixelData::Yuv16 { planes, chroma, bits, alpha } => { + let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy)); + let (cx, cy, cr_w, cr_h) = chroma_rect(cw, ch); + let c = Region { x: cx, y: cy, w: cr_w, h: cr_h }; + PixelData::Yuv16 { + planes: [ + Arc::new(crop_plane(&planes[0], w, h, 1, r)), + Arc::new(crop_plane(&planes[1], cw, ch, 1, c)), + Arc::new(crop_plane(&planes[2], cw, ch, 1, c)), + ], + chroma: *chroma, + bits: *bits, + alpha: alpha.as_ref().map(|a| Arc::new(crop_plane(a, w, h, 1, r))), + } + } + }; + VideoFrame { width: r.w as u32, height: r.h as u32, data, color: self.color, par: self.par, pts: self.pts } + } + /// The frame turned clockwise by `quarter_turns` × 90° (a container's display rotation). /// Planes rotate at their own resolution; 4:2:2 chroma is widened to 4:4:4 first for a quarter /// or three-quarter turn (its half-width chroma would become half-height, which has no @@ -595,6 +650,20 @@ fn box_plane + TryFrom + Send + Sync + Default>(src: &[ out } +/// The `r` part of a `w`×`h` plane of `n`-element pixels (`r` lies inside the plane). A plane too +/// short for its size gives a blank result, as in [`rotate_plane`]. +fn crop_plane(src: &[T], w: usize, h: usize, n: usize, r: Region) -> Vec { + let mut out = vec![T::default(); r.w * r.h * n]; + if src.len() < w * h * n || r.x + r.w > w || r.y + r.h > h { + return out; + } + for (row, o) in out.chunks_exact_mut((r.w * n).max(1)).enumerate().take(r.h) { + let s = ((r.y + row) * w + r.x) * n; + o.copy_from_slice(&src[s..s + r.w * n]); + } + out +} + /// A `w`×`h` plane of `n`-element pixels turned clockwise by `q` (1–3) quarter turns. fn rotate_plane(src: &[T], w: usize, h: usize, n: usize, q: u8) -> Vec { let mut out = vec![T::default(); w * h * n]; @@ -643,6 +712,65 @@ fn rotate_chroma(planes: &[Arc>; 3], chr #[cfg(test)] mod tests { + #[test] + fn cropping_cuts_every_plane_at_the_same_place() { + // 6x4 RGBA8, red = index + let px: Vec = (0..24u8).flat_map(|i| [i, 0, 0, 255]).collect(); + let f = VideoFrame { par: (4, 3), ..VideoFrame::rgba8(6, 4, px) }; + let c = f.cropped(Region { x: 1, y: 1, w: 3, h: 2 }); + assert_eq!((c.width, c.height, c.par), (3, 2, (4, 3))); + assert_eq!(c.to_rgba8().chunks(4).map(|p| p[0]).collect::>(), [7, 8, 9, 13, 14, 15]); + // a region reaching past the frame is clipped to it; an empty or full one changes nothing + let c = f.cropped(Region { x: 4, y: 3, w: 100, h: 100 }); + assert_eq!((c.width, c.height), (2, 1)); + assert_eq!(c.to_rgba8().chunks(4).map(|p| p[0]).collect::>(), [22, 23]); + for r in [ + Region { x: 0, y: 0, w: 0, h: 2 }, + Region { x: 9, y: 9, w: 2, h: 2 }, + Region { x: 0, y: 0, w: 6, h: 4 }, + Region { x: 0, y: 0, w: usize::MAX, h: usize::MAX }, + ] { + let c = f.cropped(r); + assert_eq!((c.width, c.height), (6, 4), "{r:?}"); + } + + // 4:2:0, odd sizes (7x5: chroma 4x3), with alpha: luma = index, chroma = 100 + index + let luma: Vec = (0..35u8).collect(); + let cb: Vec = (100..112u8).collect(); + let yuv = |chroma| VideoFrame { + width: 7, + height: 5, + data: PixelData::Yuv8 { planes: [Arc::new(luma.clone()), Arc::new(cb.clone()), Arc::new(cb.clone())], chroma, alpha: Some(Arc::new(luma.clone())) }, + color: Default::default(), + par: (1, 1), + pts: Default::default(), + }; + // an odd corner moves onto the chroma grid (1,1 -> 0,0), keeping the size + let c = yuv(Chroma::C420).cropped(Region { x: 1, y: 1, w: 3, h: 3 }); + assert_eq!((c.width, c.height), (3, 3)); + let PixelData::Yuv8 { planes, alpha, .. } = &c.data else { panic!() }; + assert_eq!(*planes[0], [0, 1, 2, 7, 8, 9, 14, 15, 16]); + assert_eq!(**alpha.as_ref().unwrap(), *planes[0]); + assert_eq!(*planes[1], [100, 101, 104, 105], "chroma 2x2 from (0,0)"); + // even corner: chroma starts at (1,1); the last chroma column of an odd width is kept + let c = yuv(Chroma::C420).cropped(Region { x: 2, y: 2, w: 5, h: 3 }); + let PixelData::Yuv8 { planes, .. } = &c.data else { panic!() }; + assert_eq!((c.width, c.height), (5, 3)); + assert_eq!(*planes[1], [105, 106, 107, 109, 110, 111], "chroma 3x2 from (1,1)"); + // 4:4:4 needs no alignment + let c = yuv(Chroma::C444).cropped(Region { x: 1, y: 1, w: 2, h: 1 }); + let PixelData::Yuv8 { planes, .. } = &c.data else { panic!() }; + assert_eq!(*planes[0], [8, 9]); + // a plane shorter than its size gives a blank picture, never a panic + let mut short = yuv(Chroma::C420); + if let PixelData::Yuv8 { planes, .. } = &mut short.data { + planes[0] = Arc::new(vec![1; 3]); + } + let c = short.cropped(Region { x: 2, y: 2, w: 2, h: 2 }); + let PixelData::Yuv8 { planes, .. } = &c.data else { panic!() }; + assert_eq!(*planes[0], [0, 0, 0, 0]); + } + #[test] fn rotation_turns_every_plane_clockwise() { // 4x2 RGBA8 with distinct pixels 0..8 diff --git a/crates/isobmff/README.md b/crates/isobmff/README.md index 3ada2ade..d74ae54a 100644 --- a/crates/isobmff/README.md +++ b/crates/isobmff/README.md @@ -69,7 +69,8 @@ Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are suppor | anything else | `Unknown { fourcc, raw }` | Video entries also parse `colr` (`nclx`/`nclc`/ICC), `pasp`, `clap`, `fiel`, `gama` and `btrt` into -`VideoParams`. Audio entries parse QuickTime sound description v0/v1/v2 into `AudioParams`. +`VideoParams`. The MP4 source in `filmcraft-codecs` crops every frame to `clap`'s clean aperture +before the track's display rotation, and reports that size. Audio entries parse QuickTime sound description v0/v1/v2 into `AudioParams`. ### Robustness The parser does not panic on malformed input; every error comes back as an `Error`. Table counts are From 77fa159f492dd1ec153bbfb717ec4a42dc5bacaf Mon Sep 17 00:00:00 2001 From: Luniiya <86487331+luniiya@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:14:55 +0200 Subject: [PATCH 03/14] ui: standardise system appearance modes and theme previews --- Cargo.lock | 1 + apps/filmcraft/Cargo.toml | 5 + apps/filmcraft/src/appearance.rs | 188 +++++++++++++++++++ apps/filmcraft/src/main.rs | 4 + crates/engine/src/autosave.rs | 5 + crates/engine/src/settings.rs | 73 +++++++- crates/engine/src/settings_tests.rs | 56 ++++++ crates/ui-egui/src/header.rs | 10 ++ crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/icons.rs | 32 ++++ crates/ui-egui/src/lib.rs | 30 +++- crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/panels/settings.rs | 248 ++++++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 6 +- crates/ui-egui/tests/settings_ui.rs | 26 ++- 17 files changed, 697 insertions(+), 6 deletions(-) create mode 100644 apps/filmcraft/src/appearance.rs diff --git a/Cargo.lock b/Cargo.lock index 2512f9b2..a488ba53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1684,6 +1684,7 @@ dependencies = [ "sys-locale", "winit", "winresource", + "zbus", ] [[package]] diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 9e43d1dc..a6a979de 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -36,6 +36,11 @@ objc2 = "0.6" # Same winit as eframe: macOS event-loop options (no app activation when driven by an agent). winit = { version = "0.30", default-features = false } +[target.'cfg(target_os = "linux")'.dependencies] +# Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the +# portal file dialogs (rfd) and accesskit already use. +zbus = "5.19" + # Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows. [target.'cfg(windows)'.build-dependencies] winresource = "0.1" diff --git a/apps/filmcraft/src/appearance.rs b/apps/filmcraft/src/appearance.rs new file mode 100644 index 00000000..c2558d74 --- /dev/null +++ b/apps/filmcraft/src/appearance.rs @@ -0,0 +1,188 @@ +//! The system's light or dark appearance for Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with +//! system, on Linux desktops where winit reports none (Wayland compositors send no theme). +//! +//! The XDG desktop portal is read once (`org.freedesktop.portal.Settings.Read` of +//! `org.freedesktop.appearance` / `color-scheme`), then its `SettingChanged` signal is followed on a +//! worker thread, which wakes the UI only when the value changes. There is no polling. Without a +//! portal answer, `gsettings` runs once at start-up, by absolute path. Elsewhere the host returns no +//! service and the UI uses what egui reports. + +#[cfg(target_os = "linux")] +const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; +#[cfg(target_os = "linux")] +const PORTAL_KEY: &str = "color-scheme"; + +#[cfg(target_os = "linux")] +fn portal_proxy() -> Option> { + let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; + zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() +} + +#[cfg(target_os = "linux")] +fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option { + let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?; + decode(portal_code(value)?) +} + +/// The `u32` inside a portal reply. `Read` returns a variant, and some portals nest another +/// variant inside it. +#[cfg(target_os = "linux")] +fn portal_code(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + other => return u32::try_from(other).ok(), + } + } + None +} + +/// The portal's `color-scheme`: 0 no preference, 1 prefer dark, 2 prefer light. +#[cfg(target_os = "linux")] +fn decode(code: u32) -> Option { + match code { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + } +} + +/// One-shot fallback for desktops without the settings portal: runs once, by absolute path. +#[cfg(target_os = "linux")] +fn gtk_theme() -> Option { + let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; + let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?; + if !output.status.success() { + return None; + } + parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?) +} + +#[cfg(target_os = "linux")] +fn parse_gtk_scheme(s: &str) -> Option { + match s.trim().trim_matches('\'') { + "prefer-dark" => Some(egui::Theme::Dark), + "prefer-light" => Some(egui::Theme::Light), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn code_of(theme: Option) -> u8 { + match theme { + Some(egui::Theme::Dark) => 1, + Some(egui::Theme::Light) => 2, + None => 0, + } +} + +/// Publish initial and subsequent answers alike: the initial Read may finish after the UI's +/// bounded startup wait, so it must also wake an already-created context. +#[cfg(target_os = "linux")] +fn publish(value: &std::sync::atomic::AtomicU8, code: u8, repaint: impl FnOnce()) { + if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code { + repaint(); + } +} + +/// The host hook (`HostHooks::system_theme`), or `None` where egui's own report is used. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::atomic::{AtomicU8, Ordering}; + use std::sync::{Arc, OnceLock}; + let value = Arc::new(AtomicU8::new(0)); + let wake: Arc> = Arc::new(OnceLock::new()); + let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake)); + let (ready, wait) = std::sync::mpsc::channel(); + let worker = move || { + let proxy = portal_proxy(); + // Subscribe before Read so a change during startup cannot be missed. + let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok()); + let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme); + publish(&worker_value, code_of(initial), || { + if let Some(ctx) = worker_wake.get() { + ctx.request_repaint(); + } + }); + let _ = ready.send(()); + let Some(signals) = signals else { return }; + // Sleeps in the signal iterator: no polling. + for message in signals { + let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { + continue; + }; + if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { + continue; + } + let code = code_of(portal_code(changed).and_then(decode)); + publish(&worker_value, code, || { + if let Some(ctx) = worker_wake.get() { + ctx.request_repaint(); + } + }); + } + }; + let spawned = std::thread::Builder::new().name("appearance-portal".into()).spawn(move || { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(worker)).is_err() { + log::warn!("the system appearance watcher stopped; Sync with system keeps the last value"); + } + }); + if spawned.is_err() { + return None; + } + // The first reading, so the first frame already shows the right theme (bounded wait). + let _ = wait.recv_timeout(std::time::Duration::from_millis(250)); + Some(Box::new(move |ctx: &egui::Context| { + let _ = wake.set(ctx.clone()); + decode(u32::from(value.load(Ordering::Relaxed))) + })) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + #[test] + fn initial_and_signal_answers_repaint_only_on_changes() { + use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; + let repaints = AtomicUsize::new(0); + let value = AtomicU8::new(0); + let repaint = || { + repaints.fetch_add(1, Ordering::Relaxed); + }; + // A late initial Read takes the same publication path as later signals. + super::publish(&value, 2, repaint); + assert_eq!(repaints.load(Ordering::Relaxed), 1); + super::publish(&value, 2, repaint); + assert_eq!(repaints.load(Ordering::Relaxed), 1); + super::publish(&value, 1, repaint); + assert_eq!(repaints.load(Ordering::Relaxed), 2); + assert_eq!(value.load(Ordering::Relaxed), 1); + } + + #[cfg(target_os = "linux")] + #[test] + fn portal_and_gsettings_values_decode() { + assert_eq!(super::decode(0), None); + assert_eq!(super::decode(1), Some(egui::Theme::Dark)); + assert_eq!(super::decode(2), Some(egui::Theme::Light)); + assert_eq!(super::decode(9), None); + let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2))))); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2)); + let plain = zbus::zvariant::OwnedValue::from(1u32); + assert_eq!(super::portal_code(plain), Some(1)); + let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap(); + assert_eq!(super::portal_code(wrong), None); + assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); + assert_eq!(super::parse_gtk_scheme("'default'\n"), None); + assert_eq!(super::parse_gtk_scheme(""), None); + assert_eq!((super::code_of(None), super::code_of(Some(egui::Theme::Dark)), super::code_of(Some(egui::Theme::Light))), (0, 1, 2)); + } +} diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 18fb4d7b..ff521eff 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -23,6 +23,7 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] mod app_nap; +mod appearance; mod args; mod audio; mod audio_in; @@ -229,6 +230,9 @@ fn main() -> eframe::Result { app.hooks.open_path = Some(Box::new(open_path)); // Settings ▸ General ▸ Interface Language ▸ System Language (#218). app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); + // Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose + // compositor reports no theme to winit (no polling: see appearance.rs). + app.hooks.system_theme = appearance::service(); app.hooks.raise_without_focus = Some(Box::new(|| { window_raise::raise_without_focus(); })); diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 73338487..0da3f3aa 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -455,6 +455,11 @@ impl Preferences { settings::sanitize(&mut v, &Self::default().to_value()); let mut p: Preferences = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?; p.clamp(); + // Old clients that set the single Color Theme still pick the theme they name. + if key == "appearance.colorTheme" { + let theme = p.appearance.color_theme.clone(); + p.appearance.select_theme(&theme); + } *self = p; Ok(()) } diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 1e035035..23f91e29 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -88,8 +88,15 @@ impl Default for GeneralPrefs { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct AppearancePrefs { - /// "Color Theme": `darkest` (default) | `dark` | `light`. + /// Legacy single "Color Theme": `darkest` | `dark` | `light`. Setting it (old settings files, + /// automation, View ▸ Appearance) selects that theme and fixes the mode to its family. pub color_theme: String, + /// "Appearance Mode": `auto` (follow the operating system) | `dark` (default) | `light`. + pub appearance_mode: String, + /// The theme shown in dark mode: `darkest` (default) | `dark`. + pub dark_theme: String, + /// The theme shown in light mode: `light`. + pub light_theme: String, /// "Accessible color contrast": brighter secondary text and borders. pub accessible_contrast: bool, /// Highlight (accent) colour of selections, focus and primary buttons (`#rrggbb`). @@ -98,7 +105,48 @@ pub struct AppearancePrefs { impl Default for AppearancePrefs { fn default() -> Self { - Self { color_theme: "darkest".into(), accessible_contrast: false, highlight_color: DEFAULT_HIGHLIGHT.into() } + Self { + color_theme: "darkest".into(), + appearance_mode: "dark".into(), + dark_theme: "darkest".into(), + light_theme: "light".into(), + accessible_contrast: false, + highlight_color: DEFAULT_HIGHLIGHT.into(), + } + } +} + +impl AppearancePrefs { + /// The theme to show (`darkest` | `dark` | `light`): the light or dark theme of the mode. Auto + /// follows `system_light` (the operating system's appearance) and is dark without an answer. + pub fn shown_theme(&self, system_light: Option) -> &str { + let light = match self.appearance_mode.as_str() { + "light" => true, + "auto" => system_light == Some(true), + _ => false, + }; + if light { &self.light_theme } else { &self.dark_theme } + } + + /// Select `theme` (a Color Theme value) as the theme of its family and fix the mode to that + /// family. Unknown names are ignored. + pub fn select_theme(&mut self, theme: &str) { + if LIGHT_THEMES.iter().any(|(v, _)| *v == theme) { + self.light_theme = theme.into(); + self.appearance_mode = "light".into(); + } else if DARK_THEMES.iter().any(|(v, _)| *v == theme) { + self.dark_theme = theme.into(); + self.appearance_mode = "dark".into(); + } + } + + /// The mode after this one in the header button's cycle: Auto, Light, Dark, Auto… + pub fn next_mode(&self) -> &'static str { + match self.appearance_mode.as_str() { + "auto" => "light", + "light" => "dark", + _ => "auto", + } } } @@ -607,6 +655,9 @@ const OPENING: &[(&str, &str)] = &[("showOpenDialog", "Show Open Dialog"), ("sho const BIN_OPEN: &[(&str, &str)] = &[("openInPlace", "Open in place"), ("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")]; const PROJECT_OPEN: &[(&str, &str)] = &[("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")]; const THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark"), ("light", "Light")]; +pub const APPEARANCE_MODES: &[(&str, &str)] = &[("auto", "Sync with system"), ("dark", "Dark"), ("light", "Light")]; +pub const DARK_THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark")]; +pub const LIGHT_THEMES: &[(&str, &str)] = &[("light", "Light")]; const MIXDOWN: &[(&str, &str)] = &[("front", "Front Only"), ("frontRear", "Front + Rear"), ("frontLfe", "Front + LFE"), ("frontRearLfe", "Front + Rear + LFE")]; const AUDITION: &[(&str, &str)] = &[("scratch", "Scratch disk location for Captured Audio"), ("nextToMedia", "Next to original media files")]; const BUFFERS: &[(&str, &str)] = &[("64", "64"), ("128", "128"), ("256", "256"), ("512", "512"), ("1024", "1024"), ("2048", "2048"), ("4096", "4096")]; @@ -714,6 +765,12 @@ static CATEGORIES: &[Category] = &[ id: "appearance", title: "Appearance", rows: &[ + // Drawn by the dialog as the Appearance Mode selector above the light and dark theme + // cards; the fields below keep their keys, validation and `prefs.schema` entries. + Row::Custom("appearanceModes"), + f("appearance.appearanceMode", "Appearance Mode", Kind::Choice(APPEARANCE_MODES), true), + f("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true), + f("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true), f("appearance.colorTheme", "Color Theme", Kind::Choice(THEMES), true), b("appearance.accessibleContrast", "Accessible color contrast", true), f("appearance.highlightColor", "Highlight Color", Kind::Color, true), @@ -1133,6 +1190,18 @@ pub fn migrate(v: &mut Value) { // pre/postroll; v2 stores them as reals, which serde reads either way. m.insert("version".into(), json!(2)); } + // Files from before appearance modes had one Color Theme: keep showing it, as a fixed Dark or + // Light mode, instead of switching established users to another theme or to Auto. + if let Some(a) = m.get_mut("appearance").and_then(Value::as_object_mut) + && !a.contains_key("appearanceMode") + && let Some(theme) = a.get("colorTheme").and_then(Value::as_str).map(str::to_string) + { + let mut p = AppearancePrefs::default(); + p.select_theme(&theme); + a.insert("appearanceMode".into(), json!(p.appearance_mode)); + a.insert("darkTheme".into(), json!(p.dark_theme)); + a.insert("lightTheme".into(), json!(p.light_theme)); + } } impl crate::autosave::Preferences { diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index d706e4a1..67d124de 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -551,3 +551,59 @@ fn hardware_decoding_setting_drives_the_decoder_switch() { assert!(filmcraft_codecs::hw::hardware_decoding()); assert!(settings::field("playback.hardwareDecoding").is_some_and(|f| f.wired && matches!(f.kind, Kind::Choice(_)))); } + +/// New users keep the dark Darkest theme (following the system is opt-in); a file saved before +/// appearance modes keeps showing its Color Theme as a fixed Dark or Light mode. +#[test] +fn appearance_defaults_and_legacy_theme_migrate() { + let a = Preferences::default().appearance; + assert_eq!((a.appearance_mode.as_str(), a.dark_theme.as_str(), a.light_theme.as_str()), ("dark", "darkest", "light")); + assert_eq!(a.shown_theme(Some(true)), "darkest", "Dark mode ignores a light system"); + let dir = tmp_dir("prefs-appearance-migrate"); + let path = dir.join("preferences.json"); + for (theme, mode, dark, light) in [("darkest", "dark", "darkest", "light"), ("dark", "dark", "dark", "light"), ("light", "light", "darkest", "light")] { + std::fs::write(&path, json!({"version": 2, "appearance": {"colorTheme": theme}}).to_string()).unwrap(); + let a = Preferences::load(&path).appearance; + assert_eq!((a.appearance_mode.as_str(), a.dark_theme.as_str(), a.light_theme.as_str()), (mode, dark, light), "{theme}"); + assert_eq!(a.shown_theme(None), theme); + } + // a v1 file without an appearance section, and a hostile theme, get the defaults + std::fs::write(&path, r#"{"autoSave":{"enabled":true}}"#).unwrap(); + assert_eq!(Preferences::load(&path).appearance, settings::AppearancePrefs::default()); + std::fs::write(&path, r#"{"appearance":{"colorTheme":"neon","appearanceMode":7,"darkTheme":"light"}}"#).unwrap(); + assert_eq!(Preferences::load(&path).appearance, settings::AppearancePrefs::default()); + // a file with a mode keeps it + std::fs::write(&path, r#"{"appearance":{"colorTheme":"darkest","appearanceMode":"auto","lightTheme":"light","darkTheme":"dark"}}"#).unwrap(); + let a = Preferences::load(&path).appearance; + assert_eq!((a.appearance_mode.as_str(), a.shown_theme(Some(true)), a.shown_theme(Some(false)), a.shown_theme(None)), ("auto", "light", "dark", "dark")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn appearance_choices_validate_and_legacy_theme_still_selects() { + let mut s = Session::default(); + s.execute("prefs.set", json!({"values": {"appearance.appearanceMode": "auto", "appearance.darkTheme": "dark"}})).unwrap(); + assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("auto", "dark")); + for (key, value) in [ + ("appearance.darkTheme", json!("light")), + ("appearance.lightTheme", json!("darkest")), + ("appearance.appearanceMode", json!("system")), + ("appearance.appearanceMode", json!(1)), + ] { + assert!(s.execute("prefs.set", json!({"key": key, "value": value})).is_err(), "{key} {value}"); + } + // the single Color Theme of older clients selects that theme and fixes the mode to its family + set(&mut s, "appearance.colorTheme", json!("light")); + assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.light_theme.as_str()), ("light", "light")); + assert_eq!(s.prefs.appearance.dark_theme, "dark", "the dark choice is kept"); + set(&mut s, "appearance.colorTheme", json!("darkest")); + assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("dark", "darkest")); + // the header button's cycle + let mut a = settings::AppearancePrefs::default(); + let mut seen = Vec::new(); + for _ in 0..4 { + a.appearance_mode = a.next_mode().into(); + seen.push(a.appearance_mode.clone()); + } + assert_eq!(seen, ["auto", "light", "dark", "auto"]); +} diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 8615b827..e3ccfda2 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -100,6 +100,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("quick-export-toggled"), true)); } crate::panels::export_mode::quick_export(app, ui.ctx(), pos2(qx - 340.0, rect.max.y + 4.0)); + // Appearance Mode: click for the next one (Auto, Light, Dark); the icon shows the current one. + let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() { + "auto" => (Icon::Monitor, tl!("Sync with system")), + "light" => (Icon::Sun, tl!("Light")), + _ => (Icon::Moon, tl!("Dark")), + }; + let tip = format!("{}: {mode}", tl!("Appearance Mode")); + if btn(ui, icon, "appearance", &tip, app).clicked() { + crate::panels::settings::cycle_appearance(app, ui.ctx()); + } let ws_resp = btn(ui, Icon::Workspaces, "workspaces", tl!("Workspaces"), app); // workspace name (caps) let ws = crate::i18n::t(&app.ui.workspace).to_uppercase(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a39dc82e..f6c16e98 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3480,3 +3480,11 @@ New Sequence Nueva secuencia Tracks Pistas tracks pistas + +# System appearance modes and theme cards. + Active Activo + Appearance Mode Modo de apariencia + Dark Theme Tema oscuro + Light Theme Tema claro + Next Appearance Mode Siguiente modo de apariencia + Sync with system Sincronizar con el sistema diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 12a3ea2a..7b395164 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -93,3 +93,6 @@ OK OK Type Tool 文字ツール Vertical Type Tool 縦書き文字ツール + +# System appearance mode menu command. + Next Appearance Mode 次の外観モード diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 40dd8244..5426f18e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -352,3 +352,6 @@ Zoom In Ampliar Zoom Out Reduzir Zoom to Sequence Zoom para a sequência + +# System appearance mode menu command. + Next Appearance Mode Próximo modo de aparência diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 32cfa3e4..3da7ce6a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -114,6 +114,10 @@ pub enum Icon { Network, /// Media Browser: recent directories (a clock face). Clock, + /// Appearance Mode: Auto (a display on a stand), Light (a sun), Dark (a crescent moon). + Monitor, + Sun, + Moon, } pub struct Pen16<'a> { @@ -725,6 +729,31 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.circle(8.0, 8.0, 6.0); pen.line(&[(8.0, 4.5), (8.0, 8.0), (10.5, 9.5)]); } + Monitor => { + pen.rect(2.0, 2.5, 14.0, 11.0); + pen.line(&[(8.0, 11.0), (8.0, 13.5)]); + pen.line(&[(5.0, 13.5), (11.0, 13.5)]); + } + Sun => { + pen.circle(8.0, 8.0, 2.8); + for i in 0..8 { + let a = (i as f32 * 45.0).to_radians(); + let (c, s) = (a.cos(), a.sin()); + pen.line(&[(8.0 + 4.6 * c, 8.0 + 4.6 * s), (8.0 + 6.2 * c, 8.0 + 6.2 * s)]); + } + } + Moon => { + // The outer circle's arc through the bottom left, back along a smaller circle that + // bites the top right out of it. + let arc = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| { + (0..=12).map(move |i| { + let a = (a0 + (a1 - a0) * i as f32 / 12.0).to_radians(); + (cx + r * a.cos(), cy + r * a.sin()) + }) + }; + let pts: Vec<(f32, f32)> = arc(8.0, 8.5, 5.5, 8.56, 261.44).chain(arc(11.2, 5.3, 4.6, 209.12, 60.88).skip(1)).collect(); + pen.closed(&pts); + } } } @@ -759,6 +788,9 @@ mod tests { use egui::epaint::{ColorMode, Shape}; const ALL: &[Icon] = &[ + Monitor, + Sun, + Moon, Selection, TrackSelectFwd, TrackSelectBack, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 83d18261..8fe4fbda 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -131,8 +131,15 @@ pub struct HostHooks { /// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸ /// Edit Original, Help ▸ Reveal Log Files). pub open_path: Option Result<(), String>>>, + /// The operating system's light or dark appearance when egui cannot report it (Linux desktops + /// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto + /// uses `egui::Context::system_theme`. + pub system_theme: Option, } +/// Reads the system appearance (Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system). +pub type SystemThemeFn = Box Option>; + /// The command a [`HostHooks::pick_file_for_relink`] caller runs with the chosen file, for hosts /// that can only run it later. #[derive(Clone, Debug)] @@ -506,6 +513,18 @@ impl FilmcraftApp { self.ui.dark = k != ThemeKind::Light; } + /// The system appearance: the host's reading, else what the windowing toolkit reports. + pub fn system_theme(&self, ctx: &egui::Context) -> Option { + self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) + } + + /// The theme Settings ▸ Appearance selects: the light or dark theme of the Appearance Mode, + /// Auto following the system (dark when it gives no answer). + pub fn appearance_kind(&self, ctx: &egui::Context) -> ThemeKind { + let light = self.system_theme(ctx).map(|t| t == egui::Theme::Light); + ThemeKind::from_pref(self.session.prefs.appearance.shown_theme(light)) + } + fn apply_tooltips(&self, ctx: &egui::Context) { // Settings ▸ General ▸ Show Tool Tips let delay = if self.session.prefs.general.show_tool_tips { 0.5 } else { 1.0e9 }; @@ -515,13 +534,22 @@ impl FilmcraftApp { /// Make the UI follow the settings after they change (theme, tooltips, frame cache budget, /// play after rendering, audio device). pub fn apply_prefs(&mut self, ctx: &egui::Context) { + // Appearance Mode ▸ Sync with system: follow the system while running. The host reports + // changes as they happen (no polling) and wakes the UI only when the value changes. + if self.applied_prefs.is_some() { + let k = self.appearance_kind(ctx); + if k != self.tokens.kind { + self.set_theme(ctx, k); + } + } if self.applied_prefs.as_ref() == Some(&self.session.prefs) { return; } let p = self.session.prefs.clone(); let prev = self.applied_prefs.take(); if prev.as_ref().is_none_or(|q| q.appearance != p.appearance || q.general.show_tool_tips != p.general.show_tool_tips) { - self.set_theme(ctx, ThemeKind::from_pref(&p.appearance.color_theme)); + let k = self.appearance_kind(ctx); + self.set_theme(ctx, k); } if prev.as_ref().is_none_or(|q| q.general.interface_language != p.general.interface_language) { let language = match i18n::Language::parse(&p.general.interface_language) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6046935..96566a15 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -93,6 +93,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None), uic!("view.theme.medium", "Medium", ["View", "Appearance"], None), uic!("view.theme.light", "Light", ["View", "Appearance"], None), + uic!("view.appearanceMode.next", "Next Appearance Mode", ["View", "Appearance"], None), uic!("window.workspace.editing", "Editing", ["Window", "Workspaces"], Some("Alt+Shift+1")), uic!("window.workspace.assembly", "Assembly", ["Window", "Workspaces"], Some("Alt+Shift+2")), uic!("window.workspace.color", "Color", ["Window", "Workspaces"], Some("Alt+Shift+3")), @@ -478,6 +479,10 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: } _ => {} } + if id == "view.appearanceMode.next" { + crate::panels::settings::cycle_appearance(app, ctx); + return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode})); + } if let Some(th) = id.strip_prefix("view.theme.") { let k = crate::theme::ThemeKind::from_name(th).ok_or("unknown theme")?; crate::panels::settings::set_theme(app, ctx, k); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 241c5975..41395d86 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -79,6 +79,192 @@ pub fn set_theme(app: &mut FilmcraftApp, ctx: &egui::Context, k: crate::theme::T app.set_theme(ctx, k); } +/// The header's appearance button and View ▸ Appearance ▸ Next Appearance Mode: Auto, Light, Dark, +/// then Auto again. The light and dark theme choices are kept. +pub fn cycle_appearance(app: &mut FilmcraftApp, ctx: &egui::Context) { + let next = app.session.prefs.appearance.next_mode(); + if let Err(e) = app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": next})) { + app.ui.status = e.to_string(); + } + app.apply_prefs(ctx); +} + +/// Keys the Appearance page draws as the mode selector and theme cards instead of plain fields. +const APPEARANCE_KEYS: &[&str] = &["appearance.appearanceMode", "appearance.lightTheme", "appearance.darkTheme", "appearance.colorTheme"]; + +/// A small FilmCraft editor painted with `t`'s colours: the header with the Import / Edit / Export +/// tabs, the Source and Program monitors, the Project panel, the tools, the Timeline with video and +/// audio clips and the playhead, and the audio meters (the Editing workspace). +fn theme_preview(ui: &mut Ui, t: &Tokens, width: f32) { + use egui::pos2; + let (rect, _) = ui.allocate_exact_size(vec2(width, 128.0), Sense::hover()); + let p = ui.painter_at(rect); + let bar = |r: Rect, w: f32, c: Color32| p.rect_filled(Rect::from_min_size(r.min, vec2(w, 2.0)), 1.0, c); + p.rect_filled(rect, 3.0, t.app_bg); + // header: home, the three mode tabs (Edit active, underlined), the title, quick actions + let header = Rect::from_min_size(rect.min, vec2(rect.width(), 12.0)); + p.rect_filled(header, CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 }, t.header_bg); + p.rect_filled(Rect::from_center_size(pos2(header.left() + 7.0, header.center().y), vec2(4.0, 4.0)), 1.0, t.text_dim); + for (i, w) in [9.0, 6.0, 9.0].into_iter().enumerate() { + let x = header.left() + 14.0 + i as f32 * 13.0; + let c = if i == 1 { t.tab_text_active } else { t.tab_text }; + bar(Rect::from_min_size(pos2(x, header.center().y - 1.0), vec2(w, 2.0)), w, c); + if i == 1 { + p.rect_filled(Rect::from_min_size(pos2(x, header.bottom() - 2.5), vec2(w, 1.0)), 0.0, c); + } + } + bar(Rect::from_min_size(pos2(header.center().x - 16.0, header.center().y - 1.0), vec2(32.0, 2.0)), 32.0, t.tab_text_active); + for i in 0..4 { + p.rect_filled(Rect::from_center_size(pos2(header.right() - 7.0 - i as f32 * 8.0, header.center().y), vec2(4.0, 4.0)), 1.0, t.icon); + } + let body = Rect::from_min_max(pos2(rect.left() + 2.0, header.bottom() + 2.0), rect.max - vec2(2.0, 2.0)); + let split_y = body.top() + body.height() * 0.5; + let panel = |r: Rect, tab_w: f32| { + p.rect_filled(r, 2.0, t.panel_bg); + bar(Rect::from_min_size(r.min + vec2(4.0, 3.5), vec2(tab_w, 2.0)), tab_w, t.tab_text_active); + Rect::from_min_max(r.min + vec2(3.0, 9.0), r.max - vec2(3.0, 3.0)) + }; + // Source monitor (left) and Program monitor (right) + let source = panel(Rect::from_min_max(body.min, pos2(body.left() + body.width() * 0.36, split_y - 1.0)), 14.0); + p.rect_filled(source, 0.0, t.monitor_bg); + p.rect_filled(source.shrink2(vec2(6.0, 8.0)), 0.0, t.field_bg); + let program = panel(Rect::from_min_max(pos2(body.left() + body.width() * 0.36 + 2.0, body.top()), pos2(body.right(), split_y - 1.0)), 16.0); + p.rect_filled(program, 0.0, t.monitor_bg); + let frame = Rect::from_center_size(program.center() - vec2(0.0, 3.0), vec2((program.height() - 9.0) * 16.0 / 9.0, program.height() - 9.0)); + p.rect_filled(frame, 0.0, Color32::from_rgb(0x2c, 0x4a, 0x2e)); + p.rect_filled(Rect::from_min_max(pos2(frame.left(), frame.bottom() - frame.height() * 0.3), frame.max), 0.0, Color32::from_rgb(0x4a, 0x3c, 0x30)); + p.circle_filled(pos2(frame.left() + frame.width() * 0.62, frame.center().y + 2.0), frame.height() * 0.26, Color32::from_rgb(0xc8, 0x2a, 0x26)); + bar(Rect::from_min_size(pos2(program.left() + 2.0, program.bottom() - 3.0), vec2(14.0, 2.0)), 14.0, t.timecode); + // Project panel, tools, Timeline, meters + let low = split_y + 1.0; + let project = panel(Rect::from_min_max(pos2(body.left(), low), pos2(body.left() + body.width() * 0.24, body.bottom())), 12.0); + for row in 0..2 { + for col in 0..2 { + let cell = + Rect::from_min_size(project.min + vec2(col as f32 * (project.width() / 2.0), 2.0 + row as f32 * 18.0), vec2(project.width() / 2.0 - 3.0, 11.0)); + p.rect_filled(cell, 1.0, if (row + col) % 2 == 0 { t.field_bg } else { t.row_selected }); + bar(Rect::from_min_size(cell.left_bottom() + vec2(0.0, 2.0), vec2(cell.width() * 0.7, 2.0)), cell.width() * 0.7, t.text_faint); + } + } + let tools = Rect::from_min_max(pos2(project.right() + 5.0, low), pos2(project.right() + 15.0, body.bottom())); + p.rect_filled(tools, 2.0, t.panel_bg); + for i in 0..5 { + let c = if i == 0 { t.accent } else { t.icon }; + p.rect_filled(Rect::from_center_size(pos2(tools.center().x, tools.top() + 7.0 + i as f32 * 9.0), vec2(5.0, 5.0)), 1.0, c); + } + let meters = Rect::from_min_max(pos2(body.right() - 12.0, low), pos2(body.right(), body.bottom())); + p.rect_filled(meters, 2.0, t.panel_bg); + for (i, h) in [0.55, 0.62].into_iter().enumerate() { + let x = meters.left() + 3.0 + i as f32 * 4.0; + p.rect_filled( + Rect::from_min_max(pos2(x, meters.bottom() - 3.0 - (meters.height() - 6.0) * h), pos2(x + 2.0, meters.bottom() - 3.0)), + 0.0, + t.render_green, + ); + } + let tl = Rect::from_min_max(pos2(tools.right() + 2.0, low), pos2(meters.left() - 2.0, body.bottom())); + p.rect_filled(tl, 2.0, t.tl_bg); + bar(Rect::from_min_size(tl.min + vec2(4.0, 3.5), vec2(14.0, 2.0)), 14.0, t.tab_text_active); + bar(Rect::from_min_size(tl.min + vec2(4.0, 9.0), vec2(16.0, 2.0)), 16.0, t.timecode); + let heads_w = 22.0; + let ruler = Rect::from_min_max(pos2(tl.left() + heads_w, tl.top() + 7.0), pos2(tl.right() - 2.0, tl.top() + 13.0)); + p.rect_filled(ruler, 0.0, t.tl_ruler_bg); + let mut x = ruler.left() + 2.0; + while x < ruler.right() { + p.line_segment([pos2(x, ruler.bottom() - 2.0), pos2(x, ruler.bottom())], Stroke::new(1.0, t.tl_ruler_tick)); + x += 6.0; + } + let video = Label::Iris.rgb(); + let audio = Label::Caribbean.rgb(); + let music = Label::Forest.rgb(); + let track_h = ((tl.bottom() - ruler.bottom() - 3.0) / 4.0).max(4.0); + for i in 0..4 { + let y = ruler.bottom() + 1.0 + i as f32 * track_h; + let head = Rect::from_min_size(pos2(tl.left() + 2.0, y), vec2(heads_w - 3.0, track_h - 1.0)); + p.rect_filled(head, 1.0, t.tl_header_bg); + p.rect_filled(Rect::from_min_size(head.min + vec2(2.0, 1.5), vec2(5.0, track_h - 4.0)), 1.0, t.accent); + let lane = Rect::from_min_max(pos2(ruler.left(), y), pos2(ruler.right(), y + track_h - 1.0)); + p.rect_filled(lane, 0.0, if i % 2 == 0 { t.tl_track_bg } else { t.tl_track_bg_alt }); + let clips: &[(f32, f32)] = match i { + 0 => &[(0.55, 0.75)], + 1 | 2 => &[(0.0, 0.22), (0.23, 0.48), (0.49, 0.7), (0.71, 0.9)], + _ => &[(0.0, 0.9)], + }; + let c = match i { + 0 | 1 => video, + 2 => audio, + _ => music, + }; + for (a, b) in clips { + let r = Rect::from_min_max(pos2(lane.left() + lane.width() * a, lane.top() + 0.5), pos2(lane.left() + lane.width() * b, lane.bottom() - 0.5)); + p.rect_filled(r, 1.0, Color32::from_rgb(c[0], c[1], c[2]).gamma_multiply(0.85)); + } + } + let ph = ruler.left() + ruler.width() * 0.3; + p.line_segment([pos2(ph, ruler.top()), pos2(ph, tl.bottom() - 2.0)], Stroke::new(1.0, t.playhead)); +} + +/// One theme card of the Appearance page: title, "Active" while the mode shows it, the preview and +/// a radio button per theme (automation ids `settings..`). +#[allow(clippy::too_many_arguments)] +fn theme_card(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: &str, title: &str, opts: &[(&str, &str)], active: bool, width: f32) { + let t = app.tokens; + let current = choice_text(get(&d.values, key)); + let shown = opts.iter().find(|(v, _)| *v == current).or(opts.first()).map_or("darkest", |(v, _)| v); + let highlight = settings::parse_hex(&choice_text(get(&d.values, "appearance.highlightColor"))); + let contrast = get(&d.values, "appearance.accessibleContrast").as_bool().unwrap_or(false); + let look = Tokens::for_kind(crate::theme::ThemeKind::from_pref(shown)).with_appearance(highlight, contrast); + let r = Frame::new() + .fill(t.field_bg) + .stroke(Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(CornerRadius::same(6)) + .inner_margin(Margin::same(10)) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(width - 20.0); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::t(title)).size(12.5).strong()); + if active { + ui.label(RichText::new(tl!("Active")).size(11.0).color(t.accent)); + } + }); + ui.add_space(4.0); + theme_preview(ui, &look, width - 20.0); + ui.add_space(4.0); + for (v, l) in opts { + let r = ui.radio(current == *v, RichText::new(crate::i18n::t(l)).size(12.5)); + app.auto.add(&format!("settings.{key}.{v}"), r.rect, l); + if r.clicked() { + put(&mut d.values, key, json!(v)); + } + } + }); + }); + app.auto.add(&format!("settings.{key}"), r.response.rect, title); +} + +/// Settings ▸ Appearance: the Appearance Mode selector above the light and dark theme cards. +fn appearance_modes(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) { + let key = "appearance.appearanceMode"; + let mode = choice_text(get(&d.values, key)); + ui.horizontal(|ui| { + field_label(ui, "Appearance Mode"); + if let Some(v) = combo(app, ui, key, &mode, &static_opts(settings::APPEARANCE_MODES), 200.0) { + put(&mut d.values, key, json!(v)); + } + }); + let mode = choice_text(get(&d.values, key)); + let light_active = mode == "light" || (mode == "auto" && app.system_theme(ui.ctx()) == Some(egui::Theme::Light)); + ui.add_space(6.0); + let width = ((ui.available_width() - 12.0) / 2.0).max(200.0); + ui.horizontal_top(|ui| { + theme_card(app, ui, d, "appearance.lightTheme", "Light Theme", settings::LIGHT_THEMES, light_active, width); + ui.add_space(4.0); + theme_card(app, ui, d, "appearance.darkTheme", "Dark Theme", settings::DARK_THEMES, !light_active, width); + }); + ui.add_space(6.0); +} + /// Patch the open dialog's draft (`ui.set {"settings": {"page": "trim", "values": {key: value}}}`). pub fn patch(app: &mut FilmcraftApp, p: &Value) -> Result<(), String> { let d = app.ui.settings.as_mut().ok_or("the Settings dialog is not open")?; @@ -422,6 +608,7 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str ), ); } + "appearanceModes" => appearance_modes(app, ui, d), "autoSaveStatus" => { let status = app.session.execute("file.autoSaveStatus", json!({})).unwrap_or_default(); if let Some(dir) = status["autoSaveDir"].as_str() { @@ -467,6 +654,7 @@ fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row] continue; } match r { + Row::Field(f) if APPEARANCE_KEYS.contains(&f.key) => continue, Row::Field(f) => draw_field(app, ui, d, f), Row::Group(title, inner) => group(ui, &t, title, |ui| rows(app, ui, d, inner)), Row::Note(text) => note(ui, &t, text), @@ -595,6 +783,66 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::theme::ThemeKind; + + #[test] + fn appearance_button_cycles_modes_without_losing_theme_choices() { + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(filmcraft_engine::Session::default()); + app.apply_prefs(&ctx); + assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("dark", ThemeKind::Dark), "new users keep Darkest"); + app.session.execute("prefs.set", json!({"values": {"appearance.darkTheme": "dark"}})).unwrap(); + app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark))); + for (mode, shown) in [("auto", ThemeKind::Medium), ("light", ThemeKind::Light), ("dark", ThemeKind::Medium), ("auto", ThemeKind::Medium)] { + cycle_appearance(&mut app, &ctx); + assert_eq!(app.session.prefs.appearance.appearance_mode, mode); + assert_eq!(app.tokens.kind, shown, "{mode}"); + assert_eq!((app.session.prefs.appearance.dark_theme.as_str(), app.session.prefs.appearance.light_theme.as_str()), ("dark", "light")); + } + // View ▸ Appearance picks a theme and fixes the mode to its family + set_theme(&mut app, &ctx, ThemeKind::Light); + assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("light", ThemeKind::Light)); + set_theme(&mut app, &ctx, ThemeKind::Dark); + assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("dark", ThemeKind::Dark)); + assert_eq!(app.session.prefs.appearance.dark_theme, "darkest"); + } + + /// Auto follows the host's reading of the system appearance as it changes, and falls back to + /// dark without an answer. + #[test] + fn auto_follows_a_stubbed_system_theme() { + use std::sync::Arc; + use std::sync::atomic::{AtomicU8, Ordering}; + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(filmcraft_engine::Session::default()); + let system = Arc::new(AtomicU8::new(2)); + let read = system.clone(); + app.hooks.system_theme = Some(Box::new(move |_| match read.load(Ordering::Relaxed) { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + })); + app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "auto"})).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.tokens.kind, ThemeKind::Light); + assert!(!app.ui.dark); + assert_eq!(ctx.theme(), egui::Theme::Light, "egui's style follows FilmCraft's choice"); + system.store(1, Ordering::Relaxed); + app.apply_prefs(&ctx); + assert_eq!(app.tokens.kind, ThemeKind::Dark); + assert_eq!(ctx.theme(), egui::Theme::Dark); + system.store(2, Ordering::Relaxed); + app.apply_prefs(&ctx); + assert_eq!(app.tokens.kind, ThemeKind::Light); + system.store(0, Ordering::Relaxed); + app.apply_prefs(&ctx); + assert_eq!(app.tokens.kind, ThemeKind::Dark, "no answer: dark"); + // a fixed mode ignores the system + app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "dark"})).unwrap(); + system.store(2, Ordering::Relaxed); + app.apply_prefs(&ctx); + assert_eq!(app.tokens.kind, ThemeKind::Dark); + } #[test] fn diff_lists_changed_leaves() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3a81ae12..8781b3f5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -299,7 +299,11 @@ fn add_craft_fonts(fonts: &mut FontDefinitions) { } pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { - let mut v = if t.kind == ThemeKind::Light { Visuals::light() } else { Visuals::dark() }; + let light = t.kind == ThemeKind::Light; + // FilmCraft decides between light and dark itself (Settings ▸ Appearance ▸ Appearance Mode), so + // egui must not switch to its other built-in style when the system appearance changes. + ctx.set_theme(if light { egui::Theme::Light } else { egui::Theme::Dark }); + let mut v = if light { Visuals::light() } else { Visuals::dark() }; v.panel_fill = t.panel_bg; v.window_fill = t.panel_bg; v.extreme_bg_color = t.field_bg; diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/settings_ui.rs index c8513618..34990635 100644 --- a/crates/ui-egui/tests/settings_ui.rs +++ b/crates/ui-egui/tests/settings_ui.rs @@ -250,17 +250,39 @@ fn appearance_labels_and_tooltips_take_effect() { let mut d = Driver::demo(); assert_eq!(d.inspect()["ui"]["dark"], true); d.menu("app.settings.appearance"); - d.click("settings.appearance.colorTheme"); - d.click("settings.appearance.colorTheme.light"); + // the Appearance Mode selector above the light and dark theme cards + assert!(d.has("settings.appearance.lightTheme.light") && d.has("settings.appearance.darkTheme.dark")); + assert!(!d.has("settings.appearance.colorTheme"), "the single Color Theme is not shown"); + // Each radio belongs below its preview, even though the cards are laid out side by side. + let elements = d.ok("ui.elements", json!({"prefix": "settings.appearance."})); + let top = |id: &str| elements.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap()["rect"][1].as_f64().unwrap(); + for (card, radio) in + [("settings.appearance.lightTheme", "settings.appearance.lightTheme.light"), ("settings.appearance.darkTheme", "settings.appearance.darkTheme.darkest")] + { + assert!(top(radio) > top(card) + 128.0, "{radio} sits below its preview"); + } + d.click("settings.appearance.darkTheme.dark"); + d.click("settings.appearance.appearanceMode"); + d.click("settings.appearance.appearanceMode.light"); d.ok("ui.set", json!({"settings": {"values": {"appearance.highlightColor": "#e0457b"}}})); d.click("settings.ok"); d.frames(2); assert_eq!(d.inspect()["ui"]["dark"], false, "Light theme applied"); + assert_eq!((d.pref("appearance.appearanceMode"), d.pref("appearance.darkTheme")), (json!("light"), json!("dark"))); + // the header button cycles Auto, Light, Dark + d.click("header.appearance"); + assert_eq!(d.pref("appearance.appearanceMode"), "dark"); + assert_eq!(d.harness.state().tokens.kind, filmcraft_ui_egui::theme::ThemeKind::Medium, "the saved dark theme"); + d.click("header.appearance"); + assert_eq!(d.pref("appearance.appearanceMode"), "auto"); + d.menu("view.appearanceMode.next"); + assert_eq!(d.pref("appearance.appearanceMode"), "light"); assert_eq!(d.pref("appearance.highlightColor"), "#e0457b"); assert_eq!(d.harness.state().tokens.accent, egui::Color32::from_rgb(0xe0, 0x45, 0x7b)); // View ▸ Appearance writes the setting d.menu("view.theme.dark"); assert_eq!(d.pref("appearance.colorTheme"), "darkest"); + assert_eq!((d.pref("appearance.appearanceMode"), d.pref("appearance.darkTheme")), (json!("dark"), json!("darkest"))); assert_eq!(d.inspect()["ui"]["dark"], true); // label names show in Edit ▸ Label From c17013fca9451b0bd123f0c003e2cd2c89375c24 Mon Sep 17 00:00:00 2001 From: hadmark Date: Sat, 10 Oct 2026 14:08:41 +0800 Subject: [PATCH 04/14] Add files via upload Add Chinese translation catalog --- crates/ui-egui/src/i18n/zh.tsv | 3477 ++++++++++++++++++++++++++++++++ 1 file changed, 3477 insertions(+) create mode 100644 crates/ui-egui/src/i18n/zh.tsv diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv new file mode 100644 index 00000000..31ba3b6f --- /dev/null +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -0,0 +1,3477 @@ +# Chinese (zh-CN) catalog. Columns, separated by a TAB: context source translation +# +# context 空 普通界面字符串;`source` 是代码中的英文文本(`tl!("…")`、`i18n::t` 翻译的 +# 菜单标签或名称)。译文为简体中文。 +# 其它 传给 `tr_ctx` 的消歧上下文(一个需要两种译法的英文词)。 +# 转义 列内使用 \n \t \\。`{name}` 等占位符须同时出现在原文与译文中,可按需调整顺序。 +# 以 # 开头的行和空行会被忽略。 +# +# 净室实现:每条译文均由英文含义直接译出,使用通用的中文视频剪辑术语(剪辑、时间轴、序列、 +# 轨道、关键帧……)。未参考或复制任何专有本地化资源、字符串表或产品界面。产品与技术名称 +# (FilmCraft、ArtCraft、Lumetri、ProRes、LUFS……)保留原文拼写。 + About FilmCraft 关于 FilmCraft + Add Audio Submix Track 添加音频子混音轨道 + Add Caption at Playhead 在播放头处添加字幕 + Add Chapter Marker… 添加章节标记… + Add Edit 添加编辑 + Add Edit to All Tracks 在所有轨道添加编辑点 + Add Flash Cue Marker… 添加闪现提示标记… + Add Frame Hold 添加帧定格 + Add Guide… 添加参考线… + Add Marker 添加标记 + Add New Caption Track… 添加新字幕轨道… + Add Range Marker 添加范围标记 + Add Range Marker to In and Out 在入点和出点间添加范围标记 + Add Tracks… 添加轨道… + Adjustment Layer… 调整图层… + Align to Selection 与所选对象对齐 + Align to Video Frame 与视频帧对齐 + Align to Video Frame as Group 作为组与视频帧对齐 + All Panels 所有面板 + Alpha Alpha + Appearance 外观 + Appearance… 外观… + Apply Audio Transition 应用音频过渡 + Apply Default Transitions to Selection 对所选对象应用默认过渡 + Apply Video Transition 应用视频过渡 + Arrange 排列 + ArtCraft Website ArtCraft 官网 + Assembly 组装 + Attach Proxies… 附加代理文件… + Audio Channels… 音频声道… + Audio Clip Mixer 音频剪辑混音器 + Audio Gain… 音频增益… + Audio Hardware… 音频硬件… + Audio In 音频入 + Audio Meters 音频电平表 + Audio Options 音频选项 + Audio Out 音频出 + Audio Track Mixer 音频轨道混音器 + Audio Waveform 音频波形 + Auto Save… 自动保存… + Automate to Sequence… 自动适配到序列… + Bars and Tone… 彩条与音调… + Bin 素材箱 + Bin From Selection 从所选对象创建素材箱 + Black Video… 黑场视频… + Blue 蓝 + Bottom 底部 + Breakout to Mono 拆分为单声道 + Bring Forward 上移一层 + Bring to Front 置于顶层 + Brown 棕 + Captions 字幕 + Captions and Graphics 字幕与图形 + Captions… 字幕… + Caribbean 加勒比 + Center Horizontally 水平居中 + Center Vertically 垂直居中 + Cerulean 蔚蓝 + Clear 清除 + Clear Guides 清除参考线 + Clear In 清除入点 + Clear In and Out 清除入点和出点 + Clear Markers 清除标记 + Clear Out 清除出点 + Clear Selected Marker 清除所选标记 + Close 关闭 + Close All Other Projects 关闭其他所有项目 + Close All Projects 关闭所有项目 + Close Gap 闭合间隙 + Close Project 关闭项目 + Color Management… 色彩管理… + Color Matte… 颜色遮罩… + Comparison View 比较视图 + Composite Video 合成视频 + Consolidate Duplicates 合并重复项 + Copy 复制 + Copy Paste Includes Sequence Markers 复制粘贴包含序列标记 + Create Captions from Transcript… 根据转录文稿创建字幕… + Create Multi-Camera Source Sequence… 创建多机位源序列… + Create Proxies… 创建代理文件… + Cut 剪切 + Darkest 最暗 + Delete Render Files 删除渲染文件 + Delete Render Files In to Out 删除入点到出点间的渲染文件 + Delete Tracks… 删除轨道… + Delete Transcript 删除转录文稿 + Demo Footage 演示素材 + Demo Project 演示项目 + Deselect All 取消全选 + Detach Proxies 分离代理文件 + Display Mode 显示模式 + Distribute 分布 + Distribute Horizontally 水平分布 + Distribute Space Horizontally 水平均匀分布间距 + Distribute Space Vertically 垂直均匀分布间距 + Distribute Vertically 垂直分布 + Duplicate 复制 + Dynamic Audio Waveforms 动态音频波形 + Edit 编辑 + Edit Offline… 脱机编辑… + Edit Original 编辑原始素材 + Edit Subclip… 编辑子剪辑… + Editing 编辑 + Effect Controls 效果控件 + Effects 效果 + Ellipse 椭圆 + Enable 启用 + Enable Remix 启用重新混音 + Essential Graphics 基本图形 + Essential Sound 基本声音 + Events 事件 + Export 导出 + Export As Motion Graphics Template… 导出为动态图形模板… + Extract 提取 + Extract Audio 提取音频 + Field Options… 场选项… + File 文件 + File… 文件… + Fill frame 填满帧 + FilmCraft Help… FilmCraft 帮助… + FilmCraft on GitHub GitHub 上的 FilmCraft + FilmCraft on getartcraft.com getartcraft.com 上的 FilmCraft + Find Next 查找下一个 + Find… 查找… + Fit 适配 + Fit to frame 适配到帧 + Flatten 拼合 + Forest 森林绿 + Frame Blending 帧混合 + Frame Hold Options… 帧定格选项… + Frame Sampling 帧采样 + From Bin 从素材箱 + From Source Monitor 从源监视器 + From Source Monitor, Match Frame 从源监视器,匹配帧 + From file… 从文件… + Full 完整 + Generate Audio Waveform 生成音频波形 + Get Media File Properties for 获取媒体文件属性: + Go to Gap 转到间隙 + Go to In 转到入点 + Go to Next Caption Segment 转到下一段字幕 + Go to Next Marker 转到下一个标记 + Go to Out 转到出点 + Go to Previous Caption Segment 转到上一段字幕 + Go to Previous Marker 转到上一个标记 + Go to Split 转到拆分点 + Graphics and Titles 图形与字幕 + Graphics… 图形… + Green 绿 + Group 编组 + Guide Templates 参考线模板 + Help 帮助 + Hide All Caption Tracks 隐藏所有字幕轨道 + High Quality Playback 高质量播放 + History 历史记录 + Import From 导入来源 + Import Image Sequence… 导入图像序列… + Import from Media Browser 从媒体浏览器导入 + Import… 导入… + Info 信息 + Ingest Settings… 摄取设置… + Insert 插入 + Insert Frame Hold Segment 插入帧定格片段 + Install Motion Graphics Template… 安装动态图形模板… + Interpret Footage… 解释素材… + Join the ArtCraft Discord… 加入 ArtCraft Discord… + Keyboard Shortcuts… 键盘快捷键… + Label 标签 + Labels… 标签… + Language 语言 + Lavender 薰衣草 + Learning 学习 + Left 左 + Libraries 库 + Lift 提升 + Light 浅 + Link 链接 + Link Media… 链接媒体… + Linked Selection 关联选择 + Lock Guides 锁定参考线 + Lumetri Color Lumetri 颜色 + Lumetri Scopes Lumetri 示波器 + Magnification 放大 + Make Offline… 设为脱机… + Make Subclip… 创建子剪辑… + Make Subsequence 创建子序列 + Manage Guides… 管理参考线… + Mark Clip 标记剪辑 + Mark In 标记入点 + Mark Out 标记出点 + Mark Selection 标记所选对象 + Mark Split 标记拆分点 + Markers 标记 + Match Frame 匹配帧 + Media Analysis & Transcription… 媒体分析与转录… + Media Browser 媒体浏览器 + Media Cache… 媒体缓存… + Media… 媒体… + Medium 中 + Memory… 内存… + Merge Clips… 合并剪辑… + Metadata 元数据 + Modify 修改 + Motion Graphics Template… 动态图形模板… + Multi-Camera 多机位 + Multi-Camera View 多机位视图 + Nest… 嵌套… + New 新建 + New Layer 新建图层 + New Project Panel 新建项目面板 + Next in Sequence 序列中的下一个 + Next in Track 轨道中的下一个 + Normalize Mix Track… 标准化混音轨道… + Offline File… 脱机文件… + Open Project… 打开项目… + Optical Flow 光流 + Overwrite 覆盖 + Paste 粘贴 + Paste Attributes… 粘贴属性… + Paste Insert 粘贴插入 + Paused Resolution 暂停时分辨率 + Playback Resolution 播放分辨率 + Playback… 播放… + Plugins… 插件… + Polygon 多边形 + Preferences 首选项 + Previous in Sequence 序列中的上一个 + Previous in Track 轨道中的上一个 + Program 节目 + Progress 进度 + Project 项目 + Project Manager… 项目管理器… + Project Settings 项目设置 + Project… 项目… + Properties 属性 + Purple 紫 + Reconnect Full Resolution Media… 重新连接完整分辨率媒体… + Recover Unsaved Changes… 恢复未保存的更改… + Rectangle 矩形 + Red 红 + Redo 重做 + Reference Monitor 参考监视器 + Remix 重新混音 + Remix Properties… 重新混音属性… + Remove Attributes… 移除属性… + Remove Filler Words 移除填充词 + Remove Pauses 移除停顿 + Remove Unused 移除未使用项 + Rename… 重命名… + Render Audio 渲染音频 + Render Effects In to Out 渲染入点到出点间的效果 + Render In to Out 渲染入点到出点 + Render Selection 渲染所选对象 + Replace Fonts in Projects… 替换项目中的字体… + Replace With Clip 替换为剪辑 + Report an Issue… 报告问题… + Reset All Parameters 重置所有参数 + Reset Duration 重置持续时间 + Reset to Saved Layout 重置为已保存的布局 + Restore Captions from Source Clip 从源剪辑恢复字幕 + Reveal Log Files… 显示日志文件… + Reverse Match Frame 反向匹配帧 + Revert 还原 + Revert Remix 还原重新混音 + Review 审阅 + Right 右 + Ripple Delete 波纹删除 + Ripple Sequence Markers 波纹序列标记 + Rose 玫瑰红 + Safe Margins 安全边距 + Save 保存 + Save All 全部保存 + Save As… 另存为… + Save Guides as Template… 将参考线另存为模板… + Save a Copy… 保存副本… + Save as Template… 另存为模板… + Scale to Frame Size 缩放为帧大小 + Scene Edit Detection… 场景编辑检测… + Scratch Disks… 暂存盘… + Search Bin 搜索素材箱 + Select 选择 + Select All 全选 + Select All Matching 选择所有匹配项 + Select Label Group 选择标签组 + Select Next Graphic 选择下一个图形 + Select Next Layer 选择下一个图层 + Select Previous Graphic 选择上一个图形 + Select Previous Layer 选择上一个图层 + Selection Follows Playhead 选择跟随播放头 + Selection as FilmCraft Project… 将所选对象另存为 FilmCraft 项目… + Selection… 所选对象… + Send Backward 下移一层 + Send to Back 置于底层 + Sequence 序列 + Sequence From Clip 从剪辑创建序列 + Sequence Settings… 序列设置… + Sequence… 序列… + Show Active Caption Tracks Only 仅显示活动字幕轨道 + Show All Caption Tracks 显示所有字幕轨道 + Show All Marker Colors 显示所有标记颜色 + Show Guides 显示参考线 + Show Rulers 显示标尺 + Show Through Edits 显示贯通编辑 + Simplify Sequence… 简化序列… + Snap in Program Monitor 在节目监视器中吸附 + Snap in Timeline 在时间轴中吸附 + Source 源 + Source Settings… 源设置… + Speed/Duration… 速度/持续时间… + Synchronize… 同步… + System Compatibility Report… 系统兼容性报告… + Tan 棕褐 + Teal 蓝绿 + Text 文本 + Time Interpolation 时间插值 + Timecode 时间码 + Timecode… 时间码… + Timeline 时间轴 + Timeline… 时间轴… + Toggle Proxies 切换代理文件 + Tools 工具 + Top 顶部 + Transcribe Sequence… 转录序列… + Transcribe… 转录… + Transcript 转录文稿 + Transparent Video… 透明视频… + Trim Edit 修剪编辑 + Trim… 修剪… + Undo 撤销 + Ungroup 取消编组 + Universal Counting Leader… 通用计数片头… + Update Metadata… 更新元数据… + Upgrade Caption to Graphic 将字幕升级为图形 + Upgrade to Source Graphic 升级为源图形 + Vertical Text 竖排文本 + Video In 视频入 + Video Options 视频选项 + Video Out 视频出 + Video and Audio Waveform Split 视频与音频波形拆分 + View 视图 + Violet 紫罗兰 + Window 窗口 + Workspaces 工作区 + Yellow 黄 + Zoom In 放大 + Zoom Out 缩小 + Zoom to Sequence 缩放到序列 + Home 主页 + Import 导入 + {name} - Edited {name} - 已编辑 + Full screen 全屏 + Volume 音量 + Master volume: use the Audio Track Mixer 主音量:请使用音频轨道混音器调整 + Search 搜索 + Quick Export 快速导出 + Join the ArtCraft Discord (discord.gg/artcraft) 加入 ArtCraft Discord(discord.gg/artcraft) + Cancel 取消 + OK 确定 + Interpret Footage 解释素材 + Color Management · {n} item 色彩管理 · {n} 项 + Color Management · {n} items 色彩管理 · {n} 项 + Media colour space (from file metadata): {detected} 媒体色彩空间(来自文件元数据):{detected} + Override media colour space: 覆盖媒体色彩空间: + Use file metadata 使用文件元数据 + Sequence Color Management 序列色彩管理 + Working Color Space 工作色彩空间 + Wide gamut color (composite in BT.2020) 广色域颜色(在 BT.2020 中合成) + Auto tone map media (HDR and log into SDR) 自动色调映射媒体(将 HDR 和 log 映射为 SDR) + Audio Gain 音频增益 + Set Gain to: 将增益设置为: + Adjust Gain by: 按以下值调整增益: + Normalize Max Peak to: 将最大峰值标准化为: + Normalize All Peaks to: 将所有峰值标准化为: + Peak Amplitude: {peak} 峰值振幅:{peak} + Standard 标准 + Adaptive 自适应 + Mono 单声道 + Stereo 立体声 + Add Tracks 添加轨道 + Amount 数量 + Placement 放置位置 + Track type 轨道类型 + Add video tracks 添加视频轨道 + Add audio tracks 添加音频轨道 + Add audio submix tracks 添加音频子混音轨道 + After {name} 在 {name} 之后 + Before First Track 在首个轨道之前 + Delete Tracks 删除轨道 + Video Tracks 视频轨道 + Audio Tracks 音频轨道 + Delete Video Tracks 删除视频轨道 + Delete Audio Tracks 删除音频轨道 + All Empty Tracks 所有空轨道 + Version {version} 版本 {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. 一款净室实现的纯 Rust 非线性视频编辑器,属于 ArtCraft 家族。 + Join the ArtCraft Discord 加入 ArtCraft Discord + Source code on GitHub GitHub 上的源代码 + Report an issue 报告问题 + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT 或 Apache-2.0。字体:Inter、Noto Serif 与 JetBrains Mono(SIL OFL 1.1)。 + just now 刚刚 + {n} s ago {n} 秒前 + {n} min ago {n} 分钟前 + {n} h ago {n} 小时前 + All Events 所有事件 + Warnings and Errors 警告与错误 + Errors Only 仅错误 + Clear All 清除全部 + No events. 无事件。 + Show finished jobs 显示已完成的任务 + Cancelled 已取消 + Failed: {e} 失败:{e} + Done 已完成 + No background jobs. 无后台任务。 + Recover Unsaved Changes 恢复未保存的更改 + Untitled 未命名 + FilmCraft was closed while “{name}” had unsaved changes. FilmCraft 在“{name}”存在未保存的更改时被关闭。 + FilmCraft quit unexpectedly while “{name}” had unsaved changes. FilmCraft 在“{name}”存在未保存的更改时意外退出。 + Recover unsaved changes from {when}? 恢复 {when} 的未保存更改吗? + The project had not been saved yet. 该项目尚未保存。 + {n} sessions have unsaved changes: {n} 个会话存在未保存的更改: + Discard 放弃 + Recover 恢复 + Not Now 暂不 + Are you sure you want to discard your changes to '{file}'? 确定要放弃对“{file}”的更改吗? + Yes 是 + No 否 + Add media to your project. Drop files anywhere in the window, or choose a source below. 将媒体添加到你的项目。将文件拖到窗口任意位置,或在下方选择来源。 + Browse files… 浏览文件… + Movies, audio and stills from disk 来自磁盘的电影、音频与静帧 + Demo footage 演示素材 + Six procedural 1080p clips with sound 六段程序生成的 1080p 带声片段 + Demo project 演示项目 + A cut sequence with transitions, effects and music 一段包含过渡、效果与音乐的剪辑序列 + Bars and Tone 彩条与音调 + SMPTE HD bars with 1 kHz reference tone 带 1 kHz 参考音调的 SMPTE HD 彩条 + Community 社区 + Join us on Discord 在 Discord 上加入我们 + OMF Export Settings OMF 导出设置 + AAF Export Settings AAF 导出设置 + OMF Title: OMF 标题: + Mixdown video 视频混音 + Audio: 音频: + Encapsulate 封装 + Separate Audio 独立音频 + Embed 嵌入 + Separate Files 独立文件 + Link to Media 链接到媒体 + Audio File Format: 音频文件格式: + Sample Rate: 采样率: + Bits per Sample: 采样精度: + Rendering: 渲染: + Trim audio files 修剪音频文件 + Handle Frames: 余量帧数: + Render audio clip effects 渲染音频剪辑效果 + Breakout to mono 拆分为单声道 + Exported {path} 已导出 {path} + Select a clip to view its metadata. 选择一个剪辑以查看其元数据。 + Clip: {name} 剪辑:{name} + Open 打开 + (no sequence) (无序列) + Show or hide {color} markers 显示或隐藏{color}标记 + Marker 标记 + {tc} Chapter {tc} 章节 + No markers. Press M to add one. 无标记。按 M 键添加一个。 + All markers are hidden by the colour filter. 所有标记均已按颜色筛选隐藏。 + Type: 类型: + Video: 视频: + Codec: 编解码器: + Duration: 持续时间: + Settings: 设置: + Playhead: 播放头: + Video {n}: 视频 {n}: + Audio {n}: 音频 {n}: + UI: 界面: + {fps} fps · {n} frames queued {fps} fps · 已排队 {n} 帧 + {panel} — coming in a later milestone {panel} — 将在后续里程碑中推出 + Close Panel 关闭面板 + Close Other Timeline Panels 关闭其他时间轴面板 + Maximize Frame 最大化帧 + Restore Workspace 恢复工作区 + Reveal Sequence in Project 在项目面板中显示序列 + Video Thumbnails 视频缩略图 + Audio Waveforms 音频波形 + Delete Preset 删除预设 + Export Preset… 导出预设… + Import Presets… 导入预设… + Effect presets 效果预设 + Save Preset 保存预设 + Name: 名称: + Type 类型 + Scale 比例 + Anchor to In Point 锚定到入点 + Anchor to Out Point 锚定到出点 + Without keyframes 不含关键帧 + Description: 描述: + Saved preset “{name}” 已保存预设“{name}” + Current duration {seconds} s 当前持续时间 {seconds} 秒 + Remix Properties 重新混音属性 + Target Duration: 目标持续时间: + Segments: 片段数: + Fewer · More 更少 · 更多 + Variations: 变体: + Remixed to {seconds} s 已重新混音为 {seconds} 秒 + {mode} · no clip {mode} · 无剪辑 + {mode} · no sequence {mode} · 无序列 + Remove Row 删除行 + Add a timecode row 添加时间码行 + Panner 声像器 + Mute 静音 + Send {n} Level 发送 {n} 电平 + Show Keyframes 显示关键帧 + Clip Keyframes 剪辑关键帧 + Track Keyframes 轨道关键帧 + Delete 删除 + Untitled Workspace 未命名工作区 + New Workspace 新建工作区 + Edit Workspaces 编辑工作区 + Rename 重命名 + Restore Original 恢复原始 + Search effects 搜索效果 + Apply to Selected Clips 应用到所选剪辑 + Metadata Display 元数据显示 + Save As New View Preset 另存为新视图预设 + Manage Saved View Presets 管理已保存的视图预设 + Freeform View Options 自由形式视图选项 + Save Arrangement 保存排列 + Saves the view, columns, sort, thumbnail and font size. 保存视图、列、排序、缩略图与字体大小。 + Grid spacing: 网格间距: + Default clip size: 默认剪辑大小: + Snap to grid 对齐网格 + Show clip names 显示剪辑名称 + Show durations 显示持续时间 + Show: 显示: + Search fields 搜索字段 + Project Metadata 项目元数据 + Add Property… 添加属性… + Column order 列顺序 + {n}. (empty) {n}.(空) + Restore 恢复 + Overwrite with Current View 用当前视图覆盖 + Graphics text search arrives with M10.1–M10.2 图形文本搜索将在 M10.1–M10.2 中推出 + Open a sequence to work with captions 打开一个序列以编辑字幕 + Add captions 添加字幕 + Create a caption track or import a caption file. 创建字幕轨道或导入字幕文件。 + Create new caption track 创建新字幕轨道 + Import captions file… 导入字幕文件… + New {format} track 新建 {format} 轨道 + Add caption at playhead 在播放头处添加字幕 + Split caption at playhead 在播放头处拆分字幕 + Merge selected captions 合并所选字幕 + Delete selected captions 删除所选字幕 + No captions on this track. Press + to add one at the playhead. 此轨道暂无字幕。按 + 键在播放头处添加。 + No matching captions. 无匹配字幕。 + Open a sequence to see its transcript 打开一个序列以查看其转录文稿 + Transcribe sequence 转录序列 + Speech-to-text turns the dialogue into editable text. 语音转文字可将对白转换为可编辑文本。 + This build has no speech-to-text; import a transcript with transcript.set. 此版本不含语音转文字;请使用 transcript.set 导入转录文稿。 + Transcribe 转录 + Extract selected text 提取所选文本 + Lift selected text 提升所选文本 + Remove filler words 移除填充词 + Create captions 创建字幕 + Speaker 说话人 + Box 文本框 + Align 对齐 + Align left 左对齐 + Align center 居中对齐 + Align right 右对齐 + Position 位置 + Position: top 位置:顶部 + Position: middle 位置:中部 + Position: bottom 位置:底部 + (gap) (间隙) + Out: {name} 出点:{name} + In: {name} 入点:{name} + Play Around Edit (Space loops, Shift+K once) 围绕编辑点播放(空格循环,Shift+K 触发一次) + Trim Backward Many 大幅向后修剪 + Trim Backward 向后修剪 + Apply Default Transitions 应用默认过渡 + Apply Default Transitions to Selection (Shift+D) 对所选对象应用默认过渡(Shift+D) + Trim Forward 向前修剪 + Trim Forward Many 大幅向前修剪 + Exit Trim Mode 退出修剪模式 + Voice-over record (right-click: Voice-Over Record Settings…) 画外音录制(右键:画外音录制设置…) + Voice-Over Record Settings… 画外音录制设置… + Recording voice-over… 正在录制画外音… + Voice-over recorded: {p} 画外音已录制:{p} + Voice-over recording stopped (nothing recorded) 画外音录制已停止(未录制任何内容) + Post-roll 后滚 + ● Recording ● 录制中 + Voice-Over Record Settings 画外音录制设置 + Source: 源: + Default Input 默认输入 + Input: 输入: + Channel {n} 通道 {n} + Countdown Sound Cues 倒计时提示音 + Pre-roll: 前滚: + seconds 秒 + Post-roll: 后滚: + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. 播放从前滚开始,位于播放头(或入点)之前;若已标记入点和出点,录制将在该范围内自动打点和结束。 + Effect 效果 + Clip Fx Editor 剪辑效果编辑器 + Track Fx Editor 轨道效果编辑器 + Master Gain 主增益 + Reset 重置 + Band {n} 频段 {n} + Solo 独奏 + Bypass 旁路 + Thr 阈值 + Ratio 比率 + Att 启动 + Rel 释放 + Gain 增益 + Output Gain 输出增益 + Limiter 限制器 + Link Channels 链接通道 + Limiter Threshold 限制器阈值 + Limiter Release 限制器释放 + Auto Gate 自动门 + Threshold 阈值 + Attack 启动 + Release 释放 + Hold 保持 + Compressor 压缩器 + Auto Makeup 自动补偿 + Makeup 补偿 + Expander 扩展器 + Soft Clip 软削波 + (no sequences) (无序列) + (no clips) (无剪辑) + Show Transport Controls 显示播放控制 + Loop 循环 + Multi-Camera Audio Follows Video 多机位音频跟随视频 + Multi-Camera Record 多机位录制 + Multi-Camera Selection Top Down 多机位自上而下选择 + Show Multi-Camera Preview Monitor 显示多机位预览监视器 + Auto-Adjust Multi-Camera Playback Quality 自动调整多机位播放质量 + Transmit Multi-Camera View 传输多机位视图 + Multi-Camera Layout 多机位布局 + Automatic 自动 + Edit Cameras… 编辑摄像机… + Add Marker (M) 添加标记(M) + Mark In (I) 标记入点(I) + Mark Out (O) 标记出点(O) + Go to In (Shift+I) 转到入点(Shift+I) + Step Back 1 Frame (Left) 后退 1 帧(左方向键) + Play-Stop Toggle (Space) 播放/停止切换(空格) + Step Forward 1 Frame (Right) 前进 1 帧(右方向键) + Go to Out (Shift+O) 转到出点(Shift+O) + Insert (,) 插入(,) + Overwrite (.) 覆盖(.) + Export Frame (Shift+E) 导出静帧(Shift+E) + Lift (;) 提升(;) + Extract (') 提取(') + Export Frame: use Export mode (M6) 导出静帧:请使用导出模式(M6) + Button Editor 按钮编辑器 + Multi-Camera Record on 多机位录制:开 + Multi-Camera Record off 多机位录制:关 + {first} - Merged {first} - 已合并 + {first} Multicam {first} 多机位 + In Points 入点 + Out Points 出点 + Clip Marker 剪辑标记 + Audio 音频 + Merge Clips 合并剪辑 + Create Multi-Camera Source Sequence 创建多机位源序列 + Synchronize Clips 同步剪辑 + Clip Name: 剪辑名称: + Sequence Name: 序列名称: + {n} clip(s): {names} {n} 个剪辑:{names} + {n} clip(s) selected; the clip on the reference track stays put. 已选择 {n} 个剪辑;参考轨道上的剪辑保持不动。 + Synchronize Point 同步点 + Ignore Hours 忽略小时 + first marker 首个标记 + Offset Audio by: 音频偏移量: + Offset: 偏移: + frames 帧 + Reference track: 参考轨道: + lowest (V1…) 最低(V1…) + Remove Audio from AV Clip 从音视频剪辑中移除音频 + Camera 1 摄像机 1 + All Cameras 所有摄像机 + Switch Audio 切换音频 + Camera Names 摄像机名称 + Clip Names 剪辑名称 + Enumerate Cameras 枚举摄像机 + Camera Angle Metadata 摄像机角度元数据 + Move source clips to Processed Clips bin 将源剪辑移到“已处理剪辑”素材箱 + Synchronized {n} clip group(s) 已同步 {n} 个剪辑组 + Merged clip created 已创建合并剪辑 + Multi-camera source sequence with {n} camera(s) 包含 {n} 个摄像机的多机位源序列 + No multi-camera clip at the playhead 播放头处无多机位剪辑 + Camera {n} — {name} 摄像机 {n} — {name} + Page {page} of {pages} 第 {page} 页,共 {pages} 页 + Previous page 上一页 + Next page 下一页 + Multi-Camera Record On/Off (0) 多机位录制 开/关(0) + Record 录制 + Edit Cameras 编辑摄像机 + Cameras shown in the Multi-Camera view (uncheck to hide). 多机位视图中显示的摄像机(取消勾选可隐藏)。 + Recorded {n} multi-camera cut(s) 已录制 {n} 个多机位切换 + Clamp Signal 钳制信号 + Presets 预设 + Parade ({kind}) Parade({kind}) + Waveform ({kind}) 波形({kind}) + Parade Type Parade 类型 + Waveform Type 波形类型 + Vectorscope Targets 矢量示波器目标 + Colour Space 色彩空间 + Brightness 亮度 + Signal Scale 信号刻度 + Histogram 直方图 + Paste Attributes 粘贴属性 + Remove Attributes 移除属性 + New Offline File 新建脱机文件 + Nested Sequence Name 嵌套序列名称 + Make Subclip 创建子剪辑 + Edit Subclip 编辑子剪辑 + Modify Clip: Audio Channels 修改剪辑:音频声道 + Modify Clip: Timecode 修改剪辑:时间码 + Frame Hold Options 帧定格选项 + Field Options 场选项 + Save Project 保存项目 + Motion 运动 + Opacity 不透明度 + Time Remapping 时间重映射 + Channel Volume 声道音量 + In Point 入点 + Out Point 出点 + Playhead 播放头 + Source Timecode 源时间码 + Sequence Time 序列时间 + Video Attributes 视频属性 + Audio Attributes 音频属性 + No effects 无效果 + Scale Attribute Times 缩放属性时间 + File Name: 文件名: + Tape Name: 磁带名称: + Contains: 包含: + Video 视频 + Width: 宽度: + Height: 高度: + Frame Rate: 帧速率: + Media Start: 媒体起点: + Subclip: {name} 子剪辑:{name} + Start: 开始: + End: 结束: + Restrict Trims To Subclip Boundaries 将修剪限制在子剪辑边界内 + Convert to Master Clip 转换为主剪辑 + Clip Channel Format: 剪辑声道格式: + Number of Audio Clips: 音频剪辑数量: + Media Source Channels 媒体源声道 + Ch. {n} 声道 {n} + Clip {n} 剪辑 {n} + Source channels the selected audio clips play: 所选音频剪辑播放的源声道: + Set Timecode: 设置时间码: + Use the file's timecode 使用文件的时间码 + Hold On 定格开启 + Timecode: 时间码: + Hold Filters 定格滤镜 + Reverse Field Dominance 反转场优先顺序 + Processing Options 处理选项 + None 无 + Always Deinterlace 始终去隔行 + Flicker Removal 闪烁消除 + Save changes to “{project_name}” before closing? 关闭前是否保存对“{project_name}”的更改? + Don't Save 不保存 + All media is online. 所有媒体均已联机。 + {name} (copy) {name}(副本) + Offline 脱机 + Missing 缺失 + Link Media 链接媒体 + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. 有 {n} 个剪辑找不到其媒体。请定位它们、搜索文件夹,或保持脱机状态。 + Clip Name 剪辑名称 + File Name 文件名 + File Path 文件路径 + Status 状态 + Match file properties: 匹配文件属性: + File Extension 文件扩展名 + Clip ID (fingerprint) 剪辑 ID(指纹) + Duration 持续时间 + Media Start 媒体起点 + Frame Size / Rate 帧大小 / 速率 + Align Timecode 对齐时间码 + Relink others automatically 自动重新链接其他项 + Search in: 搜索位置: + Browse… 浏览… + Exact name matches only 仅精确名称匹配 + No candidates yet — Search a folder or Locate the file. 暂无候选项 — 请搜索文件夹或定位文件。 + ✔ same file ✔ 同一文件 + ✔ matches ✔ 匹配 + ✖ different file ✖ 不同文件 + Preview 预览 + Offline All 全部脱机 + Locate… 定位… + Linked {n} clip(s) 已链接 {n} 个剪辑 + Nothing found with that name. 未找到使用该名称的文件。 + Make Offline 设为脱机 + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. 将 {n} 个所选剪辑设为脱机。在重新链接之前,这些剪辑将显示脱机画面。 + Media files remain on disk 媒体文件保留在磁盘上 + Media files are deleted 媒体文件将被删除 + Create Proxies 创建代理文件 + {n} clip(s) selected. 已选择 {n} 个剪辑。 + Format and size 格式与大小 + Destination 目标位置 + Next to the original media, in a Proxies folder 位于原始媒体旁,存入 Proxies 文件夹 + Proxies are made in the background and attached when done. Export always uses full-resolution media. 代理文件在后台生成,完成后自动附加。导出始终使用完整分辨率媒体。 + Creating {n} proxy file(s)… 正在创建 {n} 个代理文件… + Project Manager 项目管理器 + Sequences 序列 + Resulting Project 结果项目 + Collect Files and Copy to New Location 收集文件并复制到新位置 + Consolidate and Transcode 整合与转码 + Options 选项 + Exclude Unused Clips 排除未使用的剪辑 + Include Handles: 包含余量: + Include Proxies 包含代理文件 + Include Preview Files 包含预览文件 + Destination Path 目标路径 + Disk space: original {original} · resulting {result} ({n} files) 磁盘空间:原始 {original} · 结果 {result}({n} 个文件) + Disk space: — 磁盘空间:— + Calculate 计算 + Project Manager: writing {path} 项目管理器:正在写入 {path} + Timeline: (no sequences) 时间轴:(无序列) + Browse 浏览 + Select audio clips, then click a preset to apply it. 选择音频剪辑,然后点击预设以应用。 + {name} (user) {name}(用户) + Select audio clips to edit them here 选择音频剪辑以在此处编辑 + Multiple Clips Selected 已选择多个剪辑 + The selected clips have different audio types. Assign one type to edit them together. 所选剪辑的音频类型不同。请指定一种类型以便一起编辑。 + Assign an audio type to the selection: 为所选对象指定音频类型: + Clear Audio Type 清除音频类型 + Preset: 预设: + (Custom) (自定义) + Preset name 预设名称 + Reduce Noise 降低噪声 + Reduce Rumble 降低隆隆声 + DeHum 去嗡声 + DeEss 去齿音 + Reduce Reverb 降低混响 + Dynamics 动态 + EQ 均衡器 + Enhance Speech 增强人声 + Low Tone 低频 + High Tone 高频 + Mix 混音 + Reverb 混响 + Stereo Width 立体声宽度 + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. 将音乐重新混音至目标时长功能尚不可用。请改为修剪剪辑并添加交叉淡化。 + Pan 声像 + Auto-Match 自动匹配 + Not matched yet 尚未匹配 + Matches each of the {n} clips on its own. 分别对 {n} 个剪辑各自进行匹配。 + Duck against: 避让对象: + Untagged 未标记 + Sensitivity 灵敏度 + Reduce By 降低幅度 + Fades 淡化 + Generate Keyframes 生成关键帧 + Clip Volume 剪辑音量 + (no clip selected) (未选择剪辑) + Master · {name} 主 · {name} + Add Lumetri Color to clip 向剪辑添加 Lumetri 颜色 + Input LUT 输入 LUT + Color 颜色 + Temperature 色温 + Tint 色调 + Saturation 饱和度 + Exposure 曝光 + Contrast 对比度 + Highlights 高光 + Shadows 阴影 + Whites 白色 + Blacks 黑色 + HDR HDR + HDR White HDR 白色 + HDR Specular HDR 高光 + Look 风格 + Look LUT 风格 LUT + Intensity 强度 + Adjustments 调整 + Faded Film 褪色胶片 + Sharpen 锐化 + Vibrance 自然饱和度 + RGB Curves RGB 曲线 + HDR Range HDR 范围 + Hue Saturation Curves 色相饱和度曲线 + Hue vs Sat 色相 vs 饱和度 + Hue vs Hue 色相 vs 色相 + Hue vs Luma 色相 vs 亮度 + Luma vs Sat 亮度 vs 饱和度 + Sat vs Sat 饱和度 vs 饱和度 + Midtones 中间调 + Key 键 + Hue 色相 + Hue Range 色相范围 + Saturation Min 饱和度下限 + Luma Min 亮度下限 + Luma Max 亮度上限 + Soften 柔化 + Show Mask 显示蒙版 + Off 关 + Color/Gray 彩色/灰度 + Color/Black 彩色/黑色 + White/Black 白色/黑色 + Refine 优化 + Denoise 降噪 + Blur 模糊 + Correction 校正 + Hue Shift 色相偏移 + Midpoint 中点 + Roundness 圆度 + Feather 羽化 + LUT LUT + Color Match 色彩匹配 + Reference 参考 + Face Detection (protect skin tones) 人脸检测(保护肤色) + Apply Match 应用匹配 + Create ellipse mask 创建椭圆蒙版 + Create 4-point polygon mask 创建 4 点多边形蒙版 + Free draw bezier 自由绘制贝塞尔 + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close 在节目监视器中点击以放置蒙版点(拖动可绘制曲线);点击第一个点或按 Enter 键闭合 + Delete Mask 删除蒙版 + Inverted 反相 + Drag: move · Alt-click: smooth/corner · Cmd-click: delete 拖动:移动 · Alt+点击:平滑/拐角 · Cmd+点击:删除 + Track selected mask backward 向后跟踪所选蒙版 + Track selected mask backward 1 frame 向后跟踪所选蒙版 1 帧 + Track selected mask forward 1 frame 向前跟踪所选蒙版 1 帧 + Track selected mask forward 向前跟踪所选蒙版 + Stop tracking 停止跟踪 + Tracking method: {method} 跟踪方法:{method} + Composite 合成 + Comparison 比较 + Video and Waveform 视频与波形 + System Default 系统默认 + Default location 默认位置 + Font family 字体族 + Label Colors 标签颜色 + Decoded frames held for the monitors and thumbnails: {used} of {budget}. 为监视器和缩略图保留的解码帧:{used} / {budget}。 + FilmCraft keeps the rest of the RAM free for other applications. FilmCraft 将剩余内存留给其他应用程序。 + not available in this session 在当前会话中不可用 + Currently: {loc} — {files} file(s), {size} 当前:{loc} — {files} 个文件,{size} + ({ch} output channels) ({ch} 个输出通道) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. 节目左声道 → 设备输出 {l},右声道 → 设备输出 {r}{dev}。超出设备通道的输出将回退到 1 和 2。 + Auto-saves go to: {dir} 自动保存位置:{dir} + Recovery copy: {dir} 恢复副本:{dir} + Last auto-save {a} · last recovery copy {j} 上次自动保存 {a} · 上次恢复副本 {j} + Last auto-save {a} 上次自动保存 {a} + Last recovery copy {j} 上次恢复副本 {j} + Remove Media Cache Files 删除媒体缓存文件 + Deleted {n} file(s), {size}. 已删除 {n} 个文件,{size}。 + Reset… 重置… + Source · {name} 源 · {name} + Reset Effect 重置效果 + Save Preset… 保存预设… + Custom Setup 自定义设置 + Edit… 编辑… + Show graphs 显示图表 + Toggle animation 切换动画 + Add/Remove Keyframe 添加/移除关键帧 + Temporal Interpolation 时间插值 + Linear 线性 + Bezier 贝塞尔 + Auto Bezier 自动贝塞尔 + Continuous Bezier 连续贝塞尔 + Ease In 缓入 + Ease Out 缓出 + Select a clip to grade 选择一个剪辑以进行调色 + Select a clip to see its properties 选择一个剪辑以查看其属性 + Anchor point 锚点 + Rotation 旋转 + Level 级别 + Speed {n}% 速度 {n}% + Value 值 + Velocity 速率 + Default 默认 + Custom ({n} points) 自定义({n} 个点) + Select a graphic clip, or use the Type tool (T) on the Program monitor 选择一个图形剪辑,或在节目监视器上使用文字工具(T) + Create new graphic 创建新图形 + New Text Layer 新建文本图层 + New Text 新建文本 + New Rectangle 新建矩形 + New Ellipse 新建椭圆 + Delete Layer 删除图层 + Select a layer to edit its properties. 选择一个图层以编辑其属性。 + Align Left 左对齐 + Align Center Horizontally 水平居中对齐 + Align Right 右对齐 + Align Top 顶对齐 + Align Center Vertically 垂直居中对齐 + Align Bottom 底对齐 + Font 字体 + Font Style 字体样式 + Font Size 字号 + Alignment 对齐方式 + Left Align Text 文本左对齐 + Center Align Text 文本居中对齐 + Right Align Text 文本右对齐 + Justify 两端对齐 + Tracking 字距 + Leading 行距 + Baseline Shift 基线偏移 + Text Box Width 文本框宽度 + Style 样式 + Faux Bold 伪粗体 + Faux Italic 伪斜体 + All Caps 全大写 + Small Caps 小型大写 + Underline 下划线 + OpenType OpenType + Kerning 字距调整 + Ligatures 连字 + Shape 形状 + Size 大小 + Corner Radius 圆角半径 + Polygon Sides 多边形边数 + Fill 填充 + Stroke 描边 + Stroke 2 描边 2 + Background 背景 + Opacity 不透明度 + Size 大小 + Corner Radius 圆角半径 + Shadow 阴影 + Angle 角度 + Distance 距离 + Blur 模糊 + Search templates 搜索模板 + All Categories 全部分类 + Install… 安装… + Install Motion Graphics Template (.fcgt) 安装动态图形模板(.fcgt) + No templates match. 无匹配的模板。 + {category} · My Template {category} · 我的模板 + Apply 应用 + Remove 移除 + {n} editable properties {n} 个可编辑属性 + Template: {name} 模板:{name} + Intro Duration 片头时长 + Outro Duration 片尾时长 + Roll 滚动 + Start Off Screen 起始离屏 + End Off Screen 结束离屏 + Preroll 前滚 + Postroll 后滚 + Video Frame 视频帧 + Pin To 固定到 + Pinned Edges 固定边 + FilmCraft Graphics Template FilmCraft 图形模板 + {n} Font {n} 字体 + {n} Size {n} 大小 + {n} Color {n} 颜色 + {n} Position {n} 位置 + Show {n} 显示 {n} + My Templates 我的模板 + Export As Motion Graphics Template 导出为动态图形模板 + Category: 分类: + Editable properties: 可编辑属性: + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. 已作为 FilmCraft 图形模板(.fcgt)保存到你的模板文件夹。 + Exported graphics template to {path} 已将图形模板导出到 {path} + Replace Fonts in Projects 替换项目中的字体 + Fonts used in the project: 项目中使用的字体: + {f} (missing) {f}(缺失) + Replace: 替换: + Replaced {n} font uses 已替换 {n} 处字体使用 + Show/Hide Effects and Sends 显示/隐藏效果与发送 + Audio Track Mixer menu 音频轨道混音器菜单 + Show/Hide Tracks… 显示/隐藏轨道… + Meter Input(s) Only 仅电平表输入 + Show/Hide Tracks 显示/隐藏轨道 + Show All 全部显示 + Post-Fader 后推子 + Remove Effect 移除效果 + Fill the slots above first 请先填满上方的插槽 + pre 前 + Pre-Fader 前推子 + Mute Send 静音发送 + Remove Send 移除发送 + New Submix 新建子混音 + Pan / balance (drag; double-click: centre) 声像 / 平衡(拖动;双击:居中) + Solo safe 独奏保护 + Enable track for recording 启用轨道录音 + Volume (drag; double-click: 0 dB) 音量(拖动;双击:0 dB) + 5.1 panner (drag the puck; double-click: front centre) 5.1 声像器(拖动推子;双击:前中置) + Center 中置 + Go to In Point 转到入点 + Go to Out Point 转到出点 + Play In to Out 播放入点到出点 + Record (voice-over on the record-enabled track) 录音(在已启用录音的轨道上录制画外音) + Write keyframes 写入关键帧 + Something went wrong while drawing the window. Your project is still open; save it to be safe. 绘制窗口时出现问题。你的项目仍处于打开状态;为安全起见请保存。 + Continue 继续 + Go Back 后退 + Go Forward 前进 + Up One Level 上移一级 + File Types Displayed 显示的文件类型 + List View 列表视图 + Thumbnail View 缩略图视图 + Ingest 摄取 + Open Ingest Settings 打开摄取设置 + Import as Image Sequence 作为图像序列导入 + {sel} of {total} items selected 已选择 {sel} / {total} 项 + {total} items {total} 项 + Cannot open {dir}: {e} 无法打开 {dir}:{e} + Favorites 收藏夹 + Local Drives 本地驱动器 + Network 网络 + Recent Directories 最近使用的目录 + (none) (无) + Remove from Favorites 从收藏夹移除 + Add to Favorites 添加到收藏夹 + Clear Recent Directories 清除最近使用的目录 + Open In Source Monitor 在源监视器中打开 + Reveal in Finder 在资源管理器中显示 + Edit Columns… 编辑列… + Hover Scrub 悬停擦洗 + New Media Browser Panel 新建媒体浏览器面板 + Create Folder For Imported Projects 为导入的项目创建文件夹 + Allow Duplicate Media During Project Import 允许在项目导入期间出现重复媒体 + Refresh 刷新 + Edit Columns 编辑列 + Guides 参考线 + (no audio) (无音频) + Generating waveform… 正在生成波形… + Reference {tc} 参考 {tc} + Set 设置 + Set the reference to the playhead 将参考设置为播放头 + Reference: next frame 参考:下一帧 + Reference: previous frame 参考:上一帧 + RGBA Channels RGBA 通道 + Add Guide 添加参考线 + Save Guides as Template 将参考线另存为模板 + Manage Guides 管理参考线 + Orientation: 方向: + Vertical 垂直 + Horizontal 水平 + Position: 位置: + No saved guide templates. 无已保存的参考线模板。 + Project: {name} 项目:{name} + Bin: {name} 素材箱:{name} + Up one level 上移一级 + Video Usage: {vu} Audio Usage: {au} 视频用量:{vu} 音频用量:{au} + Icon View 图标视图 + Freeform View 自由形式视图 + Sort Icons 排序图标 + Clear (Delete) 清除(删除) + New Item 新建项目 + New Bin 新建素材箱 + Find 查找 + Automate to Sequence 自动适配到序列 + Select items to clear 选择要清除的项目 + User Order 用户顺序 + Descending 降序 + Refresh Project 刷新项目 + Reveal Project in Finder… 在资源管理器中显示项目… + New Bin From Selection 从所选对象新建素材箱 + New Search Bin 新建搜索素材箱 + New Project Shortcut 新建项目快捷方式 + List 列表 + Icon 图标 + Freeform 自由形式 + Preview Area 预览区域 + Thumbnails 缩略图 + Thumbnails show effects applied 缩略图显示已应用的效果 + Thumbnail controls for all pointing devices 适用于所有指针设备的缩略图控件 + Refresh Sort Order 刷新排序顺序 + Metadata Display… 元数据显示… + Save Current View Preset 保存当前视图预设 + Restore View Preset 恢复视图预设 + Project View Preset {n} 项目视图预设 {n} + Freeform View Options… 自由形式视图选项… + Media missing 媒体缺失 + Unreadable 不可读 + Open in Source Monitor 在源监视器中打开 + Open in Timeline 在时间轴中打开 + New Sequence From Clip 从剪辑新建序列 + Set Poster Frame 设置海报帧 + Proxy 代理 + Open in Place 就地打开 + Open in New Tab 在新标签页打开 + Open in New Window 在新窗口打开 + {n} items {n} 项 + Stack 堆叠 + Unstack 取消堆叠 + Clip Size 剪辑大小 + Larger 更大 + Smaller 更小 + Align to Grid 对齐网格 + Reset to Grid 重置到网格 + Save Arrangement… 保存排列… + Restore Arrangement 恢复排列 + Delete Arrangement 删除排列 + Drop media here to create sequence. 将媒体拖到此处以创建序列。 + Double-click a sequence in the Project panel to open it here. 在项目面板中双击序列以在此处打开。 + Open Demo Project 打开演示项目 + Insert and overwrite sequences as nests or individual clips 以嵌套或单个剪辑方式插入和覆盖序列 + Snap in Timeline (S) 在时间轴中吸附(S) + Caption track options 字幕轨道选项 + Timeline Display Settings 时间轴显示设置 + Show Video Thumbnails 显示视频缩略图 + Show Audio Waveform 显示音频波形 + Expand All Tracks 展开所有轨道 + Minimize All Tracks 最小化所有轨道 + Unrendered: should play back in real time 未渲染:应能实时播放 + Unrendered: render to play back in real time 未渲染:需渲染才能实时播放 + Rendered preview 已渲染预览 + ✓ Enable ✓ 启用 + Unlink 取消链接 + (Default) (默认) + (RGB) Gamma (RGB)伽马 + (RGB) Input Black Level (RGB)输入黑电平 + (RGB) Input White Level (RGB)输入白电平 + (RGB) Output Black Level (RGB)输出黑电平 + (RGB) Output White Level (RGB)输出白电平 + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point 10 位黑点 + 10 Bit White Point 10 位白点 + 105mm Prime 105mm 定焦 + 29.97 fps Drop-Frame 29.97 fps 掉帧 + 29.97 fps Non Drop-Frame 29.97 fps 非掉帧 + 35mm Prime 35mm 定焦 + 3D Roll 3D 滚动 + 3D Rotate 3D 旋转 + 3D Spin 3D 自旋 + 3D Spinback 3D 反向自旋 + 4-Color Gradient 四色渐变 + 5.1 Media 5.1 媒体 + 5.1 Mixdown Type 5.1 混音下混类型 + 50-300mm Zoom 50-300mm 变焦 + 59.94 fps Drop-Frame 59.94 fps 掉帧 + 59.94 fps Non Drop-Frame 59.94 fps 非掉帧 + ASC CDL ASC CDL + Accessible color contrast 无障碍颜色对比度 + Action-Safe Margin 动作安全边距 + Active Monitor 活动监视器 + Add 添加 + Add tracks automatically when editing source clips onto the Timeline 将源剪辑编辑到时间轴时自动添加轨道 + Additional Scale 附加缩放 + Additive Dissolve 添加式溶解 + Additive Dissolve (Legacy) 添加式溶解(旧版) + Adjust 调整 + Advanced 高级 + Aggressive 激进 + Align and Transform 对齐与变换 + All lines 所有行 + Allow Selection tool to choose Roll and Ripple trims without modifier key 允许选择工具在无修饰键时选择滚动和波纹修剪 + Allow current tool to change trim type of previously selected edit point 允许当前工具更改先前所选编辑点的修剪类型 + Allow duplicate media during project import 允许项目导入期间出现重复媒体 + Alpha Adjust Alpha 调整 + Alpha Blurriness Alpha 模糊度 + Alpha Channel Alpha 通道 + Alpha Glow Alpha 辉光 + Always import embedded CEA-708 closed captions with wide aspect. 始终以宽幅宽高比导入内嵌的 CEA-708 隐藏字幕。 + Always override audio tags 始终覆盖音频标签 + Ambience 环境氛围 + Ambience Intensity 环境强度 + Ambience Light Color 环境光颜色 + Amount of Noise 噪点数量 + Amount to Decolor 去色程度 + Amount to Tint 着色程度 + Amplifier 放大器 + Amplify 放大 + Amplitude 振幅 + Amplitude and Compression 振幅与压缩 + Analog 模拟 + Analog Delay 模拟延迟 + Analyze all imported media to search for visuals or audio 分析所有导入的媒体以搜索画面或音频 + Anamorphic 变形宽银幕 + Anchor 锚点 + Anchor Point 锚点 + Angle 角度 + Animation 动画 + Anti-aliasing Quality 抗锯齿质量 + Anti-flicker Filter 抗闪烁滤镜 + Antialiasing for Best Quality 最佳质量抗锯齿 + Asymmetry 不对称 + At Startup 启动时 + At playback end, return to beginning when restarting playback 播放结束时,重新播放返回到开头 + Attempt to force hardware to document sample rate 尝试强制硬件使用文档采样率 + Audio Codec 音频编解码器 + Audio Duration 音频持续时间 + Audio Effects 音频效果 + Audio Hardware 音频硬件 + Audio In Point 音频入点 + Audio Info 音频信息 + Audio Out Point 音频出点 + Audio Samples 音频采样 + Audio Time Units 音频时间单位 + Audio Transition Default Duration 音频过渡默认时长 + Audio Transitions 音频过渡 + Audio Usage 音频用量 + Auto 自动 + Auto Align 自动对齐 + Auto Reframe 自动重构图 + Auto Save 自动保存 + Auto Save also saves the current project(s) 自动保存同时保存当前项目 + Auto-tag audio types in the timeline 在时间轴中自动标记音频类型 + Auto-transcribe all imported clips 自动转录所有导入的剪辑 + Auto-transcribe only clips in sequences 仅自动转录序列中的剪辑 + Automatch Time 自动匹配时间 + Automatic Click Remover 自动咔嗒声消除 + Automatically Hide Dependent Clips 自动隐藏依赖剪辑 + Automatically Save Every 自动保存间隔 + Automatically delete cache files older than 自动删除早于以下时间的缓存文件 + Automatically delete oldest cache files when cache exceeds 当缓存超过以下大小时自动删除最旧的缓存文件 + Automatically refresh growing files 自动刷新增长中的文件 + Automatically resume playback for growing files in Source Monitor 自动恢复源监视器中增长文件的播放 + Automatically save projects 自动保存项目 + Automatically transcribe clips 自动转录剪辑 + Automation Keyframe Optimization 自动化关键帧优化 + Background Color 背景颜色 + Background Corner Radius 背景圆角半径 + Background Opacity 背景不透明度 + Background Size 背景大小 + Background fill 背景填充 + Balance 平衡 + Balanced Voice 均衡人声 + Band 1 频段 1 + Band 2 频段 2 + Band 3 频段 3 + Band 4 频段 4 + Band 5 频段 5 + Band Pass 带通 + Band Slide 频段滑移 + Band Stop 带阻 + Bandpass 带通 + Barn Doors 束光门 + Basic 基本 + Basic 3D 基本 3D + Basic Correction 基本校正 + Bass 低音 + Behind 后方 + Bessel 贝塞尔 + Bevel Alpha 斜角 Alpha + Bevel join 斜角连接 + Binauralizer - Ambisonics 双耳化 - 全景声 + Bins 素材箱 + Black 黑 + Black & White 黑白 + Black Input Level 黑色输入电平 + Black and white 黑白 + Black and white with crushed blacks and strong contrast 压黑并高对比的黑白 + Bleach Bypass 跳过漂白 + Bleach-bypass style: desaturated with hard contrast 跳过漂白风格:低饱和、硬对比 + Blend 混合 + Blend Mode 混合模式 + Blend With Original 与原始混合 + Blending Mode 混合模式 + Block Dissolve 块状溶解 + Block Height 块高度 + Block Motion 块运动 + Block Size 块大小 + Block Width 块宽度 + Blocks 块 + Blue Blurriness 蓝色模糊度 + Blue Curve 蓝色曲线 + Blue Offset 蓝色偏移 + Blue Power 蓝色强力 + Blue Slope 蓝色斜率 + Blue-Blue 蓝-蓝 + Blue-Const 蓝-常数 + Blue-Green 蓝-绿 + Blue-Red 蓝-红 + Blur & Sharpen 模糊与锐化 + Blur Amount 模糊量 + Blur Angle 模糊角度 + Blur Dimensions 模糊维度 + Blur Dissolve 模糊溶解 + Blur Layer 模糊图层 + Blur Length 模糊长度 + Blurriness 模糊度 + Bokeh Blur 散景模糊 + Boost 提升 + Border 边框 + Border Color 边框颜色 + Border Width 边框宽度 + Borders 边框 + Bottom Center 底部居中 + Bottom Left 左下 + Bottom Right 右下 + Bounce 弹跳 + Branching 分支 + Bright 明亮 + Bright Air 明亮空气 + Bright Studio 明亮影棚 + Brightness & Contrast 亮度与对比度 + British Stack 英式堆叠 + Brush Hardness 笔刷硬度 + Brush Opacity 笔刷不透明度 + Brush Position 笔刷位置 + Brush Size 笔刷大小 + Brush Spacing (secs) 笔刷间距(秒) + Brush Strokes 笔刷笔触 + Bubbles 气泡 + Bulge 凸出 + Bulge Smoother 凸出平滑 + Bump Height 凹凸高度 + Burn Alpha 烧录 Alpha + Burn Chroma 烧录色度 + Burn Color 烧录颜色 + Butt cap 平头端 + Butterworth 巴特沃斯 + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use 缓存分析结果以供复用 + Camera Angle 摄像机角度 + Camera Blur 摄像机模糊 + Camera Shake 摄像机抖动 + Capitalisation 大小写 + Cathedral 大教堂 + Cell Pattern 单元图案 + Center Channel Pan 中置声道声像 + Center Gradient 中心渐变 + Center Input 中心输入 + Center Out 中心输出 + Center Point (Latitude) 中心点(纬度) + Center Point (Longitude) 中心点(经度) + Center Split 中心分割 + Center of Sphere 球体中心 + Cents 音分 + Change to Color 更改为颜色 + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. 每次编辑后,更改会在后台稍后复制。如果 FilmCraft 意外退出,下次启动时会提示恢复。 + Channel 通道 + Channel Blur 通道模糊 + Channel Mix 通道混合 + Channel Mixer 通道混合器 + Chaos 混沌 + Chebyshev 切比雪夫 + Checker Wipe 棋盘擦除 + Checkerboard 棋盘 + Chinese 中文 + Choke 阻塞 + Chorus 合唱 + Chorus/Flanger 合唱/镶边 + Chroma 色度 + Chroma Leak 色度泄漏 + Chromatic Aberration (Blue) 色差(蓝) + Chromatic Aberration (Green) 色差(绿) + Chromatic Aberration (Red) 色差(红) + Cineon Converter Cineon 转换器 + Circle 圆形 + Clarity 清晰度 + Clean Combo 干净组合 + Clean Up Noisy Dialogue 清理嘈杂对白 + Clear Muddiness 清除浑浊 + Client 客户端 + Clip 剪辑 + Clip Level 剪辑电平 + Clipping 削波 + Clock Wipe 时钟擦除 + Clock Wipe (Legacy) 时钟擦除(旧版) + Clockwise 顺时针 + Clone 克隆 + Closed Captions 隐藏字幕 + Color & Alpha 颜色与 Alpha + Color 1 颜色 1 + Color 2 颜色 2 + Color 3 颜色 3 + Color 4 颜色 4 + Color Balance 色彩平衡 + Color Burn 颜色加深 + Color Channel 颜色通道 + Color Correction 色彩校正 + Color Distortion 色彩失真 + Color Dodge 颜色减淡 + Color Emboss 彩色浮雕 + Color Key 颜色键 + Color Pass 色彩保留 + Color Replace 颜色替换 + Color Space 色彩空间 + Color Theme 配色主题 + Color To Leave 保留颜色 + Color Tolerance 颜色容差 + Color Wheels & Match 色轮与匹配 + Colorize 着色 + Columns 列 + Comment 注释 + Complexity 复杂度 + Composite In Back 在后方合成 + Composite In Front 在前方合成 + Composite Using 使用以下方式合成 + Composite on Original 在原始上合成 + Compound Blur 复合模糊 + Compression Before Clipping 削波前压缩 + Constant Gain 恒定增益 + Constant Power 恒定功率 + Conversion Type 转换类型 + Convolution Reverb 卷积混响 + Cool Blue 冷蓝 + Cool, desaturated and punchy 冷调、低饱和、鲜明 + Core Width 核心宽度 + Corner 拐角 + Corner Pin 四角定位 + Count 计数 + Counter-clockwise 逆时针 + Crash Recovery 崩溃恢复 + Crawl Left 向左滚动 + Crawl Right 向右滚动 + Create folder for imported projects 为导入的项目创建文件夹 + Creative 创意 + Crop 裁剪 + Crop Less <-> Smooth More 少裁剪 <-> 多平滑 + Cross Displacement 交叉置换 + Cross Dissolve 交叉溶解 + Cross Dissolve (Legacy) 交叉溶解(旧版) + Cross Zoom 交叉缩放 + Cross Zoom (Legacy) 交叉缩放(旧版) + Crossfade 交叉淡化 + Crossovers 交叉 + Crystallize 晶格化 + Crystals 晶体 + Cube Spin 立方体自旋 + Curl Radius 卷曲半径 + Current Time 当前时间 + Cursor 光标 + Curvature 曲率 + Curve 曲线 + Curves 曲线 + Custom 自定义 + Custom Color 自定义颜色 + Cutoff 截止频率 + Cyan-teal shadows and warm highlights with a little more contrast 青蓝色阴影与暖色高光,略增对比 + Cycle (in Revolutions) 循环(以转数计) + Cycle Evolution 循环演化 + Damping 阻尼 + Damping HF 高频阻尼 + Damping LF 低频阻尼 + Dark 暗 + Darken 变暗 + Darker Color 深色 + Date Created 创建日期 + Date Modified 修改日期 + Day for night: darker, cooler and less saturated 夜景效果:更暗、更冷、更低饱和 + DeEsser 去齿音器 + DeHummer 去嗡声器 + DeNoise 降噪 + DeReverb 去混响 + Decay 衰减 + Decay Time (RT60) 衰减时间(RT60) + Default Audio Tracks 默认音频轨道 + Default Emoji font 默认表情符号字体 + Default Media Scaling 默认媒体缩放 + Default Output 默认输出 + Default closed caption font 默认隐藏字幕字体 + Default language 默认语言 + Default paragraph direction 默认段落方向 + Default subtitle and text font 默认字幕与文本字体 + Delay 延迟 + Delay 1 延迟 1 + Delay 2 延迟 2 + Delay 3 延迟 3 + Delay 4 延迟 4 + Delay and Echo 延迟与回声 + Delete files older than 删除早于以下时间的文件 + Delete oldest files when the cache exceeds 当缓存超过以下大小时删除最旧的文件 + Delete… 删除… + Depth 深度 + Desaturate 去饱和 + Description 描述 + Detail Amplitude 细节振幅 + Detail Level 细节级别 + Detailed Analysis 详细分析 + Device Class 设备类别 + Dialogue 对白 + Diamond 菱形 + Difference 差值 + Dimensions 尺寸 + Dimmed 暗淡 + Dip to Black 叠化到黑场 + Dip to Black (Legacy) 叠化到黑场(旧版) + Dip to Color 叠化到颜色 + Dip to White 叠化到白场 + Dip to White (Legacy) 叠化到白场(旧版) + Direction 方向 + Directional 方向性 + Directional Blur 方向模糊 + Directional Blur (Legacy) 方向模糊(旧版) + Disable video output when in the background 后台运行时禁用视频输出 + Disperse 分散 + Displacement 置换 + Display Color Management 显示色彩管理 + Display Color Settings 显示颜色设置 + Display out of sync indicators for unlinked clips 为未链接剪辑显示失步指示器 + Dissolve 溶解 + Dissolve Blocks 溶解块 + Distance 距离 + Distance to Image 到图像的距离 + Distort 扭曲 + Distortion 畸变 + Distortion Complexity 畸变复杂度 + Distortion Rate 畸变速率 + Distortion Type 畸变类型 + Divide 分割 + Do not delete cache files automatically 不自动删除缓存文件 + Don't label speakers 不标记说话人 + Double-click 双击 + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) 以降低的播放分辨率进行草稿解码(H.264:更快,部分帧滤波较少) + Drive 驱动器 + Drop Shadow 投影 + Dry 干声 + Dry Out 干声输出 + Dry Up a Roomy Voice 收紧松散人声 + Duck Deep Under Dialogue 深度压低至对白之下 + Duck Under Dialogue 压低至对白之下 + Duck Under Voice and Effects 压低至人声与效果之下 + Ducking 自动避让 + Dutch 荷兰语 + Dynamic Link 动态链接 + Dynamics Processing 动态处理 + Early Reflections 早期反射 + Earthquake 地震 + Ease In and Out 缓入缓出 + Easing 缓动 + Echo 回声 + Echo Glow 回声辉光 + Echo Operator 回声运算 + Echo Time (seconds) 回声时间(秒) + Echoes 回声 + Edge Color 边缘颜色 + Edge Feather 边缘羽化 + Edge Glow 边缘辉光 + Edge Sharpness 边缘锐度 + Edge Softness 边缘柔和度 + Edge Thickness 边缘厚度 + Edge Thin 边缘变细 + Edge Threshold 边缘阈值 + Edge Type 边缘类型 + Edge Width 边缘宽度 + Elliptical 椭圆 + Emboss 浮雕 + Enable HSL Secondary 启用 HSL 辅助 + Enable Mercury Transmit 启用 Mercury 传输 + Enable ProRes hardware accelerated encoding, if available 如可用,启用 ProRes 硬件加速编码 + Enable clip and XMP metadata linking 启用剪辑与 XMP 元数据链接 + Enable developer mode 启用开发者模式 + Enable hardware accelerated decoding (requires restart) 启用硬件加速解码(需要重启) + Enable language auto-detection 启用语言自动检测 + Enable proxies 启用代理 + End Color 结束颜色 + End Point 结束点 + End of Ramp 渐变结束 + Error 错误 + Evolution 演化 + Evolution Options 演化选项 + Exciter Amount 激励器量 + Exciter Mode 激励器模式 + Exclusion 排除 + Exponential Fade 指数淡化 + Extended Dynamic Range Monitoring 扩展动态范围监视 + FFT Filter FFT 滤波器 + Fade 淡化 + Fade Out 淡出 + Faded Matte 褪色遮罩 + Falloff 衰减 + Far Away 遥远 + Fast 快 + Faster Motion 更快速运动 + Feedback 反馈 + Feedback 1 反馈 1 + Feedback 2 反馈 2 + Feedback 3 反馈 3 + Feedback 4 反馈 4 + Feet + Frames 16mm 英尺 + 帧 16mm + Feet + Frames 35mm 英尺 + 帧 35mm + Female 女声 + File Size 文件大小 + Fill Background 填充背景 + Fill Color 填充颜色 + Fill Left with Right 用右声道填充左声道 + Fill Point 填充点 + Fill Right with Left 用左声道填充右声道 + Fill Selector 填充选择器 + Film Dissolve 胶片溶解 + Film Dissolve (Legacy) 胶片溶解(旧版) + Film Roll 胶片滚动 + Filter 滤镜 + Filter Frequency 滤镜频率 + Filter Resonance 滤镜共振 + Filter and EQ 滤镜与均衡 + Final Delay 最终延迟 + Find Edges 查找边缘 + Flanger 镶边 + Flare 光晕 + Flare Brightness 光晕亮度 + Flare Center 光晕中心 + Flare Color 光晕颜色 + Flash 闪光 + Flicker 闪烁 + Flickers 闪烁 + Flip Motion 翻转运动 + Flip Over 翻转 + Float 浮动 + Focus 焦点 + Focus Blur 焦点模糊 + Focus Point 焦点 + Focus Shape 焦点形状 + Focus Size 焦点大小 + Fold Motion 折叠运动 + Foldback 折叠 + For color management parameters, go to the Settings tab in the Lumetri Color panel. 有关色彩管理参数,请前往 Lumetri 颜色面板中的“设置”选项卡。 + Format 格式 + Four Scopes (RGB, 8 bit) 四视图示波器(RGB,8 位) + Four Scopes (YUV, float, no clamp) 四视图示波器(YUV,浮点,无钳制) + Fractal Influence 分形影响 + Fractal Type 分形类型 + Frame 帧 + Frame Color 帧颜色 + Frame Count 帧数 + Frame Layout 帧布局 + Frame Rate 帧速率 + Frame Width 帧宽度 + Frame cache for monitors and thumbnails 监视器与缩略图帧缓存 + Frames 帧 + Framing 取景 + French 法语 + Frequency 频率 + From 从 + From East 从东 + From North 北向 + From South 南向 + From West 西向 + From the Left 从左 + From the Right 从右 + Front + LFE 前置 + LFE + Front + Rear 前置 + 后置 + Front + Rear + LFE 前置 + 后置 + LFE + Front Only 仅前置 + Fuzz 毛糙 + GB GB + Gamma 伽马 + Gamma Correction 伽马校正 + Gamut Warning 色域警告 + Gamut Warning Color 色域警告颜色 + Gap 间隙 + Gap Color 间隙颜色 + Gaussian Blur 高斯模糊 + Gaussian Blur (Legacy) 高斯模糊(旧版) + General 常规 + Generate 生成 + Generate Timecode 生成时间码 + Generate waveforms and conform audio using multithreading 使用多线程生成波形并校准音频 + Generate waveforms automatically during import 导入时自动生成波形 + German 德语 + Ghosts 残影 + Glass 玻璃 + Glint 闪烁光斑 + Glitch 故障 + Glow 辉光 + Glow Brightness 辉光亮度 + Glow Color 辉光颜色 + Glow Only 仅辉光 + Glow Radius 辉光半径 + Glow Saturation 辉光饱和度 + Glow Width 辉光宽度 + Golden Hour 黄金时刻 + Good 好 + Gradient 渐变 + Gradient End 渐变结束 + Gradient Layer 渐变图层 + Gradient Placement 渐变放置 + Gradient Scatter 渐变散布 + Gradient Shape 渐变形状 + Gradient Start 渐变开始 + Gradient Wipe 渐变擦除 + Gradient Wipe (Legacy) 渐变擦除(旧版) + Grain Size 颗粒大小 + Graphic Equalizer (10 Bands) 图形均衡器(10 段) + Graphic Equalizer (20 Bands) 图形均衡器(20 段) + Graphic Equalizer (30 Bands) 图形均衡器(30 段) + Graphics 图形 + Green Blurriness 绿色模糊度 + Green Curve 绿色曲线 + Green Offset 绿色偏移 + Green Power 绿色强力 + Green Slope 绿色斜率 + Green-Blue 绿-蓝 + Green-Const 绿-常数 + Green-Green 绿-绿 + Green-Red 绿-红 + Grid 网格 + Grow 增长 + Growing Files 增长中的文件 + Grunge 做旧 + Grunge & Distort 做旧与畸变 + GuitarSuite 吉他套件 + HDR Graphics White (Nits) HDR 图形白色(尼特) + HLS Vectorscope + Histogram HLS 矢量示波器 + 直方图 + HSL Secondary HSL 辅助 + Hard 硬 + Hard Clip 硬削波 + Hard Light 硬光 + Hard Limiter 硬限制器 + Hard Mix 硬混合 + Hardware decoding 硬件解码 + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. 硬件解码:自动模式对支持的流使用系统视频解码器(macOS 上为 VideoToolbox,Windows 上为 Media Foundation,Linux 上对 H.264 和 HEVC 使用 VA-API),其余情况或硬件失败时回退到 FilmCraft 自带的解码器。已打开的媒体会保留其解码器,直到重新打开。 + Head Size 头部大小 + Heart 心形 + Height 高度 + Hexagon 六边形 + Hide Back Face 隐藏背面 + High 高 + High Cutoff 高截止 + High Frequency Cut 高频切除 + High Frequency Damping 高频阻尼 + High Pass 高通 + High Shelf 高频搁架 + High Shelf Gain 高频搁架增益 + High contrast with held highlights and deep shadows 高对比,保留高光与深邃阴影 + Highlight 高光 + Highlight Blue Balance 高光蓝平衡 + Highlight Brightness 高光亮度 + Highlight Color 高光颜色 + Highlight Green Balance 高光绿平衡 + Highlight Red Balance 高光红平衡 + Highlight Rolloff 高光衰减 + Highlight Threshold 高光阈值 + Highlight Tint 高光色调 + Highlights Lightness 高光亮度 + Highpass 高通 + Hindi 印地语 + Hindi digits 印地数字 + Horizontal 16:9 水平 16:9 + Horizontal Alignment 水平对齐 + Horizontal Blocks 水平块 + Horizontal Decentering 水平偏移居中 + Horizontal Displacement 水平置换 + Horizontal Flip 水平翻转 + Horizontal Margin 水平边距 + Horizontal and Vertical 水平与垂直 + Hue Center 色相中心 + Hue Tolerance 色相容差 + I/O Buffer Size I/O 缓冲区大小 + Ignore Alpha 忽略 Alpha + Image Control 图像控制 + Immersive Video 沉浸式视频 + Import numbered stills as image sequences 将带序号的静帧作为图像序列导入 + Impulse 脉冲 + In a Room 在室内 + In the Media Cache 在媒体缓存中 + In the project 在项目中 + In/Out Duration 入点/出点时长 + Include captions on import 导入时包含字幕 + Incoming 入向 + Indeterminate Media Timebase 不确定的媒体时基 + Initial Delay 初始延迟 + Inner 内部 + Inner Radius 内半径 + Input Boost 输入提升 + Inset 内缩 + Inside 内部 + Inside Color 内部颜色 + Inter 中间 + Interior Room Tone 室内环境音 + Internal Black Point 内部黑点 + Internal White Point 内部白点 + Interpolation 插值 + Intersect 相交 + Inverse Falloff 反向衰减 + Invert 反相 + Invert Alpha 反相 Alpha + Invert Blur 反相模糊 + Invert Fill 反相填充 + Invert Gradient 反相渐变 + Invert Left 反相左 + Invert Right 反相右 + Iris 光圈 + Iris Box 光圈方盒 + Iris Cross 光圈交叉 + Iris Diamond 光圈菱形 + Iris Radius 光圈半径 + Iris Rotation 光圈旋转 + Iris Round 光圈圆 + Iris Shape 光圈形状 + Italian 意大利语 + Japanese 日语 + JetBrains Mono JetBrains Mono + Join Through Edits 连接贯通编辑 + Kaleidoscope 万花筒 + Keep a recovery copy of unsaved changes 保留未保存更改的恢复副本 + Key Color 键颜色 + Keying 键控 + Korean 韩语 + Label Defaults 标签默认值 + Label speakers 标记说话人 + Labels 标签 + Large Hall 大厅 + Large Trim Offset 大修剪偏移 + Large Volume Adjustment 大音量调整 + Latch 闩锁 + Layer Name 图层名称 + Layers 图层 + Leave Color 保留颜色 + Left - Left 左 - 左 + Left - Right 左 - 右 + Left channel to device output 左声道到设备输出 + Left to Right 从左到右 + Left to right 从左到右 + Legacy 旧版 + Length 长度 + Lens Blur 镜头模糊 + Lens Distortion 镜头畸变 + Lens Flare 镜头光晕 + Lens Type 镜头类型 + Level 1 级别 1 + Level 2 级别 2 + Level 3 级别 3 + Level 4 级别 4 + Levels 色阶 + Lifted blacks, softer contrast, muted colour 提升黑位、柔和对比、低饱和 + Light 1 光 1 + Light 2 光 2 + Light 3 光 3 + Light 4 光 4 + Light 5 光 5 + Light Angle 光角度 + Light Color 光颜色 + Light Intensity 光强度 + Light Leak 漏光 + Light Leaks 漏光 + Light Sweep 光束扫描 + Light Type 光类型 + Lighten 变亮 + Lighter Color 浅色 + Lighting Effects 光照效果 + Lightning 闪电 + Lights & Blurs 灯光与模糊 + Lights & Glows 灯光与辉光 + Line Thickness 线条粗细 + Linear Burn 线性加深 + Linear Dodge (Add) 线性减淡(添加) + Linear Light 线性光 + Linear Ramp 线性渐变 + Linear Wipe 线性擦除 + Linear Wipe (Legacy) 线性擦除(旧版) + Linear keyframe thinning 线性关键帧抽稀 + Linear to Log 线性转 Log + Liquid Distortion 液体畸变 + Local Projects 本地项目 + Location 位置 + Log Note 日志备注 + Log to Linear Log 转线性 + Log to Log Log 转 Log + Logo Cutout 标志抠图 + Long Shadow 长阴影 + Look-Ahead Time 预读时间 + Loudness 响度 + Loudness Maximizer 响度最大化 + Loudness Meter 响度表 + Louver 百叶 + Low 低 + Low Frequency Cut 低频切除 + Low Pass 低通 + Low Shelf 低频搁架 + Low Shelf Gain 低频搁架增益 + Lower Left 左下 + Lower Right 右下 + Lowpass 低通 + Luma 亮度 + Luma Curve 亮度曲线 + Luma Fade 亮度淡化 + Luma Key 亮度键 + Luma Source 亮度源 + Luma and Chroma 亮度与色度 + Lumetri Presets Lumetri 预设 + Luminance 亮度 + Luminance Threshold 亮度阈值 + Luminosity 光度 + MB MB + Magenta 品红 + Magnify 放大 + Magnify (Legacy) 放大(旧版) + Maintain pitch while shuttling 拖动播放时保持音高 + Major Radius 主半径 + Male 男声 + Mango 芒果色 + Map Black To 黑场映射至 + Map White To 白场映射至 + Maps full-range levels into 16–235 (video range) with a luma curve 使用亮度曲线将全范围电平映射到 16–235(视频范围) + Mask Only 仅蒙版 + Master Amplitude 主振幅 + Mastering 母带处理 + Match frame sets in point 匹配帧设置入点 + Matte 遮罩 + Matte Alpha 遮罩 Alpha + Matte Cleanup 遮罩清理 + Matte Generation 遮罩生成 + Matte Luma 遮罩亮度 + Maximum 最大 + Maximum Amplitude 最大振幅 + Maximum Block Size 最大块大小 + Maximum Blur 最大模糊 + Maximum Project Versions 最大项目版本数 + Maximum Reduction 最大缩减 + Maximum Scale 最大缩放 + Media 媒体 + Media Analysis 媒体分析 + Media Analysis & Transcription 媒体分析与转录 + Media Cache 媒体缓存 + Media Cache Database 媒体缓存数据库 + Media Cache Files 媒体缓存文件 + Media Cache Management 媒体缓存管理 + Media Duration 媒体时长 + Media End 媒体结束 + Media Time 媒体时间 + Media Timecode 媒体时间码 + Media Type 媒体类型 + Media analysis and transcriptions will be cached in the Media Cache folder. Reanalysis won't be required if the media is imported into another project on this computer. 媒体分析与转录将缓存在媒体缓存文件夹中。若媒体在本机其他项目中再次导入,无需重新分析。 + Median 中位数 + Medium Room 中等房间 + Memory 内存 + Metadata & Timecode Burn-in 元数据与时间码叠加 + Method 方法 + Metrics Kerning 度量字距 + Mid 中间 + Mid Point 中点 + Midtone Blue Balance 中间调蓝平衡 + Midtone Green Balance 中间调绿平衡 + Midtone Red Balance 中间调红平衡 + Midtones Lightness 中间调亮度 + Minimum 最小值 + Minimum time 最短时间 + Minimum time interval thinning 最小时间间隔抽稀 + Minor Radius 次半径 + Mirror 镜像 + Mirror Alternate 交替镜像 + Missing font replacement 缺失字体替换 + Miter join 斜接连接 + Miter limit 斜接限制 + Mixed Crystals 混合晶体 + Mode 模式 + Modern High Gain 现代高增益 + Modulation 调制 + Modulation Rate 调制速率 + Mono Media 单声道媒体 + Monochrome 单色 + Monochrome toned cyan-blue 青蓝色单色调 + Monochrome toned warm brown 暖棕色单色调 + Monoscopic 单视 + Monospace only 仅等宽 + More colour in the less saturated areas 在饱和度较低区域增加色彩 + Morph Cut 变形剪切 + Mosaic 马赛克 + Mosaic (Legacy) 马赛克(旧版) + Motion Blur 运动模糊 + Motion Camera 运动相机 + Motion Preset 运动预设 + Motion Tween 运动补间 + Move 移动 + Movie 影片 + Movie (audio and video) 影片(音频与视频) + Multiband Compressor 多频段压缩器 + Multichannel Mono Media 多声道单声道媒体 + Multiply 正片叠底 + Multitap Delay 多抽头延迟 + Music 音乐 + Mute input during timeline recording 时间轴录制时静音输入 + Name 名称 + Narrow 窄 + Neon Color 霓虹色 + Neon Wipe 霓虹擦除 + Next to original media files 位于原始媒体文件旁 + Night 夜景 + No Motion 无运动 + No Scroll 不滚动 + No plugins are installed. Changes will take effect the next time you start FilmCraft. 未安装任何插件。更改将在下次启动 FilmCraft 时生效。 + Noise 噪点 + Noise & Grain 噪点与颗粒 + Noise (Legacy) 噪点(旧版) + Noise Level 噪点级别 + Noise Reduction/Restoration 降噪/修复 + Noise Type 噪点类型 + Non-Additive Dissolve 非叠加溶解 + Normal 正常 + Notch 1 陷波 1 + Notch 2 陷波 2 + Notch 3 陷波 3 + Notch 4 陷波 4 + Notch 5 陷波 5 + Notch 6 陷波 6 + Notch Filter 陷波滤波器 + Notch Width 陷波宽度 + Number Of Echoes 回声次数 + Number of Harmonics 谐波数量 + Obsolete 已废弃 + Octagon 八边形 + Offset 偏移 + Offset (Turbulence) 偏移(湍流) + Offset From 偏移起点 + Offset To 偏移终点 + Omni 全向 + On 开 + On Original Image 在原始图像上 + On Transparent 在透明背景上 + Open Air 露天 + Open Most Recent 打开最近的文件 + Open in new window 在新窗口中打开 + Open in place 就地打开 + Open new tab 打开新标签页 + Opens up the shadows 提亮阴影 + Order 顺序 + Orientation 方向 + Outer 外侧 + Outgoing 出向 + Output 输出 + Output Mapping 输出映射 + Outside 外部 + Outside Color 外部颜色 + Overflow 溢出 + Overlay 叠加 + Overshoot 过冲 + Page Peel 卷页 + Page Scroll 页面滚动 + Page Turn 翻页 + Paint Bucket 油漆桶 + Paint On Black 在黑色上绘制 + Paint On Original Image 在原始图像上绘制 + Paint On Transparent 在透明背景上绘制 + Paint On White 在白色上绘制 + Paint Style 绘制样式 + Paint Surface 绘制表面 + Pan (Y Axis) 平移(Y 轴) + Panel Wipe 面板擦除 + Panels 面板 + Panner - Ambisonics 声像器 - 全景声 + Parade 并列 + Parade (RGB) 并列(RGB) + Parametric Equalizer 参数均衡器 + Passband Ripple 通带纹波 + Path 路径 + Path Points 路径点 + Pause Media Encoder queue during playback 播放时暂停 Media Encoder 队列 + Peak Frequency 峰值频率 + Peak Gain 峰值增益 + Pedestal 基准电平 + Pentagon 五边形 + Per line 每行 + Percent Blur 模糊百分比 + Perspective 透视 + Phase Difference 相位差 + Phaser 移相器 + Phone Call 电话通话 + Phosphor 荧光 + Phosphor Color 荧光颜色 + Photocopy 复印 + Photocopy Color 复印颜色 + Pillow 枕形 + Pin All 全部固定 + Pin Horizontal 水平固定 + Pin Light 点光 + Pin Vertical 垂直固定 + Pinning 固定 + Pitch Shifter 变调器 + Plate 底板 + Plateau Wipe 台地擦除 + Plateaus 台地 + Plates 底板 + Play after rendering previews 渲染预览后播放 + Play audio while scrubbing in Source and Program Monitors 在源监视器与节目监视器中拖动时播放音频 + Playback 播放 + Playhead position determines trim monitor loop playback 播放头位置决定修剪监视器循环播放 + Plugins 插件 + Podcast Voice 播客人声 + Point 1 点 1 + Point 1 Color 点 1 颜色 + Point 1 Latitude 点 1 纬度 + Point 1 Longitude 点 1 经度 + Point 2 点 2 + Point 2 Color 点 2 颜色 + Point 2 Latitude 点 2 纬度 + Point 2 Longitude 点 2 经度 + Point 3 点 3 + Point 3 Color 点 3 颜色 + Point 3 Latitude 点 3 纬度 + Point 3 Longitude 点 3 经度 + Point 4 点 4 + Point 5 点 5 + Point 6 点 6 + Point 7 点 7 + Point 8 点 8 + Points 点 + Policy 策略 + Pop Motion 弹出运动 + Portuguese 葡萄牙语 + Position & Rotation 位置与旋转 + Position Amount 位置量 + Position, Scale & Rotation 位置、缩放与旋转 + Position, Scale, Rotation 位置、缩放、旋转 + Posterize 色调分离 + Posterize Time 时间色调分离 + Pre-Delay 预延迟 + Prefix 前缀 + Presence Lift 临场感提升 + Preserve Luminosity 保留亮度 + Preserve Scale 保留缩放 + ProcAmp 处理放大器 + Projecting cap 凸出端帽 + Projects 项目 + Pull Motion 拉出运动 + Pulls highlights and whites down 压低高光与白色 + Push 推 + Push (Legacy) 推(旧版) + Q Q 值 + RAM reserved for other applications 为其他应用程序预留的内存 + RGB RGB + RGB Split RGB 分离 + RGB-White RGB-白 + Radial 径向 + Radial Blur 径向模糊 + Radial Ramp 径向渐变 + Radial Wipe 径向擦除 + Radial Wipe (Legacy) 径向擦除(旧版) + Radio Announcer 电台播音员 + Radio Voice 广播人声 + Radius 半径 + Ramp 渐变 + Ramp Shape 渐变形状 + Random 随机 + Random Seed 随机种子 + Range 范围 + Ray 光线 + Ray Color 光线颜色 + Ray Length 光线长度 + Rays 光线 + Rays Only 仅光线 + Read 读取 + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness 红色模糊度 + Red Curve 红色曲线 + Red Offset 红色偏移 + Red Power 红色强力 + Red Slope 红色斜率 + Red-Blue 红-蓝 + Red-Const 红-常数 + Red-Green 红-绿 + Red-Red 红-红 + Reduce Axis 减少轴 + Reduce Interlace Flicker 减少隔行闪烁 + Reflected 反射的 + Reflection Angle 反射角度 + Reflection Center 反射中心 + Refraction 折射 + Reframe Offset 重构画面偏移 + Reframe Zoom 重构画面缩放 + Refresh growing Files Every 每隔以下时间刷新增长中的文件 + Relaxed 放松 + Release Time 释放时间 + Relief 浮雕 + Remaining 剩余 + Remove Fringe 去除边缘色 + Render Edit in Audition files to: 将 Audition 文件中的编辑渲染至: + Render audio when rendering video 渲染视频时同时渲染音频 + Repair 修复 + Repeat Edge Pixels 重复边缘像素 + Replace Color 替换颜色 + Replace With Clip From Source Monitor 用源监视器中的剪辑替换 + Replicate 复制 + Responsive Design - Position 响应式设计 - 位置 + Responsive Design - Time 响应式设计 - 时间 + Restore open sequences when opening projects 打开项目时恢复已打开的序列 + Result 结果 + Retro 复古 + Reveal Nested Sequence 显示嵌套序列 + Reveal Original Image 显示原始图像 + Reveal in Project 在项目面板中显示 + Reverb Amount 混响量 + Reverse 反转 + Right - Left 右 - 左 + Right - Right 右 - 右 + Right channel to device output 右声道到设备输出 + Right to left 从右到左 + Ripple trim adds edits to keep both sides of trim in sync 波纹修剪添加编辑以保持修剪两侧同步 + Roll (Z Axis) 滚动(Z 轴) + Room Size 房间大小 + Rotation Amount 旋转量 + Roughen 粗糙化 + Roughen Color 粗糙化颜色 + Roughen Edges 粗糙化边缘 + Round cap 圆端帽 + Round join 圆角连接 + Rounded Crop 圆角裁剪 + Rows 行 + Russian 俄语 + Rusty 锈迹 + Rusty Color 锈迹颜色 + SFX 音效 + Sample Rate 采样率 + Saturation at 50 % 50% 饱和度 + Save .cfa and .pek media cache files next to original media files when possible 尽可能将 .cfa 与 .pek 媒体缓存文件保存在原始媒体文件旁 + Scale (Degrees) 缩放(度数) + Scale Amount 缩放量 + Scale From 缩放起点 + Scale Height 缩放高度 + Scale Match 缩放匹配 + Scale To 缩放终点 + Scale Width 缩放宽度 + Scale to frame size 缩放以适配帧大小 + Scan Lines 扫描线 + Scene 场景 + Scientific Filter 科学滤波器 + Scratch disk location for Captured Audio 采集音频的暂存盘位置 + Screen 滤色 + Seconds 秒 + Segments 段 + Semi-tones 半音 + Sequence Name 序列名称 + Sequence Timecode 序列时间码 + Set focus on the Timeline when performing Insert/Overwrite edits 执行插入/覆盖编辑时将焦点设在时间轴上 + Set to frame size 设为帧大小 + Setting 设置 + Shadow Angle 阴影角度 + Shadow Blue Balance 阴影蓝平衡 + Shadow Blur 阴影模糊 + Shadow Color 阴影颜色 + Shadow Distance 阴影距离 + Shadow Green Balance 阴影绿平衡 + Shadow Only 仅阴影 + Shadow Opacity 阴影不透明度 + Shadow Red Balance 阴影红平衡 + Shadow Size 阴影大小 + Shadow Tint 阴影色调 + Shadows Lightness 阴影亮度 + Shake 抖动 + Shape Dissolve 形状溶解 + Shape Flow 形状流动 + Shape Size 形状大小 + Shapes 形状 + Shard Size 碎片大小 + Sharp Colors 锐利色彩 + Sharpen Amount 锐化量 + Shift Center To 将中心偏移至 + Shift clips that overlap trim point during ripple trimming 波纹修剪时移动与修剪点重叠的剪辑 + Shot 镜头 + Show "Project:" and "Bin:" in Project panel tab 在项目面板标签中显示“项目:”和“素材箱:” + Show Clip Mismatch Warning dialog 显示剪辑不匹配警告对话框 + Show Event Indicator 显示事件指示器 + Show Focus Area 显示焦点区域 + Show Home 显示主页 + Show Mask Tracker Preview 显示遮罩跟踪器预览 + Show Only Noise 仅显示噪点 + Show Open Dialog 显示打开对话框 + Show Project Load Error Dialog 显示项目加载错误对话框 + Show Rich Tool Tips 显示丰富工具提示 + Show Tool Tips 显示工具提示 + Show Workspace Reset Warning dialog on double click 双击时显示工作区重置警告对话框 + Show system compatibility issues at startup 启动时显示系统兼容性问题 + Shrink 收缩 + Shutter Angle 快门角度 + Similarity 相似度 + Simple Notch Filter 简单陷波滤波器 + Simple Parametric EQ 简单参数均衡器 + Simple Text 简单文本 + Single-band Compressor 单频段压缩器 + Sinusoidal 正弦 + Skew 倾斜 + Skew Axis 倾斜轴 + Slats 百叶 + Slice 切片 + Slices 切片 + Slide 滑动 + Slide (Legacy) 滑动(旧版) + Slope 斜率 + Slow 慢 + Slower Motion 更慢运动 + Small Room 小房间 + Smart Limit 智能限制 + Smart Tools 智能工具 + Smart quotes 智能引号 + Smooth 平滑 + Smooth Motion 平滑运动 + Smooth Scroll 平滑滚动 + Smoothness 平滑度 + Snap playhead in Timeline when Snap is enabled 启用吸附时时间轴中吸附播放头 + Soft 柔和 + Soft Clamp 柔和钳制 + Soft Edges (Best Quality) 柔和边缘(最佳质量) + Soft Light 柔光 + Soft Wipe 柔和擦除 + Soften Harshness 柔化硬边 + Softness 柔和度 + Solarize 日晒 + Solid Colors 纯色 + Source Point 源点 + Source Text 源文本 + Spacer 间隔 + Spanish 西班牙语 + Speaker Angle 扬声器角度 + Speaker Labeling 扬声器标签 + Special 特殊 + Special Effects 特殊效果 + Speech model 语音模型 + Speed 速度 + Spherize 球面化 + Spiky 尖锐 + Spill 溢色 + Spill Suppression 溢色抑制 + Spin 自旋 + Spin Direction 自旋方向 + Spin Motion 自旋运动 + Split 分割 + Split (Legacy) 分割(旧版) + Spotlight 聚光 + Spread 扩散 + Spring Motion 弹簧运动 + Square 方形 + Square (Width Only) 方形(仅宽度) + Square 1:1 方形 1:1 + Stabilization 稳定 + Stabilize Only 仅稳定 + Stabilize, Crop 稳定、裁剪 + Stabilize, Crop, Auto-scale 稳定、裁剪、自动缩放 + Stabilize, Synthesize Edges 稳定、合成边缘 + Stages 阶段 + Star 星形 + Star Wipe 星形擦除 + Start Angle 起始角度 + Start Color 起始颜色 + Start Point 起始点 + Start at 0 从 0 开始 + Start at 1 从 1 开始 + Start of Ramp 渐变起点 + Start with an Empty Project 以空项目开始 + Starting Intensity 起始强度 + Static Plates 静态底板 + Step forward/back many 前进/后退多步 + Stereo Expand 立体声扩展 + Stereo Expander 立体声扩展器 + Stereo Imagery 立体声像 + Stereo Media 立体声媒体 + Stereo Phasing 立体声相位 + Stereoscopic - Over/Under 立体 - 上下 + Stereoscopic - Side by Side 立体 - 左右 + Still 静止 + Still Image Default Duration 静帧默认时长 + Stone Church 石质教堂 + Stopband Attenuation 阻带衰减 + Straight Color 直色 + Strength 强度 + Stretch 拉伸 + Stretch Gradient to Fit 拉伸渐变以适配 + Stretch Map to Fit 拉伸映射以适配 + Stretch Width or Height 拉伸宽度或高度 + Stretch Wipe 拉伸擦除 + Stretch to Fit Frame 拉伸以适配帧 + Stripe 条纹 + Stripes 条纹 + Strobe Color 频闪颜色 + Strobe Duration (secs) 频闪时长(秒) + Strobe Light 频闪灯 + Strobe Period (secs) 频闪周期(秒) + Stroke 2 Color 描边 2 颜色 + Stroke 2 Type 描边 2 类型 + Stroke 2 Width 描边 2 宽度 + Stroke Angle 描边角度 + Stroke Color 描边颜色 + Stroke Density 描边密度 + Stroke Length 描边长度 + Stroke Length (secs) 描边长度(秒) + Stroke Only 仅描边 + Stroke Randomness 描边随机性 + Stroke Type 描边类型 + Stroke Width 描边宽度 + Stroke line cap 描边端帽 + Stroke line join 描边连接 + Studio Reverb 录音室混响 + Stylize 风格化 + Subclip End 子剪辑结束 + Subclip Start 子剪辑开始 + Subspace Warp 子空间扭曲 + Subtitle 字幕 + Subtract 减去 + Sum multichannel outputs to mono in Source Monitor 在源监视器中将多声道输出合并为单声道 + Super Narrow 超窄 + Surface Gloss 表面光泽 + Surface Material 表面材质 + Surround Reverb 环绕混响 + Swap 交换 + Swap Channels 交换声道 + Swivel 转动 + Synthetic 合成 + TV Power 电视电源 + Tape 磁带 + Tape Name 磁带名称 + Tape/Tube 磁带/管道 + Target Aspect 目标宽高比 + Target Color 目标颜色 + Target Field of View 目标视场角 + Target Loudness 目标响度 + Teal & Orange 青色与橙色 + Telephone 电话 + Teletext 图文电视 + Template Properties 模板属性 + Text Animator 文本动画器 + Text Color 文本颜色 + The media cache holds render previews of unsaved projects and other files FilmCraft can recreate. Deleting them is always safe; FilmCraft rebuilds them as needed. 媒体缓存保存未保存项目的渲染预览,以及其他 FilmCraft 可重建的文件。删除它们始终安全;FilmCraft 会在需要时重新构建。 + Thickness 厚度 + Tile Gradient 平铺渐变 + Tilt 倾斜 + Tilt (X Axis) 倾斜(X 轴) + Time 时间 + Time and Pitch 时间与音高 + Timecode Conversion 时间码转换 + Timeline Mouse Scrolling 时间轴鼠标滚动 + Timeline Playback Auto-Scrolling 时间轴播放自动滚动 + Tint Color 色调颜色 + To 到 + Tolerance 容差 + Tone 音调 + Top Center 顶部居中 + Top Left 左上 + Top Right 右上 + Touch 触摸 + Track Matte Key 轨道遮罩键 + Transcription 转录 + Transcription preferences 转录偏好设置 + Transform 变换 + Transformers 变换器 + Transience 瞬态 + Transition Completion 过渡完成度 + Transition Softness 过渡柔化 + Transparency 透明度 + Trash 废纸篓 + Travel Motion 行进运动 + Treble 高音 + Triangle 三角形 + Trim 修剪 + Tube 管道 + Tube-modeled Compressor 管道模型压缩器 + Tubular 管状 + Turbulent 湍流 + Turbulent Displace 湍流置换 + Turbulent Sharp 湍流锐化 + Turbulent Smooth 湍流平滑 + Turbulent Smoother 湍流平滑器 + Tweed 花呢 + Twirl 旋转扭曲 + Twirl (Legacy) 旋转扭曲(旧版) + Twirl Center 旋转扭曲中心 + Twirl Radius 旋转扭曲半径 + Twist 扭转 + Twist Smoother 扭转平滑器 + Typewriter 打字机 + Ultra Key 超级键 + Uniform Scale 等比缩放 + Uniform Spacing 均匀间距 + Unsharp Mask 锐化遮罩 + Update it at least every 至少每隔以下时间更新 + Update timeline dynamically during ripple edits. 波纹编辑时动态更新时间轴。 + Upper Frequency 上限频率 + Upper Left 上左 + Upper Right 上右 + Use Color Noise 使用彩色噪点 + Use End Color 使用结束颜色 + Use File 使用文件 + Use Glow Color 使用辉光颜色 + Use Media Source 使用媒体源 + Use Tint Color 使用色调颜色 + Utility 实用工具 + VHS Damage VHS 损坏 + VR Blur VR 模糊 + VR Chroma Leaks VR 色度泄漏 + VR Chromatic Aberrations VR 色差 + VR Color Gradients VR 色彩渐变 + VR De-Noise VR 降噪 + VR Digital Glitch VR 数字故障 + VR Fractal Noise VR 分形噪点 + VR Glow VR 辉光 + VR Gradient Wipe VR 渐变擦除 + VR Iris Wipe VR 虹膜擦除 + VR Light Leaks VR 光线泄漏 + VR Light Rays VR 光线 + VR Mobius Zoom VR 莫比乌斯缩放 + VR Plane to Sphere VR 平面到球面 + VR Projection VR 投影 + VR Random Blocks VR 随机块 + VR Rotate Sphere VR 旋转球面 + VR Sharpen VR 锐化 + VR Spherical Blur VR 球面模糊 + Validate Content Credentials on import 导入时验证内容凭证 + Vectorscope (YUV) 矢量示波器(YUV) + Vectorscope + Waveform YC 矢量示波器 + 波形 YC + Vectorscope HLS 矢量示波器 HLS + Vectorscope YUV 矢量示波器 YUV + Venetian Blinds 百叶窗 + Vertical 4:5 竖版 4:5 + Vertical 9:16 竖版 9:16 + Vertical Alignment 垂直对齐 + Vertical Blocks 垂直块 + Vertical Decentering 垂直偏离中心 + Vertical Displacement 垂直位移 + Vertical Flip 垂直翻转 + Vertical Margin 垂直边距 + Very Narrow 极窄 + Video 1 视频 1 + Video 2 视频 2 + Video 3 视频 3 + Video 4 视频 4 + Video 5 视频 5 + Video 6 视频 6 + Video 7 视频 7 + Video 8 视频 8 + Video Codec 视频编解码器 + Video Duration 视频时长 + Video Effects 视频效果 + Video In Point 视频入点 + Video Info 视频信息 + Video Limiter 视频限制器 + Video Out Point 视频出点 + Video Transition Default Duration 视频过渡默认时长 + Video Transitions 视频过渡 + Video Usage 视频使用量 + Vignette 暗角 + Vivid Light 亮光 + Vocal Booth 人声录音棚 + Vocal Enhancer 人声增强器 + Voice 人声 + Voice in a Hall 大厅人声 + Volumetric Rays 体积光线 + Warm Film 暖色胶片 + Warm Room 暖色房间 + Warm Voice 暖色人声 + Warm print stock with gentle roll-off 带柔和滚降的暖色正片 + Warm, soft late-afternoon light 温暖柔和的午后光线 + Warning 警告 + Warp Stabilizer 变形稳定器 + Wave 波浪 + Wave Height 波浪高度 + Wave Speed 波浪速度 + Wave Warp 波浪扭曲 + Wave Width 波浪宽度 + Waveform 波形 + Waveform (Luma) 波形(亮度) + Waveform (RGB) 波形(RGB) + Wavelength 波长 + Wet 湿润 + Wet Out 输出湿润 + When Opening a Project 打开项目时 + Whip 抽鞭 + Whip (Legacy) 抽鞭(旧版) + Whisper base (balanced) Whisper base(均衡) + Whisper small (most accurate) Whisper small(最准确) + Whisper tiny (fastest) Whisper tiny(最快) + White 白色 + White Input Level 白色输入电平 + White Is High 白色为高 + Wide Outdoor 宽阔户外 + Widener 扩展器 + Width 宽度 + Wiggle 摆动 + Wipe 擦除 + Wipe Angle 擦除角度 + Wonder Glow 奇幻辉光 + Wrap Back 回绕 + Write 写入 + Write XMP ID to files on import 导入时将 XMP ID 写入文件 + Write clip markers to XMP 将剪辑标记写入 XMP + Write-on 书写 + X Rotation X 轴旋转 + Y Rotation Y 轴旋转 + YC YC + YC no Chroma YC 无色度 + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves 交叉通道曲线产生的黄绿高光与蓝调提升阴影 + Z Position Z 轴位置 + Z Rotation Z 轴旋转 + Zoom 缩放 + Zoom Amount 缩放量 + Zoom Blur 缩放模糊 + Zoom Strength 缩放强度 + days 天 + frames 帧 + milliseconds 毫秒 + minute(s) 分钟 + second(s) 秒 + seconds 秒 + AAF… AAF… + Audio… 音频… + Avid Log Exchange… Avid Log Exchange… + Color… 颜色… + EDL… EDL… + Edit Workspaces… 编辑工作区… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… 常规… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace 保存对此工作区的更改 + Save as New Workspace… 另存为新工作区… + Create Search Bin 创建搜索素材箱 + Edit Offline File 编辑离线文件 + Source Settings 源设置 + Update Metadata 更新元数据 + Automate To Sequence 自动匹配到序列 + Scene Edit Detection 场景编辑检测 + Analyze 分析 + Normalize Mix Track 标准化混音轨道 + Simplify Sequence 简化序列 + Simplify 简化 + Transcribe Sequence 转录序列 + Save as Template 另存为模板 + Flash Cue Marker 闪存提示标记 + System Compatibility Report 系统兼容性报告 + no GPU adapter: the CPU compositor is used 无 GPU 适配器:使用 CPU 合成器 + Avid Log Exchange Avid Log Exchange + FilmCraft Project FilmCraft 项目 + {name} Selection.fcproj {name} Selection.fcproj + {name} (Simplified) {name}(简化) + Find in: 查找位置: + Find: 查找: + Search: 搜索: + Clip Names and Markers 剪辑名称与标记 + Match: 匹配: + All 全部 + Any 任意 + Case Sensitive 区分大小写 + Scratch Disks 暂存盘 + Ingest Settings 收录设置 + Captured and Generated: 采集与生成: + Video Previews: 视频预览: + Audio Previews: 音频预览: + Project Auto Save: 项目自动保存: + Same as Project 与项目相同 + Path: {path} 路径:{path} + Transcode 转码 + Copy and Create Proxies 复制并创建代理 + Action: 操作: + Destination: 目标: + Next to the media 位于媒体旁 + Video Rendering and Playback 视频渲染与回放 + Renderer: 渲染器: + Display Format: 显示格式: + Milliseconds 毫秒 + Capture 采集 + Capture Format: 采集格式: + Action and Title Safe Areas 动作与安全标题区域 + Title Safe Area 标题安全区域 + Action Safe Area 动作安全区域 + Total Duration 总时长 + Average Data Rate 平均数据速率 + Colour 颜色 + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} 声道 + Media Name: 媒体名称: + Scene: 场景: + Shot/Take: 镜头/条: + Log Note: 日志记录: + Codec: {codec} 编解码器:{codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? 将所选剪辑({n})的元数据写入其媒体旁的 XMP 文件? + Existing XMP files written by other applications are left unchanged. 其他应用程序写入的现有 XMP 文件保持不变。 + To {name} 至 {name} + Ordering: 排序: + Sort Order 排序顺序 + Selection Order 选择顺序 + Placement: 放置: + Sequentially 顺序 + At Unnumbered Markers 在未编号标记处 + Method: 方法: + Insert Edit 插入编辑 + Overwrite Edit 覆盖编辑 + Clip Overlap: 剪辑重叠: + Frames per Still: 每静帧帧数: + Transitions 过渡 + Apply Default Audio Transition 应用默认音频过渡 + Apply Default Video Transition 应用默认视频过渡 + Ignore Options 忽略选项 + Ignore Audio 忽略音频 + Ignore Video 忽略视频 + Apply a cut at each detected cut point 在每个检测到的切点应用剪切 + Create a subclip for each detected cut point 为每个检测到的切点创建子剪辑 + Generate clip markers at each detected cut point 在每个检测到的切点生成剪辑标记 + Sensitivity: 灵敏度: + Normalize Mix Track to: 将混音轨道标准化至: + Disabled clips 已禁用的剪辑 + Empty tracks 空轨道 + Gaps 间隙 + Video effects 视频效果 + Audio effects 音频效果 + Text (graphics and captions) 文本(图形与字幕) + Clips 剪辑 + Move clips to the lowest track possible 将剪辑移至尽可能低的轨道 + Keep: 保留: + Video and Audio 视频与音频 + Video Only 仅视频 + Audio Only 仅音频 + Language: 语言: + Audio on track {t} 轨道 {t} 上的音频 + Audio analysis: 音频分析: + Recognize when different speakers are talking 识别不同说话人讲话时 + Template Name: 模板名称: + Comments: 注释: + (unavailable) (不可用) + Decoders 解码器 + Containers 容器 + Export formats 导出格式 + Match {n} of {total} 匹配 {total} 中的 {n} + Saved template {path} 已保存模板 {path} + Scene Edit Detection: analysing in the background (see Progress) 场景编辑检测:正在后台分析(参见进度) + Delete Search Bin 删除搜索素材箱 + (removed from {list}) (已从 {list} 移除) + {key} can't be used as a shortcut {key} 不能用作快捷键 + Keyboard Shortcuts 键盘快捷键 + {active} (modified) {active}(已修改) + Loaded “{name}” 已加载“{name}” + My Shortcuts 我的快捷键 + Deleted preset “{active}” 已删除预设“{active}” + Export… 导出… + Exported to {path} 已导出至 {path} + Export: no file chosen (agents: shortcuts.export {path}) 导出:未选择文件(agents:shortcuts.export {path}) + Imported “{name}” 已导入“{name}” + Import: no file chosen (agents: shortcuts.import {path}) 导入:未选择文件(agents:shortcuts.import {path}) + Keyboard: US (macOS) 键盘:美式(macOS) + Keyboard: US 键盘:美式 + Save preset as: 另存为预设: + Commands: 命令: + {panel} Panel {panel} 面板 + Application shortcuts work whatever panel has focus 无论哪个面板获得焦点,应用程序快捷键均生效 + Panel shortcuts override application shortcuts while that panel has focus 当该面板获得焦点时,面板快捷键会覆盖应用程序快捷键 + Modifier held / selected 按住/选择的修饰键 + Search commands or keys 搜索命令或按键 + Selected: {command} 已选择:{command} + Command 命令 + Shortcut 快捷键 + Type a shortcut… (Esc cancels) 键入快捷键…(Esc 取消) + Add another shortcut 添加另一个快捷键 + No matching commands 无匹配命令 + Key: 按键: + Modifiers 修饰键 + Nothing is assigned to this key 此按键未分配任何内容 + Click a key on the keyboard to see its shortcuts 单击键盘上的按键以查看其快捷键 + Assign {key} to {command} 将 {key} 分配给 {command} + Conflict: {key} is assigned to {commands} ({ctx}) 冲突:{key} 已分配给 {commands}({ctx}) + and 和 + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. 单击命令的快捷键单元格并按键进行分配;+ 可添加另一个快捷键。 + Cleared {n} shortcut(s) of {command} 已清除 {command} 的 {n} 个快捷键 + No export preset named `{name}` 没有名为`{name}`的导出预设 + Open a sequence to export 打开一个序列以导出 + Destinations 目标 + Media File 媒体文件 + Queue ({n}) 队列({n}) + Send exports here with Send to Queue, then start the queue. 通过“发送到队列”将导出发送到此,然后启动队列。 + Ready 就绪 + Encoding {n}% 编码 {n}% + Start Queue 启动队列 + Stop 停止 + Clear Finished 清除已完成 + Not supported by FilmCraft's encoders yet FilmCraft 的编码器尚不支持 + Sequence name 序列名称 + Folder 文件夹 + Preset 预设 + More presets… 更多预设… + Multiplexer 复用器 + The sequence has no caption track. 该序列没有字幕轨道。 + Export Options 导出选项 + Burn Captions Into Video 将字幕烧录到视频 + Create Sidecar File 创建附带文件 + File Format 文件格式 + SubRip Subtitle (.srt) SubRip 字幕(.srt) + WebVTT (.vtt) WebVTT(.vtt) + Title 标题 + Creator 创建者 + Copyright 版权 + Image 图像 + Frame Size 帧大小 + Match Source 匹配源 + Field Order 场序 + Aspect 宽高比 + Profile 配置文件 + Bitrate Encoding 码率编码 + Target Bitrate 目标码率 + Adaptive ({bpp} bits per pixel) 自适应(每像素 {bpp} 位) + Maximum Bitrate 最大码率 + Key Frame Distance 关键帧间隔 + Quality 质量 + Render at Maximum Depth 以最大深度渲染 + Use Maximum Render Quality 使用最大渲染质量 + Export Audio 导出音频 + Audio Format 音频格式 + Uncompressed (PCM) 未压缩(PCM) + Match Source ({seq_sr} Hz) 匹配源({seq_sr} Hz) + Channels 声道 + Audio Bitrate 音频码率 + Sample Size 采样大小 + 16 bit 16 位 + 24 bit 24 位 + Size / Opacity 大小 / 不透明度 + Image Overlay 图像叠加 + PNG or JPEG file PNG 或 JPEG 文件 + Name Overlay 名称叠加 + Timecode Overlay 时间码叠加 + Luma Min / Max 亮度最小 / 最大 + Loudness Normalization 响度标准化 + True Peak Limit 真实峰值限制 + Audio only 仅音频 + Scaling 缩放 + Scale To Fit 缩放以适配 + Scale To Fill 缩放以填充 + Stretch To Fill 拉伸以填充 + Summary 摘要 + Sequence, {w}x{h}, {fps} fps, {rate} Hz 序列,{w}x{h},{fps} fps,{rate} Hz + Estimated file size: {size} 估计文件大小:{size} + Send to Queue 发送到队列 + Exporting {path} 正在导出 {path} + Added to the export queue 已添加到导出队列 + Preset Manager 预设管理器 + Search presets 搜索预设 + Favorites only 仅收藏 + Show in the Preset menu 在预设菜单中显示 + User 用户 + Save current settings as: 将当前设置另存为: + Export presets 导出预设 + Import: no file chosen (agents: export.presets.import {path}) 导入:未选择文件(agents:export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) 导出:未选择文件(agents:export.presets.export {path, names}) + Quick Export: open a sequence first 快速导出:请先打开一个序列 + File Name & Location 文件名与位置 + Quick Export: {path} 快速导出:{path} + Graphics problem: switched to software compositing 图形问题:已切换至软件合成 + Exporting 正在导出 + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. 单击以选择,或在空白处单击并拖动以框选。使用 Shift、Opt 和 Cmd 进行其他操作。 + Click to select all clips to the right in all tracks. Shift-click for a single track. 单击以选择所有轨道中右侧的剪辑。Shift-单击选择单个轨道。 + Click to select all clips to the left in all tracks. Shift-click for a single track. 单击以选择所有轨道中左侧的剪辑。Shift-单击选择单个轨道。 + Drag an edit point to ripple trim; later clips move to keep the gap closed. 拖动编辑点进行波纹修剪;后续剪辑移动以保持间隙闭合。 + Drag an edit point to roll it: the out of one clip and the in of the next move together. 拖动编辑点进行滚动修剪:一个剪辑的出点和下一个剪辑的入点一起移动。 + Drag an edge to change the clip's speed so it fills the new duration. 拖动边缘以更改剪辑速度,使其填满新时长。 + Drag the edge of a music clip to remix it to the new duration at musically matching beats. 拖动音乐剪辑的边缘,以音乐匹配的节拍将其重新混音至新时长。 + Click to split a clip. Shift-click to split all tracks. 单击以分割剪辑。Shift-单击以分割所有轨道。 + Drag a clip to slip its source in/out without moving it. 拖动剪辑以滑动其源入/出点而不移动它。 + Drag a clip to slide it between its neighbours. 拖动剪辑以在其相邻剪辑之间滑动。 + Drag to scroll the timeline. 拖动以滚动时间轴。 + Click to zoom in; Opt-click to zoom out. 单击以放大;Opt-单击以缩小。 + FilmCraft hit an error FilmCraft 遇到错误 + Details: {path} 详细信息:{path} + Opened {url} 已打开 {url} + (empty) (空) + Step Back 1 Frame 后退 1 帧 + Step Forward 1 Frame 前进 1 帧 + Match Playhead 匹配播放头 + Gang to Program 联动到节目 + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16 位 + AIFF, stereo 48 kHz 16-bit PCM AIFF,立体声 48 kHz 16 位 PCM + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) 变形宽银幕 2:1(2.0) + Animated GIF 动态 GIF + Animated GIF 640×360 动态 GIF 640×360 + Apple ProRes Apple ProRes + Apple ProRes 422 Apple ProRes 422 + Apple ProRes 422 HQ Apple ProRes 422 HQ + Apple ProRes 422 LT Apple ProRes 422 LT + Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Application 应用程序 + Apply Guide Template 应用参考线模板 + Auto Save Now 立即自动保存 + Auto-Match Loudness 自动匹配响度 + Avid DNxHR Avid DNxHR + Avid DNxHR HQ Avid DNxHR HQ + Avid DNxHR LB Avid DNxHR LB + Avid DNxHR SQ Avid DNxHR SQ + Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…) Avid 风格 OP-Atom:DNxHR HQ 图像文件,外加每个声道一个 24 位 48 kHz PCM 文件(_A1、_A2…) + BMP BMP + BMP Sequence BMP 序列 + Baseline 基线 + Begin Text Editing for a Graphic Layer 开始图形图层文本编辑 + Begins With 开头为 + Blur In 入点模糊 + Blur Out 出点模糊 + Brightens inside a feathered circular mask 提亮羽化圆形遮罩内部 + Broadcast 广播电视 + CBR CBR + Cancel Dynamic Trim 取消动态修剪 + Cancel Queued Export 取消排队的导出 + Center Left 中心左侧 + Center Right 中心右侧 + Center align text 居中对齐文本 + Change Mask 更改遮罩 + Clear All Events 清除所有事件 + Clear Edit Point Selection 清除编辑点选择 + Clear Finished Exports 清除已完成的导出 + Clear Poster Frame 清除静帧 + Close Bin 关闭素材箱 + Close Sequence 关闭序列 + Contains 包含 + Cut to Camera 1 切换到摄像机 1 + Cut to Camera 2 切换到摄像机 2 + Cut to Camera 3 切换到摄像机 3 + Cut to Camera 4 切换到摄像机 4 + Cut to Camera 5 切换到摄像机 5 + Cut to Camera 6 切换到摄像机 6 + Cut to Camera 7 切换到摄像机 7 + Cut to Camera 8 切换到摄像机 8 + Cut to Camera 9 切换到摄像机 9 + D1/DV NTSC (0.9091) D1/DV NTSC(0.9091) + D1/DV PAL (1.0940) D1/DV PAL(1.0940) + DVCPRO HD (1.5) DVCPRO HD(1.5) + Darkens outside a feathered oval mask 压暗羽化椭圆遮罩外部 + Decrease Audio Tracks Height 降低音频轨道高度 + Decrease Clip Volume 降低剪辑音量 + Decrease Clip Volume Many 大幅降低剪辑音量 + Decrease Font Size by Five Units 将字号减小五个单位 + Decrease Font Size by One Unit 将字号减小一个单位 + Decrease Leading by Five Units 将行距减小五个单位 + Decrease Leading by One Unit 将行距减小一个单位 + Decrease Video Tracks Height 降低视频轨道高度 + Delete Captions 删除字幕 + Delete Graphic Layer 删除图形图层 + Delete Guide Template 删除参考线模板 + Delete Media Cache Files 删除媒体缓存文件 + Discard Unsaved Changes 放弃未保存的更改 + Does Not Contain 不包含 + Download Speech Model 下载语音模型 + Dynamic Trim Stop 动态修剪停止 + Edit Caption Text 编辑字幕文本 + Ellipse Tool 椭圆工具 + Ends With 结尾为 + Entire Source 整个源 + EssentialSound EssentialSound + Export Frame 导出静帧 + Extend Next Edit To Playhead 将下一个编辑点延伸至播放头 + Extend Previous Edit To Playhead 将上一个编辑点延伸至播放头 + Extend Selected Edit to Playhead 将所选编辑点延伸至播放头 + Extend Selection Down 向下扩展选区 + Extend Selection Left 向左扩展选区 + Extend Selection Right 向右扩展选区 + Extend Selection Up 向上扩展选区 + Fade In 淡入 + Gaussian Blur from 0 to 60 over the last second 在最后一秒内高斯模糊从 0 到 60 + Gaussian Blur from 60 to 0 over the first second 在最初一秒内高斯模糊从 60 到 0 + Generate Ducking Keyframes 生成闪避关键帧 + Go to Next Edit Point 转到下一个编辑点 + Go to Next Edit Point on Any Track 转到任意轨道上的下一个编辑点 + Go to Previous Edit Point 转到上一个编辑点 + Go to Previous Edit Point on Any Track 转到任意轨道上的上一个编辑点 + Go to Selected Clip End 转到所选剪辑末尾 + Go to Selected Clip Start 转到所选剪辑开头 + Go to Sequence End 转到序列末尾 + Go to Sequence Start 转到序列开头 + H.264 H.264 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080(视频分享网站),VBR 1 遍,16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080,VBR 1 遍,目标 20 Mbps,AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080,VBR 2 遍,18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps H.264 3840×2160(视频分享网站),VBR 1 遍,45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160,VBR 1 遍,目标 60 Mbps,AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160,VBR 2 遍,50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps H.264 9:16 竖版(短视频社交平台),缩放填充,12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel H.264(采用序列的帧大小与帧速率);每像素 0.05 位 + H.264 at the sequence's frame size and rate; 0.1 bits per pixel H.264(采用序列的帧大小与帧速率);每像素 0.1 位 + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) H.264(采用序列的帧大小与帧速率);码率随帧大小自适应(每像素 0.2 位) + HD Anamorphic 1080 (1.333) HD 变形宽银幕 1080(1.333) + Hand Tool 抓手工具 + High Quality 1080p HD 高质量 1080p HD + High Quality 2160p 4K 高质量 2160p 4K + Image Sequence 图像序列 + Increase Audio Tracks Height 增加音频轨道高度 + Increase Clip Volume 增加剪辑音量 + Increase Clip Volume Many 大幅增加剪辑音量 + Increase Font Size by Five Units 将字号增加五个单位 + Increase Font Size by One Unit 将字号增加一个单位 + Increase Leading by Five Units 将行距增加五个单位 + Increase Leading by One Unit 将行距增加一个单位 + Increase Video Tracks Height 增加视频轨道高度 + Large blocks resolving to the picture over the first second 在最初一秒内由大块逐渐显现为画面 + Left align text 文本左对齐 + Looping GIF, 640×360 at 15 fps 循环 GIF,640×360,15 fps + Lower First 下方优先 + Lumetri Lumetri + MXF OP-Atom MXF OP-Atom + MXF OP-Atom DNxHR (Avid) MXF OP-Atom DNxHR(Avid) + MXF OP1a MXF OP1a + MXF OP1a DNxHR HQ MXF OP1a DNxHR HQ + MXF OP1a H.264 MXF OP1a H.264 + MXF OP1a ProRes 422 HQ MXF OP1a ProRes 422 HQ + MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode MXF OP1a,DNxHR HQ(8 位 4:2:2),24 位 48 kHz PCM,起始时间码 + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a,H.264 High long GOP(每像素 0.3 位),24 位 48 kHz PCM + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a,ProRes 422 HQ,24 位 48 kHz PCM,起始时间码 + Main 主要 + Mark In (Hover Scrub) 标记入点(悬停擦除) + Mark Out (Hover Scrub) 标记出点(悬停擦除) + Masks 遮罩 + Match Source – Adaptive High Bitrate 匹配源 – 自适应高码率 + Match Source – Adaptive Low Bitrate 匹配源 – 自适应低码率 + Match Source – Adaptive Medium Bitrate 匹配源 – 自适应中码率 + Matches 匹配项 + Maximize or Restore Active Frame 最大化或还原活动帧 + Maximize or Restore Frame Under Cursor 最大化或还原光标下的帧 + Media Browser Settings 媒体浏览器设置 + MediaBrowser MediaBrowser + MediaCache MediaCache + Merge Captions 合并字幕 + Mixer 混音器 + Mosaic In 马赛克入 + Move All Audio Targets Down 将所有音频目标下移 + Move All Audio Targets Up 将所有音频目标上移 + Move All Video Targets Down 将所有视频目标下移 + Move All Video Targets Up 将所有视频目标上移 + Move Selection Down 将选区下移 + Move Selection End 将选区移动到末尾 + Move Selection Home 将选区移动到开头 + Move Selection Left 将选区左移 + Move Selection Page Down 将选区下翻一页 + Move Selection Page Up 将选区上翻一页 + Move Selection Right 将选区右移 + Move Selection Up 将选区上移 + Multi-Camera Record On/Off Toggle 多机位录制开/关切换 + Multicam 多机位 + Navigate to End of Segment 导航至片段末尾 + Navigate to Next Line 导航至下一行 + Navigate to Next Word 导航至下一个词 + Navigate to Previous Line 导航至上一行 + Navigate to Previous Word 导航至上一个词 + Navigate to Start of Segment 导航至片段开头 + Navigation 导航 + Next Multi-Camera Page 下一个多机位页面 + Nudge Clip Selection Down 将剪辑选区下移一点 + Nudge Clip Selection Left Five Frames 将剪辑选区左移五帧 + Nudge Clip Selection Left One Frame 将剪辑选区左移一帧 + Nudge Clip Selection Right Five Frames 将剪辑选区右移五帧 + Nudge Clip Selection Right One Frame 将剪辑选区右移一帧 + Nudge Clip Selection Up 将剪辑选区上移一点 + Nudge Selected Object down by five 将所选对象下移五个单位 + Nudge Selected Object down by one 将所选对象下移一个单位 + Nudge Selected Object to left by five 将所选对象左移五个单位 + Nudge Selected Object to left by one 将所选对象左移一个单位 + Nudge Selected Object to right by five 将所选对象右移五个单位 + Nudge Selected Object to right by one 将所选对象右移一个单位 + Nudge Selected Object up by five 将所选对象上移五个单位 + Nudge Selected Object up by one 将所选对象上移一个单位 + Nudge Volume +1dB 微调音量 +1dB + Nudge Volume +3dB 微调音量 +3dB + Nudge Volume -1dB 微调音量 -1dB + Nudge Volume -3dB 微调音量 -3dB + Numbered BMP stills (24-bit) 带编号的 BMP 静帧(24 位) + 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% + Opacity 100 → 0 % over the last second 在最后一秒内不透明度从 100% → 0 + Open Bin 打开素材箱 + Open Search 打开搜索 + PNG PNG + PNG Sequence PNG 序列 + Panel 面板 + Pen Tool 钢笔工具 + PiP 35% Upper Right 画中画 35% 右上 + Picture-in-picture: 35 % scale in the upper right 画中画:右上角 35% 缩放 + Play Around 围绕播放 + Play In to Out with Preroll/Postroll 带预卷/后卷从入点到出点播放 + Play from Playhead to Out Point 从播放头播放到出点 + Play/Stop 播放/停止 + Previous Multi-Camera Page 上一个多机位页面 + Progressive 逐行扫描 + ProjectPanel ProjectPanel + QuickTime (Motion JPEG) QuickTime(Motion JPEG) + QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM QuickTime,APV 422-10(10 位 4:2:2),24 位 48 kHz PCM + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime,APV 422-12(12 位 4:2:2),24 位 48 kHz PCM + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime,DNxHR HQ(8 位 4:2:2),24 位 48 kHz PCM + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime,DNxHR LB(8 位 4:2:2),24 位 48 kHz PCM + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime,DNxHR SQ(8 位 4:2:2),24 位 48 kHz PCM + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime,ProRes 422 HQ,24 位 48 kHz PCM + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime,ProRes 422 LT,24 位 48 kHz PCM + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime,ProRes 422 Proxy,24 位 48 kHz PCM + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime,ProRes 422,24 位 48 kHz PCM + Quit FilmCraft 退出 FilmCraft + Rate Stretch Tool 速率伸缩工具 + Razor Tool 剃刀工具 + Rectangle Tool 矩形工具 + Remix Tool 重新混音工具 + Right align text 文本右对齐 + Ripple Edit Tool 波纹编辑工具 + Ripple Trim Next Edit to Playhead 波纹修剪:将下一个编辑点修剪至播放头 + Ripple Trim Previous Edit to Playhead 波纹修剪:将上一个编辑点修剪至播放头 + Rolling Edit Tool 滚动编辑工具 + Select Camera 1 选择摄像机 1 + Select Camera 2 选择摄像机 2 + Select Camera 3 选择摄像机 3 + Select Camera 4 选择摄像机 4 + Select Camera 5 选择摄像机 5 + Select Camera 6 选择摄像机 6 + Select Camera 7 选择摄像机 7 + Select Camera 8 选择摄像机 8 + Select Camera 9 选择摄像机 9 + Select Clip at Playhead 选择播放头处的剪辑 + Select Find Box 选择查找框 + Select Next Clip 选择下一个剪辑 + Select Next Panel 选择下一个面板 + Select Previous Clip 选择上一个剪辑 + Select Previous Panel 选择上一个面板 + Select to Next Line 选择至下一行 + Select to Next Word 选择至下一个词 + Select to Previous Line 选择至上一行 + Select to Previous Word 选择至上一个词 + Select to Segment End 选择至片段末尾 + Select to Segment Start 选择至片段开头 + Selection Tool 选择工具 + Set Comparison Reference 设置比较参考 + Set Creative Look 设置创意风格 + Set Input LUT 设置输入 LUT + Set Project Panel View 设置项目面板视图 + Show Next Screen 显示下一个屏幕 + Show Previous Screen 显示上一个屏幕 + Show Program Transcript 显示节目转录 + Shuttle Left 向左穿梭 + Shuttle Right 向右穿梭 + Shuttle Slow Left 向左慢速穿梭 + Shuttle Slow Right 向右慢速穿梭 + Shuttle Stop 停止穿梭 + Slide Clip Selection Left Five Frames 将剪辑选区左移五帧 + Slide Clip Selection Left One Frame 将剪辑选区左移一帧 + Slide Clip Selection Right Five Frames 将剪辑选区右移五帧 + Slide Clip Selection Right One Frame 将剪辑选区右移一帧 + Slide Tool 滑动工具 + Slip Clip Selection Left Five Frames 将剪辑选区左滑五帧 + Slip Clip Selection Left One Frame 将剪辑选区左滑一帧 + Slip Clip Selection Right Five Frames 将剪辑选区右滑五帧 + Slip Clip Selection Right One Frame 将剪辑选区右滑一帧 + Slip Tool 错动工具 + Social Vertical 1080×1920 社交竖版 1080×1920 + Soft Vignette 柔和暗角 + Source In/Out 源入/出点 + Split Caption 拆分字幕 + Square Pixels (1.0) 方形像素(1.0) + Stack Clips 堆叠剪辑 + Start Export Queue 启动导出队列 + Start Voice-over Recording 开始画外音录制 + Step Back Five Frames 后退五帧 + Step Back One Frame 后退一帧 + Step Forward Five Frames 前进五帧 + Step Forward One Frame 前进一帧 + Stop Export Queue 停止导出队列 + Stop Multi-Camera Recording 停止多机位录制 + Stop Voice-over Recording 停止画外音录制 + Sync Voice-over Capture 同步画外音采集 + TIFF TIFF + TIFF Sequence TIFF 序列 + TextPanel TextPanel + Thumbnail Size Next 缩略图大小:下一个 + Thumbnail Size Previous 缩略图大小:上一个 + Toggle All Audio Targets 切换所有音频目标 + Toggle All Source Audio 切换所有源音频 + Toggle All Source Video 切换所有源视频 + Toggle All Video Targets 切换所有视频目标 + Toggle Audio During Scrubbing 切换拖动时的音频 + Toggle Full Screen 切换全屏 + Toggle Mute for All Targeted Audio Tracks 切换所有目标音频轨道的静音 + Toggle Solo for All Targeted Audio Tracks 切换所有目标音频轨道的独奏 + Toggle Source/Program Monitor Focus 切换源/节目监视器焦点 + Toggle Target Audio 1 切换目标音频 1 + Toggle Target Audio 2 切换目标音频 2 + Toggle Target Audio 3 切换目标音频 3 + Toggle Target Audio 4 切换目标音频 4 + Toggle Target Audio 5 切换目标音频 5 + Toggle Target Audio 6 切换目标音频 6 + Toggle Target Audio 7 切换目标音频 7 + Toggle Target Audio 8 切换目标音频 8 + Toggle Target Video 1 切换目标视频 1 + Toggle Target Video 2 切换目标视频 2 + Toggle Target Video 3 切换目标视频 3 + Toggle Target Video 4 切换目标视频 4 + Toggle Target Video 5 切换目标视频 5 + Toggle Target Video 6 切换目标视频 6 + Toggle Target Video 7 切换目标视频 7 + Toggle Target Video 8 切换目标视频 8 + Toggle Track Output for All Targeted Video Tracks 切换所有目标视频轨道的轨道输出 + Toggle Trim Type 切换修剪类型 + Toggle View 切换视图 + Track Select Backward Tool 轨道选择向后工具 + Track Select Forward Tool 轨道选择向前工具 + Track Selected Mask 跟踪所选遮罩 + Trim Next Edit to Playhead 将下一个编辑点修剪至播放头 + Trim Previous Edit to Playhead 将上一个编辑点修剪至播放头 + Trimming 修剪中 + Type Tool 文字工具 + Unstack Clips 取消堆叠剪辑 + Upper First 上方优先 + VBR, 1 pass VBR,1 遍 + VBR, 2 pass VBR,2 遍 + Vimeo 1080p Full HD Vimeo 1080p 全高清 + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K 超高清 + Voice-over Record 画外音录制 + Voiceover 画外音 + WAV, stereo 48 kHz 16-bit PCM WAV,立体声 48 kHz 16 位 PCM + WAV, stereo 48 kHz 24-bit PCM WAV,立体声 48 kHz 24 位 PCM + Warm Tint 暖色色调 + Warm highlights, brown shadows 暖色高光,棕色阴影 + Waveform Audio 波形音频 + Waveform Audio 48 kHz 16-bit 波形音频 48 kHz 16 位 + Waveform Audio 48 kHz 24-bit 波形音频 48 kHz 24 位 + Web & Social 网络与社交 + Work Area 工作区域 + Write Automation 写入自动化 + YouTube 1080p Full HD YouTube 1080p 全高清 + YouTube 2160p 4K Ultra HD YouTube 2160p 4K 超高清 + Zoom Program Monitor to 100% 将节目监视器缩放至 100% + Zoom Program Monitor to Fit 将节目监视器缩放至适配 + Zoom Source Monitor to 100% 将源监视器缩放至 100% + Zoom Source Monitor to Fit 将源监视器缩放至适配 + Zoom Tool 缩放工具 + with: 与: + Interface Language 界面语言 + System Language 系统语言 + Cinematic 电影感 + Film Emulation 胶片模拟 + Technical 技术 + Teal Shadows, Warm Skin 青色阴影,暖色皮肤 + Night Exterior 夜景外景 + Golden Dusk 金色黄昏 + Steel Blue 钢蓝 + Hard Sun 强烈阳光 + Faded Print 褪色正片 + Silver Retention 银色保留 + Cross Processed 交叉冲印 + Warm Negative 暖色负片 + Neutral Mono 中性单色 + Hard Mono 硬单色 + Sepia Tone 棕褐色调 + Cold Mono 冷色单色 + Lift Shadows 提升阴影 + Protect Highlights 保护高光 + Half Saturation 半饱和度 + Boost Vibrance 提升自然饱和度 + Legal Range Squeeze 合法范围压缩 + A boxed label with a round pointer; move the label and the pointer follows. 带圆点指示器的框形标签;移动标签,指示器随之移动。 + A rounded tag that fits its text, with a status dot. 贴合文字的圆角标签,带状态圆点。 + A title on a rounded card with a soft shadow. 圆角卡片上的标题,带柔和阴影。 + Accent Color 强调色 + Bar Color 条颜色 + Box Color 框颜色 + Box Opacity 框不透明度 + Callout – Pointer 标注 – 指示 + Callout – Tag 标注 – 标签 + Callouts 标注 + Credits 演职员表 + Dot Color 圆点颜色 + Heading 标题 + Large centred title, accent rule and subtitle. 居中的大标题、强调线和副标题。 + Lower Third – Rule 下三分之一 – 线条 + Lower Third – Slab 下三分之一 – 色块 + Lower Thirds 下三分之一 + Name Color 名称颜色 + Name Size 名称大小 + Name and role on a coloured slab that widens with the name. 名称与角色显示在随名称变宽的色块上。 + Name over a thin rule that always matches the name's width. 名称显示在始终匹配名称宽度的细线之上。 + Pointer Color 指示器颜色 + Role 角色 + Rule Color 线条颜色 + Show Dot 显示圆点 + Show Role 显示角色 + Slab Color 色块颜色 + Tag Color 标签颜色 + Title Color 标题颜色 + Title Size 标题大小 + Title – Boxed 标题 – 框形 + Title – Centered 标题 – 居中 + Titles 标题 + Select an item to preview it 选择一个项目以预览 + End Credits 片尾字幕 + End Credits – Roll 片尾字幕 – 滚动 + Ticker – Crawl 滚动字幕 – 爬行 + Centred credits that roll up from below the frame and leave at the top. 居中演职员表从画面下方滚入,从顶部离开。 + A news-style line that crawls in from the right and leaves at the left. 新闻风格的一行文字从右侧爬入,从左侧离开。 + out 出 + in 入 + The language changed but could not be saved: {e} 语言已更改但无法保存:{e} + no Japanese font is installed on this system; the interface stays in English 此系统未安装日语字体;界面保持英文 + Show 显示 + Sort 排序 + Reverse the order 反转顺序 + Grab bag 杂项 + Table 表格 + No contributor data was built into this copy. 此版本未内置贡献者数据。 + No model credits were built into this copy. 此版本未内置模型署名。 + Company 公司 + Model 模型 + Version 版本 + Commits 提交 + % of all commits 占全部提交的比例 + Lines +/− 代码行 +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) 音频输出失败:无声播放(请检查设置 ▸ 音频硬件) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) 音频输出无响应:无声播放(请检查设置 ▸ 音频硬件) + Clip Speed / Duration 剪辑速度 / 时长 + Speed: 速度: + Reverse Speed 反转速度 + Ripple Edit, Shifting Trailing Clips 波纹编辑,移动后续剪辑 + About 关于 + Contributors 贡献者 + Models 模型 + Main (8-bit) 主要(8 位) + Main 10 (10-bit, HDR) 主要 10(10 位,HDR) + Main (8-bit; Main 10 for HDR sequences) 主要(8 位;HDR 序列使用主要 10) + Encoder 编码器 + Hardware (H.265 has no software encoder) 硬件(H.265 无软件编码器) + Text Properties 文本属性 + Text Box Height 文本框高度 + Paragraph Text 段落文本 + Point Text 点文本 + Text Layer Type 文本图层类型 + Text Styling 文本样式 + New Color Matte 新建颜色遮罩 + Color Matte Color 颜色遮罩颜色 + Color: 颜色: + · {time} left · 剩余 {time} + Discard take 放弃片段 + Voice-over take discarded 画外音片段已放弃 + Binary assets added 已添加二进制资源 + Binary assets removed 已移除二进制资源 + Deselect All Project Items 取消选择所有项目项 + Display name 显示名称 + First commit 首次提交 + H.265 (HEVC) H.265(HEVC) + Last commit 最近提交 + Line delta 代码行增减 + Lines added 新增行 + Lines deleted 删除行 + Merged PRs 已合并的 PR + Name (A–Z) 名称(A–Z) + Real name 真实姓名 + Select All Project Items 选择所有项目项 + Username 用户名 + Vertical Type Tool 直排文字工具 + Go to previous keyframe 转到上一个关键帧 + Go to next keyframe 转到下一个关键帧 + Add keyframe 添加关键帧 + Remove keyframe 删除关键帧 + Turn on animation and add keyframe 开启动画并添加关键帧 + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. 单击以选择,或在空白处单击并拖动以框选。使用 Shift、Alt 和 Ctrl 进行其他操作。 + Click to zoom in; Alt-click to zoom out. 单击以放大;Alt-单击以缩小。 + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete 拖动:移动 · Alt-单击:平滑/拐角 · Ctrl-单击:删除 + Include Alpha Channel 包含 Alpha 通道 + Sequence Settings 序列设置 + Color Management 色彩管理 + VR Properties VR 属性 + Editing Mode: 编辑模式: + Timebase: 时基: + Frame Size: 帧大小: + Pixel Aspect Ratio: 像素宽高比: + Fields: 场: + Channel Format: 声道格式: + Number of Channels: 声道数: + Preview File Format: 预览文件格式: + Working Color Space: 工作色彩空间: + Projection: 投影: + Layout: 布局: + Captured View: 捕获视图: + Video Previews 视频预览 + No Fields (Progressive Scan) 无场(逐行扫描) + Custom ({ratio}) 自定义({ratio}) + {rate} frames/second {rate} 帧/秒 + {rate} fps Drop-Frame Timecode {rate} fps 掉帧时间码 + {rate} fps Non Drop-Frame Timecode {rate} fps 非掉帧时间码 + {rate} fps Timecode {rate} fps 时间码 + horizontal 水平 + vertical 垂直 + Scale motion effects proportionally when changing frame size 更改帧大小时按比例缩放运动效果 + Maximum Bit Depth 最大位深度 + Maximum Render Quality 最大渲染质量 + Composite in Linear Color 在线性色彩中合成 + Auto Tone Map Media (HDR and log into SDR) 自动色调映射媒体(HDR 与 log 映射至 SDR) + Configure… 配置… + Not supported in FilmCraft yet FilmCraft 尚不支持 + FilmCraft always composites in linear light FilmCraft 始终在线性光中合成 + Loop marked range 循环标记范围 + Play Source 播放源 + Source Play/Stop 源播放/停止 + Stop Source 停止源 + New Sequence 新建序列 + Tracks 轨道 + tracks 轨道 From 9679157fdd3c26f226f4e4adc4f3fa552ab3c23c Mon Sep 17 00:00:00 2001 From: hadmark Date: Sat, 10 Oct 2026 14:14:17 +0800 Subject: [PATCH 05/14] Add Simplified Chinese language support Register Chinese language --- crates/ui-egui/src/i18n/mod.rs | 72 ++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index c04416c6..484b2519 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -34,14 +34,18 @@ pub enum Language { /// Persisted as `pt-br` (the blanket `rename_all` would produce `ptbr`). #[serde(rename = "pt-br")] PtBr, + /// Persisted as `zh` (Simplified Chinese). + #[serde(rename = "zh")] + Zh, } static JAPANESE: OnceLock = OnceLock::new(); static SPANISH: OnceLock = OnceLock::new(); static PORTUGUESE: OnceLock = OnceLock::new(); +static CHINESE: OnceLock = OnceLock::new(); impl Language { - pub const ALL: [Self; 4] = [Self::En, Self::Ja, Self::Es, Self::PtBr]; + pub const ALL: [Self; 5] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Zh]; pub fn name(self) -> &'static str { match self { @@ -49,6 +53,7 @@ impl Language { Self::Ja => "日本語", Self::Es => "Español", Self::PtBr => "Português (Brasil)", + Self::Zh => "简体中文", } } @@ -58,6 +63,7 @@ impl Language { "ja" => Some(Self::Ja), "es" => Some(Self::Es), "pt-br" => Some(Self::PtBr), + "zh" => Some(Self::Zh), _ => None, } } @@ -66,7 +72,7 @@ impl Language { /// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is. pub fn from_locales(tags: &[String]) -> Self { - const PRIMARY: [(&str, Language); 4] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr)]; + const PRIMARY: [(&str, Language); 5] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("zh", Language::Zh)]; tags.iter() .find_map(|tag| { let primary = tag.split(['-', '_', '.', '@']).next().unwrap_or_default(); @@ -82,6 +88,7 @@ impl Language { Self::Ja => "ja", Self::Es => "es", Self::PtBr => "pt-br", + Self::Zh => "zh", } } @@ -92,6 +99,7 @@ impl Language { Self::Ja => Some(JAPANESE.get_or_init(|| Catalog::parse(include_str!("ja.tsv")))), Self::Es => Some(SPANISH.get_or_init(|| Catalog::parse(include_str!("es.tsv")))), Self::PtBr => Some(PORTUGUESE.get_or_init(|| Catalog::parse(include_str!("pt-br.tsv")))), + Self::Zh => Some(CHINESE.get_or_init(|| Catalog::parse(include_str!("zh.tsv")))), } } @@ -240,13 +248,71 @@ pub fn install_japanese_font(ctx: &egui::Context) -> bool { true } +/// Common Simplified Chinese characters the interface font must cover. +const CHINESE_SAMPLE: &str = "简体中文界面字体渲染测试"; + +/// Installed families preferred for Simplified Chinese interface text, best first (sans-serif +/// faces read best at menu sizes). Any other installed face that covers [`CHINESE_SAMPLE`] is +/// used if none of these is present. +const PREFERRED_CHINESE: &[&str] = &[ + "Microsoft YaHei", + "PingFang SC", + "PingFang TC", + "Noto Sans CJK SC", + "Noto Sans CJK TC", + "Noto Sans SC", + "Source Han Sans SC", + "Source Han Sans", + "Heiti SC", + "SimHei", + "WenQuanYi Micro Hei", + "WenQuanYi Zen Hei", + "Droid Sans Fallback", +]; + +const CHINESE_FONT: &str = "system-chinese"; + +/// A Simplified Chinese font already installed on this system, for the interface (none is +/// bundled). Looked up once per process: the system font folders are scanned on first use +/// (name tables only), then the chosen face's file is read. `None` on the web and on systems +/// without a CJK font. +pub fn system_chinese_font() -> Option> { + static FONT: OnceLock>> = OnceLock::new(); + FONT.get_or_init(|| { + filmcraft_text::fonts::scan_system(); + let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect(); + let covers = |f: &filmcraft_text::fonts::Face| CHINESE_SAMPLE.chars().all(|c| f.has_char(c)); + let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); + let preferred = + PREFERRED_CHINESE.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight)); + let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?; + let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice()); + Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() })) + }) + .clone() +} + +/// Simplified Chinese for the interface: adds the system's Chinese font as the last fallback +/// of every theme font family, from the next pass on. Returns false (and changes nothing) +/// when no CJK-capable font is installed. Call it again after `theme::install`, which replaces +/// the font definitions. +pub fn install_chinese_font(ctx: &egui::Context) -> bool { + let Some(font) = system_chinese_font() else { return false }; + let families = crate::theme::font_families() + .into_iter() + .map(|family| egui::epaint::text::InsertFontFamily { family, priority: egui::epaint::text::FontPriority::Lowest }) + .collect(); + ctx.add_font(egui::epaint::text::FontInsert { name: CHINESE_FONT.into(), data: (*font).clone(), families }); + true +} + #[cfg(test)] mod tests { use super::*; #[test] fn catalogs_are_well_formed() { - for (code, text) in [("es", include_str!("es.tsv")), ("ja", include_str!("ja.tsv")), ("pt-br", include_str!("pt-br.tsv"))] { + for (code, text) in [("es", include_str!("es.tsv")), ("ja", include_str!("ja.tsv")), ("pt-br", include_str!("pt-br.tsv")), ("zh", include_str!("zh.tsv"))] { let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); for (i, (ctx, en, tr)) in entries.iter().enumerate() { From 03a840d28bc787a18026a6191e29d63565691cd2 Mon Sep 17 00:00:00 2001 From: SilentYeti Date: Fri, 9 Oct 2026 23:17:11 -0700 Subject: [PATCH 06/14] Linux: NVDEC hardware decoding for H.264 and HEVC Adds a second Linux decoder backend, crates/platform/src/nvdec, that talks to NVIDIA's driver directly (libnvcuvid + the CUDA driver, both loaded at run time). It is registered in front of VA-API: what NVDEC declines still reaches VA-API, then the software decoders. H.264 8-bit and HEVC 8/10-bit, 4:2:0 progressive. NVIDIA's parser takes Annex B access units, so there is no host-side DPB. Pictures are read back as NV12 / P016 through the existing biplanar path and are bit-exact with the software decoders. The CUDA context is the one nvenc::device already retains; it gains push/pop and copy-to-host. A seek recreates the parser and keeps the decoder. Also fixes tests/setting.rs, which expected the Media Foundation backend name on Linux, and updates the Hardware decoding tooltip and its Spanish row. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- README.md | 2 +- apps/filmcraft-cli/src/main.rs | 2 +- apps/filmcraft/src/main.rs | 2 +- crates/engine/src/settings.rs | 2 +- crates/platform/Cargo.toml | 2 +- crates/platform/README.md | 12 + crates/platform/src/lib.rs | 59 ++- crates/platform/src/nvdec/abi_tests.rs | 130 +++++ crates/platform/src/nvdec/cuvid.rs | 675 +++++++++++++++++++++++++ crates/platform/src/nvdec/ffi.rs | 213 ++++++++ crates/platform/src/nvdec/mod.rs | 123 +++++ crates/platform/src/nvenc/device.rs | 60 ++- crates/platform/src/nvenc/mod.rs | 2 +- crates/platform/tests/nvdec.rs | 182 +++++++ crates/platform/tests/setting.rs | 8 +- crates/ui-egui/src/i18n/es.tsv | 2 +- docs/adr/0001-platform-ffi.md | 11 + 18 files changed, 1472 insertions(+), 17 deletions(-) create mode 100644 crates/platform/src/nvdec/abi_tests.rs create mode 100644 crates/platform/src/nvdec/cuvid.rs create mode 100644 crates/platform/src/nvdec/ffi.rs create mode 100644 crates/platform/src/nvdec/mod.rs create mode 100644 crates/platform/tests/nvdec.rs diff --git a/AGENTS.md b/AGENTS.md index 68486045..f616a9f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ panic path, and fix a crash before building on top of it. The cross-app standard code alone (a literal that always parses): a single-item `#[allow(clippy::expect_used)]` with the reason. 3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace). The single exception is `crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware - video decoding through VideoToolbox, Media Foundation / Direct3D 11 and VA-API; hardware encoding) and does nothing else. It + video decoding through VideoToolbox, Media Foundation / Direct3D 11, VA-API and NVDEC; hardware encoding) and does nothing else. It uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a `// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)). diff --git a/README.md b/README.md index 8c498e3d..c0ae0a4b 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)): The biggest gaps today: -- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). +- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). - **No plugins.** No VST3 / Audio Units or OpenFX hosting. - **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. - **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS. diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 03ff62f0..bf7d6177 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -461,7 +461,7 @@ mod format_tests { #[test] fn startup_registers_the_hardware_decoders() { let hardware = super::register_hardware_decoders(); - // always on macOS and Windows; on Linux when a VA-API driver is there + // always on macOS and Windows; on Linux when a VA-API driver or NVIDIA's driver (NVDEC) is there let expected = cfg!(any(target_os = "macos", target_os = "windows")) || (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_))); assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}"); diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 18fb4d7b..d9cd6a4a 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -329,7 +329,7 @@ mod tests { fn startup_registers_the_hardware_decoders_without_a_logger() { assert!(!log::log_enabled!(log::Level::Info)); let hardware = super::register_hardware_decoders(); - // always on macOS and Windows; on Linux when a VA-API driver is there + // always on macOS and Windows; on Linux when a VA-API driver or NVIDIA's driver (NVDEC) is there let expected = cfg!(any(target_os = "macos", target_os = "windows")) || (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_))); assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}"); diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 1e035035..674f6389 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -966,7 +966,7 @@ static CATEGORIES: &[Category] = &[ b("playback.draftDecode", "Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered)", true), f("playback.hardwareDecoding", "Hardware decoding", Kind::Choice(HW_DECODE), true), Row::Note( - "Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.", + "Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.", ), ], }, diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index 55e3be06..02e5c306 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filmcraft-platform" -description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS, Media Foundation on Windows, VA-API on Linux), with transparent fallback to the pure-Rust decoders" +description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS, Media Foundation on Windows, VA-API and NVDEC on Linux), with transparent fallback to the pure-Rust decoders" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/platform/README.md b/crates/platform/README.md index 58df2f6e..a0237143 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -74,6 +74,16 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") ends it with an error, since a hardware stream cannot be finished in software. The same session, ring and NV12 path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows, NVENC)](#hardware-h265-hevc-encoding-windows-nvenc). +- **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264 + 8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at + run time; without them (or without a device) nothing is registered. NVIDIA's own parser takes the + stream as Annex B access units, so there is no host-side DPB: `cuvid::Session` creates the decoder + in the parser's sequence callback, decodes in its picture callback, and reads each displayed + picture back (NV12 / P016) through the CUDA driver into the same planes as the other backends. + The factory goes in front of VA-API's: what NVDEC declines still reaches VA-API (another GPU), + then the software decoders. After a start at a CRA picture its RASL pictures are not fed (no + decoder outputs them, and the parser would otherwise show the CRA picture with the last one's + timestamp). A seek recreates the parser and keeps the decoder. - **Linux: VA-API, H.264 (`avcC`) and HEVC (`hvcC`)**: H.264 8-bit 4:2:0 progressive, Constrained Baseline / Main / High; HEVC Main, Main 10 and Main Still Picture, 4:2:0 8- and 10-bit (`vaapi/`), on Intel (iHD, i965), AMD and other Mesa drivers. `libva.so.2` and `libva-drm.so.2` are loaded at run time (`vaapi/va.rs`; no libva headers needed to build), and each decoder opens @@ -288,6 +298,8 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main | `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) | | `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages | | `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` | +| `tests/nvdec.rs` (Linux, NVIDIA driver) | H.264 High, HEVC Main (open GOP: CRA + RASL) and Main 10 at 640x360, H.264 and HEVC Main 10 at 1080p: bit-exact with the software decoders over the whole stream, after `reset` + reseek to every sync sample and again after the resets; a forced mid-stream failure continues in software with identical output; interlaced and 10-bit H.264 and 4:2:2 HEVC are declined, as is everything while Hardware decoding is Off; damaged samples (bit flips, truncation, corrupt lengths) never crash or hang | +| `src/nvdec/abi_tests.rs` | FFI structs' sizes, alignments, field offsets and constants against a C compiler's view of NVIDIA's `cuviddec.h` / `nvcuvid.h` (nv-codec-headers) | | `tests/vaapi.rs` (Linux, VA-API) | HEVC Main (open GOP: CRA + RASL), Main 10, scaling lists, 3 slices with WPP (10-bit), weighted prediction with 4 references, transform skip + AMP + B-pyramid (10-bit, open GOP), Main / Main 10 at 1080p and 2160p (compared picture by picture, little memory); H.264 High (B-pyramid), Constrained Baseline with 3 slices per picture, Main with explicit weighted prediction (P and B) and 4 references, temporal direct, custom scaling matrices (JVT) with the 8x8 transform and 2 slices, an open-GOP stream, and 1080p / 2160p, 640×360 (coded 368: cropping): every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample (after an open-GOP I picture, from the seek point on), a mid-stream `flush` with decoding carrying on, and a full pass after resets; forced mid-stream failures (`VaDecoder::fail_after`) at five points continue with the software decoder's exact output; interlaced and 10-bit H.264, 4:2:2 HEVC and the Off setting are declined; an HEVC mid-GOP flush is not checked (it ends the stream, as in software); seeded mutation of samples (through the hybrid and straight into the hardware decoder) and of `avcC` records never panics or hangs; 40 decoders created and dropped; concurrent decoders; a decoder moving between threads | | `src/vaapi/tests.rs` (every OS) | The H.264 and HEVC front ends with a recording stand-in for the hardware, on x264 streams (B-pyramid, weighted prediction, 4 slices, open GOP with temporal direct) and x265 streams (open GOP with RASL, B-pyramid, Main 10 with 2 slices and weighted prediction): output order and pts are the software decoder's, also after seeks and a mid-stream flush; every buffer is consistent (current surface free, references decoded and listed once, reference lists of the active length, slice data offsets inside the slice); damaged samples are errors | | `src/vaapi/abi_tests.rs` | FFI structs' sizes, alignments, field offsets, constants and bit-field positions against a C compiler's view of libva's `va.h` and `va_dec_hevc.h` (VA-API 1.23) | diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index da0164e2..2ddf5fab 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -4,8 +4,8 @@ //! [`register`] puts the platform's hardware decoder factory in front of FilmCraft's own decoders //! (`filmcraft_codecs::register_video_decoder`). Today that is VideoToolbox on macOS for H.264 //! (`avcC`) and HEVC (`hvcC`) streams, 8- and 10-bit, 4:2:0 and 4:2:2; Media Foundation / DXVA on -//! Windows; VA-API on Linux for H.264 ([`vaapi`]); on other systems (and on Linux without a VA-API -//! driver) registration does nothing and reports [`Availability::Unavailable`]. It also registers a +//! Windows; NVDEC ([`nvdec`], NVIDIA's driver) and VA-API ([`vaapi`]) on Linux for H.264 and HEVC; on other systems (and on +//! Linux with neither) registration does nothing and reports [`Availability::Unavailable`]. It also registers a //! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an //! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on //! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows @@ -39,6 +39,8 @@ pub mod hardware_encode; pub mod hybrid; #[cfg(target_os = "windows")] pub mod media_foundation; +#[cfg(all(target_os = "linux", target_pointer_width = "64"))] +pub mod nvdec; #[cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] pub mod nvenc; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] @@ -101,9 +103,9 @@ pub fn register() -> Availability { } #[cfg(target_os = "linux")] { - match vaapi::va::probe() { + static ONCE: std::sync::Once = std::sync::Once::new(); + let mut available = match vaapi::va::probe() { Ok(driver) => { - static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| log::info!("hardware decoding through VA-API: {driver}")); filmcraft_codecs::register_video_decoder(vaapi_factory); filmcraft_codecs::hw::set_hw_backend("VA-API"); @@ -111,9 +113,23 @@ pub fn register() -> Availability { } Err(why) => { log::info!("no VA-API hardware decoding: {why}"); - Availability::Unavailable("no VA-API driver (libva and a DRM render node)") + Availability::Unavailable("no VA-API driver (libva and a DRM render node) and no NVIDIA driver (NVDEC)") } + }; + // NVIDIA's proprietary driver has no VA-API of its own: NVDEC goes in front, and what it + // declines still reaches VA-API (a second GPU) and then the software decoders + #[cfg(target_pointer_width = "64")] + match nvdec::cuvid::probe() { + Ok(driver) => { + static NVDEC: std::sync::Once = std::sync::Once::new(); + NVDEC.call_once(|| log::info!("hardware decoding through NVDEC: {driver}")); + filmcraft_codecs::register_video_decoder(nvdec_factory); + filmcraft_codecs::hw::set_hw_backend("NVDEC"); + available = Availability::Available("NVDEC"); + } + Err(why) => log::info!("no NVDEC hardware decoding: {why}"), } + available } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { @@ -133,6 +149,10 @@ pub fn registered() -> bool { } #[cfg(target_os = "linux")] { + #[cfg(target_pointer_width = "64")] + if filmcraft_codecs::video_decoder_registered(nvdec_factory) { + return true; + } filmcraft_codecs::video_decoder_registered(vaapi_factory) } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] @@ -154,7 +174,13 @@ pub fn hardware_decoder_for(entry: &filmcraft_isobmff::SampleEntry) -> bool { } #[cfg(target_os = "linux")] { - filmcraft_codecs::hw::NalStreamInfo::from_entry(entry).and_then(|r| r.ok()).is_some_and(|info| vaapi::VaDecoder::new(info).is_ok()) + filmcraft_codecs::hw::NalStreamInfo::from_entry(entry).and_then(|r| r.ok()).is_some_and(|info| { + #[cfg(target_pointer_width = "64")] + if nvdec::NvDecoder::new(info.clone()).is_ok() { + return true; + } + vaapi::VaDecoder::new(info).is_ok() + }) } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { @@ -218,3 +244,24 @@ pub fn vaapi_factory(entry: &filmcraft_isobmff::SampleEntry) -> Option Option>> { + if !filmcraft_codecs::hw::hardware_decoding() { + return None; + } + let info = filmcraft_codecs::hw::NalStreamInfo::from_entry(entry)?.ok()?; + match nvdec::NvDecoder::new(info.clone()) { + Ok(nv) => Some(Ok(Box::new(HybridDecoder::new(Box::new(nv), entry.clone(), info)))), + Err(why) => { + log::info!("NVDEC declined {} video: {why}", entry.codec.name()); + // with VA-API behind this factory, that one counts the decline (or takes the stream) + if !filmcraft_codecs::video_decoder_registered(vaapi_factory) { + filmcraft_codecs::hw::note_hw_declined(); + } + None + } + } +} diff --git a/crates/platform/src/nvdec/abi_tests.rs b/crates/platform/src/nvdec/abi_tests.rs new file mode 100644 index 00000000..5ac47d25 --- /dev/null +++ b/crates/platform/src/nvdec/abi_tests.rs @@ -0,0 +1,130 @@ +//! Linux LP64 layouts generated with GCC from the installed MIT-licensed NVIDIA +//! `dynlink_cuda.h`, `dynlink_cuviddec.h` and `dynlink_nvcuvid.h` (NVDEC API 13.1). +//! Regenerate with a C program printing sizeof, _Alignof, offsetof for every field +//! below, constant values, and the video-signal byte with each bit-field set alone. +use super::ffi::*; +use std::mem::{align_of, offset_of, size_of}; +#[test] +fn layouts_match_the_header() { + assert_eq!((size_of::(), align_of::()), (64, 4)); + assert_eq!(offset_of!(CUVIDEOFORMAT, codec), 0); + assert_eq!(offset_of!(CUVIDEOFORMAT, frame_rate), 4); + assert_eq!(offset_of!(CUVIDEOFORMAT, progressive_sequence), 12); + assert_eq!(offset_of!(CUVIDEOFORMAT, bit_depth_luma_minus8), 13); + assert_eq!(offset_of!(CUVIDEOFORMAT, bit_depth_chroma_minus8), 14); + assert_eq!(offset_of!(CUVIDEOFORMAT, min_num_decode_surfaces), 15); + assert_eq!(offset_of!(CUVIDEOFORMAT, coded_width), 16); + assert_eq!(offset_of!(CUVIDEOFORMAT, coded_height), 20); + assert_eq!(offset_of!(CUVIDEOFORMAT, display_area), 24); + assert_eq!(offset_of!(CUVIDEOFORMAT, chroma_format), 40); + assert_eq!(offset_of!(CUVIDEOFORMAT, bitrate), 44); + assert_eq!(offset_of!(CUVIDEOFORMAT, display_aspect_ratio), 48); + assert_eq!(offset_of!(CUVIDEOFORMAT, video_signal_description), 56); + assert_eq!(offset_of!(CUVIDEOFORMAT, seqhdr_data_length), 60); + assert_eq!((size_of::(), align_of::()), (88, 4)); + assert_eq!(offset_of!(CUVIDDECODECAPS, eCodecType), 0); + assert_eq!(offset_of!(CUVIDDECODECAPS, eChromaFormat), 4); + assert_eq!(offset_of!(CUVIDDECODECAPS, nBitDepthMinus8), 8); + assert_eq!(offset_of!(CUVIDDECODECAPS, reserved1), 12); + assert_eq!(offset_of!(CUVIDDECODECAPS, bIsSupported), 24); + assert_eq!(offset_of!(CUVIDDECODECAPS, nNumNVDECs), 25); + assert_eq!(offset_of!(CUVIDDECODECAPS, nOutputFormatMask), 26); + assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxWidth), 28); + assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxHeight), 32); + assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxMBCount), 36); + assert_eq!(offset_of!(CUVIDDECODECAPS, nMinWidth), 40); + assert_eq!(offset_of!(CUVIDDECODECAPS, nMinHeight), 42); + assert_eq!(offset_of!(CUVIDDECODECAPS, bIsHistogramSupported), 44); + assert_eq!(offset_of!(CUVIDDECODECAPS, nCounterBitDepth), 45); + assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxHistogramBins), 46); + assert_eq!(offset_of!(CUVIDDECODECAPS, bIsDecodeStatsSupported), 48); + assert_eq!(offset_of!(CUVIDDECODECAPS, reserved4), 49); + assert_eq!(offset_of!(CUVIDDECODECAPS, reserved3), 52); + assert_eq!((size_of::(), align_of::()), (176, 8)); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulWidth), 0); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulHeight), 8); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulNumDecodeSurfaces), 16); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, CodecType), 24); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ChromaFormat), 28); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulCreationFlags), 32); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, bitDepthMinus8), 40); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulIntraDecodeOnly), 48); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulMaxWidth), 56); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulMaxHeight), 64); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, Reserved1), 72); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, display_area), 80); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, OutputFormat), 88); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, DeinterlaceMode), 92); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulTargetWidth), 96); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulTargetHeight), 104); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulNumOutputSurfaces), 112); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, vidLock), 120); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, target_rect), 128); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, enableHistogram), 136); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, enableDecodeFeatures), 144); + assert_eq!(offset_of!(CUVIDDECODECREATEINFO, Reserved2), 152); + assert_eq!((size_of::(), align_of::()), (136, 8)); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, CodecType), 0); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulMaxNumDecodeSurfaces), 4); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulClockRate), 8); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulErrorThreshold), 12); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulMaxDisplayDelay), 16); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, flags), 20); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, uReserved1), 24); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, pUserData), 40); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnSequenceCallback), 48); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnDecodePicture), 56); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnDisplayPicture), 64); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnGetOperatingPoint), 72); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnGetSEIMsg), 80); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, pvReserved2), 88); + assert_eq!(offset_of!(CUVIDPARSERPARAMS, pExtVideoInfo), 128); + assert_eq!((size_of::(), align_of::()), (32, 8)); + assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, flags), 0); + assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, payload_size), 8); + assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, payload), 16); + assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, timestamp), 24); + assert_eq!((size_of::(), align_of::()), (24, 8)); + assert_eq!(offset_of!(CUVIDPARSERDISPINFO, picture_index), 0); + assert_eq!(offset_of!(CUVIDPARSERDISPINFO, progressive_frame), 4); + assert_eq!(offset_of!(CUVIDPARSERDISPINFO, top_field_first), 8); + assert_eq!(offset_of!(CUVIDPARSERDISPINFO, repeat_first_field), 12); + assert_eq!(offset_of!(CUVIDPARSERDISPINFO, timestamp), 16); + assert_eq!((size_of::(), align_of::()), (264, 8)); + assert_eq!(offset_of!(CUVIDPROCPARAMS, progressive_frame), 0); + assert_eq!(offset_of!(CUVIDPROCPARAMS, second_field), 4); + assert_eq!(offset_of!(CUVIDPROCPARAMS, top_field_first), 8); + assert_eq!(offset_of!(CUVIDPROCPARAMS, unpaired_field), 12); + assert_eq!(offset_of!(CUVIDPROCPARAMS, reserved_flags), 16); + assert_eq!(offset_of!(CUVIDPROCPARAMS, reserved_zero), 20); + assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_input_dptr), 24); + assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_input_pitch), 32); + assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_input_format), 36); + assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_output_dptr), 40); + assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_output_pitch), 48); + assert_eq!(offset_of!(CUVIDPROCPARAMS, Reserved1), 52); + assert_eq!(offset_of!(CUVIDPROCPARAMS, output_stream), 56); + assert_eq!(offset_of!(CUVIDPROCPARAMS, Reserved), 64); + assert_eq!(offset_of!(CUVIDPROCPARAMS, histogram_dptr), 248); + assert_eq!(offset_of!(CUVIDPROCPARAMS, pCuvidProcExt), 256); + assert_eq!((size_of::(), align_of::()), (8, 4)); + assert_eq!((size_of::(), align_of::()), (16, 4)); + assert_eq!((size_of::(), align_of::()), (8, 2)); + assert_eq!((size_of::(), align_of::()), (8, 4)); + assert_eq!((size_of::(), align_of::()), (4, 1)); +} +#[test] +fn constants_match_the_header() { + assert_eq!(cudaVideoCodec_H264 as i64, 4); + assert_eq!(cudaVideoCodec_HEVC as i64, 8); + assert_eq!(cudaVideoChromaFormat_420 as i64, 1); + assert_eq!(cudaVideoSurfaceFormat_NV12 as i64, 0); + assert_eq!(cudaVideoSurfaceFormat_P016 as i64, 1); + assert_eq!(cudaVideoDeinterlaceMode_Weave as i64, 0); + assert_eq!(cudaVideoCreate_PreferCUVID as i64, 4); + assert_eq!(CUVID_PKT_ENDOFSTREAM as i64, 1); + assert_eq!(CUVID_PKT_TIMESTAMP as i64, 2); + assert_eq!(CUVID_PKT_ENDOFPICTURE as i64, 8); + assert_eq!(7u8, 7); + assert_eq!(1u8 << 3, 8); +} diff --git a/crates/platform/src/nvdec/cuvid.rs b/crates/platform/src/nvdec/cuvid.rs new file mode 100644 index 00000000..d8d3305b --- /dev/null +++ b/crates/platform/src/nvdec/cuvid.rs @@ -0,0 +1,675 @@ +//! Synchronous NVDEC parser callbacks and CUDA readback. All driver handles stay here. +use std::cell::Cell; +use std::ffi::c_void; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::ptr; + +use filmcraft_codecs::hw::NalCodec; +use filmcraft_frame::VideoFrame; + +use super::ffi::*; +use crate::biplanar::{Biplanar, Geometry, to_frame}; +use crate::nvenc::device::Device; + +const MAX_SURFACES: u32 = 64; +const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024; +const MAX_PACKET_BYTES: usize = 64 * 1024 * 1024; +const MAX_READY_BYTES: usize = 512 * 1024 * 1024; + +#[derive(Clone, Copy)] +struct Api { + caps: GetDecoderCaps, + create_decoder: CreateDecoder, + destroy_decoder: DestroyDecoder, + decode: DecodePicture, + map: MapVideoFrame, + unmap: UnmapVideoFrame, + create_parser: CreateVideoParser, + parse: ParseVideoData, + destroy_parser: DestroyVideoParser, +} +impl Api { + fn load() -> Result<(Self, libloading::Library), String> { + // SAFETY: symbols have the signatures in NVIDIA's public headers. The caller + // retains the returned library until all handles using these pointers are destroyed. + unsafe { + let library = libloading::Library::new("libnvcuvid.so.1").map_err(|e| format!("no NVIDIA NVDEC library: {e}"))?; + let api = Self { + caps: *library.get(b"cuvidGetDecoderCaps\0").map_err(|e| e.to_string())?, + create_decoder: *library.get(b"cuvidCreateDecoder\0").map_err(|e| e.to_string())?, + destroy_decoder: *library.get(b"cuvidDestroyDecoder\0").map_err(|e| e.to_string())?, + decode: *library.get(b"cuvidDecodePicture\0").map_err(|e| e.to_string())?, + map: *library.get(b"cuvidMapVideoFrame64\0").map_err(|e| e.to_string())?, + unmap: *library.get(b"cuvidUnmapVideoFrame64\0").map_err(|e| e.to_string())?, + create_parser: *library.get(b"cuvidCreateVideoParser\0").map_err(|e| e.to_string())?, + parse: *library.get(b"cuvidParseVideoData\0").map_err(|e| e.to_string())?, + destroy_parser: *library.get(b"cuvidDestroyVideoParser\0").map_err(|e| e.to_string())?, + }; + Ok((api, library)) + } + } +} +fn status(code: i32, operation: &str) -> Result<(), String> { + if code == 0 { Ok(()) } else { Err(format!("{operation} failed ({code})")) } +} + +// Only POD structs from ffi.rs are zeroed: no Rust references or invalid enum discriminants. +fn caps(codec: i32, bits: u32) -> CUVIDDECODECAPS { + // SAFETY: this C struct contains only integers; zero is a valid value for every field. + let mut caps: CUVIDDECODECAPS = unsafe { std::mem::zeroed() }; + caps.eCodecType = codec; + caps.eChromaFormat = cudaVideoChromaFormat_420; + caps.nBitDepthMinus8 = bits.saturating_sub(8); + caps +} +fn output_format(bits: u32) -> i32 { + if bits == 10 { cudaVideoSurfaceFormat_P016 } else { cudaVideoSurfaceFormat_NV12 } +} +fn validate(coded: (u32, u32), geometry: Geometry, reorder: usize) -> Result { + let (w, h) = coded; + let (x, y, cw, ch) = geometry.crop; + if w == 0 || h == 0 || w > 8192 || h > 8192 || !w.is_multiple_of(2) || !h.is_multiple_of(2) { + return Err(format!("NVDEC coded size {w}x{h} is not supported")); + } + if !matches!(geometry.bits, 8 | 10) || cw == 0 || ch == 0 || x.checked_add(cw).is_none_or(|end| end > w) || y.checked_add(ch).is_none_or(|end| end > h) { + return Err("NVDEC picture depth or crop is invalid".into()); + } + let delay = u32::try_from(reorder).map_err(|_| "NVDEC reorder count overflows")?; + if delay >= MAX_SURFACES { + return Err("NVDEC reorder count is too large".into()); + } + Ok(delay) +} +fn check_caps(caps: &CUVIDDECODECAPS, coded: (u32, u32), bits: u32) -> Result<(), String> { + let (w, h) = coded; + let blocks = w.div_ceil(16).checked_mul(h.div_ceil(16)).ok_or("NVDEC macroblock count overflows")?; + let mask = 1u16.checked_shl(output_format(bits) as u32).ok_or("NVDEC output format overflows")?; + if caps.bIsSupported == 0 + || caps.nOutputFormatMask & mask == 0 + || w < u32::from(caps.nMinWidth) + || h < u32::from(caps.nMinHeight) + || w > caps.nMaxWidth + || h > caps.nMaxHeight + || blocks > caps.nMaxMBCount + { + return Err(format!("GPU does not support NVDEC codec {} at {w}x{h}, {bits}-bit 4:2:0", caps.eCodecType)); + } + Ok(()) +} + +struct State { + api: Api, + // Points to Session's separately boxed, immutable Device (stable across moves). + device: *const Device, + decoder: *mut c_void, + codec: i32, + coded: (u32, u32), + geometry: Geometry, + delay: u32, + surfaces: u32, + mapped: Cell>, + pending: Vec, + ready: Vec<(i64, VideoFrame)>, + error: Option, +} +impl State { + fn sequence(&mut self, format: &CUVIDEOFORMAT) -> Result { + // The driver rounds the coded size up to its own block size (HEVC 640x360 comes back as + // 640x368): take its size, which is the mapped surface's, when it holds the stream's. + let driver = (format.coded_width, format.coded_height); + let fits = |d: u32, c: u32| d >= c && d - c < 64; + if !(fits(driver.0, self.coded.0) && fits(driver.1, self.coded.1)) || (!self.decoder.is_null() && driver != self.coded) { + return Err(format!("NVDEC sequence is {}x{}, the stream {}x{}", driver.0, driver.1, self.coded.0, self.coded.1)); + } + self.coded = driver; + if format.codec != self.codec + || format.chroma_format != cudaVideoChromaFormat_420 + || format.progressive_sequence != 1 + || u32::from(format.bit_depth_luma_minus8).checked_add(8) != Some(self.geometry.bits) + || format.bit_depth_chroma_minus8 != format.bit_depth_luma_minus8 + { + return Err("NVDEC sequence differs from the stream configuration".into()); + } + let surfaces = u32::from(format.min_num_decode_surfaces).max(self.delay.checked_add(1).ok_or("NVDEC surface count overflows")?).max(2); + if format.min_num_decode_surfaces == 0 || surfaces > MAX_SURFACES { + return Err("NVDEC reported an invalid decode surface count".into()); + } + if !self.decoder.is_null() { + if surfaces > self.surfaces { + return Err("NVDEC sequence needs more decode surfaces".into()); + } + return i32::try_from(self.surfaces).map_err(|_| "NVDEC surface count overflows".into()); + } + // SAFETY: plain C data (integers and pointers) permits an all-zero initialization. + let mut info: CUVIDDECODECREATEINFO = unsafe { std::mem::zeroed() }; + info.ulWidth = u64::from(self.coded.0); + info.ulHeight = u64::from(self.coded.1); + info.ulNumDecodeSurfaces = u64::from(surfaces); + info.CodecType = self.codec; + info.ChromaFormat = cudaVideoChromaFormat_420; + info.ulCreationFlags = cudaVideoCreate_PreferCUVID; + info.bitDepthMinus8 = u64::from(self.geometry.bits.saturating_sub(8)); + info.ulMaxWidth = info.ulWidth; + info.ulMaxHeight = info.ulHeight; + let right = i16::try_from(self.coded.0).map_err(|_| "NVDEC width overflows")?; + let bottom = i16::try_from(self.coded.1).map_err(|_| "NVDEC height overflows")?; + // Explicit full coded rectangles prevent the parser's display crop being applied twice. + info.display_area = ShortRect { left: 0, top: 0, right, bottom }; + info.target_rect = info.display_area; + info.OutputFormat = output_format(self.geometry.bits); + info.DeinterlaceMode = cudaVideoDeinterlaceMode_Weave; + info.ulTargetWidth = info.ulWidth; + info.ulTargetHeight = info.ulHeight; + info.ulNumOutputSurfaces = 1; + // SAFETY: the context is current throughout the parser call; info and the out-pointer + // are live. The decoder is owned by Session until explicitly destroyed. + status(unsafe { (self.api.create_decoder)(&mut self.decoder, &mut info) }, "cuvidCreateDecoder")?; + if self.decoder.is_null() { + return Err("NVDEC returned a null decoder".into()); + } + self.surfaces = surfaces; + i32::try_from(surfaces).map_err(|_| "NVDEC surface count overflows".into()) + } + fn display(&mut self, info: &CUVIDPARSERDISPINFO) -> Result { + if self.decoder.is_null() + || info.picture_index < 0 + || u32::try_from(info.picture_index).is_ok_and(|i| i >= self.surfaces) + || info.progressive_frame != 1 + || info.repeat_first_field != 0 + { + return Err("NVDEC reported an invalid or non-progressive display picture".into()); + } + if self.pending.len() >= MAX_SURFACES as usize { + return Err("NVDEC display queue exceeds its surface limit".into()); + } + self.pending.try_reserve(1).map_err(|e| format!("NVDEC display allocation failed: {e}"))?; + self.pending.push(*info); + Ok(1) + } + fn drain(&mut self) -> Result<(), String> { + // Copy queued displays before another decode can recycle their picture indices. + for info in std::mem::take(&mut self.pending) { + self.map_picture(&info)?; + } + Ok(()) + } + fn map_picture(&mut self, info: &CUVIDPARSERDISPINFO) -> Result<(), String> { + // SAFETY: this C struct contains integers and nullable pointers only. + let mut proc: CUVIDPROCPARAMS = unsafe { std::mem::zeroed() }; + proc.progressive_frame = 1; + proc.top_field_first = info.top_field_first; + let (mut address, mut pitch) = (0u64, 0u32); + // SAFETY: the validated picture index belongs to the live decoder. Both output + // pointers and proc are writable, and Session has pushed the context on this thread. + status(unsafe { (self.api.map)(self.decoder, info.picture_index, &mut address, &mut pitch, &mut proc) }, "cuvidMapVideoFrame64")?; + self.mapped.set(Some(address)); + let mapped = Mapped { api: self.api, decoder: self.decoder, address, outstanding: &self.mapped, active: true }; + let frame = self.readback(address, pitch); + mapped.unmap()?; + let frame = frame?; + self.ready.try_reserve(1).map_err(|e| format!("NVDEC output allocation failed: {e}"))?; + self.ready.push((info.timestamp, frame)); + Ok(()) + } + fn readback(&self, address: u64, pitch: u32) -> Result { + if address == 0 { + return Err("NVDEC returned a null mapped address".into()); + } + let (luma_size, total) = mapped_layout(self.coded, self.geometry.bits, pitch)?; + let picture_bytes = (self.coded.0 as usize) + .checked_mul(self.coded.1 as usize) + .and_then(|n| n.checked_mul(if self.geometry.bits == 10 { 3 } else { 2 })) + .ok_or("NVDEC output size overflows")?; + if self.ready.len() >= MAX_SURFACES as usize + || self.ready.len().checked_add(1).and_then(|n| n.checked_mul(picture_bytes)).is_none_or(|n| n > MAX_READY_BYTES) + { + return Err("NVDEC output queue exceeds its memory limit".into()); + } + address.checked_add(u64::try_from(total).map_err(|_| "NVDEC mapped size overflows")?).ok_or("NVDEC mapped address overflows")?; + let mut host = Vec::new(); + host.try_reserve_exact(total).map_err(|e| format!("NVDEC readback allocation failed: {e}"))?; + host.resize(total, 0); + // SAFETY: Session's separately boxed device outlives State and every synchronous callback; + // it is immutable, and the CUDA context is current. No mutable Device references exist. + let device = unsafe { self.device.as_ref() }.ok_or("NVDEC CUDA device is missing")?; + device.copy_to_host(address, &mut host)?; + let luma = host.get(..luma_size).ok_or("NVDEC luma range is invalid")?; + let chroma = host.get(luma_size..).ok_or("NVDEC chroma range is invalid")?; + to_frame(&Biplanar { luma, chroma, stride: pitch as usize, rows: self.coded.1 as usize }, &self.geometry) + } +} +fn mapped_layout(coded: (u32, u32), bits: u32, pitch: u32) -> Result<(usize, usize), String> { + let row_bytes = coded.0.checked_mul(if bits == 10 { 2 } else { 1 }).ok_or("NVDEC row size overflows")?; + if pitch < row_bytes || pitch == 0 || coded.1 == 0 || !coded.1.is_multiple_of(2) || !matches!(bits, 8 | 10) { + return Err("NVDEC reported an invalid mapped pitch or size".into()); + } + let luma = (pitch as usize).checked_mul(coded.1 as usize).ok_or("NVDEC luma size overflows")?; + let total = luma.checked_add(luma / 2).ok_or("NVDEC mapped size overflows")?; + if total > MAX_FRAME_BYTES { + return Err("NVDEC mapped frame exceeds its memory limit".into()); + } + Ok((luma, total)) +} +struct Mapped<'a> { + api: Api, + decoder: *mut c_void, + address: u64, + outstanding: &'a Cell>, + active: bool, +} +impl Mapped<'_> { + fn unmap(mut self) -> Result<(), String> { + self.active = false; + // SAFETY: this guard owns one successful mapping on this decoder, and the context is + // still current. No GPU-memory references escape the synchronous host copy. + status(unsafe { (self.api.unmap)(self.decoder, self.address) }, "cuvidUnmapVideoFrame64")?; + self.outstanding.set(None); + Ok(()) + } +} +impl Drop for Mapped<'_> { + fn drop(&mut self) { + if self.active { + // SAFETY: balances a successful map on every early return or caught panic; + // the enclosing parser operation retains the current context and driver library. + let st = unsafe { (self.api.unmap)(self.decoder, self.address) }; + match status(st, "cuvidUnmapVideoFrame64") { + Ok(()) => self.outstanding.set(None), + Err(e) => log::warn!("{e}"), + } + } + } +} + +/// Callback state is exclusively accessed by synchronous callbacks while parse is running. +unsafe fn callback(data: *mut c_void, f: impl FnOnce(&mut State) -> Result) -> i32 { + // SAFETY: cuvid receives the stable Box pointer at parser creation; callbacks run + // synchronously, without a live Rust reference to State in the enclosing parse call. + let Some(state) = (unsafe { data.cast::().as_mut() }) else { return 0 }; + if state.error.is_some() { + return 0; + } + match catch_unwind(AssertUnwindSafe(|| f(state))) { + Ok(Ok(value)) => value, + Ok(Err(error)) => { + state.error = Some(error); + 0 + } + Err(_) => { + state.error = Some("NVDEC callback panicked".into()); + 0 + } + } +} +unsafe extern "C" fn sequence(data: *mut c_void, format: *mut CUVIDEOFORMAT) -> i32 { + // SAFETY: cuvid invokes this callback with its synchronous format pointer and our boxed state. + unsafe { callback(data, |state| state.sequence(format.as_ref().ok_or("NVDEC sequence pointer is null")?)) } +} +unsafe extern "C" fn decode(data: *mut c_void, picture: *mut CUVIDPICPARAMS) -> i32 { + // SAFETY: the opaque picture parameters belong to cuvid for the callback's duration; + // they are only passed through to the live decoder with the context current. + unsafe { + callback(data, |state| { + if state.decoder.is_null() || picture.is_null() { + return Err("NVDEC decode pointer or decoder is null".into()); + } + state.drain()?; + status((state.api.decode)(state.decoder, picture), "cuvidDecodePicture")?; + Ok(1) + }) + } +} +unsafe extern "C" fn display(data: *mut c_void, picture: *mut CUVIDPARSERDISPINFO) -> i32 { + // SAFETY: cuvid supplies display metadata valid during this synchronous callback; + // null is only an EOS notification, which we did not request. + unsafe { callback(data, |state| state.display(picture.as_ref().ok_or("NVDEC display pointer is null")?)) } +} + +/// A movable session. Each operation pushes its CUDA context on the calling thread. +pub struct Session { + state: Box, + parser: *mut c_void, + device: Box, + // Dropped after device/context release, and after all cuvid handles have been destroyed. + _library: libloading::Library, +} +// SAFETY: no callbacks or GPU-memory references outlive an operation. The boxed state and device +// retain stable addresses across moves; every driver call pushes the context on its calling thread. +unsafe impl Send for Session {} +impl Session { + pub fn new(codec: NalCodec, coded: (u32, u32), geometry: Geometry, reorder: usize) -> Result { + let delay = validate(coded, geometry, reorder)?; + if matches!(codec, NalCodec::H264) && geometry.bits != 8 { + return Err("NVDEC only takes 8-bit H.264".into()); + } + let codec = match codec { + NalCodec::H264 => cudaVideoCodec_H264, + NalCodec::Hevc => cudaVideoCodec_HEVC, + }; + let (api, library) = Api::load()?; + let device = Box::new(Device::new()?); + { + let _current = device.push_current()?; + let mut capabilities = caps(codec, geometry.bits); + // SAFETY: capabilities is a live writable C struct, and the CUDA context is current. + status(unsafe { (api.caps)(&mut capabilities) }, "cuvidGetDecoderCaps")?; + check_caps(&capabilities, coded, geometry.bits)?; + } + let state = Box::new(State { + api, + device: ptr::from_ref(device.as_ref()), + decoder: ptr::null_mut(), + codec, + coded, + geometry, + delay, + surfaces: 0, + mapped: Cell::new(None), + pending: Vec::new(), + ready: Vec::new(), + error: None, + }); + let mut session = Self { state, parser: ptr::null_mut(), device, _library: library }; + session.create_parser()?; + Ok(session) + } + fn create_parser(&mut self) -> Result<(), String> { + let _current = self.device.push_current()?; + // SAFETY: plain C data, nullable callback pointers and opaque pointers allow zeros. + let mut params: CUVIDPARSERPARAMS = unsafe { std::mem::zeroed() }; + params.CodecType = self.state.codec; + params.ulMaxNumDecodeSurfaces = 1; // sequence callback supplies the real DPB size + params.ulClockRate = 0; + params.ulMaxDisplayDelay = self.state.delay; + params.pUserData = (&raw mut *self.state).cast(); + params.pfnSequenceCallback = Some(sequence); + params.pfnDecodePicture = Some(decode); + params.pfnDisplayPicture = Some(display); + // SAFETY: params and output pointer are valid; boxed user data remains alive until + // parser destruction, and the library is retained by Session. + status(unsafe { (self.state.api.create_parser)(&mut self.parser, &mut params) }, "cuvidCreateVideoParser")?; + if self.parser.is_null() { + return Err("NVDEC returned a null parser".into()); + } + Ok(()) + } + fn parse(&mut self, payload: &[u8], pts: i64, flags: u64) -> Result, String> { + if let Some(error) = &self.state.error { + return Err(error.clone()); + } + if self.parser.is_null() { + return Err("NVDEC parser is not available".into()); + } + let _current = self.device.push_current()?; + let mut packet = CUVIDSOURCEDATAPACKET { + flags, + payload_size: u64::try_from(payload.len()).map_err(|_| "NVDEC packet length overflows")?, + payload: if payload.is_empty() { ptr::null() } else { payload.as_ptr() }, + timestamp: pts, + }; + // SAFETY: parser is live, payload and packet outlive this synchronous call. We do not + // borrow State across it, so callbacks can access their exclusive boxed user data. + let st = unsafe { (self.state.api.parse)(self.parser, &mut packet) }; + if let Some(error) = &self.state.error { + return Err(error.clone()); + } + status(st, "cuvidParseVideoData")?; + match catch_unwind(AssertUnwindSafe(|| self.state.drain())) { + Ok(result) => result?, + Err(_) => return Err("NVDEC readback panicked".into()), + } + Ok(std::mem::take(&mut self.state.ready)) + } + pub fn feed(&mut self, annex_b: &[u8], pts: i64) -> Result, String> { + if annex_b.is_empty() || annex_b.len() > MAX_PACKET_BYTES { + return Err("NVDEC access unit is empty or too large".into()); + } + let result = self.parse(annex_b, pts, CUVID_PKT_TIMESTAMP | CUVID_PKT_ENDOFPICTURE); + if let Err(error) = &result { + self.state.error.get_or_insert_with(|| error.clone()); + } + result + } + pub fn flush(&mut self) -> Result, String> { + let result = self.parse(&[], 0, CUVID_PKT_ENDOFSTREAM); + self.restart()?; + result + } + /// Destroy the parser and, unless `keep_decoder`, the decoder (a seek keeps it: creating one + /// costs tens of milliseconds, and the next parser's sequence callback takes it as it is). + fn destroy(&mut self, keep_decoder: bool) -> Result<(), String> { + let _current = self.device.push_current()?; + let mut error = None; + if let Some(address) = self.state.mapped.get() { + // SAFETY: retry an outstanding map before releasing its decoder; context is current. + match status(unsafe { (self.state.api.unmap)(self.state.decoder, address) }, "cuvidUnmapVideoFrame64") { + Ok(()) => self.state.mapped.set(None), + Err(e) => { + error = Some(e); + } + } + } + if !keep_decoder && !self.state.decoder.is_null() { + // SAFETY: owned decoder, no host views or callbacks remain; unmapping was attempted + // first, and context and library remain live while the driver releases its resources. + match status(unsafe { (self.state.api.destroy_decoder)(self.state.decoder) }, "cuvidDestroyDecoder") { + Ok(()) => { + self.state.decoder = ptr::null_mut(); + self.state.surfaces = 0; + self.state.mapped.set(None); + } + Err(e) => { + error.get_or_insert(e); + } + } + } + if !self.parser.is_null() { + // SAFETY: owned parser, no parse call running; user data and library are still live. + match status(unsafe { (self.state.api.destroy_parser)(self.parser) }, "cuvidDestroyVideoParser") { + Ok(()) => self.parser = ptr::null_mut(), + Err(e) => { + error.get_or_insert(e); + } + } + } + match error { + Some(error) => Err(error), + None => Ok(()), + } + } + fn restart(&mut self) -> Result<(), String> { + self.state.ready.clear(); + self.state.pending.clear(); + self.state.error = None; + let result = self.destroy(true).and_then(|()| self.create_parser()); + if let Err(error) = &result { + self.state.error = Some(error.clone()); + } + result + } + pub fn reset(&mut self) { + if let Err(e) = self.restart() { + log::warn!("NVDEC reset failed: {e}"); + } + } +} +impl Drop for Session { + fn drop(&mut self) { + if let Err(e) = self.destroy(false) { + log::warn!("NVDEC cleanup failed: {e}"); + } + } +} +/// Load both drivers and retain a primary context; does not create a decoder or parse video. +pub fn probe() -> Result { + let (_api, _library) = Api::load()?; + let device = Device::new()?; + let _current = device.push_current()?; + Ok("NVDEC (libnvcuvid, first CUDA device)".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn geometry() -> Geometry { + Geometry { crop: (0, 0, 640, 360), bits: 8, color: filmcraft_color::ColorInfo::REC709, par: (1, 1) } + } + #[test] + fn hostile_geometry_and_driver_sizes_are_errors() { + for size in [(0, 0), (u32::MAX, 368), (640, u32::MAX), (641, 368)] { + assert!(catch_unwind(|| validate(size, geometry(), 2)).unwrap().is_err()); + } + let mut g = geometry(); + g.crop = (u32::MAX, 0, 640, 360); + assert!(validate((640, 368), g, 2).is_err()); + assert!(validate((640, 368), geometry(), usize::MAX).is_err()); + assert_eq!(validate((640, 368), geometry(), 2).unwrap(), 2); + for (size, bits, pitch) in [((640, 368), 8, 639), ((640, 368), 10, 1279), ((640, 368), 8, u32::MAX), ((640, u32::MAX), 10, 1280)] { + assert!(catch_unwind(|| mapped_layout(size, bits, pitch)).unwrap().is_err()); + } + assert_eq!(mapped_layout((640, 368), 8, 640).unwrap(), (235520, 353280)); + assert_eq!(mapped_layout((640, 368), 10, 1280).unwrap(), (471040, 706560)); + } + #[test] + fn capabilities_decline_unsupported_streams() { + let mut c = caps(cudaVideoCodec_HEVC, 10); + assert!(check_caps(&c, (640, 368), 10).is_err()); + c.bIsSupported = 1; + c.nOutputFormatMask = 2; + c.nMaxWidth = 8192; + c.nMaxHeight = 8192; + c.nMaxMBCount = 65536; + assert!(check_caps(&c, (640, 368), 10).is_ok()); + assert!(check_caps(&c, (640, 368), 8).is_err()); + c.nMaxMBCount = 1; + assert!(check_caps(&c, (640, 368), 10).is_err()); + c.nMinWidth = 1000; + assert!(check_caps(&c, (640, 368), 10).is_err()); + } + #[test] + fn session_is_send() { + fn is_send() {} + is_send::(); + } + + // These stubs must never be called: the callback tests stop before driver access. + unsafe extern "C" fn test_caps(_: *mut CUVIDDECODECAPS) -> i32 { + 999 + } + unsafe extern "C" fn test_create_decoder(_: *mut *mut c_void, _: *mut CUVIDDECODECREATEINFO) -> i32 { + 999 + } + unsafe extern "C" fn test_destroy(_: *mut c_void) -> i32 { + 999 + } + unsafe extern "C" fn test_decode(_: *mut c_void, _: *mut CUVIDPICPARAMS) -> i32 { + 999 + } + unsafe extern "C" fn test_map(_: *mut c_void, _: i32, _: *mut u64, _: *mut u32, _: *mut CUVIDPROCPARAMS) -> i32 { + 999 + } + unsafe extern "C" fn test_unmap(_: *mut c_void, _: u64) -> i32 { + 999 + } + unsafe extern "C" fn test_create_parser(_: *mut *mut c_void, _: *mut CUVIDPARSERPARAMS) -> i32 { + 999 + } + unsafe extern "C" fn test_parse(_: *mut c_void, _: *mut CUVIDSOURCEDATAPACKET) -> i32 { + 999 + } + fn test_state() -> State { + State { + api: Api { + caps: test_caps, + create_decoder: test_create_decoder, + destroy_decoder: test_destroy, + decode: test_decode, + map: test_map, + unmap: test_unmap, + create_parser: test_create_parser, + parse: test_parse, + destroy_parser: test_destroy, + }, + device: ptr::null(), + decoder: ptr::null_mut(), + codec: cudaVideoCodec_H264, + coded: (640, 368), + geometry: geometry(), + delay: 2, + surfaces: 0, + mapped: Cell::new(None), + pending: Vec::new(), + ready: Vec::new(), + error: None, + } + } + #[test] + fn callback_panics_and_hostile_metadata_do_not_escape() { + let mut state = test_state(); + let data = ptr::from_mut(&mut state).cast(); + // SAFETY: data points to exclusive live test state; the closure touches no driver. + assert_eq!(unsafe { callback(data, |_| panic!("synthetic callback panic")) }, 0); + assert_eq!(state.error.as_deref(), Some("NVDEC callback panicked")); + // SAFETY: same live state. A recorded failure prevents the second closure running. + assert_eq!(unsafe { callback(data, |_| Err("later error".into())) }, 0); + assert_eq!(state.error.as_deref(), Some("NVDEC callback panicked")); + state.error = None; + let data = ptr::from_mut(&mut state).cast(); + // SAFETY: valid test user data; null metadata is deliberately tested and never dereferenced. + assert_eq!(unsafe { sequence(data, ptr::null_mut()) }, 0); + assert_eq!(state.error.as_deref(), Some("NVDEC sequence pointer is null")); + // SAFETY: C may report null user data; our callback rejects it before dereferencing. + assert_eq!(unsafe { display(ptr::null_mut(), ptr::null_mut()) }, 0); + // SAFETY: POD C metadata can be initialized to zeros for these hostile-data tests. + let mut format: CUVIDEOFORMAT = unsafe { std::mem::zeroed() }; + format.codec = state.codec; + format.coded_width = 640; + format.coded_height = 368; + format.chroma_format = cudaVideoChromaFormat_420; + format.progressive_sequence = 1; + for count in [0, 65, u8::MAX] { + format.min_num_decode_surfaces = count; + assert!(state.sequence(&format).is_err()); + } + // SAFETY: POD display metadata; the null decoder and hostile index fail before mapping. + let mut picture: CUVIDPARSERDISPINFO = unsafe { std::mem::zeroed() }; + state.decoder = ptr::dangling_mut::(); + state.surfaces = 4; + picture.progressive_frame = 1; + for index in [-1, 4, i32::MAX] { + picture.picture_index = index; + assert!(state.display(&picture).is_err()); + } + picture.picture_index = 0; + picture.timestamp = 17; + assert_eq!(state.display(&picture).unwrap(), 1); + picture.picture_index = 1; + picture.timestamp = -9; + assert_eq!(state.display(&picture).unwrap(), 1); + assert_eq!(state.pending.iter().map(|p| (p.picture_index, p.timestamp)).collect::>(), vec![(0, 17), (1, -9)]); + } + unsafe extern "C" fn test_unmap_ok(_: *mut c_void, _: u64) -> i32 { + 0 + } + #[test] + fn mapping_guard_tracks_cleanup_on_success_failure_and_unwind() { + let outstanding = Cell::new(Some(123)); + let mut api = test_state().api; + api.unmap = test_unmap_ok; + drop(Mapped { api, decoder: ptr::null_mut(), address: 123, outstanding: &outstanding, active: true }); + assert_eq!(outstanding.get(), None); + outstanding.set(Some(123)); + assert!( + catch_unwind(AssertUnwindSafe(|| { + let _mapped = Mapped { api, decoder: ptr::null_mut(), address: 123, outstanding: &outstanding, active: true }; + panic!("synthetic readback panic"); + })) + .is_err() + ); + assert_eq!(outstanding.get(), None); + outstanding.set(Some(123)); + api.unmap = test_unmap; + assert!(Mapped { api, decoder: ptr::null_mut(), address: 123, outstanding: &outstanding, active: true }.unmap().is_err()); + assert_eq!(outstanding.get(), Some(123)); + } +} diff --git a/crates/platform/src/nvdec/ffi.rs b/crates/platform/src/nvdec/ffi.rs new file mode 100644 index 00000000..814ef6fc --- /dev/null +++ b/crates/platform/src/nvdec/ffi.rs @@ -0,0 +1,213 @@ +// +// Copyright (c) 2010-2026 NVIDIA Corporation +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the software, and to permit persons to whom the +// software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +//! NVDEC declarations transcribed from NVIDIA's MIT headers (NVDEC API 13.1). +//! Linux LP64 only; C `tcu_ulong` is an unsigned long, not a u32. +#![allow(non_snake_case, non_camel_case_types, non_upper_case_globals, dead_code)] +use std::ffi::{c_int, c_ulong, c_void}; +pub const cudaVideoCodec_H264: c_int = 4; +pub const cudaVideoCodec_HEVC: c_int = 8; +pub const cudaVideoChromaFormat_420: c_int = 1; +pub const cudaVideoSurfaceFormat_NV12: c_int = 0; +pub const cudaVideoSurfaceFormat_P016: c_int = 1; +pub const cudaVideoDeinterlaceMode_Weave: c_int = 0; +pub const cudaVideoCreate_PreferCUVID: c_ulong = 4; +pub const CUVID_PKT_ENDOFSTREAM: c_ulong = 1; +pub const CUVID_PKT_TIMESTAMP: c_ulong = 2; +pub const CUVID_PKT_ENDOFPICTURE: c_ulong = 8; +#[repr(C)] +pub struct CUVIDPICPARAMS { + _opaque: [u8; 0], +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct FrameRate { + pub numerator: u32, + pub denominator: u32, +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Rect { + pub left: i32, + pub top: i32, + pub right: i32, + pub bottom: i32, +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ShortRect { + pub left: i16, + pub top: i16, + pub right: i16, + pub bottom: i16, +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct AspectRatio { + pub x: i32, + pub y: i32, +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct VideoSignal { + pub flags: u8, + pub color_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coefficients: u8, +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct CUVIDEOFORMAT { + pub codec: c_int, + pub frame_rate: FrameRate, + pub progressive_sequence: u8, + pub bit_depth_luma_minus8: u8, + pub bit_depth_chroma_minus8: u8, + pub min_num_decode_surfaces: u8, + pub coded_width: u32, + pub coded_height: u32, + pub display_area: Rect, + pub chroma_format: c_int, + pub bitrate: u32, + pub display_aspect_ratio: AspectRatio, + pub video_signal_description: VideoSignal, + pub seqhdr_data_length: u32, +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct CUVIDDECODECAPS { + pub eCodecType: c_int, + pub eChromaFormat: c_int, + pub nBitDepthMinus8: u32, + pub reserved1: [u32; 3], + pub bIsSupported: u8, + pub nNumNVDECs: u8, + pub nOutputFormatMask: u16, + pub nMaxWidth: u32, + pub nMaxHeight: u32, + pub nMaxMBCount: u32, + pub nMinWidth: u16, + pub nMinHeight: u16, + pub bIsHistogramSupported: u8, + pub nCounterBitDepth: u8, + pub nMaxHistogramBins: u16, + pub bIsDecodeStatsSupported: u8, + pub reserved4: [u8; 3], + pub reserved3: [u32; 9], +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct CUVIDDECODECREATEINFO { + pub ulWidth: c_ulong, + pub ulHeight: c_ulong, + pub ulNumDecodeSurfaces: c_ulong, + pub CodecType: c_int, + pub ChromaFormat: c_int, + pub ulCreationFlags: c_ulong, + pub bitDepthMinus8: c_ulong, + pub ulIntraDecodeOnly: c_ulong, + pub ulMaxWidth: c_ulong, + pub ulMaxHeight: c_ulong, + pub Reserved1: c_ulong, + pub display_area: ShortRect, + pub OutputFormat: c_int, + pub DeinterlaceMode: c_int, + pub ulTargetWidth: c_ulong, + pub ulTargetHeight: c_ulong, + pub ulNumOutputSurfaces: c_ulong, + pub vidLock: *mut c_void, + pub target_rect: ShortRect, + pub enableHistogram: c_ulong, + pub enableDecodeFeatures: c_ulong, + pub Reserved2: [c_ulong; 3], +} +pub type SequenceCallback = unsafe extern "C" fn(*mut c_void, *mut CUVIDEOFORMAT) -> c_int; +pub type DecodeCallback = unsafe extern "C" fn(*mut c_void, *mut CUVIDPICPARAMS) -> c_int; +pub type DisplayCallback = unsafe extern "C" fn(*mut c_void, *mut CUVIDPARSERDISPINFO) -> c_int; +#[repr(C)] +#[derive(Clone, Copy)] +pub struct CUVIDPARSERPARAMS { + pub CodecType: c_int, + pub ulMaxNumDecodeSurfaces: u32, + pub ulClockRate: u32, + pub ulErrorThreshold: u32, + pub ulMaxDisplayDelay: u32, + pub flags: u32, + pub uReserved1: [u32; 4], + pub pUserData: *mut c_void, + pub pfnSequenceCallback: Option, + pub pfnDecodePicture: Option, + pub pfnDisplayPicture: Option, + pub pfnGetOperatingPoint: Option c_int>, + pub pfnGetSEIMsg: Option c_int>, + pub pvReserved2: [*mut c_void; 5], + pub pExtVideoInfo: *mut c_void, +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct CUVIDSOURCEDATAPACKET { + pub flags: c_ulong, + pub payload_size: c_ulong, + pub payload: *const u8, + pub timestamp: i64, +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct CUVIDPARSERDISPINFO { + pub picture_index: c_int, + pub progressive_frame: c_int, + pub top_field_first: c_int, + pub repeat_first_field: c_int, + pub timestamp: i64, +} +#[repr(C)] +#[derive(Clone, Copy)] +pub struct CUVIDPROCPARAMS { + pub progressive_frame: c_int, + pub second_field: c_int, + pub top_field_first: c_int, + pub unpaired_field: c_int, + pub reserved_flags: u32, + pub reserved_zero: u32, + pub raw_input_dptr: u64, + pub raw_input_pitch: u32, + pub raw_input_format: u32, + pub raw_output_dptr: u64, + pub raw_output_pitch: u32, + pub Reserved1: u32, + pub output_stream: *mut c_void, + pub Reserved: [u32; 46], + pub histogram_dptr: *mut u64, + pub pCuvidProcExt: *mut c_void, +} +pub type GetDecoderCaps = unsafe extern "C" fn(*mut CUVIDDECODECAPS) -> c_int; +pub type CreateDecoder = unsafe extern "C" fn(*mut *mut c_void, *mut CUVIDDECODECREATEINFO) -> c_int; +pub type DestroyDecoder = unsafe extern "C" fn(*mut c_void) -> c_int; +pub type DecodePicture = unsafe extern "C" fn(*mut c_void, *mut CUVIDPICPARAMS) -> c_int; +pub type MapVideoFrame = unsafe extern "C" fn(*mut c_void, c_int, *mut u64, *mut u32, *mut CUVIDPROCPARAMS) -> c_int; +pub type UnmapVideoFrame = unsafe extern "C" fn(*mut c_void, u64) -> c_int; +pub type CreateVideoParser = unsafe extern "C" fn(*mut *mut c_void, *mut CUVIDPARSERPARAMS) -> c_int; +pub type ParseVideoData = unsafe extern "C" fn(*mut c_void, *mut CUVIDSOURCEDATAPACKET) -> c_int; +pub type DestroyVideoParser = unsafe extern "C" fn(*mut c_void) -> c_int; diff --git a/crates/platform/src/nvdec/mod.rs b/crates/platform/src/nvdec/mod.rs new file mode 100644 index 00000000..fff49a59 --- /dev/null +++ b/crates/platform/src/nvdec/mod.rs @@ -0,0 +1,123 @@ +//! Hardware decoding through NVDEC (Linux, NVIDIA's proprietary driver). +//! +//! Unlike VA-API, NVDEC brings its own stream parser (`libnvcuvid`): [`cuvid::Session`] is fed +//! Annex B access units and returns pictures in presentation order, read back from the GPU through +//! the CUDA driver. Both libraries are loaded at run time; without them there is no NVDEC decoder, +//! never a failure to start. H.264 8-bit and HEVC 8- / 10-bit, 4:2:0 progressive; everything else +//! stays with VA-API or in software. +//! +//! The FFI is in [`ffi`] (declarations) and [`cuvid`] (calls); the rest of this module is safe code. + +#[allow(unsafe_code)] +pub mod cuvid; +pub mod ffi; + +#[cfg(test)] +mod abi_tests; + +use filmcraft_codecs::hw::{NalCodec, NalStreamInfo}; +use filmcraft_codecs::{CodecError, DecodedFrame, Result, VideoDecoder}; + +use crate::annexb::to_annex_b; +use crate::biplanar::Geometry; + +/// An NVDEC decoder for one H.264 or HEVC stream. +pub struct NvDecoder { + session: cuvid::Session, + info: NalStreamInfo, + name: &'static str, + /// Whether the parser has the stream's parameter sets (sent with the first sample after a reset). + primed: bool, + /// HEVC, after starting at a CRA / BLA picture: its RASL pictures (they refer to pictures + /// before it) are not fed until the next random access point. + skip_rasl: bool, + fed: u64, + fail_after: Option, +} + +impl NvDecoder { + /// A decoder for the stream `info` describes, or why NVDEC does not take it on this system. + pub fn new(info: NalStreamInfo) -> std::result::Result { + let bits = info.bit_depth_luma; + let taken = match info.codec { + NalCodec::H264 => bits == 8, + NalCodec::Hevc => matches!(bits, 8 | 10), + }; + if !taken || info.chroma_format_idc != 1 || info.bit_depth_chroma != bits || info.interlaced { + return Err(format!( + "{} chroma format {} at {bits}/{}-bit is not taken", + if info.interlaced { "interlaced" } else { "progressive" }, + info.chroma_format_idc, + info.bit_depth_chroma + )); + } + let geometry = Geometry { crop: info.crop, bits, color: info.color, par: info.par }; + let session = cuvid::Session::new(info.codec, info.coded, geometry, info.reorder)?; + let name = match info.codec { + NalCodec::H264 => "NVDEC H.264", + NalCodec::Hevc => "NVDEC HEVC", + }; + Ok(Self { session, info, name, primed: false, skip_rasl: false, fed: 0, fail_after: None }) + } + + /// Test hook: fail every decode call after the first `n` (a GPU lost mid-stream). + pub fn fail_after(&mut self, n: u64) { + self.fail_after = Some(n); + } +} + +fn frames(out: Vec<(i64, filmcraft_frame::VideoFrame)>) -> Vec { + out.into_iter().map(|(pts, frame)| DecodedFrame { pts, frame, draft: false }).collect() +} + +impl VideoDecoder for NvDecoder { + fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + self.fed += 1; + if self.fail_after.is_some_and(|n| self.fed > n) { + return Err(CodecError::Decode("NVDEC decoder failed (test hook)".into())); + } + if self.info.codec == NalCodec::Hevc { + let types = self.info.nal_types(sample); + if self.info.is_irap(sample) { + // IDR pictures (19, 20) have no RASL pictures + self.skip_rasl = !self.primed && !types.iter().any(|t| matches!(t, 19 | 20)); + } else if self.skip_rasl && types.iter().any(|t| matches!(t, 8 | 9)) { + // Not output by any decoder (the software one drops them too); the parser drops + // them as well, but then shows the CRA picture with the last one's timestamp. + return Ok(Vec::new()); + } + } + let data = to_annex_b(&self.info, sample, !self.primed).map_err(CodecError::Decode)?; + self.primed = true; + self.session.feed(&data, pts).map(frames).map_err(CodecError::Decode) + } + + fn flush(&mut self) -> Vec { + // the parser starts over after an end of stream + self.primed = false; + match self.session.flush() { + Ok(out) => frames(out), + Err(e) => { + log::warn!("{}: {e} while flushing", self.name); + Vec::new() + } + } + } + + fn reset(&mut self) { + self.primed = false; + self.session.reset(); + } + + fn name(&self) -> &str { + self.name + } + + fn is_random_access(&self, sample: &[u8]) -> Option { + self.info.is_random_access(sample) + } + + fn is_disposable(&self, sample: &[u8]) -> bool { + self.info.is_disposable(sample) + } +} diff --git a/crates/platform/src/nvenc/device.rs b/crates/platform/src/nvenc/device.rs index fe898795..975018b5 100644 --- a/crates/platform/src/nvenc/device.rs +++ b/crates/platform/src/nvenc/device.rs @@ -67,10 +67,22 @@ mod directx { #[cfg(target_os = "linux")] mod cuda { use super::c_void; + use std::marker::PhantomData; + use std::rc::Rc; type Init = unsafe extern "C" fn(u32) -> i32; type GetDevice = unsafe extern "C" fn(*mut i32, i32) -> i32; type Retain = unsafe extern "C" fn(*mut *mut c_void, i32) -> i32; type Release = unsafe extern "C" fn(i32) -> i32; + type Push = unsafe extern "C" fn(*mut c_void) -> i32; + type Pop = unsafe extern "C" fn(*mut *mut c_void) -> i32; + type CopyToHost = unsafe extern "C" fn(*mut c_void, u64, usize) -> i32; + + /// A thread-local context stack entry; it cannot move to another thread. + pub struct Current<'a> { + _device: &'a Device, + pop: Pop, + _thread: PhantomData>, + } /// One retained primary CUDA context. Retaining does not change the calling thread's /// current context; NVENC receives the explicit context handle. No toolkit is needed. @@ -116,6 +128,49 @@ mod cuda { pub fn as_raw(&self) -> *mut c_void { self.context } + + pub fn push_current(&self) -> Result, String> { + // SAFETY: signatures match the CUDA driver headers; the retained context and + // library outlive the guard, whose Drop balances the push on this same thread. + unsafe { + let push: Push = *self._library.get(b"cuCtxPushCurrent_v2\0").map_err(|e| e.to_string())?; + let pop: Pop = *self._library.get(b"cuCtxPopCurrent_v2\0").map_err(|e| e.to_string())?; + let st = push(self.context); + if st != 0 { + return Err(format!("cuCtxPushCurrent failed ({st})")); + } + Ok(Current { _device: self, pop, _thread: PhantomData }) + } + } + + /// Copy a driver-owned device address into a bounded host slice. CUDA validates + /// the source device range; the destination is always a live writable Rust slice. + pub fn copy_to_host(&self, source: u64, destination: &mut [u8]) -> Result<(), String> { + let _current = self.push_current()?; + source.checked_add(u64::try_from(destination.len()).map_err(|_| "CUDA copy length overflows")?).ok_or("CUDA copy address overflows")?; + // SAFETY: the synchronous copy writes at most destination.len() bytes to its + // live buffer. The CUDA context and library remain live through the call. + let st = unsafe { + let copy: CopyToHost = *self._library.get(b"cuMemcpyDtoH_v2\0").map_err(|e| e.to_string())?; + copy(destination.as_mut_ptr().cast(), source, destination.len()) + }; + if st != 0 { + return Err(format!("cuMemcpyDtoH failed ({st})")); + } + Ok(()) + } + } + + impl Drop for Current<'_> { + fn drop(&mut self) { + let mut previous = std::ptr::null_mut(); + // SAFETY: this guard owns one successful push on this thread; previous is + // a valid out-pointer and the library is retained by the borrowed device. + let st = unsafe { (self.pop)(&mut previous) }; + if st != 0 { + log::warn!("cuCtxPopCurrent failed ({st})"); + } + } } impl Drop for Device { @@ -123,7 +178,10 @@ mod cuda { // SAFETY: balances this object's successful retain, after the NVENC session is // destroyed. Release drops our reference, not other users' primary-context references. unsafe { - (self.release)(self.ordinal); + let st = (self.release)(self.ordinal); + if st != 0 { + log::warn!("cuDevicePrimaryCtxRelease failed ({st})"); + } } } } diff --git a/crates/platform/src/nvenc/mod.rs b/crates/platform/src/nvenc/mod.rs index e03ed0cf..a90fe626 100644 --- a/crates/platform/src/nvenc/mod.rs +++ b/crates/platform/src/nvenc/mod.rs @@ -26,7 +26,7 @@ //! software encoder takes over. A declined HEVC export is an error that says why. #[allow(unsafe_code)] -mod device; +pub(crate) mod device; pub mod export; #[allow(unsafe_code)] mod ffi; diff --git a/crates/platform/tests/nvdec.rs b/crates/platform/tests/nvdec.rs new file mode 100644 index 00000000..d87f1561 --- /dev/null +++ b/crates/platform/tests/nvdec.rs @@ -0,0 +1,182 @@ +//! NVDEC decoding against our software decoders (Linux, NVIDIA's driver): every picture of the +//! shared H.264 High, HEVC Main (open GOP) and Main 10 fixtures and of 1080p streams must be +//! identical (bit-exact planes, colour, pixel aspect, pts and presentation order), also after +//! `reset` + reseek; a forced mid-stream failure must continue in software with the software +//! decoder's exact output; what the hardware path does not take is declined; damaged samples give +//! errors or fall back, never crash or hang. Skips without ffmpeg (fixture generator) or without +//! an NVIDIA GPU. +#![cfg(all(target_os = "linux", target_pointer_width = "64"))] + +mod common; + +use std::time::Duration; + +use common::*; +use filmcraft_codecs::VideoDecoder; +use filmcraft_codecs::hw::{NalCodec, NalStreamInfo}; +use filmcraft_platform::HybridDecoder; +use filmcraft_platform::nvdec::NvDecoder; + +fn hardware(s: &Stream) -> Option> { + match filmcraft_platform::nvdec_factory(&s.entry) { + Some(Ok(d)) => Some(d), + Some(Err(e)) => panic!("factory error: {e}"), + None => { + eprintln!("SKIPPED: no NVDEC hardware decoder for {}", s.entry.codec.name()); + None + } + } +} + +fn software(s: &Stream) -> Box { + filmcraft_codecs::software_video_decoder(&s.entry).unwrap() +} + +fn streams(ff: &std::path::Path) -> Vec<(String, Stream)> { + let mut out: Vec<_> = + ["h264_high.mp4", "hevc_main.mp4", "hevc_main10.mp4"].iter().filter_map(|n| Some((n.to_string(), read_stream(&named(ff, n)?)))).collect(); + let src = "testsrc2=s=1920x1080:r=24:d=2,noise=alls=12:allf=t"; + let gop = "bframes=3:b-pyramid=normal:keyint=24:min-keyint=24:scenecut=0:ref=4"; + let x265 = format!("log-level=error:{}", gop.replace("normal", "1")); + let cases: [(&str, Vec<&str>); 2] = [ + ("nv_h264_1080.mp4", vec!["-c:v", "libx264", "-profile:v", "high", "-preset", "veryfast", "-x264-params", gop, "-pix_fmt", "yuv420p"]), + ("nv_hevc_main10_1080.mp4", vec!["-c:v", "libx265", "-preset", "ultrafast", "-tag:v", "hvc1", "-x265-params", &x265, "-pix_fmt", "yuv420p10le"]), + ]; + for (name, enc) in cases { + let args: Vec<&str> = ["-f", "lavfi", "-i", src].into_iter().chain(enc).collect(); + if let Some(path) = fixture(ff, name, &args) { + out.push((name.to_string(), read_stream(&path))); + } + } + out +} + +#[test] +fn bit_exact_with_the_software_decoder() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (name, s) in streams(&ff) { + let Some(mut hw) = hardware(&s) else { continue }; + let mut sw = software(&s); + let a = decode_all(hw.as_mut(), &s.samples); + assert!(a.len() >= 40, "{name}: {} pictures", a.len()); + assert_same(&name, &a, &decode_all(sw.as_mut(), &s.samples)); + // reset + reseek to each later sync sample. After a seek to a non-IDR H.264 I picture + // (open GOP) the pictures shown before it are concealed differently: only those from the + // seek point on must match. (HEVC drops such pictures, RASL, in both decoders.) + let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap(); + for k in (1..s.samples.len()).filter(|&i| s.sync[i]).rev() { + let end = (k + 17).min(s.samples.len()); + hw.reset(); + sw.reset(); + let mut a = decode_all(hw.as_mut(), &s.samples[k..end]); + let mut b = decode_all(sw.as_mut(), &s.samples[k..end]); + if info.codec == NalCodec::H264 && !info.nal_types(&s.samples[k].0).contains(&5) { + let start = s.samples[k].1; + a.retain(|f| f.pts >= start); + b.retain(|f| f.pts >= start); + } + assert!(!a.is_empty(), "{name}: pictures after seeking to {k}"); + assert_same(&format!("{name} from sample {k}"), &a, &b); + } + hw.reset(); + sw.reset(); + assert_same(&format!("{name} after resets"), &decode_all(hw.as_mut(), &s.samples), &decode_all(sw.as_mut(), &s.samples)); + assert!(hw.name().starts_with("NVDEC"), "{name}: stayed in hardware ({})", hw.name()); + } +} + +#[test] +fn mid_stream_failure_continues_in_software() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (name, s) in streams(&ff) { + let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap(); + if NvDecoder::new(info.clone()).is_err() { + eprintln!("SKIPPED: no NVDEC hardware decoder for {name}"); + continue; + } + let reference = decode_all(software(&s).as_mut(), &s.samples); + let k = (1..s.samples.len()).find(|&i| s.sync[i]).unwrap(); + for fail_at in [0, 5, k, k + 1] { + let mut nv = NvDecoder::new(info.clone()).unwrap(); + nv.fail_after(fail_at as u64); + let mut d = HybridDecoder::new(Box::new(nv), s.entry.clone(), info.clone()); + let out = decode_all(&mut d, &s.samples); + assert!(!d.is_hardware(), "{name}: switched to software"); + assert_same(&format!("{name} failing at sample {fail_at}"), &out, &reference); + } + } +} + +#[test] +fn declines_what_the_hardware_path_does_not_take() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let src = "testsrc2=s=640x360:r=24:d=1"; + let cases: [(&str, Vec<&str>); 3] = [ + ("va_h264_interlaced.mp4", vec!["-c:v", "libx264", "-x264-params", "interlaced=1:keyint=12", "-pix_fmt", "yuv420p"]), + ("va_h264_10bit.mp4", vec!["-c:v", "libx264", "-profile:v", "high10", "-pix_fmt", "yuv420p10le"]), + ("va_hevc_422.mp4", vec!["-c:v", "libx265", "-tag:v", "hvc1", "-x265-params", "log-level=error:keyint=12", "-pix_fmt", "yuv422p10le"]), + ]; + for (name, enc) in cases { + let args: Vec<&str> = ["-f", "lavfi", "-i", src].into_iter().chain(enc).collect(); + let Some(path) = fixture(&ff, name, &args) else { continue }; + assert!(filmcraft_platform::nvdec_factory(&read_stream(&path).entry).is_none(), "{name}: declined"); + } + let Some(path) = named(&ff, "h264_high.mp4") else { return }; + let s = read_stream(&path); + filmcraft_codecs::hw::set_hardware_decoding(false); + let off = filmcraft_platform::nvdec_factory(&s.entry).is_none(); + filmcraft_codecs::hw::set_hardware_decoding(true); + assert!(off, "declined while hardware decoding is Off"); +} + +#[test] +fn damaged_samples_never_crash_or_hang() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (name, s) in streams(&ff).into_iter().take(3) { + if hardware(&s).is_none() { + continue; + } + for seed in 1..=8u64 { + let (samples, entry) = (s.samples.clone(), s.entry.clone()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let r = std::panic::catch_unwind(move || { + let mut seed = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + // through the hybrid, and straight into the hardware decoder (no fallback) + let mut d = filmcraft_platform::nvdec_factory(&entry).unwrap().unwrap(); + let mut raw = NvDecoder::new(NalStreamInfo::from_entry(&entry).unwrap().unwrap()).unwrap(); + for (i, (smp, pts)) in samples.iter().enumerate().take(40) { + let mut m = smp.clone(); + match xorshift(&mut seed) % 4 { + 0 => { + for _ in 0..1 + xorshift(&mut seed) % 8 { + let at = (xorshift(&mut seed) as usize) % m.len().max(1); + if let Some(b) = m.get_mut(at) { + *b ^= 1 << (xorshift(&mut seed) % 8); + } + } + } + 1 => m.truncate((xorshift(&mut seed) as usize) % m.len().max(1)), + 2 if m.len() > 4 => m[..4].copy_from_slice(&(xorshift(&mut seed) as u32).to_be_bytes()), + _ => {} + } + let _ = d.decode(&m, *pts); + let _ = raw.decode(&m, *pts); + if i % 13 == 12 { + d.reset(); + raw.reset(); + } + } + let _ = d.flush(); + let _ = raw.flush(); + }); + let _ = tx.send(r.is_ok()); + }); + match rx.recv_timeout(Duration::from_secs(60)) { + Ok(true) => {} + Ok(false) => panic!("{name} seed {seed}: panicked"), + Err(_) => panic!("{name} seed {seed}: hung"), + } + } + } +} diff --git a/crates/platform/tests/setting.rs b/crates/platform/tests/setting.rs index c177e55d..5272a061 100644 --- a/crates/platform/tests/setting.rs +++ b/crates/platform/tests/setting.rs @@ -36,8 +36,12 @@ fn off_uses_the_software_decoder() { assert_eq!(d.name(), "FilmCraft HEVC"); return; } - let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") }; - assert_eq!(d.name(), hw_name); + // Linux has two backends (NVDEC in front of VA-API): the one `register` reported + let backend = match available { + filmcraft_platform::Availability::Available(name) => name, + filmcraft_platform::Availability::Unavailable(why) => panic!("a hardware decoder, but register() said {why}"), + }; + assert!(d.name().starts_with(backend) && d.name().contains("HEVC"), "{} from {backend}", d.name()); assert_eq!(filmcraft_codecs::hw::hw_backend(), Some(backend), "perf.stats reports the backend"); assert!(filmcraft_platform::registered(), "register() put the factory in the registry"); let h1 = filmcraft_codecs::hw::hw_stats(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a39dc82e..cdb5928d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1808,7 +1808,7 @@ Hard Limiter Limitador duro Hard Mix Mezcla definida Hardware decoding Descodificación por hardware - Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Descodificación por hardware: Automático usa el descodificador de video del sistema (VideoToolbox en macOS, Media Foundation en Windows, VA-API para H.264 y HEVC en Linux) para los flujos que admite, y el descodificador propio de FilmCraft para todo lo demás o si falla el hardware. Los medios ya abiertos mantienen su descodificador hasta que se vuelven a abrir. + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Descodificación por hardware: Automático usa el descodificador de video del sistema (VideoToolbox en macOS, Media Foundation en Windows, VA-API o NVDEC para H.264 y HEVC en Linux) para los flujos que admite, y el descodificador propio de FilmCraft para todo lo demás o si falla el hardware. Los medios ya abiertos mantienen su descodificador hasta que se vuelven a abrir. Head Size Tamaño de la cabeza Heart Corazón Height Altura diff --git a/docs/adr/0001-platform-ffi.md b/docs/adr/0001-platform-ffi.md index 3dd43836..bf5a987f 100644 --- a/docs/adr/0001-platform-ffi.md +++ b/docs/adr/0001-platform-ffi.md @@ -146,3 +146,14 @@ calling thread's stack. The retained reference is released after the NVENC sessi other users' references are not reset. `libnvidia-encode.so.1` stays loaded with its function table. Both libraries come from the installed NVIDIA driver, not this project. Export registration on Linux does not claim that a hardware decoder is available. The same opt-in and fallback rules apply. + +## Addendum (2026-10-09): NVDEC hardware decoding on Linux + +NVIDIA's proprietary driver has no VA-API of its own (only through the separate +`libva-nvidia-driver`), so Linux gets a second decoder backend, `nvdec/`, registered in front of +VA-API's. The rules above hold: `libnvcuvid.so.1` and `libcuda.so.1` are loaded at run time, +`nvdec/ffi.rs` is transcribed from NVIDIA's MIT-licensed headers and checked by +`nvdec/abi_tests.rs`, only `nvdec::cuvid` carries `#[allow(unsafe_code)]`, and the CUDA context is +the one `nvenc::device` already retains for encoding. Unlike VA-API, NVDEC parses the stream +itself; its callbacks run inside our parse call and never unwind into C (they record the first +error and return 0, and the decoder then fails over to software like any other backend). From f067b709de5cd9dd58c5a36a5c411a7c6dd45fa8 Mon Sep 17 00:00:00 2001 From: hadmark Date: Sat, 10 Oct 2026 14:17:39 +0800 Subject: [PATCH 07/14] Add Simplified Chinese language support Add Simplified Chinese to language menu --- crates/ui-egui/src/menus.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6046935..bcf1a1fe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,6 +26,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.language.japanese", "日本語", ["Edit", "Language"], None), uic!("app.language.spanish", "Español", ["Edit", "Language"], None), uic!("app.language.portuguese", "Português (Brasil)", ["Edit", "Language"], None), + uic!("app.language.chinese", "简体中文", ["Edit", "Language"], None), uic!("source.playback.toggle", "Source Play/Stop", [], None), uic!("source.playback.play", "Play Source", [], None), uic!("source.playback.stop", "Stop Source", [], None), @@ -168,15 +169,19 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: let object = params.as_object_mut().ok_or("command parameters must be an object")?; object.insert("target".into(), json!("source")); } - if matches!(id, "app.language.english" | "app.language.japanese" | "app.language.spanish" | "app.language.portuguese") { + if matches!(id, "app.language.english" | "app.language.japanese" | "app.language.spanish" | "app.language.portuguese" | "app.language.chinese") { // Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) { return Err("no Japanese font is installed on this system (for example Noto Sans CJK JP); the interface stays in English".into()); } + if id == "app.language.chinese" && !crate::i18n::install_chinese_font(ctx) { + return Err("no Chinese font is installed on this system (for example Microsoft YaHei or Noto Sans CJK SC); the interface stays in English".into()); + } let language = match id { "app.language.japanese" => crate::i18n::Language::Ja, "app.language.spanish" => crate::i18n::Language::Es, "app.language.portuguese" => crate::i18n::Language::PtBr, + "app.language.chinese" => crate::i18n::Language::Zh, _ => crate::i18n::Language::En, }; // The preference is updated in memory before it is written, so a failed write (read-only @@ -543,6 +548,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { "app.language.japanese" => it.checked = Some(app.ui.language == crate::i18n::Language::Ja), "app.language.spanish" => it.checked = Some(app.ui.language == crate::i18n::Language::Es), "app.language.portuguese" => it.checked = Some(app.ui.language == crate::i18n::Language::PtBr), + "app.language.chinese" => it.checked = Some(app.ui.language == crate::i18n::Language::Zh), _ => {} } if it.id.starts_with("view.") { From 6732ad9c9486a1e58b94079bad17a46699b7b541 Mon Sep 17 00:00:00 2001 From: hadmark Date: Sat, 10 Oct 2026 14:32:49 +0800 Subject: [PATCH 08/14] Add files via upload --- .github/workflows/build-zh.yml | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/build-zh.yml diff --git a/.github/workflows/build-zh.yml b/.github/workflows/build-zh.yml new file mode 100644 index 00000000..7858e6d6 --- /dev/null +++ b/.github/workflows/build-zh.yml @@ -0,0 +1,55 @@ +# 说明:把本文件放到你 fork 仓库的 .github/workflows/ 目录下(文件名随意,如 build-zh.yml)。 +# 它会在 GitHub 的云服务器上(Windows)用 Rust 编译出带中文的 FilmCraft.exe, +# 编译完成后你从 Actions 页面的 Artifacts 下载即可。无需你本地安装任何编译环境。 +# +# 前提:你已按《三步极简提交指南》把 zh.tsv 和两个 .new 文件放进仓库(即中文已就位)。 +# 本工作流只负责"编译",不负责"翻译"。 + +name: Build zh Windows (self-use) + +on: + # 手动在 Actions 页面点 "Run workflow" 触发(推荐) + workflow_dispatch: + # 推送到 main 也自动触发(比如你提交了中文文件后) + push: + branches: [main] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + build: + name: Build FilmCraft (zh) for Windows x64 + runs-on: windows-latest + timeout-minutes: 120 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + with: + key: zh-win-x64 + + - name: Build release (FilmCraft GUI) + shell: bash + run: cargo build --release -p filmcraft + + - name: Upload exe + uses: actions/upload-artifact@v4 + with: + name: filmcraft-windows-x64-zh + path: | + target/release/filmcraft.exe + target/release/filmcraft-cli.exe + if-no-files-found: error + retention-days: 30 From b3d68fe0aeab00ecc622127ee9a4f05877055627 Mon Sep 17 00:00:00 2001 From: SilentYeti Date: Sat, 10 Oct 2026 02:17:16 -0700 Subject: [PATCH 09/14] NVDEC: reject pictures without valid bitstream data before the driver call The parser's picture parameters were passed to cuvidDecodePicture unread. Their shared head is now checked first: a null data pointer, zero length, no slices, or a slice offset past the end of the data is an error, so the session fails over to the software decoder and the driver never sees the picture. Co-Authored-By: Claude Opus 5.5 --- crates/platform/src/nvdec/abi_tests.rs | 4 ++++ crates/platform/src/nvdec/cuvid.rs | 19 +++++++++++++++++-- crates/platform/src/nvdec/ffi.rs | 15 +++++++++++++-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/platform/src/nvdec/abi_tests.rs b/crates/platform/src/nvdec/abi_tests.rs index 5ac47d25..3acf0c19 100644 --- a/crates/platform/src/nvdec/abi_tests.rs +++ b/crates/platform/src/nvdec/abi_tests.rs @@ -6,6 +6,10 @@ use super::ffi::*; use std::mem::{align_of, offset_of, size_of}; #[test] fn layouts_match_the_header() { + assert_eq!(offset_of!(CUVIDPICPARAMS, nBitstreamDataLen), 24, "CUVIDPICPARAMS.nBitstreamDataLen"); + assert_eq!(offset_of!(CUVIDPICPARAMS, pBitstreamData), 32, "CUVIDPICPARAMS.pBitstreamData"); + assert_eq!(offset_of!(CUVIDPICPARAMS, nNumSlices), 40, "CUVIDPICPARAMS.nNumSlices"); + assert_eq!(offset_of!(CUVIDPICPARAMS, pSliceDataOffsets), 48, "CUVIDPICPARAMS.pSliceDataOffsets"); assert_eq!((size_of::(), align_of::()), (64, 4)); assert_eq!(offset_of!(CUVIDEOFORMAT, codec), 0); assert_eq!(offset_of!(CUVIDEOFORMAT, frame_rate), 4); diff --git a/crates/platform/src/nvdec/cuvid.rs b/crates/platform/src/nvdec/cuvid.rs index d8d3305b..b52ff92b 100644 --- a/crates/platform/src/nvdec/cuvid.rs +++ b/crates/platform/src/nvdec/cuvid.rs @@ -97,6 +97,9 @@ fn check_caps(caps: &CUVIDDECODECAPS, coded: (u32, u32), bits: u32) -> Result<() Ok(()) } +/// Slices per picture the driver's offset list is read for. +const MAX_SLICES: u32 = 8192; + struct State { api: Api, // Points to Session's separately boxed, immutable Device (stable across moves). @@ -306,13 +309,25 @@ unsafe extern "C" fn sequence(data: *mut c_void, format: *mut CUVIDEOFORMAT) -> unsafe { callback(data, |state| state.sequence(format.as_ref().ok_or("NVDEC sequence pointer is null")?)) } } unsafe extern "C" fn decode(data: *mut c_void, picture: *mut CUVIDPICPARAMS) -> i32 { - // SAFETY: the opaque picture parameters belong to cuvid for the callback's duration; - // they are only passed through to the live decoder with the context current. + // SAFETY: the picture parameters belong to cuvid for the callback's duration: their shared + // head is read (the slice offset list for at most `MAX_SLICES` entries, as the parser sized + // it), and they are passed through to the live decoder with the context current. unsafe { callback(data, |state| { if state.decoder.is_null() || picture.is_null() { return Err("NVDEC decode pointer or decoder is null".into()); } + // A picture without bitstream data, or with slices past the end of it, is an error + // here and never reaches the driver. + let p = &*picture; + let offsets = if p.pSliceDataOffsets.is_null() || p.nNumSlices > MAX_SLICES { + &[][..] + } else { + std::slice::from_raw_parts(p.pSliceDataOffsets, p.nNumSlices as usize) + }; + if p.pBitstreamData.is_null() || p.nBitstreamDataLen == 0 || offsets.is_empty() || offsets.iter().any(|&o| o > p.nBitstreamDataLen) { + return Err("NVDEC parser returned a picture without valid bitstream data".into()); + } state.drain()?; status((state.api.decode)(state.decoder, picture), "cuvidDecodePicture")?; Ok(1) diff --git a/crates/platform/src/nvdec/ffi.rs b/crates/platform/src/nvdec/ffi.rs index 814ef6fc..b8e59ac7 100644 --- a/crates/platform/src/nvdec/ffi.rs +++ b/crates/platform/src/nvdec/ffi.rs @@ -26,7 +26,7 @@ //! NVDEC declarations transcribed from NVIDIA's MIT headers (NVDEC API 13.1). //! Linux LP64 only; C `tcu_ulong` is an unsigned long, not a u32. #![allow(non_snake_case, non_camel_case_types, non_upper_case_globals, dead_code)] -use std::ffi::{c_int, c_ulong, c_void}; +use std::ffi::{c_int, c_uint, c_ulong, c_void}; pub const cudaVideoCodec_H264: c_int = 4; pub const cudaVideoCodec_HEVC: c_int = 8; pub const cudaVideoChromaFormat_420: c_int = 1; @@ -37,9 +37,20 @@ pub const cudaVideoCreate_PreferCUVID: c_ulong = 4; pub const CUVID_PKT_ENDOFSTREAM: c_ulong = 1; pub const CUVID_PKT_TIMESTAMP: c_ulong = 2; pub const CUVID_PKT_ENDOFPICTURE: c_ulong = 8; +/// The start of `CUVIDPICPARAMS`: the fields every codec shares and this backend checks. The rest +/// (reserved words and the codec-specific union) is only ever passed through by pointer. #[repr(C)] pub struct CUVIDPICPARAMS { - _opaque: [u8; 0], + pub PicWidthInMbs: c_int, + pub FrameHeightInMbs: c_int, + pub CurrPicIdx: c_int, + pub field_pic_flag: c_int, + pub bottom_field_flag: c_int, + pub second_field: c_int, + pub nBitstreamDataLen: c_uint, + pub pBitstreamData: *const u8, + pub nNumSlices: c_uint, + pub pSliceDataOffsets: *const c_uint, } #[repr(C)] #[derive(Clone, Copy)] From 72a9a7b7aaf069a26e5615708370aaea376f68ae Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 05:47:22 -0400 Subject: [PATCH 10/14] #492: clippy is_multiple_of in the oracle test Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/mp4_oracle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codecs/tests/mp4_oracle.rs b/crates/codecs/tests/mp4_oracle.rs index bb9794f1..aef90e15 100644 --- a/crates/codecs/tests/mp4_oracle.rs +++ b/crates/codecs/tests/mp4_oracle.rs @@ -148,7 +148,7 @@ fn cropped_pictures_match_ffmpeg() { &["-flags", "unaligned", "-i", path.to_str().unwrap(), "-map", "0:v:0", "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"], ); let frame_bytes = (w * h) as usize + 2 * cw * ch; - if name.contains("clap") && raw.len() % frame_bytes != 0 { + if name.contains("clap") && !raw.len().is_multiple_of(frame_bytes) { // ffmpeg before 7.1 ignores `clap` and decodes the whole picture eprintln!("SKIPPED ({name}): this ffmpeg does not apply the clean aperture"); continue; From 57bd52876980d2d5ce5178389c7fc25d4d425fbb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 06:20:09 -0400 Subject: [PATCH 11/14] Merge origin/main into #445; PAR test fixture uses MediaInfo::audio_streams Co-Authored-By: Claude Opus 5.5 --- crates/render/src/par_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/par_tests.rs b/crates/render/src/par_tests.rs index bca5931c..2d03211d 100644 --- a/crates/render/src/par_tests.rs +++ b/crates/render/src/par_tests.rs @@ -49,7 +49,7 @@ fn halves(w: u32, h: u32, par: (u32, u32)) -> Halves { bitrate: None, hdr: None, }), - audio: None, + audio_streams: Vec::new(), container: "test".into(), start_timecode: None, file_size: None, From 0414a1d346d5bcb359cc8152d82c33ad7129b13a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 06:32:00 -0400 Subject: [PATCH 12/14] #549: NVDEC callback state from Box::into_raw, freed in Drop; setting test accepts NVDEC or VA-API Session kept the parser's user data in a Box and later wrote through that Box, which invalidates the raw pointer the callbacks use (Stacked/Tree Borrows). Rust code now reaches the state only through the same into_raw pointer, never across the parse call, and Drop frees it after the parser is destroyed. The setting test accepts either Linux backend's decoder when NVDEC registered, and the README notes perf.stats names NVDEC for streams VA-API decodes. Co-Authored-By: Claude Opus 5.5 --- crates/platform/README.md | 4 +- crates/platform/src/nvdec/cuvid.rs | 103 +++++++++++++++++++---------- crates/platform/tests/setting.rs | 6 +- 3 files changed, 76 insertions(+), 37 deletions(-) diff --git a/crates/platform/README.md b/crates/platform/README.md index da582a48..b78c5773 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -85,7 +85,9 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") in the parser's sequence callback, decodes in its picture callback, and reads each displayed picture back (NV12 / P016) through the CUDA driver into the same planes as the other backends. The factory goes in front of VA-API's: what NVDEC declines still reaches VA-API (another GPU), - then the software decoders. After a start at a CRA picture its RASL pictures are not fed (no + then the software decoders. With both registered, `perf.stats` names NVDEC as the hardware + backend, also for the streams VA-API decodes. The callback state is a `Box::into_raw` allocation + the parser and `Session` reach through the same pointer, freed in `Drop`. After a start at a CRA picture its RASL pictures are not fed (no decoder outputs them, and the parser would otherwise show the CRA picture with the last one's timestamp). A seek recreates the parser and keeps the decoder. - **Linux: VA-API, H.264 (`avcC`) and HEVC (`hvcC`)**: H.264 8-bit 4:2:0 progressive, Constrained diff --git a/crates/platform/src/nvdec/cuvid.rs b/crates/platform/src/nvdec/cuvid.rs index b52ff92b..296db299 100644 --- a/crates/platform/src/nvdec/cuvid.rs +++ b/crates/platform/src/nvdec/cuvid.rs @@ -286,7 +286,7 @@ impl Drop for Mapped<'_> { /// Callback state is exclusively accessed by synchronous callbacks while parse is running. unsafe fn callback(data: *mut c_void, f: impl FnOnce(&mut State) -> Result) -> i32 { - // SAFETY: cuvid receives the stable Box pointer at parser creation; callbacks run + // SAFETY: cuvid receives the stable, leaked State pointer at parser creation; callbacks run // synchronously, without a live Rust reference to State in the enclosing parse call. let Some(state) = (unsafe { data.cast::().as_mut() }) else { return 0 }; if state.error.is_some() { @@ -342,14 +342,18 @@ unsafe extern "C" fn display(data: *mut c_void, picture: *mut CUVIDPARSERDISPINF /// A movable session. Each operation pushes its CUDA context on the calling thread. pub struct Session { - state: Box, + // From `Box::into_raw` and freed in `Drop`: the parser's callbacks get this same pointer as + // their user data, so Rust code reaches the state only through it (never through a `Box` + // whose later use would invalidate the pointer the driver holds). + state: ptr::NonNull, parser: *mut c_void, device: Box, // Dropped after device/context release, and after all cuvid handles have been destroyed. _library: libloading::Library, } -// SAFETY: no callbacks or GPU-memory references outlive an operation. The boxed state and device -// retain stable addresses across moves; every driver call pushes the context on its calling thread. +// SAFETY: no callbacks or GPU-memory references outlive an operation. The state allocation and +// boxed device retain stable addresses across moves; every driver call pushes the context on its +// calling thread. unsafe impl Send for Session {} impl Session { pub fn new(codec: NalCodec, coded: (u32, u32), geometry: Geometry, reorder: usize) -> Result { @@ -370,7 +374,7 @@ impl Session { status(unsafe { (api.caps)(&mut capabilities) }, "cuvidGetDecoderCaps")?; check_caps(&capabilities, coded, geometry.bits)?; } - let state = Box::new(State { + let state = ptr::NonNull::new(Box::into_raw(Box::new(State { api, device: ptr::from_ref(device.as_ref()), decoder: ptr::null_mut(), @@ -383,38 +387,53 @@ impl Session { pending: Vec::new(), ready: Vec::new(), error: None, - }); + }))) + .ok_or("NVDEC state allocation is null")?; + // From here on `Drop` frees the state, also when creating the parser fails. let mut session = Self { state, parser: ptr::null_mut(), device, _library: library }; session.create_parser()?; Ok(session) } + /// The callback state. Never hold the reference across a driver call that can run the + /// parser's callbacks (`cuvidParseVideoData`): they reach the state through the same pointer. + fn state(&mut self) -> &mut State { + // SAFETY: the pointer comes from `Box::into_raw` and only `Drop` frees it, so it is valid and + // aligned. `&mut self` makes this the only Rust access; callbacks run only inside + // `parse`'s driver call, during which no reference from here is live. + unsafe { self.state.as_mut() } + } fn create_parser(&mut self) -> Result<(), String> { + let (api, codec, delay) = { + let state = self.state(); + (state.api, state.codec, state.delay) + }; let _current = self.device.push_current()?; // SAFETY: plain C data, nullable callback pointers and opaque pointers allow zeros. let mut params: CUVIDPARSERPARAMS = unsafe { std::mem::zeroed() }; - params.CodecType = self.state.codec; + params.CodecType = codec; params.ulMaxNumDecodeSurfaces = 1; // sequence callback supplies the real DPB size params.ulClockRate = 0; - params.ulMaxDisplayDelay = self.state.delay; - params.pUserData = (&raw mut *self.state).cast(); + params.ulMaxDisplayDelay = delay; + params.pUserData = self.state.as_ptr().cast(); params.pfnSequenceCallback = Some(sequence); params.pfnDecodePicture = Some(decode); params.pfnDisplayPicture = Some(display); - // SAFETY: params and output pointer are valid; boxed user data remains alive until - // parser destruction, and the library is retained by Session. - status(unsafe { (self.state.api.create_parser)(&mut self.parser, &mut params) }, "cuvidCreateVideoParser")?; + // SAFETY: params and output pointer are valid; the state allocation remains alive until + // after parser destruction (Drop), and the library is retained by Session. + status(unsafe { (api.create_parser)(&mut self.parser, &mut params) }, "cuvidCreateVideoParser")?; if self.parser.is_null() { return Err("NVDEC returned a null parser".into()); } Ok(()) } fn parse(&mut self, payload: &[u8], pts: i64, flags: u64) -> Result, String> { - if let Some(error) = &self.state.error { + if let Some(error) = &self.state().error { return Err(error.clone()); } if self.parser.is_null() { return Err("NVDEC parser is not available".into()); } + let api = self.state().api; let _current = self.device.push_current()?; let mut packet = CUVIDSOURCEDATAPACKET { flags, @@ -422,18 +441,21 @@ impl Session { payload: if payload.is_empty() { ptr::null() } else { payload.as_ptr() }, timestamp: pts, }; - // SAFETY: parser is live, payload and packet outlive this synchronous call. We do not - // borrow State across it, so callbacks can access their exclusive boxed user data. - let st = unsafe { (self.state.api.parse)(self.parser, &mut packet) }; - if let Some(error) = &self.state.error { + // SAFETY: parser is live, payload and packet outlive this synchronous call. No reference + // to State is live across it, so callbacks have exclusive access through their pointer. + let st = unsafe { (api.parse)(self.parser, &mut packet) }; + // SAFETY: as in `state()`; the parse call has returned, so no callback runs while this + // reference lives (the readback below calls no parser function). + let state = unsafe { self.state.as_mut() }; + if let Some(error) = &state.error { return Err(error.clone()); } status(st, "cuvidParseVideoData")?; - match catch_unwind(AssertUnwindSafe(|| self.state.drain())) { + match catch_unwind(AssertUnwindSafe(|| state.drain())) { Ok(result) => result?, Err(_) => return Err("NVDEC readback panicked".into()), } - Ok(std::mem::take(&mut self.state.ready)) + Ok(std::mem::take(&mut state.ready)) } pub fn feed(&mut self, annex_b: &[u8], pts: i64) -> Result, String> { if annex_b.is_empty() || annex_b.len() > MAX_PACKET_BYTES { @@ -441,7 +463,7 @@ impl Session { } let result = self.parse(annex_b, pts, CUVID_PKT_TIMESTAMP | CUVID_PKT_ENDOFPICTURE); if let Err(error) = &result { - self.state.error.get_or_insert_with(|| error.clone()); + self.state().error.get_or_insert_with(|| error.clone()); } result } @@ -454,33 +476,37 @@ impl Session { /// costs tens of milliseconds, and the next parser's sequence callback takes it as it is). fn destroy(&mut self, keep_decoder: bool) -> Result<(), String> { let _current = self.device.push_current()?; + let parser = self.parser; + // SAFETY: as in `state()`; while this reference lives only the unmap and destroy calls + // run, and they invoke no parser callbacks. + let state = unsafe { self.state.as_mut() }; let mut error = None; - if let Some(address) = self.state.mapped.get() { + if let Some(address) = state.mapped.get() { // SAFETY: retry an outstanding map before releasing its decoder; context is current. - match status(unsafe { (self.state.api.unmap)(self.state.decoder, address) }, "cuvidUnmapVideoFrame64") { - Ok(()) => self.state.mapped.set(None), + match status(unsafe { (state.api.unmap)(state.decoder, address) }, "cuvidUnmapVideoFrame64") { + Ok(()) => state.mapped.set(None), Err(e) => { error = Some(e); } } } - if !keep_decoder && !self.state.decoder.is_null() { + if !keep_decoder && !state.decoder.is_null() { // SAFETY: owned decoder, no host views or callbacks remain; unmapping was attempted // first, and context and library remain live while the driver releases its resources. - match status(unsafe { (self.state.api.destroy_decoder)(self.state.decoder) }, "cuvidDestroyDecoder") { + match status(unsafe { (state.api.destroy_decoder)(state.decoder) }, "cuvidDestroyDecoder") { Ok(()) => { - self.state.decoder = ptr::null_mut(); - self.state.surfaces = 0; - self.state.mapped.set(None); + state.decoder = ptr::null_mut(); + state.surfaces = 0; + state.mapped.set(None); } Err(e) => { error.get_or_insert(e); } } } - if !self.parser.is_null() { + if !parser.is_null() { // SAFETY: owned parser, no parse call running; user data and library are still live. - match status(unsafe { (self.state.api.destroy_parser)(self.parser) }, "cuvidDestroyVideoParser") { + match status(unsafe { (state.api.destroy_parser)(parser) }, "cuvidDestroyVideoParser") { Ok(()) => self.parser = ptr::null_mut(), Err(e) => { error.get_or_insert(e); @@ -493,12 +519,13 @@ impl Session { } } fn restart(&mut self) -> Result<(), String> { - self.state.ready.clear(); - self.state.pending.clear(); - self.state.error = None; + let state = self.state(); + state.ready.clear(); + state.pending.clear(); + state.error = None; let result = self.destroy(true).and_then(|()| self.create_parser()); if let Err(error) = &result { - self.state.error = Some(error.clone()); + self.state().error = Some(error.clone()); } result } @@ -513,6 +540,14 @@ impl Drop for Session { if let Err(e) = self.destroy(false) { log::warn!("NVDEC cleanup failed: {e}"); } + // A parser that failed to be destroyed is leaked rather than left pointing at freed state. + if self.parser.is_null() { + // SAFETY: the pointer came from `Box::into_raw` in `new`, is freed only here, and no parser + // holds it any more (destroyed above), so nothing can reach it afterwards. + drop(unsafe { Box::from_raw(self.state.as_ptr()) }); + } else { + log::warn!("NVDEC parser could not be destroyed: leaking its state"); + } } } /// Load both drivers and retain a primary context; does not create a decoder or parse video. diff --git a/crates/platform/tests/setting.rs b/crates/platform/tests/setting.rs index 5272a061..c58f4a95 100644 --- a/crates/platform/tests/setting.rs +++ b/crates/platform/tests/setting.rs @@ -36,12 +36,14 @@ fn off_uses_the_software_decoder() { assert_eq!(d.name(), "FilmCraft HEVC"); return; } - // Linux has two backends (NVDEC in front of VA-API): the one `register` reported + // Linux has two backends (NVDEC in front of VA-API): `register` reports the front one, but + // a stream NVDEC declines (an older GPU without HEVC) can still go to VA-API on a second GPU let backend = match available { filmcraft_platform::Availability::Available(name) => name, filmcraft_platform::Availability::Unavailable(why) => panic!("a hardware decoder, but register() said {why}"), }; - assert!(d.name().starts_with(backend) && d.name().contains("HEVC"), "{} from {backend}", d.name()); + let decoders: &[&str] = if backend == "NVDEC" { &["NVDEC", "VA-API"] } else { &[backend] }; + assert!(decoders.iter().any(|b| d.name().starts_with(b)) && d.name().contains("HEVC"), "{} from {backend}", d.name()); assert_eq!(filmcraft_codecs::hw::hw_backend(), Some(backend), "perf.stats reports the backend"); assert!(filmcraft_platform::registered(), "register() put the factory in the registry"); let h1 = filmcraft_codecs::hw::hw_stats(); From 1ffe0cf4e7930aeab45f1fc7a932de06326eae90 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 07:01:41 -0400 Subject: [PATCH 13/14] #505: merge origin/main; ja/uk rows, colorTheme kept in line, docs - ja.tsv rows for the new appearance strings (no duplicate Dark/Light); uk.tsv gets Next Appearance Mode, which the pt-br menu catalog now has. - appearance.colorTheme is legacy input but no longer goes stale: it follows the mode and theme choices (the theme a fixed mode shows, the dark theme in Auto). - docs/project-files.md documents appearanceMode / darkTheme / lightTheme. - #489's menu/settings label test compares with the theme cards' choices. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/autosave.rs | 6 ++++-- crates/engine/src/settings.rs | 12 +++++++++++- crates/engine/src/settings_tests.rs | 11 +++++++++++ crates/ui-egui/src/i18n/ja.tsv | 7 ++++++- crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/tests/settings_ui.rs | 9 ++++++--- docs/project-files.md | 2 +- 7 files changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 0da3f3aa..f5e8e809 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -392,6 +392,7 @@ impl Preferences { self.playback.postroll_seconds = secs(self.playback.postroll_seconds); self.trim.large_trim_offset = self.trim.large_trim_offset.clamp(1, 1000); self.labels.sanitize_labels(); + self.appearance.sync_color_theme(); self.version = settings::PREFS_VERSION; } @@ -454,12 +455,13 @@ impl Preferences { let _: Preferences = serde_json::from_value(v.clone()).map_err(|e| format!("`{key}`: {e}"))?; settings::sanitize(&mut v, &Self::default().to_value()); let mut p: Preferences = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?; - p.clamp(); - // Old clients that set the single Color Theme still pick the theme they name. + // Old clients that set the single Color Theme still pick the theme they name (before + // `clamp`, which brings `colorTheme` back in line with the mode and theme choices). if key == "appearance.colorTheme" { let theme = p.appearance.color_theme.clone(); p.appearance.select_theme(&theme); } + p.clamp(); *self = p; Ok(()) } diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index e72f7865..9f8adb1d 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -89,7 +89,10 @@ impl Default for GeneralPrefs { #[serde(rename_all = "camelCase", default)] pub struct AppearancePrefs { /// Legacy single "Color Theme": `darkest` | `dark` | `light`. Setting it (old settings files, - /// automation, View ▸ Appearance) selects that theme and fixes the mode to its family. + /// automation, View ▸ Appearance) selects that theme and fixes the mode to its family. It is + /// kept in line with the fields below ([`AppearancePrefs::sync_color_theme`]): the light or + /// dark theme of a fixed mode, and the dark theme in Auto (whose shown theme depends on the + /// system; read `appearanceMode` for that). pub color_theme: String, /// "Appearance Mode": `auto` (follow the operating system) | `dark` (default) | `light`. pub appearance_mode: String, @@ -140,6 +143,13 @@ impl AppearancePrefs { } } + /// Bring the legacy `color_theme` in line with the mode and theme choices, so clients that + /// still read it get the theme a fixed mode shows (the dark theme in Auto). + pub fn sync_color_theme(&mut self) { + let theme = self.shown_theme(None).to_string(); + self.color_theme = theme; + } + /// The mode after this one in the header button's cycle: Auto, Light, Dark, Auto… pub fn next_mode(&self) -> &'static str { match self.appearance_mode.as_str() { diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index 67d124de..6744a9c3 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -598,6 +598,17 @@ fn appearance_choices_validate_and_legacy_theme_still_selects() { assert_eq!(s.prefs.appearance.dark_theme, "dark", "the dark choice is kept"); set(&mut s, "appearance.colorTheme", json!("darkest")); assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("dark", "darkest")); + // the legacy Color Theme follows the mode and theme choices, so clients reading it aren't stale + assert_eq!(s.prefs.appearance.color_theme, "darkest"); + set(&mut s, "appearance.appearanceMode", json!("light")); + assert_eq!(s.prefs.appearance.color_theme, "light"); + set(&mut s, "appearance.appearanceMode", json!("dark")); + set(&mut s, "appearance.darkTheme", json!("dark")); + assert_eq!(s.prefs.appearance.color_theme, "dark"); + set(&mut s, "appearance.appearanceMode", json!("auto")); + assert_eq!(s.prefs.appearance.color_theme, "dark", "Auto: the dark theme"); + set(&mut s, "appearance.colorTheme", json!("light")); + assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.color_theme.as_str()), ("light", "light")); // the header button's cycle let mut a = settings::AppearancePrefs::default(); let mut seen = Vec::new(); diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index cfa1e04e..cbd1c9b7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3435,8 +3435,13 @@ Username ユーザー名 Vertical Type Tool 縦書き文字ツール -# System appearance mode menu command. +# System appearance modes and theme cards. + Active 使用中 + Appearance Mode 外観モード + Dark Theme ダークテーマ + Light Theme ライトテーマ Next Appearance Mode 次の外観モード + Sync with system システムと同期 Go to previous keyframe 前のキーフレームへ移動 Go to next keyframe 次のキーフレームへ移動 Add keyframe キーフレームを追加 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2e4fd527..6fa303f4 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -216,6 +216,7 @@ New Створити New Layer Новий шар New Project Panel Нова панель проєкту + Next Appearance Mode Наступний режим вигляду Next in Sequence Наступний у секвенції Next in Track Наступний на доріжці Normalize Mix Track… Нормалізувати доріжку міксу… diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/settings_ui.rs index e4b9eff3..3d19aaaf 100644 --- a/crates/ui-egui/tests/settings_ui.rs +++ b/crates/ui-egui/tests/settings_ui.rs @@ -251,9 +251,12 @@ fn view_menu_and_settings_name_each_color_theme_alike() { let menu = d.ok("ui.menu.list", json!({})); let menu_label = |id: &str| menu.as_array().unwrap().iter().find(|m| m["id"] == id).unwrap()["label"].as_str().unwrap().to_string(); d.menu("app.settings.appearance"); - d.click("settings.appearance.colorTheme"); - for (menu_id, pref) in [("view.theme.dark", "darkest"), ("view.theme.medium", "dark"), ("view.theme.light", "light")] { - let id = format!("settings.appearance.colorTheme.{pref}"); + // the theme cards' choices (the single Color Theme is no longer drawn) + for (menu_id, id) in [ + ("view.theme.dark", "settings.appearance.darkTheme.darkest"), + ("view.theme.medium", "settings.appearance.darkTheme.dark"), + ("view.theme.light", "settings.appearance.lightTheme.light"), + ] { let found = d.ok("ui.elements", json!({"prefix": id})); let choice = found.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap(); assert_eq!(menu_label(menu_id), choice["label"].as_str().unwrap(), "{menu_id} and {id} name the same theme"); diff --git a/docs/project-files.md b/docs/project-files.md index c8827150..5c1b08fd 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -215,7 +215,7 @@ opens the dialog on a page. | Category | Takes effect | |---|---| | General | At Startup (Show Home = demo project, Open Most Recent, empty project; recent projects are remembered on open/save), Show Tool Tips | -| Appearance | Color Theme (Darkest / Dark / Light; View ▸ Appearance writes it too), highlight colour, accessible contrast | +| Appearance | Appearance Mode `appearanceMode` (`auto` = Sync with system / `dark` (default) / `light`; the header button and View ▸ Appearance ▸ Next Appearance Mode cycle it), Dark Theme `darkTheme` (`darkest` (default) / `dark`), Light Theme `lightTheme` (`light`), highlight colour, accessible contrast. `colorTheme` (Darkest / Dark / Light) is legacy input: setting it (older files and clients, View ▸ Appearance ▸ a theme) selects that theme and fixes the mode to its family; a file without `appearanceMode` migrates to its theme's fixed mode. It is kept in line with the choices: the theme a fixed mode shows, the dark theme in Auto | | Audio | Automatch Time, Large Volume Adjustment, automation keyframe thinning (linear, minimum time) | | Audio Hardware | device class (cpal host), output device, I/O buffer size, sample rate, force document rate, Output Mapping (programme L/R → device channels) | | Auto Save | the auto-save ring and the crash-recovery journal | From 1dc42c7d0d9e6db921fd0933e6b370895d56cf0f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 07:07:40 -0400 Subject: [PATCH 14/14] Simplified Chinese rows for the appearance-mode strings Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/zh-cn.tsv | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index ac143baf..5da60959 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3481,3 +3481,7 @@ In 入点 Out 出点 Export Markers as CSV… 将标记导出为 CSV… + Active 当前 + Appearance Mode 外观模式 + Next Appearance Mode 下一个外观模式 + Sync with system 跟随系统