Merge main into #1140 (docs/gaps.md)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-11 19:34:14 -04:00
commit 6b860ede89
150 changed files with 11314 additions and 846 deletions

View File

@ -9,6 +9,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| Asset | Author | Source | Licence |
|---|---|---|---|
| `crates/ui-egui/src/i18n/id.tsv` | FilmCraft contributors | Original work: clean-room Indonesian interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 |

2
Cargo.lock generated
View File

@ -1696,6 +1696,7 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"pollster 0.4.0",
"rfd",
"serde_json",
"sys-locale",
@ -1890,6 +1891,7 @@ dependencies = [
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-interchange",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-mxf",
"filmcraft-project",

View File

@ -83,7 +83,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings
| Arabic | none (no RTL interface) | 0% |
| French | partial (whole interface; native review welcome) | ~100% |
| Portuguese (Brazil) | menus only | 9.9% |
| Indonesian | none | 0% |
| Indonesian | partial (whole interface; native review welcome) | ~100% |
| Japanese | partial (whole interface) | 99.7% |
| German | none | 0% |
| Korean | none | 0% |

View File

@ -76,7 +76,7 @@ SUBCOMMANDS
import <file>... import media into the project
export <out> [--preset name] [--format f] [--range r] [--start s --end s] [--settings json]
[--gpu-rendering auto|off]
[--scale f] [--quality 0-100] [--no-audio] [--queue]
[--scale f] [--quality 0-100] [--no-audio] [--queue] [--allow-offline]
export the active sequence and wait for it to finish: with an
export preset (`export --list-presets`; built-in or the user's), or
a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed
@ -85,9 +85,12 @@ SUBCOMMANDS
preset; --scale renders at a fraction of the frame size (0.5 =
half); --quality 0-100 for the formats that take one; --no-audio
leaves the sound out; --queue adds to the export queue and runs it
instead
instead. Offline media (missing, made offline, unreadable) renders
as placeholder frames: the export then lists it in `offlineMedia`,
warns on stderr and exits 1, unless --allow-offline accepts it
export --list-presets [query] list export presets (name, category, format) as JSON
render --seconds S --out f.png [--scale 0.5] render one Program frame to PNG
render --seconds S --out f.png [--scale 0.5] [--allow-offline]
render one Program frame to PNG (offline media: as for export)
probe <media> [--image-sequence]
media info as JSON, with MXF / Ogg / BWF details; with
--image-sequence <media> is the first numbered still of a sequence
@ -113,7 +116,8 @@ VALUES
Times: 254016000000 ticks per second; most commands also take seconds=, frame= or timecode=.
EXIT STATUS
0 success · 1 a command failed · 2 usage error
0 success · 1 a command failed, or export/render drew offline media without --allow-offline
· 2 usage error
";
/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's
@ -442,7 +446,9 @@ async fn cli() {
match r {
Ok(v) => {
print(&a, &v);
diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64()));
let written = written_paths(&v, out).join(", ");
check_offline(&a, &offline_media(&v), &format!("export: {written}"));
diag(format_args!("exported {written} in {:.1}s", t0.elapsed().as_secs_f64()));
}
Err(e) => fail(format!("export: {e}")),
}
@ -457,10 +463,12 @@ async fn cli() {
let out = a.opt("--out").unwrap_or("frame.png");
s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs));
let t0 = std::time::Instant::now();
let img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
let (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
let dt = t0.elapsed();
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e));
std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}")));
let offline: Vec<Value> = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect();
check_offline(&a, &offline, &format!("render: {out}"));
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
}
"mcp" => {
@ -468,7 +476,7 @@ async fn cli() {
usage(format!("unknown mcp option `{opt}`"));
}
let server = match a.opt("--bridge") {
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)),
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| usage(e)),
None => match Backend::open(&a) {
Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s),
Backend::Bridge(_) => usage("mcp: use --bridge ADDR to drive a running app"),
@ -500,9 +508,46 @@ fn written_paths(result: &Value, requested: &str) -> Vec<String> {
if paths.is_empty() { vec![requested.to_string()] } else { paths }
}
/// The active sequence at the playhead, or the reason it cannot be rendered (no sequence, bad scale).
fn render_at_playhead(s: &Session, scale: f32) -> Result<filmcraft_engine::render::Image, String> {
s.try_render_program_at(scale, s.playhead()).map_err(|e| e.to_string())
/// The offline items an export result reports (`offlineMedia` of a direct export, or of each
/// queued item), once each.
fn offline_media(result: &Value) -> Vec<Value> {
let lists = std::iter::once(result).chain(result.get("items").and_then(Value::as_array).into_iter().flatten());
let mut out: Vec<Value> = Vec::new();
for o in lists.filter_map(|r| r.get("offlineMedia").and_then(Value::as_array)).flatten() {
if !out.iter().any(|x| x.get("item") == o.get("item")) {
out.push(o.clone());
}
}
out
}
/// One stderr line per offline item that rendered as placeholder frames (#1054).
fn offline_warning(offline: &[Value]) -> String {
let s = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let mut msg = format!("filmcraft-cli: warning: {} offline media item(s) rendered as placeholder frames:", offline.len());
for o in offline {
let id = o.get("item").and_then(Value::as_u64).map(|i| i.to_string()).unwrap_or_default();
msg.push_str(&format!("\n item {id} {} ({}): {}", s(o, "name"), s(o, "status"), s(o, "path")));
}
msg
}
/// Warn about offline media on stderr; without `--allow-offline` that fails the run (exit 1), after
/// the output (`what`) was written, so scripts don't take placeholder frames for the real picture.
fn check_offline(a: &Args, offline: &[Value], what: &str) {
if offline.is_empty() {
return;
}
diag(format_args!("{}", offline_warning(offline)));
if !a.flag("--allow-offline") {
fail(format!("{what} has placeholder frames for offline media; relink it (exec media.relink …) or pass --allow-offline"));
}
}
/// The active sequence at the playhead with the offline items it drew, or the reason it cannot be
/// rendered (no sequence, bad scale).
fn render_at_playhead(s: &Session, scale: f32) -> Result<(filmcraft_engine::render::Image, Vec<filmcraft_engine::media_pool::OfflineMedia>), String> {
s.try_render_program_with_offline_at(scale, s.playhead()).map_err(|e| e.to_string())
}
/// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one
@ -531,7 +576,7 @@ mod format_tests {
let e = super::render_at_playhead(&s, scale).unwrap_err();
assert!(e.contains("finite and positive"), "{e}");
}
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().w, 8);
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().0.w, 8);
}
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
@ -573,6 +618,19 @@ mod format_tests {
assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]);
}
#[test]
fn offline_media_from_direct_and_queued_results() {
use serde_json::json;
let a = json!({"item": 8, "name": "a.mp4", "path": "/m/a.mp4", "status": "missing", "error": ""});
let b = json!({"item": 9, "name": "b.mov", "path": "/m/b.mov", "status": "offline", "error": ""});
assert_eq!(super::offline_media(&json!({"path": "x.mp4", "offlineMedia": [a]})), std::slice::from_ref(&a));
let queued = json!({"items": [{"offlineMedia": [a, b]}, {"offlineMedia": [a]}, {"offlineMedia": []}]});
assert_eq!(super::offline_media(&queued), [a.clone(), b.clone()]);
assert!(super::offline_media(&json!({"path": "x.mp4"})).is_empty());
let w = super::offline_warning(&[a, b]);
assert!(w.contains("2 offline media item(s)") && w.contains("item 8 a.mp4 (missing): /m/a.mp4") && w.contains("item 9 b.mov (offline)"), "{w}");
}
#[test]
fn export_lone_bound_is_forwarded_as_custom_range() {
use serde_json::json;

View File

@ -22,6 +22,9 @@ fn help_and_usage_errors() {
assert_eq!(cli(&[]).status.code(), Some(2));
assert_eq!(cli(&["frobnicate"]).status.code(), Some(2));
assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2));
// Invalid --bridge is a usage error (exit 2) for both exec and mcp (#921).
assert_eq!(cli(&["--bridge", "0.0.0.0:9876", "exec", "inspect.project"]).status.code(), Some(2));
assert_eq!(cli(&["mcp", "--bridge", "0.0.0.0:9876"]).status.code(), Some(2));
}
#[test]
@ -291,3 +294,55 @@ fn closed_stdout_still_runs_the_script_and_saves() {
assert_eq!(out.status.code(), Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
/// #1054: exporting or rendering a project whose media is missing fails (exit 1) and names the
/// offline items on stderr and in the result's `offlineMedia`, unless `--allow-offline` accepts the
/// placeholder frames; then it still warns.
#[test]
fn export_with_missing_media_fails_unless_allowed() {
let dir = std::env::temp_dir().join(format!("fc-cli-1054-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = |n: &str| dir.join(n).to_str().unwrap().to_string();
let (still, proj) = (f("still.png"), f("p.fcproj"));
let o = cli(&["--demo", "render", "--seconds", "1", "--scale", "0.1", "--out", &still]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
json_out(&cli(&["exec", "file.newSequence", "name=s", "width=64", "height=36", "fps=24", "--save-as", &proj]));
let paths = format!("paths=[\"{}\"]", still.replace('\\', "\\\\"));
let item = json_out(&cli(&["--project", &proj, "--save", "exec", "file.import", &paths]))["items"][0].as_u64().unwrap();
json_out(&cli(&["--project", &proj, "--save", "exec", "timeline.place", &format!("item={item}"), "track=V1", "frame=0", "audio=false"]));
let export = |out: &str, extra: &[&str]| {
let mut args = vec!["--project", proj.as_str(), "--compact", "export", out, "--format", "png", "--start", "0", "--end", "0.05"];
args.extend_from_slice(extra);
cli(&args)
};
let on = export(&f("on.png"), &[]);
let v = json_out(&on);
assert_eq!(v["offlineMedia"], serde_json::json!([]), "{v}");
assert!(!String::from_utf8_lossy(&on.stderr).contains("warning"), "{}", String::from_utf8_lossy(&on.stderr));
std::fs::remove_file(&still).unwrap();
let off = export(&f("off.png"), &[]);
let err = String::from_utf8_lossy(&off.stderr);
assert_eq!(off.status.code(), Some(1), "{err}");
assert!(err.contains("still.png") && err.contains("missing") && err.contains("--allow-offline"), "{err}");
let v: Value = serde_json::from_slice(&off.stdout).expect("the result JSON is still printed");
assert_eq!(v["offlineMedia"][0]["item"], item, "{v}");
assert_eq!(v["offlineMedia"][0]["status"], "missing", "{v}");
let allowed = export(&f("allowed.png"), &["--allow-offline"]);
let err = String::from_utf8_lossy(&allowed.stderr);
assert!(allowed.status.success(), "{err}");
assert!(err.contains("still.png") && err.contains("missing"), "still warns: {err}");
assert_eq!(json_out(&allowed)["offlineMedia"][0]["status"], "missing");
let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png")]);
let err = String::from_utf8_lossy(&render.stderr);
assert_eq!(render.status.code(), Some(1), "{err}");
assert!(err.contains("still.png") && err.contains("--allow-offline"), "{err}");
let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png"), "--allow-offline"]);
assert!(render.status.success(), "{}", String::from_utf8_lossy(&render.stderr));
assert!(String::from_utf8_lossy(&render.stderr).contains("still.png"));
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -41,6 +41,8 @@ open = "5"
[dev-dependencies]
glob = "0.3"
# graphics.rs: enumerate the real adapters in a test.
pollster = "0.4"
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.21"

View File

@ -15,6 +15,9 @@ Options:
--no-recover start without asking about unsaved changes
--data-dir <dir> folder for auto-save, crash recovery and logs
(also: FILMCRAFT_DATA_DIR)
--graphics <mode> start with this graphics mode and keep it: default,
dx12 (Windows), software (Windows, Linux);
`--graphics default` undoes an automatic fallback
--version print the version and exit
-h, --help print this help and exit
";
@ -28,7 +31,7 @@ pub enum Cli {
}
/// Options of a normal start.
#[derive(Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub struct Launch {
pub control_port: Option<u16>,
pub control_input_only: bool,
@ -39,6 +42,8 @@ pub struct Launch {
pub startup_flag: bool,
pub recover: Option<bool>,
pub data_dir: Option<std::path::PathBuf>,
/// `--graphics <mode>`: start with this graphics mode and keep it (see `graphics.rs`).
pub graphics: Option<crate::graphics::Mode>,
}
/// Parse the arguments after the program name. `env_control_port` is the value of
@ -49,7 +54,16 @@ pub struct Launch {
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
/// too, and for a port from the environment exactly as for one from `--control`.
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
let mut l = Launch { control_port: None, control_input_only: false, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
let mut l = Launch {
control_port: None,
control_input_only: false,
files: Vec::new(),
demo: true,
startup_flag: false,
recover: None,
data_dir: None,
graphics: None,
};
let (mut help, mut version) = (false, false);
let mut args = args.into_iter();
while let Some(a) = args.next() {
@ -66,6 +80,10 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
"--recover" => l.recover = Some(true),
"--no-recover" => l.recover = Some(false),
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
"--graphics" => {
let name = args.next().ok_or("--graphics needs a mode: default, dx12 or software")?;
l.graphics = Some(graphics_mode(&name)?);
}
// everything after `--` is a file, whatever it looks like
"--" => l.files.extend(args.by_ref()),
// macOS adds a process serial number when it launches an app bundle
@ -91,6 +109,17 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
Ok(Cli::Run(l))
}
/// A `--graphics` value: one of this platform's modes.
fn graphics_mode(name: &str) -> Result<crate::graphics::Mode, String> {
use crate::graphics::{LADDER, Mode};
let here = || LADDER.iter().map(|m| m.name()).collect::<Vec<_>>().join(", ");
let mode = Mode::parse(name).ok_or_else(|| format!("--graphics needs one of {}, got `{name}`", here()))?;
if !LADDER.contains(&mode) {
return Err(format!("--graphics {name} is not available on this platform (use one of {})", here()));
}
Ok(mode)
}
#[cfg(test)]
mod tests {
use super::*;
@ -166,7 +195,16 @@ mod tests {
let none = launch(&[]);
assert_eq!(
none,
Launch { control_port: None, control_input_only: false, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None }
Launch {
control_port: None,
control_input_only: false,
files: vec![],
demo: true,
startup_flag: false,
recover: None,
data_dir: None,
graphics: None
}
);
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
assert_eq!(l.control_port, Some(9876));
@ -188,4 +226,23 @@ mod tests {
assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help));
assert!(USAGE.contains("--control-input-only"));
}
#[test]
fn graphics_picks_one_of_this_platforms_modes() {
use crate::graphics::{LADDER, Mode};
assert_eq!(launch(&[]).graphics, None);
assert_eq!(launch(&["--graphics", "default"]).graphics, Some(Mode::Default));
for mode in LADDER {
assert_eq!(launch(&["--graphics", mode.name()]).graphics, Some(*mode));
}
// a mode this platform has not is refused, not ignored
for mode in [Mode::Dx12, Mode::Software].into_iter().filter(|m| !LADDER.contains(m)) {
assert!(parse_args(&["--graphics", mode.name()]).unwrap_err().contains("not available"));
}
assert!(parse_args(&["--graphics"]).is_err(), "missing mode");
assert!(parse_args(&["--graphics", "vulkan"]).unwrap_err().contains("vulkan"));
assert!(parse_args(&["--graphics", "DEFAULT"]).is_err());
assert!(parse_args(&["--graphics", &"x".repeat(1 << 16)]).is_err());
assert!(USAGE.contains("--graphics"));
}
}

View File

@ -1,18 +1,33 @@
//! cpal audio output: the playback master clock. Settings ▸ Audio Hardware picks the host
//! ("Device Class"), the output device, the I/O buffer size and the sample rate.
//!
//! A stream the device drops while playing (an ALSA xrun the backend does not recover from, which
//! cpal reports as `poll() returned POLLERR`; an unplugged device) is restarted by
//! [`AudioOut::recover`], keeping the played-frame clock and the play-ahead mixer, so sound
//! resumes where it stopped instead of playback going silent (#840).
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex, PoisonError};
use std::sync::{Arc, Mutex, PoisonError, TryLockError};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use filmcraft_engine::settings::AudioHardwarePrefs;
use filmcraft_ui_egui::{AudioDevices, AudioOut};
/// The playback mixer callback handed to [`AudioOut::start`].
type Fill = Box<dyn FnMut(&mut [f32], usize) + Send>;
pub struct CpalOut {
stream: Option<cpal::Stream>,
/// The running playback's mixer, shared with the stream so a restarted stream continues it.
fill: Option<Arc<Mutex<Fill>>>,
played: Arc<AtomicU64>,
failed: Arc<AtomicBool>,
health: Arc<Health>,
watchdog: Watchdog,
/// Time base for the watchdog.
epoch: Instant,
rate: u32,
channels: u16,
hw: AudioHardwarePrefs,
@ -128,8 +143,11 @@ impl CpalOut {
};
Self {
stream: None,
fill: None,
played: Arc::new(AtomicU64::new(0)),
failed: Arc::new(AtomicBool::new(false)),
health: Arc::new(Health::default()),
watchdog: Watchdog::default(),
epoch: Instant::now(),
rate: cfg.as_ref().map_or(48_000, |c| c.sample_rate().0),
channels: cfg.as_ref().map_or(2, |c| c.channels()),
hw: AudioHardwarePrefs::default(),
@ -153,19 +171,14 @@ impl CpalOut {
Ok(exact.unwrap_or(default))
}
fn converted_stream<T>(
&self,
dev: &cpal::Device,
config: &cpal::StreamConfig,
mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>,
) -> Result<cpal::Stream, cpal::BuildStreamError>
fn converted_stream<T>(&self, dev: &cpal::Device, config: &cpal::StreamConfig, fill: Arc<Mutex<Fill>>) -> Result<cpal::Stream, cpal::BuildStreamError>
where
T: cpal::SizedSample + cpal::FromSample<f32>,
{
let channels = usize::from(config.channels);
let played = self.played.clone();
let failed = self.failed.clone();
let error_flag = failed.clone();
let health = self.health.clone();
let error_health = health.clone();
let mut scratch = Vec::new();
dev.build_output_stream(
config,
@ -173,39 +186,23 @@ impl CpalOut {
// Reuse the conversion buffer. A pathological driver buffer must not panic the audio thread.
if buf.len() > 1_048_576 || scratch.try_reserve(buf.len().saturating_sub(scratch.len())).is_err() {
buf.fill(T::EQUILIBRIUM);
if !failed.swap(true, Ordering::SeqCst) {
if !health.failed.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: unable to allocate the audio conversion buffer");
}
return;
}
scratch.resize(buf.len(), 0.0);
fill(&mut scratch, channels);
render(&fill, &mut scratch, channels, &played, &health);
convert_samples(&scratch, buf);
played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst);
},
move |e| stream_error(&error_flag, e),
move |e| stream_error(&error_health, e),
None,
)
}
}
fn stream_error(failed: &AtomicBool, e: cpal::StreamError) {
if !failed.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: audio stream error: {e}");
}
}
fn convert_samples<T: cpal::Sample + cpal::FromSample<f32>>(input: &[f32], output: &mut [T]) {
// Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly.
let upper = f32::from_bits(1.0_f32.to_bits() - 1);
for (sample, converted) in input.iter().zip(output) {
*converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 });
}
}
impl AudioOut for CpalOut {
fn start(&mut self, mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
self.stop();
/// Open the configured output device and play `fill` on it. `expect` is the (sample rate,
/// channels) a restarted stream must keep: the mixer was set up for them.
fn open(&mut self, fill: Arc<Mutex<Fill>>, expect: Option<(u32, u16)>) -> Result<(), String> {
let host = host(&self.hw.device_class);
let DeviceChoice { device: dev, fallback } = output_device(&host, &self.hw.default_output, &self.fallback).ok_or("no output device")?;
self.note = fallback;
@ -214,6 +211,11 @@ impl AudioOut for CpalOut {
if channels == 0 || cfg.sample_rate().0 == 0 {
return Err("the output device returned an invalid channel count or sample rate".into());
}
if let Some((rate, ch)) = expect
&& (rate, ch) != (cfg.sample_rate().0, cfg.channels())
{
return Err(format!("the output device now runs at {} Hz with {} channels", cfg.sample_rate().0, cfg.channels()));
}
let mut config: cpal::StreamConfig = cfg.clone().into();
if let cpal::SupportedBufferSize::Range { min, max } = cfg.buffer_size()
&& (*min..=*max).contains(&self.hw.buffer_size)
@ -222,20 +224,18 @@ impl AudioOut for CpalOut {
}
self.rate = cfg.sample_rate().0;
self.channels = cfg.channels();
self.played.store(0, Ordering::SeqCst);
self.failed.store(false, Ordering::SeqCst);
let played = self.played.clone();
let failed = self.failed.clone();
let stream = match cfg.sample_format() {
cpal::SampleFormat::F32 => dev.build_output_stream(
&config,
move |buf: &mut [f32], _| {
fill(buf, channels);
played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst);
},
move |e| stream_error(&failed, e),
None,
),
cpal::SampleFormat::F32 => {
let played = self.played.clone();
let health = self.health.clone();
let error_health = health.clone();
dev.build_output_stream(
&config,
move |buf: &mut [f32], _| render(&fill, buf, channels, &played, &health),
move |e| stream_error(&error_health, e),
None,
)
}
cpal::SampleFormat::I8 => self.converted_stream::<i8>(&dev, &config, fill),
cpal::SampleFormat::I16 => self.converted_stream::<i16>(&dev, &config, fill),
cpal::SampleFormat::I24 => self.converted_stream::<cpal::I24>(&dev, &config, fill),
@ -251,10 +251,172 @@ impl AudioOut for CpalOut {
.map_err(|e| e.to_string())?;
stream.play().map_err(|e| e.to_string())?;
self.stream = Some(stream);
Ok(())
}
/// Replace a dropped stream with a new one on the same mixer, clock and format.
fn restart(&mut self) -> Result<(), String> {
let fill = self.fill.clone().ok_or("nothing is playing")?;
let expect = (self.rate, self.channels);
// The old stream (and its audio thread) goes first: only one stream ever runs the mixer.
self.stream = None;
self.health.clear_errors();
self.open(fill, Some(expect))
}
}
/// What the audio thread reports to the UI thread.
#[derive(Default)]
struct Health {
/// The device went away (unplugged): restart the stream at once.
lost: AtomicBool,
/// A backend error since the last check (cpal keeps the stream alive after these): restart
/// only if the stream also stopped consuming samples.
errored: AtomicBool,
/// Unrecoverable: the mixer panicked, a buffer could not be allocated, or restarts ran out.
/// [`AudioOut::played_frames`] reports `None` and playback goes on without sound.
failed: AtomicBool,
/// The first error of a stream has been printed.
reported: AtomicBool,
}
impl Health {
fn clear_errors(&self) {
self.lost.store(false, Ordering::SeqCst);
self.errored.store(false, Ordering::SeqCst);
self.reported.store(false, Ordering::SeqCst);
}
}
/// How long a stream that reported an error may go without consuming samples before it is
/// restarted. Well under the UI's `AUDIO_STALL_S`, which gives up on the device clock.
const STALL_RESTART: Duration = Duration::from_millis(300);
/// At most this many restarts within [`RESTART_WINDOW`]; a device that keeps failing gives up.
const MAX_RESTARTS: usize = 3;
const RESTART_WINDOW: Duration = Duration::from_secs(10);
/// What [`Watchdog::check`] decides about the running stream.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Verdict {
Healthy,
Restart,
GiveUp,
}
/// Decides when a stream that reported errors is restarted (UI thread, every frame).
#[derive(Default)]
struct Watchdog {
/// Played-frame count last seen, and when it last changed.
seen: (u64, Duration),
/// An error was reported and the stream has not consumed samples since.
pending_error: bool,
/// When recent restarts happened (at most [`MAX_RESTARTS`] kept).
restarts: Vec<Duration>,
}
impl Watchdog {
fn reset(&mut self, now: Duration) {
*self = Self { seen: (0, now), ..Self::default() };
}
fn check(&mut self, now: Duration, played: u64, errored: bool, lost: bool) -> Verdict {
if played != self.seen.0 {
self.seen = (played, now);
self.pending_error = false;
}
// An error that arrives with the stream still consuming samples is only a hiccup.
self.pending_error |= errored;
let stalled = now.saturating_sub(self.seen.1) >= STALL_RESTART;
if !lost && !(self.pending_error && stalled) {
return Verdict::Healthy;
}
self.restarts.retain(|t| now.saturating_sub(*t) < RESTART_WINDOW);
if self.restarts.len() >= MAX_RESTARTS {
return Verdict::GiveUp;
}
self.restarts.push(now);
self.seen = (played, now);
self.pending_error = false;
Verdict::Restart
}
}
/// One device callback: run the shared mixer into `buf` and advance the clock. Never panics: a
/// panicking mixer plays silence and fails the stream, and a mixer held by another stream (never
/// expected: a restart drops the old stream first) plays silence without advancing the clock.
fn render(fill: &Mutex<Fill>, buf: &mut [f32], channels: usize, played: &AtomicU64, health: &Health) {
let mut guard = match fill.try_lock() {
Ok(guard) => guard,
Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(TryLockError::WouldBlock) => {
buf.fill(0.0);
return;
}
};
if health.failed.load(Ordering::SeqCst) || catch_unwind(AssertUnwindSafe(|| (*guard)(buf, channels))).is_err() {
buf.fill(0.0);
if !health.failed.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: the audio mixer failed; playing without sound");
}
return;
}
played.fetch_add((buf.len() / channels.max(1)) as u64, Ordering::SeqCst);
}
fn stream_error(health: &Health, e: cpal::StreamError) {
match e {
cpal::StreamError::DeviceNotAvailable => health.lost.store(true, Ordering::SeqCst),
cpal::StreamError::BackendSpecific { .. } => health.errored.store(true, Ordering::SeqCst),
}
if !health.reported.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: audio stream error: {e}");
}
}
fn convert_samples<T: cpal::Sample + cpal::FromSample<f32>>(input: &[f32], output: &mut [T]) {
// Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly.
let upper = f32::from_bits(1.0_f32.to_bits() - 1);
for (sample, converted) in input.iter().zip(output) {
*converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 });
}
}
impl AudioOut for CpalOut {
fn start(&mut self, fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
self.stop();
self.played.store(0, Ordering::SeqCst);
self.health = Arc::new(Health::default());
self.watchdog.reset(self.epoch.elapsed());
let fill = Arc::new(Mutex::new(fill));
self.open(fill.clone(), None)?;
self.fill = Some(fill);
Ok(self.rate)
}
fn stop(&mut self) {
self.stream = None;
self.fill = None;
}
fn recover(&mut self) {
if self.stream.is_none() || self.health.failed.load(Ordering::SeqCst) {
return;
}
let lost = self.health.lost.swap(false, Ordering::SeqCst);
let errored = self.health.errored.swap(false, Ordering::SeqCst);
match self.watchdog.check(self.epoch.elapsed(), self.played.load(Ordering::SeqCst), errored, lost) {
Verdict::Healthy => {}
Verdict::Restart => {
log::warn!("the audio output stream {}; restarting it", if lost { "lost its device" } else { "stopped after an error" });
if let Err(e) = self.restart() {
log::warn!("the audio output stream could not be restarted: {e}");
self.stream = None;
self.health.failed.store(true, Ordering::SeqCst);
}
}
Verdict::GiveUp => {
log::warn!("the audio output stream keeps failing ({MAX_RESTARTS} restarts within {} s)", RESTART_WINDOW.as_secs());
self.health.failed.store(true, Ordering::SeqCst);
}
}
}
fn sample_rate(&self) -> u32 {
self.rate
@ -263,7 +425,7 @@ impl AudioOut for CpalOut {
self.channels as usize
}
fn played_frames(&self) -> Option<u64> {
self.stream.as_ref().filter(|_| !self.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst))
self.stream.as_ref().filter(|_| !self.health.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst))
}
fn devices(&self) -> AudioDevices {
let h = host(&self.hw.device_class);
@ -327,11 +489,92 @@ mod tests {
}
#[test]
fn failed_stream_relinquishes_the_playback_clock() {
let failed = AtomicBool::new(false);
stream_error(&failed, cpal::StreamError::DeviceNotAvailable);
assert!(failed.load(Ordering::SeqCst));
fn stream_errors_ask_for_a_restart_instead_of_failing_playback() {
let health = Health::default();
stream_error(&health, cpal::StreamError::BackendSpecific { err: cpal::BackendSpecificError { description: "`alsa::poll()` returned POLLERR".into() } });
assert!(health.errored.load(Ordering::SeqCst) && !health.lost.load(Ordering::SeqCst));
stream_error(&health, cpal::StreamError::DeviceNotAvailable);
assert!(health.lost.load(Ordering::SeqCst));
assert!(!health.failed.load(Ordering::SeqCst), "a stream error alone must not silence playback");
let output = CpalOut::new();
assert_eq!(output.played_frames(), None);
assert_eq!(output.played_frames(), None, "no stream, no clock");
}
fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
#[test]
fn an_error_while_samples_keep_flowing_is_only_a_hiccup() {
let mut w = Watchdog::default();
w.reset(ms(0));
assert_eq!(w.check(ms(10), 480, true, false), Verdict::Healthy);
// the stream keeps consuming samples after the error: never restarted
for (i, t) in (20..2_000).step_by(20).enumerate() {
assert_eq!(w.check(ms(t), 480 * (i as u64 + 2), false, false), Verdict::Healthy);
}
}
#[test]
fn a_stream_that_errors_and_stops_consuming_is_restarted() {
let mut w = Watchdog::default();
w.reset(ms(0));
assert_eq!(w.check(ms(10), 480, false, false), Verdict::Healthy);
// POLLERR: the backend reports errors and no longer calls back
assert_eq!(w.check(ms(20), 480, true, false), Verdict::Healthy);
assert_eq!(w.check(ms(200), 480, true, false), Verdict::Healthy);
assert_eq!(w.check(ms(310), 480, false, false), Verdict::Restart);
// the restarted stream gets a fresh grace period
assert_eq!(w.check(ms(400), 480, false, false), Verdict::Healthy);
// a stall without any reported error is left to the UI's own watchdog
assert_eq!(w.check(ms(2_000), 480, false, false), Verdict::Healthy);
}
#[test]
fn a_lost_device_restarts_at_once_and_repeated_failures_give_up() {
let mut w = Watchdog::default();
w.reset(ms(0));
assert_eq!(w.check(ms(5), 0, false, true), Verdict::Restart);
assert_eq!(w.check(ms(10), 0, false, true), Verdict::Restart);
assert_eq!(w.check(ms(15), 0, false, true), Verdict::Restart);
assert_eq!(w.check(ms(20), 0, false, true), Verdict::GiveUp);
// sporadic failures far apart keep being recovered
assert_eq!(w.check(ms(10_006), 0, false, true), Verdict::Restart);
}
#[test]
fn the_device_callback_never_panics_and_counts_played_frames() {
let (played, health) = (AtomicU64::new(0), Health::default());
let fill: Mutex<Fill> = Mutex::new(Box::new(|buf: &mut [f32], _| buf.fill(0.25)));
let mut buf = [1.0_f32; 8];
render(&fill, &mut buf, 2, &played, &health);
assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.25; 8], 4));
// a mixer still held elsewhere plays silence without moving the clock
{
let _held = fill.lock().unwrap();
render(&fill, &mut buf, 2, &played, &health);
}
assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.0; 8], 4));
// zero channels from a broken driver must not divide by zero
render(&fill, &mut buf, 0, &played, &health);
assert_eq!(played.load(Ordering::SeqCst), 12);
}
#[test]
fn a_panicking_mixer_plays_silence_and_fails_the_stream() {
let (played, health) = (AtomicU64::new(0), Health::default());
let fill: Mutex<Fill> = Mutex::new(Box::new(|buf: &mut [f32], _| {
buf.fill(0.5);
panic!("mixer bug");
}));
let mut buf = [1.0_f32; 4];
render(&fill, &mut buf, 2, &played, &health);
assert_eq!(buf, [0.0; 4]);
assert_eq!(played.load(Ordering::SeqCst), 0);
assert!(health.failed.load(Ordering::SeqCst));
// later callbacks stay silent instead of re-entering the broken mixer
buf = [1.0; 4];
render(&fill, &mut buf, 2, &played, &health);
assert_eq!(buf, [0.0; 4]);
}
}

View File

@ -1,33 +1,487 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
//! Graphics start-up: the wgpu backends and adapter the window is created with, and the fallback
//! when a start fails (docs/gaps.md G4, #820).
//!
//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver
//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error
//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand.
//!
//! # When a start counts as failed
//! The data folder keeps the mode to start with and the failed starts in a row in it
//! ([`STATE_FILE`]). Each start also leaves its own attempt file (`graphics-start/<pid>-<n>.json`,
//! so concurrent launches never write the same file) that is removed once the window has painted
//! two frames, when the process ends normally, or [`DEATH_WINDOW`] after launch, whatever happened
//! by then. An attempt file found by a later launch therefore means a process that died within
//! [`DEATH_WINDOW`] of its start without painting: a driver crash, typically. A start that hangs,
//! or is force-quit or crashes later, is not counted; neither is another instance that is still
//! starting (its attempt is younger than [`DEATH_WINDOW`] and is left alone). After
//! [`FAILURES_TO_MOVE_ON`] such failures in a row in the same mode, the launch moves on to the next
//! [`Mode`] of the platform's [`LADDER`] and keeps it; a start that paints resets the count. A
//! start-up error that wgpu does report (no adapter, device creation failed) moves on at once,
//! within the same process.
//!
//! # Getting back
//! A fallback mode shows a notice with a reset button in the window (`graphics.notice.reset`,
//! [`GraphicsStart::reset_to_default`]); `--graphics default` does the same from the command
//! line, and `--graphics dx12|software` picks a mode by hand. An explicit `WGPU_BACKEND` leaves
//! the saved state alone: the user is in charge.
//!
//! All files are written beside and renamed into place, so a reader never sees half a file; two
//! instances updating [`STATE_FILE`] at the same moment can lose one update (a failure counted
//! once less, a confirmation repeated by the next start), never corrupt it.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Restrict the wgpu instance to the primary backends (Vulkan, DirectX 12, Metal), i.e. leave
/// OpenGL out, unless `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`)
/// names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
// out of the instance avoids it. Keep Vulkan and DX12 both: forcing DX12 alone sends
// shaders through FXC (which rejects fx.wgsl, silently falling back to the CPU) and
// leaves machines without DX12 with no backend at all.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY);
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{self, Backends},
};
/// The saved graphics state in the data folder: `{"mode": "<mode>", "failures": <n>}`.
pub const STATE_FILE: &str = "graphics-start.json";
/// The folder in the data folder with one attempt file per start that has not painted yet.
pub const ATTEMPTS_DIR: &str = "graphics-start";
/// How long after launch a start that has not painted may die and still count as failed. A
/// driver crash in instance or device creation happens within a second or two.
pub const DEATH_WINDOW: Duration = Duration::from_secs(15);
/// Failed starts in a row, in the same mode, that move on to the next mode.
pub const FAILURES_TO_MOVE_ON: u32 = 2;
/// Attempt files looked at per launch; anything beyond is left for a later launch.
const MAX_ATTEMPTS: usize = 64;
/// The state and attempt files are a few bytes; anything longer is not ours.
const MAX_FILE: u64 = 4096;
/// How the window's graphics device is created.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// The platform default: Vulkan or DirectX 12 on Windows (no OpenGL, see [`apply`]), Metal on
/// macOS, Vulkan or OpenGL elsewhere.
Default,
/// DirectX 12 only (Windows), for Vulkan drivers that crash at start-up.
Dx12,
/// A software adapter (WARP on Windows, lavapipe / llvmpipe elsewhere); the preview then
/// composites on the CPU (`FilmcraftApp::set_wgpu`).
Software,
}
impl Mode {
/// The name in [`STATE_FILE`] and on the command line (`--graphics <name>`).
pub fn name(self) -> &'static str {
match self {
Mode::Default => "default",
Mode::Dx12 => "dx12",
Mode::Software => "software",
}
}
/// The English name shown in the interface (translated there).
pub fn label(self) -> &'static str {
match self {
Mode::Default => "default graphics",
Mode::Dx12 => "DirectX 12 only",
Mode::Software => "software rendering",
}
}
pub fn parse(name: &str) -> Option<Mode> {
[Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name)
}
}
/// The modes tried in order on this platform. macOS has only Metal, and no software adapter.
pub const LADDER: &[Mode] = if cfg!(target_os = "windows") {
&[Mode::Default, Mode::Dx12, Mode::Software]
} else if cfg!(target_os = "macos") {
&[Mode::Default]
} else {
&[Mode::Default, Mode::Software]
};
/// The mode after `mode` in `ladder`, if any.
fn next_in(ladder: &[Mode], mode: Mode) -> Option<Mode> {
let i = ladder.iter().position(|m| *m == mode)?;
ladder.get(i.checked_add(1)?).copied()
}
/// [`STATE_FILE`]: the mode to start with and the failed starts in a row in it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Saved {
mode: Mode,
failures: u32,
}
/// An attempt file left by a start: its process id (from the file name), mode and launch time.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Attempt {
pid: u32,
mode: Mode,
/// Seconds since the Unix epoch.
started: u64,
}
/// What a launch makes of the saved state and the attempt files it found.
#[derive(Debug, PartialEq, Eq)]
struct Decision {
/// The state to start with and save.
saved: Saved,
/// Moved on to the next mode because the last starts failed.
moved_on: bool,
/// Per attempt: done with (counted, or not ours to count), delete it.
delete: Vec<bool>,
}
/// This launch's mode from the saved state and the attempts other starts left. `now` is in Unix
/// seconds, `pid` is this process (an attempt with our process id is a dead process whose id was
/// reused), `window` is [`DEATH_WINDOW`] in seconds.
fn decide(saved: Option<Saved>, attempts: &[Attempt], now: u64, pid: u32, window: u64, ladder: &[Mode]) -> Decision {
let first = ladder.first().copied().unwrap_or(Mode::Default);
// Nothing saved, or a mode this platform does not use (a data folder copied from another OS).
let saved = saved.filter(|s| ladder.contains(&s.mode)).unwrap_or(Saved { mode: first, failures: 0 });
let mut failures = saved.failures;
let mut delete = Vec::with_capacity(attempts.len());
for a in attempts {
if a.started > now.saturating_add(window) {
// From the future (a clock that jumped back): unknown, and not worth keeping.
delete.push(true);
continue;
}
let dead = a.pid == pid || now.saturating_sub(a.started) >= window;
// A younger attempt is another instance starting right now: leave it to that instance.
delete.push(dead);
// Only failures of the mode we would start in count; older ones are history.
if dead && a.mode == saved.mode {
failures = failures.saturating_add(1);
}
}
if failures >= FAILURES_TO_MOVE_ON {
return match next_in(ladder, saved.mode) {
Some(next) => Decision { saved: Saved { mode: next, failures: 0 }, moved_on: true, delete },
// Nothing left to try: the last mode is still the best chance.
None => Decision { saved: Saved { mode: saved.mode, failures: 0 }, moved_on: false, delete },
};
}
Decision { saved: Saved { mode: saved.mode, failures }, moved_on: false, delete }
}
fn read_small(path: &Path) -> Option<String> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_string(&mut text).ok()?;
Some(text)
}
/// [`Saved`] from its text; `None` for anything else. A missing failure count is none.
fn parse_state(text: &str) -> Option<Saved> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
let mode = Mode::parse(v.get("mode")?.as_str()?)?;
let failures = match v.get("failures") {
None => 0,
Some(n) => u32::try_from(n.as_u64()?).ok()?,
};
Some(Saved { mode, failures })
}
fn state_json(s: Saved) -> String {
serde_json::json!({ "mode": s.mode.name(), "failures": s.failures }).to_string()
}
/// An attempt from its file name (`<pid>-<n>.json`) and text; `None` for anything else.
fn parse_attempt(file_name: &str, text: &str) -> Option<Attempt> {
let pid = file_name.strip_suffix(".json")?.split_once('-')?.0.parse().ok()?;
let v: serde_json::Value = serde_json::from_str(text).ok()?;
Some(Attempt { pid, mode: Mode::parse(v.get("mode")?.as_str()?)?, started: v.get("started")?.as_u64()? })
}
fn attempt_json(mode: Mode, started: u64) -> String {
serde_json::json!({ "mode": mode.name(), "started": started }).to_string()
}
/// Write `text` beside `path` (a name only this process uses) and rename it into place, so a
/// start that dies mid-write, or another instance reading, never sees half a file.
fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut tmp = path.as_os_str().to_owned();
tmp.push(format!(".{}.tmp", std::process::id()));
let tmp = PathBuf::from(tmp);
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
}
fn remove_quietly(path: &Path) {
match std::fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("can't remove {}: {e}", path.display()),
_ => {}
}
}
/// The attempt files in `dir` (at most [`MAX_ATTEMPTS`]): path and contents, `None` when unreadable.
fn list_attempts(dir: &Path) -> Vec<(PathBuf, Option<Attempt>)> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
entries
.filter_map(Result::ok)
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
name.ends_with(".json").then(|| {
let path = e.path();
let attempt = read_small(&path).and_then(|text| parse_attempt(&name, &text));
(path, attempt)
})
})
.take(MAX_ATTEMPTS)
.collect()
}
fn unix_now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Attempt numbers within this process, so each attempt file has its own name.
static NEXT_ATTEMPT: AtomicU32 = AtomicU32::new(0);
/// This launch's graphics mode and its files in the data folder.
#[derive(Clone, Debug)]
pub struct GraphicsStart {
/// [`STATE_FILE`]; `None` without a data folder or with `WGPU_BACKEND` set.
state: Option<PathBuf>,
/// This start's attempt file (with `state`).
attempt: Option<PathBuf>,
mode: Mode,
/// [`Self::reset_to_default`] was used: a later [`Self::confirm`] must not save this mode over it.
reset: Arc<AtomicBool>,
}
impl GraphicsStart {
/// Pick this launch's mode from the data folder and record the attempt. `forced`
/// (`--graphics`) starts in that mode and saves it. `explicit_backend` (`WGPU_BACKEND` set)
/// leaves the data folder alone and starts in `forced`, else [`Mode::Default`].
pub fn begin(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>) -> Self {
let start = Self::begin_at(data_dir, explicit_backend, forced, unix_now(), std::process::id());
if let Some(path) = start.attempt.clone() {
expire_later(path);
}
start
}
/// [`Self::begin`] at `now` (Unix seconds) as process `pid`, without the expiry timer.
fn begin_at(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>, now: u64, pid: u32) -> Self {
let first = LADDER.first().copied().unwrap_or(Mode::Default);
let forced = forced.map(|m| if LADDER.contains(&m) { m } else { first });
let reset = Arc::new(AtomicBool::new(false));
let Some(dir) = data_dir.filter(|_| !explicit_backend) else {
return Self { state: None, attempt: None, mode: forced.unwrap_or(Mode::Default), reset };
};
let state = dir.join(STATE_FILE);
let attempts_dir = dir.join(ATTEMPTS_DIR);
let saved = read_small(&state).and_then(|t| parse_state(&t));
let found = list_attempts(&attempts_dir);
let known: Vec<Attempt> = found.iter().filter_map(|(_, a)| *a).collect();
let decision = decide(saved, &known, now, pid, DEATH_WINDOW.as_secs(), LADDER);
let mut to_delete = decision.delete.iter();
for (path, attempt) in &found {
// unreadable files in our folder are debris (a different version, a damaged disk)
if attempt.is_none() || to_delete.next().copied().unwrap_or(false) {
remove_quietly(path);
}
}
let file = state.display();
let next = match forced {
Some(mode) => {
log::info!("graphics mode {} chosen with --graphics", mode.name());
Saved { mode, failures: 0 }
}
None => {
if decision.moved_on {
let failed = saved.map_or(first, |s| s.mode).name();
log::warn!(
"the last {FAILURES_TO_MOVE_ON} starts with graphics mode {failed} died before their window painted; starting with {} (`--graphics default` or the notice in the window goes back)",
decision.saved.mode.name()
);
} else if decision.saved.failures > 0 {
log::warn!("the last start with graphics mode {} died before its window painted", decision.saved.mode.name());
} else if decision.saved.mode != Mode::Default {
log::info!("graphics mode {} after earlier failed starts ({file}; `--graphics default` goes back)", decision.saved.mode.name());
}
decision.saved
}
};
if saved != Some(next)
&& let Err(e) = write_atomic(&state, &state_json(next))
{
log::warn!("can't write {file}: {e}");
}
let start = Self { state: Some(state), attempt: None, mode: next.mode, reset };
start.with_new_attempt(&attempts_dir, now, pid)
}
/// Record a new attempt file for the current mode.
fn with_new_attempt(mut self, dir: &Path, now: u64, pid: u32) -> Self {
let n = NEXT_ATTEMPT.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("{pid}-{n}.json"));
match write_atomic(&path, &attempt_json(self.mode, now)) {
Ok(()) => self.attempt = Some(path),
Err(e) => log::warn!("can't write {}: {e}", path.display()),
}
self
}
pub fn mode(&self) -> Mode {
self.mode
}
/// Running in a fallback mode, i.e. not the platform default.
pub fn degraded(&self) -> bool {
self.mode != Mode::Default
}
/// Move on to the next mode after a start-up error wgpu reported; `false` when none is left.
/// The error is certain, so the next mode is saved at once.
pub fn step_down(&mut self) -> bool {
let Some(next) = next_in(LADDER, self.mode) else {
return false;
};
log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name());
self.mode = next;
if let Some(state) = self.state.clone() {
self.save(&state, Saved { mode: next, failures: 0 });
if let Some(old) = self.attempt.take() {
remove_quietly(&old);
if let Some(dir) = old.parent() {
let path = self.clone().with_new_attempt(dir, unix_now(), std::process::id()).attempt;
if let Some(path) = &path {
expire_later(path.clone());
}
self.attempt = path;
}
}
}
true
}
/// The window is up: keep this mode, and the failed starts in a row are over.
pub fn confirm(&self) {
self.finish();
let Some(state) = &self.state else { return };
if self.reset.load(Ordering::Relaxed) {
return;
}
let ok = Saved { mode: self.mode, failures: 0 };
if read_small(state).and_then(|t| parse_state(&t)) != Some(ok) {
self.save(state, ok);
}
}
/// This start is over, whether it painted or not (the window closed, or every mode failed
/// with an error that was reported): its attempt no longer means a crash.
pub fn finish(&self) {
if let Some(path) = &self.attempt {
remove_quietly(path);
}
}
/// Start with the default mode next time (the notice's reset button). This process keeps
/// its mode: the graphics device can't be swapped under a running window.
pub fn reset_to_default(&self) -> Result<(), String> {
self.reset.store(true, Ordering::Relaxed);
let Some(state) = &self.state else { return Ok(()) };
let first = LADDER.first().copied().unwrap_or(Mode::Default);
write_atomic(state, &state_json(Saved { mode: first, failures: 0 })).map_err(|e| format!("can't write {}: {e}", state.display()))?;
log::info!("graphics mode reset to {} for the next start", first.name());
Ok(())
}
fn save(&self, path: &Path, s: Saved) {
if let Err(e) = write_atomic(path, &state_json(s)) {
log::warn!("can't write {}: {e}", path.display());
}
}
}
/// Remove the attempt file at `path` [`DEATH_WINDOW`] from now: a start still alive then is not
/// counted as a failed one, painted or not.
fn expire_later(path: PathBuf) {
let spawned = std::thread::Builder::new().name("graphics-start".into()).spawn(move || {
let _ = std::panic::catch_unwind(move || {
std::thread::sleep(DEATH_WINDOW);
remove_quietly(&path);
});
});
if let Err(e) = spawned {
log::warn!("can't start the graphics start timer: {e}");
}
}
/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override`
/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`.
pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>) {
apply_for(options, mode, backend_override, cfg!(target_os = "windows"));
}
fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>, windows: bool) {
let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else {
return;
};
match mode {
// eframe's default (`WGPU_BACKEND`, else PRIMARY | GL), except on Windows: there it
// includes GL. Creating that backend can crash inside AMD's atio6axx.dll before adapter
// selection or any Rust error handling runs, so the window never appears. Choosing an
// adapter afterwards is too late: only leaving GL out of the instance avoids it. Keep
// Vulkan and DX12 both: forcing DX12 alone sends shaders through FXC (which rejects
// fx.wgsl, silently falling back to the CPU) and leaves machines without DX12 with no
// backend at all.
Mode::Default if windows => create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY),
Mode::Default => {}
// Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU.
Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12,
Mode::Software => {
create.instance_descriptor.backends = software_backends(windows);
create.native_adapter_selector = Some(Arc::new(pick_software));
}
}
}
/// The backends a software adapter is looked for in: WARP is a DX12 adapter, and GL stays out on
/// Windows for the reason in [`apply_for`].
fn software_backends(windows: bool) -> Backends {
if windows { Backends::DX12 } else { Backends::VULKAN | Backends::GL }
}
/// The first software adapter that can draw to the window's surface.
fn pick_software(adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>) -> Result<wgpu::Adapter, String> {
adapters
.iter()
.find(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_some() && surface.is_none_or(|s| a.is_surface_supported(s)))
.cloned()
.ok_or_else(|| format!("no software graphics adapter (WARP, lavapipe, llvmpipe) among {} adapters", adapters.len()))
}
/// Whether `err` from `eframe::run_native` is a graphics start-up failure that another mode may
/// avoid (as opposed to a display, event-loop or app error).
pub fn is_graphics_failure(err: &eframe::Error) -> bool {
matches!(err, eframe::Error::Wgpu(_))
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_backends(backend_override: Option<Backends>) -> Backends {
const WINDOWS: &[Mode] = &[Mode::Default, Mode::Dx12, Mode::Software];
const LINUX: &[Mode] = &[Mode::Default, Mode::Software];
const MACOS: &[Mode] = &[Mode::Default];
fn configured(mode: Mode, backend_override: Option<Backends>, windows: bool) -> (Backends, bool) {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
apply_for(&mut options, mode, backend_override, windows);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.backends
(create.instance_descriptor.backends, create.native_adapter_selector.is_some())
}
#[test]
@ -37,16 +491,349 @@ mod tests {
panic!("default native options must create a wgpu instance");
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
let configured = configured_backends(None);
assert_eq!(configured, Backends::PRIMARY);
assert!(!configured.contains(Backends::GL));
assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN));
let (backends, selector) = configured(Mode::Default, None, true);
assert_eq!(backends, Backends::PRIMARY);
assert!(!backends.contains(Backends::GL));
assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN));
assert!(!selector);
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
assert_eq!(configured_backends(Some(backends)), backends);
assert_eq!(configured(Mode::Default, Some(backends), true).0, backends);
}
}
#[test]
fn fallback_modes_leave_out_the_crashing_backends() {
// DX12 only: Intel's and AMD's Vulkan drivers are what crashed (#512, #413).
assert_eq!(configured(Mode::Dx12, None, true), (Backends::DX12, false));
// Software on Windows is WARP (DX12), never GL (atio6axx.dll, #477).
assert_eq!(configured(Mode::Software, None, true), (Backends::DX12, true));
assert_eq!(configured(Mode::Software, None, false), (Backends::VULKAN | Backends::GL, true));
// Off Windows the default is eframe's own.
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false));
}
const W: u64 = 15;
const NOW: u64 = 1_000_000;
const ME: u32 = 7;
fn saved(mode: Mode, failures: u32) -> Option<Saved> {
Some(Saved { mode, failures })
}
fn dead(mode: Mode) -> Attempt {
Attempt { pid: 1, mode, started: NOW - 100 }
}
fn mode_after(s: Option<Saved>, attempts: &[Attempt], ladder: &[Mode]) -> (Mode, u32, bool) {
let d = decide(s, attempts, NOW, ME, W, ladder);
(d.saved.mode, d.saved.failures, d.moved_on)
}
#[test]
fn two_failed_starts_in_a_row_move_on_to_the_next_mode_and_keep_it() {
// first launch
assert_eq!(mode_after(None, &[], WINDOWS), (Mode::Default, 0, false));
// one start died: counted, but the same mode is tried again
assert_eq!(mode_after(None, &[dead(Mode::Default)], WINDOWS), (Mode::Default, 1, false));
// the second in a row moves on: DX12, then software
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], WINDOWS), (Mode::Software, 0, true));
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], LINUX), (Mode::Software, 0, true));
// two dead instances found at once count as two
assert_eq!(mode_after(None, &[dead(Mode::Default), dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
// a saved mode is kept
assert_eq!(mode_after(saved(Mode::Dx12, 0), &[], WINDOWS), (Mode::Dx12, 0, false));
assert_eq!(mode_after(saved(Mode::Default, 1), &[], WINDOWS), (Mode::Default, 1, false));
// nothing left to try: stay
assert_eq!(mode_after(saved(Mode::Software, 1), &[dead(Mode::Software)], WINDOWS), (Mode::Software, 0, false));
assert_eq!(mode_after(saved(Mode::Default, 5), &[dead(Mode::Default)], MACOS), (Mode::Default, 0, false));
// a mode this platform has not: start over
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], LINUX), (Mode::Default, 0, false));
assert_eq!(mode_after(saved(Mode::Software, 0), &[], MACOS), (Mode::Default, 0, false));
assert_eq!(mode_after(None, &[], &[]), (Mode::Default, 0, false));
// a hostile count doesn't overflow
assert_eq!(mode_after(saved(Mode::Default, u32::MAX), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
}
#[test]
fn only_starts_that_died_soon_after_launch_in_the_current_mode_count() {
let starting = Attempt { pid: 2, mode: Mode::Default, started: NOW - 3 };
let just_now = Attempt { pid: 3, mode: Mode::Default, started: NOW };
let future = Attempt { pid: 4, mode: Mode::Default, started: NOW + 3600 };
let older_mode = dead(Mode::Dx12);
let mine = Attempt { pid: ME, mode: Mode::Default, started: NOW - 1 };
let d = decide(saved(Mode::Default, 0), &[starting, just_now, future, older_mode, mine], NOW, ME, W, WINDOWS);
// another instance still starting is left alone; one from the future is dropped; a
// failure in another mode is history; our own process id can only be a dead process
assert_eq!(d.delete, [false, false, true, true, true]);
assert_eq!((d.saved, d.moved_on), (Saved { mode: Mode::Default, failures: 1 }, false));
// the clock a little behind another instance's: still starting
let ahead = Attempt { pid: 5, mode: Mode::Default, started: NOW + 2 };
assert_eq!(decide(None, &[ahead], NOW, ME, W, WINDOWS).delete, [false]);
}
#[test]
fn the_files_round_trip_and_reject_anything_else() {
for mode in WINDOWS {
for failures in [0, 1, 2, u32::MAX] {
assert_eq!(parse_state(&state_json(Saved { mode: *mode, failures })), saved(*mode, failures));
}
assert_eq!(parse_attempt("12-0.json", &attempt_json(*mode, NOW)), Some(Attempt { pid: 12, mode: *mode, started: NOW }));
}
// without a count (an earlier build of this change wrote `pending`): none
assert_eq!(parse_state(r#"{"mode":"dx12","pending":true}"#), saved(Mode::Dx12, 0));
for bad in [
"",
"{",
"null",
"[]",
r#"{"mode":"vulkan","failures":1}"#,
r#"{"failures":1}"#,
r#"{"mode":1,"failures":1}"#,
r#"{"mode":"dx12","failures":"one"}"#,
r#"{"mode":"dx12","failures":-1}"#,
r#"{"mode":"dx12","failures":4294967296}"#,
] {
assert_eq!(parse_state(bad), None, "{bad}");
}
let good = attempt_json(Mode::Default, NOW);
for (name, text) in [
("12-0.json", "{"),
("12-0.json", r#"{"mode":"default"}"#),
("12-0.json", r#"{"mode":"default","started":-5}"#),
("x-0.json", good.as_str()),
("12.json", good.as_str()),
("-0.json", good.as_str()),
("99999999999-0.json", good.as_str()),
("12-0.txt", good.as_str()),
] {
assert_eq!(parse_attempt(name, text), None, "{name} {text}");
}
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("filmcraft-graphics-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn state_of(dir: &Path) -> Option<Saved> {
read_small(&dir.join(STATE_FILE)).and_then(|t| parse_state(&t))
}
fn attempt_files(dir: &Path) -> usize {
std::fs::read_dir(dir.join(ATTEMPTS_DIR)).map_or(0, |d| d.filter_map(Result::ok).count())
}
/// A launch as another process (`pid`) at `now`.
fn launch(dir: &Path, pid: u32, now: u64) -> GraphicsStart {
GraphicsStart::begin_at(Some(dir), false, None, now, pid)
}
#[test]
fn a_start_that_paints_keeps_its_mode_and_clears_its_attempt() {
let dir = temp_dir("paint");
let start = launch(&dir, 10, NOW);
assert_eq!(start.mode(), Mode::Default);
assert!(!start.degraded());
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(attempt_files(&dir), 1);
start.confirm();
assert_eq!(attempt_files(&dir), 0);
// a window closed before it painted is no failure either
let start = launch(&dir, 11, NOW + 60);
start.finish();
assert_eq!(attempt_files(&dir), 0);
assert_eq!(launch(&dir, 12, NOW + 120).mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn two_dead_starts_move_on_and_a_painted_one_resets_the_count() {
let dir = temp_dir("dead");
let second = LADDER.get(1).copied().unwrap_or(Mode::Default);
// dies without painting
let _ = launch(&dir, 20, NOW);
// the next launch counts it, tries the same mode again and dies too
let again = launch(&dir, 21, NOW + 60);
assert_eq!(again.mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 1));
// two in a row: the next mode, kept
let moved = launch(&dir, 22, NOW + 120);
assert_eq!(moved.mode(), second);
assert_eq!(moved.degraded(), second != Mode::Default);
moved.confirm();
assert_eq!(launch(&dir, 23, NOW + 180).mode(), second);
// one failure, then a start that paints: the count starts over
let _ = std::fs::remove_dir_all(&dir);
let _ = launch(&dir, 30, NOW);
let painted = launch(&dir, 31, NOW + 60);
painted.confirm();
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
let _ = launch(&dir, 32, NOW + 120);
assert_eq!(launch(&dir, 33, NOW + 180).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn concurrent_launches_neither_count_nor_disturb_each_other() {
let dir = temp_dir("race");
// two instances start within the same seconds; neither has painted yet
let a = launch(&dir, 40, NOW);
let b = launch(&dir, 41, NOW + 1);
let c = launch(&dir, 42, NOW + 2);
assert_eq!((a.mode(), b.mode(), c.mode()), (Mode::Default, Mode::Default, Mode::Default));
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(attempt_files(&dir), 3, "each instance has its own attempt file");
b.confirm();
a.confirm();
c.confirm();
assert_eq!(attempt_files(&dir), 0);
assert_eq!(launch(&dir, 43, NOW + 60).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_huge_or_foreign_files_start_with_the_default() {
let dir = temp_dir("corrupt");
let _ = std::fs::create_dir_all(dir.join(ATTEMPTS_DIR));
let huge = format!(r#"{{"mode":"software","failures":1,"pad":"{}"}}"#, "x".repeat(10_000));
for text in ["garbage", huge.as_str()] {
let _ = std::fs::write(dir.join(STATE_FILE), text);
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("50-0.json"), text);
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("notes.txt"), text);
let start = launch(&dir, 51, NOW);
assert_eq!(start.mode(), Mode::Default);
// replaced with a well-formed state; the unreadable attempt is gone, other files stay
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert!(!dir.join(ATTEMPTS_DIR).join("50-0.json").exists());
assert!(dir.join(ATTEMPTS_DIR).join("notes.txt").exists());
start.finish();
}
// a data folder that is a file: nothing can be written, the default still starts
let file = dir.join("not-a-folder");
let _ = std::fs::write(&file, "x");
let mut start = GraphicsStart::begin_at(Some(&file), false, None, NOW, 52);
assert_eq!(start.mode(), Mode::Default);
start.confirm();
let _ = start.step_down();
assert!(start.reset_to_default().is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn many_attempt_files_are_bounded_per_launch() {
let dir = temp_dir("many");
let attempts = dir.join(ATTEMPTS_DIR);
let _ = std::fs::create_dir_all(&attempts);
let n = MAX_ATTEMPTS + 10;
for pid in 0..n {
let _ = std::fs::write(attempts.join(format!("{}-0.json", pid + 1000)), attempt_json(Mode::Software, NOW + 1));
}
// all of them belong to instances still starting: none is counted or removed
let start = launch(&dir, 60, NOW);
assert_eq!(start.mode(), Mode::Default);
assert_eq!(attempt_files(&dir), n + 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn graphics_flag_picks_the_mode_and_default_undoes_a_fallback() {
let dir = temp_dir("forced");
let last = LADDER.last().copied().unwrap_or(Mode::Default);
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 1 }));
launch(&dir, 70, NOW).confirm();
// `--graphics default`
let start = GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Default), NOW + 60, 71);
assert_eq!(start.mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
start.confirm();
assert_eq!(launch(&dir, 72, NOW + 120).mode(), Mode::Default);
// `--graphics <the last mode>` is kept for the next launches
GraphicsStart::begin_at(Some(&dir), false, Some(last), NOW + 180, 73).confirm();
assert_eq!(launch(&dir, 74, NOW + 240).mode(), last);
// a mode this platform has not starts with the first one
if !LADDER.contains(&Mode::Dx12) {
assert_eq!(GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Dx12), NOW + 300, 75).mode(), Mode::Default);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reset_to_default_wins_over_a_later_confirm() {
let dir = temp_dir("reset");
let last = LADDER.last().copied().unwrap_or(Mode::Default);
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 0 }));
let start = launch(&dir, 80, NOW);
assert_eq!(start.mode(), last);
// the notice's button, from a clone of the start (the app holds one)
assert_eq!(start.clone().reset_to_default(), Ok(()));
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
start.confirm();
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(launch(&dir, 81, NOW + 60).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_explicit_backend_leaves_the_data_folder_alone() {
let dir = temp_dir("explicit");
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: Mode::Software, failures: 1 }));
let mut start = GraphicsStart::begin_at(Some(&dir), true, None, NOW, 90);
assert_eq!(start.mode(), Mode::Default);
start.confirm();
let _ = start.step_down();
assert_eq!(start.reset_to_default(), Ok(()));
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
assert_eq!(attempt_files(&dir), 0);
// with --graphics too, that mode starts, still without touching the folder
let last = LADDER.last().copied().unwrap_or(Mode::Default);
assert_eq!(GraphicsStart::begin_at(Some(&dir), true, Some(last), NOW, 91).mode(), last);
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
// no data folder: nothing is written, the default still starts
assert_eq!(GraphicsStart::begin_at(None, false, None, NOW, 92).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn step_down_walks_the_ladder_and_saves_each_mode_at_once() {
let dir = temp_dir("step");
let mut start = launch(&dir, ME, NOW);
for expected in LADDER.iter().skip(1) {
assert!(start.step_down());
assert_eq!(start.mode(), *expected);
assert_eq!(state_of(&dir), saved(*expected, 0));
assert_eq!(attempt_files(&dir), 1, "one attempt file per process");
}
assert!(!start.step_down(), "nothing after the last mode");
start.finish();
assert_eq!(attempt_files(&dir), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_software_selector_only_picks_a_software_adapter() {
if wgpu::Instance::enabled_backend_features().is_empty() {
return;
}
// only the backends software mode uses: no driver this test loads is one the app avoids
let backends = software_backends(cfg!(target_os = "windows"));
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() });
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
match pick_software(&adapters, None) {
Ok(adapter) => assert!(filmcraft_ui_egui::software_rasterizer(&adapter.get_info()).is_some()),
Err(why) => {
assert!(adapters.iter().all(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_none()), "{why}");
}
}
assert!(pick_software(&[], None).is_err());
}
}

View File

@ -31,7 +31,6 @@ mod control_server;
#[cfg(target_os = "linux")]
mod dev_icon;
mod file_filters;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
#[cfg(target_os = "macos")]
@ -44,6 +43,8 @@ use filmcraft_engine::Session;
use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir};
use filmcraft_ui_egui::FilmcraftApp;
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// Reverse-DNS app id: Linux `.desktop` file, hicolor icon name and Wayland app id.
const APP_ID: &str = "ai.storyteller.filmcraft";
@ -80,7 +81,7 @@ fn main() -> eframe::Result {
let logger = logging::install();
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) {
let launch = match args::parse(env_port, std::env::args().skip(1)) {
Ok(Cli::Run(l)) => l,
Ok(Cli::Help) => {
tell(args::USAGE, false);
@ -95,7 +96,8 @@ fn main() -> eframe::Result {
std::process::exit(2);
}
};
let log_dir = data_dir.clone().or_else(default_data_dir).map(|d| d.join(logging::LOG_DIR));
let data_root = launch.data_dir.clone().or_else(default_data_dir);
let log_dir = data_root.as_ref().map(|d| d.join(logging::LOG_DIR));
log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
// The log file lives next to the crash logs; opened after the arguments, so `--help`,
// `--version` and usage errors leave no file behind. Records logged until now go in first.
@ -118,165 +120,38 @@ fn main() -> eframe::Result {
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
register_hardware_decoders();
register_gpu_frame_renderer();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FilmCraft")
.with_inner_size([1600.0, 980.0])
.with_min_inner_size([900.0, 560.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none())
.with_icon(app_icon())
// Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`.
.with_app_id(APP_ID),
event_loop_builder: agent_event_loop(control_port.is_some()),
..Default::default()
// Which graphics backends and adapter the window starts with: `--graphics`, else the saved
// mode, which moves on after starts that died before their window painted (a driver crash),
// see graphics.rs.
let backend_override = eframe::wgpu::Backends::from_env();
let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some(), launch.graphics);
// The GPU export renderer creates its own wgpu instance with every backend; after a start
// fell back, that would load the driver that failed. Exports then composite on the CPU.
if graphics.mode() == graphics::Mode::Default {
register_gpu_frame_renderer();
}
let started = loop {
let mut options = native_options(launch.control_port.is_some());
// Before eframe creates the wgpu instance (e.g. no OpenGL on Windows, see graphics.rs).
graphics::apply(&mut options, graphics.mode(), backend_override);
let app_created = Arc::new(AtomicBool::new(false));
let result = eframe::run_native("FilmCraft", options, app_creator(launch.clone(), graphics.clone(), Arc::clone(&app_created)));
let app_created = app_created.load(Ordering::Relaxed);
match &result {
// wgpu reported the failure (no adapter, device creation failed) before the app
// existed: try the next mode in this process.
Err(e) if !app_created && graphics::is_graphics_failure(e) && graphics.step_down() => {
log::warn!("graphics start failed: {e}");
continue;
}
// Closed before the second frame: the window still came up.
Ok(()) if app_created => graphics.confirm(),
_ => {}
}
break result;
};
// Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs).
#[cfg(target_os = "windows")]
let options = {
let mut options = options;
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
options
};
let started = eframe::run_native(
"FilmCraft",
options,
Box::new(move |cc| {
let mut session = Session::default();
if let Some(dir) = data_dir.clone().or_else(default_data_dir) {
let mut cfg = AutosaveConfig::new(dir);
cfg.local_offset = local_offset;
if let Err(e) = session.start_autosave(cfg) {
log::warn!("auto-save and crash recovery unavailable: {e}");
}
}
// voice-over recording reads the microphone through cpal
session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class)));
let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::error!("{e}");
}
} else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" {
// Settings ▸ General ▸ At Startup ▸ Open Most Recent
let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned();
match recent {
Some(p) => {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::error!("{e}");
}
}
None => {
let _ = session.execute("file.openDemoProject", json!({}));
}
}
} else if !startup_flag && session.prefs.general.at_startup == "emptyProject" {
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect();
if !media.is_empty() {
let _ = session.execute("file.import", json!({"paths": media}));
}
if recover == Some(true) && !session.recovery_candidates().is_empty() {
let id = session.recovery_candidates()[0].id.clone();
match session.execute("file.recover", json!({"id": id})) {
Ok(r) => log::info!("recovered {r}"),
Err(e) => log::error!("recovery failed: {e}"),
}
}
let mut app = FilmcraftApp::new(session);
if recover == Some(false) {
app.dialog = None;
}
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none()
{
app.set_wgpu(rs);
}
// Keep device selection available even when the default device is unavailable.
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`).
app.audio = Some(Box::new(audio::CpalOut::new()));
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
}));
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_folder_at =
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
app.hooks.open_path = Some(Box::new(open_path));
// Help menu, About and Discord links (#642): eframe is built without its `links`
// feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher
// that waits for the browser never freezes the UI.
app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string())));
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
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();
}));
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_open_project = Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
#[cfg(target_os = "macos")]
{
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
app.command_inbox = Some(rx);
app.hooks.shortcuts_changed = Some(update);
app.ui.show_menu_bar = false;
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) };
}
Ok(Box::new(app))
}),
);
// However it ended, this start no longer counts as one that died.
graphics.finish();
// The window could not be created (no usable GPU / graphics driver, no display): say so
// instead of exiting silently, and keep the reason in the crash log.
if let Err(e) = &started {
@ -288,6 +163,179 @@ fn main() -> eframe::Result {
started
}
/// The main window's options; built for each start attempt (they hold a one-shot hook).
fn native_options(agent: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FilmCraft")
.with_inner_size([1600.0, 980.0])
.with_min_inner_size([900.0, 560.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(!agent)
.with_icon(app_icon())
// Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`.
.with_app_id(APP_ID),
event_loop_builder: agent_event_loop(agent),
..Default::default()
}
}
/// Creates the app once eframe has its window and graphics device; sets `created` when it runs,
/// and confirms the graphics mode once two frames have painted.
fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<AtomicBool>) -> eframe::AppCreator<'static> {
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch;
Box::new(move |cc| {
created.store(true, Ordering::Relaxed);
// A fallback graphics mode says so in the window, with a button back to the default.
let notice = graphics.degraded().then(|| {
let start = graphics.clone();
filmcraft_ui_egui::graphics_notice::GraphicsNotice::new(graphics.mode().label(), Box::new(move || start.reset_to_default()))
});
let confirmed = AtomicBool::new(false);
cc.egui_ctx.on_end_pass(
"graphics-start",
Arc::new(move |ui| {
if ui.ctx().cumulative_frame_nr() >= 2 && !confirmed.swap(true, Ordering::Relaxed) {
graphics.confirm();
}
}),
);
let mut session = Session::default();
if let Some(dir) = data_dir.clone().or_else(default_data_dir) {
let mut cfg = AutosaveConfig::new(dir);
cfg.local_offset = local_offset;
if let Err(e) = session.start_autosave(cfg) {
log::warn!("auto-save and crash recovery unavailable: {e}");
}
}
// voice-over recording reads the microphone through cpal
session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class)));
let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::error!("{e}");
}
} else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" {
// Settings ▸ General ▸ At Startup ▸ Open Most Recent
let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned();
match recent {
Some(p) => {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::error!("{e}");
}
}
None => {
let _ = session.execute("file.openDemoProject", json!({}));
}
}
} else if !startup_flag && session.prefs.general.at_startup == "emptyProject" {
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect();
if !media.is_empty() {
let _ = session.execute("file.import", json!({"paths": media}));
}
if recover == Some(true) && !session.recovery_candidates().is_empty() {
let id = session.recovery_candidates()[0].id.clone();
match session.execute("file.recover", json!({"id": id})) {
Ok(r) => log::info!("recovered {r}"),
Err(e) => log::error!("recovery failed: {e}"),
}
}
let mut app = FilmcraftApp::new(session);
app.graphics_notice = notice;
if recover == Some(false) {
app.dialog = None;
}
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none()
{
app.set_wgpu(rs);
}
// Keep device selection available even when the default device is unavailable.
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`).
app.audio = Some(Box::new(audio::CpalOut::new()));
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
}));
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_folder_at =
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
app.hooks.open_path = Some(Box::new(open_path));
// Help menu, About and Discord links (#642): eframe is built without its `links`
// feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher
// that waits for the browser never freezes the UI.
app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string())));
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
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();
}));
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_open_project = Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
#[cfg(target_os = "macos")]
{
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
app.command_inbox = Some(rx);
app.hooks.shortcuts_changed = Some(update);
app.ui.show_menu_bar = false;
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) };
}
Ok(Box::new(app))
})
}
/// Files copied in the file manager, else an image, on the system clipboard (Paste, #611).
fn clipboard_media() -> Option<filmcraft_ui_egui::ClipboardMedia> {
let mut clipboard = arboard::Clipboard::new().ok()?;
@ -319,6 +367,7 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> {
}
c
} else {
// Linux file managers have no select-file flag: open the parent folder instead.
let mut c = std::process::Command::new("xdg-open");
let p = std::path::Path::new(path);
c.arg(if reveal { p.parent().unwrap_or(p) } else { p });

View File

@ -4,7 +4,7 @@
use std::sync::mpsc::{Receiver, channel};
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, menu_items};
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, NATIVE_APP_MENU, menu_items};
use muda::accelerator::Accelerator;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
@ -28,25 +28,39 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
let mut titles: Vec<(String, Submenu)> = Vec::new();
let bar = Menu::new();
let app_menu = Submenu::new("FilmCraft", true);
let [settings_label, hide_label, hide_others_label, quit_label] = NATIVE_APP_MENU;
// FilmCraft ▸ Settings ▸ <category> (Premiere's app-menu layout; General is Cmd+,)
let settings = Submenu::new(app.ui.language.tr("Settings"), true);
titles.push(("Settings".into(), settings.clone()));
let settings = Submenu::new(app.ui.language.tr(settings_label), true);
titles.push((settings_label.into(), settings.clone()));
for it in items.iter().filter(|i| i.id.starts_with("app.settings.")) {
let label = it.label.trim_end_matches('…').to_string();
let mi = MenuItem::with_id(it.id.clone(), label, true, it.shortcut.as_deref().and_then(accel));
native.push((it.id.clone(), mi.clone()));
let _ = settings.append(&mi);
}
// About and Discord repeat Help menu commands: same translated labels, updated with them
let label = |id: &str, english: &str| items.iter().find(|i| i.id == id).map_or_else(|| english.to_string(), |i| i.label.clone());
let about = MenuItem::with_id("app.about", label("app.about", "About FilmCraft"), true, None);
let discord = MenuItem::with_id("help.discord", label("help.discord", "Join the ArtCraft Discord…"), true, None);
native.push(("app.about".into(), about.clone()));
native.push(("help.discord".into(), discord.clone()));
// the predefined items keep their native actions; our text lets them follow the language
let predefined = [
(hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))),
(hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))),
(quit_label, PredefinedMenuItem::quit(Some(app.ui.language.tr(quit_label)))),
];
let [(_, hide), (_, hide_others), (_, quit)] = &predefined;
let _ = app_menu.append_items(&[
&MenuItem::with_id("app.about", "About FilmCraft", true, None),
&MenuItem::with_id("help.discord", "Join the ArtCraft Discord…", true, None),
&about,
&discord,
&PredefinedMenuItem::separator(),
&settings,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
hide,
hide_others,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
quit,
]);
let _ = bar.append(&app_menu);
for top in MENUS {
@ -109,12 +123,17 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
filmcraft_ui_egui::i18n::Language::Ru
} else if checked("app.language.french") {
filmcraft_ui_egui::i18n::Language::Fr
} else if checked("app.language.indonesian") {
filmcraft_ui_egui::i18n::Language::Id
} else {
filmcraft_ui_egui::i18n::Language::En
};
for (title, submenu) in &titles {
submenu.set_text(language.tr(title));
}
for (label, item) in &predefined {
item.set_text(language.tr(label));
}
for (id, mi) in &native {
if let Some(it) = items.iter().find(|i| &i.id == id) {
mi.set_text(&it.label);

View File

@ -70,6 +70,9 @@ sync_github_name = false
[people.ossini]
sync_github_name = true
[people.opentechnepal]
sync_github_name = true
[people.vangel76]
real_name = "Valentin Klein"
display_name = "Valentin Klein"

View File

@ -47,14 +47,14 @@ impl BridgeClient {
}
let mut start = self.call("engine.execute", json!({"command": id, "params": params})).await?;
let Some(job) = start.get("job").and_then(Value::as_u64) else { return Ok(start) };
let result = loop {
let (result, offline) = loop {
tokio::time::sleep(JOB_POLL).await;
let jobs = self.call("engine.execute", json!({"command": "jobs.list", "params": {}})).await?;
let Some(j) = jobs.as_array().and_then(|a| a.iter().find(|j| j["id"].as_u64() == Some(job))).cloned() else {
break Value::Null;
break (Value::Null, None);
};
if j["finished"].as_bool() == Some(true) {
break j["result"].clone();
break (j["result"].clone(), j.get("offlineMedia").cloned());
}
};
if let Some(e) = result.get("error").and_then(Value::as_str) {
@ -62,6 +62,9 @@ impl BridgeClient {
}
if let Some(o) = start.as_object_mut() {
o.insert("result".into(), result);
if let Some(off) = offline {
o.insert("offlineMedia".into(), off);
}
}
Ok(start)
}
@ -121,6 +124,11 @@ mod tests {
/// A stand-in for the app's control server: the export starts job 7, which `jobs.list` reports
/// running twice and then finished with `result`. Every request is recorded.
async fn fake_app(result: Value) -> (String, Arc<Mutex<Vec<Value>>>) {
fake_app_with(result, None).await
}
/// [`fake_app`] whose finished job also lists `offline` as its `offlineMedia`.
async fn fake_app_with(result: Value, offline: Option<Value>) -> (String, Arc<Mutex<Vec<Value>>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let seen = Arc::new(Mutex::new(Vec::new()));
@ -138,7 +146,11 @@ mod tests {
Some("jobs.list") => {
polls += 1;
let finished = polls >= 3;
json!([{"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}])
let mut j = json!({"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }});
if let Some(o) = offline.clone().filter(|_| finished) {
j["offlineMedia"] = o;
}
json!([j])
}
_ => json!(null),
};
@ -165,6 +177,16 @@ mod tests {
assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "jobs.list").count(), 3);
}
/// The offline media a polled export drew comes back as in a blocking export's reply (#1054).
#[tokio::test]
async fn a_polled_export_reports_its_offline_media() {
let off = json!([{"item": 8, "status": "missing"}]);
let (addr, _) = fake_app_with(json!({"frames": 3}), Some(off.clone())).await;
let b = BridgeClient::new(addr).unwrap();
let r = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap();
assert_eq!(r["offlineMedia"], off, "{r}");
}
#[tokio::test]
async fn a_failed_blocking_export_is_an_error() {
let (addr, _) = fake_app(json!({"error": "encode: disk full"})).await;

View File

@ -72,6 +72,7 @@ impl FilmcraftMcp {
rep.report(done, total, j["status"].as_str().unwrap_or_default()).await;
}
if finished {
let offline = state.as_ref().and_then(|j| j.get("offlineMedia").cloned());
let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null);
if let Some(e) = result.get("error").and_then(Value::as_str) {
return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]);
@ -79,6 +80,9 @@ impl FilmcraftMcp {
let mut out = start;
if let Some(o) = out.as_object_mut() {
o.insert("result".into(), result);
if let Some(off) = offline {
o.insert("offlineMedia".into(), off);
}
}
return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]);
}

View File

@ -70,12 +70,15 @@ with libdav1d: **bit-exact** on all 6 fixtures.
wasm32, or without the default `threads` feature, everything runs on the calling thread). Headers are parsed in order; each frame is
then decoded by a frame worker once the reference frames it reads are finished, so independent
frames (hierarchical GOP layers) decode concurrently; tiles of a frame decode in parallel into
private buffers that are merged afterwards. Results never depend on scheduling (the oracle tests
compare 1 thread, many threads and libdav1d). With frame threads, pictures may be returned by a
private buffers that are merged afterwards; the in-loop filters (deblocking, CDEF, loop
restoration) each run in parallel bands of 64 rows (`postfilter.rs` explains why the bands are
independent). Results never depend on scheduling (the oracle tests compare 1 thread, many threads
and libdav1d). With frame threads, pictures may be returned by a
later `decode_pts` call or by `flush`; each carries the `pts` of the temporal unit that showed it.
`tests/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame
(`Decoder::stats()`) and all-core fps for 1080p fixtures. The hot loops (sub-pixel filters,
(`Decoder::stats()`) and all-core fps with its per-stage ms/frame for 1080p fixtures (key frames,
GOPs, tiles, low delay) and a single-tile 2160p GOP; `AV1_PERF_ONLY=a,b` picks fixtures. The hot loops (sub-pixel filters,
CDEF, inverse transforms, deblocking, entropy decoding) are written for auto-vectorisation.
Measured 2026-10-01 on a 14-core Apple Silicon Mac (release build, SVT-AV1 preset 8 1080p

View File

@ -1,35 +1,45 @@
//! CDEF (7.15).
use crate::frame::{FrameBuf, Plane};
use crate::frame::{FrameBuf, RowBand};
use crate::par::Pool;
use crate::postfilter::bands;
use crate::spec_tables::*;
use crate::state::FrameState;
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
/// equals CurrFrame).
pub(crate) fn apply(fs: &FrameState) -> Option<FrameBuf> {
/// equals CurrFrame). Bands of 64 luma rows (whole 8x8 blocks) are filtered in parallel: every
/// block reads only CurrFrame and writes only its own samples.
pub(crate) fn apply(fs: &FrameState, pool: &Pool) -> Option<FrameBuf> {
let fh = &fs.fh;
if fh.coded_lossless || fh.allow_intrabc || !fs.seq.enable_cdef {
return None;
}
let (mi_rows, mi_cols) = (fh.mi_rows as usize, fh.mi_cols as usize);
let mut out = fs.cur.clone();
let mut any = false;
let mut r = 0;
while r < mi_rows {
let mut c = 0;
while c < mi_cols {
let idx = fs.cdef_idx(r & !15, c & !15);
if idx >= 0 && cdef_block(fs, &mut out, r, c, idx as usize) {
any = true;
let ranges = bands(pool, mi_rows * 4);
let mut planes = out.row_bands(&ranges);
let any = pool.map_mut(&mut planes, |b, band| {
let (start, end) = ranges[b];
let mut any = false;
let mut r = start / 4;
while r < mi_rows.min(end / 4) {
let mut c = 0;
while c < mi_cols {
let idx = fs.cdef_idx(r & !15, c & !15);
if idx >= 0 && cdef_block(fs, band, r, c, idx as usize) {
any = true;
}
c += 2;
}
c += 2;
r += 2;
}
r += 2;
}
any.then_some(out)
any
});
drop(planes);
any.contains(&true).then_some(out)
}
fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usize) -> bool {
fn cdef_block(fs: &FrameState, out: &mut [RowBand; 3], r: usize, c: usize, idx: usize) -> bool {
let mi = &fs.mi;
let sk = |rr: usize, cc: usize| -> bool { if rr < mi.rows && cc < mi.cols { mi.skip[mi.idx(rr, cc)] } else { true } };
let skip = sk(r, c) && sk(r + 1, c) && sk(r, c + 1) && sk(r + 1, c + 1);
@ -125,7 +135,7 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
}
#[allow(clippy::too_many_arguments)]
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
fn filter(fs: &FrameState, out: &mut [RowBand; 3], plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
if pri == 0 && sec == 0 {
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
return;
@ -169,7 +179,7 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
let (pri_adj, sec_adj) = (adj(pri), adj(sec));
let dst = &mut out.planes[plane];
let dst = &mut out[plane];
if all_avail {
// Every tap is available: whole rows at a time (constrain() with threshold 0 is 0).
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
@ -230,7 +240,12 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
/// CDEF of a block whose taps are all available, `W` samples per row. Samples (at most 12
/// bits), differences and the weighted tap sum all fit in i16, which doubles the SIMD width.
#[inline(always)]
fn filter_rows<const W: usize>(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &[(isize, i32, i32, u32); 12], dst: &mut Plane, (y0, x0, h): (usize, usize, usize)) {
fn filter_rows<const W: usize>(
buf: &[i32; PAD_STRIDE * PAD_STRIDE],
taps: &[(isize, i32, i32, u32); 12],
dst: &mut RowBand,
(y0, x0, h): (usize, usize, usize),
) {
let mut b16 = [0i16; PAD_STRIDE * PAD_STRIDE];
for (d, &s) in b16.iter_mut().zip(buf.iter()) {
*d = s as i16;

View File

@ -106,6 +106,48 @@ impl Plane {
dst.row_from_mut(self.oy + y, self.ox)[..self.stride].copy_from_slice(s);
}
}
/// Disjoint mutable bands of whole rows, for filtering in parallel: one per `[start, end)`
/// range of rows (relative to `oy`; ascending and not overlapping, else the later band is
/// cut to what is left). Ranges are clipped to the stored rows.
pub fn row_bands(&mut self, ranges: &[(usize, usize)]) -> Vec<RowBand<'_>> {
let (stride, oy) = (self.stride.max(1), self.oy);
let mut out = Vec::with_capacity(ranges.len());
let mut rest = self.data.as_mut_slice();
// First row of `rest`.
let mut y = 0usize;
for &(start, end) in ranges {
let skip = start.saturating_sub(y).saturating_mul(stride).min(rest.len());
rest = std::mem::take(&mut rest).split_at_mut(skip).1;
y = y.saturating_add(skip / stride);
let len = end.saturating_sub(y).saturating_mul(stride).min(rest.len());
let (band, tail) = std::mem::take(&mut rest).split_at_mut(len);
out.push(RowBand { data: band, stride, y0: oy.saturating_add(y) });
y = y.saturating_add(len / stride);
rest = tail;
}
out
}
}
/// Mutable whole rows [y0, y0 + data.len() / stride) of a plane, addressed with the plane's
/// coordinates (see [`Plane::row_bands`]).
pub struct RowBand<'a> {
pub data: &'a mut [u16],
pub stride: usize,
pub y0: usize,
}
impl RowBand<'_> {
#[inline(always)]
pub fn set(&mut self, x: usize, y: usize, v: u16) {
self.data[(y - self.y0) * self.stride + x] = v;
}
#[inline(always)]
pub fn row_mut(&mut self, y: usize) -> &mut [u16] {
let o = (y - self.y0) * self.stride;
&mut self.data[o..o + self.stride]
}
}
/// A frame's three planes, sized to the macroblock-aligned coded area plus margins.
@ -158,6 +200,16 @@ impl FrameBuf {
}
}
/// [`Plane::row_bands`] of all three planes for ranges of luma rows (chroma rows scaled
/// down): element `i` holds the bands of range `i`.
pub fn row_bands(&mut self, luma: &[(usize, usize)]) -> Vec<[RowBand<'_>; 3]> {
let ssy = self.subsampling_y;
let chroma: Vec<(usize, usize)> = luma.iter().map(|&(s, e)| (s >> ssy, e >> ssy)).collect();
let [p0, p1, p2] = &mut self.planes;
let (b1, b2) = (p1.row_bands(&chroma), p2.row_bands(&chroma));
p0.row_bands(luma).into_iter().zip(b1).zip(b2).map(|((y, u), v)| [y, u, v]).collect()
}
pub fn plane_width(&self, plane: usize) -> usize {
if plane == 0 { self.width } else { (self.width + self.subsampling_x) >> self.subsampling_x }
}
@ -285,3 +337,40 @@ impl MiInfo {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rows(b: &RowBand) -> (usize, usize) {
(b.y0, b.data.len() / b.stride)
}
#[test]
fn row_bands_split_whole_rows() {
let mut p = Plane::new(8, 20);
for (i, v) in p.data.iter_mut().enumerate() {
*v = (i / 8) as u16;
}
let bands = p.row_bands(&[(0, 8), (8, 16), (16, usize::MAX)]);
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(0, 8), (8, 8), (16, 4)]);
// Each band's row y holds the samples of plane row y.
for mut b in bands {
let y = b.y0 + 1;
assert!(b.row_mut(y).iter().all(|&v| v as usize == y));
}
// Gaps between bands (the loop filter's even / odd turns).
let bands = p.row_bands(&[(0, 4), (10, 14), (18, 30)]);
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(0, 4), (10, 4), (18, 2)]);
}
#[test]
fn row_bands_never_overlap_or_panic() {
let mut p = Plane::new(4, 10);
// Overlapping, descending and out-of-range ranges are cut to what is left.
let bands = p.row_bands(&[(2, 6), (4, 8), (1, 3), (9, 2), (50, 60), (usize::MAX, usize::MAX)]);
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(2, 4), (6, 2), (8, 0), (9, 0), (10, 0), (10, 0)]);
let mut empty = Plane::default();
assert!(empty.row_bands(&[(0, 64), (64, usize::MAX)]).iter().all(|b| b.data.is_empty()));
}
}

View File

@ -1,20 +1,33 @@
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
//! super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::frame::RowBand;
use crate::spec_tables::*;
use crate::state::FrameState;
use crate::stats::{DecodeStats, Stage, Timer};
/// The post-filters run on the frame worker; `_pool` is for row-parallel filtering (not yet used).
/// `draft`: only super-resolution (which sets the output size) runs.
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate::par::Pool, draft: bool) {
/// Luma rows per band of the row-parallel filters: whole 64x64 CDEF filter blocks, and no
/// transform block crosses a band edge.
const BAND_ROWS: usize = 64;
/// The [start, end) luma rows of the bands covering `rows`: [`BAND_ROWS`] each (the last one
/// runs to the end of the plane), or one band for everything when `pool` is sequential.
pub(crate) fn bands(pool: &crate::par::Pool, rows: usize) -> Vec<(usize, usize)> {
let n = if pool.is_parallel() { rows.div_ceil(BAND_ROWS).max(1) } else { 1 };
(0..n).map(|b| (b * BAND_ROWS, if b + 1 == n { usize::MAX } else { (b + 1) * BAND_ROWS })).collect()
}
/// The post-filters run on the frame worker, each in row bands on `pool` (the result does not
/// depend on how the bands are scheduled). `draft`: only super-resolution (which sets the
/// output size) runs.
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, pool: &crate::par::Pool, draft: bool) {
let mut t = Timer::start();
let lvl = fs.fh.lf.level;
if (lvl[0] != 0 || lvl[1] != 0) && !draft {
loop_filter(fs);
loop_filter(fs, pool);
}
stats.add(Stage::LoopFilter, t.lap());
let cdef = if draft { None } else { crate::cdef::apply(fs) };
let cdef = if draft { None } else { crate::cdef::apply(fs, pool) };
stats.add(Stage::Cdef, t.lap());
let (up_cur, up_cdef) = if fs.fh.use_superres {
let c = crate::restoration::upscale(fs, &fs.cur);
@ -25,7 +38,7 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate:
};
stats.add(Stage::Superres, t.lap());
fs.cur = if fs.fh.lr.uses_lr && !draft {
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur))
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur), pool)
} else {
up_cdef.unwrap_or(up_cur)
};
@ -35,28 +48,102 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate:
// ---------------------------------------------------------------------------------------------
// Loop filter (7.14)
fn loop_filter(fs: &mut FrameState) {
let (mi_rows, mi_cols) = (fs.fh.mi_rows as usize, fs.fh.mi_cols as usize);
for plane in 0..fs.num_planes {
if plane == 0 || fs.fh.lf.level[1 + plane] != 0 {
for pass in 0..2 {
let row_step = if plane == 0 { 1 } else { 1 << fs.ssy };
let col_step = if plane == 0 { 1 } else { 1 << fs.ssx };
let mut row = 0;
while row < mi_rows {
let mut col = 0;
while col < mi_cols {
edge(fs, plane, pass, row, col);
col += col_step;
}
row += row_step;
/// Luma rows above a band that its horizontal edges read or write (p6 .. p0 of the widest
/// filter, rounded up to whole 4x4 rows); chroma: `LF_MARGIN >> subsampling_y`.
const LF_MARGIN: usize = 8;
/// The loop filter in row bands, all of a frame's in one parallel pass (each join costs a
/// worker wake-up, which is slow on a busy machine).
///
/// Vertical edges (pass 0) only touch the 4 rows of their own edge. A horizontal edge (pass 1)
/// reads and writes at most `filter_size / 2` samples on each side, never more than the
/// transform blocks on either side are high, so no two edges touch the same sample and
/// any order gives the spec's raster-order result. Transform blocks never cross a band edge, so
/// the only edges reaching outside a band are those on its top row, whose p side lies in the
/// band above. Each band therefore also filters the vertical edges of a copy of the
/// `LF_MARGIN` rows above it (the same work as the band above does on them), runs its top
/// horizontal edges against that copy, and afterwards the samples those edges changed are
/// written back; the band above never touches them.
fn loop_filter(fs: &mut FrameState, pool: &crate::par::Pool) {
let luma = bands(pool, fs.fh.mi_rows as usize * 4);
let planes: Vec<usize> = (0..fs.num_planes).filter(|&p| p == 0 || fs.fh.lf.level[1 + p] != 0).collect();
let mut cur = std::mem::take(&mut fs.cur);
let f: &FrameState = fs;
// Unfiltered copies of the rows above each band but the first.
let margins: Vec<[Vec<u16>; 3]> = luma
.iter()
.map(|&(start, _)| {
std::array::from_fn(|p| {
let sub_y = if p == 0 { 0 } else { f.ssy };
let pl = &cur.planes[p];
let (y0, y1) = ((start.saturating_sub(LF_MARGIN)) >> sub_y, start >> sub_y);
let rows = pl.data.get(y0 * pl.stride..y1 * pl.stride);
if start == 0 || !planes.contains(&p) { Vec::new() } else { rows.map(<[u16]>::to_vec).unwrap_or_default() }
})
})
.collect();
let mut bands: Vec<([RowBand; 3], [Vec<u16>; 3])> = cur.row_bands(&luma).into_iter().zip(margins).collect();
let changed = pool.map_mut(&mut bands, |b, (band, margin)| {
let mut changed: [Vec<(usize, u16)>; 3] = Default::default();
for &p in &planes {
changed[p] = filter_band(f, p, luma[b], &mut band[p], &mut margin[p]);
}
changed
});
drop(bands);
for (&(start, _), ch) in luma.iter().zip(&changed) {
for (p, list) in ch.iter().enumerate() {
let sub_y = if p == 0 { 0 } else { f.ssy };
let pl = &mut cur.planes[p];
let first = (start.saturating_sub(LF_MARGIN) >> sub_y) * pl.stride;
for &(i, v) in list {
if let Some(d) = pl.data.get_mut(first + i) {
*d = v;
}
}
}
}
fs.cur = cur;
}
fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize) {
/// Both loop filter passes over the band of `plane` holding luma rows [start, end); `margin` is
/// an unfiltered copy of the rows above the band (empty for the first band). Returns the samples
/// of `margin` that the band's top horizontal edges changed, as (index, value).
fn filter_band(fs: &FrameState, plane: usize, (start, end): (usize, usize), band: &mut RowBand, margin: &mut [u16]) -> Vec<(usize, u16)> {
let mi_rows = fs.fh.mi_rows as usize;
let (r0, r1) = (start / 4, mi_rows.min(end / 4));
let sub_y = if plane == 0 { 0 } else { fs.ssy };
let edges = |band: &mut RowBand, mut above: Option<&mut RowBand>, pass: usize, rows: std::ops::Range<usize>| {
let row_step = if plane == 0 { 1 } else { 1 << fs.ssy };
let col_step = if plane == 0 { 1 } else { 1 << fs.ssx };
let mi_cols = fs.fh.mi_cols as usize;
let mut row = rows.start;
while row < rows.end {
let mut col = 0;
while col < mi_cols {
edge(fs, band, above.as_deref_mut(), plane, pass, row, col);
col += col_step;
}
row += row_step;
}
};
edges(band, None, 0, r0..r1);
if margin.is_empty() {
edges(band, None, 1, r0..r1);
return Vec::new();
}
let stride = band.stride;
let y0 = start.saturating_sub(LF_MARGIN) >> sub_y;
let mut above = RowBand { data: margin, stride, y0 };
edges(&mut above, None, 0, r0.saturating_sub(LF_MARGIN / 4)..r0);
let before = above.data.to_vec();
edges(band, Some(&mut above), 1, r0..r1);
above.data.iter().zip(&before).enumerate().filter(|(_, (a, b))| a != b).map(|(i, (&a, _))| (i, a)).collect()
}
/// One loop filter edge (7.14.2) at mode info position (row, col): 4 lines of samples across
/// it. `above` holds the rows above `band` for horizontal edges on the band's top row.
fn edge(fs: &FrameState, band: &mut RowBand, above: Option<&mut RowBand>, plane: usize, pass: usize, row: usize, col: usize) {
let (sub_x, sub_y) = if plane == 0 { (0, 0) } else { (fs.ssx, fs.ssy) };
let (dx, dy) = if pass == 0 { (1i32, 0i32) } else { (0, 1) };
let x = col * 4;
@ -102,16 +189,40 @@ fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize)
return;
}
let t = LfThresh::new(limit, blimit, thresh, fs.bit_depth);
let pl = &mut fs.cur.planes[plane];
let stride = pl.stride;
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
let base = yp * stride + xp;
let chroma = plane > 0;
let stride = band.stride;
if let Some(above) = above.filter(|_| pass == 1 && yp == band.y0) {
// The p side is in the rows above the band: filter each line in a copy that spans both.
let (n_above, n_band) = (above.data.len() / stride.max(1), band.data.len() / stride.max(1));
for k in 0..4 {
let x = xp + k;
let mut line = [0u16; 2 * LF_LINE];
for j in 1..=n_above.min(LF_LINE) {
line[LF_LINE - j] = above.data[(n_above - j) * stride + x];
}
for j in 0..n_band.min(LF_LINE) {
line[LF_LINE + j] = band.data[j * stride + x];
}
sample_filter(&mut line, LF_LINE, 1, chroma, filter_size, t);
for j in 1..=n_above.min(LF_LINE) {
above.data[(n_above - j) * stride + x] = line[LF_LINE - j];
}
for j in 0..n_band.min(LF_LINE) {
band.data[j * stride + x] = line[LF_LINE + j];
}
}
return;
}
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
let base = (yp - band.y0) * stride + xp;
for k in 0..4 {
sample_filter(&mut pl.data, base + k * along, step, chroma, filter_size, t);
sample_filter(band.data, base + k * along, step, chroma, filter_size, t);
}
}
/// Samples on each side of an edge that the sample filter can read (at most p6 .. q6).
const LF_LINE: usize = 8;
/// Adaptive filter strength (7.14.4): (lvl, limit, blimit, thresh).
fn strength(fs: &FrameState, row: usize, col: usize, plane: usize, pass: usize) -> (i32, i32, i32, i32) {
let mi = &fs.mi;

View File

@ -1,6 +1,8 @@
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::frame::{FrameBuf, Plane};
use crate::frame::{FrameBuf, Plane, RowBand};
use crate::par::Pool;
use crate::postfilter::bands;
use crate::spec_tables::*;
use crate::state::FrameState;
@ -70,56 +72,66 @@ impl LrCtx<'_> {
}
/// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame.
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf {
/// Bands of 64 luma rows are restored in parallel: every unit reads only the two input frames
/// and writes only its own samples.
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, pool: &Pool) -> FrameBuf {
let fh = &fs.fh;
let mut lr = cdef.clone();
let up_w = fh.upscaled_width as i32;
let fr_h = fh.frame_height as i32;
let bd = fs.bit_depth;
for plane in 0..fs.num_planes {
let rtype_frame = fh.lr.frame_restoration_type[plane];
if rtype_frame == RESTORE_NONE as u8 {
continue;
}
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
let unit_rows = fs.lr_unit_rows[plane] as i32;
let unit_cols = fs.lr_unit_cols[plane] as i32;
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
let mut row = 0;
while row * 4 < fr_h {
let luma_y = row * 4;
let stripe_num = (luma_y + 8) / 64;
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
let mut col = 0;
while col * 4 < up_w {
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
let x = (col * 4) >> sub_x;
let y = (row * 4) >> sub_y;
let w = (4 >> sub_x).min(plane_end_x - x + 1);
let h = (4 >> sub_y).min(plane_end_y - y + 1);
if w > 0 && h > 0 {
let ui = (unit_row * unit_cols + unit_col) as usize;
match fs.lr.lr_type[plane][ui] {
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
_ => {}
}
}
col += 1;
let ranges = bands(pool, fh.frame_height as usize);
let mut planes = lr.row_bands(&ranges);
pool.map_mut(&mut planes, |b, band| {
// Rows of 4 luma samples in this band.
let (start, end) = ranges[b];
let (row0, row1) = ((start / 4) as i32, (end / 4).min(fh.frame_height as usize) as i32);
for (plane, out) in band.iter_mut().enumerate().take(fs.num_planes) {
let rtype_frame = fh.lr.frame_restoration_type[plane];
if rtype_frame == RESTORE_NONE as u8 {
continue;
}
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
let unit_rows = fs.lr_unit_rows[plane] as i32;
let unit_cols = fs.lr_unit_cols[plane] as i32;
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
let mut row = row0;
while row < row1 && row * 4 < fr_h {
let luma_y = row * 4;
let stripe_num = (luma_y + 8) / 64;
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
let mut col = 0;
while col * 4 < up_w {
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
let x = (col * 4) >> sub_x;
let y = (row * 4) >> sub_y;
let w = (4 >> sub_x).min(plane_end_x - x + 1);
let h = (4 >> sub_y).min(plane_end_y - y + 1);
if w > 0 && h > 0 {
let ui = (unit_row * unit_cols + unit_col) as usize;
match fs.lr.lr_type[plane][ui] {
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, plane, ui, x, y, w, h, bd),
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, plane, ui, x, y, w, h, bd),
_ => {}
}
}
col += 1;
}
row += 1;
}
row += 1;
}
}
});
drop(planes);
lr
}
#[allow(clippy::too_many_arguments)]
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
let round0: u32 = if bd == 12 { 5 } else { 3 };
let round1: u32 = if bd == 12 { 9 } else { 11 };
let coef = &fs.lr.lr_wiener[plane][ui];
@ -163,7 +175,7 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize
}
#[allow(clippy::too_many_arguments)]
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
let set = fs.lr.lr_sgr_set[plane][ui] as usize;
let mut flt0 = [[0i32; 4]; 4];
let mut flt1 = [[0i32; 4]; 4];

View File

@ -16,6 +16,11 @@ pub fn ffmpeg_dav1d() -> Option<PathBuf> {
ffmpeg_with(&[("-decoders", "libdav1d")])
}
/// ffmpeg with libaom (4:2:2 and 4:4:4 fixtures, which SVT-AV1 cannot make) and libdav1d.
pub fn ffmpeg_libaom() -> Option<PathBuf> {
ffmpeg_with(&[("-encoders", "libaom-av1"), ("-decoders", "libdav1d")])
}
/// Builds such as the Windows "essentials" ones lack these libraries; skip rather than fail.
fn ffmpeg_with(needs: &[(&str, &str)]) -> Option<PathBuf> {
let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?;
@ -38,9 +43,11 @@ pub fn run(ff: &Path, args: &[&str]) {
assert!(out.status.success(), "ffmpeg {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
}
/// An AV1 fixture: a lavfi source encoded by ffmpeg's libsvtav1 into IVF.
/// An AV1 fixture: a lavfi source encoded by ffmpeg (libsvtav1 unless [`Spec::encoder`] says
/// otherwise) into IVF.
pub struct Spec {
pub name: &'static str,
pub encoder: &'static str,
pub lavfi: String,
pub frames: u32,
pub pix_fmt: &'static str,
@ -50,7 +57,12 @@ pub struct Spec {
impl Spec {
pub fn new(name: &'static str, lavfi: impl Into<String>, frames: u32, pix_fmt: &'static str, enc: &[&str]) -> Spec {
Spec { name, lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() }
Spec { name, encoder: "libsvtav1", lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() }
}
pub fn encoder(mut self, encoder: &'static str) -> Spec {
self.encoder = encoder;
self
}
}
@ -60,7 +72,7 @@ pub fn make(ff: &Path, spec: &Spec) -> PathBuf {
let tmp = filmcraft_testkit::temp_path(&path);
let frames = spec.frames.to_string();
let t = tmp.to_str().unwrap().to_string();
let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", "libsvtav1"];
let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", spec.encoder];
args.extend(spec.enc.iter().map(String::as_str));
args.extend(["-f", "ivf", t.as_str()]);
run(ff, &args);

View File

@ -101,3 +101,19 @@ fn inter_1080p() {
check_bit_exact(&ff, &s);
}
}
/// 4:2:2 and 4:4:4 (libaom; SVT-AV1 only makes 4:2:0): the post-filters run in row bands whose
/// chroma rows depend on the vertical subsampling, so these check the band edges there too.
#[test]
fn inter_chroma_444_422() {
let Some(ff) = ffmpeg_libaom() else { return };
let p = ["-cpu-used", "8", "-crf", "40", "-enable-restoration", "1"];
let src = "testsrc2=s=416x400:r=25,noise=alls=8:allf=t";
for s in [
Spec::new("inter_aom_444", src, 6, "yuv444p", &p).encoder("libaom-av1"),
Spec::new("inter_aom_422", src, 6, "yuv422p", &p).encoder("libaom-av1"),
Spec::new("inter_aom_444_10bit", src, 6, "yuv444p10le", &p).encoder("libaom-av1"),
] {
check_bit_exact(&ff, &s);
}
}

View File

@ -51,8 +51,8 @@ fn run(frames: &[Vec<u8>], threads: usize) -> Run {
}
fn bench(spec: &Spec) {
// AV1_PERF_ONLY=<substring> limits the run to matching fixtures.
if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !spec.name.contains(f.as_str())) {
// AV1_PERF_ONLY=<substring>[,<substring>...] limits the run to matching fixtures.
if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !f.split(',').any(|f| spec.name.contains(f))) {
return;
}
let Some(ff) = ffmpeg() else { return };
@ -68,7 +68,6 @@ fn bench(spec: &Spec) {
}
}
let b = best.unwrap();
let n = b.stats.frames.max(1) as f64;
let cpu_fps = b.cpu.map(|c| format!("{:6.1}", b.shown as f64 / c)).unwrap_or_else(|| " ?".into());
println!(
"{:<26} {:>3} shown {:>3} decoded {:>3} tiles/frame | 1 thread: {} fps (thread CPU) {:6.1} fps (wall)",
@ -79,24 +78,35 @@ fn bench(spec: &Spec) {
cpu_fps,
b.shown as f64 / b.wall
);
let total = b.stats.total_secs();
let mut line = String::from(" ms/frame:");
println!("{}", stage_line(" ms/frame:", &b.stats));
// All cores: best of 3 by wall time.
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let mut best: Option<Run> = None;
for _ in 0..3 {
let r = run(&frames, threads);
assert_eq!(r.shown, b.shown);
if best.as_ref().is_none_or(|b| r.wall < b.wall) {
best = Some(r);
}
}
let m = best.unwrap();
println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / m.wall, load_average());
// Per-stage wall time on the frame workers (stages of different frames overlap).
println!("{}", stage_line(&format!(" {threads} threads, ms/frame:"), &m.stats));
}
/// Per-stage milliseconds per frame and share of the total.
fn stage_line(label: &str, stats: &DecodeStats) -> String {
let n = stats.frames.max(1) as f64;
let total = stats.total_secs();
let mut line = String::from(label);
for s in Stage::ALL {
let v = b.stats.secs(s);
let v = stats.secs(s);
if v > 0.0005 {
line += &format!(" {} {:.2} ({:.0}%)", s.name(), 1e3 * v / n, 100.0 * v / total);
}
}
println!("{line}");
// All cores: best of 3 by wall time.
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let mut best_wall = f64::INFINITY;
for _ in 0..3 {
let r = run(&frames, threads);
assert_eq!(r.shown, b.shown);
best_wall = best_wall.min(r.wall);
}
println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / best_wall, load_average());
line
}
#[test]
@ -110,4 +120,9 @@ fn decode_throughput() {
// Higher quality (more coefficients, finer partitions) and a multi-tile stream (4x2 tiles).
bench(&Spec::new("perf_1080p_gop_hq", src, 60, "yuv420p", &p("18", "keyint=60")));
bench(&Spec::new("perf_1080p_gop_tiles", src, 60, "yuv420p", &p("30", "keyint=60:tile-columns=2:tile-rows=1")));
// Low delay (each frame references the previous one, as screen and camera recorders encode):
// frames cannot overlap, so a frame's own parallelism decides the speed.
bench(&Spec::new("perf_1080p_lowdelay", src, 60, "yuv420p", &p("30", "keyint=60:pred-struct=1")));
// One tile at 2160p: the post-filters' row bands are most of the parallelism there.
bench(&Spec::new("perf_2160p_gop", "testsrc2=s=3840x2160:r=25,noise=alls=6:allf=t", 30, "yuv420p", &p("30", "keyint=30")));
}

View File

@ -176,3 +176,32 @@ fn playing_at_another_rate_leaves_no_holes() {
let tail: Vec<Vec<f32>> = got.iter().map(|c| c[2048..].to_vec()).collect();
assert_eq!(holes(&tail), 0);
}
/// #790: the encoder priming stored as `CodecDelay` (1024 samples: 21.333 ms at 48 kHz, 23.220 ms
/// at 44.1 kHz) is trimmed exactly. The demuxer takes it off the millisecond timestamps rounded to
/// whole milliseconds; starting the first packet there played the track 16 (48 kHz) or 10 (44.1 kHz)
/// samples late.
#[test]
fn matroska_codec_delay_is_trimmed_to_the_sample() {
for rate in [48_000u32, 44_100] {
let (asc, units) = aac(rate);
let want = continuous(&asc, rate, &units);
let delay_ns = (1024 * 1_000_000_000 + rate as i64 / 2) / rate as i64;
let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC");
spec.codec_private = asc.clone();
spec.audio = Some((rate as f64, 2, None));
spec.codec_delay_ns = delay_ns as u64;
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], MuxOptions::default()).unwrap();
for (k, u) in units.iter().enumerate() {
let start_ns = (k as i64 * 1024 * 1_000_000_000 + rate as i64 / 2) / rate as i64;
w.write_frame(0, start_ns - delay_ns, true, u, None).unwrap();
}
let bytes: Arc<[u8]> = w.finish().unwrap().into_inner().into();
let src = MkvSource::open("primed.mkv", bytes).unwrap();
let frames = units.len().saturating_sub(2) * 1024;
let got = played(&src, rate, frames);
let trimmed: Vec<Vec<f32>> = want.iter().map(|c| c[1024..].to_vec()).collect();
let (n, first) = mismatches(&got, &trimmed, 0);
assert_eq!(n, 0, "{rate} Hz: {n} samples differ from the decode with the priming trimmed, first (channel, sample) {first:?}");
}
}

View File

@ -152,6 +152,10 @@ struct State {
bytes: usize,
/// When a frame was last asked of this cache.
last_used: Instant,
/// Presentation pts of the frame the last request wanted: eviction keeps a window around it
/// (not around the newest decoded picture). Walking a long GOP dumps pictures in decode
/// order; a window around those would throw away the playhead frame and force another walk.
pin: i64,
/// Only background requests (thumbnails, [`filmcraft_media::cancel::with_background`]) have
/// used the decoder since it was made: it is given up as soon as another background request
/// is done, whatever the caches' recency.
@ -457,6 +461,7 @@ impl GopCache {
drafts: Default::default(),
bytes: 0,
last_used: Instant::now(),
pin: i64::MIN,
background: false,
}),
pool: pool.clone(),
@ -509,10 +514,19 @@ impl GopCache {
st.bytes -= old.byte_size();
filmcraft_frame::pool::recycle(old);
}
// evict frames far from the most recent (keep a window around the working position)
// Evict frames far from the request (pin), not the newest decode. A frame-threaded
// decoder can emit a burst past the playhead; pivoting on those pts used to drop the
// wanted picture and walk the GOP again.
let pivot = if st.pin != i64::MIN { st.pin } else { pts };
while st.bytes > budget && st.frames.len() > 2 {
let (Some(&first), Some(&last)) = (st.frames.keys().next(), st.frames.keys().next_back()) else { break };
let victim = if pts - first > last - pts { first } else { last };
let mut victim = if pivot - first > last - pivot { first } else { last };
if victim == pivot || victim == pts {
victim = if victim == first { last } else { first };
if victim == pivot || victim == pts {
break;
}
}
if let Some(v) = st.frames.remove(&victim) {
st.drafts.remove(&victim);
st.bytes -= v.byte_size();
@ -583,6 +597,7 @@ impl GopCache {
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
let _decoding = DecodingGuard::enter(me);
st.last_used = Instant::now();
st.pin = want_pts;
let background = filmcraft_media::cancel::background();
if let Some(f) = st.cached(want_pts, draft).cloned() {
HITS.fetch_add(1, Ordering::Relaxed);
@ -919,19 +934,27 @@ mod tests {
fn intra_frames_decode_in_parallel_once_each() {
let s = Arc::new(samples(64, 1, 0, true));
let c = Arc::new(GopCache::new(None));
std::thread::scope(|scope| {
for t in 0..4usize {
let (s, c) = (s.clone(), c.clone());
scope.spawn(move || {
for k in 0..64usize {
let i = (k * 7 + t * 16) % 64;
assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i);
}
});
}
});
// a frame two threads miss at the same moment may decode twice; nothing more
assert!(s.decodes.load(Ordering::Relaxed) <= 64 + 4 * 4);
// First decode 64 distinct cold frames: each of the four workers owns
// 16 indices. Multiplication by seven permutes them without overlap.
// Then let every worker request all 64 cached frames in overlapping order.
// Concurrent misses for the same cold frame may legitimately decode twice,
// so they cannot support a scheduler-independent duplicate allowance.
for requests_per_worker in [16usize, 64] {
let start = Arc::new(std::sync::Barrier::new(4));
std::thread::scope(|scope| {
for t in 0..4usize {
let (s, c, start) = (s.clone(), c.clone(), start.clone());
scope.spawn(move || {
start.wait();
for k in 0..requests_per_worker {
let i = (k * 7 + t * 16) % 64;
assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i);
}
});
}
});
assert_eq!(s.decodes.load(Ordering::Relaxed), 64, "cold frames decode once; warm requests hit the cache");
}
assert_eq!(s.resets.load(Ordering::Relaxed), 0);
}
@ -1364,4 +1387,90 @@ mod tests {
}
assert_eq!(s.resets.load(Ordering::Relaxed), 1);
}
/// Many pictures in one GOP; a decoder that emits a burst past the wanted sample (frame-
/// threaded H.264). Eviction used to pivot on the newest pts and drop the playhead frame.
struct Burst {
n: usize,
/// Pictures held before a decode dumps them all.
burst: usize,
held: VecDeque<i64>,
resets: Arc<AtomicUsize>,
decodes: Arc<AtomicUsize>,
}
impl VideoDecoder for Burst {
fn decode(&mut self, _sample: &[u8], pts: i64) -> crate::Result<Vec<DecodedFrame>> {
self.decodes.fetch_add(1, Ordering::Relaxed);
self.held.push_back(pts);
if self.held.len() < self.burst {
return Ok(Vec::new());
}
Ok(self.held.drain(..).map(|p| picture((p, false))).collect())
}
fn flush(&mut self) -> Vec<DecodedFrame> {
self.held.drain(..).map(|p| picture((p, false))).collect()
}
fn reset(&mut self) {
self.resets.fetch_add(1, Ordering::Relaxed);
self.held.clear();
}
fn name(&self) -> &str {
"burst"
}
}
impl VideoSamples for Burst {
fn count(&self) -> usize {
self.n
}
fn pts(&self, i: usize) -> i64 {
i as i64 * 1000
}
fn sync_before(&self, _i: usize) -> usize {
0
}
fn sample_at(&self, t: i64) -> Option<usize> {
let i = (t / 1000) as usize;
(i < self.n).then_some(i)
}
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
Ok(vec![i as u8])
}
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
Ok(Box::new(Burst { n: self.n, burst: self.burst, held: VecDeque::new(), resets: self.resets.clone(), decodes: self.decodes.clone() }))
}
}
#[test]
fn long_gop_seek_keeps_the_target_and_does_not_walk_twice() {
let s = Burst { n: 200, burst: 90, held: VecDeque::new(), resets: Default::default(), decodes: Default::default() };
let mut c = GopCache::new(None);
c.budget = 0;
// Want a picture early in the GOP; the burst dumps 90 pictures at once (past MIN_FRAMES).
assert_eq!(index_of(&c.frame(&s, 10_000).expect("frame")), 10);
assert_eq!(s.resets.load(Ordering::Relaxed), 1);
let decodes = s.decodes.load(Ordering::Relaxed);
assert!(decodes <= 90 + 1, "walked {decodes} samples");
// The wanted picture is still cached: asking again must not restart or re-feed.
assert_eq!(index_of(&c.frame(&s, 10_000).expect("cached")), 10);
assert_eq!(s.resets.load(Ordering::Relaxed), 1);
assert_eq!(s.decodes.load(Ordering::Relaxed), decodes);
// Playback continues from the same run.
assert_eq!(index_of(&c.frame(&s, 11_000).expect("frame")), 11);
assert_eq!(s.resets.load(Ordering::Relaxed), 1);
}
#[test]
fn long_gop_continue_does_not_restart_for_the_next_frame() {
let s = samples(300, 250, 8, false);
let mut c = GopCache::new(None);
c.budget = 0;
assert_eq!(index_of(&c.frame(&s, 80_000).expect("frame")), 80);
let resets = s.resets.load(Ordering::Relaxed);
let decodes = s.decodes.load(Ordering::Relaxed);
assert_eq!(index_of(&c.frame(&s, 81_000).expect("frame")), 81);
assert_eq!(s.resets.load(Ordering::Relaxed), resets);
assert!(s.decodes.load(Ordering::Relaxed) - decodes <= 8 + 2);
}
}

View File

@ -258,7 +258,7 @@ impl NalStreamInfo {
bit_depth_chroma: sps.bit_depth_chroma,
interlaced: !sps.frame_mbs_only,
profile_idc: sps.profile_idc,
color: vui_color(crop.2, crop.3, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range),
color: vui_color(crop.2, crop.3, vui.colour_primaries, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range),
par: sar_par(vui.sar),
// as the software decoder's DPB (`max_reorder`)
reorder: sps.max_num_reorder_frames().min(sps.max_dpb_frames()),
@ -304,7 +304,7 @@ impl NalStreamInfo {
bit_depth_chroma: sps.bit_depth_chroma,
interlaced: false,
profile_idc: sps.ptl.profile_idc,
color: vui_color(crop.2, crop.3, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range),
color: vui_color(crop.2, crop.3, vui.colour_primaries, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range),
par: sar_par(vui.sar),
reorder: sps.max_num_reorder as usize,
})

View File

@ -71,15 +71,14 @@ pub struct MkvSource {
/// length (AAC, MPEG audio, AC-3, E-AC-3) therefore run on from one another, resynchronising to the
/// timestamp only across gaps of more than half a packet (and more than two ticks); see
/// [`crate::audio::contiguous_starts`]. Opus packets: [`opus_starts`]. Other codecs start at their
/// timestamps.
/// timestamps. Timestamps are taken with the exact `CodecDelay` off (see [`stamp_to_samples`]).
fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec<i64> {
use crate::audio::{FixedFrames, PacketTime, contiguous_starts, fixed_packet_samples};
let Some(t) = file.tracks.get(ti) else { return Vec::new() };
if let Some(h) = opus_head(&t.codec) {
return opus_starts(file, bytes, ti, &h, rate);
}
let (n, d) = tb(t);
let at = |pts: i64| (pts as i128 * n as i128 * rate as i128 / d as i128) as i64;
let at = stamp_to_samples(t, rate, codec_delay_samples(t, rate));
let first = || file.read_sample(bytes, ti, 0).unwrap_or_default();
let out_rate = u32::try_from(rate).unwrap_or(0);
let frame = match &t.codec {
@ -98,18 +97,13 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec
/// Sample-exact Opus packet start positions (48 kHz frames, pre-skip removed).
///
/// The demuxer also subtracts a rounded `CodecDelay` from the timestamps. Starts are accumulated
/// from each packet's TOC duration, resynchronising to the timestamp only across real gaps (more
/// than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's pre-skip when it is
/// absent).
/// Starts are accumulated from each packet's TOC duration, resynchronising to the timestamp only
/// across real gaps (more than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's
/// pre-skip when it is absent).
fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_opus::OpusHead, rate: i64) -> Vec<i64> {
let Some(t) = file.tracks.get(ti) else { return Vec::new() };
let (n, d) = tb(t);
let delay_ns = t.codec_delay_ns as i128;
// The demuxer's rounding of CodecDelay to ticks, undone here.
let delay_ticks = codec_delay_ticks(t);
let skip = if t.codec_delay_ns > 0 { (delay_ns * rate as i128 / 1_000_000_000) as i64 } else { head.pre_skip as i64 };
let at = |pts: i64| ((pts.saturating_add(delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64).saturating_sub(skip);
let skip = if t.codec_delay_ns > 0 { codec_delay_samples(t, rate) } else { head.pre_skip as i64 };
let at = stamp_to_samples(t, rate, skip);
let packets = t.samples.iter().enumerate().map(|(i, s)| crate::audio::PacketTime {
stamp: at(s.pts),
stamped: own_stamp(s),
@ -118,6 +112,28 @@ fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_o
crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate))
}
/// A block timestamp (with the demuxer's rounded `CodecDelay` taken off) as a position in sample
/// frames at `rate`, `skip` frames of priming removed (#790). The demuxer's rounding is undone first:
/// `CodecDelay` is rarely a whole number of ticks (1024 AAC samples at 48 kHz are 21.333 ms, 256
/// AC-3 samples at 44.1 kHz 5.805 ms), so starting the first packet at the rounded delay played the
/// whole track up to 16 samples late.
fn stamp_to_samples(t: &filmcraft_matroska::Track, rate: i64, skip: i64) -> impl Fn(i64) -> i64 {
let (n, d) = tb(t);
let delay_ticks = codec_delay_ticks(t);
move |pts: i64| {
let ticks = pts.saturating_add(delay_ticks) as i128;
let at = ticks.checked_mul(n as i128).and_then(|x| x.checked_mul(rate as i128)).map_or(ticks.signum() * i128::MAX, |x| x / d as i128);
i64::try_from(at).unwrap_or(if at < 0 { i64::MIN } else { i64::MAX }).saturating_sub(skip)
}
}
/// `CodecDelay` in sample frames at `rate`, rounded to the nearest (the element holds whole
/// nanoseconds: 1024 samples at 48 kHz are stored as 21 333 333 ns).
fn codec_delay_samples(t: &filmcraft_matroska::Track, rate: i64) -> i64 {
let ns = (t.codec_delay_ns as i128).saturating_mul(rate.max(0) as i128);
i64::try_from(ns.saturating_add(500_000_000) / 1_000_000_000).unwrap_or(i64::MAX)
}
/// `CodecDelay` in timestamp ticks, rounded as the demuxer rounds it (half away from zero) before
/// subtracting it from every timestamp of the track.
fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 {
@ -358,7 +374,11 @@ impl MkvSource {
let rate = match t.default_duration_ns {
Some(ns) if ns > 0 => FrameRate::from_f64(1e9 / ns as f64),
_ => {
let mut d: Vec<i64> = t.samples.windows(2).take(240).map(|p| (p[1].pts - p[0].pts).abs()).filter(|d| *d > 0).collect();
// blocks are stored in decode order: with B-frames, neighbours in the file are
// up to a few frames apart, so step between presentation times (#923)
let mut pts: Vec<i64> = t.samples.iter().take(241).map(|s| s.pts).collect();
pts.sort_unstable();
let mut d: Vec<i64> = pts.windows(2).map(|p| p[1].saturating_sub(p[0])).filter(|d| *d > 0).collect();
d.sort_unstable();
let (n, dd) = tb(t);
let step = d.get(d.len() / 2).copied().unwrap_or(1).max(1) as f64 * n as f64 / dd as f64;

View File

@ -7,8 +7,9 @@
//! first random-access picture; display order comes from the PTS (or, without timestamps, the
//! GOP and temporal reference). Leading pictures of an open GOP decode from the previous GOP.
//! - Audio: MPEG audio layers I-III (symphonia), AAC in ADTS or LATM (our decoder), AC-3 (our
//! decoder), Blu-ray / AVCHD and DVD LPCM. Audio frames are placed on the timeline from their
//! PTS relative to the first video frame, sample-contiguously (re-anchored only at gaps).
//! decoder), Blu-ray / AVCHD and DVD LPCM. Every audio PID is a stream (capped). Audio frames
//! are placed on the timeline from their PTS relative to the first video frame,
//! sample-contiguously (re-anchored only at gaps).
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
@ -104,7 +105,8 @@ pub struct MpegSource {
es: Option<(Arc<[u8]>, Vec<std::ops::Range<usize>>)>,
video: Option<VideoTrack>,
gop: GopCache,
audio: Option<AudioTrack>,
/// Playable audio tracks in file order: `info.audio_streams[k]` describes `audios[k]`.
audios: Vec<AudioTrack>,
/// Why the video or audio stream cannot be decoded.
unsupported_video: Option<String>,
unsupported_audio: Option<String>,
@ -201,8 +203,8 @@ impl MpegSource {
log::warn!("{name}: {w}");
}
let vstream = file.find(Kind::Video, |c| vkind(c).is_some()).or_else(|| file.find(Kind::Video, |_| true));
let astream = file.find(Kind::Audio, audio_supported).or_else(|| file.find(Kind::Audio, |_| true));
if vstream.is_none() && astream.is_none() {
let has_audio = file.streams.iter().any(|s| s.kind() == Kind::Audio && !s.units.is_empty());
if vstream.is_none() && !has_audio {
return Err(CodecError::Unsupported(format!("{}: no video or audio streams", file.format.name())));
}
let mut src = MpegSource {
@ -221,7 +223,7 @@ impl MpegSource {
es: None,
video: None,
gop: GopCache::new(None),
audio: None,
audios: Vec::new(),
unsupported_video: None,
unsupported_audio: None,
};
@ -256,7 +258,7 @@ impl MpegSource {
src.video = Some(track);
src.describe_video(rate_from_pts)?;
if let Some(file) = src.file.take() {
src.setup_audio(file, astream, origin);
src.setup_audio(file, origin);
}
}
None => {
@ -273,12 +275,12 @@ impl MpegSource {
bitrate: None,
hdr: None,
});
src.setup_audio(file, astream, None);
src.setup_audio(file, None);
}
}
} else {
src.info.kind = MediaKind::AudioOnly;
src.setup_audio(file, astream, None);
src.setup_audio(file, None);
}
Ok(src)
}
@ -326,7 +328,7 @@ impl MpegSource {
es: Some((all, aus)),
video: Some(track),
gop: GopCache::new(None),
audio: None,
audios: Vec::new(),
unsupported_video: None,
unsupported_audio: None,
};
@ -347,15 +349,15 @@ impl MpegSource {
/// Timeline position (samples at the audio stream's rate) of the first audio frame, and the
/// number of samples in the audio stream.
pub fn audio_extent(&self) -> Option<(i64, i64)> {
let a = self.audio.as_ref()?;
let a = self.audios.first()?;
let first = *a.starts.first()?;
let end = a.starts.last()? + *a.lens.last()? as i64;
Some((first, end - first))
}
/// The demuxed stream indices of the presented video and audio.
/// The demuxed stream indices of the presented video and first audio.
pub fn stream_indices(&self) -> (Option<usize>, Option<usize>) {
(self.video.as_ref().filter(|_| self.es.is_none()).map(|v| v.stream), self.audio.as_ref().map(|a| a.stream))
(self.video.as_ref().filter(|_| self.es.is_none()).map(|v| v.stream), self.audios.first().map(|a| a.stream))
}
/// Presentation timestamps (90 kHz) of the presented video frames in display order. Frames
@ -475,15 +477,31 @@ impl MpegSource {
Ok(())
}
/// Choose and index the audio stream; `origin`: PTS of the first video frame.
fn setup_audio(&mut self, file: File, astream: Option<usize>, origin: Option<i64>) {
let track = astream.and_then(|ai| self.audio_track(&file, ai, origin));
/// Index every audio stream the container carries (capped); `origin`: PTS of the first video frame.
fn setup_audio(&mut self, file: File, origin: Option<i64>) {
let indices: Vec<usize> = file
.streams
.iter()
.enumerate()
.filter(|(_, s)| s.kind() == Kind::Audio && !s.units.is_empty())
.map(|(i, _)| i)
.take(filmcraft_media::MAX_AUDIO_STREAMS)
.collect();
let mut tracks = Vec::new();
for ai in indices {
if let Some(t) = self.audio_track(&file, ai, origin) {
tracks.push(t);
}
}
self.file = Some(file);
self.audio = track;
self.audios = tracks;
}
/// Mark the audio stream as not decodable (it still shows in the media info).
/// Mark the audio stream as not decodable (it still shows in the media info when nothing plays).
fn audio_unsupported(&mut self, codec_name: &str, why: String) -> Option<AudioTrack> {
if !self.info.audio_streams.is_empty() {
return None;
}
self.unsupported_audio = Some(why);
self.info.audio_streams =
vec![AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: format!("{codec_name} (unsupported)"), bits_per_sample: None }];
@ -594,14 +612,16 @@ impl MpegSource {
next = Some(s + n as i64);
}
let total = next.unwrap_or(0).max(0);
if self.info.video.is_none() || self.unsupported_video.is_some() {
if self.unsupported_audio.take().is_some() {
self.info.audio_streams.clear();
}
if self.info.audio_streams.is_empty() && (self.info.video.is_none() || self.unsupported_video.is_some()) {
self.info.duration = Tick::from_units(total, rate.max(1) as i64);
if self.info.video.is_none() {
self.info.kind = MediaKind::AudioOnly;
}
}
self.info.audio_streams =
vec![AudioStreamInfo { sample_rate: rate.max(1), channels: channels.max(1) as u32, codec: codec_name, bits_per_sample: bits }];
self.info.audio_streams.push(AudioStreamInfo { sample_rate: rate.max(1), channels: channels.max(1) as u32, codec: codec_name, bits_per_sample: bits });
Some(AudioTrack {
stream: ai,
codec,
@ -783,10 +803,16 @@ impl MediaSource for MpegSource {
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
if let Some(why) = &self.unsupported_audio {
self.audio_stream(0, start, frames, sample_rate)
}
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
if self.audios.is_empty()
&& let Some(why) = &self.unsupported_audio
{
return Err(MediaError::Unsupported(why.clone()));
}
let a = self.audio.as_ref().ok_or(MediaError::NoStream("audio"))?;
let a = self.audios.get(stream).ok_or(MediaError::NoStream("audio"))?;
let ch = a.channels;
let ratio = a.rate as f64 / sample_rate as f64;
let exact = a.rate == sample_rate;
@ -852,6 +878,7 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedRe
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_media::MediaSource;
/// A mutated transport stream whose PTS span gave ~0 fps panicked on open with "attempt to
/// divide by zero" (`FrameRate::tick_of`).
@ -868,6 +895,118 @@ mod tests {
assert_eq!(stream_rate(None, Some(50.0)), FrameRate::from_f64(50.0));
}
/// MPEG-1 layer II frame, 48 kHz 192 kb/s (576 bytes).
fn mp2_frame(k: u8) -> Vec<u8> {
let mut v = vec![0xFF, 0xFD, 0xA4, 0x00];
v.extend((0..572).map(|i| (i as u8) ^ k));
v
}
fn ts_bytes(prefix: u8, v: i64) -> [u8; 5] {
let v = v & ((1 << 33) - 1);
[(prefix << 4) | (((v >> 30) & 7) as u8) << 1 | 1, (v >> 22) as u8, (((v >> 15) & 0x7F) as u8) << 1 | 1, (v >> 7) as u8, ((v & 0x7F) as u8) << 1 | 1]
}
fn pes(stream_id: u8, pts: Option<i64>, payload: &[u8]) -> Vec<u8> {
let mut h = Vec::new();
if let Some(p) = pts {
h.extend(ts_bytes(2, p));
}
let flags = if pts.is_some() { 0x80 } else { 0 };
let len = 3 + h.len() + payload.len();
let mut v = vec![0, 0, 1, stream_id, (len >> 8) as u8, len as u8, 0x80, flags, h.len() as u8];
v.extend(h);
v.extend_from_slice(payload);
v
}
/// Tiny TS muxer: PAT, PMT and PES packets in 188-byte packets.
struct TsMux {
out: Vec<u8>,
cc: std::collections::HashMap<u16, u8>,
}
impl TsMux {
fn packet(&mut self, pid: u16, pusi: bool, payload: &[u8]) -> usize {
let cc = self.cc.entry(pid).or_insert(0);
let mut p = vec![0x47, (pusi as u8) << 6 | (pid >> 8) as u8, pid as u8];
let n = payload.len().min(184);
if n < 184 {
let al = 183 - n;
p.push(0x30 | *cc);
p.push(al as u8);
if al > 0 {
p.push(0);
p.extend(std::iter::repeat_n(0xFF, al - 1));
}
} else {
p.push(0x10 | *cc);
}
*cc = (*cc + 1) & 15;
p.extend_from_slice(&payload[..n]);
self.out.extend(&p);
n
}
fn section(&mut self, pid: u16, body: &[u8]) {
let mut s = body.to_vec();
s.extend(crc32_mpeg(&s).to_be_bytes());
let mut payload = vec![0];
payload.extend(s);
self.packet(pid, true, &payload);
}
fn psi(&mut self, streams: &[(u8, u16)]) {
let pat = [0x00, 0xB0, 13, 0, 1, 0xC1, 0, 0, 0, 1, 0xE1, 0x00];
self.section(0, &pat);
let mut pmt = vec![0x02, 0xB0, 0, 0, 1, 0xC1, 0, 0, 0xE1, 0x00, 0xF0, 0];
for &(st, pid) in streams {
pmt.extend([st, 0xE0 | (pid >> 8) as u8, pid as u8, 0xF0, 0]);
}
let len = pmt.len() - 3 + 4;
pmt[1] = 0xB0 | (len >> 8) as u8;
pmt[2] = len as u8;
self.section(0x100, &pmt);
}
fn pes(&mut self, pid: u16, data: &[u8]) {
let mut rest = data;
let mut first = true;
while !rest.is_empty() {
let n = self.packet(pid, first, rest);
rest = &rest[n..];
first = false;
}
}
}
fn crc32_mpeg(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc ^= (b as u32) << 24;
for _ in 0..8 {
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
}
}
crc
}
/// Dual MPEG audio in one transport stream: import used to keep only the first PID.
#[test]
fn every_audio_pid_is_a_stream() {
let mut mux = TsMux { out: Vec::new(), cc: Default::default() };
mux.psi(&[(0x03, 0x1100), (0x03, 0x1101)]);
for k in 0..4u16 {
mux.pes(0x1100, &pes(0xC0, Some(90_000 + 2160 * k as i64), &mp2_frame(k as u8)));
mux.pes(0x1101, &pes(0xC1, Some(90_000 + 2160 * k as i64), &mp2_frame(k as u8 + 16)));
}
let source = MpegSource::open("dual.ts", mux.out.into()).unwrap();
assert_eq!(source.info().audio_streams.len(), 2, "{:?}", source.info().audio_streams);
assert_eq!(source.info().audio_streams.iter().map(|a| a.sample_rate).collect::<Vec<_>>(), vec![48_000, 48_000]);
let a0 = source.audio(0, 64, 48_000).unwrap();
let a1 = source.audio_stream(1, 0, 64, 48_000).unwrap();
assert_eq!(a0.channels.len(), 2);
assert_eq!(a1.channels.len(), 2);
assert!(source.audio_stream(2, 0, 64, 48_000).is_err());
}
#[test]
fn presentation_drops_leading_pictures_and_orders_by_key() {
// decode order I(2) B(0) B(1) P(5) B(3) B(4): the leading B pictures are not presented

View File

@ -229,6 +229,37 @@ fn matroska_prores_pcm() {
check_mkv("blue_prores_pcm.mkv", &["-c:v", "prores_ks", "-profile:v", "2"], &["-c:a", "pcm_s16le"]);
}
/// `bytes` with its first `DefaultDuration` element (0x23E383) overwritten by an EBML Void of the
/// same length, as a muxer that leaves the optional element out would write the track.
fn without_default_duration(bytes: &[u8]) -> Vec<u8> {
let mut out = bytes.to_vec();
let at = out.windows(3).position(|w| w == [0x23, 0xE3, 0x83]).expect("DefaultDuration");
// ffmpeg writes a one-byte size (0x80 | n)
let total = 4 + (out[at + 3] & 0x7F) as usize;
out[at] = 0xEC;
out[at + 1] = 0x80 | (total - 2) as u8;
out[at + 2..at + total].fill(0);
out
}
#[test]
fn matroska_reordered_video_without_default_duration_keeps_its_rate() {
// blocks are in decode order, so B-frames put file neighbours 2–3 frames apart (#923)
for (bf, name) in [(0, "rate25_bf0.mkv"), (1, "rate25_bf1.mkv"), (2, "rate25_bf2.mkv")] {
let params = format!("bframes={bf}:b-adapt=0:b-pyramid=0:keyint=250:scenecut=0");
let args =
["-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "25", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", &params, "-an"];
let Some(control) = fixture(name, &args) else { return };
let stripped = without_default_duration(&control);
for (label, bytes) in [("with DefaultDuration", control.to_vec()), ("without", stripped)] {
let src = crate::open_bytes(name, bytes.into()).expect("open");
let rate = src.info().video.as_ref().expect("video").frame_rate;
assert_eq!((rate.num, rate.den), (25, 1), "{bf} B-frames, {label}");
assert_eq!(src.info().duration, Tick::from_rational(1, 1, 1), "{bf} B-frames, {label}");
}
}
}
/// Path of a fixture under `target/fixtures/codecs/`, generated with ffmpeg when missing
/// (`None` when ffmpeg or the encoder is unavailable).
fn fixture_path(name: &str, args: &[&str]) -> Option<PathBuf> {

View File

@ -140,11 +140,14 @@ pub(crate) fn tight_plane<T: Copy>(src: Vec<T>, stride: usize, w: usize, h: usiz
}
/// Colour of an H.264 / HEVC picture from its VUI code points (ITU-T H.273), as the decoders
/// report it: BT.709 by default with the matrix guessed from the size, the signalled matrix and
/// transfer when known, and full range when flagged. Shared by the software and hardware
/// decoders so the two are interchangeable.
pub fn vui_color(width: u32, height: u32, matrix: u8, transfer: u8, full_range: bool) -> filmcraft_color::ColorInfo {
/// report it: BT.709 by default with the matrix guessed from the size, the signalled primaries,
/// matrix and transfer when known, and full range when flagged. Shared by the software and
/// hardware decoders so the two are interchangeable.
pub fn vui_color(width: u32, height: u32, primaries: u8, matrix: u8, transfer: u8, full_range: bool) -> filmcraft_color::ColorInfo {
let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(width, height), ..filmcraft_color::ColorInfo::REC709 };
if let Some(p) = primaries_from_code(primaries) {
color.primaries = p;
}
if let Some(m) = filmcraft_color::Matrix::from_code(matrix) {
color.matrix = m;
}
@ -250,7 +253,7 @@ impl H264Decoder {
let y = tight_plane(p.y, p.y_stride, w, h);
let u = tight_plane(p.u, p.uv_stride, cw, ch);
let v = tight_plane(p.v, p.uv_stride, cw, ch);
let color = vui_color(p.width, p.height, p.color.matrix, p.color.transfer, p.color.full_range);
let color = vui_color(p.width, p.height, p.color.primaries, p.color.matrix, p.color.transfer, p.color.full_range);
let par = sar_par(p.sar);
let frame = VideoFrame {
width: p.width,
@ -371,7 +374,7 @@ impl HevcDecoder {
alpha: None,
},
};
let color = vui_color(p.width, p.height, p.color.matrix, p.color.transfer, p.color.full_range);
let color = vui_color(p.width, p.height, p.color.primaries, p.color.matrix, p.color.transfer, p.color.full_range);
let par = sar_par(p.sar);
let frame = VideoFrame { width: p.width, height: p.height, data, color, par, pts: filmcraft_time::Tick::ZERO };
DecodedFrame { pts: p.pts, frame, draft: p.draft }

View File

@ -53,12 +53,18 @@ fn eac3_in_mp4_matroska_and_transport_streams() {
};
let ours = src.audio(start, n, 48_000).unwrap().channels;
assert_eq!(ours.len(), ch, "{name}");
// Matroska stores CodecDelay (the encoder's 256-sample priming) in ns and timestamps in
// ms, and we place packets at the rounded value (240 samples): a shared Matroska timing
// limit (AAC and AC-3 alike), not a decoding one. Elsewhere the samples line up exactly.
let (worst, lag) = (-32..=32).map(|lag| (snr(&ours, &want, ch, lag), lag)).fold((f64::MIN, 0), |a, b| if b.0 > a.0 { b } else { a });
let max_lag = if name.ends_with(".mkv") { 16 } else { 0 };
assert!(lag.abs() <= max_lag, "{name}: decode is {lag} samples off ffmpeg's");
// Matroska stores the encoder's 256-sample priming as CodecDelay, which we trim exactly
// (checked to the sample against the encoded signal in `matroska_audio_oracle`). Whether
// ffmpeg's CLI trims it too depends on its version (9.x does; 2023-24 builds keep the
// priming samples), so for .mkv its decode may start 256 samples earlier than ours.
// Elsewhere the samples line up exactly, whatever the version.
let lags: Vec<i64> = match name.ends_with(".mkv") {
true => (-32..=32).chain(-256 - 32..=-256 + 32).collect(),
false => (-32..=32).collect(),
};
let (worst, lag) = lags.into_iter().map(|lag| (snr(&ours, &want, ch, lag), lag)).fold((f64::MIN, 0), |a, b| if b.0 > a.0 { b } else { a });
let ok = if name.ends_with(".mkv") { lag == 0 || lag == -256 } else { lag == 0 };
assert!(ok, "{name}: decode is {lag} samples off ffmpeg's");
// a window read on a fresh source (random access) gives the same samples
let fresh = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap();
let at = n / 3;

View File

@ -0,0 +1,61 @@
//! Decoded H.264 / HEVC frames carry the colour primaries their VUI signals (#924). ffmpeg writes
//! MP4 without a `colr` box unless asked, so for most files the bitstream is the only place the
//! primaries are described: stream info already read them, the decoded frames reported BT.709.
mod common;
use common::*;
use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer};
use filmcraft_media::FrameRequest;
use filmcraft_time::Tick;
/// One 64×64 frame of `codec` with the colour `params` (x264 / x265 syntax), and our stream and
/// frame colour for it.
fn colors(ff: &std::path::Path, name: &str, codec: &[&str]) -> Option<(ColorInfo, ColorInfo)> {
let mut args = vec!["-f", "lavfi", "-i", "testsrc2=size=64x64:rate=1", "-frames:v", "1"];
args.extend_from_slice(codec);
let path = fixture(ff, name, &args)?;
let isobmff = filmcraft_isobmff::open(bytes(&path)).unwrap();
let track = isobmff.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).unwrap();
assert!(track.entries[0].video.as_ref().unwrap().color.is_none(), "{name}: the fixture has a colr box");
let src = filmcraft_codecs::open_bytes(name, bytes(&path)).unwrap();
let stream = src.info().video.as_ref().unwrap().color;
let frame = src.video_frame(FrameRequest::full(Tick::ZERO)).unwrap().color;
Some((stream, frame))
}
#[test]
fn decoded_frames_keep_the_signalled_primaries() {
let ff = filmcraft_testkit::require_ffmpeg!();
let x264 = |p: &'static str| ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", p];
let x265 = |p: &'static str, pix: &'static str| ["-c:v", "libx265", "-pix_fmt", pix, "-tag:v", "hvc1", "-x265-params", p];
let sdr2020 = ColorInfo { matrix: Matrix::Bt709, transfer: Transfer::Bt709, primaries: Primaries::Bt2020, range: Range::Limited };
let cases: [(&str, Vec<&str>, ColorInfo, ColorSpace); 4] = [
("prim2020_h264.mp4", x264("colorprim=bt2020:transfer=bt709:colormatrix=bt709").to_vec(), sdr2020, ColorSpace::Rec2020),
("prim2020_hevc.mp4", x265("log-level=error:colorprim=bt2020:transfer=bt709:colormatrix=bt709", "yuv420p").to_vec(), sdr2020, ColorSpace::Rec2020),
// control: BT.709 throughout
(
"prim709_h264.mp4",
x264("colorprim=bt709:transfer=bt709:colormatrix=bt709").to_vec(),
ColorInfo { primaries: Primaries::Bt709, ..sdr2020 },
ColorSpace::Rec709,
),
// HDR keeps its transfer and now its primaries too
(
"prim2020_pq_hevc.mp4",
x265("log-level=error:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc", "yuv420p10le").to_vec(),
ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Pq, primaries: Primaries::Bt2020, range: Range::Limited },
ColorSpace::Rec2100Pq,
),
];
for (name, codec, want, space) in cases {
let Some((stream, frame)) = colors(&ff, name, &codec) else {
eprintln!("SKIPPED ({name}): this ffmpeg can't encode it");
continue;
};
println!("{name}: stream {stream:?}, frame {frame:?}");
assert_eq!(stream, want, "{name}: stream");
assert_eq!(frame, want, "{name}: decoded frame");
assert_eq!(ColorSpace::from_info(&frame), space, "{name}");
}
}

View File

@ -48,3 +48,100 @@ fn matroska_audio_plays_without_holes() {
assert!(r_ours < 2.0 * r_theirs + 0.005, "{name}: clicks between packets (|Δ²| {r_ours} vs ffmpeg's {r_theirs})");
}
}
/// Mean |difference| between `ours[i]` and `theirs[i + lag]` over `from..from + len`.
fn mean_err(ours: &[f32], theirs: &[f32], from: usize, len: usize, lag: i64) -> f64 {
(from..from + len).map(|i| (ours[i] as f64 - theirs[(i as i64 + lag) as usize] as f64).abs()).sum::<f64>() / len as f64
}
/// The lag in `lags` at which `ours` best matches `theirs` (least [`mean_err`]), and that error.
fn best_lag(ours: &[f32], theirs: &[f32], from: usize, len: usize, lags: std::ops::RangeInclusive<i64>) -> (i64, f64) {
lags.map(|lag| (lag, mean_err(ours, theirs, from, len, lag))).min_by(|a, b| a.1.total_cmp(&b.1)).unwrap()
}
/// A reference signal we control: low-passed white noise from a fixed xorshift seed. Its
/// autocorrelation dies out within a few samples, so even a one-sample misalignment shows.
fn noise(n: usize) -> Vec<f32> {
let (mut state, mut y) = (0x9E37_79B9_7F4A_7C15u64, 0f32);
(0..n)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let white = (state >> 40) as f32 / (1u64 << 24) as f32 - 0.5;
y = 0.5 * y + 0.4 * white;
y
})
.collect()
}
/// #790: ffmpeg stores the encoder priming as `CodecDelay` (1024 samples = 21.333 ms for AAC at
/// 48 kHz, 256 = 5.805 ms for AC-3 at 44.1 kHz, 1105 = 25.057 ms for MP3), and block timestamps to
/// the millisecond. Taking the rounded delay as the start of the first packet played the whole
/// track up to 16 samples late.
///
/// The strict check is version-independent: ffmpeg encodes a signal we generate, and our decode
/// must line up with that signal to the sample (the stream's `CodecDelay` trimmed exactly). ffmpeg's
/// own decode is compared only after a bounded best-lag search: how its CLI handles Matroska
/// priming depends on the version (9.x trims `CodecDelay`, 2023-24 builds keep the priming
/// samples), so its lag is reported, not asserted.
#[test]
fn matroska_audio_is_sample_aligned_with_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
let cases: [(&str, u32, &[&str]); 6] = [
("ref_aac_48k.mkv", 48_000, &["-c:a", "aac", "-b:a", "256k"]),
("ref_aac_44k.mkv", 44_100, &["-c:a", "aac", "-b:a", "256k"]),
("ref_ac3_44k.mkv", 44_100, &["-c:a", "ac3", "-b:a", "448k"]),
("ref_ac3_48k.mkv", 48_000, &["-c:a", "ac3", "-b:a", "448k"]),
("ref_eac3_48k.mkv", 48_000, &["-c:a", "eac3", "-b:a", "384k"]),
("ref_mp3_44k.mkv", 44_100, &["-c:a", "libmp3lame", "-b:a", "320k"]),
];
for (name, rate, codec) in cases {
let reference = noise(rate as usize * 2);
let raw = format!("ref_noise_{rate}.f32");
let raw_path = filmcraft_testkit::fixtures::generate(&dir().join(&raw), |tmp| {
std::fs::write(tmp, reference.iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>()).is_ok()
})
.unwrap();
let (raw_arg, rate_arg) = (raw_path.to_str().unwrap().to_string(), rate.to_string());
let args: Vec<&str> = ["-f", "f32le", "-ar", &rate_arg, "-ac", "1", "-i", &raw_arg].iter().copied().chain(codec.iter().copied()).collect();
let Some(path) = fixture(&ff, name, &args) else {
eprintln!("SKIPPED ({name}): this ffmpeg can't encode it");
continue;
};
let file = bytes(&path);
let mkv = filmcraft_matroska::open(file.clone()).unwrap();
let delay_ns = mkv.tracks.first().map_or(0, |t| t.codec_delay_ns) as i64;
let delay = (delay_ns * rate as i64 + 500_000_000) / 1_000_000_000;
assert!(delay > 0, "{name}: no CodecDelay");
let src = filmcraft_codecs::open_bytes(name, file).unwrap();
let frames = rate as usize * 3 / 2;
let mut ours = Vec::with_capacity(frames);
while ours.len() < frames {
let n = 1000.min(frames - ours.len());
ours.extend_from_slice(&src.audio(ours.len() as i64, n, rate).unwrap().channels[0]);
}
let (from, len, bound) = (rate as usize / 4, rate as usize / 4, delay + 32);
// strict: the encoder's input, to the sample
let (lag, err) = best_lag(&ours, &reference, from, len, -bound..=bound);
let level = reference[from..from + len].iter().map(|v| v.abs() as f64).sum::<f64>() / len as f64;
assert_eq!(lag, 0, "{name}: our decode is {lag} samples off the encoded signal (CodecDelay {delay} samples)");
assert!(err < 0.25 * level, "{name}: decode error {err:.2e} against a signal level of {level:.2e}");
// ffmpeg's decode, wherever its version puts the priming
let theirs = ffmpeg_audio_f32(&ff, &path, &[]);
assert!(theirs.len() >= frames, "{name}: ffmpeg decoded {} samples", theirs.len());
let (ff_lag, ff_err) = best_lag(&ours, &theirs, from, len, -bound..=bound);
let priming = match ff_lag {
0 => "trims CodecDelay".to_string(),
l if l == delay => "keeps the priming samples".to_string(),
l => format!("is {l} samples off"),
};
println!("{name}: CodecDelay {delay} samples; vs the encoded signal lag 0, error {err:.2e} (level {level:.2e}); ffmpeg {priming}, error {ff_err:.2e}");
assert!(ff_err < 1e-3, "{name}: differs from ffmpeg's decode by {ff_err:.2e} at its best lag {ff_lag}");
// read from the middle, as a seek does
let at = rate as i64 / 2;
let mid = src.audio(at, 2000, rate).unwrap().channels[0].clone();
let diff = mid.iter().zip(&ours[at as usize..]).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max);
assert!(diff < 1e-3, "{name}: a read from {at} differs from continuous decoding by {diff}");
}
}

View File

@ -171,6 +171,98 @@ fn dpx_stills_every_layout() {
}
}
/// A big-endian DPX file holding `data` as its one image element (`w` × `h`, the given
/// descriptor, bit depth, packing and colorimetric code; reference codes undefined).
fn dpx_file(w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, colorimetric: u8, data: &[u8]) -> Vec<u8> {
let mut f = vec![0u8; 2048];
let put = |f: &mut Vec<u8>, at: usize, v: &[u8]| f[at..at + v.len()].copy_from_slice(v);
put(&mut f, 0, b"SDPX");
put(&mut f, 4, &2048u32.to_be_bytes());
put(&mut f, 8, b"V2.0");
put(&mut f, 16, &(2048 + data.len() as u32).to_be_bytes());
put(&mut f, 770, &1u16.to_be_bytes());
put(&mut f, 772, &w.to_be_bytes());
put(&mut f, 776, &h.to_be_bytes());
let el = 780;
put(&mut f, el + 4, &u32::MAX.to_be_bytes());
put(&mut f, el + 12, &u32::MAX.to_be_bytes());
f[el + 20] = descriptor;
f[el + 22] = colorimetric;
f[el + 23] = bits;
put(&mut f, el + 24, &packing.to_be_bytes());
put(&mut f, el + 28, &2048u32.to_be_bytes());
f.extend_from_slice(data);
f
}
/// `pix_fmt` raw frame of ffmpeg's test picture at `w` × `h`.
fn raw_picture(ff: &Path, w: u32, h: u32, pix_fmt: &str) -> Vec<u8> {
let alpha = if pix_fmt.contains('a') { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" };
let vf = format!("scale={w}:{h}{alpha}");
ffmpeg_out(ff, &["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-f", "rawvideo", "-pix_fmt", pix_fmt, "-"])
}
/// A DPX file we built must import exactly like ffmpeg decodes it, within `tol` of its 16-bit
/// decode rounded to 8 bits.
fn dpx_matches(ff: &Path, name: &str, file: Vec<u8>, tol: u8) {
let path = dir().join(name);
std::fs::create_dir_all(dir()).unwrap();
std::fs::write(&path, &file).unwrap();
let src = filmcraft_codecs::open_bytes(name, file.into()).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(src.info().kind, MediaKind::Still, "{name}");
let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap());
let wide = ffmpeg_out(ff, &["-i", path.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]);
let want: Vec<u8> = wide.as_chunks::<2>().0.iter().map(|c| ((u32::from(u16::from_le_bytes(*c)) * 255 + 32767) / 65535) as u8).collect();
assert_eq!(ours.len(), want.len(), "{name}");
let d = ours.iter().zip(&want).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(d <= tol, "{name}: differs by {d}");
eprintln!("{name}: max diff {d}");
}
/// Packed 12-bit RGB (packing 0: components back to back, low bits first, lines on 32-bit
/// boundaries) and 8-bit Y'CbCr (4:2:2, 4:4:4, 4:4:4:4 with alpha; BT.709, BT.601 and unspecified
/// colorimetry), layouts ffmpeg reads but does not write, built here from ffmpeg's test picture
/// (#841).
#[test]
fn dpx_packed_and_ycbcr_match_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
let (w, h) = (95u32, 63u32);
let rgb: Vec<u32> = raw_picture(&ff, w, h, "rgb48le").as_chunks::<2>().0.iter().map(|c| u32::from(u16::from_le_bytes(*c)) >> 4).collect();
let mut data = Vec::new();
for line in rgb.chunks(w as usize * 3) {
let (mut acc, mut n) = (0u64, 0u32);
for &v in line {
acc |= u64::from(v) << n;
n += 12;
while n >= 32 {
data.extend_from_slice(&(acc as u32).to_be_bytes());
acc >>= 32;
n -= 32;
}
}
if n > 0 {
data.extend_from_slice(&(acc as u32).to_be_bytes());
}
}
dpx_matches(&ff, "dpx_packed12.dpx", dpx_file(w, h, 50, 12, 0, 6, &data), 1);
// Y'CbCr: even width, scan lines a multiple of 4 bytes
let (w, h) = (96u32, 64u32);
let px = (w * h) as usize;
let uyvy = raw_picture(&ff, w, h, "uyvy422");
let yuv = raw_picture(&ff, w, h, "yuv444p");
let yuva = raw_picture(&ff, w, h, "yuva444p");
let interleave = |planes: &[u8], order: &[usize]| -> Vec<u8> { (0..px).flat_map(|i| order.iter().map(move |&p| planes[p * px + i])).collect() };
// planes are Y, U (Cb), V (Cr), A; DPX stores Cb Y Cr A
let cbycr = interleave(&yuv, &[1, 0, 2]);
let cbycra = interleave(&yuva, &[1, 0, 2, 3]);
for colorimetric in [6u8, 7, 0] {
dpx_matches(&ff, &format!("dpx_cbycry_{colorimetric}.dpx"), dpx_file(w, h, 100, 8, 0, colorimetric, &uyvy), 1);
dpx_matches(&ff, &format!("dpx_cbycr_{colorimetric}.dpx"), dpx_file(w, h, 102, 8, 0, colorimetric, &cbycr), 1);
}
dpx_matches(&ff, "dpx_cbycra.dpx", dpx_file(w, h, 103, 8, 0, 6, &cbycra), 1);
}
#[test]
fn missing_frames_hold_the_previous_frame() {
let ff = filmcraft_testkit::require_ffmpeg!();

View File

@ -0,0 +1,69 @@
//! #925: a chunk after an RF64 `data` sentinel must not become audio.
use std::sync::Arc;
use filmcraft_time::Tick;
fn chunk(id: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut out = id.to_vec();
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(body);
if body.len() % 2 == 1 {
out.push(0);
}
out
}
/// Four 16-bit mono frames of silence at 48 kHz, as RIFF or RF64, optionally with a trailing iXML.
fn fixture(rf64: bool, trailing: bool) -> Vec<u8> {
let mut fmt = Vec::new();
for v in [1u16, 1] {
fmt.extend_from_slice(&v.to_le_bytes());
}
fmt.extend_from_slice(&48_000u32.to_le_bytes());
fmt.extend_from_slice(&96_000u32.to_le_bytes());
fmt.extend_from_slice(&2u16.to_le_bytes());
fmt.extend_from_slice(&16u16.to_le_bytes());
let mut out = if rf64 { b"RF64\xff\xff\xff\xffWAVE".to_vec() } else { b"RIFF\0\0\0\0WAVE".to_vec() };
if rf64 {
let mut ds = vec![0u8; 28];
ds[8..16].copy_from_slice(&8u64.to_le_bytes());
ds[16..24].copy_from_slice(&4u64.to_le_bytes());
out.extend(chunk(b"ds64", &ds));
}
out.extend(chunk(b"fmt ", &fmt));
let offset = out.len();
out.extend(chunk(b"data", &[0u8; 8]));
if rf64 {
out[offset + 4..offset + 8].copy_from_slice(&u32::MAX.to_le_bytes());
}
if trailing {
out.extend(chunk(b"iXML", b"<BWFXML></BWFXML>"));
}
let size = out.len() - 8;
if rf64 {
out[20..28].copy_from_slice(&(size as u64).to_le_bytes());
} else {
out[4..8].copy_from_slice(&(size as u32).to_le_bytes());
}
out
}
#[test]
fn rf64_metadata_stays_outside_audio() {
for (name, rf64, trailing) in [("rf64.wav", true, false), ("riff_ixml.wav", false, true), ("rf64_ixml.wav", true, true)] {
let bytes: Arc<[u8]> = fixture(rf64, trailing).into();
let src = filmcraft_codecs::open_bytes(name, bytes.clone()).unwrap();
let stream = filmcraft_media::reader::open_reader(
name,
Arc::new(filmcraft_media::reader::MemReader(bytes)),
&filmcraft_codecs::reader_openers(),
&filmcraft_codecs::openers(),
)
.unwrap();
for s in [&src, &stream] {
assert_eq!(s.info().duration, Tick::from_units(4, 48_000), "{name}");
assert_eq!(s.audio(0, 20, 48_000).unwrap().channels.remove(0), vec![0.0; 20], "{name}");
}
}
}

View File

@ -20,7 +20,7 @@ pub use grade::GradeSpace;
pub use log::LogCurve;
pub use lut::{Lut, Lut1d, Lut3d, LutFormat};
pub use spaces::{ColorPipeline, ColorSpace, Curve, Gamut, WorkingSpace};
pub use transform::{DecodeTable, InputTransform, OutputTransform, REFERENCE_WHITE_NITS, ToneMap};
pub use transform::{DecodeTable, GpuCurve, GpuInputParams, InputTransform, OutputTransform, REFERENCE_WHITE_NITS, ToneMap};
/// YUV ↔ RGB matrix coefficients.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]

View File

@ -186,7 +186,7 @@ impl LutFormat {
}
fn floats(line: &str) -> Result<Vec<f32>, String> {
line.split_whitespace().map(|x| x.parse::<f32>().map_err(|_| format!("not a number: {x:?}"))).collect()
line.split_whitespace().map(|x| x.parse::<f32>().ok().filter(|v| v.is_finite()).ok_or_else(|| format!("not a finite number: {x:?}"))).collect()
}
impl Lut {
@ -252,6 +252,9 @@ impl Lut {
if v.len() != 2 {
return Err(err(&format!("{key} needs 2 values")));
}
if v[1] <= v[0] {
return Err(err(&format!("{key} maximum must exceed its minimum")));
}
let r = Some([v[0], v[1]]);
if key == "LUT_1D_INPUT_RANGE" { range1 = r } else { range3 = r }
}
@ -591,6 +594,16 @@ mod tests {
assert!(Lut::parse_cube("TITLE \"x\"\n").is_err());
assert!(Lut::parse_cube("LUT_3D_SIZE 2\n0 0 0\nLUT_1D_SIZE 2\n").is_err());
assert!(Lut::parse_cube("LUT_3D_SIZE 2\nDOMAIN_MIN 1 1 1\nDOMAIN_MAX 0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n").is_err());
for key in ["LUT_1D_INPUT_RANGE", "LUT_3D_INPUT_RANGE"] {
let (size, rows) = if key.starts_with("LUT_1D") { ("LUT_1D_SIZE 2", 2) } else { ("LUT_3D_SIZE 2", 8) };
let table = "0 0 0\n".repeat(rows);
for range in ["0 0", "1 0", "NaN 1", "0 inf"] {
assert!(Lut::parse_cube(&format!("{size}\n{key} {range}\n{table}")).is_err(), "accepted {key} {range}");
}
assert!(Lut::parse_cube(&format!("{size}\n{key} -0.5 2\n{table}")).is_ok());
}
assert!(Lut::parse_cube("LUT_1D_SIZE 2\n0 0 0\nNaN 1 1\n").is_err());
assert!(Lut::parse_cube("LUT_1D_SIZE 2\nDOMAIN_MAX inf 1 1\n0 0 0\n1 1 1\n").is_err());
// comments, blank lines, Windows newlines, inline comments
let txt = "# hi\r\nTITLE \"t\"\r\n\r\nLUT_3D_SIZE 2 # two\r\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
let l = Lut3d::parse_cube(txt).unwrap();

View File

@ -259,6 +259,54 @@ impl InputTransform {
pub fn convert(&self, rgb: [f32; 3]) -> [f32; 3] {
self.apply(rgb.map(|v| self.table.lookup(v)))
}
/// The transform as plain numbers for a shader, which evaluates the curve and the BT.2390
/// EETF directly instead of sampling the tables. `None` for what a shader does not do: log
/// curves and the camera-gamut compression.
pub fn gpu_params(&self) -> Option<GpuInputParams> {
let curve = match self.source.curve() {
Curve::Sdr | Curve::Srgb => GpuCurve::Sdr,
Curve::Linear => GpuCurve::Linear,
Curve::Pq => GpuCurve::Pq,
Curve::Hlg => GpuCurve::Hlg,
Curve::Log(_) => return None,
};
if self.compress {
return None;
}
Some(GpuInputParams {
curve,
hlg: self.hlg,
matrix: (!self.identity_matrix).then_some(self.matrix),
tone: self.tone.as_ref().map(|t| (t.src_peak_nits as f32, t.dst_peak_nits as f32)),
desaturate: self.desaturate,
})
}
}
/// How a shader decodes a channel ([`decode_channel`] without the table): SDR with the sRGB curve,
/// linear, PQ in working units (cd/m² over [`REFERENCE_WHITE_NITS`]), HLG to scene light.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GpuCurve {
Sdr,
Linear,
Pq,
Hlg,
}
/// [`InputTransform`] for a shader ([`InputTransform::gpu_params`]), stage by stage as
/// [`InputTransform::apply`] runs them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GpuInputParams {
pub curve: GpuCurve,
/// HLG OOTF: BT.2020 luma weights and the scale to working units.
pub hlg: Option<([f32; 3], f32)>,
/// Into the working primaries (row-major), when they differ.
pub matrix: Option<[[f32; 3]; 3]>,
/// BT.2390 tone mapping (source peak, target peak) in cd/m², on max(R,G,B) as a ratio.
pub tone: Option<(f32, f32)>,
/// Luma weights to desaturate into the working gamut with ([`desaturate_into_gamut`]).
pub desaturate: Option<[f32; 3]>,
}
/// Whether gamut `a` lies inside gamut `b` (so no compression is needed going a → b).
@ -462,6 +510,25 @@ mod tests {
}
}
#[test]
fn gpu_params_cover_display_referred_media_only() {
// HLG / PQ / BT.2020 into Rec. 709: every stage the shader has (#965)
let p = |cs| InputTransform::new(cs, Range::Limited, &ColorPipeline::REC709, None).gpu_params();
let hlg = p(ColorSpace::Rec2100Hlg).unwrap();
assert_eq!(hlg.curve, GpuCurve::Hlg);
assert!(hlg.hlg.is_some() && hlg.matrix.is_some() && hlg.desaturate.is_some());
assert_eq!(hlg.tone, Some((HDR_PEAK_NITS as f32, REFERENCE_WHITE_NITS as f32)));
let pq = InputTransform::new(ColorSpace::Rec2100Pq, Range::Limited, &ColorPipeline::REC709, Some(4000.0)).gpu_params().unwrap();
assert_eq!((pq.curve, pq.hlg, pq.tone), (GpuCurve::Pq, None, Some((4000.0, REFERENCE_WHITE_NITS as f32))));
let sdr2020 = p(ColorSpace::Rec2020).unwrap();
assert_eq!((sdr2020.curve, sdr2020.tone), (GpuCurve::Sdr, None));
assert!(sdr2020.matrix.is_some() && sdr2020.desaturate.is_some());
// log curves and camera-gamut compression stay on the CPU
for cs in ColorSpace::ALL.into_iter().filter(|c| c.is_log()) {
assert_eq!(p(cs), None, "{cs:?}");
}
}
#[test]
fn sdr_in_hdr_keeps_values_and_display_tone_maps() {
let pipe = hdr(WorkingSpace::Rec2100Pq);

View File

@ -247,6 +247,15 @@ fn track_mut(seq: &mut Sequence, id: TrackId) -> Result<&mut Track> {
seq.track_mut(id).ok_or(EditError::NoTrack(id))
}
/// The clip's track, or [`EditError::Locked`] when that track may not change.
fn unlocked_track(seq: &Sequence, clip: ClipId) -> Result<TrackId> {
let (tid, _) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
if seq.track(tid).ok_or(EditError::NoTrack(tid))?.locked {
return Err(EditError::Locked);
}
Ok(tid)
}
/// Overwrite edits: each item replaces material on its track.
pub fn overwrite(seq: &mut Sequence, placements: Vec<(TrackId, TrackItem)>, ctx: &mut EditCtx) -> Result<Vec<ClipId>> {
let mut links = HashMap::new();
@ -633,7 +642,8 @@ fn neighbours(track: &Track, id: ClipId) -> (Tick, Tick) {
/// Compute the clamped delta a trim may apply (for UI feedback while dragging).
pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, ctx: &EditCtx) -> Result<Tick> {
let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
let tid = unlocked_track(seq, clip)?;
let (_, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?;
let (prev_end, next_start) = neighbours(tr, clip);
let media = media_len(ctx, it);
@ -703,6 +713,7 @@ pub fn move_edge(it: &mut TrackItem, edge: Edge, d: Tick, move_start: bool) {
/// Trim one edge of an item (Selection-tool edge drag = Regular; Ripple tool = Ripple).
/// Linked partners should be trimmed by the caller with the same delta.
pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
unlocked_track(seq, clip)?;
let d = clamp_trim(seq, clip, edge, mode, delta, ctx)?;
if d == Tick::ZERO {
return Ok(d);
@ -759,6 +770,9 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
/// overwrite material on a sync-locked track, or when such a linked clip has no room to follow.
pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
let Some(&first) = clips.first() else { return Ok(Tick::ZERO) };
for c in clips {
unlocked_track(seq, *c)?;
}
let mut d = delta;
for c in clips {
let x = clamp_trim(seq, *c, edge, TrimMode::Ripple, d, ctx)?;
@ -861,6 +875,8 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta
/// Rolling edit between two adjacent items on one track: moves the cut by `delta`.
pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
unlocked_track(seq, left)?;
unlocked_track(seq, right)?;
let (_, l) = seq.find_item(left).ok_or(EditError::NoItem(left))?;
let (_, r) = seq.find_item(right).ok_or(EditError::NoItem(right))?;
if l.end() != r.start {
@ -908,6 +924,7 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
/// Slip: change which part of the media an item shows without moving it.
pub fn slip(seq: &mut Sequence, clip: ClipId, delta_media: Tick, ctx: &mut EditCtx) -> Result<Tick> {
unlocked_track(seq, clip)?;
let (_, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
let used = src_of(it.duration, it.speed);
let max_in = media_len(ctx, it).map(|m| m - used).unwrap_or(Tick::MAX);
@ -921,6 +938,7 @@ pub fn slip(seq: &mut Sequence, clip: ClipId, delta_media: Tick, ctx: &mut EditC
/// Slide: move an item between its neighbours, trimming them to keep the gapless span.
pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
unlocked_track(seq, clip)?;
let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
let it = it.clone();
let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?;
@ -1001,6 +1019,7 @@ pub fn slide_items(seq: &mut Sequence, clips: &[ClipId], delta: Tick, ctx: &mut
/// Rate stretch: change an item's duration by dragging an edge, adjusting speed to keep the same media.
pub fn rate_stretch(seq: &mut Sequence, clip: ClipId, edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result<f64> {
unlocked_track(seq, clip)?;
let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?;
let (prev_end, next_start) = neighbours(tr, clip);
@ -1038,6 +1057,9 @@ pub fn set_speed_group(seq: &mut Sequence, clips: &[ClipId], speed: f64, reverse
if !(speed.is_finite() && speed > 0.0) {
return Err(EditError::Other("speed must be positive".into()));
}
for clip in clips {
unlocked_track(seq, *clip)?;
}
let mut work = seq.clone();
// per member track: where its later material starts, and by how much the member's length changed
let mut origins: Vec<(TrackId, Tick, Tick)> = Vec::new();

View File

@ -274,6 +274,17 @@ fn regular_trim_respects_neighbours_and_handles() {
fx.seq.check().unwrap();
}
#[test]
fn trim_on_a_locked_track_is_refused() {
let mut fx = Fx::new();
let v1 = fx.v(0);
let a = fx.put(v1, 0, 100, 0);
fx.seq.track_mut(v1).unwrap().locked = true;
let mut n = fx.next;
assert_eq!(trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, -f(20), &mut Fx::ctx(&mut n)), Err(EditError::Locked));
assert_eq!(fx.spans(v1), vec![(0, 100)]);
}
#[test]
fn held_regular_in_trim_stops_at_previous_clip() {
let mut fx = Fx::new();

View File

@ -46,4 +46,5 @@ web-time = "1"
[dev-dependencies]
filmcraft-testkit = { workspace = true }
filmcraft-isobmff = { workspace = true }
image = { version = "0.25", default-features = false, features = ["png"] }

View File

@ -168,6 +168,32 @@ fn missing_audio_tracks_are_cloned_from_the_destination() {
assert_eq!(again, ids);
}
/// #823: a container with two PCM tracks imports both streams, and overwrite places linked clips.
#[test]
fn import_keeps_every_audio_stream_and_places_linked_clips() {
use filmcraft_isobmff::{Brand, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
for (rate, value) in [(48_000u32, 8192i16), (24_000, -16384)] {
let entry = SampleEntry::pcm(PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 1, sample_rate: rate as f64 });
let track = mux.add_track(TrackConfig::new(entry, rate)).unwrap();
let data: Vec<u8> = (0..rate / 10).flat_map(|_| value.to_le_bytes()).collect();
mux.write_sample(track, WriteSample { data: &data, duration: rate / 10, composition_offset: 0, is_sync: true }).unwrap();
}
let bytes = mux.finish().unwrap().into_inner();
let mut s = Session::default();
let item = crate::commands::import_bytes(&mut s, "/two-audio.mov", bytes.into(), None).unwrap();
let streams = s.project.item(item).unwrap().as_media().unwrap().info.audio_streams.len();
assert_eq!(streams, 2);
s.execute("file.newSequence", json!({"name": "S", "fromItem": item.0, "video": 1, "audio": 1})).unwrap();
let seq = s.state.active_sequence.unwrap();
let q = s.project.sequence(seq).unwrap();
assert_eq!(q.audio_tracks.len(), 2);
let (a, b) = (&q.audio_tracks[0].items[0], &q.audio_tracks[1].items[0]);
assert_eq!((a.audio_stream, b.audio_stream), (0, 1));
assert_eq!(a.link, b.link);
assert!(a.link.is_some());
}
#[test]
fn hostile_track_counts_are_an_error_not_a_crash() {
let s = demo();

View File

@ -572,15 +572,16 @@ pub fn scan_candidates(data_dir: &Path, skip: Option<&Path>) -> Vec<RecoveryCand
if !dir.is_dir() || Some(dir.as_path()) == skip || !lock_is_stale(&dir) {
continue;
}
let meta = fs::read(dir.join(META)).ok().and_then(|b| serde_json::from_slice::<JournalMeta>(&b).ok());
match meta {
Some(meta) if dir.join(SNAPSHOT).is_file() => {
out.push(RecoveryCandidate { id: e.file_name().to_string_lossy().into_owned(), dir, meta });
}
_ => {
let _ = fs::remove_dir_all(&dir);
}
let snap = dir.join(SNAPSHOT);
if !snap.is_file() {
// Empty leftover dirs (clean exit, or a crash before the first snapshot).
let _ = fs::remove_dir_all(&dir);
continue;
}
// Keep the snapshot even when session.json is missing or unreadable: the project
// is still recoverable. Meta fields fall back to defaults.
let meta = fs::read(dir.join(META)).ok().and_then(|b| serde_json::from_slice::<JournalMeta>(&b).ok()).unwrap_or_default();
out.push(RecoveryCandidate { id: e.file_name().to_string_lossy().into_owned(), dir, meta });
}
out.sort_by(|a, b| b.meta.saved_unix.cmp(&a.meta.saved_unix).then(b.id.cmp(&a.id)));
out

View File

@ -176,6 +176,28 @@ fn auto_save_ring_rotates_and_can_save_the_project() {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn scan_keeps_snapshot_when_session_metadata_is_unreadable() {
// #894: a stale session with a snapshot must not be deleted because session.json is missing
// or corrupt — the recovered project would vanish from the list and the file would be gone.
let d = temp_dir("recover-nometa");
let root = autosave::recovery_root(&d);
let sess = root.join("123-1");
std::fs::create_dir_all(&sess).unwrap();
let snap = filmcraft_format::encode(&filmcraft_project::Project::default(), false);
std::fs::write(sess.join("snapshot.fcproj"), snap).unwrap();
std::fs::write(sess.join("session.json"), b"not json {").unwrap();
let found = autosave::scan_candidates(&d, None);
assert_eq!(found.len(), 1);
assert_eq!(found[0].id, "123-1");
assert!(sess.join("snapshot.fcproj").is_file(), "snapshot must survive the scan");
std::fs::remove_file(sess.join("session.json")).unwrap();
let found = autosave::scan_candidates(&d, None);
assert_eq!(found.len(), 1, "missing metadata still lists the snapshot");
assert!(sess.join("snapshot.fcproj").is_file());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn portable_marker_keeps_data_next_to_the_executable() {
// #139: a portable install must not write to %APPDATA%.

View File

@ -1715,8 +1715,8 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<Value> {
}
let Some((_, it)) = q.find_item_mut(*c) else { continue };
let src_in = match how {
// the Source monitor frame lines up with the sequence playhead
Replace::MatchFrame => src_ph - Tick(((ph - it.start).0 as f64 * it.speed.abs()).round() as i64),
// the Source monitor frame lines up with the sequence playhead (reverse-aware)
Replace::MatchFrame => src_ph - (it.moving_source_time_at(ph) - it.source_in),
_ if keep_in => {
let kept = keep_within.map(|(first, last)| it.source_in.clamp(first, last)).unwrap_or(it.source_in);
if kept != it.source_in {

View File

@ -606,6 +606,32 @@ fn replace_with_clip_from_source_match_frame_and_bin() {
assert!(s.execute("clip.replaceFromBin", json!({"item": music.0})).is_err());
}
/// Match Frame on a reversed clip maps the Source monitor frame through reverse playback, not as
/// if the clip still ran forward (source 50 at sequence 20 used to become source 69).
#[test]
fn replace_from_source_match_frame_on_a_reversed_clip() {
let mut s = demo();
let rate = s.sequence_rate();
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
let forest = item_named(&s, "Misty_Forest.mp4");
s.execute("file.newSequence", json!({"name": "MatchFrameReverse"})).unwrap();
s.execute("timeline.place", json!({"item": forest.0, "time": 0})).unwrap();
let c = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
s.execute("timeline.trim", json!({"clip": c.id.0, "edge": "out", "mode": "regular", "delta": (rate.tick_of(60) - c.duration).0})).unwrap();
select(&mut s, &[c.id.0]);
s.execute("clip.speedDuration", json!({"speed": 100, "reverse": true, "ripple": false})).unwrap();
let got = clip(&s, c.id.0);
assert_eq!(rate.frame_at(got.duration), 60);
assert!(got.reverse);
s.execute("source.open", json!({"item": forest.0})).unwrap();
s.execute("playhead.set", json!({"time": rate.tick_of(20).0})).unwrap();
s.execute("source.setPlayhead", json!({"frame": 50})).unwrap();
s.execute("clip.replaceFromSourceMatchFrame", json!({})).unwrap();
let got = clip(&s, c.id.0);
assert_eq!(got.source_time_at(rate.tick_of(20)), rate.tick_of(50));
assert_ne!(got.source_time_at(rate.tick_of(20)), rate.tick_of(69));
}
/// Replace With Clip ▸ From Bin restarts the clip at the new item's In point (as Premiere does);
/// `keepSourceIn` keeps the clip's place in the media instead.
#[test]

View File

@ -239,6 +239,15 @@ pub(crate) fn time_p(s: &Session, p: &Value, prefix: &str) -> Option<Tick> {
None
}
/// Sequence time from params. Times before 0 are unreachable in the GUI and cut off on export.
pub(crate) fn sequence_time_p(s: &Session, p: &Value, prefix: &str, cmd: &str) -> Result<Option<Tick>> {
let Some(t) = time_p(s, p, prefix) else { return Ok(None) };
if t < Tick::ZERO {
return Err(bad(cmd, "time must not be negative"));
}
Ok(Some(t))
}
pub(crate) fn clip_p(p: &Value, k: &str) -> Option<ClipId> {
u64_p(p, k).map(ClipId)
}
@ -397,20 +406,75 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
/// most constrained clip (media end or start) still moves, and no linked partner slips further.
pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>, deltas: &[(ClipId, Tick)]) -> Result<()> {
let mut scale = 1.0_f64;
let mut any = false;
for (c, d) in deltas {
if d.0 == 0 {
continue;
}
let x = edit::slip(&mut q.clone(), *c, *d, ctx)?;
scale = scale.min(x.0.abs() as f64 / d.0.abs() as f64);
match edit::slip(&mut q.clone(), *c, *d, ctx) {
Err(edit::EditError::Locked) => continue,
Err(e) => return Err(e.into()),
Ok(x) => {
any = true;
scale = scale.min(x.0.abs() as f64 / d.0.abs() as f64);
}
}
}
if !any && deltas.iter().any(|(_, d)| d.0 != 0) {
return Err(edit::EditError::Locked.into());
}
for (c, d) in deltas {
let dd = if scale < 1.0 { Tick((d.0 as f64 * scale).round() as i64) } else { *d };
edit::slip(q, *c, dd, ctx)?;
match edit::slip(q, *c, dd, ctx) {
Err(edit::EditError::Locked) => continue,
Err(e) => return Err(e.into()),
Ok(_) => {}
}
}
Ok(())
}
/// Adjacent roll pairs that follow `left`/`right` when linked selection is on: the given cut, plus
/// through-edits between the same pieces' linked partners at that same time (Premiere linked
/// selection). Split edits (audio cut at another time) and unlinked clips are not added.
pub fn linked_roll_pairs(seq: &filmcraft_project::Sequence, left: ClipId, right: ClipId, follow_links: bool) -> Vec<(ClipId, ClipId)> {
let mut pairs = vec![(left, right)];
if !follow_links {
return pairs;
}
let Some((_, l)) = seq.find_item(left) else {
return pairs;
};
let Some((_, r)) = seq.find_item(right) else {
return pairs;
};
let cut = l.end();
let left_link = l.link;
let right_link = r.link;
if left_link.is_none() && right_link.is_none() {
return pairs;
}
for tr in seq.all_tracks() {
if tr.locked {
continue;
}
for w in tr.items.windows(2) {
if w[0].id == left && w[1].id == right {
continue;
}
if w[0].end() != w[1].start || w[0].end() != cut {
continue;
}
let left_ok = left_link.is_some_and(|g| w[0].link == Some(g));
let right_ok = right_link.is_some_and(|g| w[1].link == Some(g));
if left_ok && right_ok {
pairs.push((w[0].id, w[1].id));
}
}
}
pairs
}
/// Expand a clip selection with linked partners (when linked selection is on).
pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
let Some(seq) = s.active_sequence() else { return clips.to_vec() };
@ -432,12 +496,22 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
/// clamped by each one's media and neighbours.
fn clamp_trim_linked(q: &filmcraft_project::Sequence, clips: &[ClipId], edge: Edge, mode: TrimMode, delta: Tick, ctx: &edit::EditCtx) -> Result<Tick> {
let mut d = delta;
let mut any = false;
for c in clips {
let x = edit::clamp_trim(q, *c, edge, mode, d, ctx)?;
if x.abs() < d.abs() {
d = x;
match edit::clamp_trim(q, *c, edge, mode, d, ctx) {
Err(edit::EditError::Locked) => continue,
Err(e) => return Err(e.into()),
Ok(x) => {
any = true;
if x.abs() < d.abs() {
d = x;
}
}
}
}
if !any {
return Err(edit::EditError::Locked.into());
}
Ok(d)
}
@ -681,8 +755,9 @@ pub(crate) fn place_item(
if s.state.sequences_as_clips && pi.as_sequence().is_some_and(|q| q.multicam.is_none()) {
return crate::sequence_tools::place_sequence_clips(s, item, range, at, vdest, adest, insert, label);
}
let has_v = pi.has_video() && vdest.is_some();
let has_a = pi.has_audio() && adest.is_some();
let (video, audio) = s.project.source_streams(item);
let has_v = video && vdest.is_some();
let has_a = audio && adest.is_some();
if !has_v && !has_a {
return Err(EngineError::Other("no destination track for this media (check source patching)".into()));
}
@ -724,7 +799,7 @@ pub(crate) fn place_item(
let mut a = p.make_track_item(item, TrackKind::Audio, aat, arange, rate).ok_or_else(|| bad(label, "bad item"))?;
// Modify ▸ Audio Channels with several audio clips: one per clip, on the tracks below
let clips: Vec<Vec<u16>> =
p.item(item).and_then(|i| i.as_media()).and_then(|m| m.interpret.audio_channels.as_ref()).map(|m| m.clips.clone()).unwrap_or_default();
p.resolve_media(item).and_then(|(_, m, _)| m.interpret.audio_channels.as_ref()).map(|m| m.clips.clone()).unwrap_or_default();
let streams = p.resolve_media(item).map_or(1, |(_, m, _)| m.info.audio_streams.len());
let specs = audio_placement_specs(&clips, streams);
let first = p.sequence(seq_id).and_then(|q| q.audio_tracks.iter().position(|t| t.id == adest)).unwrap_or(0);
@ -1289,7 +1364,7 @@ fn build() -> Vec<CommandSpec> {
has_seq,
crate::export_tools::export_media
),
query!("jobs.list", "List Jobs", "{}", |s, _| Ok(Value::Array(s.jobs.iter().map(crate::Job::to_json).collect()))),
query!("jobs.list", "List Jobs", "{}", |s, _| Ok(Value::Array(s.jobs.iter().map(|j| crate::export_tools::job_json(s, j)).collect()))),
query!("perf.stats", "Performance Statistics", "{}", |s, _| Ok(crate::perf::stats(s))),
cmd!("jobs.cancel", "Cancel Job", [], None, r#"{"job":id}"#, always, |s, p| {
let id = u64_p(p, "job").ok_or_else(|| bad("jobs.cancel", "need `job`"))?;
@ -1462,7 +1537,12 @@ fn build() -> Vec<CommandSpec> {
}
}
for group in &groups {
edit::set_speed_group(q, group, speed, reverse, ripple, ctx)?;
let unlocked: Vec<ClipId> =
group.iter().copied().filter(|c| q.find_item(*c).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| !tr.locked))).collect();
if unlocked.is_empty() {
return Err(edit::EditError::Locked.into());
}
edit::set_speed_group(q, &unlocked, speed, reverse, ripple, ctx)?;
}
for c in &sel {
if let (Some(m), Some((_, it))) = (interp, q.find_item_mut(*c)) {
@ -1740,7 +1820,7 @@ fn build() -> Vec<CommandSpec> {
"Apply Audio Transition",
["Sequence"],
Some("Cmd+Shift+D"),
r#"{"clip":id?,"effect":str?,"frames":i64?}"#,
r#"{"clip":id?,"effect":"constant_power|Constant Power|…"?,"frames":i64?,"edge":"in"|"out"?,"params":{param:value}?,"reverse":bool?}"#,
has_seq,
|s, p| apply_transition(s, p, TrackKind::Audio)
),
@ -1999,7 +2079,7 @@ fn build() -> Vec<CommandSpec> {
return Ok(v);
}
let (_seq, ph) = marker_list_mut(s)?;
let t = time_p(s, p, "").unwrap_or(ph);
let t = sequence_time_p(s, p, "", "markers.add")?.unwrap_or(ph);
let name = str_p(p, "name").unwrap_or("").to_string();
let comment = str_p(p, "comment").unwrap_or("").to_string();
let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green);
@ -2200,9 +2280,10 @@ fn build() -> Vec<CommandSpec> {
return Err(bad("timeline.place", "enable video, audio, or both"));
}
let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?;
let at = time_p(s, p, "").unwrap_or(s.playhead());
let at = sequence_time_p(s, p, "", "timeline.place")?.unwrap_or(s.playhead());
let tg = s.targeting();
let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?;
let (has_video, has_audio) = s.project.source_streams(item);
let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t));
// `track` is where the picture goes and `audioTrack` where the sound goes. A caller that
// names an audio track as `track` asks for the sound alone, on that track.
@ -2211,21 +2292,21 @@ fn build() -> Vec<CommandSpec> {
return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once"));
}
(_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")),
(Some(t), None) if is_audio(t) && !pi.has_audio() => {
(Some(t), None) if is_audio(t) && !has_audio => {
return Err(bad("timeline.place", "`track` names an audio track and this item has no sound"));
}
(Some(t), None) if is_audio(t) => (None, Some(t)),
(Some(_), None) if !pi.has_video() => {
(Some(_), None) if !has_video => {
return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track"));
}
(v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)),
};
let v = v.filter(|_| video);
let a = a.filter(|_| audio);
if p.get("video").is_some() && video && pi.has_video() && v.is_none() {
if p.get("video").is_some() && video && has_video && v.is_none() {
return Err(bad("timeline.place", "no video destination: add or enable a video track"));
}
if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() {
if p.get("audio").is_some() && audio && has_audio && a.is_none() {
return Err(bad("timeline.place", "no audio destination: add or enable an audio track"));
}
let full = full_duration(s, pi);
@ -2302,6 +2383,9 @@ fn build() -> Vec<CommandSpec> {
.unwrap_or_default();
let clips = with_links(s, &[c]);
let applied = s.edit_sequence(if mode == TrimMode::Ripple { "Ripple Trim" } else { "Trim" }, |q, ctx, st| {
if q.find_item(c).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| tr.locked)) {
return Err(edit::EditError::Locked.into());
}
let before = q.find_item(c).map(|(_, i)| i.range());
// clamp across all linked partners, then apply the common delta
let mut dd = clamp_trim_linked(q, &clips, edge, mode, d, ctx)?;
@ -2309,6 +2393,10 @@ fn build() -> Vec<CommandSpec> {
// the clip and its linked partners ripple as one edit
let mut group = vec![c];
group.extend(clips.iter().copied().filter(|o| *o != c));
group.retain(|id| q.find_item(*id).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| !tr.locked)));
if group.is_empty() {
return Err(edit::EditError::Locked.into());
}
dd = edit::ripple_trim_group(q, &group, edge, dd, ctx)?;
if st.ripple_sequence_markers
&& let Some(r) = before
@ -2322,7 +2410,11 @@ fn build() -> Vec<CommandSpec> {
}
} else {
for c in &clips {
edit::trim(q, *c, edge, mode, dd, ctx)?;
match edit::trim(q, *c, edge, mode, dd, ctx) {
Err(edit::EditError::Locked) => continue,
Err(e) => return Err(e.into()),
Ok(_) => {}
}
}
}
Ok(dd)
@ -2412,8 +2504,26 @@ fn build() -> Vec<CommandSpec> {
.map(Tick)
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
.unwrap_or_default();
let x = s.edit_sequence("Rolling Edit", |q, ctx, _| Ok(edit::roll(q, l, r, d, ctx)?))?;
Ok(json!({"delta": x.0}))
let follow = s.state.linked_selection;
let (x, n, linked) = s.edit_sequence("Rolling Edit", |q, ctx, _| {
let pairs = linked_roll_pairs(q, l, r, follow);
let linked = pairs.len() > 1;
let mut d = d;
for &(pl, pr) in &pairs {
let mut probe = q.clone();
let x = edit::roll(&mut probe, pl, pr, d, ctx)?;
if x.abs() < d.abs() {
d = x;
}
}
if d != Tick::ZERO {
for &(pl, pr) in &pairs {
edit::roll(q, pl, pr, d, ctx)?;
}
}
Ok((d, pairs.len(), linked))
})?;
Ok(json!({"delta": x.0, "pairs": n, "linked": linked}))
}),
cmd!("timeline.slip", "Slip", [], None, r#"{"clip":id,"delta":ticks|"deltaFrames":i64}"#, has_seq, |s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.slip", "need `clip`"))?;
@ -2441,7 +2551,14 @@ fn build() -> Vec<CommandSpec> {
.unwrap_or_default();
let clips = with_links(s, &[c]);
// clamp across all linked partners, then slide them by the common delta
s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?;
s.edit_sequence("Slide", |q, ctx, _| {
let unlocked: Vec<ClipId> =
clips.iter().copied().filter(|id| q.find_item(*id).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| !tr.locked))).collect();
if unlocked.is_empty() {
return Err(edit::EditError::Locked.into());
}
Ok(edit::slide_items(q, &unlocked, d, ctx)?)
})?;
Ok(Value::Null)
}),
cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| {
@ -2456,8 +2573,19 @@ fn build() -> Vec<CommandSpec> {
let clips = with_links(s, &[c]);
let sp = s.edit_sequence("Rate Stretch", |q, ctx, _| {
let mut sp = 1.0;
let mut any = false;
for c in &clips {
sp = edit::rate_stretch(q, *c, edge, d, ctx)?;
match edit::rate_stretch(q, *c, edge, d, ctx) {
Err(edit::EditError::Locked) => continue,
Err(e) => return Err(e.into()),
Ok(s) => {
any = true;
sp = s;
}
}
}
if !any {
return Err(edit::EditError::Locked.into());
}
Ok(sp)
})?;
@ -2600,7 +2728,7 @@ fn build() -> Vec<CommandSpec> {
"Set Effect Parameter",
[],
None,
r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path|"option name" (a choice takes its index or its name),"time":ticks? (where the keyframe goes when the parameter is animated; a parameter that is not animated gets a new static value whatever the time),"keyframe":bool? (true: write a keyframe at `time` and animate the parameter),"merge":bool?,"begin":bool?}"##,
r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path|"option name" (a choice takes its index or its name),"time":ticks? (where the keyframe goes when the parameter is animated, kept there even outside the clip; default: the playhead, pinned to the clip; a parameter that is not animated gets a new static value whatever the time),"keyframe":bool? (true: write a keyframe at `time` and animate the parameter),"merge":bool?,"begin":bool?}"##,
has_seq,
|s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.setParam", "need `clip`"))?;
@ -2608,8 +2736,10 @@ fn build() -> Vec<CommandSpec> {
let val = p.get("value").cloned().ok_or_else(|| bad("effects.setParam", "need `value`"))?;
let eff = p.get("effect").cloned().unwrap_or(json!("motion"));
let keyframe = bool_p(p, "keyframe").unwrap_or(false);
// an explicit `time` outside the clip keeps its keyframe there, before the start as after
// the end, so a ramp across a cut keeps its slope (#1055); the playhead is pinned to the clip
let at = time_p(s, p, "");
let ph = s.playhead();
let tl = time_p(s, p, "").unwrap_or(ph);
let pq = p.clone();
// `merge`: a drag of one parameter is one undo step (#201); `begin` starts a new one
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("setParam:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null)));
@ -2618,7 +2748,7 @@ fn build() -> Vec<CommandSpec> {
}
let keyframes = s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
let mt = it.source_time_at(tl.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
let mt = it.source_time_at(at.unwrap_or_else(|| ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))));
let e = match &eff {
Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
@ -2820,20 +2950,48 @@ fn build() -> Vec<CommandSpec> {
}
items.sort_by_key(|i| i.0);
items.dedup();
let mut used = std::collections::HashSet::new();
for it in s.project.sequences() {
if let Some(q) = it.as_sequence() {
for t in q.all_tracks() {
used.extend(t.items.iter().map(|x| x.item));
}
}
}
let mut gone = Vec::new();
let mut offlined = Vec::new();
for i in items {
let keep = used.contains(&i)
&& s.project.item(i).and_then(filmcraft_project::ProjectItem::as_media).is_some_and(|m| matches!(m.media, MediaRef::File { .. }));
if keep {
offlined.push(i);
} else {
gone.push(i);
}
}
s.edit("Clear", |pr, st| {
for i in &offlined {
if let Some(m) = pr.item_mut(*i).and_then(filmcraft_project::ProjectItem::as_media_mut) {
m.offline = true;
}
}
for b in &bins {
pr.root.remove_bin(*b);
}
for i in &items {
for i in &offlined {
if pr.root.parent_of(*i).is_none() {
pr.root.children.push(filmcraft_project::BinEntry::Item(*i));
}
}
for i in &gone {
pr.items.remove(i);
pr.root.remove_item(*i);
}
// remove track items that referenced deleted media
let seq_ids: Vec<ItemId> = pr.sequences().map(|q| q.id).collect();
for sid in seq_ids {
if let Some(q) = pr.sequence_mut(sid) {
for t in q.all_tracks_mut() {
t.items.retain(|x| !items.contains(&x.item));
t.items.retain(|x| !gone.contains(&x.item));
filmcraft_edit::remove_orphan_transitions(t);
}
}
@ -2841,11 +2999,12 @@ fn build() -> Vec<CommandSpec> {
st.project_selection.clear();
Ok(())
})?;
for i in &items {
for i in gone.iter().chain(&offlined) {
s.media.remove(*i);
}
crate::relink::refresh(s);
s.fix_state();
Ok(json!({"items": items.len(), "bins": bins.len()}))
Ok(json!({"items": gone.len(), "bins": bins.len(), "offline": offlined.len()}))
}),
cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id|binId]?,"bin":binId|null}"#, always, |s, p| {
let items: Vec<ItemId> = match p.get("items").and_then(Value::as_array) {

View File

@ -234,15 +234,15 @@ fn project_delete_takes_explicit_bins_and_items_without_a_selection() {
assert_eq!(disabled(s.execute("project.delete", json!({}))), "select an item in the Project panel");
// an empty bin, by its id alone
let r = s.execute("project.delete", json!({"items": [empty]})).unwrap();
assert_eq!(r, json!({"items": 0, "bins": 1}));
assert_eq!(r, json!({"items": 0, "bins": 1, "offline": 0}));
assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none());
// a bin with a clip in it, and an item, each by id alone
let r = s.execute("project.delete", json!({"items": [outer]})).unwrap();
assert_eq!(r, json!({"items": 1, "bins": 1}));
assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0}));
assert!(s.project.item(ocean).is_none());
let dunes = item_named(&s, "Desert_Dunes.mp4");
let r = s.execute("project.delete", json!({"items": [dunes.0]})).unwrap();
assert_eq!(r, json!({"items": 1, "bins": 0}));
assert_eq!(r, json!({"items": 1, "bins": 0, "offline": 0}));
assert_eq!(s.project.items.len(), before - 2);
assert!(s.state.project_selection.is_empty(), "the selection is left as it was");
assert!(!s.is_enabled("project.delete"), "menu enablement still follows the selection");

View File

@ -440,8 +440,9 @@ pub fn spawn_export(s: &mut Session, project: Arc<Project>, seq: ItemId, mut set
let id = s.jobs.iter().map(|j| j.id).max().unwrap_or(0) + 1;
let job = crate::Job { id, label, progress: Default::default(), result: Default::default() };
// Export always renders full-resolution media, whatever the proxy toggle says.
let provider = s.media.full_res_provider(project.clone(), s.services.clone());
let provider = s.media.full_res_provider(project.clone(), s.services.clone()).recording();
let (prog, res) = (job.progress.clone(), job.result.clone());
let offline = s.export_offline.entry(id).or_default().clone();
if cfg!(target_arch = "wasm32") && !wait && filmcraft_export::stepped(format) {
// No threads: the host advances the export between UI frames (`Session::pump_jobs`).
let mut exporter = filmcraft_export::Exporter::new(project, seq, &settings, &prog).map_err(|e| EngineError::Other(e.to_string()))?;
@ -452,6 +453,7 @@ pub fn spawn_export(s: &mut Session, project: Arc<Project>, seq: ItemId, mut set
}
let run = move || {
let r = filmcraft_export::export(&project, seq, &settings, &provider, &prog).map_err(|e| e.to_string());
*offline.lock().unwrap_or_else(|x| x.into_inner()) = provider.offline_used();
if let Err(e) = &r {
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
prog.finished.store(true, Ordering::Relaxed);
@ -514,6 +516,7 @@ pub fn export_media(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other(format!("export failed: {e}")));
}
out["result"] = v["result"].clone();
out["offlineMedia"] = offline_media_json(s, id);
}
Ok(out)
}
@ -568,6 +571,22 @@ impl ExportQueue {
}
}
/// A `jobs.list` entry: [`crate::Job::to_json`], plus `offlineMedia` for an export job, so callers
/// that poll an export (the bridge, MCP long exports) can report it like `file.exportMedia` does.
pub(crate) fn job_json(s: &Session, j: &crate::Job) -> Value {
let mut v = j.to_json();
if s.export_offline.contains_key(&j.id) {
v["offlineMedia"] = offline_media_json(s, j.id);
}
v
}
/// The offline items export job `job` drew the slate for, as JSON (`[]` while it runs or when none).
fn offline_media_json(s: &Session, job: u64) -> Value {
let list = s.export_offline.get(&job).map(|o| o.lock().unwrap_or_else(|e| e.into_inner()).clone()).unwrap_or_default();
serde_json::to_value(list).unwrap_or_else(|_| json!([]))
}
fn item_json(s: &Session, it: &QueueItem) -> Value {
let job = it.job.and_then(|id| s.jobs.iter().find(|j| j.id == id));
let (progress, status_text) = match job {
@ -588,6 +607,7 @@ fn item_json(s: &Session, it: &QueueItem) -> Value {
"statusText": status_text,
"job": it.job,
"error": it.error,
"offlineMedia": it.job.map_or_else(|| json!([]), |j| offline_media_json(s, j)),
})
}

View File

@ -357,6 +357,9 @@ pub struct Session {
pub journal: Vec<(String, Value)>,
/// Background jobs (exports, render previews…).
pub jobs: Vec<Job>,
/// Export job → the offline items it drew the slate for, filled when the export finishes
/// (#1054; `offlineMedia` in `file.exportMedia` and queue results).
pub export_offline: std::collections::HashMap<u64, Arc<std::sync::Mutex<Vec<media_pool::OfflineMedia>>>>,
/// User preferences (`prefs.*` commands) and where they persist (None = not persisted).
pub prefs: autosave::Preferences,
pub prefs_path: Option<std::path::PathBuf>,
@ -476,6 +479,7 @@ impl Session {
events: Vec::new(),
journal: Vec::new(),
jobs: Vec::new(),
export_offline: Default::default(),
prefs: Default::default(),
prefs_path: None,
persistence: None,
@ -1024,11 +1028,22 @@ impl Session {
/// [`Session::render_program_at`], with the reason when nothing can be rendered.
pub fn try_render_program_at(&self, scale: f32, t: Tick) -> Result<filmcraft_render::Image> {
self.render_program_with(scale, t, &self.media.provider(self.project.clone(), self.services.clone()))
}
/// [`Session::try_render_program_at`], also naming the offline items the frame drew the slate
/// for (#1054).
pub fn try_render_program_with_offline_at(&self, scale: f32, t: Tick) -> Result<(filmcraft_render::Image, Vec<media_pool::OfflineMedia>)> {
let provider = self.media.provider(self.project.clone(), self.services.clone()).recording();
let img = self.render_program_with(scale, t, &provider)?;
Ok((img, provider.offline_used()))
}
fn render_program_with(&self, scale: f32, t: Tick, provider: &dyn filmcraft_render::SourceProvider) -> Result<filmcraft_render::Image> {
let seq = self.renderable_sequence(scale)?;
let t = self.sequence_rate().snap(t.max(Tick::ZERO));
let provider = self.media.provider(self.project.clone(), self.services.clone());
let opts = filmcraft_render::RenderOptions { scale, captions: true, ..Default::default() };
Ok(filmcraft_render::render_sequence(&self.project, seq, t, opts, &provider))
Ok(filmcraft_render::render_sequence(&self.project, seq, t, opts, provider))
}
/// The active sequence, if a frame of it at `scale` is within the image size limits.
@ -1114,6 +1129,8 @@ mod keyboard_tests;
#[cfg(test)]
mod linked_speed_tests;
#[cfg(test)]
mod locked_clip_edits_tests;
#[cfg(test)]
mod masks_tests;
#[cfg(test)]
mod media_browser_tests;
@ -1156,6 +1173,8 @@ mod remix_tests;
#[cfg(test)]
mod ripple_delete_tests;
#[cfg(test)]
mod roll_linked_tests;
#[cfg(test)]
mod scopes_tests;
#[cfg(test)]
mod sequence_inspect_tests;

View File

@ -0,0 +1,84 @@
//! #911: a locked track refuses timing and source-range edits the way a locked destination
//! refuses `timeline.move`.
use super::*;
use filmcraft_project::ClipId;
use serde_json::json;
fn fixture() -> (Session, u64) {
let mut s = Session::default();
s.execute("file.newProject", json!({"name": "Lock check"})).unwrap();
s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap();
s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap();
let item = s.project.items.values().find(|i| i.as_media().is_some()).map(|i| i.id.0).unwrap();
s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0, "sourceIn": 0, "duration": 1_016_064_000_000i64})).unwrap();
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
(s, clip)
}
fn v1_clip(s: &Session, id: u64) -> filmcraft_project::TrackItem {
s.active_sequence().unwrap().find_item(ClipId(id)).unwrap().1.clone()
}
/// Place a 100-frame clip, lock V1, trim Out by −20 frames: the command errors and duration stays 100.
#[test]
fn locked_track_refuses_trim_out_and_keeps_duration() {
let (mut s, clip) = fixture();
let before = v1_clip(&s, clip);
assert_eq!(s.sequence_rate().frame_at(before.duration), 100);
s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap();
let err = s.execute("timeline.trim", json!({"clip": clip, "edge": "out", "mode": "regular", "deltaFrames": -20})).unwrap_err();
assert!(err.to_string().contains("locked"), "{err}");
let after = v1_clip(&s, clip);
assert_eq!(after.duration, before.duration);
assert_eq!(after.start, before.start);
assert_eq!(after.source_in, before.source_in);
assert_eq!(after.speed, before.speed);
}
#[test]
fn unlocked_track_still_trims_out() {
let (mut s, clip) = fixture();
s.execute("timeline.trim", json!({"clip": clip, "edge": "out", "mode": "regular", "deltaFrames": -20})).unwrap();
assert_eq!(s.sequence_rate().frame_at(v1_clip(&s, clip).duration), 80);
}
#[test]
fn locked_track_refuses_move_on_the_same_track() {
let (mut s, clip) = fixture();
let before = v1_clip(&s, clip);
s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap();
let err = s.execute("timeline.move", json!({"moves": [{"clip": clip, "track": "V1", "time": 1_016_064_000_000i64}], "linked": false})).unwrap_err();
assert!(err.to_string().contains("locked"), "{err}");
assert_eq!(v1_clip(&s, clip).duration, before.duration);
}
#[test]
fn locked_track_refuses_roll_slip_slide_speed() {
let mut s = Session::default();
s.execute("file.newProject", json!({"name": "Lock check"})).unwrap();
s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap();
s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap();
let item = s.project.items.values().find(|i| i.as_media().is_some()).map(|i| i.id.0).unwrap();
let frame = 10_160_640_000i64;
s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0, "sourceIn": frame * 10, "duration": frame * 60})).unwrap();
s.execute("timeline.place", json!({"item": item, "track": "V1", "time": frame * 60, "sourceIn": 0, "duration": frame * 100})).unwrap();
let (left, right) = {
let q = s.active_sequence().unwrap();
(q.video_tracks[0].items[0].id.0, q.video_tracks[0].items[1].id.0)
};
s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap();
let snap = s.project.clone();
for cmd in [
json!({"id": "timeline.roll", "p": {"left": left, "right": right, "deltaFrames": 10}}),
json!({"id": "timeline.slip", "p": {"clip": left, "deltaFrames": -10}}),
json!({"id": "timeline.slide", "p": {"clip": left, "deltaFrames": 10}}),
json!({"id": "timeline.rateStretch", "p": {"clip": left, "edge": "out", "deltaFrames": 20}}),
json!({"id": "clip.speedDuration", "p": {"clips": [left], "speed": 200}}),
json!({"id": "timeline.trim", "p": {"clip": left, "edge": "in", "mode": "regular", "deltaFrames": 20}}),
] {
let err = s.execute(cmd["id"].as_str().unwrap(), cmd["p"].clone()).unwrap_err();
assert!(err.to_string().contains("locked"), "{}: {err}", cmd["id"]);
assert_eq!(*s.project, *snap, "{} must leave the sequence unchanged", cmd["id"]);
}
}

View File

@ -91,9 +91,12 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
/// A mask path from JSON: the serialized [`MaskPath`] object, an array of vertices
/// (`{"p":[x,y],"in":[x,y]?,"out":[x,y]?}`), or an array of `[x, y]` / `{"x","y"}` points.
pub fn path_from_json(v: &Value) -> Option<MaskPath> {
if let Ok(p) = serde_json::from_value::<MaskPath>(v.clone()) {
return Some(p);
}
// The vertex forms first: serde also accepts `{"p","in","out"}` vertices (the `masks.list` /
// `masks.add` form) but silently drops the unknown `in` / `out` tangents.
vertex_path(v).or_else(|| serde_json::from_value::<MaskPath>(v.clone()).ok())
}
fn vertex_path(v: &Value) -> Option<MaskPath> {
let arr = v.as_array().or_else(|| v.get("vertices").and_then(Value::as_array))?;
let closed = v.get("closed").and_then(Value::as_bool).unwrap_or(true);
let mut vertices = Vec::new();

View File

@ -380,3 +380,22 @@ fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners()
assert_eq!(v.len(), 6);
assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]);
}
/// `path_to_json` → `path_from_json` keeps the Bézier tangents (they used to come back as zero, so
/// a pen mask's dragged-out curves were committed as corners; #853).
#[test]
fn path_json_round_trip_keeps_tangents() {
use filmcraft_geom::Vec2;
use filmcraft_project::mask::{MaskPath, MaskVertex};
let path = MaskPath {
vertices: vec![
MaskVertex { p: Vec2::new(10.0, 20.0), t_in: Vec2::new(-5.0, 0.0), t_out: Vec2::new(5.0, 0.0) },
MaskVertex::corner(Vec2::new(80.0, 20.0)),
MaskVertex { p: Vec2::new(40.0, 90.0), t_in: Vec2::new(3.0, -4.0), t_out: Vec2::new(-3.0, 4.0) },
],
closed: true,
};
assert_eq!(crate::masks::path_from_json(&crate::masks::path_to_json(&path)), Some(path.clone()));
// the serialized project form still parses
assert_eq!(crate::masks::path_from_json(&serde_json::to_value(&path).unwrap()), Some(path));
}

View File

@ -12,7 +12,7 @@
//! compositor derives its pixel scale from the frame it gets, so effects and Motion render the
//! same picture at lower resolution. Export asks for [`MediaPool::full_res_provider`].
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
@ -32,6 +32,17 @@ pub struct OfflineStatus {
pub error: String,
}
/// An offline item an export or a rendered frame drew the slate for (#1054). `status` is the
/// word `media.status` uses: `missing`, `offline` (made offline) or `unreadable`.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct OfflineMedia {
pub item: u64,
pub name: String,
pub path: String,
pub status: &'static str,
pub error: String,
}
pub struct MediaPool {
/// item → (reference key, source)
sources: RwLock<HashMap<ItemId, (String, SharedSource)>>,
@ -316,12 +327,12 @@ impl MediaPool {
/// A render-side provider bound to a project snapshot (proxies when enabled).
pub fn provider(self: &Arc<Self>, project: Arc<Project>, services: Arc<dyn Services>) -> PoolProvider {
PoolProvider { pool: self.clone(), project, services, full_res: false }
PoolProvider { pool: self.clone(), project, services, full_res: false, used: None }
}
/// A provider that always reads full-resolution media (export).
pub fn full_res_provider(self: &Arc<Self>, project: Arc<Project>, services: Arc<dyn Services>) -> PoolProvider {
PoolProvider { pool: self.clone(), project, services, full_res: true }
PoolProvider { pool: self.clone(), project, services, full_res: true, used: None }
}
}
@ -332,10 +343,45 @@ pub struct PoolProvider {
pub services: Arc<dyn Services>,
/// Ignore proxies (export always renders full resolution).
pub full_res: bool,
/// Items asked for so far, when recording ([`PoolProvider::recording`]).
used: Option<Mutex<BTreeSet<ItemId>>>,
}
impl PoolProvider {
/// This provider, remembering the items it is asked for so [`PoolProvider::offline_used`] can
/// tell which of them rendered offline.
pub fn recording(mut self) -> Self {
self.used = Some(Mutex::new(BTreeSet::new()));
self
}
/// The offline items among those asked for since [`PoolProvider::recording`], once each, in
/// item order (subclips count as their media). Empty when not recording.
pub fn offline_used(&self) -> Vec<OfflineMedia> {
let Some(used) = &self.used else { return Vec::new() };
let used = used.lock().unwrap_or_else(|e| e.into_inner()).clone();
let media: BTreeSet<ItemId> = used.into_iter().filter_map(|i| self.project.resolve_media(i).map(|(id, _, _)| id)).collect();
media
.into_iter()
.filter_map(|id| {
let st = self.pool.offline_status(id)?;
let status = match st.reason {
OfflineReason::Missing => "missing",
OfflineReason::MadeOffline => "offline",
OfflineReason::Unreadable => "unreadable",
};
let name = self.project.item(id).map(|i| i.name.clone()).unwrap_or_default();
Some(OfflineMedia { item: id.0, name, path: st.path, status, error: st.error })
})
.collect()
}
}
impl filmcraft_render::SourceProvider for PoolProvider {
fn source(&self, item: ItemId) -> Option<SharedSource> {
if let Some(used) = &self.used {
used.lock().unwrap_or_else(|e| e.into_inner()).insert(item);
}
if self.full_res { self.pool.full_res_source(&self.project, item, &*self.services) } else { self.pool.source_for(&self.project, item, &*self.services) }
}
}

View File

@ -520,3 +520,11 @@ fn surround_sequence_settings_and_panner_lanes() {
assert!(s.execute("file.newSequence", json!({"mix": "7.1"})).is_err());
s.execute("file.newSequence", json!({"trackType": "Standard"})).unwrap();
}
#[test]
fn audio_transition_schema_lists_edge_params_and_reverse() {
let params = find_command("sequence.applyAudioTransition").unwrap().params;
for key in ["\"edge\"", "\"params\"", "\"reverse\"", "constant_power"] {
assert!(params.contains(key), "schema `{params}` is missing {key}");
}
}

View File

@ -14,6 +14,7 @@
//! | `file.projectSettings.general` / `.scratchDisks` | File ▸ Project Settings ▸ General… / Scratch Disks… |
//! | `edit.find` / `edit.findNext` | Edit ▸ Find… (⌘F) / Find Next |
//! | `edit.editOriginal` | Edit ▸ Edit Original (⌘E): the frontend opens the files |
//! | `file.reveal` | Project panel ▸ Reveal in Finder / Show in Explorer: the frontend reveals the media file |
//! | `clip.editOffline` | Clip ▸ Edit Offline… |
//! | `clip.sourceSettings` | Clip ▸ Source Settings… |
//! | `clip.restoreCaptionsFromSource` | Clip ▸ Restore Captions from Source Clip (always disabled: no embedded caption reader) |
@ -163,6 +164,8 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
spec("edit.find", "Find…", &["Edit"], Some("Cmd+F"), FIND_PARAMS, always, find),
spec("edit.findNext", "Find Next", &["Edit"], None, "{}", has_find, find_next),
spec("edit.editOriginal", "Edit Original", &["Edit"], Some("Cmd+E"), r#"{"items":[id]?}"#, has_original, edit_original),
// Project panel context menu (not in the File menu): reveal the clip's media file.
query("file.reveal", "Reveal in Finder", &[], None, r#"{"items":[id]?}"#, has_file_selection, edit_original),
// ---------------- Clip ----------------
spec(
"clip.editOffline",

View File

@ -361,6 +361,18 @@ fn edit_original_update_metadata_and_clip_items() {
assert!(std::fs::read_to_string(&side).unwrap().contains("someone else"));
}
#[test]
fn file_reveal_enabled_for_imported_clip() {
let dir = tmp_dir("reveal");
let path = dir.join("take.mp4");
make_movie(&path, DemoScene::Aurora, 64, 36, 8);
let (mut s, items, _) = session_with(&[&path]);
assert!(!s.is_enabled("file.reveal"), "disabled with no Project panel selection");
s.execute("project.select", json!({"items": [items[0].0]})).unwrap();
assert!(s.is_enabled("file.reveal"));
assert_eq!(s.execute("file.reveal", json!({})).unwrap()["paths"], json!([path.to_string_lossy()]));
}
#[test]
fn edit_offline_sets_the_log_fields() {
let mut s = demo();
@ -469,13 +481,13 @@ fn clear_deletes_a_bin_with_everything_in_it() {
select(&mut s);
// an empty bin, by its id: gone, nothing else touched
let r = s.execute("project.delete", json!({"items": [empty]})).unwrap();
assert_eq!(r, json!({"items": 0, "bins": 1}));
assert_eq!(r, json!({"items": 0, "bins": 1, "offline": 0}));
assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none());
assert_eq!(s.project.items.len(), before);
// a bin holding a sub-bin holding a clip: all three go
select(&mut s);
let r = s.execute("project.delete", json!({"items": [outer]})).unwrap();
assert_eq!(r, json!({"items": 1, "bins": 1}));
assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0}));
assert!(s.project.root.find_bin(filmcraft_project::BinId(outer)).is_none());
assert!(s.project.root.find_bin(filmcraft_project::BinId(inner)).is_none());
assert!(s.project.item(ocean).is_none());
@ -487,7 +499,7 @@ fn clear_deletes_a_bin_with_everything_in_it() {
// the clip, its bin and the bin around it all named: each counted once
select(&mut s);
let r = s.execute("project.delete", json!({"items": [ocean.0, inner, outer]})).unwrap();
assert_eq!(r, json!({"items": 1, "bins": 1}));
assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0}));
assert_eq!(s.project.items.len(), before - 1);
s.execute("edit.undo", json!({})).unwrap();
// the project's own top bin is never cleared
@ -496,3 +508,31 @@ fn clear_deletes_a_bin_with_everything_in_it() {
s.execute("project.delete", json!({"items": [root]})).unwrap();
assert_eq!(s.project.items.len(), before);
}
#[test]
fn deleting_media_used_on_the_timeline_makes_it_offline() {
let mut s = Session::default();
s.execute("file.newProject", json!({"name": "Offline"})).unwrap();
s.execute("file.newSequence", json!({"name": "Seq", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap();
let item = s
.execute("file.newOfflineFile", json!({"name": "Clip", "seconds": 2, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16}))
.unwrap()["item"]
.as_u64()
.unwrap();
s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0})).unwrap();
let clips_before = s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len();
let r = s.execute("project.delete", json!({"items": [item]})).unwrap();
assert_eq!(r["offline"], 1);
assert_eq!(r["items"], 0);
assert!(s.project.item(ItemId(item)).unwrap().as_media().unwrap().offline);
assert_eq!(s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len(), clips_before);
let unused = s
.execute("file.newOfflineFile", json!({"name": "Spare", "seconds": 1, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16}))
.unwrap()["item"]
.as_u64()
.unwrap();
let r = s.execute("project.delete", json!({"items": [unused]})).unwrap();
assert_eq!(r["items"], 1);
assert_eq!(r["offline"], 0);
assert!(s.project.item(ItemId(unused)).is_none());
}

View File

@ -201,3 +201,72 @@ fn explicit_folder_remaps_normalize_both_prefixes_and_respect_boundaries() {
assert_eq!(apply_remap("C:/shoot/Media/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), Some("D:/archive/Media/a.mov".into()));
assert_eq!(apply_remap("C:/shoot/Media2/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), None);
}
/// #1054: an export (or a rendered frame) that draws the offline slate names the offline items it
/// used, with their status, so a headless caller can tell placeholder frames from real ones. Media
/// outside the exported range is not reported.
#[test]
fn export_names_the_offline_media_it_rendered() {
let root = tmp_dir("relink-export-offline");
let (path, items) = saved_project(&root);
let out = |name: &str| root.join(name).to_string_lossy().into_owned();
let mut s = open(&path);
let r = s
.execute("file.exportMedia", json!({"path": out("on.png"), "format": "png", "range": "custom", "startSeconds": 0, "endSeconds": 0.05, "wait": true}))
.unwrap();
assert_eq!(r["offlineMedia"], json!([]), "{r}");
std::fs::remove_file(root.join("Media/a.mov")).unwrap();
let mut s = open(&path);
// b.mov (frames 12..24) is still there
let r = s
.execute("file.exportMedia", json!({"path": out("b.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "wait": true}))
.unwrap();
assert_eq!(r["offlineMedia"], json!([]), "{r}");
let r = s.execute("file.exportMedia", json!({"path": out("all.wav"), "format": "wav", "wait": true})).unwrap();
let off = r["offlineMedia"].as_array().unwrap();
assert_eq!(off.len(), 1, "{r}");
assert_eq!(off[0]["item"], json!(items[0].0));
assert_eq!(off[0]["name"], json!("a.mov"));
assert_eq!(off[0]["status"], json!("missing"));
assert!(off[0]["path"].as_str().unwrap().ends_with("a.mov"), "{r}");
// made offline on purpose
s.execute("media.makeOffline", json!({"items": [items[1].0]})).unwrap();
let r = s
.execute("file.exportMedia", json!({"path": out("b2.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "wait": true}))
.unwrap();
assert_eq!(
r["offlineMedia"].as_array().map(|a| a.iter().map(|o| (o["item"].clone(), o["status"].clone())).collect::<Vec<_>>()),
Some(vec![(json!(items[1].0), json!("offline"))]),
"{r}"
);
// the queue reports it per item
let r = s
.execute(
"export.queue.add",
json!({"path": out("q.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "start": true, "wait": true}),
)
.unwrap();
assert_eq!(r["items"][0]["offlineMedia"][0]["status"], json!("offline"), "{r}");
// one rendered frame
let (_, off) = s.try_render_program_with_offline_at(0.5, s.sequence_rate().tick_of(3)).unwrap();
assert_eq!(off.iter().map(|o| (o.item, o.status)).collect::<Vec<_>>(), [(items[0].0, "missing")]);
let (_, off) = s.try_render_program_with_offline_at(0.5, s.sequence_rate().tick_of(30)).unwrap();
assert!(off.is_empty(), "nothing on the timeline there: {off:?}");
let _ = std::fs::remove_dir_all(&root);
}
/// #1054: `jobs.list` carries an export job's `offlineMedia`, for callers that poll the job (the
/// bridge and MCP long exports) instead of waiting in `file.exportMedia`.
#[test]
fn jobs_list_reports_an_exports_offline_media() {
let root = tmp_dir("relink-jobs-offline");
let (path, items) = saved_project(&root);
std::fs::remove_file(root.join("Media/b.mov")).unwrap();
let mut s = open(&path);
let out = root.join("all.wav").to_string_lossy().into_owned();
let job = s.execute("file.exportMedia", json!({"path": out, "format": "wav", "wait": true})).unwrap()["job"].clone();
let jobs = s.execute("jobs.list", json!({})).unwrap();
let j = jobs.as_array().unwrap().iter().find(|j| j["id"] == job).unwrap();
assert_eq!(j["offlineMedia"][0]["item"], json!(items[1].0), "{j}");
let _ = std::fs::remove_dir_all(&root);
}

View File

@ -0,0 +1,86 @@
//! #855: a rolling edit on a linked A/V cut must move the sound cut with the picture.
use filmcraft_project::ClipId;
use filmcraft_time::Tick;
use serde_json::json;
use crate::Session;
fn range(s: &Session, c: ClipId) -> (Tick, Tick) {
s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap()
}
fn source_in(s: &Session, c: ClipId) -> Tick {
s.active_sequence().unwrap().find_item(c).unwrap().1.source_in
}
/// Two consecutive 48-frame A/V clips (picture on V1, linked sound on A1).
fn two_clips(s: &mut Session) -> [(ClipId, ClipId); 2] {
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name": "Roll", "video": 1, "audio": 1})).unwrap();
let rate = s.sequence_rate();
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
for n in 0..2 {
s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap();
}
let q = s.active_sequence().unwrap();
[0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id))
}
/// #855: rolling the V1 cut +12 frames used to leave A1, so export played the second clip's audio
/// under the first clip's picture.
#[test]
fn roll_moves_linked_audio_cut() {
let mut s = Session::default();
let [(v0, a0), (v1, a1)] = two_clips(&mut s);
assert!(s.state.linked_selection);
let rate = s.sequence_rate();
let f = |n: i64| rate.tick_of(n);
let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap();
assert_eq!(r["linked"], json!(true), "{r}");
assert_eq!(r["pairs"], json!(2), "{r}");
assert_eq!(range(&s, v0), (f(0), f(60)));
assert_eq!(range(&s, v1), (f(60), f(96)));
assert_eq!(range(&s, a0), (f(0), f(60)), "linked sound must roll with the picture");
assert_eq!(range(&s, a1), (f(60), f(96)));
}
/// Linked selection off (or unlinked clips) rolls only the given pair and says so.
#[test]
fn roll_without_linked_selection_leaves_audio() {
let mut s = Session::default();
let [(v0, a0), (v1, a1)] = two_clips(&mut s);
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
let rate = s.sequence_rate();
let f = |n: i64| rate.tick_of(n);
let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap();
assert_eq!(r["linked"], json!(false), "{r}: clips were not followed as a linked pair");
assert_eq!(range(&s, v0), (f(0), f(60)));
assert_eq!(range(&s, v1), (f(60), f(96)));
assert_eq!(range(&s, a0), (f(0), f(48)), "unlinked A1 cut stays");
assert_eq!(range(&s, a1), (f(48), f(96)));
}
/// #526/#558: reversed linked clips still keep the media on the unmoved timeline edges.
#[test]
fn roll_linked_reversed_clips_keeps_media_mapping() {
let mut s = Session::default();
let [(v0, a0), (v1, a1)] = two_clips(&mut s);
s.execute("timeline.select", json!({"clips": [v0.0, a0.0, v1.0, a1.0]})).unwrap();
s.execute("clip.speedDuration", json!({"speed": 100, "reverse": true})).unwrap();
let rate = s.sequence_rate();
let f = |n: i64| rate.tick_of(n);
let left_in = source_in(&s, v0);
let right_in = source_in(&s, v1);
s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap();
assert_eq!(range(&s, v0), (f(0), f(60)));
assert_eq!(range(&s, a0), (f(0), f(60)));
assert_eq!(range(&s, v1), (f(60), f(96)));
assert_eq!(range(&s, a1), (f(60), f(96)));
// reversed left: source_in moves so the clip's In still shows the same media
assert_eq!(source_in(&s, v0), left_in - f(12));
assert_eq!(source_in(&s, a0), left_in - f(12));
// reversed right: source_in stays so the clip's Out still shows the same media
assert_eq!(source_in(&s, v1), right_in);
assert_eq!(source_in(&s, a1), right_in);
}

View File

@ -9,8 +9,10 @@
//!
//! - **Apply a cut at each detected cut point**: the clip (and its linked audio) is cut at the
//! first frame of each new shot;
//! - **Create a subclip for each cut**: one subclip per shot, in a new bin "<clip> Scenes";
//! - **Generate clip markers**: a marker on the clip's master clip (media time) at each cut.
//! - **Create a bin of subclips from each detected cut point**: one subclip per shot, in a new
//! bin "<clip> Scenes";
//! - **Create a clip marker at each detected cut point**: a marker on the clip's master clip
//! (media time) at each cut.
use std::sync::{Arc, Mutex};

View File

@ -20,7 +20,7 @@ use filmcraft_project::{ClipId, ItemKind, Label, Marker, MarkerId, MarkerKind, S
use filmcraft_time::{Tick, TimeRange};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p};
use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, sequence_time_p, str_p, u64_p};
use crate::{EngineError, Result, Session};
type Run = fn(&mut Session, &Value) -> Result<Value>;
@ -483,7 +483,7 @@ fn show_active_only(s: &mut Session, p: &Value) -> Result<Value> {
// ---------------------------------------------------------------------------------------------
fn add_flash_cue(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.sequence_rate().snap(time_p(s, p, "").unwrap_or(s.playhead()));
let t = s.sequence_rate().snap(sequence_time_p(s, p, "", "markers.addFlashCue")?.unwrap_or(s.playhead()));
let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::FlashCue).count()).unwrap_or(0) + 1;
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Cue Point {n}"));
let comment = str_p(p, "comment").unwrap_or("").to_string();

View File

@ -31,7 +31,7 @@ use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId,
use filmcraft_time::{Tick, TimeRange};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, str_p, time_p, track_p, with_links};
use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, sequence_time_p, str_p, time_p, track_p, with_links};
use crate::{EngineError, Result, Session};
type Run = fn(&mut Session, &Value) -> Result<Value>;
@ -206,7 +206,7 @@ fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?}"#,
has_seq,
|s, p| {
let t = time_p(s, p, "").unwrap_or(s.playhead());
let t = sequence_time_p(s, p, "", "markers.addChapter")?.unwrap_or(s.playhead());
let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::Chapter).count()).unwrap_or(0) + 1;
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Chapter {n}"));
let id = add_marker(s, "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?;
@ -735,11 +735,12 @@ pub(crate) fn wants_new_track(p: &Value) -> bool {
pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.place";
let item = crate::commands::item_p(p, "item").ok_or_else(|| bad(cmd, "need `item`"))?;
let pi = s.project.item(item).ok_or_else(|| bad(cmd, "no such item"))?;
s.project.item(item).ok_or_else(|| bad(cmd, "no such item"))?;
let (video, audio) = s.project.source_streams(item);
let is_new = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new"));
let enabled = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true);
let new_v = is_new("track") && pi.has_video() && enabled("video");
let new_a = is_new("audioTrack") && pi.has_audio() && enabled("audio");
let new_v = is_new("track") && video && enabled("video");
let new_a = is_new("audioTrack") && audio && enabled("audio");
if !new_v && !new_a {
return Err(bad(cmd, "no new track to add: the item has no picture or sound for it"));
}
@ -977,7 +978,7 @@ fn add_marker(s: &mut Session, label: &str, t: Tick, dur: Tick, kind: MarkerKind
/// `duration` say otherwise) at the playhead.
fn add_range_marker(s: &mut Session, p: &Value) -> Result<Value> {
let rate = s.sequence_rate();
let t = time_p(s, p, "").unwrap_or(s.playhead());
let t = sequence_time_p(s, p, "", "markers.addRange")?.unwrap_or(s.playhead());
let dur = p
.get("duration")
.and_then(Value::as_i64)

View File

@ -34,7 +34,7 @@ pub const PREFS_VERSION: u32 = 2;
#[serde(rename_all = "camelCase", default)]
pub struct GeneralPrefs {
/// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, `uk`,
/// `zh-cn`, `de`, `ru`, `fr`, or `system` (the default: the operating system's preferred language when the
/// `zh-cn`, `de`, `ru`, `fr`, `id`, or `system` (the default: the operating system's preferred language when the
/// interface has it, otherwise English).
pub interface_language: String,
/// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`.
@ -777,6 +777,7 @@ static CATEGORIES: &[Category] = &[
("de", "Deutsch"),
("ru", "Русский"),
("fr", "Français"),
("id", "Bahasa Indonesia"),
]),
true,
),

View File

@ -38,6 +38,12 @@ fn interface_language_persists_and_rejects_hostile_values() {
assert_eq!(Preferences::load(&path).general.interface_language, "de");
set(&mut s, "general.interfaceLanguage", json!("fr"));
assert_eq!(Preferences::load(&path).general.interface_language, "fr");
// Indonesian is stored as `id`, not a locale tag or the legacy `in` code
for value in [json!("id-ID"), json!("in"), json!("ID")] {
assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err());
}
set(&mut s, "general.interfaceLanguage", json!("id"));
assert_eq!(Preferences::load(&path).general.interface_language, "id");
// back to following the operating system (#218)
set(&mut s, "general.interfaceLanguage", json!("system"));
assert_eq!(Preferences::load(&path).general.interface_language, "system");

View File

@ -1,5 +1,5 @@
use super::*;
use filmcraft_project::ItemId;
use filmcraft_project::{ItemId, ItemKind};
use filmcraft_time::{Tick, TimeRange};
use serde_json::json;
@ -83,6 +83,107 @@ fn audio_only_source_drops_only_sound() {
assert_eq!(s.active_sequence().unwrap().audio_tracks[1].items.len(), 1);
}
fn subclip_session(video: bool, audio: bool) -> (Session, ItemId) {
let mut s = Session::default();
s.execute("file.newSequence", json!({"name":"Subclip test","fps":24})).unwrap();
let parent =
s.execute("file.newOfflineFile", json!({"name":"Parent","video":video,"audio":audio,"fps":24,"seconds":10})).unwrap()["item"].as_u64().unwrap();
let sub = s.execute("clip.makeSubclip", json!({"item":parent,"startFrame":10,"endFrame":60})).unwrap()["item"].as_u64().unwrap();
(s, ItemId(sub))
}
fn assert_subclip_placement(s: &Session, item: ItemId, expected: (usize, usize)) {
assert_eq!(counts(s), expected);
let q = s.active_sequence().unwrap();
let ItemKind::Subclip { range, .. } = &s.project.item(item).unwrap().kind else { panic!("expected a subclip") };
for clip in q.all_tracks().flat_map(|t| t.items.iter()) {
assert_eq!(clip.item, item);
assert_eq!(clip.source_in, range.start);
assert_eq!(clip.duration, q.settings.frame_rate.snap_nearest(range.duration));
}
if expected == (1, 1) {
let video = &q.video_tracks[0].items[0];
let audio = &q.audio_tracks[0].items[0];
assert!(video.link.is_some());
assert_eq!(video.link, audio.link);
}
}
#[test]
fn subclip_placement_keeps_parent_streams_range_and_history() {
for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] {
let (mut s, sub) = subclip_session(video, audio);
let before = s.project.to_json();
s.execute("timeline.place", json!({"item":sub.0,"track":"V1","audioTrack":"A1","time":0,"video":true,"audio":true})).unwrap();
assert_subclip_placement(&s, sub, expected);
let placed = s.project.to_json();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), placed);
s.project = std::sync::Arc::new(Project::from_json(&placed).unwrap());
assert_subclip_placement(&s, sub, expected);
}
}
#[test]
fn subclip_audio_can_be_placed_on_an_audio_track() {
for video in [false, true] {
let (mut s, sub) = subclip_session(video, true);
s.execute("timeline.place", json!({"item":sub.0,"track":"A1","time":0,"video":false,"audio":true})).unwrap();
assert_subclip_placement(&s, sub, (0, 1));
}
let (mut s, sub) = subclip_session(true, false);
let before = s.project.to_json();
assert!(s.execute("timeline.place", json!({"item":sub.0,"track":"A1","time":0,"video":false,"audio":true})).is_err());
assert_eq!(s.project.to_json(), before);
}
#[test]
fn source_insert_and_overwrite_keep_subclip_sound() {
for command in ["source.insert", "source.overwrite"] {
for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] {
let (mut s, sub) = subclip_session(video, audio);
s.execute("source.open", json!({"item":sub.0})).unwrap();
s.execute(command, json!({})).unwrap();
assert_subclip_placement(&s, sub, expected);
}
}
}
#[test]
fn subclip_placement_can_add_tracks_for_parent_streams() {
for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] {
let (mut s, sub) = subclip_session(video, audio);
let before = s.project.to_json();
s.execute("timeline.place", json!({"item":sub.0,"track":"new","audioTrack":"new","time":0})).unwrap();
assert_eq!(counts(&s), expected);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
}
}
#[test]
fn subclip_placement_keeps_parent_channel_map_and_all_audio_streams() {
let (mut s, sub) = subclip_session(true, true);
let parent = s.project.resolve_media(sub).unwrap().0;
let m = std::sync::Arc::make_mut(&mut s.project).item_mut(parent).unwrap().as_media_mut().unwrap();
m.info.audio_streams.push(m.info.audio_streams[0].clone());
m.interpret.audio_channels = Some(filmcraft_project::AudioChannelMap { format: filmcraft_project::AudioChannels::Mono, clips: vec![vec![1], vec![0]] });
s.execute("timeline.place", json!({"item":sub.0,"track":"V1","audioTrack":"A1","time":0})).unwrap();
assert_eq!(counts(&s), (1, 3));
let q = s.active_sequence().unwrap();
let link = q.video_tracks[0].items[0].link;
assert!(link.is_some());
for (index, (stream, channels)) in [(0, vec![1]), (0, vec![0]), (1, vec![])].into_iter().enumerate() {
let clip = &q.audio_tracks[index].items[0];
assert_eq!(clip.item, sub);
assert_eq!(clip.audio_stream, stream);
assert_eq!(clip.source_channels, channels);
assert_eq!(clip.link, link);
}
}
#[test]
fn malformed_source_spans_are_bounded() {
let s = setup();

View File

@ -381,6 +381,59 @@ fn set_param_writes_a_keyframe_when_asked_or_animated() {
assert_eq!(s.execute("effects.setParam", odd).unwrap()["keyframes"], 2);
}
/// A keyframe set with a `time` outside the clip stays at that time (#1055), before the start as
/// after the end: a ramp that spans a cut shows its true slice, and a clip-end keyframe is not
/// overwritten by a later one. Only the playhead fallback is pinned to the clip.
#[test]
fn set_param_keeps_a_keyframe_outside_the_clip_at_its_time() {
let mut s = demo();
let it = s.active_sequence().unwrap().video_tracks[0].items[1].clone();
let (c, start, end) = (it.id.0, it.start, it.end());
let opacity = |s: &Session| {
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1;
let p = &it.effect("opacity").unwrap().params["opacity"];
(p.keyframes.iter().map(|k| (k.time, k.value.as_f64().unwrap())).collect::<Vec<_>>(), it.clone())
};
let set = |value: f64, time: Tick| json!({"clip": c, "effect": "opacity", "param": "opacity", "value": value, "time": time.0, "keyframe": true});
let len = end - start;
// Case A: a keyframe exactly at the clip end, then one past it, gives three keyframes
s.execute("effects.setParam", set(100.0, start)).unwrap();
s.execute("effects.setParam", set(100.0, end)).unwrap();
let r = s.execute("effects.setParam", set(20.0, end + len)).unwrap();
assert_eq!(r["keyframes"], 3, "{r}");
let (keys, it) = opacity(&s);
assert_eq!(keys, vec![(it.source_time_at(start), 100.0), (it.source_time_at(end), 100.0), (it.source_time_at(end + len), 20.0)]);
// Case B: a ramp past the end is not compressed; halfway through the clip is a quarter of the way down
s.undo();
s.undo();
let r = s.execute("effects.setParam", set(0.0, end + len)).unwrap();
assert_eq!(r["keyframes"], 2, "{r}");
let (_, it) = opacity(&s);
let p = it.effect("opacity").unwrap().params["opacity"].clone();
let mid = start + Tick(len.0 / 2);
assert!((p.f64_at(it.source_time_at(mid)) - 75.0).abs() < 0.5, "{}", p.f64_at(it.source_time_at(mid)));
// before the start the same holds: the keyframe keeps its time and the ramp its slope
s.undo();
s.undo();
s.execute("effects.setParam", set(0.0, start - len)).unwrap();
s.execute("effects.setParam", set(100.0, end)).unwrap();
let (keys, it) = opacity(&s);
assert_eq!(keys, vec![(it.source_time_at(start - len), 0.0), (it.source_time_at(end), 100.0)]);
let p = it.effect("opacity").unwrap().params["opacity"].clone();
assert!((p.f64_at(it.source_time_at(start)) - 50.0).abs() < 0.5, "{}", p.f64_at(it.source_time_at(start)));
// hostile times saturate instead of overflowing
for t in [i64::MIN, i64::MAX] {
s.execute("effects.setParam", json!({"clip": c, "effect": "opacity", "param": "opacity", "value": 50.0, "time": t})).unwrap();
}
// with no `time`, a playhead past the clip still lands on its last frame
s.undo();
s.undo();
s.execute("playhead.set", json!({"time": (end + len).0})).unwrap();
s.execute("effects.setParam", json!({"clip": c, "effect": "opacity", "param": "opacity", "value": 40.0})).unwrap();
let (keys, it) = opacity(&s);
assert!(keys.contains(&(it.source_time_at(end - Tick(1)), 40.0)), "{keys:?}");
}
/// A choice parameter takes its option name as well as its index, as transition parameters do
/// (`effects.list {"detail": true}` lists the names).
#[test]
@ -958,3 +1011,34 @@ fn enable_flips_each_selected_clip() {
s.execute("clip.enable", json!({"clips": [a, b]})).unwrap();
assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true));
}
/// Times before sequence start are unreachable in the GUI and cut off on export.
#[test]
fn negative_place_and_marker_times_are_refused() {
let mut s = demo();
let item = s.active_sequence().unwrap().video_tracks[0].items[0].item;
let before = s.project.clone();
let n_markers = s.active_sequence().unwrap().markers.len();
let n_clips: usize = s.active_sequence().unwrap().video_tracks.iter().map(|t| t.items.len()).sum();
for (cmd, params) in [
("timeline.place", json!({"item": item.0, "frame": -12})),
("timeline.place", json!({"item": item.0, "seconds": -1.0})),
("timeline.place", json!({"item": item.0, "time": -1})),
("markers.add", json!({"frame": -10})),
("markers.add", json!({"seconds": -0.5})),
("markers.add", json!({"time": i64::MIN})),
("markers.addChapter", json!({"frame": -1})),
("markers.addRange", json!({"time": -1, "durationFrames": 10})),
("markers.addFlashCue", json!({"frame": -3})),
] {
let e = s.execute(cmd, params.clone()).expect_err(&format!("{cmd} {params}"));
assert!(matches!(&e, EngineError::BadParams { cmd: c, msg } if c == cmd && msg.contains("negative")), "{cmd} {params}: {e:?}");
assert_eq!(*s.project, *before);
}
assert_eq!(s.active_sequence().unwrap().markers.len(), n_markers);
let n_clips_after: usize = s.active_sequence().unwrap().video_tracks.iter().map(|t| t.items.len()).sum();
assert_eq!(n_clips_after, n_clips);
s.execute("timeline.place", json!({"item": item.0, "frame": 0})).unwrap();
s.execute("markers.add", json!({"frame": 0, "name": "start"})).unwrap();
assert!(s.active_sequence().unwrap().markers.iter().any(|m| m.start == Tick::ZERO && m.name == "start"));
}

View File

@ -1,6 +1,6 @@
//! The export audio path: the sequence mix at the output sample rate, folded to the output
//! channel count, with Export ▸ Effects ▸ Loudness Normalization (a BS.1770-4 measuring pass over
//! the export range, then a linear gain and a look-ahead true-peak limiter).
//! the export range, then bounded gain refinement through a look-ahead true-peak limiter).
//!
//! The limiter's look-ahead delays its output; [`AudioOut`] mixes that many samples ahead (silence
//! past the end of the range) and drops the first ones, so the exported audio stays aligned with
@ -20,6 +20,7 @@ use crate::{ExportError, ExportSettings, Result};
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
pub struct LoudnessReport {
pub measured_lufs: f64,
/// Applied gain before the limiter, including bounded loudness correction.
pub gain_db: f64,
}
@ -84,11 +85,62 @@ impl AudioOut {
}
let measured = meter.integrated();
let gain_db = filmcraft_audio_dsp::normalize_gain_db(measured, ln.target_lufs);
self.gain = 10f64.powf(gain_db / 20.0) as f32;
self.loudness = Some(LoudnessReport { measured_lufs: measured, gain_db });
// Limiting changes integrated loudness. Measure the actual limited signal and
// refine its INPUT gain; the peak ceiling remains in force on every pass.
// Four trials and at most 9 dB of extra drive bound both work and compression.
let start = self.out;
let mut trial_gain = gain_db;
let mut best_gain = gain_db;
let mut best_error = f64::INFINITY;
let mut previous = None;
if measured.is_finite() && ln.target_lufs.is_finite() {
for _ in 0..4 {
self.rewind(start, trial_gain);
let mut limited = filmcraft_audio_dsp::LoudnessMeter::new(self.sr as f64, self.channels);
while self.out < self.end {
if cancel() {
self.rewind(start, best_gain);
return Err(ExportError::Cancelled);
}
let until = self.out.saturating_add(self.sr as i64).min(self.end);
if let Some(buf) = self.pull(until, sources) {
let refs: Vec<&[f32]> = buf.iter().map(Vec::as_slice).collect();
limited.process(&refs);
}
}
let achieved = limited.integrated();
if !achieved.is_finite() {
break;
}
let error = ln.target_lufs - achieved;
if error.abs() < best_error {
best_error = error.abs();
best_gain = trial_gain;
}
// Stop on convergence or a ceiling-limited plateau. An impossible target
// must not cause unbounded passes or ever bypass the limiter.
if error.abs() <= 0.1 || previous.is_some_and(|last: f64| (achieved - last).abs() < 0.02) {
break;
}
previous = Some(achieved);
trial_gain += error.clamp(-3.0, 3.0);
}
}
self.rewind(start, best_gain);
self.loudness = Some(LoudnessReport { measured_lufs: measured, gain_db: best_gain });
Ok(())
}
/// Restore the output cursor and limiter history between measuring passes and delivery.
fn rewind(&mut self, start: i64, gain_db: f64) {
self.out = start;
self.next_in = start;
self.gain = 10f64.powf(gain_db / 20.0) as f32;
if let Some(limiter) = self.limiter.as_mut() {
limiter.reset();
}
}
/// Mix `n` samples from `pos`, folded to the output channels (silence past the range end).
/// Stereo output of a 5.1 Mix is the ITU-R BS.775 downmix; 5.1 output of a stereo Mix places it
/// on L and R; 5.1 is in L, R, C, LFE, Ls, Rs order.

View File

@ -518,7 +518,7 @@ fn loudness_normalization_hits_target_and_true_peak() {
assert!((i - target).abs() < 0.5, "target {target}: measured {i:.2} LUFS");
assert!(tp <= -0.9, "true peak {tp:.2} dBTP");
let rep = prog.loudness.lock().unwrap().unwrap();
assert!((rep.measured_lufs - (target - rep.gain_db)).abs() < 1e-6);
assert!((rep.measured_lufs + rep.gain_db - target).abs() < 0.1, "unlimited tone needs no correction");
}
// a target the peaks cannot reach: the limiter holds the true-peak ceiling
let path = dir.path("hot.wav");
@ -870,3 +870,80 @@ fn gpu_rendering_is_off_by_default() {
let auto: ExportSettings = serde_json::from_value(serde_json::json!({"format": "h264", "gpuRendering": "auto"})).unwrap();
assert_eq!(auto.gpu_rendering, crate::GpuRendering::Auto);
}
/// Brief large peaks over a quiet bed exercise loudness lost during limiter release.
struct CrestSource(filmcraft_media::SharedSource, Arc<std::sync::atomic::AtomicUsize>);
impl MediaSource for CrestSource {
fn info(&self) -> &filmcraft_media::MediaInfo {
self.0.info()
}
fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<filmcraft_frame::VideoFrame>> {
self.0.video_frame(req)
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<filmcraft_frame::AudioBuffer> {
self.1.fetch_add(1, Ordering::Relaxed);
let samples: Vec<f32> = (0..frames)
.map(|i| {
let at = start + i as i64;
let phase = at.rem_euclid(sample_rate as i64 / 4);
let amplitude = if phase < sample_rate as i64 / 500 { 0.9 } else { 0.08 };
amplitude * (std::f32::consts::TAU * 1000.0 * at as f32 / sample_rate as f32).sin()
})
.collect();
Ok(filmcraft_frame::AudioBuffer { sample_rate, channels: vec![samples.clone(), samples] })
}
}
#[test]
fn loudness_normalization_corrects_limiter_loss_and_bounds_impossible_targets() {
use std::cell::Cell;
let (p, seq, mut sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0));
for source in sources.0.values_mut() {
*source = Arc::new(CrestSource(source.clone(), reads.clone()));
}
let range = TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND));
let mut settings = ExportSettings::default();
settings.effects.loudness = LoudnessNormalization { enabled: true, target_lufs: -14.0, true_peak_dbtp: -2.0 };
for target in [-14.0, 2.0] {
settings.effects.loudness.target_lufs = target;
let mut audio = audio_out::AudioOut::new(p.clone(), seq, &settings, range).unwrap();
let chunks = Cell::new(0);
audio
.measure(&settings, &sources, &|| {
chunks.set(chunks.get() + 1);
false
})
.unwrap();
let report = audio.loudness.unwrap();
let result = audio.rest(&sources).unwrap();
let (integrated, peak) = lufs(&result, 48_000);
eprintln!("target {target}: output {integrated:.3} LUFS, {peak:.3} dBTP, gain {:.3}, chunks {}", report.gain_db, chunks.get());
assert!(chunks.get() <= 5, "one raw pass plus at most four limited trials");
assert_eq!(result[0].len(), 48_000, "measuring must rewind output and limiter latency");
assert!(peak <= -1.8, "limiting must remain enabled: {peak}");
if target == -14.0 {
assert!((integrated - target).abs() < 0.15, "achieved {integrated}");
assert!(report.gain_db > target - report.measured_lufs + 0.3, "fixture must require limiter correction");
} else {
assert!(integrated < target - 1.0, "impossible target sacrifices loudness, not peak ceiling");
}
}
// Cancel after raw measurement and one limited trial, before further refinement.
// Record source reads at cancellation to prove no further mixing occurs.
let mut audio = audio_out::AudioOut::new(p, seq, &settings, range).unwrap();
let chunks = Cell::new(0);
let reads_at_cancel = Cell::new(None);
let cancel = || {
chunks.set(chunks.get() + 1);
if chunks.get() > 2 {
reads_at_cancel.set(Some(reads.load(Ordering::Relaxed)));
true
} else {
false
}
};
assert!(matches!(audio.measure(&settings, &sources, &cancel), Err(ExportError::Cancelled)));
assert_eq!(chunks.get(), 3);
assert_eq!(reads_at_cancel.get(), Some(reads.load(Ordering::Relaxed)));
}

View File

@ -162,14 +162,15 @@ fn gpu_blend_modes_match_cpu() {
layers: vec![
// opaque YUV on the left, a partially transparent ramp on the right; the top-right
// corner stays empty (destination alpha 0)
PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.35, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None },
PlanLayer { frame: base.clone(), matrix: Affine::translate(200.0, 40.0), opacity: 0.9, blend: Blend::Normal, fx: None },
PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.35, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None, input: None },
PlanLayer { frame: base.clone(), matrix: Affine::translate(200.0, 40.0), opacity: 0.9, blend: Blend::Normal, fx: None, input: None },
PlanLayer {
frame: top.clone(),
matrix: Affine::motion(Vec2::new(170.0, 95.0), Vec2::new(1.1, 1.1), 17.0, Vec2::new(80.0, 60.0)),
opacity: 0.8,
blend: mode,
fx: None,
input: None,
},
PlanLayer {
frame: demo.clone(),
@ -177,6 +178,7 @@ fn gpu_blend_modes_match_cpu() {
opacity: 0.6,
blend: mode,
fx: None,
input: None,
},
],
};
@ -235,8 +237,8 @@ fn gpu_blend_edge_cases_match_cpu() {
width: n,
height: n,
layers: vec![
PlanLayer { frame: back.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None },
PlanLayer { frame: src.clone(), matrix: Affine::IDENTITY, opacity, blend: mode, fx: None },
PlanLayer { frame: back.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None },
PlanLayer { frame: src.clone(), matrix: Affine::IDENTITY, opacity, blend: mode, fx: None, input: None },
],
};
let cpu = execute_cpu(&plan);
@ -276,8 +278,8 @@ fn gpu_dissolve_pattern_is_exact() {
width: w,
height: h,
layers: vec![
PlanLayer { frame: red.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: 1.0, blend: Blend::Normal, fx: None },
PlanLayer { frame: white.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: op, blend: Blend::Dissolve, fx: None },
PlanLayer { frame: red.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: 1.0, blend: Blend::Normal, fx: None, input: None },
PlanLayer { frame: white.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: op, blend: Blend::Dissolve, fx: None, input: None },
],
};
let cpu = execute_cpu(&plan).over_black_rgba8();
@ -303,7 +305,7 @@ fn normal_fast_path_and_blend_layers_off_output() {
};
let mut c = GpuCompositor::new(&dev, &q);
let grey = Arc::new(VideoFrame::rgba_f32(4, 4, vec![0.2; 64]));
let layer = |m: Affine, blend: Blend| PlanLayer { frame: grey.clone(), matrix: m, opacity: 1.0, blend, fx: None };
let layer = |m: Affine, blend: Blend| PlanLayer { frame: grey.clone(), matrix: m, opacity: 1.0, blend, fx: None, input: None };
let normal = FramePlan::Layers { width: 32, height: 16, layers: vec![layer(Affine::scale(8.0, 4.0), Blend::Normal)] };
c.composite(&normal);
assert!(c.backdrop.is_none());
@ -329,7 +331,14 @@ fn normal_fast_path_and_blend_layers_off_output() {
#[test]
fn quad_bounds_clamps_and_handles_non_finite() {
let l = |m: Affine| PlanLayer { frame: Arc::new(VideoFrame::rgba_f32(10, 10, vec![0.0; 400])), matrix: m, opacity: 1.0, blend: Blend::Screen, fx: None };
let l = |m: Affine| PlanLayer {
frame: Arc::new(VideoFrame::rgba_f32(10, 10, vec![0.0; 400])),
matrix: m,
opacity: 1.0,
blend: Blend::Screen,
fx: None,
input: None,
};
assert_eq!(quad_bounds(&l(Affine::translate(5.0, 5.0)), 100, 100), Some((4, 4, 12, 12)));
assert_eq!(quad_bounds(&l(Affine::translate(-5.0, 95.0)), 100, 100), Some((0, 94, 6, 6)));
assert_eq!(quad_bounds(&l(Affine::translate(f64::NAN, 0.0)), 100, 100), Some((0, 0, 100, 100)));

View File

@ -15,6 +15,13 @@ struct U {
p1: vec4<f32>, // y_off y_scale c_off c_scale (code units)
p2: vec4<f32>, // kr kb code_scale footprint
p3: vec4<f32>, // blend mode, alpha-plane scale (0: none), effect-source integer decimation, unused
// Colour management of HDR / wide-gamut Y'CbCr (`filmcraft_color::InputTransform`, #965):
q0: vec4<f32>, // on, curve (0 sdr, 1 linear, 2 pq, 3 hlg), HLG OOTF scale (0: none), reference white cd/m²
q1: vec4<f32>, // HLG OOTF luma weights, gamut matrix on
q2: vec4<f32>, // gamut matrix row 0, tone mapping on
q3: vec4<f32>, // gamut matrix row 1, tone-mapping source peak cd/m²
q4: vec4<f32>, // gamut matrix row 2, tone-mapping target peak cd/m²
q5: vec4<f32>, // desaturation luma weights, desaturation on
};
@group(0) @binding(0) var<uniform> u: U;
@ -85,6 +92,82 @@ fn to_linear(v: vec3<f32>) -> vec3<f32> {
return srgb_to_linear(clamp(v, vec3(0.0), vec3(1.0)));
}
// ---- colour management (`filmcraft_color::transform`), for `u.q0.x` = 1
// SMPTE ST 2084 on a 0..1 scale (1 = 10 000 cd/m²) and back.
fn pq1(e: f32) -> f32 {
let m1 = 0.1593017578125; let m2 = 78.84375; let c1 = 0.8359375; let c2 = 18.8515625; let c3 = 18.6875;
let p = pow(max(e, 0.0), 1.0 / m2);
return pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0 / m1);
}
fn pq1_inv(y: f32) -> f32 {
let m1 = 0.1593017578125; let m2 = 78.84375; let c1 = 0.8359375; let c2 = 18.8515625; let c3 = 18.6875;
let p = pow(clamp(y, 0.0, 1.0), m1);
return pow((c1 + c2 * p) / (1.0 + c3 * p), m2);
}
// ITU-R BT.2390 EETF (`bt2390_eetf`), source peak s to target peak d, in cd/m².
fn eetf(nits: f32, s: f32, d: f32) -> f32 {
if d >= s || nits <= 0.0 { return clamp(nits, 0.0, max(s, d)); }
let smax = pq1_inv(s / 10000.0);
let e1 = min(pq1_inv(nits / 10000.0) / smax, 1.0);
let max_lum = pq1_inv(d / 10000.0) / smax;
let ks = 1.5 * max_lum - 0.5;
var e2 = e1;
if e1 >= ks {
let t = (e1 - ks) / (1.0 - ks);
let t2 = t * t; let t3 = t2 * t;
e2 = (2.0 * t3 - 3.0 * t2 + 1.0) * ks + (t3 - 2.0 * t2 + t) * (1.0 - ks) + (-2.0 * t3 + 3.0 * t2) * max_lum;
}
return pq1(e2 * smax) * 10000.0;
}
// Signal → working linear: `InputTransform::convert` (curve, HLG OOTF, gamut matrix, tone
// mapping, desaturation into the working gamut). The CPU's decode tables clamp the signal to 0..1.
fn managed(rgb: vec3<f32>) -> vec3<f32> {
let v = clamp(rgb, vec3(0.0), vec3(1.0));
let curve = u32(u.q0.y);
let white = u.q0.w;
var c = v;
if curve == 0u { c = srgb_to_linear(v); }
if curve == 2u { c = vec3(pq1(v.r), pq1(v.g), pq1(v.b)) * (10000.0 / white); }
if curve == 3u { c = hlg_inv(v); }
if u.q0.z > 0.0 {
let ys = max(dot(u.q1.xyz, c), 0.0);
var g = 0.0;
if ys > 0.0 { g = u.q0.z * pow(ys, 0.2); }
c = c * g;
}
if u.q1.w > 0.5 {
c = vec3(dot(u.q2.xyz, c), dot(u.q3.xyz, c), dot(u.q4.xyz, c));
}
if u.q2.w > 0.5 {
let m = max(max(c.r, c.g), c.b);
if m > 0.0 {
let nits = m * white;
var k = 1.0;
if nits >= u.q3.w {
k = u.q4.w / white / m;
} else if nits > 1e-6 {
k = eetf(nits, u.q3.w, u.q4.w) / nits;
}
c = c * k;
}
}
if u.q5.w > 0.5 {
let mn = min(min(c.r, c.g), c.b);
if mn < 0.0 {
let y = dot(u.q5.xyz, c);
if y <= 0.0 {
c = vec3(0.0);
} else {
c = max(y + (c - y) * (y / (y - mn)), vec3(0.0));
}
}
}
return c;
}
// One linear premultiplied sample at source position p.
fn sample(p: vec2<f32>) -> vec4<f32> {
let kind = u32(u.p0.y);
@ -113,7 +196,9 @@ fn sample(p: vec2<f32>) -> vec4<f32> {
if u.p3.y > 0.0 {
a = clamp(load4(tex3, p).r * u.p3.y, 0.0, 1.0);
}
return vec4(to_linear(vec3(R, G, B)) * a, a);
var lin = to_linear(vec3(R, G, B));
if u.q0.x > 0.5 { lin = managed(vec3(R, G, B)); }
return vec4(lin * a, a);
}
// The layer's premultiplied linear colour at this pixel, opacity applied.

View File

@ -383,7 +383,12 @@ fn color_op(op: u32, c: vec3<f32>) -> vec3<f32> {
case OP_EXTRACT: {
let lo = u.p0.x; let hi = u.p0.y; let soft = u.p0.z;
let l = linear_to_srgb1(max(luma709(c), 0.0));
let inside = min(clamp((l - lo) / soft, 0.0, 1.0), clamp((hi - l) / soft, 0.0, 1.0));
// Hard cutoffs avoid both division by zero and an artificial narrow fade
// that amplifies transfer-function rounding relative to the CPU reference.
var inside = select(0.0, 1.0, l > lo && l < hi);
if soft > 0.0 {
inside = min(clamp((l - lo) / soft, 0.0, 1.0), clamp((hi - l) / soft, 0.0, 1.0));
}
return vec3(select(inside, 1.0 - inside, u.i1.x != 0u));
}
case OP_INVERT: {

View File

@ -126,8 +126,14 @@ fn cases() -> Vec<(&'static str, Vec<(&'static str, ParamValue)>, bool)> {
("gamma_correction", vec![("gamma", fl(-3.0))], false),
("levels", vec![("in_black", fl(20.0)), ("in_white", fl(230.0)), ("out_black", fl(10.0)), ("out_white", fl(240.0)), ("gamma", fl(150.0))], false),
("levels", vec![("in_black", fl(200.0)), ("in_white", fl(0.0)), ("gamma", fl(5.0)), ("out_white", fl(-50.0))], true),
("extract", vec![], true),
("extract", vec![("invert", ParamValue::Bool(true))], true),
("extract", vec![("black", fl(40.0)), ("white", fl(200.0)), ("softness", fl(30.0))], true),
("extract", vec![("black", fl(90.0)), ("white", fl(160.0)), ("invert", ParamValue::Bool(true))], true),
("extract", vec![("black", fl(90.0)), ("white", fl(160.0)), ("softness", fl(1.0))], true),
("extract", vec![("black", fl(40.0)), ("white", fl(200.0)), ("softness", fl(100.0))], true),
("extract", vec![("black", fl(160.0)), ("white", fl(90.0))], true),
("extract", vec![("black", fl(90.0)), ("white", fl(90.0))], true),
("invert", vec![("blend", fl(30.0))], false),
("invert", vec![("channel", ParamValue::Choice(1))], false),
("invert", vec![("channel", ParamValue::Choice(2)), ("blend", fl(70.0))], false),
@ -329,6 +335,34 @@ fn gpu_effects_match_cpu_exactly() {
assert!(failures.is_empty(), "GPU effects differ from the CPU:\n{}", failures.join("\n"));
}
#[test]
fn gpu_extract_zero_softness_has_no_fractional_edge() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut c = GpuCompositor::new(&dev, &q);
let (w, h) = (67, 41);
let px = picture(w, h);
let frame = VideoFrame::rgba_f32(w, h, px.clone());
let mut masks = Vec::new();
for invert in [false, true] {
let e = effect("extract", &[("black", fl(90.0)), ("white", fl(160.0)), ("invert", ParamValue::Bool(invert))]);
let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w as usize, h as usize).unwrap();
let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: 1, ops: vec![op] }).unwrap();
for (src, out) in px.as_chunks::<4>().0.iter().zip(gpu.as_chunks::<4>().0.iter()) {
assert_eq!(out[3], src[3], "Extract must preserve alpha");
assert!(out[0] == 0.0 || out[0] == out[3], "zero softness produced a fractional edge: {out:?}");
assert_eq!(out[0], out[1]);
assert_eq!(out[0], out[2]);
}
masks.push(gpu);
}
for ((normal, inverted), src) in masks[0].as_chunks::<4>().0.iter().zip(masks[1].as_chunks::<4>().0.iter()).zip(px.as_chunks::<4>().0.iter()) {
assert_eq!(normal[0] + inverted[0], src[3], "inversion must complement the premultiplied mask");
}
}
/// Chains run in order (and Unsharp keeps its original while blurring, between other blurs).
#[test]
fn gpu_effect_chains_match_cpu() {
@ -578,6 +612,7 @@ fn gpu_effect_layers_match_cpu_plan() {
opacity: 1.0,
blend: Blend::Normal,
fx: Some(Arc::new(LayerFx { size: (320, 180), decimation: 2, ops: ops(&chain_c, 320, 180, 0.5) })),
input: None,
},
PlanLayer {
frame: ramp.clone(),
@ -585,6 +620,7 @@ fn gpu_effect_layers_match_cpu_plan() {
opacity: 0.8,
blend: mode,
fx: Some(Arc::new(LayerFx { size: (160, 120), decimation: 1, ops: ops(&chain_a, 160, 120, 1.0) })),
input: None,
},
PlanLayer::new(ramp.clone(), Affine::translate(10.0, 20.0), 0.5, Blend::Normal),
PlanLayer {
@ -593,6 +629,7 @@ fn gpu_effect_layers_match_cpu_plan() {
opacity: 0.7,
blend: mode,
fx: Some(Arc::new(LayerFx { size: (320, 180), decimation: 1, ops: ops(&chain_b, 320, 180, 1.0) })),
input: None,
},
],
};

View File

@ -586,10 +586,11 @@ impl GpuCompositor {
chroma: up.chroma,
size: (f.width, f.height),
color: f.color,
input: None,
})
}
fn uniforms(src: &SrcInfo, m: &filmcraft_geom::Affine, opacity: f32, blend: Blend, out: (u32, u32)) -> [f32; 28] {
fn uniforms(src: &SrcInfo, m: &filmcraft_geom::Affine, opacity: f32, blend: Blend, out: (u32, u32)) -> [f32; UNIFORM_FLOATS] {
let (kr, kb) = src.color.matrix.kr_kb();
let bits_scale = src.code_levels / 256.0; // 2^(bits - 8), independent of texture encoding
let (yo, ys, co, cs) = match (src.color.range, src.kind) {
@ -609,7 +610,31 @@ impl GpuCompositor {
let footprint = (1.0 / sx.min(sy).max(1e-6)) as f32;
let taps = if footprint > 1.25 { footprint.ceil().min(4.0) } else { 1.0 };
let _ = Matrix::Bt709;
[
// colour management (q0..q5), stage by stage as `InputTransform::apply`
let q = src.input.map_or([0.0; 24], |p| {
let curve = match p.curve {
filmcraft_color::GpuCurve::Sdr => 0.0,
filmcraft_color::GpuCurve::Linear => 1.0,
filmcraft_color::GpuCurve::Pq => 2.0,
filmcraft_color::GpuCurve::Hlg => 3.0,
};
let (hl, hk) = p.hlg.unwrap_or(([0.0; 3], 0.0));
let mx = p.matrix.unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
let (sp, dp) = p.tone.unwrap_or((0.0, 0.0));
let dl = p.desaturate.unwrap_or([0.0; 3]);
let on = |b: bool| if b { 1.0 } else { 0.0 };
#[rustfmt::skip]
let q = [
1.0, curve, hk, filmcraft_color::REFERENCE_WHITE_NITS as f32,
hl[0], hl[1], hl[2], on(p.matrix.is_some()),
mx[0][0], mx[0][1], mx[0][2], on(p.tone.is_some()),
mx[1][0], mx[1][1], mx[1][2], sp,
mx[2][0], mx[2][1], mx[2][2], dp,
dl[0], dl[1], dl[2], on(p.desaturate.is_some()),
];
q
});
let base = [
m.a as f32,
m.b as f32,
m.c as f32,
@ -638,10 +663,14 @@ impl GpuCompositor {
src.alpha,
0.0,
0.0,
]
];
let mut u = [0.0; UNIFORM_FLOATS];
u[..28].copy_from_slice(&base);
u[28..].copy_from_slice(&q);
u
}
fn uniform_buffer(&self, u: &[f32; 28]) -> wgpu::Buffer {
fn uniform_buffer(&self, u: &[f32; UNIFORM_FLOATS]) -> wgpu::Buffer {
let bytes: Vec<u8> = u.iter().flat_map(|v| v.to_le_bytes()).collect();
let buf = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("layer-u"),
@ -679,7 +708,8 @@ impl GpuCompositor {
},
None => VideoFrame::rgba_f32(img.w as u32, img.h as u32, img.px.clone()),
};
owned = [PlanLayer { frame: Arc::new(frame), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }];
owned =
[PlanLayer { frame: Arc::new(frame), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }];
(img.w as u32, img.h as u32, &owned[..])
}
FramePlan::Composite { width, height, steps } => return self.composite_steps(*width, *height, steps, prep),
@ -736,7 +766,10 @@ impl GpuCompositor {
chroma: (1, 1),
size: (l.frame.width, l.frame.height),
color: l.frame.color,
input: None,
});
// HDR / wide-gamut Y'CbCr: the planner's input transform, in the shader (#965)
let src = SrcInfo { input: l.input.as_ref().filter(|_| src.kind == 2).and_then(|t| t.gpu_params()), ..src };
let views = self.uploads.get(k).map_or_else(|| std::array::from_fn(|_| self.dummy.clone()), |up| up.views.clone());
let job = match &l.fx {
Some(lfx) => {
@ -768,7 +801,8 @@ impl GpuCompositor {
// what the layer draws: the effect result (linear premultiplied RGBA) or the frame
let (u, tex) = match &job {
Some(j) => {
let s = SrcInfo { kind: 1, code_scale: 1.0, code_levels: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color };
let s =
SrcInfo { kind: 1, code_scale: 1.0, code_levels: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color, input: None };
(Self::uniforms(&s, &l.matrix, l.opacity, l.blend, (w, h)), [j.result.clone(), self.dummy.clone(), self.dummy.clone(), self.dummy.clone()])
}
None => {
@ -1108,8 +1142,13 @@ struct SrcInfo {
chroma: (u32, u32),
size: (u32, u32),
color: filmcraft_color::ColorInfo,
/// Colour management beyond the default decode (a Y'CbCr layer's [`PlanLayer::input`]).
input: Option<filmcraft_color::GpuInputParams>,
}
/// Floats in a layer's uniform block (`U` in `composite.wgsl`: 13 vec4).
const UNIFORM_FLOATS: usize = 52;
/// A render pass drawing into the accumulator.
fn accum_pass<'e>(enc: &'e mut wgpu::CommandEncoder, view: &wgpu::TextureView, load: wgpu::LoadOp<wgpu::Color>) -> wgpu::RenderPass<'e> {
enc.begin_render_pass(&wgpu::RenderPassDescriptor {

View File

@ -23,6 +23,7 @@ fn yuv_code_range_uniforms() {
chroma: (1, 1),
size: (1, 1),
color: filmcraft_color::ColorInfo { range, ..filmcraft_color::ColorInfo::REC709 },
input: None,
};
let u = GpuCompositor::uniforms(&src, &Affine::IDENTITY, 1.0, Blend::Normal, (1, 1));
// Full: 0..2^bits-1, neutral 2^(bits-1). Limited: BT.709 8-bit codes * 2^(bits-8).
@ -122,13 +123,14 @@ fn gpu_matches_cpu_plan() {
width: w,
height: h,
layers: vec![
PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.5, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None },
PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.5, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None, input: None },
PlanLayer {
frame: rgba,
matrix: Affine::motion(Vec2::new(200.0, 100.0), Vec2::new(0.4, 0.4), 12.0, Vec2::new(160.0, 90.0)),
opacity: 0.7,
blend: Blend::Normal,
fx: None,
input: None,
},
],
};
@ -287,8 +289,8 @@ fn prepared_upload_matches_inline_conversion() {
width: w as usize,
height: h as usize,
layers: vec![
PlanLayer { frame: yuv16, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None },
PlanLayer { frame: rgbaf, matrix: Affine::scale(0.5, 0.5), opacity: 0.8, blend: Blend::Normal, fx: None },
PlanLayer { frame: yuv16, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None },
PlanLayer { frame: rgbaf, matrix: Affine::scale(0.5, 0.5), opacity: 0.8, blend: Blend::Normal, fx: None, input: None },
],
};
let image = FramePlan::Image(filmcraft_render::Image { w: w as usize, h: h as usize, px: f32_layer });
@ -317,8 +319,11 @@ fn upload_cache_keeps_buffers_alive() {
let mut c = GpuCompositor::new(&dev, &q);
let px = Arc::new(vec![200u8; 16 * 8 * 4]);
let frame = Arc::new(VideoFrame { width: 16, height: 8, data: PixelData::Rgba8(px.clone()), ..(*yuv_frame(16, 8)).clone() });
let plan =
FramePlan::Layers { width: 16, height: 8, layers: vec![PlanLayer { frame, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }] };
let plan = FramePlan::Layers {
width: 16,
height: 8,
layers: vec![PlanLayer { frame, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }],
};
c.composite(&plan);
drop(plan);
assert!(Arc::strong_count(&px) > 1, "cached upload must own its pixel buffer");
@ -467,8 +472,8 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() {
width: w,
height: h,
layers: vec![
PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None },
PlanLayer { frame: yuv_alpha_frame(320, 180, bits), matrix: Affine::IDENTITY, opacity: 0.9, blend: Blend::Normal, fx: None },
PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None },
PlanLayer { frame: yuv_alpha_frame(320, 180, bits), matrix: Affine::IDENTITY, opacity: 0.9, blend: Blend::Normal, fx: None, input: None },
],
};
let cpu = execute_cpu(&plan).over_black_rgba8();
@ -484,7 +489,7 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() {
let bg = execute_cpu(&FramePlan::Layers {
width: w,
height: h,
layers: vec![PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }],
layers: vec![PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }],
})
.over_black_rgba8();
let (l, r, b0) = (at(&gpu, 1), at(&gpu, w - 2), at(&bg, 1));
@ -500,3 +505,50 @@ fn fast_half_float_conversion_matches_the_reference_for_every_value() {
assert!(fast.to_bits() == slow.to_bits() || (fast.is_nan() && slow.is_nan()), "{h:#06x}: {fast} vs {slow}");
}
}
/// HDR and wide-gamut Y'CbCr on a Rec. 709 sequence (#965): the shader's colour management
/// (curve, HLG OOTF, BT.2020 → 709 matrix, BT.2390 tone mapping, desaturation) matches the CPU
/// running the same `InputTransform`.
#[test]
fn hdr_input_gpu_matches_cpu() {
use filmcraft_color::{ColorInfo, ColorPipeline, ColorSpace, InputTransform, Matrix, Primaries, Transfer};
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut c = GpuCompositor::new(&dev, &q);
// a 10-bit luma ramp (to the top code) under neutral, saturated and extreme chroma, 4:4:4
let (w, h) = (256u32, 6u32);
let chroma = [(512, 512), (300, 760), (760, 300), (64, 512), (960, 960), (512, 64)];
let y: Vec<u16> = (0..h).flat_map(|_| (0..w).map(|x| (64 + x * 876 / 255) as u16)).collect();
let cb: Vec<u16> = (0..h).flat_map(|r| std::iter::repeat_n(chroma[r as usize].0, w as usize)).collect();
let cr: Vec<u16> = (0..h).flat_map(|r| std::iter::repeat_n(chroma[r as usize].1, w as usize)).collect();
let data = PixelData::Yuv16 { planes: [Arc::new(y), Arc::new(cb), Arc::new(cr)], chroma: Chroma::C444, bits: 10, alpha: None };
let hdr = |transfer| ColorInfo { matrix: Matrix::Bt2020Ncl, transfer, primaries: Primaries::Bt2020, range: Range::Limited };
let cases = [
("HLG", hdr(Transfer::Hlg), None),
("PQ", hdr(Transfer::Pq), None),
("PQ 4000 cd/m²", hdr(Transfer::Pq), Some(4000.0)),
("BT.2020 SDR", ColorInfo { primaries: Primaries::Bt2020, ..ColorInfo::REC709 }, None),
];
for (name, color, peak) in cases {
let frame = Arc::new(VideoFrame { width: w, height: h, data: data.clone(), color, par: (1, 1), pts: Default::default() });
let cs = ColorSpace::from_info(&color);
let t = Arc::new(InputTransform::new(cs, color.range, &ColorPipeline::REC709, peak));
assert!(t.gpu_params().is_some(), "{name}");
let layer = PlanLayer { input: Some(t), ..PlanLayer::new(frame.clone(), Affine::IDENTITY, 1.0, Blend::Normal) };
let plan = FramePlan::Layers { width: w as usize, height: h as usize, layers: vec![layer] };
let cpu = execute_cpu(&plan).over_black_rgba8();
c.composite(&plan);
let (_, _, gpu) = c.read_output().expect("readback");
let error = cpu.iter().zip(&gpu).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
// and the stage does something: the default decode of the same frame is far off
let plain = FramePlan::Layers { width: w as usize, height: h as usize, layers: vec![PlanLayer::new(frame, Affine::IDENTITY, 1.0, Blend::Normal)] };
c.composite(&plain);
let (_, _, unmanaged) = c.read_output().expect("readback");
let apart = cpu.iter().zip(&unmanaged).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
eprintln!("{name}: max error {error}, unmanaged differs by up to {apart}");
assert!(error <= 2, "{name}: GPU differs from the CPU by up to {error} codes");
assert!(apart > 20, "{name}: colour management changed nothing ({apart})");
}
}

View File

@ -4,6 +4,11 @@
use filmcraft_h264::{Decoder, Picture};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, PoisonError};
// Tests share fixture names within each binary. Serialize the existence check,
// generation and publication so a late producer cannot replace a file in use.
static FIXTURE_GENERATION: Mutex<()> = Mutex::new(());
/// One encoder configuration of the fixture matrix.
pub struct Fixture {
@ -334,6 +339,7 @@ fn run(cmd: &mut Command) -> bool {
/// Returns None (with a message) when ffmpeg is unavailable.
pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> {
let ff = filmcraft_testkit::ffmpeg_or_skip(f.name)?;
let _generation = FIXTURE_GENERATION.lock().unwrap_or_else(PoisonError::into_inner);
let dir = fixtures_dir();
let h264 = dir.join(format!("{}.h264", f.name));
let yuv = dir.join(format!("{}.yuv", f.name));

View File

@ -148,3 +148,50 @@ fn generate_fixtures() {
filmcraft_testkit::fixtures::generate_and_report(&format!("h264/{}", f.name), &outs, || common::ensure(f));
}
}
/// Concurrent consumers must publish one complete fixture without replacing an open file.
#[test]
fn concurrent_fixture_publication_is_safe() {
if filmcraft_testkit::ffmpeg_or_skip("concurrent_fixture_publication").is_none() {
return;
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let name = Box::leak(format!("concurrent_fixture_{}_{stamp}", std::process::id()).into_boxed_str());
let fixture =
common::Fixture { name, source: "testsrc2", width: 64, height: 48, frames: 2, filter: "", args: &["-preset", "ultrafast", "-x264-params", "keyint=1"] };
let open_published = |path: &std::path::Path| {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
// Like the external decoder, allow reads but deny replacement while open.
options.share_mode(1);
}
options.open(path).unwrap()
};
let start = std::sync::Barrier::new(4);
let results = std::thread::scope(|scope| {
let jobs: Vec<_> = (0..4)
.map(|_| {
scope.spawn(|| {
start.wait();
let paths = common::ensure(&fixture).expect("software fixture must generate");
// Keep both published files open until every producer has finished.
let files = (open_published(&paths.0), open_published(&paths.1));
(paths, files)
})
})
.collect();
jobs.into_iter().map(|j| j.join().expect("fixture producer panicked")).collect::<Vec<_>>()
});
let paths = results[0].0.clone();
for (got, _) in &results {
assert_eq!(got, &paths);
}
assert!(!std::fs::read(&paths.0).unwrap().is_empty());
assert_eq!(std::fs::metadata(&paths.1).unwrap().len(), 2 * (64 * 48 + 2 * 32 * 24));
drop(results);
std::fs::remove_file(paths.0).unwrap();
std::fs::remove_file(paths.1).unwrap();
}

View File

@ -273,9 +273,10 @@ impl Exp<'_, '_> {
let mut s = self.stack_json(base, &it.name, Some((it.source_in, it.duration, rate)));
self.stack.pop();
s["effects"] = json!(effects);
s["markers"] = json!(markers);
s["enabled"] = json!(it.enabled);
s["metadata"]["filmcraft"]["clip"] = meta["filmcraft"].clone();
// Nested sequence markers stay on the Stack; clip instance markers live on the clip metadata.
s["metadata"]["filmcraft"]["clip"]["markers"] = json!(markers);
return s;
}
let _ = generator_of(self.p, base);
@ -665,7 +666,10 @@ impl Imp<'_, '_> {
let name = name_of(c);
let mut ti = self.b.clip(item, kind, if name.is_empty() { "Nested Sequence" } else { &name }, pos, dur, src_in);
let clip_meta = meta.and_then(|m| m.get("clip")).cloned();
// Stack markers belong to the nested sequence, not the clip instance.
// Stack markers belong to the nested sequence; instance markers are on filmcraft.clip.
if let Some(ref cm) = clip_meta {
ti.markers = self.markers(cm);
}
self.apply_clip_meta(&mut ti, c, clip_meta.as_ref());
Some(ti)
}

View File

@ -0,0 +1,77 @@
//! Nested sequence markers vs clip instance markers in OTIO (issue #901).
//!
//! FCP7 XML and FCPXML already keep these on the right objects. OTIO must not drop the nested
//! sequence's markers when the using clip has none, and must not put clip instance markers on the
//! nested sequence.
mod support;
use filmcraft_interchange::{ExportOptions, Format};
use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind};
use filmcraft_time::{FrameRate, Tick};
use support::*;
fn nest_project(clip_has_marker: bool) -> (filmcraft_project::Project, filmcraft_project::ItemId, filmcraft_project::ItemId) {
let r = FrameRate::FPS_24;
let mut p = filmcraft_project::Project::new("P");
let a = media(&mut p, "/m/a.mov", true, true, r);
let nest = sequence(&mut p, "Inner", r, false);
clip(&mut p, nest, TrackKind::Video, 0, a, 0, 48, 0);
{
let q = p.sequence_mut(nest).unwrap();
q.markers.push(Marker {
id: MarkerId(1),
start: r.tick_of(12),
duration: Tick::ZERO,
name: "on nest".into(),
comment: String::new(),
kind: MarkerKind::Comment,
color: Label::Green,
});
}
let s = sequence(&mut p, "Outer", r, false);
let nest_clip = clip(&mut p, s, TrackKind::Video, 0, nest, 0, 48, 0);
if clip_has_marker {
let q = p.sequence_mut(s).unwrap();
let (_, it) = q.find_item_mut(nest_clip).unwrap();
it.markers.push(Marker {
id: MarkerId(2),
start: r.tick_of(6),
duration: Tick::ZERO,
name: "on clip".into(),
comment: String::new(),
kind: MarkerKind::Comment,
color: Label::Yellow,
});
}
(p, s, nest)
}
fn names(ms: &[Marker]) -> Vec<&str> {
ms.iter().map(|m| m.name.as_str()).collect()
}
#[test]
fn otio_keeps_nested_sequence_markers_when_clip_has_none() {
let (p, s, nest) = nest_project(false);
let (imp, text, rep) = roundtrip(&p, s, Format::Otio, &ExportOptions::default());
assert!(!rep.has_warnings(), "{rep}");
let outer = only_seq(&imp);
let nest_clip = &imp.project.sequence(outer).unwrap().video_tracks[0].items[0];
assert!(nest_clip.markers.is_empty(), "clip instance must stay marker-free\n{text}");
let inner = nest_clip.item;
assert_eq!(imp.project.item(inner).unwrap().name, "Inner");
assert_eq!(names(&imp.project.sequence(inner).unwrap().markers), names(&p.sequence(nest).unwrap().markers), "{text}");
}
#[test]
fn otio_keeps_clip_instance_markers_off_the_nested_sequence() {
let (p, s, nest) = nest_project(true);
let (imp, text, _) = roundtrip(&p, s, Format::Otio, &ExportOptions::default());
let outer = only_seq(&imp);
let nest_clip = &imp.project.sequence(outer).unwrap().video_tracks[0].items[0];
assert_eq!(names(&nest_clip.markers), vec!["on clip"], "{text}");
assert_eq!(nest_clip.markers[0].start, FrameRate::FPS_24.tick_of(6));
let inner = nest_clip.item;
assert_eq!(names(&imp.project.sequence(inner).unwrap().markers), names(&p.sequence(nest).unwrap().markers), "{text}");
}

View File

@ -1,16 +1,23 @@
//! DPX (`.dpx`) stills, decoded clean-room from SMPTE ST 268 (Digital Picture Exchange).
//!
//! The first image element is decoded: luma, RGB, RGBA or ABGR, 8, 10, 12 or 16 bits per
//! component, big-endian (`SDPX`) or little-endian (`XPDS`) files. 10-bit components are filled into
//! 32-bit words (packing 1, method A: two padding bits at the bottom; packing 2, method B: at the
//! top), 12-bit components fill 16-bit words the same two ways. Scan lines start on a 32-bit
//! boundary and may carry end-of-line padding. Orientations 0–3 (either scan direction, either line
//! order) are honoured. Code values map linearly onto 8 bits (full range); a logarithmic (printing
//! density) transfer is shown as stored, without a film-log conversion.
//! The first image element is decoded: luma, RGB, RGBA, ABGR, or Y'CbCr (CbYCrY 4:2:2, CbYACrYA
//! 4:2:2:4, CbYCr 4:4:4, CbYCrA 4:4:4:4), 8, 10, 12 or 16 bits per component, big-endian (`SDPX`) or
//! little-endian (`XPDS`) files. 10-bit components are filled into 32-bit words (packing 1, method
//! A: two padding bits at the bottom; packing 2, method B: at the top), 12-bit components fill
//! 16-bit words the same two ways; packed 10/12-bit components (packing 0) follow each other with no
//! padding, the first in the low bits of a 32-bit word, a component crossing into the next word
//! continuing in its low bits. Scan lines start on a 32-bit boundary and may carry end-of-line
//! padding. Orientations 0–3 (either scan direction, either line order) are honoured. RGB and luma
//! code values map linearly onto 8 bits (full range); a logarithmic (printing density) transfer is
//! shown as stored, without a film-log conversion. Y'CbCr is converted with the matrix the
//! colorimetric field names (BT.709, or BT.601 for the 601 and composite video codes; by picture
//! size when unspecified), in video range unless the reference low / high data codes span the
//! whole code range; 4:2:2 chroma is shared by the two pixels of a pair.
//!
//! Not decoded (an "unsupported" error): run-length encoded elements, the 10/12-bit "packed"
//! layout (packing 0), Y'CbCr descriptors, 1-bit and floating-point components, transposed
//! orientations.
//! Not decoded (an "unsupported" error): run-length encoded elements, 1-bit and floating-point
//! components, transposed orientations.
use filmcraft_color::{Matrix, Range, normalize_c, normalize_y, ycbcr_to_rgb};
use crate::{MediaError, Result, still::Raster};
@ -63,6 +70,8 @@ enum Packing {
Words { shift: u32 },
/// Three 10-bit components per 32-bit word, the first in the high bits, above `pad` bits.
Ten { pad: u32 },
/// `bits`-bit components back to back in 32-bit words, low bits first (packing 0).
Packed { bits: u32 },
}
impl Packing {
@ -73,6 +82,7 @@ impl Packing {
Packing::Bytes => comps,
Packing::Words { .. } => comps.checked_mul(2)?,
Packing::Ten { .. } => comps.div_ceil(3).checked_mul(4)?,
Packing::Packed { bits } => comps.checked_mul(bits as usize)?.div_ceil(32).checked_mul(4)?,
};
raw.checked_next_multiple_of(4)
}
@ -93,6 +103,69 @@ impl Packing {
}
}
}
Packing::Packed { bits } => {
let mask = (1u64 << bits) - 1;
for i in 0..comps {
let bit = i * bits as usize;
let at = bit / 32 * 4;
let pair = u64::from(order.u32(line, at)) | (u64::from(order.u32(line, at + 4)) << 32);
out.push(((pair >> (bit % 32)) & mask) as u16);
}
}
}
}
}
/// What the stored components of a pixel are.
#[derive(Clone, Copy)]
enum Components {
/// `n` components per pixel; the indices of R, G, B and A among them (alpha `None`: opaque).
Rgb { n: usize, map: [Option<usize>; 4] },
/// `n` components per pixel: Cb (4:2:2: Cb on even pixels, Cr on odd ones), Y', then Cr (4:4:4
/// only), then alpha when `alpha`.
YCbCr { n: usize, sub: bool, alpha: bool, matrix: Matrix, range: Range },
}
impl Components {
fn per_pixel(self) -> usize {
match self {
Components::Rgb { n, .. } | Components::YCbCr { n, .. } => n,
}
}
fn has_alpha(self) -> bool {
match self {
Components::Rgb { map, .. } => map[3].is_some(),
Components::YCbCr { alpha, .. } => alpha,
}
}
/// RGBA8 of pixel `x` from the unpacked component values of its scan line (`bits` per value).
fn pixel(self, line: &[u16], x: usize, bits: u32) -> [u8; 4] {
let max = (1u32 << bits) - 1;
let to8 = |v: u32| ((v.min(max) * 255 + max / 2) / max) as u8;
let get = |i: usize| line.get(i).map(|&v| u32::from(v));
match self {
Components::Rgb { n, map } => map.map(|slot| match slot {
Some(i) => get(x * n + i).map_or(0, to8),
None => 255,
}),
Components::YCbCr { n, sub, alpha, matrix, range } => {
let base = x * n;
let (cb, cr) = if sub {
let pair = (x & !1) * n;
(get(pair), get(pair + n))
} else {
(get(base), get(base + 2))
};
// a missing Cr (odd width, 4:2:2) is neutral
let mid = 1u32 << (bits - 1);
let y = normalize_y(get(base + 1).unwrap_or(0), bits, range);
let rgb = ycbcr_to_rgb(y, normalize_c(cb.unwrap_or(mid), bits, range), normalize_c(cr.unwrap_or(mid), bits, range), matrix);
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let a = if alpha { get(base + n - 1).map_or(0, to8) } else { 255 };
[c(rgb[0]), c(rgb[1]), c(rgb[2]), a]
}
}
}
}
@ -110,7 +183,10 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
let orientation = order.u16(bytes, 768);
let (w, h) = (order.u32(bytes, 772) as usize, order.u32(bytes, 776) as usize);
let el = ELEMENT;
let low_code = order.u32(bytes, el + 4);
let high_code = order.u32(bytes, el + 12);
let descriptor = bytes.get(el + 20).copied().unwrap_or(0);
let colorimetric = bytes.get(el + 22).copied().unwrap_or(0);
let bits = bytes.get(el + 23).copied().unwrap_or(0);
let packing = order.u16(bytes, el + 24);
let encoding = order.u16(bytes, el + 26);
@ -120,14 +196,6 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
return Err(err(name, "empty picture"));
}
let pixels = w.checked_mul(h).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?;
// component order per pixel: indices of R, G, B, A (None: opaque) in the stored components
let (n, map): (usize, [Option<usize>; 4]) = match descriptor {
6 => (1, [Some(0), Some(0), Some(0), None]),
50 => (3, [Some(0), Some(1), Some(2), None]),
51 => (4, [Some(0), Some(1), Some(2), Some(3)]),
52 => (4, [Some(3), Some(2), Some(1), Some(0)]),
d => return Err(unsupported(name, format!("descriptor {d}"))),
};
if encoding != 0 {
return Err(unsupported(name, "run-length encoding".into()));
}
@ -138,6 +206,7 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
(10, 2) => Packing::Ten { pad: 0 },
(12, 1) => Packing::Words { shift: 4 },
(12, 2) => Packing::Words { shift: 0 },
(10 | 12, 0) => Packing::Packed { bits: u32::from(bits) },
(b, p) => return Err(unsupported(name, format!("{b}-bit components with packing {p}"))),
};
let (flip_x, flip_y) = match orientation {
@ -147,6 +216,27 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
3 => (true, true),
o => return Err(unsupported(name, format!("orientation {o}"))),
};
let max = (1u32 << bits) - 1;
// video range unless the reference codes span every code value
let range = if low_code == 0 && high_code == max { Range::Full } else { Range::Limited };
let matrix = match colorimetric {
5 | 6 => Matrix::Bt709,
7..=10 => Matrix::Bt601,
_ => filmcraft_frame::default_matrix(w as u32, h as u32),
};
let ycbcr = |n, sub, alpha| Components::YCbCr { n, sub, alpha, matrix, range };
let components = match descriptor {
6 => Components::Rgb { n: 1, map: [Some(0), Some(0), Some(0), None] },
50 => Components::Rgb { n: 3, map: [Some(0), Some(1), Some(2), None] },
51 => Components::Rgb { n: 4, map: [Some(0), Some(1), Some(2), Some(3)] },
52 => Components::Rgb { n: 4, map: [Some(3), Some(2), Some(1), Some(0)] },
100 => ycbcr(2, true, false),
101 => ycbcr(3, true, true),
102 => ycbcr(3, false, false),
103 => ycbcr(4, false, true),
d => return Err(unsupported(name, format!("descriptor {d}"))),
};
let n = components.per_pixel();
let comps = w.checked_mul(n).ok_or_else(|| err(name, "picture too large"))?;
let pad = if eol_padding == UNDEFINED { 0 } else { eol_padding as usize };
let stride = layout.line_bytes(comps).and_then(|l| l.checked_add(pad)).ok_or_else(|| err(name, "picture too large"))?;
@ -157,8 +247,7 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
// the last line needs no end-of-line padding
let size = stride.checked_mul(h - 1).and_then(|s| s.checked_add(stride - pad)).ok_or_else(|| err(name, "picture too large"))?;
let data = offset.checked_add(size).and_then(|end| bytes.get(offset..end)).ok_or_else(|| err(name, "truncated image data"))?;
let max = (1u32 << bits) - 1;
let to8 = |v: u16| ((u32::from(v).min(max) * 255 + max / 2) / max) as u8;
let bits = u32::from(bits);
let mut rgba = vec![0u8; pixels * 4];
let mut line = Vec::with_capacity(comps);
for y in 0..h {
@ -168,19 +257,12 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
for x in 0..w {
let dx = if flip_x { w - 1 - x } else { x };
let o = (dy * w + dx) * 4;
let Some(px) = rgba.get_mut(o..o + 4) else { continue };
for (c, slot) in map.iter().enumerate() {
let v = match slot {
Some(i) => line.get(x * n + i).map_or(0, |&v| to8(v)),
None => 255,
};
if let Some(d) = px.get_mut(c) {
*d = v;
}
if let Some(px) = rgba.get_mut(o..o + 4) {
px.copy_from_slice(&components.pixel(&line, x, bits));
}
}
}
let has_alpha = map[3].is_some() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255);
let has_alpha = components.has_alpha() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255);
Ok(Raster { width: w as u32, height: h as u32, rgba, has_alpha })
}
@ -250,14 +332,84 @@ mod tests {
assert_eq!(r.rgba, [255, 0, 0, 128]);
}
/// Packing 0: components back to back, low bits first, each line on a 32-bit boundary.
fn packed(lines: &[&[u32]], bits: u32, big: bool) -> Vec<u8> {
let mut out = Vec::new();
for line in lines {
let (mut acc, mut n) = (0u64, 0u32);
for &v in *line {
acc |= u64::from(v) << n;
n += bits;
while n >= 32 {
let w = acc as u32;
out.extend_from_slice(&if big { w.to_be_bytes() } else { w.to_le_bytes() });
acc >>= 32;
n -= 32;
}
}
if n > 0 {
let w = acc as u32;
out.extend_from_slice(&if big { w.to_be_bytes() } else { w.to_le_bytes() });
}
}
out
}
#[test]
fn packed_ten_and_twelve_bit_components_cross_words() {
// 2 × 2 RGB 10-bit: 6 components (60 bits) per line, the fourth straddles two words
let lines: [&[u32]; 2] = [&[1023, 0, 512, 4, 1019, 1023], &[0, 1023, 0, 1023, 1023, 1023]];
for big in [true, false] {
let r = decode("a.dpx", &file(big, 2, 2, 50, 10, 0, 0, &packed(&lines, 10, big))).unwrap();
assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 255, 255, 0, 255, 0, 255, 255, 255, 255, 255]);
}
// 3 × 1 RGB 12-bit: 9 components, 108 bits in 4 words
let line: [&[u32]; 1] = [&[4095, 0, 2048, 16, 4079, 0, 0, 0, 4095]];
let r = decode("a.dpx", &file(true, 3, 1, 50, 12, 0, 0, &packed(&line, 12, true))).unwrap();
assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255, 0, 0, 255, 255]);
}
#[test]
fn ycbcr_descriptors_convert_to_rgb() {
// 8-bit CbYCrY, video range, BT.709 (colorimetric 6): black, white, then a red pixel pair
// (the 709 code triple of red is rounded, hence green 1)
let mut f = file(true, 4, 1, 100, 8, 0, 0, &[128, 16, 128, 235, 102, 63, 240, 63]);
f[ELEMENT + 22] = 6;
let r = decode("a.dpx", &f).unwrap();
assert_eq!(r.rgba, [0, 0, 0, 255, 255, 255, 255, 255, 255, 1, 0, 255, 255, 1, 0, 255]);
assert!(!r.has_alpha);
// red in BT.601 (colorimetric 7): the 601 code triple, rounded
let mut f = file(true, 2, 1, 100, 8, 0, 0, &[90, 81, 240, 81]);
f[ELEMENT + 22] = 7;
assert_eq!(decode("a.dpx", &f).unwrap().rgba, [254, 0, 0, 255, 254, 0, 0, 255]);
// 10-bit CbYCr filled (method A), full range from the reference codes: mid grey
let word = |a: u32, b: u32, c: u32| ((a << 22) | (b << 12) | (c << 2)).to_be_bytes();
let mut f = file(true, 1, 1, 102, 10, 1, 0, &word(512, 512, 512));
f[ELEMENT + 4..ELEMENT + 8].copy_from_slice(&0u32.to_be_bytes());
f[ELEMENT + 12..ELEMENT + 16].copy_from_slice(&1023u32.to_be_bytes());
assert_eq!(decode("a.dpx", &f).unwrap().rgba, [128, 128, 128, 255]);
// 16-bit CbYCrA: white at half opacity
let v = |x: u16| x.to_be_bytes();
let data: Vec<u8> = [v(32768), v(60160), v(32768), v(32768)].concat();
let r = decode("a.dpx", &file(true, 1, 1, 103, 16, 0, 0, &data)).unwrap();
assert_eq!(r.rgba, [255, 255, 255, 128]);
assert!(r.has_alpha);
// 8-bit CbYACrYA, 3 pixels: the last pixel has no Cr and is neutral
let r = decode("a.dpx", &file(true, 3, 1, 101, 8, 0, 0, &[128, 235, 255, 128, 16, 0, 128, 235, 255, 0, 0, 0])).unwrap();
assert_eq!(r.rgba, [255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255]);
assert!(r.has_alpha);
}
#[test]
fn hostile_headers_fail_cleanly() {
// huge dimensions with no data, truncated data, unsupported layouts
assert!(decode("a.dpx", &file(true, u32::MAX, u32::MAX, 50, 10, 1, 0, &[])).is_err());
assert!(decode("a.dpx", &file(true, 4096, 4096, 50, 10, 1, 0, &[0; 64])).is_err());
assert!(decode("a.dpx", &file(true, 2, 2, 50, 10, 1, 0, &[0; 12])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 100, 8, 0, 0, &[0; 4])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 0, 0, &[0; 4])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 150, 8, 0, 0, &[0; 4])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 3, 0, &[0; 4])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 50, 16, 0, 0, &[0; 4])).is_err());
assert!(decode("a.dpx", &file(true, 3, 3, 100, 12, 0, 0, &[0; 20])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 50, 32, 0, 0, &[0; 16])).is_err());
assert!(decode("a.dpx", &file(true, 1, 1, 50, 8, 0, 5, &[0; 4])).is_err());
assert!(decode("a.dpx", &file(true, 0, 1, 50, 8, 0, 0, &[0; 4])).is_err());

View File

@ -62,6 +62,7 @@ impl WavSource {
let mut data = None;
let mut bext_tr = None;
let mut ixml_tr = None;
let mut ds64_data = None;
for _ in 0..MAX_CHUNKS {
let Some(body) = pos.checked_add(8).filter(|&b| b <= len) else { break };
let mut hdr = [0u8; 8];
@ -69,8 +70,12 @@ impl WavSource {
let id = &hdr[0..4];
let mut clen = u64::from(le32(&hdr, 4));
let rest = len - body;
if id == b"data" && (clen == 0xFFFF_FFFF || clen > rest) {
clen = rest;
if id == b"data" {
// RF64 sentinel: the real size is ds64.dataSize (EBU 3306 §3.4), else the rest of the file
if clen == 0xFFFF_FFFF {
clen = ds64_data.unwrap_or(rest);
}
clen = clen.min(rest);
}
if clen > rest {
break;
@ -87,6 +92,11 @@ impl WavSource {
}
fmt = Some((tag, ch, rate, bits));
}
// ds64: riffSize u64, dataSize u64, sampleCount u64, ...
b"ds64" if clen >= 16 => {
let b = read_range(&*reader, body + 8, 8).map_err(io)?;
ds64_data = Some(u64::from(le32(&b, 0)) | u64::from(le32(&b, 4)) << 32);
}
b"data" => data = Some((body, clen)),
// bext: Description 256, Originator 32, OriginatorReference 32, OriginationDate 10,
// OriginationTime 8, then TimeReference (low u32, high u32)

View File

@ -1595,9 +1595,10 @@ impl Project {
// a multi-camera source sequence edits in as a multi-camera clip showing its first angle
let multicam =
it.as_sequence().and_then(|q| q.multicam.as_ref()).map(|m| MulticamSel { enabled: true, angle: m.first_video_angle().unwrap_or(0) as u32 });
let source_channels = match (kind, &it.kind) {
(TrackKind::Audio, ItemKind::Media(m)) => m.interpret.audio_channels.as_ref().and_then(|a| a.clips.first().cloned()).unwrap_or_default(),
_ => Vec::new(),
let source_channels = if kind == TrackKind::Audio {
self.resolve_media(item).and_then(|(_, m, _)| m.interpret.audio_channels.as_ref()).and_then(|a| a.clips.first().cloned()).unwrap_or_default()
} else {
Vec::new()
};
let id = ClipId(self.alloc_id());
let dur = seq_rate.snap_nearest(source.duration).max(seq_rate.frame_duration());
@ -1671,6 +1672,16 @@ fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source:
}
impl Project {
/// Available picture and sound, resolving a subclip to its parent media. Missing parents,
/// cycles and overly deep subclip chains carry neither stream.
pub fn source_streams(&self, item: ItemId) -> (bool, bool) {
let Some(pi) = self.item(item) else { return (false, false) };
match &pi.kind {
ItemKind::Subclip { .. } => self.resolve_media(item).map_or((false, false), |(_, m, _)| (m.info.has_video(), m.info.has_audio())),
_ => (pi.has_video(), pi.has_audio()),
}
}
/// Resolve media through at most sixteen items, preserving the outermost subclip range.
/// Missing parents, cycles and longer chains are unavailable media.
pub fn resolve_media(&self, item: ItemId) -> Option<(ItemId, &MediaClip, Option<TimeRange>)> {
@ -1895,6 +1906,7 @@ mod tests {
fn media_resolution_bounds_depth_and_preserves_the_outer_range() {
let (mut p, media, _) = demo_project();
assert_eq!(p.resolve_media(media).map(|(id, _, range)| (id, range)), Some((media, None)));
let streams = p.source_streams(media);
let mut parent = media;
let mut last_range = TimeRange::default();
for n in 0..15 {
@ -1902,9 +1914,14 @@ mod tests {
parent = p.add_item("Sub", Label::Iris, ItemKind::Subclip { parent, range: last_range, restrict_trims: false }, None);
}
assert_eq!(p.resolve_media(parent).map(|(id, _, range)| (id, range)), Some((media, Some(last_range))));
assert_eq!(p.source_streams(parent), streams);
let too_deep = p.add_item("Too deep", Label::Iris, ItemKind::Subclip { parent, range: last_range, restrict_trims: false }, None);
assert!(p.resolve_media(too_deep).is_none());
assert!(p.resolve_media(ItemId(u64::MAX)).is_none());
assert_eq!(p.source_streams(too_deep), (false, false));
assert_eq!(p.source_streams(ItemId(u64::MAX)), (false, false));
p.item_mut(parent).unwrap().kind = ItemKind::Subclip { parent, range: last_range, restrict_trims: false };
assert_eq!(p.source_streams(parent), (false, false));
}
#[test]

View File

@ -351,7 +351,8 @@ pub fn clip_is_mono(item: &TrackItem, sources: &dyn SourceProvider) -> Option<bo
}
/// The clip's audio (after speed/reverse) for timeline samples `[x0, x0 + len)` as `w` channels
/// (2: stereo, mono sources on both sides; 6: 5.1), with silence outside the clip.
/// (2: stereo, mono sources on both sides, 5.1 sources folded down; 6: 5.1), with silence outside
/// the clip.
fn raw_channels(item: &TrackItem, src: SourceAudio, x0: i64, len: usize, sr: u32, w: usize) -> Vec<Vec<f32>> {
let mut out = vec![vec![0.0f32; len]; w];
let item_s0 = item.start.to_units_floor(sr as i64);
@ -394,6 +395,17 @@ fn raw_channels(item: &TrackItem, src: SourceAudio, x0: i64, len: usize, sr: u32
return out;
}
if w == 2 {
// a 5.1 source (or a six-channel pick) folds down with ITU-R BS.775, as the Mix and the
// Source Monitor do: picking its first two channels would drop C (dialogue) and surrounds
if let Some(six) = source_51_fold(item, buf.channel_count()) {
let picked: Vec<&[f32]> = six.iter().filter_map(|&c| buf.channels.get(c).map(Vec::as_slice)).collect();
let conv = chans::convert(&picked, Layout::Surround51, Layout::Stereo, Mixdown::FrontRear);
for (dst, ch) in out.iter_mut().zip(&conv) {
let m = n.min(ch.len());
dst[off..off + m].copy_from_slice(&ch[..m]);
}
return out;
}
let (left, right) = source_pair(item, buf.channel_count());
for (c, dst) in out.iter_mut().enumerate() {
let ch = &buf.channels[if c == 0 { left } else { right }];
@ -431,6 +443,17 @@ pub fn source_51(item: &TrackItem, available: usize) -> Vec<usize> {
}
}
/// The six source channels (L, R, C, LFE, Ls, Rs) a clip on a stereo track folds down: a six-channel
/// pick (Modify ▸ Audio Channels), else all of a six-channel source. `None` plays [`source_pair`]
/// (sources with another channel count have no known 5.1 layout).
fn source_51_fold(item: &TrackItem, available: usize) -> Option<Vec<usize>> {
match item.source_channels.len() {
0 if available == 6 => Some((0..6).collect()),
6.. => Some(source_51(item, available)),
_ => None,
}
}
/// The source channels (left, right) an audio clip plays: its `source_channels` (one = mono on
/// both sides; Modify ▸ Audio Channels, Breakout to Mono), else the first two (mono sources on
/// both sides). Channels the source doesn't have fall back to the first.

View File

@ -3,7 +3,10 @@
//! transform. The maths lives in `filmcraft_color::transform`.
//!
//! A sequence whose pipeline is "plain" (Rec. 709 working space without wide gamut) and media
//! whose metadata says Rec. 709 / sRGB / linear take the original fast path unchanged.
//! whose metadata says Rec. 709 / sRGB / linear take the original fast path unchanged. On such a
//! sequence, display-referred HDR and wide-gamut Y'CbCr (PQ, HLG, BT.2020, P3) stays on the GPU
//! path as well, with its input transform run in the compositor's shader ([`gpu_input`]); log
//! media and the other pipelines are converted here, on the CPU.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
@ -51,6 +54,18 @@ pub fn input_transform(cs: ColorSpace, range: Range, pipe: &ColorPipeline, peak_
t
}
/// The input transform of a frame the GPU compositor can colour manage itself (#965): a Y'CbCr
/// frame of display-referred HDR or wide-gamut media (PQ, HLG, BT.2020, P3) on a plain pipeline,
/// whose working → monitor transform is the identity. None for log media (camera-gamut
/// compression), RGB frames and other pipelines, which [`decode`] converts on the CPU.
pub fn gpu_input(project: &Project, item: ItemId, pipe: &ColorPipeline, cs: ColorSpace, frame: &VideoFrame) -> Option<Arc<InputTransform>> {
if !pipe.is_plain() || !matches!(frame.data, filmcraft_frame::PixelData::Yuv8 { .. } | filmcraft_frame::PixelData::Yuv16 { .. }) {
return None;
}
let t = input_transform(cs, frame.color.range, pipe, source_peak_nits(project, item));
t.gpu_params().map(|_| t)
}
/// Decode a frame of `item` (box-decimated by `n`) into the working space of `pipe`.
pub fn decode(project: &Project, item: ItemId, frame: &VideoFrame, n: usize, pipe: &ColorPipeline) -> Image {
let cs = source_space(project, item, frame);

View File

@ -77,6 +77,13 @@ pub(crate) fn dec(c: [f32; 3]) -> [f32; 3] {
[srgb_to_linear(c[0].clamp(0.0, 1.0)), srgb_to_linear(c[1].clamp(0.0, 1.0)), srgb_to_linear(c[2].clamp(0.0, 1.0))]
}
/// Stable seed for the *exact* frame timestamp. A fixed 30 Hz quantizer
/// would reuse the same noise field at 60/120 fps and can repeat at 30 fps
/// through floating-point rounding of seconds.
pub(crate) fn noise_frame_seed(cx: &FxCtx) -> u64 {
cx.t.0 as u64
}
pub(crate) fn hash3(x: usize, y: usize, z: u64) -> f32 {
let mut h = (x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (y as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) ^ z.wrapping_mul(0x1656_67B1_9E37_79F9);
h ^= h >> 31;
@ -110,7 +117,7 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let amt = f(e, "amount", cx) / 100.0;
let colored = b(e, "color");
let clip = b(e, "clip");
let frame = (cx.seconds * 30.0) as u64;
let frame = noise_frame_seed(cx);
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {

View File

@ -320,12 +320,17 @@ impl FxOp {
g: 100.0 / f(e, "gamma", cx).max(1.0),
}
}
"extract" => FxOp::Extract {
lo: f(e, "black", cx) / 255.0,
hi: f(e, "white", cx) / 255.0,
soft: f(e, "softness", cx) / 100.0 * 0.2 + 1e-4,
invert: b(e, "invert"),
},
"extract" => {
let softness = f(e, "softness", cx);
FxOp::Extract {
lo: f(e, "black", cx) / 255.0,
hi: f(e, "white", cx) / 255.0,
// Zero softness is a hard cutoff. A tiny substitute fade amplifies
// CPU/GPU transfer-function rounding and introduces unwanted grey edges.
soft: if softness <= 0.0 { 0.0 } else { softness / 100.0 * 0.2 + 1e-4 },
invert: b(e, "invert"),
}
}
"invert" => FxOp::Invert { channel: choice(e, "channel"), blend: f(e, "blend", cx) / 100.0 },
"posterize" => FxOp::Posterize { n: f(e, "levels", cx).max(2.0) - 1.0 },
"gaussian_blur" | "gaussian_blur_legacy" => {
@ -730,7 +735,12 @@ impl FxOp {
let (lo, hi, soft, inv) = (*lo, *hi, *soft, *invert);
img.map_rgb(|c, _, _| {
let l = linear_to_srgb(luma709(c[0], c[1], c[2]).max(0.0));
let inside = ((l - lo) / soft).clamp(0.0, 1.0).min(((hi - l) / soft).clamp(0.0, 1.0));
let inside = if soft <= 0.0 {
// Match the feathered function's limiting value at the cutoffs.
if l > lo && l < hi { 1.0 } else { 0.0 }
} else {
((l - lo) / soft).clamp(0.0, 1.0).min(((hi - l) / soft).clamp(0.0, 1.0))
};
let v = if inv { 1.0 - inside } else { inside };
[v, v, v]
});
@ -1086,3 +1096,55 @@ fn resample_cpu(src: &Image, inv: &Affine, rect: [u32; 4]) -> Image {
});
out
}
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_project::{ParamValue, find_effect};
use filmcraft_time::Tick;
#[test]
fn extract_zero_softness_has_no_fractional_edge() {
let cx = FxCtx {
t: Tick::ZERO,
px_scale: 1.0,
seconds: 0.0,
timecode: "",
clip_name: "",
project: None,
env: None,
working: filmcraft_color::WorkingSpace::Rec709,
};
// Both sides of each cutoff, including samples inside the former artificial fade.
let lo = 90.0 / 255.0;
let hi = 160.0 / 255.0;
let samples = [lo - 0.00005, lo + 0.00005, hi - 0.00005, hi + 0.00005];
for softness in [0.0, -1.0] {
for invert in [false, true] {
for alpha in [1.0, 0.5] {
let mut e = find_effect("extract").unwrap().instance();
for (key, value) in [("black", 90.0), ("white", 160.0), ("softness", softness)] {
e.params.get_mut(key).unwrap().value = ParamValue::Float(value);
}
e.params.get_mut("invert").unwrap().value = ParamValue::Bool(invert);
let mut img = Image {
w: 4,
h: 1,
px: samples
.iter()
.flat_map(|v| {
let c = filmcraft_color::srgb_to_linear(*v) * alpha;
[c, c, c, alpha]
})
.collect(),
};
FxOp::eval(&e, &cx, 4, 1).unwrap().apply(&mut img);
for (pixel, inside) in img.px.as_chunks::<4>().0.iter().zip([false, true, true, false]) {
let value = if inside != invert { alpha } else { 0.0 };
assert_eq!(*pixel, [value, value, value, alpha], "softness={softness}, invert={invert}");
}
}
}
}
}
}

View File

@ -638,6 +638,25 @@ fn a_51_track_passes_through_a_51_mix_and_folds_into_stereo() {
assert!(close(meters[&crate::mixer::MASTER][5], 0.6, 1e-6));
}
#[test]
fn a_51_source_on_a_stereo_track_folds_down_instead_of_dropping_centre_and_surrounds() {
// the default after New Sequence From Clip: a 5.1 file on a stereo track in a stereo Mix
let mut r = Rig::with_channels(&[chan_dc], 6);
let k = std::f32::consts::FRAC_1_SQRT_2;
let (lo, ro) = (0.1 + k * 0.3 + k * 0.5, 0.2 + k * 0.3 + k * 0.6);
let st = mix_all(&r, 0, 256);
assert_eq!(st.len(), 2);
assert!(close(st[0][9], lo, 1e-6) && close(st[1][9], ro, 1e-6), "BS.775 fold, got {} / {}", st[0][9], st[1][9]);
// a six-channel pick folds the picked channels (here the source reversed: Rs, Ls, LFE, C, R, L)
r.track(0).items[0].source_channels = vec![5, 4, 3, 2, 1, 0];
let st = mix_all(&r, 0, 256);
assert!(close(st[0][9], 0.6 + k * 0.4 + k * 0.2, 1e-6) && close(st[1][9], 0.5 + k * 0.4 + k * 0.1, 1e-6));
// a stereo pick still plays exactly those two channels
r.track(0).items[0].source_channels = vec![2, 4];
let st = mix_all(&r, 0, 256);
assert!(close(st[0][9], 0.3, 1e-6) && close(st[1][9], 0.5, 1e-6));
}
#[test]
fn stereo_track_through_a_51_submix_into_a_stereo_mix() {
let mut r = Rig::new(&[chan_dc]);

View File

@ -39,6 +39,10 @@ pub struct PlanLayer {
pub blend: Blend,
/// Standard effects to run on the frame before it is placed (None: draw the frame directly).
pub fx: Option<Arc<LayerFx>>,
/// Colour management of a Y'CbCr frame that needs more than the default decode (HDR, BT.2020,
/// P3 media on a Rec. 709 sequence; [`crate::colorman::gpu_input`]): run on the GPU as on the
/// CPU. None: the default decode.
pub input: Option<Arc<filmcraft_color::InputTransform>>,
}
/// The GPU effect stage of a layer: the frame is decoded into a working image of `size` (the
@ -54,7 +58,7 @@ pub struct LayerFx {
impl PlanLayer {
/// A layer drawing `frame` directly.
pub fn new(frame: Arc<VideoFrame>, matrix: Affine, opacity: f32, blend: Blend) -> Self {
Self { frame, matrix, opacity, blend, fx: None }
Self { frame, matrix, opacity, blend, fx: None, input: None }
}
/// Size of the picture the matrix places: the working image with effects, else the frame.
@ -408,10 +412,13 @@ fn push_item(
let time = crate::video_source_time(item, t, src.info().frame_rate());
let Ok(frame) = src.video_frame(FrameRequest { time, scale: want }) else { return };
let cs = crate::colorman::source_space(project, item.item, &frame);
// log / HDR / wide-gamut media is converted on the CPU (below), and so are blended
// HDR and wide-gamut media on a Rec. 709 sequence is converted on the GPU too (#965); log
// media and other pipelines are converted on the CPU (below), and so are blended
// in-between frames (Frame Blending / Optical Flow on speed-changed clips)
let pipe = &seq.settings.color;
let input = if crate::colorman::needs_management(pipe, cs, &frame) { crate::colorman::gpu_input(project, item.item, pipe, cs, &frame) } else { None };
let plain =
!crate::colorman::needs_management(&seq.settings.color, cs, &frame) && crate::interpolation_blend(item, t, src.info().frame_rate()).is_none();
(input.is_some() || !crate::colorman::needs_management(pipe, cs, &frame)) && crate::interpolation_blend(item, t, src.info().frame_rate()).is_none();
if plain && !chain.is_empty() {
// GPU effect stage: the working image the CPU would decode (`base_layer`), the
// effects evaluated for it, placed as `item_layer` places it
@ -435,7 +442,7 @@ fn push_item(
.then_apply(&motion)
.then_apply(&Affine::scale(1.0 / px_scale as f64, 1.0 / px_scale as f64));
let fx = LayerFx { size: (lw as u32, lh as u32), decimation: n as u32, ops };
out.push(PlanLayer { frame, matrix: m, opacity: op * extra_opacity, blend: bl, fx: Some(Arc::new(fx)) });
out.push(PlanLayer { frame, matrix: m, opacity: op * extra_opacity, blend: bl, fx: Some(Arc::new(fx)), input });
return;
}
} else if plain {
@ -450,7 +457,7 @@ fn push_item(
};
let px_scale = frame.width as f64 / size.0.max(1) as f64;
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion).then_apply(&Affine::scale(1.0 / px_scale, 1.0 / px_scale));
out.push(PlanLayer::new(frame, m, op * extra_opacity, bl));
out.push(PlanLayer { input, ..PlanLayer::new(frame, m, op * extra_opacity, bl) });
return;
}
}
@ -468,8 +475,12 @@ fn near_identity(m: &Affine) -> bool {
/// A layer's working image with its effects applied, on the CPU (the reference for the GPU
/// effect stage).
pub fn effect_image(frame: &VideoFrame, fx: &LayerFx) -> crate::Image {
let (w, h, px) = frame.to_linear_f32_decimated(fx.decimation.max(1) as usize);
let mut img = crate::Image { w, h, px };
effect_image_with(frame, fx, None)
}
/// [`effect_image`] of a colour-managed layer ([`PlanLayer::input`]).
pub fn effect_image_with(frame: &VideoFrame, fx: &LayerFx, input: Option<&filmcraft_color::InputTransform>) -> crate::Image {
let mut img = linear_image(frame, fx.decimation.max(1) as usize, input);
for op in &fx.ops {
op.apply(&mut img);
}
@ -506,12 +517,23 @@ pub fn execute_cpu(plan: &FramePlan) -> crate::Image {
}
}
/// A layer's frame (box-decimated by `n`) as a linear working image: the default decode, or
/// `input`'s table and pixel stage as `colorman::decode` runs them.
fn linear_image(frame: &VideoFrame, n: usize, input: Option<&filmcraft_color::InputTransform>) -> crate::Image {
let (w, h, px) = frame.to_linear_f32_decimated_with(n, input.map(|t| &t.table));
let mut img = crate::Image { w, h, px };
if let Some(t) = input.filter(|t| t.has_pixel_stage()) {
img.map_rgb(|c, _, _| t.apply(c));
}
img
}
fn execute_layers(canvas: &mut crate::Image, layers: &[PlanLayer]) {
let (width, height) = (canvas.w, canvas.h);
for l in layers {
let src = match &l.fx {
Some(fx) => effect_image(&l.frame, fx),
None => crate::Image { w: l.frame.width as usize, h: l.frame.height as usize, px: l.frame.to_linear_f32() },
Some(fx) => effect_image_with(&l.frame, fx, l.input.as_deref()),
None => linear_image(&l.frame, 1, l.input.as_deref()),
};
let placed = if l.matrix == Affine::scale(width as f64, height as f64) && src.w == 1 && src.h == 1 {
crate::Image::filled(width, height, src.get(0, 0))

View File

@ -299,3 +299,28 @@ fn a_region_layer_expands_to_the_whole_layer() {
assert_eq!(full.px.iter().filter(|v| **v == 1.0).count(), 2 * 2 * 4);
assert!(full.px[..(2 * 6 + 4) * 4].iter().all(|v| *v == 0.0));
}
/// HDR and wide-gamut Y'CbCr on a Rec. 709 sequence is planned as a GPU layer carrying its input
/// transform, not converted to a float image on the CPU (#965: 4K HLG / PQ playback fell to a
/// third of real time), and the plan still renders what the reference renderer does.
#[test]
fn hdr_media_is_planned_for_the_gpu_with_its_colour_management() {
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
let hdr = |transfer| ColorInfo { matrix: Matrix::Bt2020Ncl, transfer, primaries: Primaries::Bt2020, range: Range::Limited };
for (name, color) in
[("HLG", hdr(Transfer::Hlg)), ("PQ", hdr(Transfer::Pq)), ("BT.2020 SDR", ColorInfo { primaries: Primaries::Bt2020, ..ColorInfo::REC709 })]
{
let f = Arc::new(VideoFrame { color, ..(*camera(W, H)).clone() });
let (p, seq, map) = scene(&[Spec::new(f)]);
let opts = RenderOptions::default();
let plan = plan::plan_frame(&p, seq, Tick::ZERO, opts, &map);
let plan::FramePlan::Layers { layers, .. } = &plan else { panic!("{name}: not a layer plan") };
assert_eq!(layers.len(), 1, "{name}");
assert!(matches!(layers[0].frame.data, PixelData::Yuv8 { .. }), "{name}: converted on the CPU");
assert!(layers[0].input.is_some(), "{name}: no colour management for the GPU");
let reference = render_sequence(&p, seq, Tick::ZERO, opts, &map);
let got = plan::execute_cpu(&plan);
let max = got.px.iter().zip(&reference.px).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max);
assert!(max < 1e-4, "{name}: plan differs from the reference by {max}");
}
}

View File

@ -337,7 +337,7 @@ pub fn noise(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let colored = bv(e, "color");
let clip = bv(e, "clip");
let grain = (fv(e, "grain_size", cx).max(1.0) * cx.px_scale).max(1.0);
let frame = (cx.seconds * 30.0) as u64;
let frame = crate::effects::noise_frame_seed(cx);
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {

View File

@ -0,0 +1,42 @@
use filmcraft_project::{ParamValue, find_effect};
use filmcraft_render::{
Image,
effects::{FxCtx, apply},
};
use filmcraft_time::FrameRate;
fn render_noise(effect_id: &str, rate: FrameRate, frame: i64, amount: f64) -> Vec<f32> {
let mut image = Image::filled(16, 16, [0.5, 0.5, 0.5, 1.0]);
let mut effect = find_effect(effect_id).unwrap().instance();
effect.param_mut("amount").unwrap().value = ParamValue::Float(amount);
let tick = rate.tick_of(frame);
let context = FxCtx {
t: tick,
px_scale: 1.0,
seconds: tick.seconds(),
timecode: "00:00:00:00",
clip_name: "generated matte",
project: None,
env: None,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut image, &effect, &context);
image.px
}
#[test]
fn both_noise_variants_change_on_every_sequence_frame() {
for effect_id in ["noise", "noise_legacy"] {
for rate in [FrameRate::FPS_24, FrameRate::FPS_30, FrameRate::FPS_60, FrameRate::FPS_120] {
for frame in 0..5 {
let this = render_noise(effect_id, rate, frame, 100.0);
let next = render_noise(effect_id, rate, frame + 1, 100.0);
assert_ne!(this, next, "{effect_id} {rate:?} repeated frames {frame} and {}", frame + 1);
assert_eq!(this, render_noise(effect_id, rate, frame, 100.0), "same timestamp must be deterministic");
}
}
// Floating-point second-to-30Hz conversion also repeated frames 122/123 at 30 fps.
assert_ne!(render_noise(effect_id, FrameRate::FPS_30, 122, 100.0), render_noise(effect_id, FrameRate::FPS_30, 123, 100.0));
assert_eq!(render_noise(effect_id, FrameRate::FPS_60, 0, 0.0), [0.5, 0.5, 0.5, 1.0].repeat(16 * 16));
}
}

View File

@ -0,0 +1,59 @@
//! Horizontal vs vertical graphic text with underline (letter A, 60 px).
use filmcraft_geom::{Affine, Vec2};
use filmcraft_project::graphic::{new_text_layer, new_vertical_text_layer};
use filmcraft_project::{EffectInstance, ParamValue};
use filmcraft_render::graphic_clip::render_graphic_layers;
use filmcraft_render::image::Image;
use filmcraft_time::Tick;
fn set(e: &mut EffectInstance, k: &str, v: ParamValue) {
e.params.get_mut(k).unwrap_or_else(|| panic!("{k}")).value = v;
}
fn draw(layer: EffectInstance) -> Image {
let mut img = Image::new(320, 200);
render_graphic_layers(&[layer], Tick::ZERO, (320, 200), &Affine::IDENTITY, &mut img);
img
}
fn ink_box(img: &Image) -> [usize; 4] {
let mut b: Option<[usize; 4]> = None;
for y in 0..img.h {
for x in 0..img.w {
if img.get(x, y)[3] > 0.4 {
b = Some(match b {
Some(a) => [a[0].min(x), a[1].min(y), a[2].max(x), a[3].max(y)],
None => [x, y, x, y],
});
}
}
}
b.expect("ink")
}
fn count(img: &Image) -> usize {
(0..img.h).flat_map(|y| (0..img.w).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.4).count()
}
#[test]
fn underline_horizontal_vs_vertical_letter_a() {
let pos = Vec2::new(80.0, 100.0);
let mut h = new_text_layer("A", pos, 60.0);
let h_plain = draw(h.clone());
set(&mut h, "underline", ParamValue::Bool(true));
let h_ul = draw(h);
let mut v = new_vertical_text_layer("A", Vec2::new(200.0, 100.0), 60.0);
let v_plain = draw(v.clone());
set(&mut v, "underline", ParamValue::Bool(true));
let v_ul = draw(v);
assert!(count(&h_ul) > count(&h_plain) + 20, "horizontal underline adds ink");
assert!(count(&v_ul) > count(&v_plain) + 20, "vertical underline adds ink");
let hb = ink_box(&h_ul);
let hp = ink_box(&h_plain);
let vb = ink_box(&v_ul);
let vp = ink_box(&v_plain);
assert!(hb[3] > hp[3] + 1, "horizontal underline sits below the letter: plain {hp:?} ul {hb:?}");
assert!(vb[2] > vp[2] + 1, "vertical underline sits to the right of the letter: plain {vp:?} ul {vb:?}");
}

View File

@ -682,7 +682,8 @@ pub fn layout_rich_uncached(text: &str, style: &TextStyle, runs: &[StyleRun], pa
lay.underline_runs.push(*si);
}
} else if style.underline && pl.content > 0.0 {
lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]);
let y = baseline - style.baseline_shift + pm.underline_pos;
lay.underlines.push([x, y, x + pl.content, y + pm.underline_thickness]);
lay.underline_runs.push(0);
}
lay.lines.push(Line {
@ -724,6 +725,7 @@ fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32,
let row = (pm.ascent + pm.descent).max(style.size * 0.5) + style.tracking * style.size / 1000.0;
let flat = ParagraphStyle { vertical: true, ..Default::default() };
lay.vertical = true;
let rich = sty.rich();
let mut base = 0usize;
let mut ncols = 0usize;
for (c, p) in text.split('\n').enumerate() {
@ -761,6 +763,21 @@ fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32,
}
g
}));
// Underline runs down the column, to the right of the cell (the "below" side of vertical-rl).
let y_top = baseline - pl.ascent;
if rich {
for (_x0, x1, si) in &pl.underlines {
let st = sty.list[*si as usize];
let um = fonts::face(sty.prim[*si as usize].face).metrics(st.size);
let x = *x1 + shift - st.baseline_shift + um.underline_pos;
lay.underlines.push([x, y_top, x + um.underline_thickness, y_top + row]);
lay.underline_runs.push(*si);
}
} else if style.underline && pl.content > 0.0 {
let x = shift + pl.left_trim + pl.content + pm.underline_pos;
lay.underlines.push([x, y_top, x + pm.underline_thickness, y_top + row]);
lay.underline_runs.push(0);
}
lay.lines.push(Line {
range: pl.range,
baseline,
@ -998,6 +1015,23 @@ mod tests {
assert_eq!(h.lines.len(), 2);
}
#[test]
fn vertical_underline_runs_down_the_column() {
let s = TextStyle { underline: true, ..st(60.0) };
let h = layout("A", &s, &ParagraphStyle::default());
let v = layout("A", &s, &ParagraphStyle { vertical: true, ..Default::default() });
assert_eq!(h.underlines.len(), 1);
assert_eq!(v.underlines.len(), 1, "vertical cells get an underline");
let [hx0, hy0, hx1, hy1] = h.underlines[0];
let [vx0, vy0, vx1, vy1] = v.underlines[0];
assert!(hx1 - hx0 > hy1 - hy0, "horizontal underline is a wide strip");
assert!(vy1 - vy0 > vx1 - vx0, "vertical underline is a tall strip");
let runs = [StyleRun { range: 0..1, style: s.clone() }];
let rich = layout_rich("A", &st(60.0), &runs, &ParagraphStyle { vertical: true, ..Default::default() });
assert_eq!(rich.underlines.len(), 1);
assert_eq!(rich.underline_runs, vec![1]);
}
#[test]
fn vertical_cells_keep_combining_marks_with_their_base() {
let style = st(32.0);

View File

@ -0,0 +1,25 @@
//! Whole-style underline must follow `baseline_shift` the same way character-style runs do.
use filmcraft_text::{ParagraphStyle, StyleRun, TextStyle, layout, layout_rich};
fn style(shift: f32) -> TextStyle {
TextStyle { size: 100.0, underline: true, baseline_shift: shift, ..Default::default() }
}
fn underline_y(shift: f32) -> (f32, f32) {
let para = ParagraphStyle::default();
let s = style(shift);
let plain = layout("Hi", &s, &para);
let rich = layout_rich("Hi", &s, &[StyleRun { range: 0..2, style: s.clone() }], &para);
(plain.underlines[0][1], rich.underlines[0][1])
}
#[test]
fn whole_style_underline_follows_baseline_shift() {
let (p0, r0) = underline_y(0.0);
for shift in [40.0, -40.0] {
let (plain, rich) = underline_y(shift);
assert!((plain - rich).abs() < 1e-3, "plain {plain} vs rich {rich} at shift {shift}");
assert!((plain - (p0 - shift)).abs() < 1e-3, "plain underline y {plain} should move with shift {shift}");
assert!((rich - (r0 - shift)).abs() < 1e-3, "rich underline y {rich} should move with shift {shift}");
}
}

View File

@ -18,7 +18,7 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho
## Localisation
Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified
Chinese, German, Russian and French. The language is stored in the engine's `general.interfaceLanguage` preference,
Chinese, German, Russian, French and Indonesian (which covers the whole interface). The language is stored in the engine's `general.interfaceLanguage` preference,
restored on startup, and also available in Settings > General.
Its default, System Language (`system`), follows the operating system: the first of the user's
preferred languages that the interface has (the host supplies them through

View File

@ -347,11 +347,16 @@ impl Bench {
}
}
for r in &recs {
rep.latency.push(ms(r.finished - r.queued));
rep.service.push(ms(r.finished - r.started));
rep.job_cpu.push(ms(r.cpu));
rep.src_wall.push(ms(r.source_wall));
rep.src_cpu.push(ms(r.source_cpu));
// Preroll jobs run before the play clock; counting them as on-clock latency made
// the LongGOP seek look like a mid-play stall (`lat.max` ≈ `preroll_ms`).
let on_clock = r.queued >= anchor && due_at.contains_key(&r.key.frame) && r.key.target == target;
if on_clock {
rep.latency.push(ms(r.finished - r.queued));
rep.service.push(ms(r.finished - r.started));
rep.job_cpu.push(ms(r.cpu));
rep.src_wall.push(ms(r.source_wall));
rep.src_cpu.push(ms(r.source_cpu));
}
if !due_at.contains_key(&r.key.frame) || r.key.target != target {
rep.wasted_jobs += 1;
}

View File

@ -581,7 +581,9 @@ pub fn draw_group_chrome(
t: &Tokens,
reg: &mut crate::automation::Registry,
) -> Vec<DockAction> {
draw_group_chrome_details(ui, g, focused, seqs, t, reg).actions
let chrome = draw_group_chrome_details(ui, g, seqs, t, reg);
paint_group_focus(ui, g, focused, t);
chrome.actions
}
#[derive(Default)]
@ -590,14 +592,7 @@ pub struct Chrome {
pub tabs: Vec<(PanelKind, Rect)>,
}
pub fn draw_group_chrome_details(
ui: &mut egui::Ui,
g: &Group,
focused: PanelKind,
seqs: &SeqTabs,
t: &Tokens,
reg: &mut crate::automation::Registry,
) -> Chrome {
pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t: &Tokens, reg: &mut crate::automation::Registry) -> Chrome {
let mut actions = Vec::new();
let mut tab_rects: Vec<(PanelKind, Rect)> = Vec::new();
let painter = ui.painter().clone();
@ -687,6 +682,22 @@ pub fn draw_group_chrome_details(
list_opened = !open;
}
}
// The wheel over the strip steps through the tabs: down = next, up = previous, one tab
// per wheel notch (trackpad scrolling adds up to a notch first), stopping at the ends.
let wheel_id = egui::Id::new(("tab-wheel", g.path.clone()));
if ui.rect_contains_pointer(strip) && !tabs.is_empty() {
const NOTCH: f32 = 40.0;
let total = ui.ctx().data(|d| d.get_temp::<f32>(wheel_id)).unwrap_or(0.0) + crate::panels::timeline::wheel_input(ui.ctx()).delta.y;
let steps = (total / NOTCH).trunc();
ui.ctx().data_mut(|d| d.insert_temp(wheel_id, total - steps * NOTCH));
let to = (active_at as i64).saturating_sub(steps as i64).clamp(0, tabs.len() as i64 - 1) as usize;
if let Some(tab) = tabs.get(to).filter(|_| to != active_at) {
activate(tab, &mut actions);
actions.push(DockAction::Focus(tab.panel));
}
} else {
ui.ctx().data_mut(|d| d.remove::<f32>(wheel_id));
}
let mut x = strip.min.x + 12.0;
// sequence tabs as drawn (for dragging one past its neighbours), and the one being dragged
let mut seq_rects: Vec<(u64, Rect)> = Vec::new();
@ -836,10 +847,6 @@ pub fn draw_group_chrome_details(
ui.ctx().data_mut(|d| d.remove::<bool>(list_id));
}
}
// focus outline
if active_panel == Some(focused) {
painter.rect_stroke(g.rect, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside);
}
// clicking anywhere in the panel focuses it
if let Some(p) = active_panel
&& ui.rect_contains_pointer(g.rect)
@ -850,6 +857,13 @@ pub fn draw_group_chrome_details(
Chrome { actions, tabs: tab_rects }
}
/// Paint once after the group's contents so child backgrounds cannot hide its focus (#1075).
pub(crate) fn paint_group_focus(ui: &egui::Ui, group: &Group, focused: PanelKind, tokens: &Tokens) {
if group.panels.get(group.active).copied() == Some(focused) {
ui.painter().rect_stroke(group.rect, 0.0, Stroke::new(1.0, tokens.focus), StrokeKind::Inside);
}
}
fn paint_widget_focus(painter: &egui::Painter, response: &egui::Response, tokens: &Tokens) {
if response.has_focus() {
painter.rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.5, tokens.focus), StrokeKind::Inside);

View File

@ -191,7 +191,7 @@ impl FilmcraftApp {
let mut actions = vec![];
let mut targets = vec![];
for group in &groups {
let chrome = dock::draw_group_chrome_details(ui, group, self.ui.focused, &seqs, &t, &mut self.auto);
let chrome = dock::draw_group_chrome_details(ui, group, &seqs, &t, &mut self.auto);
if let Some(target) = target(group, chrome.tabs, true) {
targets.push(target);
}
@ -205,6 +205,9 @@ impl FilmcraftApp {
self.panel_body(ui, panel, group.content);
}
}
for group in &groups {
dock::paint_group_focus(ui, group, self.ui.focused, &t);
}
let floating_order = self.floating_order(&ctx);
for index in floating_order.into_iter().filter(|_| maximized.is_none()) {
if let Some(target) = self.floating_group(&ctx, index, &seqs, &mut actions) {
@ -279,7 +282,7 @@ impl FilmcraftApp {
panels: floating.panels.clone(),
active: floating.active,
};
let chrome = dock::draw_group_chrome_details(ui, &group, self.ui.focused, seqs, &t, &mut self.auto);
let chrome = dock::draw_group_chrome_details(ui, &group, seqs, &t, &mut self.auto);
actions.extend(chrome.actions);
if let Some(active) = group.panels.get(group.active).copied() {
self.panel_body(ui, active, group.content);
@ -307,6 +310,7 @@ impl FilmcraftApp {
if next != rect {
let _ = self.dock_command(ctx, Action::MoveFloating { panel, rect: [next.min.x, next.min.y, next.width(), next.height()] });
}
dock::paint_group_focus(ui, &group, self.ui.focused, &t);
output = target(&group, chrome.tabs, false);
});
output

View File

@ -585,7 +585,9 @@ impl FrameServer {
// the frame due now; once the clock runs, the frames before it are late
self.request_job(key, rate.tick_of(key.frame), scale, project, 0, false, (!preroll && dir > 0).then_some(Tick::ZERO));
}
let ahead = prefetch_depth(speed);
// During preroll the clock has not started: only the frames that gate start, not a
// prefetch run that walks the rest of a LongGOP and evicts the playhead pictures.
let ahead = if preroll { (PREROLL_FRAMES - 1).max(0) } else { prefetch_depth(speed) };
for i in 0..ahead {
let f = key.frame + (lead + i * stride) * dir * step;
if f >= 0 {
@ -914,7 +916,7 @@ fn plan_job(job: &Job, seq: ItemId, pool: &Arc<MediaPool>, services: &Arc<dyn Se
// captions (they are not part of the preview hash), so captions are layered on live.
let (w, h) = filmcraft_render::output_size(q, job.scale);
let matrix = filmcraft_geom::Affine::scale(w as f64 / frame.width.max(1) as f64, h as f64 / frame.height.max(1) as f64);
let mut layers = vec![filmcraft_render::plan::PlanLayer { frame, matrix, opacity: 1.0, blend: filmcraft_render::Blend::Normal, fx: None }];
let mut layers = vec![filmcraft_render::plan::PlanLayer { frame, matrix, opacity: 1.0, blend: filmcraft_render::Blend::Normal, fx: None, input: None }];
for o in filmcraft_render::caption_overlays(q, job.time, w, h) {
layers.push(filmcraft_render::plan::PlanLayer {
frame: Arc::new(filmcraft_frame::VideoFrame::rgba_f32(o.w as u32, o.h as u32, o.px)),
@ -922,6 +924,7 @@ fn plan_job(job: &Job, seq: ItemId, pool: &Arc<MediaPool>, services: &Arc<dyn Se
opacity: 1.0,
blend: filmcraft_render::Blend::Normal,
fx: None,
input: None,
});
}
return (filmcraft_render::plan::FramePlan::Layers { width: w, height: h, layers }, true);

View File

@ -0,0 +1,83 @@
//! The notice shown while the window runs in a fallback graphics mode (#820): the desktop app
//! moved on to DirectX 12 only or a software adapter after earlier starts died in the graphics
//! driver. It says so and offers a one-click way back to the default mode for the next start.
//!
//! Automation ids: `graphics.notice` (the notice), `graphics.notice.reset` (Use Default Graphics),
//! `graphics.notice.close`.
use crate::{FilmcraftApp, i18n};
/// Puts the default graphics mode back for the next start; the error says what could not be saved.
pub type ResetFn = Box<dyn FnMut() -> Result<(), String>>;
/// A fallback graphics mode, as the host reports it.
pub struct GraphicsNotice {
/// The mode's English name ("DirectX 12 only", "software rendering"), translated when shown.
pub mode: String,
reset: ResetFn,
/// The default mode is saved for the next start.
pub reset_done: bool,
/// Closed for this session.
pub closed: bool,
}
impl GraphicsNotice {
pub fn new(mode: &str, reset: ResetFn) -> Self {
Self { mode: mode.to_string(), reset, reset_done: false, closed: false }
}
/// Use Default Graphics: save the default mode for the next start.
pub fn reset(&mut self) -> Result<(), String> {
(self.reset)()?;
self.reset_done = true;
Ok(())
}
}
/// Draw the notice (bottom right, above the status bar) until it is closed.
pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
let Some(mut notice) = app.graphics_notice.take() else { return };
if notice.closed {
app.graphics_notice = Some(notice);
return;
}
let title = tlf!("Safe graphics mode: {mode}", mode = i18n::t(&notice.mode));
let (mut reset, mut close) = (false, false);
let shown = egui::Window::new("graphics-notice")
.id(egui::Id::new("graphics-notice"))
.title_bar(false)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::RIGHT_BOTTOM, [-12.0, -32.0])
.show(ctx, |ui| {
ui.set_max_width(360.0);
ui.label(egui::RichText::new(&title).strong());
ui.add_space(4.0);
if notice.reset_done {
ui.label(tl!("The default graphics are used from the next start."));
} else {
ui.label(tl!("An earlier start failed with the default graphics. Updating the graphics driver usually fixes this."));
}
ui.add_space(6.0);
ui.horizontal(|ui| {
if !notice.reset_done {
let label = tl!("Use Default Graphics");
let r = ui.button(label);
app.auto.add_response("graphics.notice.reset", &r, label);
reset = r.clicked();
}
let label = tl!("Close");
let c = ui.button(label);
app.auto.add_response("graphics.notice.close", &c, label);
close = c.clicked();
});
});
if let Some(shown) = shown {
app.auto.add("graphics.notice", shown.response.rect, &title);
}
if reset && let Err(e) = notice.reset() {
app.ui.status = e;
}
notice.closed |= close;
app.graphics_notice = Some(notice);
}

View File

@ -6,7 +6,7 @@
# apps, text addresses the user informally ("du") and commands use the infinitive ("Projekt
# öffnen…"). Product, format and feature names (FilmCraft, ArtCraft, Lumetri, Essential Graphics,
# EDL, AAF…) keep their spelling. This catalog covers every menu, including the native macOS
# Settings menu; anything else is shown in English.
# app menu; anything else is shown in English.
#
# Terms: track = Spur, bin = Ablage, marker = Marker, playhead = Abspielposition, In/Out = In-/Out-
# Punkt, transition = Übergang, frame hold = Standbild, panel = Bedienfeld, workspace =
@ -180,6 +180,8 @@
Guide Templates Hilfslinienvorlagen
Help Hilfe
Hide All Caption Tracks Alle Untertitelspuren ausblenden
Hide FilmCraft FilmCraft ausblenden
Hide Others Andere ausblenden
High Quality Playback Wiedergabe in hoher Qualität
History Verlauf
Import From Importieren aus
@ -269,6 +271,7 @@
Properties Eigenschaften
Proxy Proxy
Purple Lila
Quit FilmCraft FilmCraft beenden
Reconnect Full Resolution Media… Medien in voller Auflösung neu verknüpfen…
Recover Unsaved Changes… Nicht gespeicherte Änderungen wiederherstellen…
Rectangle Rechteck

Can't render this file because it contains an unexpected character in line 6 and column 45.

View File

@ -2804,8 +2804,8 @@
Ignore Audio Ignorar audio
Ignore Video Ignorar video
Apply a cut at each detected cut point Aplicar un corte en cada punto de corte detectado
Create a subclip for each detected cut point Crear un subclip para cada punto de corte detectado
Generate clip markers at each detected cut point Generar marcadores de clip en cada punto de corte detectado
Create a bin of subclips from each detected cut point Crear una bandeja de subclips a partir de cada punto de corte detectado
Create a clip marker at each detected cut point Crear un marcador de clip en cada punto de corte detectado
Sensitivity: Sensibilidad:
Normalize Mix Track to: Normalizar pista de mezcla a:
Disabled clips Clips desactivados
@ -3489,6 +3489,7 @@
Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Alt y Ctrl para otras opciones.
Click to zoom in; Alt-click to zoom out. Haz clic para acercar; Alt+clic para alejar.
Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastrar: mover · Alt+clic: suave/esquina · Ctrl+clic: eliminar
Drag inward to round the corners Arrastrar hacia dentro para redondear las esquinas
Include Alpha Channel Incluir canal alfa
Sequence Settings Ajustes de secuencia
Color Management Gestión del color
@ -3562,3 +3563,9 @@
Edit Project Notes Editar notas del proyecto
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto.
Use Effect Shutter Angle Usar el ángulo de obturación del efecto
Safe graphics mode: {mode} Modo gráfico seguro: {mode}
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un inicio anterior falló con los gráficos predeterminados. Actualizar el controlador gráfico suele solucionarlo.
Use Default Graphics Usar gráficos predeterminados
The default graphics are used from the next start. Los gráficos predeterminados se usarán a partir del próximo inicio.
DirectX 12 only Solo DirectX 12
software rendering renderizado por software

Can't render this file because it contains an unexpected character in line 3 and column 95.

Some files were not shown because too many files have changed in this diff Show More