Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
762344769b
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -1689,6 +1689,7 @@ dependencies = [
|
|||
"sys-locale",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -211,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
|||
|
||||
### Interface localization
|
||||
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese` and `app.language.ukrainian` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/<code>.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish and Japanese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet). Searches match the displayed labels as well as English sources. Brazilian Portuguese and Ukrainian cover the core menus; untranslated labels use English. Ukrainian uses the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved.
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian/Simplified Chinese and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese`, `app.language.ukrainian` and `app.language.chinese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/<code>.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish, Japanese and Simplified Chinese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet; `zh-cn.tsv` must cover every `tl!` literal and menu label too). Searches match the displayed labels as well as English sources. Brazilian Portuguese and Ukrainian cover the core menus; untranslated labels use English. Ukrainian uses the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Simplified Chinese (`zh-cn`; System Language picks it for `zh`, `zh-CN`, `zh-SG` and `zh-Hans`, not for Traditional locales) uses the Chinese fallback font the interface always installs (craft-fonts, otherwise an installed system face); with none, switching to it is refused the same way. Existing vertical text support is preserved.
|
||||
|
|
|
|||
|
|
@ -528,7 +528,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:?}");
|
||||
|
|
|
|||
|
|
@ -41,6 +41,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"
|
||||
|
|
|
|||
|
|
@ -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<zbus::blocking::Proxy<'static>> {
|
||||
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<egui::Theme> {
|
||||
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<u32> {
|
||||
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<egui::Theme> {
|
||||
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<egui::Theme> {
|
||||
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<egui::Theme> {
|
||||
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<egui::Theme>) -> 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<filmcraft_ui_egui::SystemThemeFn> {
|
||||
#[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<OnceLock<egui::Context>> = 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -230,6 +231,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();
|
||||
}));
|
||||
|
|
@ -354,7 +358,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:?}");
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
|||
filmcraft_ui_egui::i18n::Language::PtBr
|
||||
} else if checked("app.language.ukrainian") {
|
||||
filmcraft_ui_egui::i18n::Language::Uk
|
||||
} else if checked("app.language.chinese") {
|
||||
filmcraft_ui_egui::i18n::Language::ZhCn
|
||||
} else {
|
||||
filmcraft_ui_egui::i18n::Language::En
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<ColorInfo>,
|
||||
/// 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<Region>,
|
||||
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<Region>) -> 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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ use std::collections::HashMap;
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use filmcraft_color::ColorInfo;
|
||||
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<Region> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// Colour of a video sample entry. The `colr` box (`nclx` / `nclc`) wins; what it leaves
|
||||
/// unspecified, or all of it when there is none (ffmpeg writes MP4 without `colr` by default),
|
||||
/// comes from the stream's own description: `vpcC` / `apvC`, or the SPS VUI in `avcC` / `hvcC`
|
||||
|
|
@ -163,11 +185,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<u32> = t.samples.iter().take(240).map(|s| s.duration).collect();
|
||||
durs.sort_unstable();
|
||||
|
|
@ -268,7 +300,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<Vec<u8>> {
|
||||
|
|
@ -735,6 +767,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::<u32>() / 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;
|
||||
|
|
|
|||
|
|
@ -53,3 +53,113 @@ 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<u8> {
|
||||
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);
|
||||
// `-flags unaligned`: ffmpeg otherwise skips a left crop that would misalign its frame
|
||||
// pointers (on AVX-512 machines it needs 64-byte alignment) and returns wider frames
|
||||
let raw = ffmpeg_out(
|
||||
&ff,
|
||||
&["-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().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;
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +455,12 @@ 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}"))?;
|
||||
// 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(())
|
||||
|
|
|
|||
|
|
@ -639,7 +639,7 @@ fn sequence_from_clip(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let pi = s.project.item(first).ok_or_else(|| bad("file.newSequenceFromClip", "no such item"))?.clone();
|
||||
let mut settings = match &pi.kind {
|
||||
ItemKind::Sequence(q) => q.settings.clone(),
|
||||
_ => media_root(&s.project, first).map(|(_, m, _)| crate::commands::default_seq_settings_for(&m.info)).unwrap_or_default(),
|
||||
_ => media_root(&s.project, first).map(|(_, m, _)| crate::commands::default_seq_settings_for(m)).unwrap_or_default(),
|
||||
};
|
||||
if let ItemKind::AdjustmentLayer { width, height, rate, .. } = &pi.kind {
|
||||
settings.width = *width;
|
||||
|
|
@ -1519,17 +1519,24 @@ fn fit_fill(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
|||
let clips = video_clips(s, p);
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let (fw, fh) = (q.settings.width as f64, q.settings.height as f64);
|
||||
let sizes: Vec<(ClipId, (u32, u32))> =
|
||||
clips.iter().filter_map(|c| q.find_item(*c).and_then(|(_, i)| filmcraft_render::source_size(&s.project, i.item)).map(|sz| (*c, sz))).collect();
|
||||
let frame_par = q.settings.par;
|
||||
// (clip, storage size for the anchor, size at the display aspect in sequence pixels for the scale)
|
||||
let sizes: Vec<(ClipId, (u32, u32), (f64, f64))> = clips
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let (_, i) = q.find_item(*c)?;
|
||||
Some((*c, filmcraft_render::source_size(&s.project, i.item)?, s.project.conformed_source_size(i.item, frame_par)?))
|
||||
})
|
||||
.collect();
|
||||
if sizes.is_empty() {
|
||||
return Err(bad(if fill { "clip.fillFrame" } else { "clip.fitToFrame" }, "select a video clip"));
|
||||
}
|
||||
let label = if fill { "Fill Frame" } else { "Fit to Frame" };
|
||||
let out = s.edit_sequence(label, |q, _, _| {
|
||||
let mut out = Vec::new();
|
||||
for (c, (w, h)) in &sizes {
|
||||
for (c, (w, h), (dw, dh)) in &sizes {
|
||||
let Some((_, it)) = q.find_item_mut(*c) else { continue };
|
||||
let (sx, sy) = (fw / (*w).max(1) as f64, fh / (*h).max(1) as f64);
|
||||
let (sx, sy) = (fw / dw.max(1.0), fh / dh.max(1.0));
|
||||
let pct = (if fill { sx.max(sy) } else { sx.min(sy) }) * 100.0;
|
||||
it.scale_to_frame = false;
|
||||
let Some(m) = it.effect_mut("motion") else { continue };
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
|||
"clip.interpretFootage",
|
||||
"Interpret Footage…",
|
||||
&["Clip", "Modify"],
|
||||
r#"{"items":[id]?,"colorSpace":"auto"|"<color space id>"}"#,
|
||||
r#"{"items":[id]?,"colorSpace":"auto"|"<color space id>"?,"pixelAspect":[num,den]|"file"?}"#,
|
||||
has_footage,
|
||||
interpret,
|
||||
),
|
||||
|
|
@ -292,25 +292,57 @@ pub(crate) fn footage_targets(s: &Session, p: &Value) -> Vec<ItemId> {
|
|||
v
|
||||
}
|
||||
|
||||
/// `clip.interpretFootage`: the colour space (`colorSpace`) and / or the pixel aspect ratio
|
||||
/// (`pixelAspect`) the footage is interpreted with. `"auto"` / `"file"` go back to the file's
|
||||
/// metadata; a parameter left out keeps its current interpretation.
|
||||
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "clip.interpretFootage";
|
||||
let items = footage_targets(s, p);
|
||||
if items.is_empty() {
|
||||
return Err(bad("clip.interpretFootage", "no footage selected"));
|
||||
return Err(bad(CMD, "no footage selected"));
|
||||
}
|
||||
let cs = match str_p(p, "colorSpace") {
|
||||
None => return Err(bad("clip.interpretFootage", "need `colorSpace` (auto or a colour space id)")),
|
||||
Some("auto") | Some("") => None,
|
||||
Some(c) => Some(ColorSpace::parse(c).ok_or_else(|| bad("clip.interpretFootage", format!("unknown colour space `{c}`")))?),
|
||||
None => None,
|
||||
Some("auto") | Some("") => Some(None),
|
||||
Some(c) => Some(Some(ColorSpace::parse(c).ok_or_else(|| bad(CMD, format!("unknown colour space `{c}`")))?)),
|
||||
};
|
||||
let par =
|
||||
match p.get("pixelAspect") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(f)) if f == "file" || f == "auto" => Some(None),
|
||||
Some(v) => Some(Some(pixel_aspect_p(v).ok_or_else(|| {
|
||||
bad(CMD, format!("pixelAspect must be [num, den] between 1:{0} and {0}:1, or \"file\"", filmcraft_project::MAX_PIXEL_ASPECT))
|
||||
})?)),
|
||||
};
|
||||
if cs.is_none() && par.is_none() {
|
||||
return Err(bad(CMD, "need `colorSpace` (auto or a colour space id) or `pixelAspect`"));
|
||||
}
|
||||
s.edit("Interpret Footage", |pr, _| {
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Media(m)) = pr.item_mut(*i).map(|it| &mut it.kind) {
|
||||
m.interpret.color_space = cs;
|
||||
if let Some(cs) = cs {
|
||||
m.interpret.color_space = cs;
|
||||
}
|
||||
if let Some(par) = par {
|
||||
m.interpret.par = par;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "colorSpace": cs.map(|c| c.id()).unwrap_or("auto")}))
|
||||
let first = items.first().and_then(|i| s.project.item(*i)).and_then(|i| i.as_media());
|
||||
Ok(json!({
|
||||
"items": items.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"colorSpace": first.and_then(|m| m.interpret.color_space).map(|c| c.id()).unwrap_or("auto"),
|
||||
"pixelAspect": first.map(|m| m.pixel_aspect()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `[num, den]` as a pixel aspect ratio FilmCraft can use ([`filmcraft_project::checked_par`]).
|
||||
fn pixel_aspect_p(v: &Value) -> Option<(u32, u32)> {
|
||||
let [n, d] = v.as_array()?.as_slice() else { return None };
|
||||
let (n, d) = (u32::try_from(n.as_u64()?).ok()?, u32::try_from(d.as_u64()?).ok()?);
|
||||
filmcraft_project::checked_par((n, d))
|
||||
}
|
||||
|
||||
fn spaces(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -357,12 +357,17 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
|
|||
out
|
||||
}
|
||||
|
||||
pub(crate) fn default_seq_settings_for(info: &filmcraft_media::MediaInfo) -> SequenceSettings {
|
||||
/// Sequence settings matching a media clip (New Sequence From Clip, New Sequence from an item):
|
||||
/// its frame size, rate and pixel aspect ratio (Interpret Footage's, else the file's), so a
|
||||
/// 1440 x 1080 clip with 4:3 pixels makes a 1440 x 1080 sequence that displays 16:9.
|
||||
pub(crate) fn default_seq_settings_for(media: &MediaClip) -> SequenceSettings {
|
||||
let info = &media.info;
|
||||
let mut st = SequenceSettings::default();
|
||||
if let Some(v) = &info.video {
|
||||
st.width = v.width;
|
||||
st.height = v.height;
|
||||
st.frame_rate = v.frame_rate;
|
||||
st.par = media.pixel_aspect();
|
||||
st.preset = format!("{}x{} {}", v.width, v.height, v.frame_rate.label());
|
||||
}
|
||||
if let Some(a) = info.audio() {
|
||||
|
|
@ -455,8 +460,15 @@ fn new_generator(s: &mut Session, g: Generator, name: &str, label: Label, p: &Va
|
|||
let name = str_p(p, "name").unwrap_or(name).to_string();
|
||||
let src = GeneratorSource::new(g, w, h, st.frame_rate, Tick::from_seconds_f64(secs));
|
||||
let pool = s.media.clone();
|
||||
let par = filmcraft_project::sane_par(st.par);
|
||||
let id = s.edit(&format!("New {name}"), |pr, st| {
|
||||
let id = crate::demo::add_generator(pr, &pool, src, &name, label, None);
|
||||
// made for the sequence: its pixels have the sequence's aspect, as in Premiere
|
||||
if let Some(ItemKind::Media(m)) = pr.item_mut(id).map(|i| &mut i.kind)
|
||||
&& let Some(v) = m.info.video.as_mut()
|
||||
{
|
||||
v.par = par;
|
||||
}
|
||||
st.project_selection = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
|
|
@ -585,6 +597,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();
|
||||
|
|
@ -593,6 +607,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) {
|
||||
|
|
@ -601,8 +616,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));
|
||||
}
|
||||
|
|
@ -753,7 +770,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
|s, p| {
|
||||
let mut st = SequenceSettings::default();
|
||||
if let Some(from) = item_p(p, "fromItem").and_then(|i| s.project.item(i)).and_then(|i| i.as_media()) {
|
||||
st = default_seq_settings_for(&from.info);
|
||||
st = default_seq_settings_for(from);
|
||||
}
|
||||
if let Some(w) = checked_u32_p(p, "width", "file.newSequence")? {
|
||||
st.width = w;
|
||||
|
|
|
|||
|
|
@ -1073,6 +1073,8 @@ mod nesting_tests;
|
|||
#[cfg(test)]
|
||||
mod panels_tests;
|
||||
#[cfg(test)]
|
||||
mod par_tests;
|
||||
#[cfg(test)]
|
||||
mod presets_tests;
|
||||
#[cfg(test)]
|
||||
mod previews_tests;
|
||||
|
|
|
|||
|
|
@ -475,12 +475,12 @@ fn sync_items(s: &Session, items: &[ItemId], cmd: &str) -> Result<Vec<SyncClip>>
|
|||
}
|
||||
|
||||
fn settings_for(s: &Session, item: ItemId) -> SequenceSettings {
|
||||
let info = match s.project.item(item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Media(m)) => Some(m.info.clone()),
|
||||
Some(ItemKind::Subclip { parent, .. }) => s.project.item(*parent).and_then(|i| i.as_media()).map(|m| m.info.clone()),
|
||||
let media = match s.project.item(item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Media(m)) => Some(m),
|
||||
Some(ItemKind::Subclip { parent, .. }) => s.project.item(*parent).and_then(|i| i.as_media()),
|
||||
_ => None,
|
||||
};
|
||||
let mut st = info.as_ref().map(crate::commands::default_seq_settings_for).unwrap_or_default();
|
||||
let mut st = media.map(crate::commands::default_seq_settings_for).unwrap_or_default();
|
||||
if let Some(m) = s.project.item(item).and_then(|i| i.as_media())
|
||||
&& let Some(r) = m.interpret.frame_rate
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
//! Anamorphic media: the pixel aspect ratio reaches New Sequence From Clip, the placement of clips
|
||||
//! (default scaling, Fit / Fill) and the rendered frame, Interpret Footage overrides it, and
|
||||
//! hostile ratios are square. Synthetic media (a Color Matte given 4:3 pixels) runs everywhere;
|
||||
//! ffmpeg-made 1440 x 1080-style files (at a tenth of the size) check the MP4 and Matroska
|
||||
//! demuxers end to end when ffmpeg is installed.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use filmcraft_project::{ItemId, ItemKind, ParamValue};
|
||||
|
||||
/// A 144 x 108 Color Matte (a 1440 x 1080 stand-in) whose pixels are `par`.
|
||||
fn matte(s: &mut Session, par: (u32, u32)) -> ItemId {
|
||||
let r = s.execute("file.newColorMatte", json!({"color": "#ff0000", "width": 144, "height": 108, "seconds": 2.0})).unwrap();
|
||||
let id = ItemId(r["item"].as_u64().unwrap());
|
||||
if let Some(ItemKind::Media(m)) = Arc::make_mut(&mut s.project).item_mut(id).map(|i| &mut i.kind) {
|
||||
m.info.video.as_mut().unwrap().par = par;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// The alpha of the middle row of the program frame at columns `xs`.
|
||||
fn coverage(s: &Session, xs: &[usize]) -> Vec<f32> {
|
||||
let img = s.render_program(1.0).unwrap();
|
||||
xs.iter().map(|&x| img.get(x, img.h / 2)[3]).collect()
|
||||
}
|
||||
|
||||
/// A 192 x 108 square-pixel sequence (a 1920 x 1080 stand-in) with `item` overwritten at 0.
|
||||
fn overwrite_into_square_hd(s: &mut Session, item: ItemId) -> filmcraft_project::ClipId {
|
||||
s.execute("file.newSequence", json!({"name": "HD", "width": 192, "height": 108, "fps": 24})).unwrap();
|
||||
s.execute("source.open", json!({"item": item.0})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 0.0})).unwrap();
|
||||
let r = s.execute("source.overwrite", json!({})).unwrap();
|
||||
filmcraft_project::ClipId(r["clips"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn motion_scale(s: &Session, clip: filmcraft_project::ClipId) -> f64 {
|
||||
let (_, it) = s.active_sequence().unwrap().find_item(clip).unwrap();
|
||||
match it.effect("motion").unwrap().params["scale"].value {
|
||||
ParamValue::Float(v) => v,
|
||||
ref v => panic!("{v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_sequence_from_clip_carries_the_pixel_aspect() {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, (4, 3));
|
||||
let r = s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
|
||||
let st = s.project.sequence(ItemId(r["sequence"].as_u64().unwrap())).unwrap().settings.clone();
|
||||
assert_eq!((st.width, st.height, st.par), (144, 108, (4, 3)));
|
||||
assert!(st.validate().is_ok());
|
||||
// the clip maps pixel for pixel onto its own sequence: the whole frame
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
assert!(coverage(&s, &[0, 1, 72, 142, 143]).iter().all(|a| *a > 0.99));
|
||||
// New Sequence from the item too
|
||||
s.execute("file.newSequence", json!({"name": "From item", "fromItem": item.0})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().settings.par, (4, 3));
|
||||
// a ratio written in other terms is reduced
|
||||
let other = matte(&mut s, (16, 12));
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [other.0]})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().settings.par, (4, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anamorphic_clip_fills_a_square_pixel_sequence() {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, (4, 3));
|
||||
let clip = overwrite_into_square_hd(&mut s, item);
|
||||
// displayed 192 wide at 100 %: the whole frame (it was pillarboxed at 144 wide)
|
||||
assert_eq!(motion_scale(&s, clip), 100.0);
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
assert!(coverage(&s, &[1, 10, 96, 181, 190]).iter().all(|a| *a > 0.99), "{:?}", coverage(&s, &[1, 10, 96, 181, 190]));
|
||||
// Fit / Fill fit the display size: already the frame size
|
||||
s.execute("clip.fitToFrame", json!({"clips": [clip.0]})).unwrap();
|
||||
assert!((motion_scale(&s, clip) - 100.0).abs() < 1e-9);
|
||||
s.execute("clip.fillFrame", json!({"clips": [clip.0]})).unwrap();
|
||||
assert!((motion_scale(&s, clip) - 100.0).abs() < 1e-9);
|
||||
assert!(coverage(&s, &[1, 190]).iter().all(|a| *a > 0.99));
|
||||
// a square-pixel matte of the same size is pillarboxed, and Fit to Frame leaves it so
|
||||
let square = matte(&mut s, (1, 1));
|
||||
let clip = overwrite_into_square_hd(&mut s, square);
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
let c = coverage(&s, &[1, 20, 30, 96, 165, 175, 190]);
|
||||
assert!(c[0] < 0.01 && c[1] < 0.01 && c[2] > 0.99 && c[3] > 0.99 && c[4] > 0.99 && c[5] < 0.01 && c[6] < 0.01, "{c:?}");
|
||||
s.execute("clip.fitToFrame", json!({"clips": [clip.0]})).unwrap();
|
||||
assert!((motion_scale(&s, clip) - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_media_scaling_uses_the_display_size() {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, (4, 3));
|
||||
// Set to Frame Size: 1920 x 1080 display in 1920 x 1080 needs no scaling
|
||||
s.prefs.media.default_media_scaling = "setToFrameSize".into();
|
||||
let clip = overwrite_into_square_hd(&mut s, item);
|
||||
assert_eq!(motion_scale(&s, clip), 100.0);
|
||||
// in a 96 x 108 frame it is halved (display 192 wide), not scaled to 96 / 144
|
||||
s.execute("file.newSequence", json!({"name": "Narrow", "width": 96, "height": 108, "fps": 24})).unwrap();
|
||||
s.execute("source.open", json!({"item": item.0})).unwrap();
|
||||
let r = s.execute("source.overwrite", json!({})).unwrap();
|
||||
let clip = filmcraft_project::ClipId(r["clips"][0].as_u64().unwrap());
|
||||
assert_eq!(motion_scale(&s, clip), 50.0);
|
||||
// Scale to Frame Size: the renderer fits the display size
|
||||
s.prefs.media.default_media_scaling = "scaleToFrameSize".into();
|
||||
s.execute("file.newSequence", json!({"name": "Wide", "width": 384, "height": 108, "fps": 24})).unwrap();
|
||||
s.execute("source.open", json!({"item": item.0})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 0.0})).unwrap();
|
||||
s.execute("source.overwrite", json!({})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
let c = coverage(&s, &[90, 100, 192, 283, 293]);
|
||||
assert!(c[0] < 0.01 && c[1] > 0.99 && c[2] > 0.99 && c[3] > 0.99 && c[4] < 0.01, "192 wide, centred: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_footage_pixel_aspect_wins_over_the_file_and_undoes() {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, (4, 3));
|
||||
let r = s.execute("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": [2, 1]})).unwrap();
|
||||
assert_eq!(r["pixelAspect"], json!([2, 1]));
|
||||
let media = |s: &Session| s.project.item(item).unwrap().as_media().unwrap().clone();
|
||||
assert_eq!((media(&s).interpret.par, media(&s).pixel_aspect()), (Some((2, 1)), (2, 1)));
|
||||
// the colour interpretation is left as it was
|
||||
assert_eq!(media(&s).interpret.color_space, None);
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().settings.par, (2, 1));
|
||||
// a 2:1 picture 144 wide shows 288 wide: wider than the 192-wide square frame at 100 %
|
||||
let clip = overwrite_into_square_hd(&mut s, item);
|
||||
assert_eq!(motion_scale(&s, clip), 100.0);
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
assert!(coverage(&s, &[1, 190]).iter().all(|a| *a > 0.99));
|
||||
s.execute("clip.fitToFrame", json!({"clips": [clip.0]})).unwrap();
|
||||
assert!((motion_scale(&s, clip) - 100.0 * 192.0 / 288.0).abs() < 1e-9);
|
||||
// "file" goes back to the file's ratio; undo restores the override
|
||||
s.execute("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": "file"})).unwrap();
|
||||
assert_eq!((media(&s).interpret.par, media(&s).pixel_aspect()), (None, (4, 3)));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(media(&s).interpret.par, Some((2, 1)));
|
||||
// hostile values are refused and change nothing
|
||||
let rev = s.revision;
|
||||
for bad in [
|
||||
json!([0, 1]),
|
||||
json!([1, 0]),
|
||||
json!([9, 1]),
|
||||
json!([1, 9]),
|
||||
json!([4]),
|
||||
json!([4, 3, 1]),
|
||||
json!([-4, 3]),
|
||||
json!([4.5, 3]),
|
||||
json!([u64::MAX, 1]),
|
||||
json!("wide"),
|
||||
json!({"n": 4}),
|
||||
] {
|
||||
assert!(s.execute("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("clip.interpretFootage", json!({"items": [item.0]})).is_err(), "nothing to change");
|
||||
assert_eq!((s.revision, media(&s).interpret.par), (rev, Some((2, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_file_pixel_aspects_make_square_sequences() {
|
||||
for bad in [(0, 0), (0, 3), (4, 0), (u32::MAX, 1), (1, u32::MAX), (9, 1)] {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, bad);
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
|
||||
let st = s.active_sequence().unwrap().settings.clone();
|
||||
assert_eq!(st.par, (1, 1), "{bad:?}");
|
||||
assert!(st.validate().is_ok());
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
assert!(coverage(&s, &[0, 143]).iter().all(|a| *a > 0.99), "{bad:?}");
|
||||
let clip = overwrite_into_square_hd(&mut s, item);
|
||||
assert_eq!(motion_scale(&s, clip), 100.0);
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
let c = coverage(&s, &[1, 96, 190]);
|
||||
assert!(c[0] < 0.01 && c[1] > 0.99 && c[2] < 0.01, "{bad:?}: square, pillarboxed: {c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generators_made_for_a_non_square_sequence_have_its_pixels() {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, (4, 3));
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
|
||||
// a Color Matte made now is 144 x 108 with the sequence's 4:3 pixels: it fills the frame
|
||||
let r = s.execute("file.newColorMatte", json!({"color": "#00ff00"})).unwrap();
|
||||
let matte2 = ItemId(r["item"].as_u64().unwrap());
|
||||
let m = s.project.item(matte2).unwrap().as_media().unwrap();
|
||||
assert_eq!((m.info.video.as_ref().unwrap().width, m.pixel_aspect()), (144, (4, 3)));
|
||||
}
|
||||
|
||||
/// ffmpeg-made 144 x 108 H.264 with 4:3 samples (`setsar=4/3`): MP4 (`pasp`) and Matroska
|
||||
/// (DisplayWidth / DisplayHeight).
|
||||
fn anamorphic_fixtures() -> Option<Vec<PathBuf>> {
|
||||
let ffmpeg = filmcraft_testkit::oracle::ffmpeg_or_skip("par")?;
|
||||
let dir = filmcraft_testkit::fixtures_dir("engine/par");
|
||||
let mut out = Vec::new();
|
||||
for ext in ["mp4", "mkv"] {
|
||||
let path = dir.join(format!("anamorphic_144x108_sar43.{ext}"));
|
||||
out.push(filmcraft_testkit::fixtures::generate(&path, |tmp| {
|
||||
std::process::Command::new(&ffmpeg)
|
||||
.args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=144x108:rate=24:duration=1"])
|
||||
.args(["-vf", "setsar=4/3", "-c:v", "libx264", "-preset", "fast", "-pix_fmt", "yuv420p"])
|
||||
.arg(tmp)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
})?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_anamorphic_files_fill_a_square_frame_and_make_anamorphic_sequences() {
|
||||
let Some(files) = anamorphic_fixtures() else { return };
|
||||
for file in files {
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("file.import", json!({"paths": [file.to_string_lossy()]})).unwrap();
|
||||
let item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
let v = s.project.item(item).unwrap().as_media().unwrap().info.video.clone().unwrap();
|
||||
assert_eq!((v.width, v.height, v.par), (144, 108, (4, 3)), "{}", file.display());
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().settings.par, (4, 3));
|
||||
overwrite_into_square_hd(&mut s, item);
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
// testsrc2 is opaque everywhere: covered edge to edge
|
||||
let c = coverage(&s, &[1, 10, 181, 190]);
|
||||
assert!(c.iter().all(|a| *a > 0.99), "{}: {c:?}", file.display());
|
||||
}
|
||||
}
|
||||
|
|
@ -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().map_or(48_000, |a| a.sample_rate.max(8000));
|
||||
|
|
|
|||
|
|
@ -88,8 +88,18 @@ 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. 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,
|
||||
/// 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 +108,55 @@ 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<bool>) -> &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();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
"auto" => "light",
|
||||
"light" => "dark",
|
||||
_ => "auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -607,6 +665,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")];
|
||||
|
|
@ -687,6 +748,7 @@ static CATEGORIES: &[Category] = &[
|
|||
("es", "Español"),
|
||||
("pt-br", "Português (Brasil)"),
|
||||
("uk", "Українська"),
|
||||
("zh-cn", "简体中文"),
|
||||
]),
|
||||
true,
|
||||
),
|
||||
|
|
@ -721,6 +783,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),
|
||||
|
|
@ -973,7 +1041,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.",
|
||||
),
|
||||
],
|
||||
},
|
||||
|
|
@ -1140,6 +1208,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 {
|
||||
|
|
@ -1218,17 +1298,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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -551,3 +551,70 @@ 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 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();
|
||||
for _ in 0..4 {
|
||||
a.appearance_mode = a.next_mode().into();
|
||||
seen.push(a.appearance_mode.clone());
|
||||
}
|
||||
assert_eq!(seen, ["auto", "light", "dark", "auto"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T: Copy + Into<u32> + TryFrom<u32> + 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<T: Copy + Default>(src: &[T], w: usize, h: usize, n: usize, r: Region) -> Vec<T> {
|
||||
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<T: Copy + Default + Send + Sync>(src: &[T], w: usize, h: usize, n: usize, q: u8) -> Vec<T> {
|
||||
let mut out = vec![T::default(); w * h * n];
|
||||
|
|
@ -643,6 +712,65 @@ fn rotate_chroma<T: Copy + Default + Send + Sync>(planes: &[Arc<Vec<T>>; 3], chr
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn cropping_cuts_every_plane_at_the_same_place() {
|
||||
// 6x4 RGBA8, red = index
|
||||
let px: Vec<u8> = (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::<Vec<_>>(), [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::<Vec<_>>(), [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<u8> = (0..35u8).collect();
|
||||
let cb: Vec<u8> = (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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,6 +78,18 @@ 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 input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
|
||||
NVENC)](#hardware-h265-hevc-encoding-windows-nvenc).
|
||||
- **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264
|
||||
8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at
|
||||
run time; without them (or without a device) nothing is registered. NVIDIA's own parser takes the
|
||||
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. 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
|
||||
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
|
||||
|
|
@ -293,6 +305,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) |
|
||||
|
|
|
|||
|
|
@ -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<filmcraft
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The NVDEC factory: a [`HybridDecoder`] around [`nvdec::NvDecoder`] for H.264 and HEVC streams
|
||||
/// this system's NVIDIA GPU decodes, `None` otherwise (VA-API and the software decoders keep those).
|
||||
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
|
||||
pub fn nvdec_factory(entry: &filmcraft_isobmff::SampleEntry) -> Option<filmcraft_codecs::Result<Box<dyn filmcraft_codecs::VideoDecoder>>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
//! 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!(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::<CUVIDEOFORMAT>(), align_of::<CUVIDEOFORMAT>()), (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::<CUVIDDECODECAPS>(), align_of::<CUVIDDECODECAPS>()), (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::<CUVIDDECODECREATEINFO>(), align_of::<CUVIDDECODECREATEINFO>()), (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::<CUVIDPARSERPARAMS>(), align_of::<CUVIDPARSERPARAMS>()), (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::<CUVIDSOURCEDATAPACKET>(), align_of::<CUVIDSOURCEDATAPACKET>()), (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::<CUVIDPARSERDISPINFO>(), align_of::<CUVIDPARSERDISPINFO>()), (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::<CUVIDPROCPARAMS>(), align_of::<CUVIDPROCPARAMS>()), (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::<FrameRate>(), align_of::<FrameRate>()), (8, 4));
|
||||
assert_eq!((size_of::<Rect>(), align_of::<Rect>()), (16, 4));
|
||||
assert_eq!((size_of::<ShortRect>(), align_of::<ShortRect>()), (8, 2));
|
||||
assert_eq!((size_of::<AspectRatio>(), align_of::<AspectRatio>()), (8, 4));
|
||||
assert_eq!((size_of::<VideoSignal>(), align_of::<VideoSignal>()), (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);
|
||||
}
|
||||
|
|
@ -0,0 +1,725 @@
|
|||
//! 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<u32, String> {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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).
|
||||
device: *const Device,
|
||||
decoder: *mut c_void,
|
||||
codec: i32,
|
||||
coded: (u32, u32),
|
||||
geometry: Geometry,
|
||||
delay: u32,
|
||||
surfaces: u32,
|
||||
mapped: Cell<Option<u64>>,
|
||||
pending: Vec<CUVIDPARSERDISPINFO>,
|
||||
ready: Vec<(i64, VideoFrame)>,
|
||||
error: Option<String>,
|
||||
}
|
||||
impl State {
|
||||
fn sequence(&mut self, format: &CUVIDEOFORMAT) -> Result<i32, String> {
|
||||
// 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<i32, String> {
|
||||
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<VideoFrame, String> {
|
||||
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<Option<u64>>,
|
||||
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, String>) -> i32 {
|
||||
// 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::<State>().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 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// 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<State>,
|
||||
parser: *mut c_void,
|
||||
device: Box<Device>,
|
||||
// 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 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<Self, String> {
|
||||
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 = ptr::NonNull::new(Box::into_raw(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,
|
||||
})))
|
||||
.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 = codec;
|
||||
params.ulMaxNumDecodeSurfaces = 1; // sequence callback supplies the real DPB size
|
||||
params.ulClockRate = 0;
|
||||
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; 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<Vec<(i64, VideoFrame)>, 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 api = self.state().api;
|
||||
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. 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(|| state.drain())) {
|
||||
Ok(result) => result?,
|
||||
Err(_) => return Err("NVDEC readback panicked".into()),
|
||||
}
|
||||
Ok(std::mem::take(&mut state.ready))
|
||||
}
|
||||
pub fn feed(&mut self, annex_b: &[u8], pts: i64) -> Result<Vec<(i64, VideoFrame)>, 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<Vec<(i64, VideoFrame)>, 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 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) = state.mapped.get() {
|
||||
// SAFETY: retry an outstanding map before releasing its decoder; context is current.
|
||||
match status(unsafe { (state.api.unmap)(state.decoder, address) }, "cuvidUnmapVideoFrame64") {
|
||||
Ok(()) => state.mapped.set(None),
|
||||
Err(e) => {
|
||||
error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 { (state.api.destroy_decoder)(state.decoder) }, "cuvidDestroyDecoder") {
|
||||
Ok(()) => {
|
||||
state.decoder = ptr::null_mut();
|
||||
state.surfaces = 0;
|
||||
state.mapped.set(None);
|
||||
}
|
||||
Err(e) => {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !parser.is_null() {
|
||||
// SAFETY: owned parser, no parse call running; user data and library are still live.
|
||||
match status(unsafe { (state.api.destroy_parser)(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> {
|
||||
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());
|
||||
}
|
||||
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}");
|
||||
}
|
||||
// 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.
|
||||
pub fn probe() -> Result<String, String> {
|
||||
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<T: Send>() {}
|
||||
is_send::<Session>();
|
||||
}
|
||||
|
||||
// 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::<c_void>();
|
||||
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<_>>(), 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
//
|
||||
// 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_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;
|
||||
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;
|
||||
/// 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 {
|
||||
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)]
|
||||
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<SequenceCallback>,
|
||||
pub pfnDecodePicture: Option<DecodeCallback>,
|
||||
pub pfnDisplayPicture: Option<DisplayCallback>,
|
||||
pub pfnGetOperatingPoint: Option<unsafe extern "C" fn(*mut c_void, *mut c_void) -> c_int>,
|
||||
pub pfnGetSEIMsg: Option<unsafe extern "C" fn(*mut c_void, *mut c_void) -> 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;
|
||||
|
|
@ -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<u64>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
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<DecodedFrame> {
|
||||
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<Vec<DecodedFrame>> {
|
||||
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<DecodedFrame> {
|
||||
// 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<bool> {
|
||||
self.info.is_random_access(sample)
|
||||
}
|
||||
|
||||
fn is_disposable(&self, sample: &[u8]) -> bool {
|
||||
self.info.is_disposable(sample)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Rc<()>>,
|
||||
}
|
||||
|
||||
/// 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<Current<'_>, 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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,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;
|
||||
|
|
|
|||
|
|
@ -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<Box<dyn VideoDecoder>> {
|
||||
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<dyn VideoDecoder> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,14 +36,14 @@ fn off_uses_the_software_decoder() {
|
|||
assert_eq!(d.name(), "FilmCraft HEVC");
|
||||
return;
|
||||
}
|
||||
let backend = if cfg!(target_os = "linux") {
|
||||
assert!(d.name().starts_with("VA-API HEVC ("), "Linux Auto decoder: {}", d.name());
|
||||
"VA-API"
|
||||
} else {
|
||||
let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") };
|
||||
assert_eq!(d.name(), hw_name);
|
||||
backend
|
||||
// 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}"),
|
||||
};
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -884,6 +890,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)?;
|
||||
|
|
@ -1628,6 +1679,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
|
||||
|
|
@ -1969,4 +2049,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
|
|||
/// it covers the whole output.
|
||||
fn adjustment_region(seq: &Sequence, item: &TrackItem, project: &Project, mt: Tick, scale: f32, w: usize, h: usize) -> Option<Image> {
|
||||
let size = source_size(project, item.item).unwrap_or((seq.settings.width, seq.settings.height));
|
||||
let motion = motion_matrix(seq, item, size, mt);
|
||||
let motion = motion_matrix(seq, item, size, source_par(project, item.item), mt);
|
||||
let s = scale as f64;
|
||||
let (fw, fh) = (((size.0 as f64 * s).round() as usize).max(1), ((size.1 as f64 * s).round() as usize).max(1));
|
||||
let m = Affine::scale(s, s).then_apply(&motion).then_apply(&Affine::scale(1.0 / s, 1.0 / s));
|
||||
|
|
@ -332,10 +332,26 @@ pub fn source_size(project: &Project, item: ItemId) -> Option<(u32, u32)> {
|
|||
project.source_size(item)
|
||||
}
|
||||
|
||||
/// Pixel aspect ratio of an item's source, for [`motion_matrix`]: Interpret Footage's override,
|
||||
/// else the media's; `None` for items drawn in sequence pixels (graphics, adjustment layers).
|
||||
pub fn source_par(project: &Project, item: ItemId) -> Option<(u32, u32)> {
|
||||
project.source_par(item)
|
||||
}
|
||||
|
||||
/// The Motion transform of an item at media time `mt`, mapping full-res source pixels to
|
||||
/// full-res sequence pixels.
|
||||
pub fn motion_matrix(seq: &Sequence, item: &TrackItem, src: (u32, u32), mt: Tick) -> Affine {
|
||||
///
|
||||
/// `src` is the source's size in its own (storage) pixels and `src_par` their pixel aspect ratio
|
||||
/// (`None`: the sequence's, for graphics and adjustment layers). Motion works in square display
|
||||
/// units, as Premiere's does: the Anchor Point is in source pixels and the Position in sequence
|
||||
/// pixels, but Scale and Rotation see the picture at its display aspect, so a 1440 x 1080 clip with
|
||||
/// 4:3 pixels covers a 1920 x 1080 square-pixel frame at 100 %, and a square-pixel clip in a 4:3
|
||||
/// sequence is narrowed to its display width. Scale to Frame Size fits the display size too.
|
||||
pub fn motion_matrix(seq: &Sequence, item: &TrackItem, src: (u32, u32), src_par: Option<(u32, u32)>, mt: Tick) -> Affine {
|
||||
let (sw, sh) = (seq.settings.width as f64, seq.settings.height as f64);
|
||||
let seq_par = filmcraft_project::sane_par(seq.settings.par);
|
||||
let src_par = src_par.map_or(seq_par, filmcraft_project::sane_par);
|
||||
let (p, q) = (filmcraft_project::par_ratio(src_par), filmcraft_project::par_ratio(seq_par));
|
||||
let mut pos = Vec2::new(sw / 2.0, sh / 2.0);
|
||||
let mut anchor = Vec2::new(src.0 as f64 / 2.0, src.1 as f64 / 2.0);
|
||||
let mut scale = Vec2::new(1.0, 1.0);
|
||||
|
|
@ -356,10 +372,22 @@ pub fn motion_matrix(seq: &Sequence, item: &TrackItem, src: (u32, u32), mt: Tick
|
|||
rot = m.f64_at("rotation", mt);
|
||||
}
|
||||
if item.scale_to_frame {
|
||||
let fit = (sw / src.0 as f64).min(sh / src.1 as f64);
|
||||
// the display size fitted in the frame's display size
|
||||
let fit = (sw * q / (src.0.max(1) as f64 * p)).min(sh / src.1.max(1) as f64);
|
||||
scale = scale * fit;
|
||||
}
|
||||
Affine::motion(pos, scale, rot, anchor)
|
||||
// source px → (× p) display units → Scale, Rotation → (÷ q) sequence px. Without rotation the
|
||||
// two stretches merge into one horizontal factor, exactly 1 when the ratios are the same.
|
||||
if rot == 0.0 {
|
||||
let r = if src_par == seq_par { 1.0 } else { p / q };
|
||||
return Affine::motion(pos, Vec2::new(scale.x * r, scale.y), rot, anchor);
|
||||
}
|
||||
Affine::translate(pos.x, pos.y).then_apply(&Affine::scale(1.0 / q, 1.0)).then_apply(&Affine::motion(
|
||||
Vec2::ZERO,
|
||||
Vec2::new(scale.x * p, scale.y),
|
||||
rot,
|
||||
anchor,
|
||||
))
|
||||
}
|
||||
|
||||
/// Render one track item's layer at timeline time `t` into a canvas-sized image.
|
||||
|
|
@ -403,7 +431,7 @@ pub(crate) fn item_layer_ex(
|
|||
// time inside a frame hold without Hold Filters).
|
||||
let mt = item.effect_time_at(t);
|
||||
let src_size = source_size(project, item.item)?;
|
||||
let motion = motion_matrix(seq, item, src_size, mt);
|
||||
let motion = motion_matrix(seq, item, src_size, source_par(project, item.item), mt);
|
||||
// How many output pixels one source pixel covers → request a reduced frame when possible.
|
||||
let lin = ((motion.a * motion.a + motion.b * motion.b).sqrt()).max((motion.c * motion.c + motion.d * motion.d).sqrt());
|
||||
let want = (lin * opts.scale as f64).clamp(1.0 / 64.0, 1.0) as f32;
|
||||
|
|
@ -678,3 +706,7 @@ mod preview_tests;
|
|||
#[cfg(test)]
|
||||
#[path = "region_tests.rs"]
|
||||
mod region_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "par_tests.rs"]
|
||||
mod par_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
//! Pixel aspect ratio: a clip with non-square pixels is placed at its display aspect (a 1440 x 1080
|
||||
//! picture with 4:3 pixels covers a 1920 x 1080 square-pixel frame), a sequence with non-square
|
||||
//! pixels holds clips at its own aspect, Interpret Footage overrides the file's ratio, and hostile
|
||||
//! ratios are square. Small in-memory frames stand in for 1440 x 1080 / 1920 x 1080 at a tenth of
|
||||
//! the size: 144 x 108 with 4:3 pixels shows 192 x 108.
|
||||
|
||||
use super::*;
|
||||
use filmcraft_frame::AudioBuffer;
|
||||
use filmcraft_media::{MediaInfo, MediaKind, MediaSource, VideoStreamInfo};
|
||||
use filmcraft_project::{Label, MediaClip, MediaRef, SequenceSettings, TrackKind, resolve_auto_points};
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, TimeRange};
|
||||
|
||||
const FPS: FrameRate = FrameRate::FPS_24;
|
||||
|
||||
/// One still picture: the left half red, the right half blue (opaque).
|
||||
struct Halves {
|
||||
info: MediaInfo,
|
||||
frame: Arc<VideoFrame>,
|
||||
}
|
||||
|
||||
impl MediaSource for Halves {
|
||||
fn info(&self) -> &MediaInfo {
|
||||
&self.info
|
||||
}
|
||||
fn video_frame(&self, _req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
|
||||
Ok(self.frame.clone())
|
||||
}
|
||||
fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
Ok(AudioBuffer::silence(sample_rate, 2, frames))
|
||||
}
|
||||
}
|
||||
|
||||
fn halves(w: u32, h: u32, par: (u32, u32)) -> Halves {
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| if i % w < w / 2 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }).collect();
|
||||
let frame = Arc::new(VideoFrame { par, ..VideoFrame::rgba8(w, h, px) });
|
||||
let info = MediaInfo {
|
||||
name: "halves".into(),
|
||||
kind: MediaKind::Movie,
|
||||
duration: Tick(10 * TICKS_PER_SECOND),
|
||||
video: Some(VideoStreamInfo {
|
||||
width: w,
|
||||
height: h,
|
||||
frame_rate: FPS,
|
||||
par,
|
||||
codec: "test".into(),
|
||||
pixel_format: "RGBA 8-bit".into(),
|
||||
color: filmcraft_color::ColorInfo::SRGB_FULL,
|
||||
has_alpha: false,
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
}),
|
||||
audio_streams: Vec::new(),
|
||||
container: "test".into(),
|
||||
start_timecode: None,
|
||||
file_size: None,
|
||||
};
|
||||
Halves { info, frame }
|
||||
}
|
||||
|
||||
/// A project with a `src`-sized clip of `src_par` pixels on V1 of a `seq`-sized sequence with
|
||||
/// `seq_par` pixels, placed as the timeline places media (Motion's points resolved, 100 %).
|
||||
fn project(src: (u32, u32), src_par: (u32, u32), seq: (u32, u32), seq_par: (u32, u32), scale_to_frame: bool) -> (Project, ItemId, ItemId, SourceMap) {
|
||||
let mut p = Project::new("par");
|
||||
let source = halves(src.0, src.1, src_par);
|
||||
let clip = MediaClip {
|
||||
media: MediaRef::File { path: "halves.mov".into() },
|
||||
info: source.info.clone(),
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
};
|
||||
let item = p.add_item("halves", Label::Iris, ItemKind::Media(clip), None);
|
||||
let settings = SequenceSettings { width: seq.0, height: seq.1, par: seq_par, frame_rate: FPS, ..Default::default() };
|
||||
let sid = p.new_sequence("s", settings, 1, 0, None);
|
||||
let mut ti = p.make_track_item(item, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, FPS.tick_of(24)), FPS).unwrap();
|
||||
for e in &mut ti.effects {
|
||||
resolve_auto_points(e, seq, src);
|
||||
}
|
||||
ti.scale_to_frame = scale_to_frame;
|
||||
p.sequence_mut(sid).unwrap().video_tracks[0].items.push(ti);
|
||||
let mut map = SourceMap::default();
|
||||
map.0.insert(item, Arc::new(source) as SharedSource);
|
||||
(p, sid, item, map)
|
||||
}
|
||||
|
||||
fn render(p: &Project, seq: ItemId, map: &SourceMap) -> Image {
|
||||
render_sequence(p, seq, Tick::ZERO, RenderOptions::default(), map)
|
||||
}
|
||||
|
||||
/// Middle-row colour of column `x`: 'r', 'b' or '.' (transparent).
|
||||
fn col(img: &Image, x: usize) -> char {
|
||||
let c = img.get(x, img.h / 2);
|
||||
match c {
|
||||
_ if c[3] < 0.01 => '.',
|
||||
_ if c[3] > 0.99 && c[0] > 0.99 && c[2] < 0.01 => 'r',
|
||||
_ if c[3] > 0.99 && c[2] > 0.99 && c[0] < 0.01 => 'b',
|
||||
_ => '?',
|
||||
}
|
||||
}
|
||||
|
||||
/// The middle row as a string of `col`s, one per column.
|
||||
fn row(img: &Image) -> String {
|
||||
(0..img.w).map(|x| col(img, x)).collect()
|
||||
}
|
||||
|
||||
/// `got` is `want` except where bilinear filtering mixes neighbours: next to a change in `want`
|
||||
/// and at the frame's edges.
|
||||
#[track_caller]
|
||||
fn assert_row(got: &str, want: &str) {
|
||||
let (g, w): (Vec<char>, Vec<char>) = (got.chars().collect(), want.chars().collect());
|
||||
assert_eq!(g.len(), w.len(), "\n got {got}\nwant {want}");
|
||||
for i in 1..w.len().saturating_sub(1) {
|
||||
if w[i - 1] == w[i] && w[i + 1] == w[i] {
|
||||
assert_eq!(g[i], w[i], "column {i}\n got {got}\nwant {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anamorphic_clip_fills_a_square_pixel_frame_at_100_percent() {
|
||||
// 1440 x 1080 with 4:3 pixels in 1920 x 1080: the whole frame, split in the middle
|
||||
let (p, seq, _, map) = project((144, 108), (4, 3), (192, 108), (1, 1), false);
|
||||
let img = render(&p, seq, &map);
|
||||
assert_row(&row(&img), &format!("{}{}", "r".repeat(96), "b".repeat(96)));
|
||||
// every row is covered, top to bottom
|
||||
for y in [1, 53, 106] {
|
||||
assert!(img.get(1, y)[3] > 0.99 && img.get(190, y)[3] > 0.99, "row {y}");
|
||||
}
|
||||
// the same picture with square pixels is pillarboxed at 1440 (144) wide: what the bug showed
|
||||
let (p, seq, _, map) = project((144, 108), (1, 1), (192, 108), (1, 1), false);
|
||||
assert_row(&row(&render(&p, seq, &map)), &format!("{}{}{}{}", ".".repeat(24), "r".repeat(72), "b".repeat(72), ".".repeat(24)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gpu_plan_places_an_anamorphic_clip_like_the_cpu() {
|
||||
let (p, seq, _, map) = project((144, 108), (4, 3), (192, 108), (1, 1), false);
|
||||
for scale in [1.0, 0.5] {
|
||||
let opts = RenderOptions { scale, ..Default::default() };
|
||||
let want = render_sequence(&p, seq, Tick::ZERO, opts, &map);
|
||||
let got = plan::execute_cpu(&plan::plan_frame(&p, seq, Tick::ZERO, opts, &map));
|
||||
assert_eq!((got.w, got.h), (want.w, want.h));
|
||||
assert_row(&row(&got), &row(&want));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_to_frame_size_fits_the_display_size() {
|
||||
// 1920-wide display picture in a 3840 x 1080 frame: fitted by height at 1:1, 1920 wide
|
||||
let (p, seq, _, map) = project((144, 108), (4, 3), (384, 108), (1, 1), true);
|
||||
assert_row(&row(&render(&p, seq, &map)), &format!("{}{}{}{}", ".".repeat(96), "r".repeat(96), "b".repeat(96), ".".repeat(96)));
|
||||
// fitted by width: 1920 x 1080 display in 960 x 1080 → 960 x 540, letterboxed
|
||||
let (p, seq, _, map) = project((144, 108), (4, 3), (96, 108), (1, 1), true);
|
||||
let img = render(&p, seq, &map);
|
||||
assert_row(&row(&img), &format!("{}{}", "r".repeat(48), "b".repeat(48)));
|
||||
assert!(img.get(48, 10)[3] < 0.01 && img.get(48, 54)[3] > 0.99, "letterboxed to 54 rows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_with_the_clips_pixel_aspect_shows_it_pixel_for_pixel() {
|
||||
// New Sequence From Clip: 1440 x 1080, 4:3 pixels; the clip maps 1:1 onto the frame
|
||||
let (p, seq, _, map) = project((144, 108), (4, 3), (144, 108), (4, 3), false);
|
||||
let m = motion_matrix(p.sequence(seq).unwrap(), &p.sequence(seq).unwrap().video_tracks[0].items[0], (144, 108), Some((4, 3)), Tick::ZERO);
|
||||
assert!(identity_placement(&m), "{m:?}");
|
||||
let img = render(&p, seq, &map);
|
||||
assert_eq!(row(&img), format!("{}{}", "r".repeat(72), "b".repeat(72)));
|
||||
// the same ratio written differently is the same ratio
|
||||
let (p, seq, _, map) = project((144, 108), (8, 6), (144, 108), (4, 3), false);
|
||||
assert_eq!(render(&p, seq, &map).px, img.px);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_pixel_clips_are_narrowed_in_a_non_square_sequence() {
|
||||
// 1920 x 1080 square in 1440 x 1080 with 4:3 pixels: exactly the frame
|
||||
let (p, seq, _, map) = project((192, 108), (1, 1), (144, 108), (4, 3), false);
|
||||
assert_row(&row(&render(&p, seq, &map)), &format!("{}{}", "r".repeat(72), "b".repeat(72)));
|
||||
// 1440 x 1080 square (4:3 picture) in it: 1080 sequence pixels wide, pillarboxed
|
||||
let (p, seq, _, map) = project((144, 108), (1, 1), (144, 108), (4, 3), false);
|
||||
assert_row(&row(&render(&p, seq, &map)), &format!("{}{}{}{}", ".".repeat(18), "r".repeat(54), "b".repeat(54), ".".repeat(18)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_happens_at_the_display_aspect() {
|
||||
// a 100 x 100 square-pixel source in a 200 x 100 sequence with 2:1 pixels shows 50 x 100
|
||||
// sequence pixels (a square on screen); turned 90° it is still that square
|
||||
let settings = SequenceSettings { width: 200, height: 100, par: (2, 1), ..Default::default() };
|
||||
let mut p = Project::new("rot");
|
||||
let sid = p.new_sequence("s", settings, 1, 0, None);
|
||||
let adj = p.add_item("a", Label::Iris, ItemKind::AdjustmentLayer { width: 100, height: 100, rate: FPS, duration: Tick(TICKS_PER_SECOND) }, None);
|
||||
let mut ti = p.make_track_item(adj, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, FPS.tick_of(1)), FPS).unwrap();
|
||||
for e in &mut ti.effects {
|
||||
resolve_auto_points(e, (200, 100), (100, 100));
|
||||
}
|
||||
let seq = p.sequence(sid).unwrap();
|
||||
let r = filmcraft_geom::Rect { x: 0.0, y: 0.0, w: 100.0, h: 100.0 };
|
||||
for rot in [0.0, 90.0, -270.0] {
|
||||
ti.effect_mut("motion").unwrap().params.get_mut("rotation").unwrap().value = ParamValue::Float(rot);
|
||||
let b = motion_matrix(seq, &ti, (100, 100), Some((1, 1)), Tick::ZERO).bounds(&r);
|
||||
assert!((b.w - 50.0).abs() < 1e-9 && (b.h - 100.0).abs() < 1e-9 && (b.x - 75.0).abs() < 1e-9, "{rot}°: {b:?}");
|
||||
}
|
||||
// a 45° turn: the square's diagonal, √2 · 100 display units, both ways
|
||||
ti.effect_mut("motion").unwrap().params.get_mut("rotation").unwrap().value = ParamValue::Float(45.0);
|
||||
let b = motion_matrix(seq, &ti, (100, 100), Some((1, 1)), Tick::ZERO).bounds(&r);
|
||||
let d = 100.0 * std::f64::consts::SQRT_2;
|
||||
assert!((b.w - d / 2.0).abs() < 1e-9 && (b.h - d).abs() < 1e-9, "{b:?}");
|
||||
// graphics and adjustment layers (no ratio of their own) are in sequence pixels
|
||||
ti.effect_mut("motion").unwrap().params.get_mut("rotation").unwrap().value = ParamValue::Float(0.0);
|
||||
let b = motion_matrix(seq, &ti, (100, 100), None, Tick::ZERO).bounds(&r);
|
||||
assert!((b.w - 100.0).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_footage_overrides_the_files_pixel_aspect() {
|
||||
// the file says square; Interpret Footage says 4:3
|
||||
let (mut p, seq, item, map) = project((144, 108), (1, 1), (192, 108), (1, 1), false);
|
||||
match &mut p.item_mut(item).unwrap().kind {
|
||||
ItemKind::Media(m) => m.interpret.par = Some((4, 3)),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_row(&row(&render(&p, seq, &map)), &format!("{}{}", "r".repeat(96), "b".repeat(96)));
|
||||
// and the other way round: a 4:3 file interpreted as square is pillarboxed
|
||||
let (mut p, seq, item, map) = project((144, 108), (4, 3), (192, 108), (1, 1), false);
|
||||
match &mut p.item_mut(item).unwrap().kind {
|
||||
ItemKind::Media(m) => m.interpret.par = Some((1, 1)),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(row(&render(&p, seq, &map)).starts_with(&".".repeat(24)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_pixel_aspect_ratios_render_square_without_panicking() {
|
||||
let pillarboxed = format!("{}{}{}{}", ".".repeat(24), "r".repeat(72), "b".repeat(72), ".".repeat(24));
|
||||
let filled = format!("{}{}", "r".repeat(96), "b".repeat(96));
|
||||
let hostile = [(0, 0), (0, 3), (4, 0), (u32::MAX, 1), (1, u32::MAX), (9, 1), (1, 9)];
|
||||
for bad in hostile {
|
||||
// a clip's ratio: square
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
let (p, seq, _, map) = project((144, 108), bad, (192, 108), (1, 1), false);
|
||||
row(&render(&p, seq, &map))
|
||||
});
|
||||
assert_row(&r.unwrap_or_default(), &pillarboxed);
|
||||
// a sequence's ratio (a damaged project): square, so a 4:3-pixel clip fills it
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
let (p, seq, _, map) = project((144, 108), (4, 3), (192, 108), bad, true);
|
||||
let plan = plan::execute_cpu(&plan::plan_frame(&p, seq, Tick::ZERO, RenderOptions::default(), &map));
|
||||
(row(&render(&p, seq, &map)), row(&plan))
|
||||
});
|
||||
let (cpu, gpu) = r.unwrap_or_default();
|
||||
assert_row(&cpu, &filled);
|
||||
assert_row(&gpu, &filled);
|
||||
// an Interpret Footage override that makes no sense: the file's ratio
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
let (mut p, seq, item, map) = project((144, 108), (4, 3), (192, 108), (1, 1), false);
|
||||
if let ItemKind::Media(m) = &mut p.item_mut(item).unwrap().kind {
|
||||
m.interpret.par = Some(bad);
|
||||
}
|
||||
row(&render(&p, seq, &map))
|
||||
});
|
||||
assert_row(&r.unwrap_or_default(), &filled);
|
||||
}
|
||||
// extreme but valid ratios on degenerate sizes stay finite
|
||||
for (src, par) in [((1, 1), (8, 1)), ((1, 32_768), (1, 8)), ((32_768, 1), (8, 1))] {
|
||||
let (p, seq, _, map) = project(src, par, (16, 16), (1, 8), true);
|
||||
let img = render(&p, seq, &map);
|
||||
assert!(img.px.iter().all(|v| v.is_finite()), "{src:?} {par:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ fn system_font() -> Option<Arc<FontData>> {
|
|||
.clone()
|
||||
}
|
||||
|
||||
/// Whether [`install`] has a Chinese face to add: craft-fonts Hans/Hant faces, or an installed
|
||||
/// system face (scanned once per process). The Simplified Chinese interface needs one.
|
||||
pub(crate) fn available() -> bool {
|
||||
craft_fonts().next().is_some() || system_font().is_some()
|
||||
}
|
||||
|
||||
/// Append Chinese faces after the Latin UI fonts and the Japanese fallbacks. This keeps the
|
||||
/// interface's Latin typography and Japanese glyph forms, and covers the simplified and
|
||||
/// traditional hanzi in media and track names that Japanese fonts lack.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1810,7 +1810,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
|
||||
|
|
@ -3383,6 +3383,7 @@
|
|||
out salida
|
||||
in entrada
|
||||
The language changed but could not be saved: {e} El idioma ha cambiado, pero no se ha podido guardar: {e}
|
||||
no Chinese font is installed on this system; the interface stays in English no hay ninguna fuente china instalada en este sistema; la interfaz sigue en inglés
|
||||
no Japanese font is installed on this system; the interface stays in English no hay ninguna fuente japonesa instalada en este sistema; la interfaz sigue en inglés
|
||||
Show Mostrar
|
||||
Sort Ordenar
|
||||
|
|
@ -3487,6 +3488,14 @@
|
|||
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
|
||||
In Entrada
|
||||
Out Salida
|
||||
Export Markers as CSV… Exportar marcadores como CSV…
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
|
|
@ -3378,6 +3378,7 @@
|
|||
out アウト
|
||||
in イン
|
||||
The language changed but could not be saved: {e} 表示言語は変更されましたが、保存できませんでした: {e}
|
||||
no Chinese font is installed on this system; the interface stays in English このシステムには中国語フォントがインストールされていないため、表示は英語のままです
|
||||
no Japanese font is installed on this system; the interface stays in English このシステムには日本語フォントがインストールされていないため、表示は英語のままです
|
||||
Show 表示
|
||||
Sort 並べ替え
|
||||
|
|
@ -3434,6 +3435,14 @@
|
|||
Select All Project Items すべてのプロジェクト項目を選択
|
||||
Username ユーザー名
|
||||
Vertical Type Tool 縦書き文字ツール
|
||||
|
||||
# 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 キーフレームを追加
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
|
|
@ -16,6 +16,10 @@
|
|||
//! `theme::install` already puts them in every font family, see [`craft_japanese_font`]), otherwise
|
||||
//! a font already installed on the system ([`system_japanese_font`]); with neither, switching to
|
||||
//! Japanese is refused with a message.
|
||||
//!
|
||||
//! Simplified Chinese uses the Chinese fallback `theme::install` always adds (craft-fonts Hans/Hant
|
||||
//! faces, otherwise one installed system face, see `cjk.rs`); without one, switching to Chinese is
|
||||
//! refused the same way ([`chinese_font_available`]).
|
||||
|
||||
mod catalog;
|
||||
|
||||
|
|
@ -35,15 +39,19 @@ pub enum Language {
|
|||
#[serde(rename = "pt-br")]
|
||||
PtBr,
|
||||
Uk,
|
||||
/// Simplified Chinese, persisted as `zh-cn`.
|
||||
#[serde(rename = "zh-cn")]
|
||||
ZhCn,
|
||||
}
|
||||
|
||||
static JAPANESE: OnceLock<Catalog> = OnceLock::new();
|
||||
static SPANISH: OnceLock<Catalog> = OnceLock::new();
|
||||
static PORTUGUESE: OnceLock<Catalog> = OnceLock::new();
|
||||
static UKRAINIAN: OnceLock<Catalog> = OnceLock::new();
|
||||
static CHINESE: OnceLock<Catalog> = OnceLock::new();
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [Self; 5] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk];
|
||||
pub const ALL: [Self; 6] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
|
|
@ -52,6 +60,7 @@ impl Language {
|
|||
Self::Es => "Español",
|
||||
Self::PtBr => "Português (Brasil)",
|
||||
Self::Uk => "Українська",
|
||||
Self::ZhCn => "简体中文",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +71,7 @@ impl Language {
|
|||
"es" => Some(Self::Es),
|
||||
"pt-br" => Some(Self::PtBr),
|
||||
"uk" => Some(Self::Uk),
|
||||
"zh-cn" => Some(Self::ZhCn),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -69,11 +79,21 @@ impl Language {
|
|||
/// Interface Language ▸ System Language (#218): the first of the user's preferred languages
|
||||
/// (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.
|
||||
/// Chinese gets the Simplified catalog only for Simplified locales (`zh`, `zh-CN`, `zh-SG`,
|
||||
/// `zh-Hans…`); Traditional ones (`zh-TW`, `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped.
|
||||
pub fn from_locales(tags: &[String]) -> Self {
|
||||
const PRIMARY: [(&str, Language); 5] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("uk", Language::Uk)];
|
||||
tags.iter()
|
||||
.find_map(|tag| {
|
||||
let primary = tag.split(['-', '_', '.', '@']).next().unwrap_or_default();
|
||||
let mut parts = tag.split(['-', '_', '.', '@']);
|
||||
let primary = parts.next().unwrap_or_default();
|
||||
if primary.eq_ignore_ascii_case("zh") {
|
||||
// the subtag after `zh` (a script or a region), if any; `.UTF-8` / `@x` end it
|
||||
let rest = tag.get(primary.len()..).unwrap_or_default();
|
||||
let sub = if rest.starts_with(['-', '_']) { parts.next().unwrap_or_default() } else { "" };
|
||||
let simplified = sub.is_empty() || ["cn", "sg", "hans"].iter().any(|s| sub.eq_ignore_ascii_case(s));
|
||||
return simplified.then_some(Language::ZhCn);
|
||||
}
|
||||
PRIMARY.iter().find(|(code, _)| primary.eq_ignore_ascii_case(code)).map(|(_, l)| *l)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
|
|
@ -87,6 +107,7 @@ impl Language {
|
|||
Self::Es => "es",
|
||||
Self::PtBr => "pt-br",
|
||||
Self::Uk => "uk",
|
||||
Self::ZhCn => "zh-cn",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +119,7 @@ impl Language {
|
|||
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::Uk => Some(UKRAINIAN.get_or_init(|| Catalog::parse(include_str!("uk.tsv")))),
|
||||
Self::ZhCn => Some(CHINESE.get_or_init(|| Catalog::parse(include_str!("zh-cn.tsv")))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,15 +268,26 @@ pub fn install_japanese_font(ctx: &egui::Context) -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
/// Whether the interface can show Simplified Chinese: `theme::install` always adds a Chinese
|
||||
/// fallback face when there is one (craft-fonts Hans/Hant faces, otherwise an installed system
|
||||
/// face, see `cjk.rs`), so nothing needs installing here. False on systems without either.
|
||||
pub fn chinese_font_available() -> bool {
|
||||
crate::cjk::available()
|
||||
}
|
||||
|
||||
#[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")), ("uk", include_str!("uk.tsv"))]
|
||||
{
|
||||
for (code, text) in [
|
||||
("es", include_str!("es.tsv")),
|
||||
("ja", include_str!("ja.tsv")),
|
||||
("pt-br", include_str!("pt-br.tsv")),
|
||||
("uk", include_str!("uk.tsv")),
|
||||
("zh-cn", include_str!("zh-cn.tsv")),
|
||||
] {
|
||||
let (entries, errors) = catalog::parse_entries(text);
|
||||
assert!(errors.is_empty(), "{code}: {errors:?}");
|
||||
for (i, (ctx, en, tr)) in entries.iter().enumerate() {
|
||||
|
|
@ -280,6 +313,9 @@ mod tests {
|
|||
assert_eq!(Language::Uk.tr("File"), "Файл");
|
||||
assert_eq!(Language::Uk.tr("мій кліп.mp4"), "мій кліп.mp4");
|
||||
assert_eq!(Language::Uk.tr("An untranslated label"), "An untranslated label");
|
||||
assert_eq!(Language::ZhCn.name(), "简体中文");
|
||||
assert_eq!(Language::ZhCn.tr("File"), "文件");
|
||||
assert_eq!(Language::ZhCn.tr("我的视频.mp4"), "我的视频.mp4");
|
||||
for l in Language::ALL {
|
||||
assert_eq!(Language::parse(l.code()), Some(l));
|
||||
let json = serde_json::to_string(&l).unwrap();
|
||||
|
|
@ -370,6 +406,64 @@ mod tests {
|
|||
assert!(missing.is_empty(), "untranslated tl! strings: {missing:#?}");
|
||||
}
|
||||
|
||||
/// The Simplified Chinese catalog is complete: every menu label and every `tl!` literal.
|
||||
#[test]
|
||||
fn chinese_translates_every_menu_label_and_tl_literal() {
|
||||
let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
|
||||
let mut missing: Vec<String> = Vec::new();
|
||||
let mut need = |text: &str| {
|
||||
if translatable(text) && !Language::ZhCn.has(text) && !missing.iter().any(|m| m == text) {
|
||||
missing.push(text.to_string());
|
||||
}
|
||||
};
|
||||
for top in crate::menus::MENUS {
|
||||
need(top);
|
||||
}
|
||||
for it in crate::menus::menu_items(&app) {
|
||||
need(&it.label);
|
||||
for p in &it.path {
|
||||
need(p);
|
||||
}
|
||||
}
|
||||
for (_, text) in sources() {
|
||||
for lit in tl_literals(&text) {
|
||||
need(&lit);
|
||||
}
|
||||
}
|
||||
missing.sort();
|
||||
assert!(missing.is_empty(), "untranslated Chinese strings: {missing:#?}");
|
||||
}
|
||||
|
||||
/// Chinese switches and persists like the other languages when a Chinese face is available,
|
||||
/// and is refused (the interface stays as it was) without one.
|
||||
#[test]
|
||||
fn chinese_switches_and_persists_or_is_refused_without_a_font() {
|
||||
let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
let r = crate::menus::invoke(&mut app, &ctx, "app.language.chinese", serde_json::json!({}));
|
||||
if !chinese_font_available() {
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
assert_eq!(app.ui.language, Language::En);
|
||||
assert_eq!(app.session.prefs.general.interface_language, "system");
|
||||
// a saved Chinese preference falls back to English at startup
|
||||
app.session.execute("prefs.set", serde_json::json!({"key": "general.interfaceLanguage", "value": "zh-cn"})).unwrap();
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.language, Language::En);
|
||||
set_current(Language::En);
|
||||
return;
|
||||
}
|
||||
assert_eq!(r.unwrap(), serde_json::json!("zh-cn"));
|
||||
assert_eq!(app.ui.language, Language::ZhCn);
|
||||
assert_eq!(app.session.prefs.general.interface_language, "zh-cn");
|
||||
assert!(crate::menus::menu_items(&app).iter().any(|it| it.id == "app.language.chinese" && it.checked == Some(true)));
|
||||
let prefs = serde_json::to_string(&app.session.prefs).unwrap();
|
||||
let mut restarted = filmcraft_engine::Session::default();
|
||||
restarted.prefs = serde_json::from_str(&prefs).unwrap();
|
||||
assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::ZhCn);
|
||||
crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap();
|
||||
set_current(Language::En);
|
||||
}
|
||||
|
||||
/// The crate's sources (`src/**/*.rs`), each cut at its test module.
|
||||
fn sources() -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
|
|
@ -671,6 +765,13 @@ mod tests {
|
|||
assert_eq!(l(&[tag]), Language::Uk);
|
||||
}
|
||||
assert_eq!(l(&["pt_PT.UTF-8@euro"]), Language::PtBr);
|
||||
for tag in ["zh", "zh-CN", "zh_CN.UTF-8", "zh-Hans", "zh-Hans-CN", "zh-SG", "ZH-cn", "zh.UTF-8"] {
|
||||
assert_eq!(l(&[tag]), Language::ZhCn, "{tag}");
|
||||
}
|
||||
for tag in ["zh-TW", "zh_TW.UTF-8", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW"] {
|
||||
assert_eq!(l(&[tag]), Language::En, "{tag}: Traditional Chinese has no catalog");
|
||||
}
|
||||
assert_eq!(l(&["zh-TW", "ja-JP"]), Language::Ja);
|
||||
assert_eq!(l(&["fr-FR", "de", "es-MX", "ja"]), Language::Es, "the first one the interface has");
|
||||
assert_eq!(l(&["en-GB", "es"]), Language::En);
|
||||
for none in [&[][..], &["fr"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"]] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 8.
|
|
|
@ -216,6 +216,7 @@
|
|||
New Створити
|
||||
New Layer Новий шар
|
||||
New Project Panel Нова панель проєкту
|
||||
Next Appearance Mode Наступний режим вигляду
|
||||
Next in Sequence Наступний у секвенції
|
||||
Next in Track Наступний на доріжці
|
||||
Normalize Mix Track… Нормалізувати доріжку міксу…
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 7.
|
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
/// Colour parameters: pick a colour from the Program monitor (a pipette).
|
||||
Eyedropper,
|
||||
}
|
||||
|
|
@ -727,6 +731,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);
|
||||
}
|
||||
Eyedropper => {
|
||||
// a pipette: bulb at the top right, glass tube running to a tip at the bottom left
|
||||
pen.closed(&[(10.0, 4.5), (11.5, 3.0), (13.0, 3.0), (13.0, 4.5), (11.5, 6.0)]);
|
||||
|
|
@ -768,6 +797,9 @@ mod tests {
|
|||
use egui::epaint::{ColorMode, Shape};
|
||||
|
||||
const ALL: &[Icon] = &[
|
||||
Monitor,
|
||||
Sun,
|
||||
Moon,
|
||||
Selection,
|
||||
TrackSelectFwd,
|
||||
TrackSelectBack,
|
||||
|
|
|
|||
|
|
@ -132,8 +132,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<Box<dyn FnMut(&str, bool) -> 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<SystemThemeFn>,
|
||||
}
|
||||
|
||||
/// Reads the system appearance (Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system).
|
||||
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
|
||||
|
||||
/// 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)]
|
||||
|
|
@ -511,6 +518,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<egui::Theme> {
|
||||
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 };
|
||||
|
|
@ -520,13 +539,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) {
|
||||
|
|
@ -539,6 +567,9 @@ impl FilmcraftApp {
|
|||
if language == i18n::Language::Ja && !i18n::install_japanese_font(ctx) {
|
||||
self.ui.language = i18n::Language::En;
|
||||
self.ui.status = tl!("no Japanese font is installed on this system; the interface stays in English").into();
|
||||
} else if language == i18n::Language::ZhCn && !i18n::chinese_font_available() {
|
||||
self.ui.language = i18n::Language::En;
|
||||
self.ui.status = tl!("no Chinese font is installed on this system; the interface stays in English").into();
|
||||
} else {
|
||||
self.ui.language = language;
|
||||
}
|
||||
|
|
@ -1618,7 +1649,9 @@ impl eframe::App for FilmcraftApp {
|
|||
theme::install(ctx, &self.tokens);
|
||||
// theme::install replaces the fonts: add the system Japanese font back (or fall back to
|
||||
// English when a saved Japanese setting meets a system without one)
|
||||
if self.ui.language == i18n::Language::Ja && !i18n::install_japanese_font(ctx) {
|
||||
// (likewise Chinese without a Chinese face)
|
||||
let japanese_missing = self.ui.language == i18n::Language::Ja && !i18n::install_japanese_font(ctx);
|
||||
if japanese_missing || (self.ui.language == i18n::Language::ZhCn && !i18n::chinese_font_available()) {
|
||||
self.ui.language = i18n::Language::En;
|
||||
}
|
||||
self.styled = true;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("app.language.spanish", "Español", ["Edit", "Language"], None),
|
||||
uic!("app.language.portuguese", "Português (Brasil)", ["Edit", "Language"], None),
|
||||
uic!("app.language.ukrainian", "Українська", ["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),
|
||||
|
|
@ -95,6 +96,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None),
|
||||
uic!("view.theme.medium", "Dark", ["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")),
|
||||
|
|
@ -170,16 +172,29 @@ 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" | "app.language.ukrainian") {
|
||||
if matches!(
|
||||
id,
|
||||
"app.language.english"
|
||||
| "app.language.japanese"
|
||||
| "app.language.spanish"
|
||||
| "app.language.portuguese"
|
||||
| "app.language.ukrainian"
|
||||
| "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());
|
||||
}
|
||||
// Chinese needs the Chinese fallback theme::install adds (craft-fonts or a system face)
|
||||
if id == "app.language.chinese" && !crate::i18n::chinese_font_available() {
|
||||
return Err("no Chinese font is installed on this system (for example Noto Sans CJK SC or Microsoft YaHei); 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.ukrainian" => crate::i18n::Language::Uk,
|
||||
"app.language.chinese" => crate::i18n::Language::ZhCn,
|
||||
_ => crate::i18n::Language::En,
|
||||
};
|
||||
// The preference is updated in memory before it is written, so a failed write (read-only
|
||||
|
|
@ -418,7 +433,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"}));
|
||||
|
|
@ -485,6 +500,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);
|
||||
|
|
@ -551,6 +570,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec<MenuItem> {
|
|||
"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.ukrainian" => it.checked = Some(app.ui.language == crate::i18n::Language::Uk),
|
||||
"app.language.chinese" => it.checked = Some(app.ui.language == crate::i18n::Language::ZhCn),
|
||||
_ => {}
|
||||
}
|
||||
if it.id.starts_with("view.") {
|
||||
|
|
|
|||
|
|
@ -87,11 +87,17 @@ pub fn selected_graphic(app: &FilmcraftApp) -> Option<(ClipId, TrackItem)> {
|
|||
|
||||
/// Layers of the graphic clips visible at the playhead, front-most first. A layer whose
|
||||
/// visibility is off is not in the picture, so it has no box and cannot be clicked either.
|
||||
/// Sequence pixels → screen points on the monitor picture `pic`. The picture shows the frame at its
|
||||
/// display aspect, so with non-square sequence pixels the two axes scale differently.
|
||||
fn frame_to_screen(pic: Rect, frame: (u32, u32)) -> Affine {
|
||||
let (kx, ky) = (pic.width() as f64 / frame.0.max(1) as f64, pic.height() as f64 / frame.1.max(1) as f64);
|
||||
Affine::translate(pic.min.x as f64, pic.min.y as f64).then_apply(&Affine::scale(kx, ky))
|
||||
}
|
||||
|
||||
pub fn visible_layers(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Vec<LayerView> {
|
||||
let Some(seq) = app.session.active_sequence() else { return Vec::new() };
|
||||
let t = app.session.playhead();
|
||||
let k = pic.width() as f64 / frame.0.max(1) as f64;
|
||||
let screen = Affine::translate(pic.min.x as f64, pic.min.y as f64).then_apply(&Affine::scale(k, k));
|
||||
let screen = frame_to_screen(pic, frame);
|
||||
let mut out = Vec::new();
|
||||
for tr in seq.video_tracks.iter().rev() {
|
||||
if !tr.enabled {
|
||||
|
|
@ -104,7 +110,7 @@ pub fn visible_layers(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Vec<L
|
|||
let Some(ItemKind::Graphic { width, height, .. }) = app.session.project.item(it.item).map(|p| &p.kind) else { continue };
|
||||
let size = (*width, *height);
|
||||
let mt = it.source_time_at(t);
|
||||
let base = screen.then_apply(&filmcraft_render::motion_matrix(seq, it, size, mt));
|
||||
let base = screen.then_apply(&filmcraft_render::motion_matrix(seq, it, size, None, mt));
|
||||
let idx = layer_indices(&it.effects);
|
||||
for (li, &ei) in idx.iter().enumerate().rev() {
|
||||
if let Some(spec) = eval_layer(&it.effects[ei], mt, size).filter(|s| s.enabled) {
|
||||
|
|
@ -119,15 +125,14 @@ pub fn visible_layers(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Vec<L
|
|||
/// Screen → graphic-canvas mapping for new layers: the selected graphic clip under the playhead
|
||||
/// (its Motion applies), else the sequence frame.
|
||||
fn canvas_target(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> (Option<ClipId>, Affine) {
|
||||
let k = pic.width() as f64 / frame.0.max(1) as f64;
|
||||
let screen = Affine::translate(pic.min.x as f64, pic.min.y as f64).then_apply(&Affine::scale(k, k));
|
||||
let screen = frame_to_screen(pic, frame);
|
||||
let t = app.session.playhead();
|
||||
if let Some((c, it)) = selected_graphic(app)
|
||||
&& it.range().contains(t)
|
||||
&& let Some(seq) = app.session.active_sequence()
|
||||
&& let Some(ItemKind::Graphic { width, height, .. }) = app.session.project.item(it.item).map(|p| &p.kind)
|
||||
{
|
||||
let m = screen.then_apply(&filmcraft_render::motion_matrix(seq, &it, (*width, *height), it.source_time_at(t)));
|
||||
let m = screen.then_apply(&filmcraft_render::motion_matrix(seq, &it, (*width, *height), None, it.source_time_at(t)));
|
||||
return (Some(c), m);
|
||||
}
|
||||
(None, screen)
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ pub fn effect_rows(
|
|||
fn clip_to_screen(app: &FilmcraftApp, it: &TrackItem, mt: Tick, pic: Rect, frame: (u32, u32)) -> Option<Affine> {
|
||||
let seq = app.session.active_sequence()?;
|
||||
let size = filmcraft_render::source_size(&app.session.project, it.item).unwrap_or(frame);
|
||||
let motion = filmcraft_render::motion_matrix(seq, it, size, mt);
|
||||
let motion = filmcraft_render::motion_matrix(seq, it, size, filmcraft_render::source_par(&app.session.project, it.item), mt);
|
||||
let view = Affine::translate(pic.min.x as f64, pic.min.y as f64)
|
||||
.then_apply(&Affine::scale(pic.width() as f64 / frame.0 as f64, pic.height() as f64 / frame.1 as f64));
|
||||
Some(view.then_apply(&motion))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -97,6 +97,17 @@ fn frame_size(app: &FilmcraftApp, w: Which) -> Option<(u32, u32)> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Width over height of one pixel of a monitor's picture: the active sequence's pixel aspect
|
||||
/// ratio (Program) or the Source item's (Interpret Footage first). Monitors show the picture at
|
||||
/// its display aspect, so a 1440 x 1080 frame with 4:3 pixels is drawn 16:9.
|
||||
pub fn pixel_aspect(app: &FilmcraftApp, w: Which) -> f64 {
|
||||
let par = match w {
|
||||
Which::Program => app.session.active_sequence().map(|q| q.settings.par),
|
||||
Which::Source => app.session.state.source_item.and_then(|i| app.session.project.source_par(i)),
|
||||
};
|
||||
par.map_or(1.0, filmcraft_project::par_ratio)
|
||||
}
|
||||
|
||||
/// Handle a `view.*` command (None for other ids).
|
||||
pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
let rest = id.strip_prefix("view.")?;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.<key>.<value>`).
|
||||
#[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() {
|
||||
|
|
|
|||
|
|
@ -436,7 +436,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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
//! Monitors show pictures at their display aspect: a 1440 x 1080 clip with 4:3 pixels (here a
|
||||
//! 144 x 108 Color Matte given 4:3 pixels) is drawn 16:9 in the Source monitor, and so is the
|
||||
//! sequence New Sequence From Clip makes of it in the Program monitor. Square pixels stay 4:3.
|
||||
//! Driven headless through the control channel.
|
||||
//!
|
||||
//! With `FILMCRAFT_UI_SHOTS=<dir>` the test also renders the UI with wgpu and writes `par-*.png`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_engine::project::{ItemId, ItemKind};
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::control::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Driver {
|
||||
harness: Harness<'static, FilmcraftApp>,
|
||||
tx: Sender<ControlRequest>,
|
||||
shots: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn new(session: Session) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let app = FilmcraftApp::new(session).with_control(rx);
|
||||
let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from);
|
||||
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
|
||||
if shots.is_some() {
|
||||
b = b.wgpu().with_pixels_per_point(1.0);
|
||||
}
|
||||
let harness = b.build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx, shots };
|
||||
d.frames(4);
|
||||
d
|
||||
}
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
let ctx = self.harness.ctx.clone();
|
||||
let mut raw = std::mem::take(self.harness.input_mut());
|
||||
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
|
||||
*self.harness.input_mut() = raw;
|
||||
self.harness.step();
|
||||
}
|
||||
}
|
||||
fn ok(&mut self, method: &str, params: Value) -> Value {
|
||||
let (req, reply) = ControlRequest::new(method, params.clone());
|
||||
self.tx.send(req).unwrap();
|
||||
for _ in 0..600 {
|
||||
self.frames(1);
|
||||
if let Ok(r) = reply.try_recv() {
|
||||
assert_eq!(r["ok"], true, "{method} {params}: {r}");
|
||||
return r["result"].clone();
|
||||
}
|
||||
}
|
||||
panic!("no reply to {method} {params}");
|
||||
}
|
||||
fn exec(&mut self, command: &str, params: Value) -> Value {
|
||||
let r = self.ok("engine.execute", json!({"command": command, "params": params}));
|
||||
self.frames(3);
|
||||
r
|
||||
}
|
||||
fn shot(&mut self, name: &str) {
|
||||
let Some(dir) = self.shots.clone() else { return };
|
||||
// give the frame workers time to deliver the pictures
|
||||
for _ in 0..8 {
|
||||
self.frames(3);
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
}
|
||||
let img = self.harness.render().expect("wgpu render");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
img.save(dir.join(format!("par-{name}.png"))).unwrap();
|
||||
}
|
||||
/// Width over height of an element's rect.
|
||||
fn aspect(&mut self, id: &str) -> f64 {
|
||||
let v = self.ok("ui.elements", json!({"prefix": id}));
|
||||
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone();
|
||||
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
r[2] / r[3]
|
||||
}
|
||||
}
|
||||
|
||||
/// A session with a 144 x 108 Color Matte whose pixels are `par`.
|
||||
fn session_with_matte(par: (u32, u32)) -> (Session, ItemId) {
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("file.newColorMatte", json!({"color": "#3366ff", "width": 144, "height": 108, "seconds": 2.0})).unwrap();
|
||||
let id = ItemId(r["item"].as_u64().unwrap());
|
||||
if let Some(ItemKind::Media(m)) = Arc::make_mut(&mut s.project).item_mut(id).map(|i| &mut i.kind) {
|
||||
m.info.video.as_mut().unwrap().par = par;
|
||||
}
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitors_show_non_square_pixels_at_the_display_aspect() {
|
||||
for (par, want) in [((4, 3), 16.0 / 9.0), ((1, 1), 4.0 / 3.0), ((0, 0), 4.0 / 3.0), ((u32::MAX, 1), 4.0 / 3.0)] {
|
||||
let (s, item) = session_with_matte(par);
|
||||
let mut d = Driver::new(s);
|
||||
d.exec("file.newSequenceFromClip", json!({"items": [item.0]}));
|
||||
d.exec("source.open", json!({"item": item.0}));
|
||||
let (src, prg) = (d.aspect("source.picture"), d.aspect("program.picture"));
|
||||
assert!((src - want).abs() < 0.01, "{par:?}: Source picture {src}");
|
||||
assert!((prg - want).abs() < 0.01, "{par:?}: Program picture {prg}");
|
||||
d.shot(&format!("monitors-{}x{}", par.0, par.1));
|
||||
// Interpret Footage changes the Source monitor's picture at once
|
||||
d.exec("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": [2, 1]}));
|
||||
let src = d.aspect("source.picture");
|
||||
assert!((src - 8.0 / 3.0).abs() < 0.01, "{par:?}: interpreted 2:1, Source picture {src}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
@ -265,17 +268,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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ no media outside its range, so there a kept In is moved to the subclip's first o
|
|||
the result's `sourceInClamped: [ids]` (present with `keepSourceIn`) names the clips it was moved for.
|
||||
|
||||
**Colour** (`crates/engine/src/color.rs`): `sequence.colorSettings {workingSpace: rec709|rec2100-pq|
|
||||
rec2100-hlg, wideGamut, autoToneMap}`, `clip.interpretFootage {items?, colorSpace: auto|<id>}`,
|
||||
rec2100-hlg, wideGamut, autoToneMap}`, `clip.interpretFootage {items?, colorSpace?: auto|<id>, pixelAspect?: [num, den]|file}`,
|
||||
`color.spaces`, `media.colorInfo {item}`; LUTs: `lut.import {path, name?}`, `lut.list`,
|
||||
`lut.remove {id}`, `lut.export {lut, path, format?}`, `lumetri.setInputLut` / `lumetri.setLook
|
||||
{clip?, lut: lib:<id>|builtin:<id>|"", path?}`, `lumetri.setSection {clip?, section, on?}`,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue