Merge origin/main into #1404 (gaps.md: keep only the PXR24 edits)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 01:19:55 -04:00
commit 9c00138b4b
140 changed files with 6984 additions and 673 deletions

View File

@ -347,6 +347,13 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
A PC whose GPUs were installed from different driver packages of one vendor (a laptop with an
integrated and a discrete AMD GPU, for example) can list one GPU twice, once through each installed
version of the Vulkan driver, and a window drawn through the stale one crashes inside the driver
(`amdvlk64.dll`, exit code `0xc0000005`). FilmCraft draws with the newer one and says so in
`%APPDATA%\FilmCraft\Logs\filmcraft.log`; installing one driver package for every GPU removes the
duplicate. With one driver per GPU the adapter is the one wgpu picks, as before.
### macOS
| Build | File | Notes |

View File

@ -106,6 +106,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
## Progress log
- **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088)
- **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`).
- **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)).
- **2026-10-10:** ~75 community PRs merged: GPU export rendering (Auto) with Vignette, Video Limiter and Lumetri basic / creative / vignette on the GPU (#421); Linux NVDEC H.264 / HEVC decode (#549); text-to-speech narrations with Kokoro-82M (#278); Japanese for the whole interface (#449) and Ukrainian menus (#407); ALSA default-device fallback (#254, fixes #23); drop import into the timeline (#517); system appearance (#505); timecode scrub editing (#576); NVENC RGBA input (#486); marker CSV export (#454); many fixes (#526–#575).
@ -164,6 +165,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience |
| 2026-10-10 | minor | Headline adds mainstream practitioner (~55%) and essentials user (~60%); ready for real work ~55% -> ~56% (formula written out, same inputs); beta distance ~19 points |

View File

@ -5,6 +5,13 @@
//! `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.
//!
//! # The adapter
//! In the default mode on Windows the adapter is wgpu's own choice, except for a GPU that Vulkan
//! lists through two installed versions of one driver: there the newer one draws, because a
//! window drawn through the stale one dies inside the driver (`amdvlk64.dll`; see
//! `filmcraft_gpu::adapter`). That case is avoided up front instead of left to the fallback
//! below, which would end in DirectX 12 only.
//!
//! # 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`,
@ -427,6 +434,11 @@ fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<B
let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else {
return;
};
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
// compute shader when the device is created, one that Metal can't compile on an Intel Iris
// Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns
// the check back on.
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
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
@ -435,7 +447,16 @@ fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<B
// 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 if windows => {
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY);
// wgpu's own choice of adapter, except for a GPU that Windows lists through two
// versions of one driver: the first entry can be a stale one that crashes inside the
// driver (`filmcraft_gpu::adapter`). A selector replaces `power_preference`
// (`WGPU_POWER_PREF`), so it takes that along.
let power = create.power_preference;
create.native_adapter_selector =
Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| filmcraft_gpu::adapter::pick(adapters, surface, power)));
}
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,
@ -484,6 +505,22 @@ mod tests {
(create.instance_descriptor.backends, create.native_adapter_selector.is_some())
}
#[test]
fn no_mode_validates_indirect_calls() {
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {
return;
}
for mode in [Mode::Default, Mode::Dx12, Mode::Software] {
let mut options = NativeOptions::default();
apply_for(&mut options, mode, None, false);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
// Intel Metal can't compile the validation's compute shader, and the device is lost.
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "{mode:?}");
}
}
#[test]
fn windows_default_leaves_out_gl() {
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
@ -495,7 +532,19 @@ mod tests {
assert_eq!(backends, Backends::PRIMARY);
assert!(!backends.contains(Backends::GL));
assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN));
assert!(!selector);
// and chooses the adapter itself: a GPU listed through two versions of one driver
assert!(selector);
}
#[test]
fn only_the_windows_default_and_software_modes_choose_the_adapter() {
for backend_override in [None, Some(Backends::VULKAN), Some(Backends::DX12)] {
assert!(configured(Mode::Default, backend_override, true).1);
// elsewhere, and in DirectX 12 only mode, the adapter is wgpu's own choice
assert!(!configured(Mode::Default, backend_override, false).1);
assert!(!configured(Mode::Dx12, backend_override, true).1);
}
assert!(configured(Mode::Software, None, true).1 && configured(Mode::Software, None, false).1);
}
#[test]

View File

@ -447,11 +447,8 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog)
let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string();
let media = t("Media");
let project = t("FilmCraft Project");
let dialog = match dialog {
FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts },
FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts, name },
other => other,
};
let project_exts = project_patterns();
let dialog = prepare_dialog(dialog);
let (tx, rx) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
let shown = std::panic::catch_unwind(move || {
@ -462,9 +459,9 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog)
}
FileDialog::OpenFile { filter, exts } => rfd::FileDialog::new().add_filter(&filter, &exts).pick_file().map(path).into_iter().collect(),
FileDialog::Relink { exts, .. } => rfd::FileDialog::new().add_filter(&media, &exts).pick_file().map(path).into_iter().collect(),
FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &["fcproj"]).pick_file().map(path).into_iter().collect(),
FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &project_exts).pick_file().map(path).into_iter().collect(),
FileDialog::SaveProject { name } => {
rfd::FileDialog::new().add_filter(&project, &["fcproj"]).set_file_name(name).save_file().map(path).into_iter().collect()
rfd::FileDialog::new().add_filter(&project, &project_exts).set_file_name(name).save_file().map(path).into_iter().collect()
}
FileDialog::SaveAs { filter, exts, name } => {
rfd::FileDialog::new().add_filter(&filter, &exts).set_file_name(name).save_file().map(path).into_iter().collect()
@ -492,6 +489,29 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog)
rx
}
/// A dialog as the Linux dialog thread shows it: filter names translated (here, on the UI thread:
/// the interface language is per thread) and every extension list turned into the
/// case-insensitive patterns the synchronous pickers use, so `CLIP.MP4` stays visible (#262).
#[cfg(target_os = "linux")]
fn prepare_dialog(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> filmcraft_ui_egui::native_dialogs::FileDialog {
use filmcraft_ui_egui::native_dialogs::FileDialog;
let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string();
let patterns = |exts: Vec<String>| file_filters::extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>());
match dialog {
FileDialog::OpenFiles { exts } => FileDialog::OpenFiles { exts: patterns(exts) },
FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts: patterns(exts) },
FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts: patterns(exts), name },
FileDialog::Relink { exts, hint } => FileDialog::Relink { exts: patterns(exts), hint },
other => other,
}
}
/// The project dialogs' filter patterns (`EDIT.FCPROJ` included).
#[cfg(target_os = "linux")]
fn project_patterns() -> Vec<String> {
file_filters::extensions(&["fcproj"])
}
#[cfg(test)]
mod tests {
/// Start-up registers the hardware decoders whether or not anything is logged (no logger is
@ -505,4 +525,31 @@ mod tests {
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
}
/// #262 on Linux: the dialogs shown off the UI thread (#260) filter with the same
/// case-insensitive patterns as the synchronous pickers, so upper- and mixed-case files show.
#[cfg(target_os = "linux")]
#[test]
fn off_thread_dialogs_keep_case_insensitive_filters() {
use filmcraft_ui_egui::RelinkHint;
use filmcraft_ui_egui::native_dialogs::FileDialog;
let mp4 = || vec!["mp4".to_string()];
let hint = RelinkHint { command: "media.relink".into(), params: serde_json::json!({}) };
for dialog in [
FileDialog::OpenFiles { exts: mp4() },
FileDialog::OpenFile { filter: "Media".into(), exts: mp4() },
FileDialog::SaveAs { filter: "Media".into(), exts: mp4(), name: "clip.mp4".into() },
FileDialog::Relink { exts: mp4(), hint },
] {
let exts = match super::prepare_dialog(dialog) {
FileDialog::OpenFiles { exts } | FileDialog::OpenFile { exts, .. } | FileDialog::SaveAs { exts, .. } | FileDialog::Relink { exts, .. } => exts,
other => panic!("the dialog changed kind: {other:?}"),
};
assert_eq!(exts.first().map(String::as_str), Some("mp4"), "the literal extension stays first (save suffix)");
assert!(exts.iter().any(|e| e == "[mM][pP]4"), "{exts:?}");
}
let project = super::project_patterns();
assert_eq!(project.first().map(String::as_str), Some("fcproj"));
assert!(project.iter().any(|e| e == "[fF][cC][pP][rR][oO][jJ]"), "{project:?}");
}
}

View File

@ -77,3 +77,14 @@ sync_github_name = true
real_name = "Valentin Klein"
display_name = "Valentin Klein"
sync_github_name = false
[people.n-delic]
display_name = "n-delic"
sync_github_name = false
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"

View File

@ -8,7 +8,9 @@ use filmcraft_apv::{
};
fn ffmpeg() -> Option<PathBuf> {
filmcraft_testkit::ffmpeg_or_skip("apv oracle")
// The APV demuxer is new in ffmpeg 8.0; older toolchains are found but
// cannot read the fixtures, so they skip like a missing tool (#1249).
filmcraft_testkit::ffmpeg_with_format_or_skip("apv oracle", "apv", "8.0")
}
fn fixture_dir() -> PathBuf {

View File

@ -1,4 +1,5 @@
//! Client for the desktop app's JSON-lines control protocol. One connection, reconnect on failure.
//! Client for the desktop app's JSON-lines control protocol. A connection per request in flight,
//! reused once its reply is in; reconnect on failure.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
@ -12,12 +13,17 @@ use crate::AutomationError;
/// How often a blocking export's app job is polled.
const JOB_POLL: Duration = Duration::from_millis(250);
/// Idle connections kept for reuse; the app serves at most 16 at once.
const MAX_IDLE: usize = 4;
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
pub struct BridgeClient {
addr: String,
conn: Mutex<Option<Conn>>,
/// Connections with no request in flight. A request takes one (or opens one) for its whole
/// round trip and puts it back only once the reply is read, so a slow or abandoned request
/// never holds up the others or leaves its late reply for the next one to read (#1538).
idle: Mutex<Vec<Conn>>,
next_id: AtomicU64,
}
@ -29,7 +35,7 @@ impl BridgeClient {
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`")));
}
Ok(Self { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1) })
Ok(Self { addr, idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
}
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
@ -71,20 +77,14 @@ impl BridgeClient {
/// Call a control method; returns `result` or the app's error.
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
let mut guard = self.conn.lock().await;
for attempt in 0..2 {
if guard.is_none() {
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
.await
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control <port>`", self.addr)))?;
let (r, w) = s.into_split();
*guard = Some((BufReader::new(r), w));
}
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let Some(conn) = guard.as_mut() else {
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
// A retry gets a fresh connection: the failed one may have been closed by an app restart.
let idle = if attempt == 0 { self.idle.lock().await.pop() } else { None };
let mut conn = match idle {
Some(c) => c,
None => self.connect().await?,
};
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
let res: Result<Value, AutomationError> = async {
conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?;
@ -98,21 +98,32 @@ impl BridgeClient {
.await;
match res {
Ok(v) => {
let mut idle = self.idle.lock().await;
if idle.len() < MAX_IDLE {
idle.push(conn);
}
drop(idle);
return if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()))
};
}
Err(e) if attempt == 0 => {
*guard = None;
let _ = e;
}
Err(_) if attempt == 0 => {}
Err(e) => return Err(e),
}
}
Err(AutomationError::Bridge("unreachable".into()))
}
async fn connect(&self) -> Result<Conn, AutomationError> {
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
.await
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control <port>`", self.addr)))?;
let (r, w) = s.into_split();
Ok((BufReader::new(r), w))
}
}
#[cfg(test)]
@ -195,6 +206,71 @@ mod tests {
assert!(e.to_string().contains("export failed: encode: disk full"), "{e}");
}
/// A stand-in for the app that serves any number of connections and answers each request on
/// its own, as the app does: `slow` after `slow_reply` (never when `None`), anything else at
/// once with its command id. The ids of the requests it receives are sent to the returned channel.
async fn slow_app(slow_reply: Option<Duration>) -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let (seen, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
while let Ok((s, _)) = listener.accept().await {
let seen = seen.clone();
tokio::spawn(async move {
let (r, w) = s.into_split();
let w = Arc::new(Mutex::new(w));
let mut lines = BufReader::new(r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let req: Value = serde_json::from_str(&line).unwrap();
let cmd = req["params"]["command"].as_str().unwrap_or_default().to_string();
let _ = seen.send(cmd.clone());
let delay = if cmd == "slow" { slow_reply } else { Some(Duration::ZERO) };
let w = w.clone();
tokio::spawn(async move {
let Some(d) = delay else { return };
tokio::time::sleep(d).await;
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": cmd}));
let _ = w.lock().await.write_all(line.as_bytes()).await;
});
}
});
}
});
(addr, rx)
}
/// A command the app is slow to answer holds up only itself: a later call is answered at once
/// instead of queueing behind it, and behind every call queued after it (#1538).
#[tokio::test]
async fn a_slow_command_does_not_block_later_calls() {
let (addr, mut seen) = slow_app(None).await;
let b = Arc::new(BridgeClient::new(addr).unwrap());
let slow = tokio::spawn({
let b = b.clone();
async move { b.execute("slow", json!({})).await }
});
assert_eq!(seen.recv().await.as_deref(), Some("slow"));
let fast = tokio::time::timeout(Duration::from_secs(5), b.execute("fast", json!({}))).await;
assert_eq!(fast.expect("the later call waited on the slow one").unwrap(), json!("fast"));
slow.abort();
}
/// A call given up while it waits leaves no late reply on a connection for the next call to read
/// as its own (#1538).
#[tokio::test]
async fn an_abandoned_call_leaves_no_late_reply_behind() {
let (addr, mut seen) = slow_app(Some(Duration::from_millis(100))).await;
let b = Arc::new(BridgeClient::new(addr).unwrap());
let slow = tokio::spawn({
let b = b.clone();
async move { b.execute("slow", json!({})).await }
});
assert_eq!(seen.recv().await.as_deref(), Some("slow"));
slow.abort();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(b.execute("fast", json!({})).await.unwrap(), json!("fast"));
}
/// Everything else is forwarded unchanged.
#[tokio::test]
async fn other_commands_are_forwarded() {

View File

@ -122,7 +122,7 @@ fn opendml_file_past_1_gb() {
];
let Some(path) = fixture("large_opendml.avi", &args) else { return };
let file = std::fs::File::open(&path).unwrap();
let src = FileSource(file);
let src = FileSource(std::sync::Mutex::new(file));
let f = open(&src).unwrap();
assert_eq!(f.index, IndexSource::OpenDml);
assert_eq!(f.streams[0].chunks.len(), packets(&path, 0));
@ -139,13 +139,54 @@ fn opendml_file_past_1_gb() {
}
/// A file on disk as a byte source, read where asked.
struct FileSource(std::fs::File);
struct FileSource(std::sync::Mutex<std::fs::File>);
impl filmcraft_avi::ByteSource for FileSource {
fn len(&self) -> u64 {
self.0.metadata().map(|m| m.len()).unwrap_or(0)
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).metadata().map(|m| m.len()).unwrap_or(0)
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
std::os::unix::fs::FileExt::read_exact_at(&self.0, buf, offset)
use std::io::{Read, Seek, SeekFrom};
if buf.is_empty() {
return Ok(());
}
let mut file = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
file.seek(SeekFrom::Start(offset))?;
file.read_exact(buf)
}
}
#[test]
fn file_source_reads_exact_offsets_and_reports_eof() {
use filmcraft_avi::ByteSource as _;
use std::io::Write as _;
let path = filmcraft_testkit::fixtures::temp_path(&filmcraft_testkit::fixtures_dir("avi").join("read-at-test.bin"));
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
file.write_all(b"0123456789abcdef").unwrap();
drop(file);
let source = FileSource(std::sync::Mutex::new(std::fs::File::open(&path).unwrap()));
assert_eq!(source.len(), 16);
let mut bytes = [0; 4];
source.read_at(7, &mut bytes).unwrap();
assert_eq!(&bytes, b"789a");
source.read_at(1, &mut bytes).unwrap();
assert_eq!(&bytes, b"1234");
assert_eq!(source.read_at(15, &mut bytes).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof);
assert!(source.read_at(u64::MAX, &mut bytes).is_err());
source.read_at(u64::MAX, &mut []).unwrap();
std::thread::scope(|scope| {
for (offset, expected) in [(0, b"0123"), (8, b"89ab")] {
let source = &source;
scope.spawn(move || {
for _ in 0..64 {
let mut bytes = [0; 4];
source.read_at(offset, &mut bytes).unwrap();
assert_eq!(&bytes, expected);
}
});
}
});
drop(source);
std::fs::remove_file(path).unwrap();
}

View File

@ -117,8 +117,8 @@ impl<'a> BitReader<'a> {
Ok(())
}
/// Unsigned Exp-Golomb `ue(v)`.
pub fn read_ue(&mut self) -> Result<u32> {
/// Raw Exp-Golomb codeNum, up to 32 leading zeros (so below 2^33).
fn read_code_num(&mut self) -> Result<u64> {
let mut zeros = 0u32;
while !self.read_bit()? {
zeros += 1;
@ -130,14 +130,20 @@ impl<'a> BitReader<'a> {
return Ok(0);
}
let v = self.read_bits_u64(zeros)?;
let r = (1u64 << zeros) - 1 + v;
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
Ok((1u64 << zeros) - 1 + v)
}
/// Unsigned Exp-Golomb `ue(v)`.
pub fn read_ue(&mut self) -> Result<u32> {
u32::try_from(self.read_code_num()?).map_err(|_| BitError::InvalidExpGolomb)
}
/// Signed Exp-Golomb `se(v)`.
pub fn read_se(&mut self) -> Result<i32> {
let k = self.read_ue()? as i64;
Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) })
// codeNum 2^32 is -2^31, so the mapping is done before narrowing to 32 bits.
let k = self.read_code_num()? as i64;
let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) };
i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb)
}
/// Truncated Exp-Golomb `te(v)` with range `max`.
@ -196,7 +202,11 @@ impl BitWriter {
self.write_bits(b as u32, 1);
}
pub fn write_ue(&mut self, v: u32) {
let x = v as u64 + 1;
self.write_code_num(u64::from(v));
}
/// Exp-Golomb codeNum up to 2^32 (the code of `se(v)` -2^31).
fn write_code_num(&mut self, v: u64) {
let x = v + 1;
let len = 64 - x.leading_zeros();
let zeros = len - 1;
for _ in 0..zeros {
@ -210,8 +220,8 @@ impl BitWriter {
}
}
pub fn write_se(&mut self, v: i32) {
let k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 };
self.write_ue(k);
let m = u64::from(v.unsigned_abs()) * 2;
self.write_code_num(if v > 0 { m - 1 } else { m });
}
pub fn bit_len(&self) -> usize {
self.buf.len() * 8 + self.nbits as usize
@ -377,6 +387,41 @@ mod tests {
}
}
#[test]
fn signed_exp_golomb_extremes_roundtrip() {
let vals = [0, 1, -1, i32::MAX, i32::MIN];
let mut w = BitWriter::new();
for v in vals {
w.write_se(v);
}
let b = w.finish();
let mut r = BitReader::new(&b);
for v in vals {
assert_eq!(r.read_se(), Ok(v));
}
}
#[test]
fn signed_exp_golomb_reads_minimum_from_spec_bits() {
// 32 zero bits, 1, then the 32-bit suffix 1: codeNum = 2^32 -> -2^31.
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80];
assert_eq!(BitReader::new(&bytes).read_se(), Ok(i32::MIN));
}
#[test]
fn signed_exp_golomb_rejects_positive_overflow() {
// 32 zero bits, 1, then 32 zero bits: codeNum = 2^32 - 1 -> +2^31, not an i32.
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0];
assert_eq!(BitReader::new(&bytes).read_se(), Err(BitError::InvalidExpGolomb));
}
#[test]
fn unsigned_exp_golomb_keeps_u32_range() {
// codeNum = 2^32 is a valid se(v) code but does not fit ue(v)'s u32.
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80];
assert_eq!(BitReader::new(&bytes).read_ue(), Err(BitError::InvalidExpGolomb));
}
#[test]
fn emulation_prevention() {
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];

View File

@ -23,6 +23,27 @@ All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 00
frames map through `FrameRate::FPS_29_97`. `Document::snap_to_frames` snaps cues to a sequence's
frame grid (the engine does this on import) and removes overlaps.
### TTML speaker metadata
TTML and DFXP exports define each distinct speaker label once in the document head and refer to
it with a stable, unique `ttm:agent` ID. Names are XML-escaped full-name text with preserved
whitespace; punctuation, Unicode and literal underscores survive a round trip. Agent type is
`other` because a caption's speaker label does not say whether it denotes a person or a group.
On import, agent definitions directly under `head` or `head/metadata` resolve paragraph and timed
span speakers. Speaker metadata uses the standard TTML namespace or the April and October 2006
TTaf1 draft namespaces. Agent IDs use `xml:id`, except in the April draft, which uses unqualified
`id`. Agent definitions from unrelated application metadata are ignored. Recognized `ttm:agent`
attributes take precedence over the legacy local-name `agent` fallback. A full name is preferred,
otherwise the first nonempty name (or an explicitly empty name) is used. Multiple agent references become one display label, joined with `, ` in
reference order; the project does not retain the individual agent identities. Older FilmCraft
files without agent definitions remain readable through the previous underscore-to-space
fallback. Other agent metadata, including actor relationships, is not retained.
This mapping follows [W3C TTML1, third edition (2018), §§12.1.5–12.2.1](https://www.w3.org/TR/2018/REC-ttml1-20181108/#metadata-vocabulary-agent).
The [April 2006 TTaf1 draft, §§7.2.1 and 12.1.5–12.2.1](https://www.w3.org/TR/2006/WD-ttaf1-dfxp-20060427/)
defines its earlier metadata namespace and agent ID form.
### SCC writer
Each caption is sent as a pop-on load (RCL, ENM, then per row a preamble address code and tab

View File

@ -8,12 +8,14 @@
//! offsets (`123f`) at `ttp:frameRate` / `ttp:frameRateMultiplier`, which are exact; without
//! one, and always for DFXP (for older players), times are clock times with milliseconds.
//!
//! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read):
//! **Reading** accepts TTML1 2010, the 2006 `ttaf1` drafts and TTML2:
//! clock times (`HH:MM:SS`, `.fraction`, `:FF` frames and `.subframes`), offset times (`h`, `m`,
//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested `par` time
//! containers (`body` / `div` begins add up and their ends clip what is inside), timed `<span>`s inside an untimed `<p>`, `<br/>`, whitespace
//! collapsing, styles referenced by id (italic / bold / underline) and `ttm:agent` / `xml:id`.
use std::collections::{HashMap, HashSet};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use crate::{Cue, Document, Error, Result, TICKS_PER_MS};
@ -31,6 +33,9 @@ const NS_TT: &str = "http://www.w3.org/ns/ttml";
const NS_TTP: &str = "http://www.w3.org/ns/ttml#parameter";
const NS_TTS: &str = "http://www.w3.org/ns/ttml#styling";
const NS_TTM: &str = "http://www.w3.org/ns/ttml#metadata";
const NS_TTM_DRAFT: &str = "http://www.w3.org/2006/10/ttaf1#metadata";
const NS_TTM_DRAFT_APRIL: &str = "http://www.w3.org/2006/04/ttaf1#metadata";
const NS_XML: &str = "http://www.w3.org/XML/1998/namespace";
const IMSC1_TEXT: &str = "http://www.w3.org/ns/ttml/profile/imsc1/text";
const DFXP_PROFILE: &str = "http://www.w3.org/ns/ttml/profile/dfxp-presentation";
@ -122,6 +127,10 @@ fn is_ncname(s: &str) -> bool {
c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && c.all(|x| x.is_alphanumeric() || matches!(x, '_' | '-' | '.'))
}
fn cue_id(cue: &Cue, index: usize) -> String {
cue.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", index + 1))
}
/// Write `doc` as TTML. `rate` (IMSC1 only) makes times exact frame offsets; `lang` is the
/// document language (`xml:lang`).
pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str) -> String {
@ -140,13 +149,38 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
}
}
o.push_str(&format!(" xml:lang=\"{}\">\n", esc(if lang.is_empty() { "en" } else { lang })));
// Keep agent identifiers distinct from the IDs already emitted for cues, style and region.
let mut ids: HashSet<String> = ["s1".into(), "r1".into()].into_iter().collect();
ids.extend(doc.cues.iter().enumerate().map(|(i, c)| cue_id(c, i)));
let mut speakers = Vec::new();
let mut speaker_ids = HashMap::new();
for speaker in doc.cues.iter().filter_map(|c| c.speaker.as_deref()) {
if speaker_ids.contains_key(speaker) {
continue;
}
let mut id = format!("agent{}", speakers.len() + 1);
// Each collision consumes a distinct reserved ID, so this is bounded by ids.len().
while ids.contains(&id) {
id.push('_');
}
ids.insert(id.clone());
speaker_ids.insert(speaker, id.clone());
speakers.push((speaker, id));
}
o.push_str(" <head>\n");
for (speaker, id) in speakers {
o.push_str(&format!(
" <ttm:agent xml:id=\"{id}\" type=\"other\"><ttm:name type=\"full\" xml:space=\"preserve\">{}</ttm:name></ttm:agent>\n",
// XML 1.0 §2.11 normalizes literal CR even with xml:space="preserve".
esc(speaker).replace('\r', "&#13;")
));
}
o.push_str(" <styling>\n <style xml:id=\"s1\" tts:color=\"white\" tts:fontFamily=\"proportionalSansSerif\" tts:fontSize=\"100%\" tts:textAlign=\"center\"/>\n </styling>\n");
o.push_str(" <layout>\n <region xml:id=\"r1\" tts:origin=\"10% 10%\" tts:extent=\"80% 80%\" tts:displayAlign=\"after\"/>\n </layout>\n");
o.push_str(" </head>\n <body region=\"r1\" style=\"s1\">\n <div>\n");
for (i, c) in doc.cues.iter().enumerate() {
let id = c.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", i + 1));
let agent = c.speaker.as_deref().map(|s| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{}\"", esc(&agent_id(s)))).unwrap_or_default();
let id = cue_id(c, i);
let agent = c.speaker.as_deref().and_then(|s| speaker_ids.get(s)).map(|id| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{id}\"")).unwrap_or_default();
o.push_str(&format!(
" <p xml:id=\"{}\" begin=\"{}\" end=\"{}\"{agent}>{}</p>\n",
esc(&id),
@ -159,12 +193,6 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
o
}
/// `ttm:agent` refers to an agent id; we write the speaker name itself (made an NCName).
fn agent_id(s: &str) -> String {
let v: String = s.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect();
if v.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { v } else { format!("_{v}") }
}
fn gcd(a: i64, b: i64) -> i64 {
if b == 0 { a.abs().max(1) } else { gcd(b, a % b) }
}
@ -203,6 +231,56 @@ fn attr<'a>(n: roxmltree::Node<'a, '_>, local: &str) -> Option<&'a str> {
n.attributes().find(|a| a.name() == local).map(|a| a.value())
}
fn metadata_namespace(namespace: Option<&str>) -> bool {
matches!(namespace, Some(NS_TTM) | Some(NS_TTM_DRAFT) | Some(NS_TTM_DRAFT_APRIL))
}
/// Significant TTML agents are children of head or of head's metadata children (TTML1 §12.1.5).
fn speaker_names(root: roxmltree::Node) -> HashMap<String, String> {
let mut out = HashMap::new();
let content_namespace = root.tag_name().namespace();
let Some(head) = root.children().find(|n| n.is_element() && n.tag_name().name() == "head" && n.tag_name().namespace() == content_namespace) else {
return out;
};
for agent in head.descendants().filter(|n| n.is_element() && n.tag_name().name() == "agent" && metadata_namespace(n.tag_name().namespace())) {
if !agent
.parent()
.is_some_and(|p| p == head || (p.tag_name().name() == "metadata" && p.tag_name().namespace() == content_namespace && p.parent() == Some(head)))
{
continue;
}
let id = if agent.tag_name().namespace() == Some(NS_TTM_DRAFT_APRIL) {
// The April 2006 draft defines an unqualified id, before TTML adopted xml:id.
agent.attributes().find(|a| a.name() == "id" && a.namespace().is_none()).map(|a| a.value())
} else {
agent.attribute((NS_XML, "id"))
};
let Some(id) = id else { continue };
let names: Vec<_> = agent
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "name" && metadata_namespace(n.tag_name().namespace()))
.map(|n| {
let kind = n.attributes().find(|a| a.name() == "type" && a.namespace().is_none()).map(|a| a.value());
(kind, n.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>())
})
.collect();
let name = names.iter().find(|(kind, _)| *kind == Some("full")).or_else(|| names.iter().find(|(_, text)| !text.is_empty())).or_else(|| names.first());
if let Some((_, name)) = name {
// An explicitly empty full name remains empty, including our own Some("") speakers.
out.insert(id.to_string(), name.clone());
}
}
out
}
fn speaker(n: roxmltree::Node, names: &HashMap<String, String>) -> Option<String> {
let refs = n.attributes().find(|a| a.name() == "agent" && metadata_namespace(a.namespace())).map(|a| a.value()).or_else(|| n.attribute("agent"));
refs.map(|refs| {
// The project stores one speaker label; multiple agents become an ordered display label.
refs.split_whitespace().map(|id| names.get(id).cloned().unwrap_or_else(|| id.trim_start_matches('_').replace('_', " "))).collect::<Vec<_>>().join(", ")
})
}
fn timing(root: roxmltree::Node) -> Result<Timing> {
let invalid =
|name: &str| Error::Syntax { line: root.document().text_pos_at(root.range().start).row as usize, msg: format!("invalid TTML timing parameter {name}") };
@ -294,10 +372,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
}
whole.0 as i128 + decimal_ticks("0", vf, fd)?
}
"t" => {
let n: i128 = vi.parse().ok()?;
n * tps / tm.tick_rate.max(1) as i128
}
"t" => decimal_ticks(vi, vf, tps)? / tm.tick_rate.max(1) as i128,
_ => return None,
};
i64::try_from(t).ok().map(Tick)
@ -411,6 +486,7 @@ pub fn parse(text: &str) -> Result<Document> {
return Err(Error::NotFormat("TTML"));
}
let tm = timing(root)?;
let speakers = speaker_names(root);
let mut styles = std::collections::HashMap::new();
for s in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
if let Some(id) = s.attributes().find(|a| a.name() == "id").map(|a| a.value()) {
@ -428,7 +504,7 @@ pub fn parse(text: &str) -> Result<Document> {
}
// the body is a time container like a div: its begin offsets, and its end clips, everything inside
let t = times(body, Scope { begin: Tick::ZERO, end: None }, tm, &mut doc);
walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, Fmt::default(), &mut doc);
walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, &speakers, Fmt::default(), &mut doc);
doc.cues.sort_by_key(|c| (c.start, c.end));
Ok(doc)
}
@ -480,13 +556,13 @@ fn times(n: roxmltree::Node, parent: Scope, tm: Timing, doc: &mut Document) -> T
Times { begin, end, bad }
}
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collections::HashMap<String, Fmt>, fmt: Fmt, doc: &mut Document) {
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &HashMap<String, Fmt>, speakers: &HashMap<String, String>, fmt: Fmt, doc: &mut Document) {
for c in n.children().filter(|c| c.is_element()) {
let f = style_of(c, styles, fmt);
match c.tag_name().name() {
"div" => {
let t = times(c, parent, *tm, doc);
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, f, doc);
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, speakers, f, doc);
}
"p" => {
let t = times(c, parent, *tm, doc);
@ -494,7 +570,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
continue;
}
let (b, e) = (t.begin.unwrap_or(parent.begin), t.end);
let speaker = attr(c, "agent").map(|a| a.trim().trim_start_matches('_').replace('_', " "));
let cue_speaker = speaker(c, speakers);
let id = c.attributes().find(|a| a.name() == "id").map(|a| a.value().to_string());
let spans: Vec<roxmltree::Node> = c.children().filter(|x| x.is_element() && x.tag_name().name() == "span" && has_timing(*x)).collect();
// a timed paragraph, or one without timed spans in a container with an end, is one cue
@ -512,7 +588,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
}
let text = clean(&o.s);
if e > b && !text.is_empty() {
doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() });
doc.cues.push(Cue { start: b, end: e, text, speaker: cue_speaker, id, ..Default::default() });
}
}
None => {
@ -526,7 +602,13 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
collect(sp, styles, style_of(sp, styles, f), &mut o);
let text = clean(&o.s);
if se > sb && !text.is_empty() {
doc.cues.push(Cue { start: sb, end: se, text, speaker: speaker.clone(), ..Default::default() });
doc.cues.push(Cue {
start: sb,
end: se,
text,
speaker: speaker(sp, speakers).or_else(|| cue_speaker.clone()),
..Default::default()
});
}
}
}
@ -631,6 +713,23 @@ mod tests {
assert!(!doc.warnings.is_empty());
}
#[test]
fn fractional_tick_offsets_keep_exact_caption_times() {
for format in [crate::Format::Ttml, crate::Format::Dfxp] {
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:tickRate="10"><body><div><p begin="1.5t" end="12.5t">Hello</p></div></body></tt>"#;
let doc = crate::parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].start, Tick(TICKS_PER_SECOND * 3 / 20));
assert_eq!(doc.cues[0].end, Tick(TICKS_PER_SECOND * 5 / 4));
assert!(doc.warnings.is_empty());
}
let timing = Timing { tick_rate: 3, ..tm(FrameRate::FPS_25) };
assert_eq!(parse_time("0.5t", timing), Some(Tick(TICKS_PER_SECOND / 6)));
for expr in ["1.xt", "1.1234567890123t", "999999999999999.5t"] {
assert_eq!(parse_time(expr, timing), None, "{expr}");
}
}
#[test]
fn reads_styles_spans_and_nesting() {
let x = r#"<?xml version="1.0"?>

View File

@ -38,7 +38,7 @@ pub fn split_voice(text: &str) -> (Option<String>, String) {
Some(_) => return (None, text.to_string()),
None => body.to_string(),
};
if body.contains("<v ") || body.contains("<v.") {
if body.contains("<v ") || body.contains("<v.") || body.contains("<v\t") {
return (None, text.to_string());
}
return ((!name.is_empty()).then(|| name.to_string()), body);
@ -177,6 +177,19 @@ mod tests {
assert_eq!(split_voice("<vx>hi"), (None, "<vx>hi".into()));
}
#[test]
fn multiple_voices_with_tab_separator() {
let text = "<v Ann>Hello <v\tBob>there";
assert_eq!(split_voice(text), (None, text.to_string()));
let source = format!("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n{text}\n");
let doc = parse(&source).unwrap();
assert_eq!(doc.cues[0].speaker, None);
assert_eq!(doc.cues[0].text, text);
assert_eq!(parse(&write(&doc)).unwrap().cues, doc.cues);
let closed = "<v Ann>Hi <v\tBob>x</v>";
assert_eq!(split_voice(closed), (None, closed.to_string()));
}
#[test]
fn without_signature() {
let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap();

View File

@ -0,0 +1,219 @@
use std::collections::{HashMap, HashSet};
use filmcraft_captions::{Cue, Document, Format, WriteOptions, parse, write};
use filmcraft_time::FrameRate;
const TT: &str = "http://www.w3.org/ns/ttml";
const META: &str = "http://www.w3.org/ns/ttml#metadata";
const XML: &str = "http://www.w3.org/XML/1998/namespace";
#[test]
fn imports_speaker_names_from_significant_ttml_metadata() {
for format in [Format::Ttml, Format::Dfxp] {
for metadata in [
r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="alias">Ann</ttm:name><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>"#
.to_string(),
r#"<metadata><ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent></metadata>"#.to_string(),
r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann<!-- editorial note --> Lee</ttm:name></ttm:agent>"#.to_string(),
] {
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>{metadata}</head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
assert_eq!(doc.cues[0].text, "Hello");
}
}
}
#[test]
fn exported_speakers_have_unique_resolvable_ids_and_exact_names() {
let rate = FrameRate::FPS_25;
let speakers =
["Ann Lee", "Ann_Lee", "O’Connor & <Guest>", "_Ann", "山田 花子", "Ann Lee", "", " Ann Lee ", "Ann\rLee", "Ann\r\nLee", "Ann\nLee", "Ann\tLee"];
let ids = ["agent1", "agent2", "speaker1", "speaker2", "c5", "c6", "c7", "c8", "c9", "c10", "c11", "c12"];
let doc = Document {
cues: speakers
.iter()
.enumerate()
.map(|(i, name)| Cue {
start: rate.tick_of(i as i64 * 50),
end: rate.tick_of(i as i64 * 50 + 25),
text: format!("Line {}", i + 1),
speaker: Some((*name).into()),
id: Some(ids[i].into()),
..Default::default()
})
.collect(),
..Default::default()
};
for format in [Format::Ttml, Format::Dfxp] {
let bytes = write(&doc, format, WriteOptions { rate: Some(rate), ..Default::default() });
assert_eq!(bytes, write(&doc, format, WriteOptions { rate: Some(rate), ..Default::default() }), "stable output");
let text = std::str::from_utf8(&bytes).unwrap();
let xml = roxmltree::Document::parse(text).unwrap();
let mut all_ids = HashSet::new();
for id in xml.descendants().filter_map(|n| n.attribute((XML, "id"))) {
assert!(all_ids.insert(id), "duplicate xml:id {id}");
}
let mut agents = HashMap::new();
for agent in xml.descendants().filter(|n| n.has_tag_name((META, "agent"))) {
assert_eq!(agent.attribute("type"), Some("other"));
let name = agent.children().find(|n| n.has_tag_name((META, "name"))).unwrap();
assert_eq!(name.attribute("type"), Some("full"));
assert_eq!(name.attribute((XML, "space")), Some("preserve"));
agents.insert(agent.attribute((XML, "id")).unwrap(), name.text().unwrap_or(""));
}
assert_eq!(agents.len(), 11, "each exact name is defined once");
let paragraphs: Vec<_> = xml.descendants().filter(|n| n.has_tag_name((TT, "p"))).collect();
assert_eq!(paragraphs.len(), speakers.len());
for (paragraph, expected) in paragraphs.iter().zip(speakers) {
let id = paragraph.attribute((META, "agent")).unwrap();
assert_eq!(agents.get(id).copied(), Some(expected), "reference {id}");
}
let back = parse(&bytes, format).unwrap();
assert_eq!(back.cues, doc.cues, "{format:?} names and cue content survive");
}
}
#[test]
fn imports_multiple_agent_names_in_reference_order() {
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>
<ttm:agent xml:id="ann" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
<ttm:agent xml:id="guest" type="person"><ttm:name type="alias">O’Connor</ttm:name></ttm:agent>
</head><body><div><p begin="1s" end="2s" ttm:agent="guest ann">Together</p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues[0].speaker.as_deref(), Some("O’Connor, Ann Lee"));
}
#[test]
fn keeps_legacy_filmcraft_speaker_references_readable() {
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><body><div>
<p begin="1s" end="2s" ttm:agent="Ann_Lee">Hello</p>
</div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
}
#[test]
fn timed_span_can_name_its_own_speaker() {
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>
<ttm:agent xml:id="ann" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
<ttm:agent xml:id="guest" type="person"><ttm:name type="full">Guest</ttm:name></ttm:agent>
</head><body><div><p ttm:agent="ann"><span begin="1s" end="2s" ttm:agent="guest">Hello</span>
<span begin="3s" end="4s">Welcome</span></p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues.len(), 2);
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Guest"));
assert_eq!(doc.cues[1].speaker.as_deref(), Some("Ann Lee"));
}
#[test]
fn foreign_metadata_does_not_replace_ttml_speakers() {
let real = r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>"#;
for (metadata, attributes) in [
(
format!(r#"{real}<metadata><app:agent id="speaker1"><app:name type="full">Library editor</app:name></app:agent></metadata>"#),
r#"ttm:agent="speaker1""#,
),
(real.into(), r#"app:agent="library" ttm:agent="speaker1""#),
] {
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library"><head>{metadata}</head><body><div><p begin="1s" end="2s" {attributes}>Hello</p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"), "{xml}");
}
// A TTML agent nested inside another application's metadata container is not significant.
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library"><head><app:metadata>{real}</app:metadata></head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues[0].speaker.as_deref(), Some("speaker1"));
}
#[test]
fn imports_draft_metadata_and_legacy_unqualified_speaker_references() {
for (tt, metadata, attributes) in
[("http://www.w3.org/2006/10/ttaf1", "http://www.w3.org/2006/10/ttaf1#metadata", r#"ttm:agent="speaker1""#), (TT, META, r#"agent="speaker1""#)]
{
let xml = format!(
r#"<tt xmlns="{tt}" xmlns:ttm="{metadata}"><head><ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent></head><body><div><p begin="1s" end="2s" {attributes}>Hello</p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
}
}
#[test]
fn keeps_april_2006_draft_speaker_references_readable() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<tt xml:lang="en" xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:ttm="http://www.w3.org/2006/04/ttaf1#metadata">
<head><metadata>
<ttm:agent id="Ann_Lee" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
</metadata></head>
<body><div><p begin="1s" end="2s" ttm:agent="Ann_Lee">Hello</p></div></body>
</tt>"#;
for format in [Format::Ttml, Format::Dfxp] {
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].text, "Hello");
assert_eq!(doc.cues[0].start, FrameRate::FPS_25.tick_of(25));
assert_eq!(doc.cues[0].end, FrameRate::FPS_25.tick_of(50));
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"), "the documented draft keeps its previously readable speaker");
}
}
#[test]
fn reads_april_2006_agent_definitions_with_unqualified_ids() {
let xml = r#"<tt xml:lang="en" xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:ttm="http://www.w3.org/2006/04/ttaf1#metadata">
<head><ttm:agent id="speaker1" type="person"><ttm:name type="full">Ann-Marie Lee</ttm:name></ttm:agent></head>
<body><div><p begin="1s" end="2s" ttm:agent="speaker1">Welcome</p></div></body></tt>"#;
for format in [Format::Ttml, Format::Dfxp] {
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].text, "Welcome");
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann-Marie Lee"), "the name resolves through the draft's unqualified id");
}
}
fn assert_full_speaker_name_with_type(attributes: &str) {
let xml = format!(
r#"<tt xml:lang="en" xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library">
<head><ttm:agent xml:id="speaker1" type="person">
<ttm:name type="alias">Ann</ttm:name><ttm:name {attributes}>Ann Lee</ttm:name>
</ttm:agent></head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
);
let mut speakers = Vec::new();
for format in [Format::Ttml, Format::Dfxp] {
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].text, "Hello");
assert_eq!(doc.cues[0].start, FrameRate::FPS_25.tick_of(25));
assert_eq!(doc.cues[0].end, FrameRate::FPS_25.tick_of(50));
speakers.push(doc.cues[0].speaker.clone());
}
assert_eq!(speakers, [Some("Ann Lee".into()), Some("Ann Lee".into())], "TTML and DFXP prefer the full name: {attributes}");
}
#[test]
fn name_type_without_application_attribute_prefers_full_name() {
assert_full_speaker_name_with_type(r#"type="full""#);
}
#[test]
fn name_type_after_application_attribute_prefers_full_name() {
assert_full_speaker_name_with_type(r#"app:type="contact" type="full""#);
}
#[test]
fn name_type_before_application_attribute_prefers_full_name() {
assert_full_speaker_name_with_type(r#"type="full" app:type="contact""#);
}

View File

@ -814,6 +814,21 @@ impl AudioFileSource {
};
return Ok(Self { info, rate, samples });
}
if ext == "aac" {
let (rate, samples) = decode_adts(&bytes)?.ok_or_else(|| CodecError::Decode(format!("{name}: no ADTS AAC frames")))?;
let frames = samples.first().map_or(0, Vec::len);
let info = MediaInfo {
name: name.to_string(),
kind: MediaKind::AudioOnly,
duration: Tick::from_units(frames as i64, rate as i64),
video: None,
audio_streams: vec![AudioStreamInfo { sample_rate: rate, channels: samples.len() as u32, codec: "AAC".into(), bits_per_sample: None }],
container: "ADTS".into(),
start_timecode: None,
file_size: Some(bytes.len() as u64),
};
return Ok(Self { info, rate, samples });
}
let mss = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.clone())), Default::default());
let mut hint = Hint::new();
hint.with_extension(&ext);
@ -871,20 +886,94 @@ impl AudioFileSource {
}
}
/// The length of an ID3v2 tag at the start of `bytes` (0 without one): a 10-byte header, a
/// syncsafe size, and a 10-byte footer when flagged (ID3v2.4 §3).
fn id3v2_len(bytes: &[u8]) -> usize {
match bytes {
[b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => {
let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize);
size.saturating_add(if flags & 0x10 != 0 { 20 } else { 10 })
}
_ => 0,
}
}
/// Most samples (all channels together) a standalone ADTS file decodes into memory: 4 GiB of f32,
/// about three hours of 48 kHz stereo. Longer files are refused rather than allocated.
const MAX_ADTS_SAMPLES: usize = 1 << 30;
/// The length of the ADTS frame starting `b` (ISO/IEC 13818-7 §6.2: 12-bit sync word, layer `00`,
/// 13-bit `frame_length` including the header), when it starts with a header and fits in `b`.
fn adts_frame_len(b: &[u8]) -> Option<usize> {
let [0xFF, b1, _, b3, b4, b5, ..] = *b else { return None };
if b1 & 0xF6 != 0xF0 {
return None;
}
let len = ((b3 as usize & 3) << 11) | (b4 as usize) << 3 | (b5 as usize) >> 5;
let hdr = if b1 & 1 != 0 { 7 } else { 9 };
(len > hdr && len <= b.len()).then_some(len)
}
/// A raw ADTS stream (`.aac`) decoded with our AAC decoder: (output sample rate, planar samples).
/// Frames follow each other from the first header (after an ID3v2 tag); damaged data is skipped
/// up to the next header, frames with another configuration than the first are skipped, and a
/// frame that does not decode keeps its duration as silence. HE-AAC is upsampled to its output
/// rate. `Ok(None)` when no frame decodes.
fn decode_adts(bytes: &[u8]) -> Result<Option<(u32, Vec<Vec<f32>>)>> {
let mut frames: Vec<(Vec<u8>, &[u8])> = Vec::new();
let mut p = id3v2_len(bytes);
while let Some(rest) = bytes.get(p..).filter(|r| r.len() >= 7) {
match adts_frame_len(rest).and_then(|len| Some((len, adts_split(rest.get(..len)?)?))) {
Some((len, f)) => {
frames.push(f);
p += len;
}
None => p += 1,
}
}
let Some(asc) = frames.first().map(|f| f.0.clone()) else { return Ok(None) };
let Some(rate) = aac_output_rate(&asc, frames.iter().take(8).map(|f| f.1)) else { return Ok(None) };
let mut dec = PacketDecoder::aac(&asc, rate)?;
let mut out: Vec<Vec<f32>> = Vec::new();
let mut total = 0usize;
let mut last = 0usize;
for (cfg, payload) in frames {
if cfg != asc {
continue;
}
let decoded = dec.decode(payload, 0).ok().filter(|c| out.is_empty() || c.len() == out.len());
match decoded {
Some(c) if out.is_empty() => {
last = c.first().map_or(0, Vec::len);
out = c;
}
Some(c) => {
last = c.first().map_or(0, Vec::len);
for (o, s) in out.iter_mut().zip(c) {
o.extend(s);
}
}
None => {
for o in out.iter_mut() {
o.resize(o.len() + last, 0.0);
}
}
}
total = total.saturating_add(last.saturating_mul(out.len()));
if total > MAX_ADTS_SAMPLES {
return Err(CodecError::Unsupported("AAC file too long to import as raw ADTS (over 4 GiB decoded): remux it to .m4a".into()));
}
}
Ok(out.first().is_some_and(|c| !c.is_empty()).then_some((rate, out)))
}
/// A raw MPEG audio Layer I / II stream (`.mp2`) decoded with our decoder: (layer, sample rate,
/// planar samples). Frames follow each other from the first frame header (after an ID3v2 tag);
/// damaged data is skipped up to the next frame header of the same layer and rate, and a frame
/// with another channel count keeps its duration as silence. `None` when the stream does not start
/// with a Layer I / II frame followed by another frame (or the end), e.g. MP3.
fn decode_mpeg_layer12(bytes: &[u8]) -> Option<(u8, u32, Vec<Vec<f32>>)> {
let mut p = match bytes {
// ID3v2: 10-byte header, a syncsafe size, and a 10-byte footer when flagged
[b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => {
let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize);
size.saturating_add(if flags & 0x10 != 0 { 20 } else { 10 })
}
_ => 0,
};
let mut p = id3v2_len(bytes);
let frame_at = |p: usize| bytes.get(p..).and_then(|b| filmcraft_mpa::parse_header(b).ok()).filter(|h| h.frame_bytes > 0);
let first = frame_at(p)?;
let next = p.saturating_add(first.frame_bytes);
@ -928,7 +1017,7 @@ impl MediaSource for AudioFileSource {
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<std::result::Result<SharedSource, MediaError>> {
let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
if !matches!(ext.as_str(), "mp3" | "mp2" | "flac" | "ogg" | "oga" | "aif" | "aiff" | "aifc") {
if !matches!(ext.as_str(), "mp3" | "mp2" | "aac" | "flac" | "ogg" | "oga" | "aif" | "aiff" | "aifc") {
return None;
}
Some(AudioFileSource::decode(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))

View File

@ -175,9 +175,10 @@ impl StreamInfo {
/// with (parameter sets, a sequence header, a key frame of another format).
pub fn parameters_changed(&self, sample: &[u8]) -> bool {
match self {
Self::Nal(i) => {
i.nals(sample).into_iter().any(|n| n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) && !i.parameter_sets.iter().any(|p| p == n))
}
Self::Nal(i) => i.nals(sample).into_iter().any(|n| {
n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h)))
&& !i.parameter_sets.iter().any(|p| without_trailing_zeros(p) == without_trailing_zeros(n))
}),
Self::Frame(i) => i.parameters_changed(sample),
}
}
@ -199,6 +200,14 @@ impl StreamInfo {
}
}
/// A NAL unit without the zero bytes after its last byte: a NAL unit never ends with 0x00
/// (H.264 7.4.1, H.265 7.4.2), so they are trailing_zero_8bits some muxers keep (ffmpeg in `hvcC`), not
/// part of the parameter set.
fn without_trailing_zeros(nal: &[u8]) -> &[u8] {
let end = nal.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
nal.get(..end).unwrap_or_default()
}
/// Length-prefixed parameter-set entries (`u16` length + bytes) starting at `pos`.
fn read_sets(rec: &[u8], pos: &mut usize, count: usize, out: &mut Vec<Vec<u8>>) -> Result<()> {
for _ in 0..count {
@ -402,13 +411,12 @@ mod tests {
}
}
#[test]
fn nal_splitting_is_bounded() {
let info = NalStreamInfo {
fn hevc_info(parameter_sets: Vec<Vec<u8>>) -> NalStreamInfo {
NalStreamInfo {
codec: NalCodec::Hevc,
length_size: 4,
highest_tid: None,
parameter_sets: Vec::new(),
parameter_sets,
coded: (16, 16),
crop: (0, 0, 16, 16),
chroma_format_idc: 1,
@ -419,11 +427,33 @@ mod tests {
color: filmcraft_color::ColorInfo::REC709,
par: (1, 1),
reorder: 0,
};
}
}
#[test]
fn nal_splitting_is_bounded() {
let info = hevc_info(Vec::new());
// CRA (21) then a length that runs past the end
let s = [0, 0, 0, 2, 21 << 1, 1, 0xff, 0xff, 0xff, 0xff, 1];
assert_eq!(info.nal_types(&s), vec![21]);
assert!(info.is_irap(&s));
assert!(!info.is_irap(&[0, 0, 0, 2, 1 << 1, 1]));
}
#[test]
fn parameter_sets_compare_without_trailing_zero_bytes() {
// the hvcC PPS of an ffmpeg-muxed file and the in-band one of its first sample (#1483)
let hvcc_pps = vec![0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x90, 0x00];
let info = StreamInfo::Nal(hevc_info(vec![hvcc_pps.clone()]));
let sample = |nal: &[u8]| {
let mut v = (nal.len() as u32).to_be_bytes().to_vec();
v.extend_from_slice(nal);
v
};
assert!(!info.parameters_changed(&sample(&hvcc_pps)), "the same PPS");
assert!(!info.parameters_changed(&sample(&hvcc_pps[..7])), "a trailing zero byte is not part of the NAL unit");
assert!(!info.parameters_changed(&sample(&[&hvcc_pps[..], &[0, 0]].concat())), "nor are several");
assert!(info.parameters_changed(&sample(&hvcc_pps[..6])), "a missing byte that is not zero is a change");
assert!(info.parameters_changed(&sample(&[0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x91])), "another PPS is a change");
}
}

View File

@ -83,7 +83,7 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec
let out_rate = u32::try_from(rate).unwrap_or(0);
let frame = match &t.codec {
Codec::Aac { asc } => fixed_packet_samples(FixedFrames::Aac(asc), &[], out_rate),
Codec::Mp3 | Codec::Mp2 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate),
Codec::Mp3 | Codec::Mp2 | Codec::Mp1 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate),
Codec::Ac3 => fixed_packet_samples(FixedFrames::Ac3, &first(), out_rate),
Codec::Eac3 => fixed_packet_samples(FixedFrames::Eac3, &first(), out_rate),
_ => None,
@ -317,7 +317,7 @@ fn codec_label(c: &Codec) -> String {
Codec::Flac { .. } => "FLAC".into(),
Codec::Pcm { bits, float, .. } => format!("PCM {bits}-bit{}", if *float { " float" } else { "" }),
Codec::Mp3 => "MP3".into(),
Codec::Mp2 => "MPEG Audio".into(),
Codec::Mp2 | Codec::Mp1 => "MPEG Audio".into(),
Codec::Other(id) if id == "V_MPEG2" => "MPEG-2 Video".into(),
Codec::Other(id) if id == "V_MPEG1" => "MPEG-1 Video".into(),
other => other.name().to_string(),
@ -486,6 +486,7 @@ impl MkvSource {
Codec::Opus { head } => PacketDecoder::opus(filmcraft_opus::OpusHead::parse(head).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?),
Codec::Mp3 => PacketDecoder::new(CODEC_TYPE_MP3, rate, None),
Codec::Mp2 => PacketDecoder::mpeg_audio(2, rate),
Codec::Mp1 => PacketDecoder::mpeg_audio(1, rate),
Codec::Ac3 | Codec::Eac3 => PacketDecoder::ac3(),
// symphonia wants the STREAMINFO block body: skip `fLaC` + the 4-byte block header.
Codec::Flac { private } => PacketDecoder::new(CODEC_TYPE_FLAC, rate, private.get(8..42).map(<[u8]>::to_vec)),

View File

@ -347,6 +347,7 @@ impl Mp4Source {
Some(Mp4Audio { track: i, state, starts, offset, preroll })
})
.collect();
snap_to_frame_grid(&mut file, vtrack);
rebase_unedited_video(&mut file, vtrack, &mut audios);
Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation).with_crop(aperture), audios })
}
@ -666,6 +667,66 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, Media
Some(Mp4Source::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
/// Put the video samples of a constant-rate file whose pictures sit just off its frame grid back
/// on the grid (#1282). Phone footage stamps frames up to a millisecond after where they belong (a
/// first frame 34.1 ms long at 30 fps), and the frame shown at a time is the last one that starts at
/// or before it: a sequence frame asked at the exact grid time got the picture before.
///
/// The grid starts at the first picture and steps by the nominal frame duration. A file is snapped
/// only when every picture is less than a quarter of a frame from its own grid point and no two land
/// on the same one: a frame that far off was placed there (variable frame rate), so a file with one
/// such picture keeps all its times. A picture at most one unit off is left where it is: that can't
/// be told from the timescale's rounding. Snapping keeps the samples' presentation order. Only the video samples move: the first picture, which the audio and
/// `rebase_unedited_video` go by, is the grid's origin, and the clip's duration comes from the
/// track header.
fn snap_to_frame_grid(file: &mut Mp4File, vtrack: Option<usize>) {
let Some(t) = vtrack.and_then(|i| file.tracks.get_mut(i)) else { return };
let Some(first) = t.samples.iter().map(|s| s.pts).min() else { return };
// The nominal duration: the median sample duration, averaged with the durations one unit from
// it. Those are the same duration rounded to the timescale (1501 and 1502 at 59.94 fps over
// 90 kHz): the median alone drifts half a unit a frame, enough to snap a short clip onto the
// wrong points.
let mut durs: Vec<u32> = t.samples.iter().map(|s| s.duration).collect();
durs.sort_unstable();
let Some(&median) = durs.get(durs.len() / 2) else { return };
let near: Vec<f64> = durs.iter().filter(|&&d| d.abs_diff(median) <= 1).map(|&d| f64::from(d)).collect();
let mean = near.iter().sum::<f64>() / near.len().max(1) as f64;
if mean <= 0.0 {
return;
}
let rate = FrameRate::from_f64(f64::from(t.timescale) / mean);
// one frame lasts `step_num / step_den` track units
let (step_num, step_den) = (i128::from(t.timescale) * i128::from(rate.den), i128::from(rate.num));
if step_num <= 0 || step_den <= 0 {
return;
}
// a sample's grid index and its new time
let snap = |pts: i64| -> Option<(i128, i64)> {
let off = i128::from(pts) - i128::from(first);
// the nearest grid index, then its grid point (both rounded half up)
let n = (2 * off * step_den + step_num).div_euclid(2 * step_num);
let at = i128::from(first) + (2 * n * step_num + step_den).div_euclid(2 * step_den);
let off_grid = (i128::from(pts) - at).abs();
if off_grid * 4 * step_den >= step_num {
return None;
}
Some((n, if off_grid <= 1 { pts } else { i64::try_from(at).ok()? }))
};
let Some(snapped) = t.samples.iter().map(|s| snap(s.pts)).collect::<Option<Vec<_>>>() else { return };
// Two pictures by one grid point: not a constant rate. Checked on the index, not the new time:
// one picture kept a unit after the point and another moved onto it would be distinct times in
// the wrong order.
let mut points: Vec<i128> = snapped.iter().map(|&(n, _)| n).collect();
points.sort_unstable();
points.dedup();
if points.len() != snapped.len() {
return;
}
for (s, (_, p)) in t.samples.iter_mut().zip(snapped) {
s.pts = p;
}
}
/// Start the file at its first picture when its video track has no edit list (#714). Without an
/// edit list the media timeline is the presentation, so a B-frame stream's first picture sits at its
/// composition delay (two frames on an x264 MP4 written without `elst`, and on fragmented MP4s):

View File

@ -134,3 +134,104 @@ fn webm_with_1ms_timestamps_shows_every_frame_once() {
fn mp4_with_a_1ms_timescale_shows_every_frame_once() {
check("mov", mp4_1ms);
}
/// An MP4 at a 90 kHz timescale whose frames after the first start `late` units after the frame
/// grid, as phone footage does (issue #1282): the first sample lasts `late` units longer.
fn mp4_90k_late(rate: FrameRate, late: i64) -> Arc<[u8]> {
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 90_000)).unwrap();
let start = |k: usize| {
let grid = (k as i128 * 90_000 * rate.den as i128 + rate.num as i128 / 2) / rate.num as i128;
grid as i64 + if k > 0 { late } else { 0 }
};
for k in 0..frame_count(rate) {
let duration = (start(k + 1) - start(k)) as u32;
w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap();
}
w.finish().unwrap().into_inner().into()
}
fn check_90k_late(late: i64) {
let mut failures = Vec::new();
for rate in RATES {
let src = crate::open_bytes("synthetic.mov", mp4_90k_late(rate, late)).unwrap();
for p in problems(src.as_ref(), rate) {
failures.push(format!("{rate}, frames {late}/90000 s late: {p}"));
}
}
assert!(failures.is_empty(), "mov:\n{}", failures.join("\n"));
}
#[test]
fn mp4_with_frames_1ms_after_the_grid_shows_every_frame_once() {
check_90k_late(90);
}
#[test]
fn mp4_with_frames_on_the_grid_shows_every_frame_once() {
check_90k_late(0);
}
/// An MP4 at 90 kHz with these sample durations.
fn mp4_90k(durations: &[u32]) -> Arc<[u8]> {
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 90_000)).unwrap();
for (k, &duration) in durations.iter().enumerate() {
w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap();
}
w.finish().unwrap().into_inner().into()
}
/// Files that are not a constant rate gone slightly off its grid keep their own times: whatever is
/// asked (each frame's start, the unit before it, every 30 fps grid point), the frame shown is the
/// last one starting at or before it.
#[test]
fn mp4_variable_rate_and_on_grid_frame_times_stay_put() {
let cases: [(&str, Vec<u32>); 5] = [
// 1/30 s and 1/20 s in turn
("alternating", (0..60).map(|k| if k % 2 == 0 { 3000 } else { 4500 }).collect()),
// 30 fps, then every frame 0.4 frame later (one frame lasts 1.4 frames)
("shifted", (0..80).map(|k| if k == 40 { 4200 } else { 3000 }).collect()),
// 30 fps with a frame shown for a fifth of a frame: it and the next are both near one grid point
(
"stutter",
(0..80)
.map(|k| match k {
40 => 600,
41 => 5400,
_ => 3000,
})
.collect(),
),
// 30 fps with two frames by one grid point, one a unit after it and one five units after
(
"one unit and five after one point",
(0..80)
.map(|k| match k {
39 => 3001,
40 => 4,
41 => 2995,
_ => 3000,
})
.collect(),
),
// 59.94 fps on its grid, stamps truncated to the timescale (1501, 1502, …)
("59.94 truncated", (0..120).map(|k: u64| ((k + 1) * 1_501_500 / 1000 - k * 1_501_500 / 1000) as u32).collect()),
];
let mut failures = Vec::new();
for (name, durations) in cases {
let src = crate::open_bytes("synthetic.mov", mp4_90k(&durations)).unwrap();
let starts: Vec<i64> = durations.iter().scan(0i64, |t, &d| Some(std::mem::replace(t, *t + i64::from(d)))).collect();
let end = *starts.last().unwrap();
let mut asked: Vec<i64> = starts.iter().flat_map(|&s| [s, s - 1]).filter(|&s| s >= 0).collect();
asked.extend((0..).map(|k| k * 3000).take_while(|&t| t <= end));
for t in asked {
let want = starts.iter().rposition(|&s| s <= t).unwrap();
let got = index_of(&src.video_frame(FrameRequest::full(Tick::from_rational(t, 1, 90_000))).unwrap());
if got != want {
failures.push(format!("{name}: at {t}/90000 s wanted frame {want}, got {got}"));
}
}
}
assert!(failures.is_empty(), "{}", failures[..failures.len().min(8)].join("\n"));
}

View File

@ -0,0 +1,66 @@
//! Standalone ADTS `.aac` files (#1291): File ▸ Import offers them, and FilmCraft's own AAC export
//! writes them (#814), so they must open as audio. The streams are made with our AAC encoder, which
//! prefixes each access unit with an ADTS header (ISO/IEC 13818-7 §6.2).
use std::sync::Arc;
use filmcraft_aac::{Encoder, EncoderConfig};
const RATE: u32 = 48_000;
/// `secs` of a 440 Hz tone at half scale in every channel, as an ADTS stream.
fn adts_tone(channels: usize, secs: u32) -> Vec<u8> {
let tone: Vec<f32> = (0..RATE * secs).map(|i| (std::f32::consts::TAU * 440.0 * i as f32 / RATE as f32).sin() * 0.5).collect();
let planar: Vec<&[f32]> = (0..channels).map(|_| tone.as_slice()).collect();
let mut enc = Encoder::new(EncoderConfig { adts: true, ..EncoderConfig::cbr(RATE, channels, 128_000) }).unwrap();
let mut out = Vec::new();
for au in enc.encode(&planar).into_iter().chain(enc.flush()) {
out.extend(au);
}
out
}
fn rms(s: &[f32]) -> f32 {
(s.iter().map(|v| v * v).sum::<f32>() / s.len().max(1) as f32).sqrt()
}
#[test]
fn adts_aac_files_import_as_audio() {
let src = filmcraft_codecs::open_bytes("tone.aac", Arc::from(adts_tone(2, 1))).unwrap();
let info = src.info();
let a = info.audio().unwrap();
assert_eq!((a.sample_rate, a.channels), (RATE, 2));
// One second plus the encoder's priming and the last frame's padding (at most two frames).
let secs = info.duration.seconds();
assert!((1.0..=1.0 + 2048.0 / RATE as f64).contains(&secs), "duration {secs}");
let buf = src.audio(RATE as i64 / 4, RATE as usize / 2, RATE).unwrap();
for (c, ch) in buf.channels.iter().enumerate() {
let level = rms(ch);
assert!((level - 0.5 / 2f32.sqrt()).abs() < 0.05, "channel {c} rms {level}");
}
}
#[test]
fn damaged_adts_files_fail_without_panicking() {
let good = adts_tone(1, 1);
let mut cases: Vec<Vec<u8>> = (0..good.len().min(4096)).step_by(7).map(|n| good[..n].to_vec()).collect();
for i in (0..good.len()).step_by(97) {
let mut b = good.clone();
b[i] ^= 1 << (i % 8);
cases.push(b);
}
// A frame length of zero, and one running past the end of the file.
let mut zero = good.clone();
zero[3] &= !3;
zero[4] = 0;
zero[5] &= 0x1F;
cases.push(zero);
let mut long = good[..64].to_vec();
long[3] |= 3;
long[4] = 0xFF;
cases.push(long);
for (i, b) in cases.into_iter().enumerate() {
let r = std::panic::catch_unwind(|| filmcraft_codecs::open_bytes("damaged.aac", Arc::from(b)).map(|s| s.audio(0, 4800, RATE).is_ok()));
assert!(r.is_ok(), "case {i} panicked");
}
}

View File

@ -0,0 +1,89 @@
//! MPEG audio Layer I (MP1) in Matroska (`A_MPEG/L1`, #1402) against ffmpeg's decode. No
//! installed encoder writes Layer I, so the frames are synthetic (valid random Layer I frames,
//! as in `crates/mpa/tests/oracle.rs`) and ffmpeg only remuxes them into Matroska and decodes.
mod common;
use common::*;
struct Bits(Vec<u8>, usize);
impl Bits {
fn put(&mut self, v: u32, n: u32) {
for i in (0..n).rev() {
if self.1.is_multiple_of(8) {
self.0.push(0);
}
if (v >> i) & 1 == 1 {
let last = self.0.len() - 1;
self.0[last] |= 0x80 >> (self.1 % 8);
}
self.1 += 1;
}
}
}
/// Mono Layer I frames, MPEG-1, 48 kHz, 192 kbit/s (192-byte frames): allocations on the first 8
/// subbands only, so every frame fits.
fn layer1_stream(frames: usize) -> Vec<u8> {
let mut seed = 0x2545_F491u32;
let mut rnd = |n: u32| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed % n
};
let mut out = Vec::new();
for _ in 0..frames {
let mut b = Bits(Vec::new(), 0);
for byte in [0xFFu32, 0xFF, 6 << 4 | 1 << 2, 3 << 6] {
b.put(byte, 8);
}
let alloc: Vec<u32> = (0..32).map(|sb| if sb < 8 { 1 + rnd(8) } else { 0 }).collect();
for &a in &alloc {
b.put(a, 4);
}
for &a in &alloc {
if a != 0 {
b.put(10 + rnd(30), 6);
}
}
for _ in 0..12 {
for &a in &alloc {
if a != 0 {
b.put(rnd((1 << (a + 1)) - 1), a + 1);
}
}
}
let mut f = b.0;
f.resize(192, 0);
out.extend(f);
}
out
}
#[test]
fn mp1_in_matroska_matches_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
let raw = dir().join("synthetic_layer1.mp1");
std::fs::create_dir_all(dir()).unwrap();
std::fs::write(&raw, layer1_stream(250)).unwrap();
let Some(mkv) = fixture(&ff, "synthetic_layer1.mkv", &["-f", "mp3", "-i", raw.to_str().unwrap(), "-c:a", "copy"]) else {
eprintln!("SKIPPED: this ffmpeg can't remux Layer I into Matroska");
return;
};
let m = filmcraft_matroska::open(bytes(&mkv)).unwrap();
assert!(matches!(m.tracks.first().map(|t| &t.codec), Some(filmcraft_matroska::Codec::Mp1)), "A_MPEG/L1 maps to Codec::Mp1");
let src = filmcraft_codecs::open_bytes("synthetic_layer1.mkv", bytes(&mkv)).unwrap();
let a = src.info().audio().cloned().unwrap();
assert_eq!((a.sample_rate, a.channels), (48_000, 1));
let want = ffmpeg_audio_f32(&ff, &mkv, &[]);
let n = want.len().min(250 * 384) - 800;
let got = src.audio(0, n, 48_000).unwrap();
let peak = want.iter().fold(0f32, |m, x| m.max(x.abs()));
// ffmpeg's CLI may or may not drop the decoder delay of a stream copy; search a few lags
let (worst, lag) = (-2i64..=2)
.map(|lag| ((2..n - 2).map(|i| (want[(i as i64 + lag) as usize] - got.channels[0][i]).abs()).fold(0f32, f32::max), lag))
.fold((f32::INFINITY, 0), |m, x| if x.0 < m.0 { x } else { m });
println!("mkv Layer I: max error {worst} at lag {lag}, peak {peak}");
assert!(peak > 0.01 && worst <= 2e-4, "peak {peak}, max error {worst} at lag {lag}");
}

View File

@ -345,8 +345,23 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option<ItemId> {
fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "timeline.move";
let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new();
let mut new_moves = Vec::new();
for m in p.get("moves").and_then(Value::as_array).into_iter().flatten() {
let track = track_p(s, m, "track", CMD)?;
let track = if str_p(m, "track").is_some_and(|t| t.eq_ignore_ascii_case("new")) {
let clip = clip_p(m, "clip").ok_or_else(|| bad(CMD, "new track needs a clip"))?;
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (source, _) = q.find_item(clip).ok_or(edit::EditError::NoItem(clip))?;
if q.track(source).is_some_and(|t| t.locked) {
return Err(edit::EditError::Locked.into());
}
if m.get("time").and_then(Value::as_i64).is_none() {
return Err(bad(CMD, "new track needs a time"));
}
new_moves.push((clip, source));
Some(source)
} else {
track_p(s, m, "track", CMD)?
};
if let (Some(clip), Some(track), Some(time)) = (clip_p(m, "clip"), track, m.get("time").and_then(Value::as_i64)) {
listed.push((clip, track, Tick(time)));
}
@ -391,16 +406,48 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
q.into_iter().flat_map(|q| q.all_tracks()).flat_map(|t| t.items.iter().map(move |i| (i.id.0, t.id, i.start, i.end(), i.item))).collect()
};
let before = spots(s);
// Allocate destinations in the same transaction as the move: an invalid edit leaves
// neither empty tracks nor history entries. Each source track keeps its clips together.
let prepare = |q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>| -> Result<Vec<(ClipId, TrackId, Tick)>> {
let sources: Vec<_> = q.all_tracks().filter(|t| new_moves.iter().any(|m| m.1 == t.id)).map(|t| (t.id, t.kind, t.channels)).collect();
for kind in [TrackKind::Video, TrackKind::Audio] {
let added = sources.iter().filter(|s| s.1 == kind).count();
if added > 0 && q.tracks(kind).len().saturating_add(added) > crate::sequence_tools::MAX_TRACKS {
return Err(bad(CMD, "new tracks exceed the sequence track limit"));
}
}
let mut destinations = Vec::new();
for (source, kind, channels) in sources {
let tracks = if kind == TrackKind::Video { &mut q.video_tracks } else { &mut q.audio_tracks };
let prefix = if kind == TrackKind::Video { "Video" } else { "Audio" };
let id = TrackId(ctx.alloc());
let mut track = Track::new(id, kind, format!("{prefix} {}", tracks.len() + 1));
track.channels = channels;
tracks.push(track);
destinations.push((source, id));
}
Ok(moves
.iter()
.map(|&(clip, track, time)| {
let dest = new_moves.iter().find(|m| m.0 == clip).and_then(|m| destinations.iter().find(|d| d.0 == m.1)).map_or(track, |d| d.1);
(clip, dest, time)
})
.collect())
};
let copied = if copy {
let label = if ins { "Copy (Insert)" } else { "Copy" };
s.edit_sequence(label, |q, ctx, st| {
let ids = edit::copy_items(q, &moves, ins, ctx)?;
let destinations = prepare(q, ctx)?;
let ids = edit::copy_items(q, &destinations, ins, ctx)?;
st.selection = ids.clone();
st.transition_selection.clear();
Ok(ids)
})?
} else {
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| {
let destinations = prepare(q, ctx)?;
Ok(edit::move_items(q, &destinations, ins, ctx)?)
})?;
Vec::new()
};
// An overwrite shortens, splits or removes whatever already sits where a clip lands, and a
@ -2528,12 +2575,7 @@ fn build() -> Vec<CommandSpec> {
None,
r#"{"moves":[{"clip":id,"track":id|"V2"|"new" (after the last of its kind),"time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#,
has_seq,
|s, p| {
if crate::sequence_tools::move_wants_new_track(p) {
return crate::sequence_tools::move_to_new_tracks(s, p);
}
move_clips(s, p)
}
move_clips
),
cmd!(
"timeline.trim",
@ -3148,20 +3190,40 @@ 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() {
for it in s.project.sequences().filter(|it| !items.contains(&it.id)) {
if let Some(q) = it.as_sequence() {
for t in q.all_tracks() {
used.extend(t.items.iter().map(|x| x.item));
for clip in t
.items
.iter()
.filter(|clip| !items.contains(&clip.item) || s.project.item(clip.item).is_some_and(|item| matches!(item.kind, ItemKind::Media(_))))
{
// Preserve the whole valid file-backed chain, not just its root.
if !s.project.resolve_media(clip.item).is_some_and(|(_, media, _)| matches!(media.media, MediaRef::File { .. })) {
continue;
}
let mut id = clip.item;
for _ in 0..16 {
used.insert(id);
match s.project.item(id).map(|item| &item.kind) {
Some(ItemKind::Subclip { parent, .. }) => id = *parent,
_ => break,
}
}
}
}
}
}
let mut gone = Vec::new();
let mut offlined = Vec::new();
let mut retained_subclips = 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 if used.contains(&i) && s.project.item(i).is_some_and(|item| matches!(item.kind, ItemKind::Subclip { .. })) {
retained_subclips.push(i);
} else {
gone.push(i);
}
@ -3175,7 +3237,7 @@ fn build() -> Vec<CommandSpec> {
for b in &bins {
pr.root.remove_bin(*b);
}
for i in &offlined {
for i in offlined.iter().chain(&retained_subclips) {
if pr.root.parent_of(*i).is_none() {
pr.root.children.push(filmcraft_project::BinEntry::Item(*i));
}

View File

@ -360,6 +360,13 @@ fn user_presets_persist_with_favourites_import_and_export() {
assert_eq!(favs, 2);
let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len();
assert_eq!(hits, 8, "seven QuickTime ProRes presets and MXF OP1a ProRes 422 HQ");
// #1371: the query also matches the format id and the normalised label
let h264_only = s.execute("export.presets.list", json!({"format": "h264"})).unwrap()["presets"].as_array().unwrap().len();
let mut count = |q: &str| s.execute("export.presets.list", json!({"query": q})).unwrap()["presets"].as_array().unwrap().len();
assert!(count("h264") > 0, "format id `h264` finds the H.264 presets");
assert!(count("h264") >= h264_only, "every h264 preset is found by its format id");
assert_eq!(count("h.264"), count("H.264"));
assert!(count("h.264") >= h264_only);
// persisted: a fresh session with the same data directory sees both
let mut s2 = demo();
s2.export_presets.set_dir(&dir.0);
@ -661,8 +668,11 @@ fn ranges_resolve_and_sidecar_captions_are_written() {
let out = dir.path("cap.wav");
s.execute("file.exportMedia", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": out, "captionSidecar": "srt", "wait": true})).unwrap();
let srt = std::fs::read_to_string(dir.path("cap.srt")).unwrap();
// timed from the start of the export range (In at 1 s; the caption starts on frame 35)
assert!(srt.contains("Hello sidecar") && srt.contains("00:00:00,460 --> 00:00:01,461"), "{srt}");
// Timed from In at 1 s (the caption starts on frame 35), clipped to the export duration.
let resolved = s.execute("export.resolve", json!({"preset": "Waveform Audio 48 kHz 16-bit"})).unwrap();
let duration = filmcraft_time::Tick(resolved["range"]["duration"].as_i64().unwrap());
let timing = format!("00:00:00,460 --> {}", filmcraft_captions::format_clock(duration, ','));
assert!(srt.contains("Hello sidecar") && srt.contains(&timing), "{srt}");
s.execute("file.exportMedia", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": out, "captionSidecar": "vtt", "wait": true})).unwrap();
assert!(std::fs::read_to_string(dir.path("cap.vtt")).unwrap().starts_with("WEBVTT"));
// a bad preset name is a clean error
@ -706,3 +716,102 @@ fn unknown_settings_keys_are_errors_not_ignored() {
assert_eq!(ok.effects.loudness.target_lufs, -14.0, "{key}");
}
}
#[test]
fn caption_sidecars_keep_only_the_exported_interval() {
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use filmcraft_captions::{Document, Format};
use filmcraft_media::MediaSource;
use filmcraft_time::{TICKS_PER_SECOND, Tick};
#[derive(Default)]
struct MemoryFiles(Mutex<BTreeMap<String, Vec<u8>>>);
impl crate::Services for MemoryFiles {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
self.0.lock().unwrap().get(path).cloned().ok_or_else(|| std::io::ErrorKind::NotFound.into())
}
fn write_file(&self, path: &str, bytes: &[u8]) -> std::io::Result<()> {
self.0.lock().unwrap().insert(path.to_owned(), bytes.to_vec());
Ok(())
}
fn export_in_memory(&self) -> bool {
true
}
}
fn session(text: &str) -> (Session, Arc<MemoryFiles>) {
let files = Arc::new(MemoryFiles::default());
files.0.lock().unwrap().insert("input.vtt".into(), text.as_bytes().to_vec());
let mut s = Session::new(files.clone());
s.execute("file.newSequence", json!({"name":"Caption range", "fps":25, "width":16, "height":16, "video":0, "audio":0})).unwrap();
s.execute("captions.import", json!({"path":"input.vtt"})).unwrap();
s.execute("markers.markIn", json!({"time":2 * TICKS_PER_SECOND})).unwrap();
// The UI Out point includes that frame; the export range ends at exactly five seconds.
s.execute("markers.markOut", json!({"time":5 * TICKS_PER_SECOND - TICKS_PER_SECOND / 25})).unwrap();
(s, files)
}
fn sidecar(s: &mut Session, files: &MemoryFiles, format: Format, range: Value, duration: Tick) -> Document {
let ext = format.extension();
let mut params = json!({"format":"wav", "path":"output.wav", "captionSidecar":ext, "wait":true});
params.as_object_mut().unwrap().extend(range.as_object().unwrap().clone());
let result = s.execute("file.exportMedia", params).unwrap();
assert_eq!(result["result"]["path"], "output.wav");
assert!(result["result"]["bytes"].as_u64().is_some_and(|n| n > 0), "{result}");
let wav = files.0.lock().unwrap().get("output.wav").unwrap().clone();
let audio = filmcraft_media::wav::WavSource::parse("output.wav", wav.into()).unwrap();
assert_eq!(audio.info().duration, duration, "exported audio duration");
let bytes = files.0.lock().unwrap().get(&format!("output.{ext}")).unwrap().clone();
let doc = filmcraft_captions::parse(&bytes, format).unwrap();
assert!(doc.warnings.is_empty(), "{doc:?}");
doc
}
// Every time is both a millisecond and an exact frame boundary at 25 fps.
let fixture = "WEBVTT\n\nSTYLE\n::cue { color: yellow }\n\nREGION\nid:bottom\n\nNOTE original fixture\n\nbefore\n00:00:00.000 --> 00:00:01.000\nBefore\n\nin\n00:00:01.600 --> 00:00:02.400 line:90% align:center\n<v Ann>Crossing In\n\ninside\n00:00:03.000 --> 00:00:04.000\nInside\n\nout\n00:00:04.600 --> 00:00:05.400\nCrossing Out\n\nafter\n00:00:06.000 --> 00:00:07.000\nAfter\n";
let (mut s, files) = session(fixture);
let original = serde_json::to_value(&s.project).unwrap();
let history = s.execute("history.list", json!({})).unwrap();
let ticks = |tenths: i64| Tick(tenths * (TICKS_PER_SECOND / 10));
let expected = [(ticks(0), ticks(4), "Crossing In"), (ticks(10), ticks(20), "Inside"), (ticks(26), ticks(30), "Crossing Out")];
for format in [Format::Srt, Format::WebVtt] {
for range in [json!({"range":"custom", "startTime":2 * TICKS_PER_SECOND, "endTime":5 * TICKS_PER_SECOND}), json!({"range":"inOut"}), json!({})] {
let doc = sidecar(&mut s, &files, format, range, ticks(30));
let actual: Vec<_> = doc.cues.iter().map(|c| (c.start, c.end, c.text.as_str())).collect();
assert_eq!(actual, expected, "{format:?}: exclude later cues and clip both crossing boundaries");
if format == Format::WebVtt {
assert_eq!(doc.cues[0].id.as_deref(), Some("in"));
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann"));
assert_eq!(doc.cues[0].settings, "line:90% align:center");
assert_eq!(doc.blocks, ["STYLE\n::cue { color: yellow }", "REGION\nid:bottom", "NOTE original fixture"]);
}
}
let all = sidecar(&mut s, &files, format, json!({"range":"entire"}), ticks(70));
let actual: Vec<_> = all.cues.iter().map(|c| (c.start, c.end, c.text.as_str())).collect();
assert_eq!(
actual,
[
(ticks(0), ticks(10), "Before"),
(ticks(16), ticks(24), "Crossing In"),
(ticks(30), ticks(40), "Inside"),
(ticks(46), ticks(54), "Crossing Out"),
(ticks(60), ticks(70), "After")
]
);
// A standalone caption export still exports the whole track even with sequence In/Out set.
let path = format!("standalone.{}", format.extension());
s.execute("captions.export", json!({"path":path})).unwrap();
let standalone = filmcraft_captions::parse(files.0.lock().unwrap().get(&path).unwrap(), format).unwrap();
assert_eq!(standalone, all);
}
assert_eq!(serde_json::to_value(&s.project).unwrap(), original, "export must not trim the project captions");
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
let (mut edge, edge_files) = session("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nEnds at In\n\n00:00:05.000 --> 00:00:06.000\nStarts at Out\n");
for format in [Format::Srt, Format::WebVtt] {
let doc = sidecar(&mut edge, &edge_files, format, json!({"range":"inOut"}), ticks(30));
assert!(doc.cues.is_empty(), "half-open export excludes cues touching either boundary: {doc:?}");
}
}

View File

@ -449,7 +449,11 @@ fn write_sidecar(s: &Session, project: &Project, seq: ItemId, settings: &ExportS
let Some(track) = q.caption_tracks.first() else { return Ok(None) };
let format = filmcraft_captions::Format::from_name(kind).ok_or_else(|| bad("export", format!("caption sidecar `{kind}`: srt | vtt")))?;
let range = filmcraft_export::export_range(project, seq, settings).map_err(|e| EngineError::Other(e.to_string()))?;
let doc = filmcraft_captions::document_from_track(track, range.start);
let mut doc = filmcraft_captions::document_from_track(track, range.start);
doc.cues.retain_mut(|cue| {
cue.end = cue.end.min(range.duration);
cue.start < cue.end
});
let bytes =
filmcraft_captions::write(&doc, format, filmcraft_captions::WriteOptions { drop_frame: q.settings.drop_frame, rate: Some(q.settings.frame_rate) });
let ext = if format == filmcraft_captions::Format::WebVtt { "vtt" } else { kind };
@ -953,8 +957,14 @@ fn preset_json(s: &Session, pr: &ExportPreset, full: bool) -> Value {
v
}
/// Lower-cased alphanumerics only, so `h264`, `h.264` and `H.264` compare equal.
fn squash_alnum(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
fn presets_list(s: &mut Session, p: &Value) -> Result<Value> {
let query = str_p(p, "query").unwrap_or("").to_ascii_lowercase();
let squashed = squash_alnum(&query);
let cat = str_p(p, "category").map(str::to_ascii_lowercase);
let fav = bool_p(p, "favorites").unwrap_or(false);
let fmt = str_p(p, "format").and_then(Format::from_name);
@ -967,6 +977,10 @@ fn presets_list(s: &mut Session, p: &Value) -> Result<Value> {
|| x.name.to_ascii_lowercase().contains(&query)
|| x.description.to_ascii_lowercase().contains(&query)
|| x.category.to_ascii_lowercase().contains(&query)
// The format id (`h264`) and label (`H.264`) are equivalent spellings.
|| (!squashed.is_empty()
&& (squash_alnum(x.settings.format.id()).contains(&squashed)
|| squash_alnum(x.settings.format.label()).contains(&squashed)))
})
.filter(|x| cat.as_ref().is_none_or(|c| x.category.to_ascii_lowercase() == *c))
.filter(|x| !fav || s.export_presets.is_favorite(&x.name))

View File

@ -514,8 +514,79 @@ fn union_box(boxes: &[(usize, Vec2, [f64; 4])]) -> [f64; 4] {
boxes.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, b| [a[0].min(b.2[0]), a[1].min(b.2[1]), a[2].max(b.2[2]), a[3].max(b.2[3])])
}
/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection.
/// Video clips (not graphics) the align commands move by their Motion ▸ Position (#1081): `clip`,
/// else the selected ones. Empty when a graphic clip is named or selected: its layers align.
fn align_target_clips(s: &Session, p: &Value) -> Vec<ClipId> {
let Some(seq) = s.active_sequence() else { return Vec::new() };
let video = |c: &ClipId| seq.find_item(*c).is_some_and(|(t, _)| seq.track(t).is_some_and(|t| t.kind == TrackKind::Video)) && !is_graphic(s, seq, *c);
match u64_p(p, "clip").map(ClipId) {
Some(c) => [c].into_iter().filter(video).collect(),
None if s.state.selection.iter().any(|c| is_graphic(s, seq, *c)) => Vec::new(),
None => s.state.selection.iter().copied().filter(video).collect(),
}
}
fn has_graphic_or_clip(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if align_target_clips(s, &Value::Null).is_empty() { has_graphic(s).map_err(|_| "select a graphic or video clip".into()) } else { Ok(()) }
}
/// Align video clips to the frame (each, as a group, or to their union) by moving Motion ▸
/// Position only, from the clip's scaled and rotated bounds at the playhead. Position gets a
/// keyframe there only when it is already animated, as typing a value would.
fn align_clips(s: &mut Session, clips: &[ClipId], how: &str, mode: AlignTo) -> Result<Value> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let t = s.playhead();
let at = |it: &filmcraft_project::TrackItem| it.source_time_at(t.min(it.end() - Tick(1)).max(it.start));
let frame = [0.0, 0.0, seq.settings.width as f64, seq.settings.height as f64];
let mut boxes = Vec::new();
for (i, &c) in clips.iter().enumerate() {
let Some((_, it)) = seq.find_item(c) else { continue };
let Some(src) = s.project.source_size(it.item) else { continue };
let Some(motion) = it.effect("motion") else { continue };
let mt = at(it);
let m = filmcraft_render::motion_matrix(seq, it, src, s.project.source_par(it.item), mt);
let (w, h) = (src.0 as f64, src.1 as f64);
let corners = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].map(|(x, y)| m.apply(Vec2::new(x, y)));
let b = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.x), b[1].min(p.y), b[2].max(p.x), b[3].max(p.y)]);
let pos = motion.vec2_at("position", mt);
let pos = if pos.x.is_nan() { Vec2::new(frame[2] / 2.0, frame[3] / 2.0) } else { pos };
boxes.push((i, pos, b));
}
if boxes.is_empty() {
return Err(bad("graphics.align", "the selected clips have no picture to align"));
}
let union = union_box(&boxes);
let group = align_delta(how, frame, union)?;
let mut moves = Vec::new();
for (i, pos, b) in &boxes {
let (dx, dy) = match mode {
AlignTo::Frame => align_delta(how, frame, *b)?,
AlignTo::FrameGroup => group,
AlignTo::Selection => align_delta(how, union, *b)?,
};
moves.extend(clips.get(*i).map(|c| (*c, Vec2::new(pos.x + dx, pos.y + dy))));
}
s.edit_sequence("Align Clips", |q, _, _| {
for (c, pos) in &moves {
let Some((_, it)) = q.find_item_mut(*c) else { continue };
let mt = at(it);
if let Some(prm) = it.effects.iter_mut().find(|e| e.effect == "motion").and_then(|e| e.params.get_mut("position")) {
prm.set_at(mt, ParamValue::Vec2(*pos));
}
}
Ok(())
})?;
Ok(json!({"clips": moves.iter().map(|(c, _)| c.0).collect::<Vec<_>>()}))
}
/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection. With video
/// clips selected instead of a graphic, the clips align (#1081).
fn align_layers(s: &mut Session, p: &Value, how: &str, mode: AlignTo) -> Result<Value> {
let clips = align_target_clips(s, p);
if !clips.is_empty() {
return align_clips(s, &clips, how, mode);
}
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.align", "no graphic clip"))?;
let layers = layers_p(s, clip, p)?;
let (boxes, size) = layer_boxes(s, clip, &layers)?;
@ -669,7 +740,7 @@ fn has_graphics_in_seq(s: &Session) -> std::result::Result<(), String> {
if graphic_clips(s).is_empty() { Err("the sequence has no graphics".into()) } else { Ok(()) }
}
const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers)}"#;
const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers; selected video clips move by Motion Position)}"#;
macro_rules! align_cmd {
($id:literal, $label:literal, $menu:expr, $how:literal, $mode:expr, $en:expr) => {
@ -787,12 +858,12 @@ fn menu_commands() -> Vec<CommandSpec> {
Ok(json!({"clip": clip.0, "item": item.0}))
},
),
align_cmd!("graphics.alignFrame.left", "Left", F, "left", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.hcenter", "Center Horizontally", F, "hcenter", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.right", "Right", F, "right", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.top", "Top", F, "top", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.vcenter", "Center Vertically", F, "vcenter", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.bottom", "Bottom", F, "bottom", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.left", "Left", F, "left", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignFrame.hcenter", "Center Horizontally", F, "hcenter", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignFrame.right", "Right", F, "right", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignFrame.top", "Top", F, "top", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignFrame.vcenter", "Center Vertically", F, "vcenter", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignFrame.bottom", "Bottom", F, "bottom", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignGroup.left", "Left", G, "left", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.hcenter", "Center Horizontally", G, "hcenter", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.right", "Right", G, "right", AlignTo::FrameGroup, has_two_layers),

View File

@ -658,3 +658,34 @@ fn a_layer_saved_before_the_box_height_existed_can_still_get_one() {
s.execute("graphics.set", json!({"clip": clip.0, "props": {"box_height": 100}})).unwrap();
assert_eq!(s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"][0]["box"], json!([500.0, 100.0]));
}
#[test]
fn align_to_frame_moves_selected_video_clips_by_position_only() {
// #1081
let mut s = demo();
let q = s.active_sequence().unwrap();
let (fw, fh) = (q.settings.width as f64, q.settings.height as f64);
let it = q.video_tracks[0].items[0].clone();
let (sw, _) = s.project.source_size(it.item).unwrap();
let c = it.id.0;
assert!(!s.is_enabled("graphics.alignFrame.hcenter"), "nothing selected");
s.execute("timeline.select", json!({"clips": [c]})).unwrap();
s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "scale", "value": 50.0})).unwrap();
s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "position", "value": [300.0, 200.0]})).unwrap();
let pos = |s: &Session| {
let it = s.active_sequence().unwrap().find_item(ClipId(c)).unwrap().1.clone();
let m = it.effect("motion").unwrap();
(m.vec2_at("position", Tick::ZERO), m.f64_at("scale", Tick::ZERO))
};
s.execute("graphics.alignFrame.hcenter", json!({})).unwrap();
let (p, scale) = pos(&s);
assert!((p.x - fw / 2.0).abs() < 1e-6 && (p.y - 200.0).abs() < 1e-6, "{p:?}");
assert_eq!(scale, 50.0, "scale untouched");
s.execute("graphics.alignFrame.left", json!({})).unwrap();
assert!((pos(&s).0.x - sw as f64 * 0.25).abs() < 1e-6, "left edge on the frame's: {:?}", pos(&s).0);
s.execute("graphics.alignFrame.bottom", json!({})).unwrap();
let h = s.project.source_size(it.item).unwrap().1 as f64;
assert!((pos(&s).0.y - (fh - h * 0.25)).abs() < 1e-6, "{:?}", pos(&s).0);
s.execute("edit.undo", json!({})).unwrap();
assert!((pos(&s).0.y - 200.0).abs() < 1e-6, "one undo step per alignment");
}

View File

@ -225,6 +225,7 @@ fn nudge_time(s: &mut Session, frames: i64) -> Result<Value> {
fn nudge_track(s: &mut Session, up: bool) -> Result<Value> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let mut moves = Vec::new();
let mut blocked = 0usize;
for c in &s.state.selection {
let Some((tid, it)) = seq.find_item(*c) else { continue };
let Some(tr) = seq.track(tid) else { continue };
@ -234,11 +235,17 @@ fn nudge_track(s: &mut Session, up: bool) -> Result<Value> {
(TrackKind::Video, true) | (TrackKind::Audio, false) => 1,
_ => -1,
};
let dest = usize::try_from(idx + step).ok().and_then(|i| list.get(i)).ok_or_else(|| EngineError::Other("no track to move to".into()))?;
// a clip already on the outermost track stays put (a linked video+audio selection must not
// fail because its audio has nowhere to go)
let Some(dest) = usize::try_from(idx + step).ok().and_then(|i| list.get(i)) else {
blocked += 1;
continue;
};
moves.push((*c, dest.id, it.start));
}
if moves.is_empty() {
return Err(EngineError::Other("no clips selected".into()));
let why = if blocked > 0 { "no track to move to" } else { "no clips selected" };
return Err(EngineError::Other(why.into()));
}
s.edit_sequence("Nudge", |q, ctx, _| Ok(edit::move_items(q, &moves, false, ctx)?))?;
Ok(json!({"moved": moves.len()}))

View File

@ -190,6 +190,14 @@ fn nudge_slip_and_slide_the_selection_with_undo() {
s.execute("timeline.nudgeDown", json!({})).unwrap();
assert_eq!(track_of(&s, last.0), v1_id);
assert!(s.execute("timeline.nudgeDown", json!({})).is_err(), "there is no track below V1");
// a linked video+audio selection: the video goes up, the audio (already on the outermost
// track in that direction) stays, instead of the whole nudge failing (#1368)
s.execute("timeline.select", json!({"clips": [last.0]})).unwrap();
assert!(s.state.selection.len() > 1, "the linked audio is selected too");
s.execute("timeline.nudgeUp", json!({})).unwrap();
assert_eq!(track_of(&s, last.0), s.active_sequence().unwrap().video_tracks[1].id);
s.execute("timeline.nudgeDown", json!({})).unwrap();
assert_eq!(track_of(&s, last.0), v1_id);
// slip: the source In moves, the clip stays
let mid = v1[1];
s.execute("timeline.select", json!({"clips": [mid.0]})).unwrap();

View File

@ -378,6 +378,56 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"job": job, "preset": pr.id, "outputs": outputs, "skipped": skipped}))
}
/// Copy to a new file, numbering collisions without replacing another take. Open the input
/// first and create the output exclusively, so a failed read or a competing copy cannot replace
/// an existing destination. Relink only after the copy's identity has been checked.
fn copy_for_ingest(s: &mut Session, item: ItemId, path: &str, dir: &Path) -> Result<String> {
let source = Path::new(path);
let name = source.file_name().ok_or_else(|| bad("ingest", "source has no file name"))?;
let mut input = std::fs::File::open(source).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
let mut number = 1u64;
let (out, mut output) = loop {
let mut next = if number == 1 { name.to_os_string() } else { source.file_stem().unwrap_or(name).to_os_string() };
if number != 1 {
next.push(format!(" {number}"));
if let Some(extension) = source.extension() {
next.push(".");
next.push(extension);
}
}
let out = dir.join(next);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&out) {
Ok(output) => break (out, output),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
number = number.checked_add(1).ok_or_else(|| bad("ingest", "no unused destination name"))?;
}
Err(e) => return Err(EngineError::Other(format!("{}: {e}", out.display()))),
}
};
let copied = std::io::copy(&mut input, &mut output).and_then(|_| output.set_permissions(input.metadata()?.permissions()));
drop(output);
let out_s = out.to_string_lossy().into_owned();
let linked = copied.map_err(|e| EngineError::Other(format!("copying {path}: {e}"))).and_then(|_| {
let a = crate::relink::identity_of(&*s.services, path).map_err(|e| EngineError::Other(e.to_string()))?;
let b = crate::relink::identity_of(&*s.services, &out_s).map_err(|e| EngineError::Other(e.to_string()))?;
if a != b {
return Err(EngineError::Other(format!("the copy of {} doesn't match the original", name.to_string_lossy())));
}
// The generated suffix may change the name; every other relink check still applies.
crate::relink::relink(s, &json!({"item": item.0, "path": out_s, "relinkOthers": false, "match": {"fileName": false}}))?;
Ok(())
});
if let Err(error) = linked {
// Only this operation's exclusively created file can be removed on failure.
if let Err(cleanup) = std::fs::remove_file(&out) {
return Err(EngineError::Other(format!("{error}; removing incomplete copy {}: {cleanup}", out.display())));
}
return Err(error);
}
Ok(out_s)
}
/// Ingest freshly imported items per the project's ingest settings.
pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
let ing = s.project.settings.ingest.clone();
@ -393,21 +443,10 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
Some(d) if !d.is_empty() => PathBuf::from(d),
_ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"),
};
let fname = Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let out = dir.join(&fname);
if Path::new(&path) == out {
if Path::new(&path).file_name().is_some_and(|name| Path::new(&path) == dir.join(name)) {
continue;
}
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
std::fs::copy(&path, &out).map_err(|e| EngineError::Other(format!("copying {fname}: {e}")))?;
let out_s = out.to_string_lossy().into_owned();
// verify the copy before switching to it
let a = crate::relink::identity_of(&*s.services, &path).map_err(|e| EngineError::Other(e.to_string()))?;
let b = crate::relink::identity_of(&*s.services, &out_s).map_err(|e| EngineError::Other(e.to_string()))?;
if a != b {
return Err(EngineError::Other(format!("the copy of {fname} doesn't match the original")));
}
crate::relink::relink(s, &json!({"item": i.0, "path": out_s, "relinkOthers": false}))?;
let out_s = copy_for_ingest(s, i, &path, &dir)?;
copied.push(json!({"item": i.0, "path": out_s}));
}
}
@ -423,6 +462,7 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
let pr =
preset(if ing.preset.is_empty() { DEFAULT_TRANSCODE_PRESET } else { ing.preset.as_str() }).ok_or_else(|| bad("ingest", "unknown preset"))?;
let mut work = Vec::new();
let mut taken = Vec::new();
for &i in items {
let Some((clip, path, name)) = media_path(&s.project, i) else { continue };
if clip.info.video.is_none() {
@ -433,7 +473,9 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
_ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"),
};
let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name);
work.push((i, unique_path(&dir, &stem, "", pr.extension, &[]).to_string_lossy().into_owned(), pr));
let out = unique_path(&dir, &stem, "", pr.extension, &taken).to_string_lossy().into_owned();
taken.push(out.clone());
work.push((i, out, pr));
}
if !work.is_empty() {
let id = start_job(s, format!("Ingest: transcode {} clip(s)", work.len()), work, OnDone::ReplaceMedia, false)?;

View File

@ -124,6 +124,155 @@ fn ingest_copies_and_creates_proxies_on_import() {
let _ = std::fs::remove_dir_all(&root);
}
/// Import two different camera takes with the same basename into a folder that already has
/// that name. Check the saved project with a new media pool so cached decoders cannot hide a
/// replaced file.
fn check_ingest_collision(action: &str) {
use filmcraft_media::FrameRequest;
use filmcraft_project::{ItemId, MediaRef};
use filmcraft_time::FrameRate;
use std::collections::BTreeSet;
use std::sync::atomic::Ordering;
let root = tmp_dir(&format!("ingest-collision-{action}"));
let paths = [root.join("Card A/A001.mov"), root.join("Card B/A001.mov")];
for (path, scene) in paths.iter().zip([DemoScene::Aurora, DemoScene::Plasma]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
make_movie(path, scene, 160, 96, 4);
}
let original: Vec<Vec<u8>> = paths.iter().map(|p| std::fs::read(p).unwrap()).collect();
assert_ne!(original[0], original[1], "different source takes");
let dest = root.join("Ingest");
std::fs::create_dir_all(&dest).unwrap();
let existing = dest.join("A001.mov");
let existing_bytes = b"existing destination must survive ingest";
std::fs::write(&existing, existing_bytes).unwrap();
let mut s = Session::default();
s.execute("project.ingestSettings", json!({"enabled": true, "action": action, "destination": dest.to_string_lossy(), "preset": "prores_proxy_half"}))
.unwrap();
let result = s.execute("file.import", json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>()})).unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
let items: Vec<ItemId> = result["items"].as_array().unwrap().iter().map(|i| ItemId(i.as_u64().unwrap())).collect();
assert_eq!(items.len(), 2, "both takes imported");
if let Some(id) = result["ingest"]["job"]["job"].as_u64() {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let job = s.jobs.iter().find(|j| j.id == id).unwrap();
if job.progress.finished.load(Ordering::Relaxed) && job.result.lock().unwrap().is_some() {
assert!(job.result.lock().unwrap().as_ref().unwrap().is_ok(), "ingest job failed");
break;
}
assert!(std::time::Instant::now() < deadline, "ingest job did not finish");
std::thread::sleep(std::time::Duration::from_millis(25));
}
s.poll_persistence();
}
assert_eq!(std::fs::read(&existing).unwrap(), existing_bytes, "ingest must preserve existing destination files");
let project_path = root.join("ingested.fcproj");
s.execute("file.save", json!({"path": project_path.to_string_lossy()})).unwrap();
let mut reopened = Session::default();
reopened.execute("file.open", json!({"path": project_path.to_string_lossy()})).unwrap();
let request = FrameRequest { time: FrameRate::FPS_24.tick_of(2), scale: 1.0 };
let mut linked = BTreeSet::new();
let mut proxies = BTreeSet::new();
for (index, item) in items.iter().enumerate() {
let media = reopened.project.item(*item).unwrap().as_media().unwrap();
let MediaRef::File { path } = &media.media else { panic!("ingest must retain file media") };
assert!(linked.insert(path.clone()), "different takes must have different ingest paths");
assert_eq!(std::path::Path::new(path).parent(), Some(dest.as_path()));
assert_eq!(std::fs::read(&paths[index]).unwrap(), original[index], "source card is unchanged");
if action != "transcode" {
assert_eq!(std::fs::read(path).unwrap(), original[index], "each copy retains its own take");
assert_eq!(media.identity, Some(crate::relink::identity_of_bytes(&original[index])));
}
let source = reopened.media.open_file(&paths[index].to_string_lossy(), &*reopened.services).unwrap();
let expected = source.video_frame(request).unwrap();
let ingested = reopened.source(*item).unwrap().video_frame(request).unwrap();
let quality = |frame: &filmcraft_frame::VideoFrame| {
let rgba = image::RgbaImage::from_raw(frame.width, frame.height, frame.to_rgba8()).unwrap();
let expected = image::RgbaImage::from_raw(expected.width, expected.height, expected.to_rgba8()).unwrap();
let expected = image::imageops::resize(&expected, frame.width, frame.height, image::imageops::FilterType::Triangle);
psnr(rgba.as_raw(), expected.as_raw())
};
assert!(quality(&ingested) > 30.0, "saved project retains the correct picture for take {index}");
if action == "copyAndCreateProxies" {
let Some(MediaRef::File { path }) = &media.proxy else { panic!("copy should have its proxy attached") };
assert!(proxies.insert(path.clone()), "different takes must have different proxy paths");
let proxy = reopened.media.open_file(path, &*reopened.services).unwrap().video_frame(request).unwrap();
assert!(quality(&proxy) > 30.0, "proxy retains the correct picture for take {index}");
}
}
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn ingest_copy_preserves_same_named_takes_and_existing_files() {
check_ingest_collision("copy");
}
#[test]
fn ingest_copy_and_proxy_preserves_same_named_takes_and_existing_files() {
check_ingest_collision("copyAndCreateProxies");
}
#[test]
fn ingest_transcode_preserves_same_named_takes_and_existing_files() {
check_ingest_collision("transcode");
}
#[test]
fn ingest_copy_keeps_sources_already_at_destination_and_extensionless_names() {
use filmcraft_project::{ItemId, MediaRef};
for (name, numbered) in [("Take.wav", "Take 2.wav"), ("Take", "Take 2")] {
let root = tmp_dir("ingest-copy-existing-source");
let dest = root.join("Ingest");
let card = root.join("Card");
std::fs::create_dir_all(&dest).unwrap();
std::fs::create_dir_all(&card).unwrap();
let paths = [dest.join(name), card.join(name)];
let takes = [filmcraft_media::wav::write_wav16(&[0.25; 960], 1, 48_000), filmcraft_media::wav::write_wav16(&[-0.25; 960], 1, 48_000)];
for (path, bytes) in paths.iter().zip(&takes) {
std::fs::write(path, bytes).unwrap();
}
let mut s = Session::default();
s.execute("project.ingestSettings", json!({"enabled": true, "action": "copy", "destination": dest.to_string_lossy()})).unwrap();
let result = s.execute("file.import", json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>()})).unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
assert_eq!(result["items"].as_array().unwrap().len(), 2);
assert_eq!(result["ingest"]["copied"].as_array().unwrap().len(), 1, "source already at destination is kept");
for (index, path) in [dest.join(name), dest.join(numbered)].iter().enumerate() {
let item = ItemId(result["items"][index].as_u64().unwrap());
let media = s.project.item(item).unwrap().as_media().unwrap();
assert_eq!(media.media, MediaRef::File { path: path.to_string_lossy().into_owned() });
assert_eq!(std::fs::read(path).unwrap(), takes[index]);
assert_eq!(std::fs::read(&paths[index]).unwrap(), takes[index]);
}
assert_eq!(std::fs::read_dir(&dest).unwrap().count(), 2, "no unnecessary copies or trailing-dot filenames");
let _ = std::fs::remove_dir_all(root);
}
}
#[test]
fn ingest_copy_does_not_create_a_destination_when_the_source_is_missing() {
use filmcraft_project::ItemId;
let root = tmp_dir("ingest-copy-missing-source");
let source = root.join("Take.wav");
std::fs::write(&source, filmcraft_media::wav::write_wav16(&[0.25; 960], 1, 48_000)).unwrap();
let mut s = Session::default();
let imported = s.execute("file.import", json!({"paths": [source.to_string_lossy()]})).unwrap();
let item = ItemId(imported["items"][0].as_u64().unwrap());
let dest = root.join("Ingest");
s.execute("project.ingestSettings", json!({"enabled": true, "action": "copy", "destination": dest.to_string_lossy()})).unwrap();
let before = s.project.clone();
std::fs::rename(&source, root.join("Moved.wav")).unwrap();
assert!(crate::proxies::ingest(&mut s, &[item]).is_err());
assert_eq!(s.project, before, "failed copy does not relink the item");
assert!(!dest.exists(), "no destination is created before the source can be opened");
let _ = std::fs::remove_dir_all(root);
}
/// Measure: Program-monitor playback cost of 4K H.264 with and without proxies (sequential frames
/// at ½ resolution). Run with `RAYON_NUM_THREADS=1` so wall time ≈ CPU time:
/// `cargo test --release -p filmcraft-engine proxy_playback_perf_4k -- --ignored --nocapture`.

View File

@ -270,3 +270,100 @@ fn jobs_list_reports_an_exports_offline_media() {
assert_eq!(j["offlineMedia"][0]["item"], json!(items[1].0), "{j}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn deleting_bin_keeps_subclip_source_but_removes_unused_media() {
let root = tmp_dir("delete-bin-used-media");
let (path, items) = saved_project(&root);
let mut s = open(&path);
let p = std::sync::Arc::make_mut(&mut s.project);
let seq = s.state.active_sequence.unwrap();
let range = filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, p.item(items[0]).unwrap().duration());
let sub =
p.add_item("Subclip", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
for track in p.sequence_mut(seq).unwrap().all_tracks_mut() {
track.items.retain(|clip| clip.item != items[1]);
for clip in &mut track.items {
clip.item = sub;
}
}
let bin = p.add_bin("Sources", None);
p.move_to_bin(&items, Some(bin)).unwrap();
let before = s.active_sequence().unwrap().clone();
s.execute("project.delete", json!({"items": [bin.0, items[0].0, bin.0]})).unwrap();
assert_eq!(s.active_sequence().unwrap(), &before);
assert!(s.project.root.find_bin(bin).is_none());
assert!(s.project.item(items[0]).unwrap().as_media().unwrap().offline);
assert!(s.project.item(items[1]).is_none());
assert!(s.project.resolve_media(sub).unwrap().1.offline);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.root.parent_of(items[0]), Some(bin));
assert!(s.project.item(items[1]).is_some());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn deleting_sequence_and_its_media_does_not_keep_an_unused_source() {
let root = tmp_dir("delete-sequence-and-media");
let (path, items) = saved_project(&root);
let mut s = open(&path);
let seq = s.state.active_sequence.unwrap();
s.execute("project.delete", json!({"items": [seq.0, items[0].0]})).unwrap();
assert!(s.project.item(seq).is_none());
assert!(s.project.item(items[0]).is_none());
assert!(s.project.item(items[1]).is_some());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn deleting_subclip_and_source_does_not_keep_an_unused_source() {
let root = tmp_dir("delete-subclip-and-source");
let (path, items) = saved_project(&root);
let mut s = open(&path);
let seq = s.state.active_sequence.unwrap();
let p = std::sync::Arc::make_mut(&mut s.project);
let range = filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, p.item(items[0]).unwrap().duration());
let sub =
p.add_item("Subclip", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
for t in p.sequence_mut(seq).unwrap().all_tracks_mut() {
for c in &mut t.items {
if c.item == items[0] {
c.item = sub;
}
}
}
s.execute("project.delete", json!({"items": [sub.0, items[0].0]})).unwrap();
assert!(s.project.item(sub).is_none());
assert!(s.project.item(items[0]).is_none(), "a removed timeline subclip must not retain its unused source");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn deleting_bin_preserves_intermediate_subclip_needed_by_surviving_clip() {
let root = tmp_dir("delete-intermediate-subclip");
let (path, items) = saved_project(&root);
let mut s = open(&path);
let seq = s.state.active_sequence.unwrap();
let p = std::sync::Arc::make_mut(&mut s.project);
let range = filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, p.item(items[0]).unwrap().duration());
let parent =
p.add_item("Parent", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
let outer = p.add_item("Outer", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent, range, restrict_trims: false }, None);
for t in p.sequence_mut(seq).unwrap().all_tracks_mut() {
for c in &mut t.items {
if c.item == items[0] {
c.item = outer;
}
}
}
let bin = p.add_bin("Sources", None);
p.move_to_bin(&[items[0], parent], Some(bin)).unwrap();
let before = s.active_sequence().unwrap().clone();
s.execute("project.delete", json!({"items": [bin.0]})).unwrap();
assert_eq!(s.active_sequence().unwrap(), &before);
assert!(s.project.resolve_media(outer).is_some(), "the surviving nested subclip must still resolve");
assert!(s.project.resolve_media(outer).unwrap().1.offline);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.root.parent_of(parent), Some(bin));
let _ = std::fs::remove_dir_all(root);
}

View File

@ -276,3 +276,40 @@ fn flash_cue_markers_round_trip() {
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_sequence().unwrap().markers.iter().all(|m| m.kind != MarkerKind::FlashCue));
}
/// Apply Video Transition (Cmd+D) at both ends of an adjustment layer with Lumetri Color fades the
/// grade in and out, as on a clip: halfway through each dissolve the picture is about halfway
/// between the original and the graded frame, not the original with a hard cut after it.
#[test]
fn cmd_d_on_an_adjustment_layer_fades_its_effects_in_and_out() {
let mut s = demo();
let item = s.execute("file.newAdjustmentLayer", json!({"seconds": 4})).unwrap()["item"].as_u64().unwrap();
let layer = s.execute("timeline.place", json!({"item": item, "track": "V3", "seconds": 1.5})).unwrap()["clips"][0].as_u64().unwrap();
s.execute("effects.apply", json!({"clips": [layer], "effect": "lumetri"})).unwrap();
s.execute("effects.setParam", json!({"clip": layer, "effect": "lumetri", "param": "exposure", "value": 2.0})).unwrap();
s.execute("timeline.select", json!({"clips": [layer]})).unwrap();
for edge in ["in", "out"] {
s.execute("sequence.applyVideoTransition", json!({"clip": layer, "edge": edge, "frames": 24})).unwrap();
}
let transitions: Vec<_> = s.active_sequence().unwrap().video_tracks[2].transitions.clone();
assert_eq!(transitions.len(), 2, "a dissolve at each end");
let mean = |s: &Session, t: Tick| {
let img = s.render_program_at(0.25, t).unwrap();
img.px.chunks(4).map(|c| f64::from(c[0] + c[1] + c[2])).sum::<f64>() / (img.w * img.h) as f64
};
for tr in &transitions {
let mid = tr.start + Tick(tr.duration.0 / 2);
let mf = mean(&s, mid);
// the original frame: the layer switched off; the graded frame: without the dissolves
s.execute("clip.enable", json!({"clips": [layer]})).unwrap();
let m0 = mean(&s, mid);
s.execute("edit.undo", json!({})).unwrap();
let ids: Vec<u64> = transitions.iter().map(|t| t.id.0).collect();
s.execute("sequence.removeTransition", json!({"transitions": ids})).unwrap();
let m1 = mean(&s, mid);
s.execute("edit.undo", json!({})).unwrap();
assert!(m1 > m0 * 1.2, "the grade brightens the frame: {m0} → {m1}");
let k = (mf - m0) / (m1 - m0);
assert!((0.25..0.75).contains(&k), "halfway through the dissolve the grade is about half on: {k:.3}");
}
}

View File

@ -774,65 +774,6 @@ pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// `timeline.move` names `"new"` as the `track` of a move.
pub(crate) fn move_wants_new_track(p: &Value) -> bool {
p.get("moves").and_then(Value::as_array).is_some_and(|m| m.iter().any(is_new_move))
}
/// One move of `timeline.move` with `"track":"new"`.
fn is_new_move(m: &Value) -> bool {
m.get("track").and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new"))
}
/// `timeline.move` with `"track":"new"` on some moves (#1104: a clip dragged into the empty space
/// above the top video track or below the last audio track): one track is added after the last one
/// of each kind the `"new"` clips come from, they all land on it, and the track and the move are
/// one undo step. When the move fails, the new tracks go away again.
pub(crate) fn move_to_new_tracks(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.move";
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (mut new_v, mut new_a) = (false, false);
for m in p.get("moves").and_then(Value::as_array).into_iter().flatten().filter(|m| is_new_move(m)) {
let clip = crate::commands::clip_p(m, "clip").ok_or_else(|| bad(cmd, "need `clip` in each move"))?;
let (track, _) = q.find_item(clip).ok_or_else(|| bad(cmd, "no such clip"))?;
if q.video_tracks.iter().any(|t| t.id == track) {
new_v = true;
} else {
new_a = true;
}
}
let added = add_tracks(s, &json!({"video": u64::from(new_v), "audio": u64::from(new_a)}))?;
let mark = s.history.undo.len();
let (video, audio) = (added.get("video").and_then(|v| v.get(0)).cloned(), added.get("audio").and_then(|v| v.get(0)).cloned());
let mut moves = p.get("moves").and_then(Value::as_array).cloned().unwrap_or_default();
for m in moves.iter_mut().filter(|m| is_new_move(m)) {
let on_video = crate::commands::clip_p(m, "clip")
.and_then(|c| s.active_sequence().and_then(|q| q.find_item(c).map(|(t, _)| q.video_tracks.iter().any(|v| v.id == t))))
.unwrap_or(true);
m["track"] = if on_video { video.clone() } else { audio.clone() }.unwrap_or(Value::Null);
}
let mut q = p.clone();
q["moves"] = Value::Array(moves);
let run = crate::commands::find(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?.run;
match run(s, &q) {
Ok(v) => {
// the new tracks and the move are one undo step
if mark >= 1
&& s.history.undo.len() == mark + 1
&& let (Some((label, _)), Some((_, before))) = (s.history.undo.pop(), s.history.undo.pop())
{
s.history.undo.push((label, before));
}
Ok(v)
}
Err(e) => {
s.undo();
s.history.redo.pop();
Err(e)
}
}
}
fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;

View File

@ -737,7 +737,8 @@ impl Shortcuts {
let nb = Binding { command: command.into(), keys: chord.canonical(), panel: panel.map(str::to_string) };
let before = self.snapshot();
if !add {
self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && b.context() == nb.context()));
let copies = if nb.panel.is_none() { self.app_chords(command, None) } else { Vec::new() };
self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && (b.context() == nb.context() || is_copy(b, &copies))));
}
let mut reassigned = Vec::new();
if !keep_conflicts {
@ -769,6 +770,7 @@ impl Shortcuts {
let before = self.snapshot();
let n0 = self.bindings.len();
let twins = shortcut_twins(command);
let copies = if panel == Some(None) { self.app_chords(command, chord.as_ref()) } else { Vec::new() };
self.bindings.retain(|b| {
let cmd = same_shortcut_command(&b.command, command);
let key_ok = chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c));
@ -778,7 +780,7 @@ impl Shortcuts {
let panel_ok = match &panel {
None => true,
Some(p) if p.is_none() && twins => true,
Some(p) => &b.panel == p,
Some(p) => &b.panel == p || is_copy(b, &copies),
};
!(cmd && key_ok && panel_ok)
});
@ -916,6 +918,16 @@ impl Shortcuts {
Ok(name)
}
/// The application-wide keys of `command` (only `key`, when given).
fn app_chords(&self, command: &str, key: Option<&Chord>) -> Vec<Chord> {
self.bindings
.iter()
.filter(|b| same_shortcut_command(&b.command, command) && b.panel.is_none())
.filter_map(Binding::chord)
.filter(|c| key.is_none_or(|k| k == c))
.collect()
}
// ---------------------------------------------------------------- queries
pub fn for_command(&self, id: &str) -> Vec<&Binding> {
@ -1017,6 +1029,12 @@ fn same_shortcut_command(bound: &str, target: &str) -> bool {
bound == target || (shortcut_twins(bound) && shortcut_twins(target))
}
/// A panel binding on one of the command's application keys (New Bin's Cmd+B in the Project
/// panel) is a copy of that shortcut, so editing the application shortcut edits it too (#1333).
fn is_copy(b: &Binding, app_chords: &[Chord]) -> bool {
b.panel.is_some() && b.chord().is_some_and(|c| app_chords.contains(&c))
}
fn check_panel(panel: Option<&str>) -> std::result::Result<Option<&'static str>, String> {
match panel {
None => Ok(None),

View File

@ -133,6 +133,42 @@ fn clear_close_sequence_also_clears_file_close() {
assert_eq!(s.shortcuts.resolve(&w, None, Platform::current()).unwrap().command, "sequence.addEdit");
}
#[test]
fn clear_new_bin_frees_cmd_b_in_the_project_panel_too() {
// #1333: New Bin has Cmd+B application-wide and again in the Project panel. The editor's Clear
// sends panel Application, so only the first went; the Project panel kept Cmd+B.
let dir = temp_dir("new-bin");
let mut s = Session::default();
s.shortcuts.set_dir(&dir);
let b = Chord::parse("Cmd+B").unwrap();
let p = Platform::current();
assert_eq!(s.shortcuts.resolve(&b, Some("Project"), p).unwrap().command, "file.newBin");
s.execute("shortcuts.clear", json!({"command": "file.newBin", "panel": "Application"})).unwrap();
assert!(s.shortcuts.for_command("file.newBin").is_empty(), "{:?}", s.shortcuts.for_command("file.newBin"));
s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+B"})).unwrap();
let mut t = Shortcuts::new();
t.set_dir(&dir);
for sc in [&s.shortcuts, &t] {
assert_eq!(sc.resolve(&b, None, p).unwrap().command, "sequence.addEdit");
assert_eq!(sc.resolve(&b, Some("Project"), p).unwrap().command, "sequence.addEdit");
assert!(sc.for_command("file.newBin").is_empty());
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reassigning_new_bin_moves_its_project_panel_key_too() {
// #1333: assigning New Bin another key replaced Cmd+B application-wide only.
let mut s = Session::default();
s.execute("shortcuts.set", json!({"command": "file.newBin", "keys": "Cmd+Alt+B"})).unwrap();
let keys: Vec<(&str, Option<&str>)> = s.shortcuts.for_command("file.newBin").iter().map(|b| (b.keys.as_str(), b.panel.as_deref())).collect();
assert_eq!(keys, [("Cmd+Alt+B", None)]);
assert!(s.shortcuts.resolve(&Chord::parse("Cmd+B").unwrap(), Some("Project"), Platform::Mac).is_none());
// a panel key of its own (History ▸ Left for Undo) is not a copy of the application one: it stays
s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Alt+Z"})).unwrap();
assert!(s.shortcuts.for_command("edit.undo").iter().any(|b| b.panel.as_deref() == Some("History") && b.keys == "Left"));
}
#[test]
fn panel_shortcuts_override_application_ones_with_focus() {
let s = Session::default();

View File

@ -299,3 +299,116 @@ fn copy_leaves_the_originals_and_links_the_copies() {
assert!(r.is_err());
assert_eq!(s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::<usize>(), 2);
}
#[test]
fn new_track_moves_and_copies_are_one_atomic_edit() {
for copy in [false, true] {
let mut s = demo();
let (vt, v, at, a) = last_pair(&s);
let before = s.project.clone();
let count = s.history.undo.len();
let q = s.active_sequence().unwrap();
let counts = (q.video_tracks.len(), q.audio_tracks.len());
let r = s
.execute(
"timeline.move",
json!({"moves":[{"clip":v.id.0,"track":"new","time":v.start.0},{"clip":a.id.0,"track":"new","time":a.start.0}],"linked":false,"copy":copy}),
)
.unwrap();
let q = s.active_sequence().unwrap();
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (counts.0 + 1, counts.1 + 1));
let new_v = q.video_tracks.last().unwrap();
let new_a = q.audio_tracks.last().unwrap();
assert_eq!(new_a.channels, before.sequence(s.state.active_sequence.unwrap()).unwrap().track(at).unwrap().channels);
assert_eq!((new_v.items[0].item, new_a.items[0].item), (v.item, a.item));
assert_eq!((new_v.items[0].start, new_a.items[0].start), (v.start, a.start));
if copy {
assert_eq!(state(&s, v.id), (vt, v.start));
assert_eq!(state(&s, a.id), (at, a.start));
assert_ne!(new_v.items[0].id, v.id);
assert!(new_v.items[0].link.is_some() && new_v.items[0].link == new_a.items[0].link);
assert_ne!(new_v.items[0].link, v.link);
assert_eq!(s.state.selection.len(), 2);
assert_eq!(r["copied"].as_array().unwrap().len(), 2);
} else {
assert_eq!(state(&s, v.id), (new_v.id, v.start));
assert_eq!(state(&s, a.id), (new_a.id, a.start));
}
q.check().unwrap();
assert_eq!(s.history.undo.len(), count + 1);
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
}
}
#[test]
fn new_track_group_keeps_clips_from_the_same_source_together() {
let mut s = demo();
let q = s.active_sequence().unwrap();
let clips = q.video_tracks[0].items[..2].to_vec();
let tracks = q.video_tracks.len();
let before = s.project.clone();
let moves: Vec<_> = clips.iter().map(|c| json!({"clip":c.id.0,"track":"new","time":c.start.0})).collect();
s.execute("timeline.move", json!({"moves":moves,"linked":false})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!(q.video_tracks.len(), tracks + 1);
let t = q.video_tracks.last().unwrap();
assert_eq!(t.items, clips);
q.check().unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
}
#[test]
fn refused_new_track_moves_preserve_document_and_both_history_stacks() {
let mut s = demo();
let (_, v, _, _) = last_pair(&s);
s.execute("timeline.setTrack", json!({"track":"V1","locked":true})).unwrap();
for moves in [
json!([{"clip":v.id.0,"track":"new","time":v.start.0}]),
json!([{"clip":u64::MAX,"track":"new","time":0}]),
json!([{"clip":v.id.0,"track":"new","time":i64::MAX}]),
json!([{"clip":v.id.0,"track":"new","time":0},{"clip":u64::MAX,"track":"V2","time":0}]),
] {
let before = s.project.clone();
let history = s.execute("history.list", json!({})).unwrap();
assert!(s.execute("timeline.move", json!({"moves":moves,"linked":false})).is_err());
assert_eq!(*s.project, *before);
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
}
s.execute("edit.undo", json!({})).unwrap();
let before = s.project.clone();
let history = s.execute("history.list", json!({})).unwrap();
assert!(!history["redo"].as_array().unwrap().is_empty());
for moves in [
json!([{"clip":v.id.0,"track":"new","time":0},{"clip":u64::MAX,"track":"V2","time":0}]),
json!([{"clip":v.id.0,"track":"new","time":i64::MAX}]),
json!([{"clip":v.id.0,"track":"new"}]),
json!([{"clip":v.id.0,"track":"new","time":"invalid"}]),
] {
assert!(s.execute("timeline.move", json!({"moves":moves,"linked":false})).is_err());
assert_eq!(*s.project, *before);
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
}
}
#[test]
fn new_track_moves_respect_the_track_limit() {
let mut s = demo();
let (_, v, _, _) = last_pair(&s);
s.edit_sequence("Limit fixture", |q, ctx, _| {
while q.video_tracks.len() < crate::sequence_tools::MAX_TRACKS {
q.video_tracks.push(filmcraft_project::Track::new(TrackId(ctx.alloc()), filmcraft_project::TrackKind::Video, "Limit".into()));
}
Ok(())
})
.unwrap();
let before = s.project.clone();
let history = s.execute("history.list", json!({})).unwrap();
assert!(s.execute("timeline.move", json!({"moves":[{"clip":v.id.0,"track":"new","time":v.start.0}],"linked":false})).is_err());
assert_eq!(*s.project, *before);
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
}

View File

@ -363,18 +363,9 @@ impl Exporter {
}
}
None => {
let bps = if self.settings.audio.bits >= 24 { 3 } else { 2 };
let mut pcm = Vec::with_capacity(n * buf.len() * bps);
for i in 0..n {
for c in &buf {
let s = c[i].clamp(-1.0, 1.0);
if bps == 3 {
pcm.extend_from_slice(&((s * 8_388_607.0).round() as i32).to_le_bytes()[..3]);
} else {
pcm.extend_from_slice(&((s * 32767.0).round() as i16).to_le_bytes());
}
}
}
// little-endian, like the track's `lpcm` / `sowt` description says
let mut pcm = Vec::new();
crate::pcm::encode(crate::pcm::PcmContainer::Wav, &crate::audio_out::interleave(&buf), self.settings.audio.sample_format(), &mut pcm);
mux.write_sample(at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true })
.map_err(|e| ExportError::Io(e.to_string()))?;
}
@ -428,8 +419,15 @@ impl Exporter {
Some(mux.add_track(c).map_err(|e| ExportError::Io(e.to_string()))?)
}
(Some(a), None) => {
let bits = if self.settings.audio.bits >= 24 { 24 } else { 16 };
let pcm = PcmConfig { bits, float: false, big_endian: false, signed: true, channels: a.channels as _, sample_rate: a.sr as f64 };
let f = self.settings.audio.sample_format();
let pcm = PcmConfig {
bits: f.bits() as _,
float: f.is_float(),
big_endian: false,
signed: true,
channels: a.channels as _,
sample_rate: a.sr as f64,
};
Some(mux.add_track(TrackConfig::new(SampleEntry::pcm(pcm), a.sr)).map_err(|e| ExportError::Io(e.to_string()))?)
}
};

View File

@ -625,6 +625,14 @@ impl ExportSettings {
if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) {
return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into()));
}
if self.audio.float && self.audio.bits != 32 {
return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into()));
}
let sample_format = self.audio.sample_format();
if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) {
let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() };
return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label())));
}
if self.field_order != FieldOrder::Progressive && self.has_video() {
return Err(ExportError::Unsupported(format!("{} field order: FilmCraft's encoders write progressive frames", self.field_order.label())));
}
@ -1108,19 +1116,19 @@ impl AudioEncoder for AacEncoder {
/// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit.
pub(crate) struct FlacAudio {
enc: filmcraft_flac::Encoder,
bits: u16,
format: SampleFormat,
/// Samples of the frame `flush` returns (shorter than a full block).
tail: u32,
}
impl FlacAudio {
pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result<Self> {
let bits = if s.audio.bits >= 24 { 24 } else { 16 };
let format = s.audio.sample_format();
let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?;
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, bits as u8);
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, format.bits() as u8);
cfg.level = s.audio.flac_level.min(8);
let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?;
Ok(FlacAudio { enc, bits, tail: 0 })
Ok(FlacAudio { enc, format, tail: 0 })
}
}
@ -1129,7 +1137,7 @@ impl AudioEncoder for FlacAudio {
SampleEntry::flac(&self.enc.streaminfo())
}
fn encode(&mut self, planar: &[Vec<f32>]) -> Result<Vec<Vec<u8>>> {
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect();
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| self.format.quantise(s)).collect()).collect();
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))
}
@ -1851,12 +1859,12 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?,
Format::Wav | Format::Aiff | Format::Flac => {
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
let (ch, sr, fmt) = (a.channels as u16, a.sr, settings.audio.sample_format());
let frames = a.remaining();
// the sizes are known up front: refuse an AIFF that cannot hold them before measuring
let mut writer = match settings.format {
Format::Flac => {
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 });
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, fmt.bits() as u8);
cfg.level = settings.audio.flac_level.min(8);
AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?))
}
@ -1864,7 +1872,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
_ => AudioWriter::Pcm(pcm::PcmContainer::Aiff),
};
let head = match &writer {
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?,
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, fmt, frames).map_err(ExportError::Unsupported)?,
AudioWriter::Flac(enc) => enc.header(),
};
// one second per chunk: memory stays flat however long the range is
@ -1885,9 +1893,9 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
buf.clear();
match &mut writer {
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf),
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), fmt, &mut buf),
AudioWriter::Flac(enc) => {
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect();
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| fmt.quantise(s)).collect()).collect();
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?;
}
@ -1898,7 +1906,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
}
match &mut writer {
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?,
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, fmt)).map_err(io)?,
AudioWriter::Flac(enc) => {
out.write_all(&enc.finish()).map_err(io)?;
// the frame sizes and MD5 are known now: fill them into STREAMINFO

View File

@ -108,7 +108,8 @@ impl MxfMux {
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let seed = format!("{}|{now}", settings.path);
let ids = PackageIds::from_seed(&seed, &s.name);
let bits = if settings.audio.bits >= 24 { 24 } else { 16 };
// integers only: `ExportSettings::validate` refuses float PCM in MXF
let bits = u32::from(settings.audio.sample_format().bits());
let mut cfg = WriterConfig::new(if atom { Pattern::OpAtom } else { Pattern::Op1a }, rate, ids.clone());
cfg.picture = Some(picture(settings, s.width, s.height));
cfg.modified = Timestamp::from_unix((now / 1_000_000_000) as i64);

View File

@ -1,10 +1,13 @@
//! Audio-only files: WAV (RIFF, little-endian PCM; RF64 past 4 GiB) and AIFF (big-endian PCM),
//! 16 or 24 bit, plus image-sequence file naming.
//! 16, 24 or 32-bit integer or 32-bit float ([`SampleFormat`]), plus image-sequence file naming.
//! Float WAV is `WAVE_FORMAT_IEEE_FLOAT` with a `fact` chunk; float AIFF is AIFF-C (`fl32`).
//!
//! The header is written first from the known length ([`header`]), then the samples in chunks
//! ([`encode`]) and the pad byte ([`pad`]), so an export streams to disk instead of building the
//! file in memory.
use crate::SampleFormat;
/// The container of an audio-only export.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PcmContainer {
@ -12,47 +15,55 @@ pub enum PcmContainer {
Aiff,
}
/// Quantise one sample to a signed integer of `bits` (16 or 24).
pub(crate) fn quantise(s: f32, bits: u16) -> i32 {
let max = if bits >= 24 { 8_388_607.0 } else { 32_767.0 };
(s.clamp(-1.0, 1.0) * max).round() as i32
}
/// The sample size written for a requested `bits`: 24, else 16.
fn sample_bits(bits: u16) -> u16 {
if bits >= 24 { 24 } else { 16 }
}
/// Bytes of sample data for `frames` frames, or None when that does not fit 64 bits.
fn data_len(frames: u64, channels: u16, bits: u16) -> Option<u64> {
frames.checked_mul(u64::from(channels))?.checked_mul(u64::from(sample_bits(bits) / 8))
fn data_len(frames: u64, channels: u16, format: SampleFormat) -> Option<u64> {
frames.checked_mul(u64::from(channels))?.checked_mul(format.bytes() as u64)
}
/// The pad byte after an odd-sized data chunk (RIFF and AIFF chunks are word-aligned).
pub fn pad(frames: u64, channels: u16, bits: u16) -> &'static [u8] {
if data_len(frames, channels, bits).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
pub fn pad(frames: u64, channels: u16, format: SampleFormat) -> &'static [u8] {
if data_len(frames, channels, format).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
}
/// `KSDATAFORMAT_SUBTYPE_PCM` / `_IEEE_FLOAT` after their first two bytes (the format tag).
const SUBTYPE_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
/// AIFF-C format version 1 (`FVER`).
const AIFC_VERSION_1: u32 = 0xA280_5140;
/// The AIFF-C compression name of `fl32`, as a Pascal string of even length.
const FL32_NAME: &[u8] = b"\x1532-bit floating point";
/// Everything before the samples of a file of `frames` frames. A WAV whose RIFF size would not
/// fit 32 bits (4 GiB: 24-bit 5.1 at 96 kHz after about 41 minutes) is written as RF64 (EBU Tech
/// 3306), with the 64-bit sizes in a `ds64` chunk. AIFF has no 64-bit form, so a longer AIFF is
/// an error naming WAV instead.
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u16, frames: u64) -> Result<Vec<u8>, String> {
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, format: SampleFormat, frames: u64) -> Result<Vec<u8>, String> {
let too_long = || "the audio is too long to write".to_string();
let bits = sample_bits(bits);
let bits = format.bits();
let bps = bits / 8;
let data = data_len(frames, channels, bits).ok_or_else(too_long)?;
let float = format.is_float();
let data = data_len(frames, channels, format).ok_or_else(too_long)?;
let block_align = channels.checked_mul(bps).ok_or("too many audio channels")?;
match container {
PcmContainer::Wav => {
let byte_rate = u32::try_from(u64::from(sample_rate) * u64::from(block_align)).map_err(|_| "the audio sample rate is too high".to_string())?;
let ext = channels > 2;
let fmt_len: u32 = if ext { 40 } else { 16 };
// plain PCM has no cbSize; IEEE float has an empty one
let fmt_len: u32 = if ext {
40
} else if float {
18
} else {
16
};
// every format but integer PCM has a `fact` chunk (the frame count; RF64: in `ds64`)
let fact_len: u64 = if float { 12 } else { 0 };
let padded = data.checked_add(data & 1).ok_or_else(too_long)?;
// "WAVE" + fmt chunk + data chunk header + samples
let riff = (4 + 8 + u64::from(fmt_len) + 8).checked_add(padded).ok_or_else(too_long)?;
// "WAVE" + fmt chunk + fact chunk + data chunk header + samples
let riff = (4 + 8 + u64::from(fmt_len) + fact_len + 8).checked_add(padded).ok_or_else(too_long)?;
let rf64 = riff > u64::from(u32::MAX);
let mut v = Vec::with_capacity(80);
let mut v = Vec::with_capacity(100);
if rf64 {
const DS64: u64 = 8 + 28;
v.extend_from_slice(b"RF64");
@ -68,21 +79,30 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
v.extend_from_slice(&(riff as u32).to_le_bytes());
v.extend_from_slice(b"WAVE");
}
let tag: u16 = if float { 3 } else { 1 };
v.extend_from_slice(b"fmt ");
v.extend_from_slice(&fmt_len.to_le_bytes());
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes());
v.extend_from_slice(&(if ext { 0xFFFE } else { tag }).to_le_bytes());
v.extend_from_slice(&channels.to_le_bytes());
v.extend_from_slice(&sample_rate.to_le_bytes());
v.extend_from_slice(&byte_rate.to_le_bytes());
v.extend_from_slice(&block_align.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
if ext {
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM / _IEEE_FLOAT
v.extend_from_slice(&22u16.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
let mask: u32 = if channels == 6 { 0x3F } else { 0 };
v.extend_from_slice(&mask.to_le_bytes());
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
v.extend_from_slice(&tag.to_le_bytes());
v.extend_from_slice(&SUBTYPE_TAIL);
} else if float {
v.extend_from_slice(&0u16.to_le_bytes());
}
if float {
v.extend_from_slice(b"fact");
v.extend_from_slice(&4u32.to_le_bytes());
v.extend_from_slice(&(if rf64 { u32::MAX } else { u32::try_from(frames).unwrap_or(u32::MAX) }).to_le_bytes());
}
v.extend_from_slice(b"data");
v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes());
@ -93,16 +113,31 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
let frames32 = u32::try_from(frames).map_err(|_| aiff_too_long())?;
// SSND: offset + block size + samples
let ssnd = u32::try_from(data.checked_add(8).ok_or_else(aiff_too_long)?).map_err(|_| aiff_too_long())?;
let form = (4u32 + (8 + 18)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
let mut v = Vec::with_capacity(54);
// floats need AIFF-C: a version chunk, and the compression type and name in COMM
let comm: u32 = if float { 18 + 4 + FL32_NAME.len() as u32 } else { 18 };
let fver: u32 = if float { 8 + 4 } else { 0 };
let form =
(4u32 + fver + (8 + comm)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
let mut v = Vec::with_capacity(90);
v.extend_from_slice(b"FORM");
v.extend_from_slice(&form.to_be_bytes());
v.extend_from_slice(b"AIFFCOMM");
v.extend_from_slice(&18u32.to_be_bytes());
if float {
v.extend_from_slice(b"AIFCFVER");
v.extend_from_slice(&4u32.to_be_bytes());
v.extend_from_slice(&AIFC_VERSION_1.to_be_bytes());
} else {
v.extend_from_slice(b"AIFF");
}
v.extend_from_slice(b"COMM");
v.extend_from_slice(&comm.to_be_bytes());
v.extend_from_slice(&channels.to_be_bytes());
v.extend_from_slice(&frames32.to_be_bytes());
v.extend_from_slice(&bits.to_be_bytes());
v.extend_from_slice(&extended(sample_rate));
if float {
v.extend_from_slice(b"fl32");
v.extend_from_slice(FL32_NAME);
}
v.extend_from_slice(b"SSND");
v.extend_from_slice(&ssnd.to_be_bytes());
v.extend_from_slice(&0u32.to_be_bytes()); // offset
@ -112,13 +147,12 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
}
}
/// Append interleaved samples as `bits`-bit PCM (little-endian for WAV, big-endian for AIFF).
pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut Vec<u8>) {
let bits = sample_bits(bits);
let bps = usize::from(bits / 8);
/// Append interleaved samples in `format` (little-endian for WAV, big-endian for AIFF).
pub fn encode(container: PcmContainer, interleaved: &[f32], format: SampleFormat, out: &mut Vec<u8>) {
let bps = format.bytes();
out.reserve(interleaved.len().saturating_mul(bps));
for &s in interleaved {
let q = quantise(s, bits);
let q = if format.is_float() { SampleFormat::float(s).to_bits() as i32 } else { format.quantise(s) };
match container {
PcmContainer::Wav => out.extend_from_slice(q.to_le_bytes().get(..bps).unwrap_or_default()),
PcmContainer::Aiff => out.extend_from_slice(q.to_be_bytes().get(4 - bps..).unwrap_or_default()),
@ -127,19 +161,19 @@ pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut
}
/// A whole file in memory (short audio; exports stream with [`header`] and [`encode`]).
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
let frames = (interleaved.len() / usize::from(channels.max(1))) as u64;
let mut v = header(container, channels, sample_rate, bits, frames)?;
encode(container, interleaved, bits, &mut v);
v.extend_from_slice(pad(frames, channels, bits));
let mut v = header(container, channels, sample_rate, format, frames)?;
encode(container, interleaved, format, &mut v);
v.extend_from_slice(pad(frames, channels, format));
Ok(v)
}
/// A RIFF/WAVE file of interleaved samples. More than two channels are written as
/// `WAVE_FORMAT_EXTENSIBLE` with the speaker mask of the layout (6 channels: L, R, C, LFE, Ls, Rs
/// = `0x3F`).
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits)
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, format)
}
/// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`).
@ -156,9 +190,9 @@ fn extended(rate: u32) -> [u8; 10] {
out
}
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`) of interleaved samples.
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, bits)
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`; AIFF-C for floats) of interleaved samples.
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, format)
}
/// File name of frame `index` of an image sequence of `count` frames exported to `path`:
@ -181,6 +215,11 @@ pub fn image_sequence_path(path: &str, index: u64, count: u64) -> String {
mod tests {
use super::*;
const I16: SampleFormat = SampleFormat::Int16;
const I24: SampleFormat = SampleFormat::Int24;
const I32: SampleFormat = SampleFormat::Int32;
const F32: SampleFormat = SampleFormat::Float32;
#[test]
fn extended_sample_rates() {
assert_eq!(extended(44_100), [0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]);
@ -198,10 +237,10 @@ mod tests {
#[test]
fn wav_and_aiff_layout() {
let s = [0.5f32, -0.5, 1.0, -1.0];
let w = write_wav(&s, 2, 48_000, 24).unwrap();
let w = write_wav(&s, 2, 48_000, I24).unwrap();
assert_eq!(w.len(), 44 + 12);
assert_eq!(&w[34..36], &24u16.to_le_bytes());
let a = write_aiff(&s, 2, 48_000, 16).unwrap();
let a = write_aiff(&s, 2, 48_000, I16).unwrap();
assert_eq!(&a[..4], b"FORM");
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
assert_eq!(&a[a.len() - 2..], &(-32767i16).to_be_bytes());
@ -220,7 +259,7 @@ mod tests {
#[test]
fn wav_past_4_gib_is_rf64() {
let frames = 96_000u64 * 60 * 60; // an hour: 6.2 GB
let h = header(PcmContainer::Wav, 6, 96_000, 24, frames).unwrap();
let h = header(PcmContainer::Wav, 6, 96_000, I24, frames).unwrap();
let data = frames * 6 * 3;
assert_eq!(&h[..4], b"RF64");
assert_eq!(le32(&h, 4), u32::MAX);
@ -238,37 +277,129 @@ mod tests {
fn wav_up_to_4_gib_stays_riff() {
// the longest stereo 16-bit RIFF: 36 bytes of header after the size field
let max = (u64::from(u32::MAX) - 36) / 4;
let h = header(PcmContainer::Wav, 2, 48_000, 16, max).unwrap();
let h = header(PcmContainer::Wav, 2, 48_000, I16, max).unwrap();
assert_eq!(&h[..4], b"RIFF");
assert_eq!(u64::from(le32(&h, 4)), 36 + max * 4);
assert_eq!(u64::from(le32(&h, 40)), max * 4);
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, 16, max + 1).unwrap()[..4], b"RF64");
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, I16, max + 1).unwrap()[..4], b"RF64");
}
#[test]
fn aiff_past_4_gib_is_refused_not_wrapped() {
let e = header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60 * 8).unwrap_err();
let e = header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60 * 8).unwrap_err();
assert!(e.contains("export WAV"), "{e}");
assert!(header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60).is_ok());
assert!(header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60).is_ok());
}
#[test]
fn hostile_sizes_are_errors() {
for c in [PcmContainer::Wav, PcmContainer::Aiff] {
assert!(header(c, 2, 48_000, 24, u64::MAX).is_err());
assert!(header(c, u16::MAX, 48_000, 24, 1).is_err());
assert!(header(c, 2, 48_000, I24, u64::MAX).is_err());
assert!(header(c, u16::MAX, 48_000, I24, 1).is_err());
}
assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 1).is_err());
assert!(header(PcmContainer::Wav, 6, u32::MAX, I24, 1).is_err());
}
#[test]
fn odd_data_chunks_are_padded() {
// one 24-bit mono frame: 3 bytes of data, one pad byte, counted in the RIFF size
let w = write_wav(&[0.25], 1, 48_000, 24).unwrap();
let w = write_wav(&[0.25], 1, 48_000, I24).unwrap();
assert_eq!((w.len(), le32(&w, 4) as usize, le32(&w, 40)), (48, 40, 3));
let a = write_aiff(&[0.25], 1, 48_000, 24).unwrap();
let a = write_aiff(&[0.25], 1, 48_000, I24).unwrap();
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
assert!(pad(2, 1, 24).is_empty());
assert!(pad(2, 1, I24).is_empty());
}
fn be32(b: &[u8], at: usize) -> u32 {
u32::from_be_bytes(b[at..at + 4].try_into().unwrap())
}
#[test]
fn integer_samples_clip_at_full_scale() {
assert_eq!([1.0, -1.0, 2.0, -2.0, f32::NAN, 0.5].map(|s| I32.quantise(s)), [i32::MAX, -i32::MAX, i32::MAX, -i32::MAX, 0, 1 << 30]);
assert_eq!([1.0, -1.0, 4.0].map(|s| I24.quantise(s)), [8_388_607, -8_388_607, 8_388_607]);
assert_eq!([1.0, -1.0, f32::INFINITY].map(|s| I16.quantise(s)), [32_767, -32_767, 32_767]);
assert_eq!(F32.quantise(0.5), 0);
assert_eq!([2.5, f32::NAN, f32::INFINITY, f32::NEG_INFINITY].map(SampleFormat::float), [2.5, 0.0, f32::MAX, f32::MIN]);
}
#[test]
fn wav_and_aiff_32_bit_integer() {
let w = write_wav(&[1.0, -1.0], 1, 48_000, I32).unwrap();
assert_eq!(w.len(), 44 + 8);
assert_eq!(u16::from_le_bytes([w[20], w[21]]), 1, "integer PCM");
assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]])), (4, 32));
assert_eq!(&w[44..], [i32::MAX.to_le_bytes(), (-i32::MAX).to_le_bytes()].concat());
let a = write_aiff(&[1.0, -1.0], 1, 48_000, I32).unwrap();
assert_eq!(&a[8..12], b"AIFF");
assert_eq!(u16::from_be_bytes([a[26], a[27]]), 32);
assert_eq!(&a[a.len() - 8..], [i32::MAX.to_be_bytes(), (-i32::MAX).to_be_bytes()].concat());
}
/// Float keeps the mix as it is: peaks above full scale survive.
#[test]
fn wav_32_bit_float() {
let s = [0.25f32, -2.0, 1.5, f32::NAN];
let w = write_wav(&s, 2, 48_000, F32).unwrap();
assert_eq!(&w[12..16], b"fmt ");
assert_eq!(le32(&w, 16), 18);
assert_eq!(u16::from_le_bytes([w[20], w[21]]), 3, "WAVE_FORMAT_IEEE_FLOAT");
assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]]), u16::from_le_bytes([w[36], w[37]])), (8, 32, 0));
assert_eq!((&w[38..42], le32(&w, 42), le32(&w, 46)), (&b"fact"[..], 4, 2));
assert_eq!((&w[50..54], le32(&w, 54)), (&b"data"[..], 16));
assert_eq!(le32(&w, 4) as usize, w.len() - 8);
let back: Vec<f32> = w[58..].chunks(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect();
assert_eq!(back, [0.25, -2.0, 1.5, 0.0]);
// 5.1: WAVE_FORMAT_EXTENSIBLE with the IEEE float sub-format, still with `fact`
let w = write_wav(&[0.5; 12], 6, 48_000, F32).unwrap();
assert_eq!((le32(&w, 16), u16::from_le_bytes([w[20], w[21]])), (40, 0xFFFE));
assert_eq!(&w[44..60], [&3u16.to_le_bytes()[..], &SUBTYPE_TAIL].concat());
assert_eq!((&w[60..64], le32(&w, 68)), (&b"fact"[..], 2));
// RF64: the frame count is in `ds64`
let h = header(PcmContainer::Wav, 2, 48_000, F32, 48_000 * 60 * 60 * 4).unwrap();
assert_eq!(&h[..4], b"RF64");
let fact = h.windows(4).position(|c| c == b"fact").unwrap();
assert_eq!(le32(&h, fact + 8), u32::MAX);
}
#[test]
fn aiff_32_bit_float_is_aifc() {
let a = write_aiff(&[0.25, -3.0], 1, 44_100, F32).unwrap();
assert_eq!((&a[..4], be32(&a, 4) as usize, &a[8..12]), (&b"FORM"[..], a.len() - 8, &b"AIFC"[..]));
assert_eq!((&a[12..16], be32(&a, 16), be32(&a, 20)), (&b"FVER"[..], 4, AIFC_VERSION_1));
assert_eq!((&a[24..28], be32(&a, 28)), (&b"COMM"[..], 44));
assert_eq!((u16::from_be_bytes([a[32], a[33]]), be32(&a, 34), u16::from_be_bytes([a[38], a[39]])), (1, 2, 32));
assert_eq!(&a[50..54], b"fl32");
assert_eq!(&a[54..76], FL32_NAME);
assert_eq!((&a[76..80], be32(&a, 80)), (&b"SSND"[..], 8 + 8));
assert_eq!(&a[92..], [0.25f32.to_be_bytes(), (-3.0f32).to_be_bytes()].concat());
}
/// ffprobe (an external oracle; skipped where it is not installed) reads every new format.
#[test]
fn ffprobe_reads_32_bit_formats() {
let dir = std::env::temp_dir().join(format!("filmcraft-pcm32-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let s: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.01).sin() * 1.5).collect();
let cases = [
("i32.wav", write_wav(&s, 2, 48_000, I32).unwrap(), "pcm_s32le"),
("f32.wav", write_wav(&s, 2, 48_000, F32).unwrap(), "pcm_f32le"),
("f32-6.wav", write_wav(&s, 6, 48_000, F32).unwrap(), "pcm_f32le"),
("i32.aiff", write_aiff(&s, 2, 48_000, I32).unwrap(), "pcm_s32be"),
("f32.aifc", write_aiff(&s, 2, 48_000, F32).unwrap(), "pcm_f32be"),
];
for (name, bytes, codec) in cases {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
let out = std::process::Command::new("ffprobe")
.args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,bits_per_sample", "-of", "default=nw=1"])
.arg(&path)
.output();
let Ok(out) = out else { break };
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains(&format!("codec_name={codec}")) && text.contains("bits_per_sample=32"), "{name}: {text}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it).
@ -278,8 +409,8 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.wav");
let frames = 48_000u64 * 60 * 60 * 5; // 5 hours of 24-bit stereo: 5.2 GB
let mut f = header(PcmContainer::Wav, 2, 48_000, 24, frames).unwrap();
encode(PcmContainer::Wav, &[0.0; 9_600], 24, &mut f);
let mut f = header(PcmContainer::Wav, 2, 48_000, I24, frames).unwrap();
encode(PcmContainer::Wav, &[0.0; 9_600], I24, &mut f);
std::fs::write(&path, &f).unwrap();
let out = std::process::Command::new("ffprobe")
.args(["-v", "error", "-show_entries", "format=format_name,duration", "-of", "default=nw=1"])

View File

@ -223,15 +223,96 @@ pub struct AudioSettings {
pub channels: u32,
/// AAC bitrate.
pub bitrate_kbps: u32,
/// PCM and FLAC sample size: 16 or 24.
/// PCM and FLAC sample size: 16, 24 or 32 (other values round down to one of them, 16 at least).
pub bits: u16,
/// 32-bit floating-point PCM instead of integers (needs `bits` 32; WAV, AIFF and QuickTime).
pub float: bool,
/// FLAC compression level, 0 (fastest) to 8 (smallest). Every level is lossless.
pub flac_level: u8,
}
impl Default for AudioSettings {
fn default() -> Self {
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 }
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, float: false, flac_level: 5 }
}
}
impl AudioSettings {
/// The sample format [`AudioSettings::bits`] and [`AudioSettings::float`] ask for.
pub fn sample_format(&self) -> SampleFormat {
match self.bits {
32.. if self.float => SampleFormat::Float32,
32.. => SampleFormat::Int32,
24.. => SampleFormat::Int24,
_ => SampleFormat::Int16,
}
}
/// Ask for `f` ([`AudioSettings::bits`] and [`AudioSettings::float`]).
pub fn set_sample_format(&mut self, f: SampleFormat) {
self.bits = f.bits();
self.float = f.is_float();
}
}
/// How PCM and FLAC audio stores its samples. The mix is 32-bit float: the integer formats
/// clip it at full scale, 32-bit float keeps it as it is (peaks above 0 dBFS included).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SampleFormat {
Int16,
Int24,
Int32,
Float32,
}
impl SampleFormat {
pub const ALL: [SampleFormat; 4] = [SampleFormat::Int16, SampleFormat::Int24, SampleFormat::Int32, SampleFormat::Float32];
pub fn bits(self) -> u16 {
match self {
SampleFormat::Int16 => 16,
SampleFormat::Int24 => 24,
SampleFormat::Int32 | SampleFormat::Float32 => 32,
}
}
/// Bytes per sample.
pub fn bytes(self) -> usize {
usize::from(self.bits() / 8)
}
pub fn is_float(self) -> bool {
self == SampleFormat::Float32
}
/// The name the Export settings show.
pub fn label(self) -> &'static str {
match self {
SampleFormat::Int16 => "16 bit",
SampleFormat::Int24 => "24 bit",
SampleFormat::Int32 => "32 bit",
SampleFormat::Float32 => "32 bit float",
}
}
/// One mix sample as a signed integer of this size, clipped at full scale (0 for
/// [`SampleFormat::Float32`], which is never quantised). Not-a-number becomes silence.
pub fn quantise(self, s: f32) -> i32 {
// f32 products for 16 and 24 bit (the bytes every earlier export wrote); f64 for 32 bit,
// whose full scale 2^31 - 1 f32 cannot hold. NaN casts to 0, the casts saturate.
let s = s.clamp(-1.0, 1.0);
match self {
SampleFormat::Int16 => (s * 32_767.0).round() as i32,
SampleFormat::Int24 => (s * 8_388_607.0).round() as i32,
SampleFormat::Int32 => (f64::from(s) * 2_147_483_647.0).round() as i32,
SampleFormat::Float32 => 0,
}
}
/// One mix sample as stored by [`SampleFormat::Float32`]: as it is, NaN as silence and
/// infinities as the largest finite values.
pub fn float(s: f32) -> f32 {
if s.is_nan() { 0.0 } else { s.clamp(f32::MIN, f32::MAX) }
}
}
@ -473,6 +554,16 @@ impl ExportSettings {
Resolved { width: w, height: h, rate, sample_rate: self.audio.sample_rate.unwrap_or(seq_sr), channels, target_kbps: target, max_kbps: max, keyint }
}
/// Whether the audio codec and container of this export store samples as `f`: PCM in WAV,
/// AIFF and QuickTime takes all four, MXF integers only; FLAC integers only; AAC none.
pub fn supports_sample_format(&self, f: SampleFormat) -> bool {
match self.audio_codec() {
AudioCodec::Aac => false,
AudioCodec::Flac => !f.is_float(),
AudioCodec::Pcm | AudioCodec::Auto => !(f.is_float() && self.format.is_mxf()),
}
}
/// The audio codec actually used.
pub fn audio_codec(&self) -> AudioCodec {
match (self.format, self.audio.codec) {
@ -548,7 +639,7 @@ impl ExportSettings {
Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
};
let audio_bps = if self.has_audio() || self.format.is_audio_only() {
let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 };
let pcm = r.sample_rate as f64 * r.channels as f64 * f64::from(self.audio.sample_format().bits());
match self.audio_codec() {
AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0,
// typical of music and dialogue; noise compresses less, silence far more
@ -649,13 +740,13 @@ impl ExportSettings {
};
match self.audio_codec() {
AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate),
AudioCodec::Flac => format!(
"FLAC (lossless) {}-bit, level {}, {} Hz, {ch}",
if self.audio.bits >= 24 { 24 } else { 16 },
self.audio.flac_level.min(8),
r.sample_rate
),
_ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate),
AudioCodec::Flac => {
format!("FLAC (lossless) {}-bit, level {}, {} Hz, {ch}", self.audio.sample_format().bits(), self.audio.flac_level.min(8), r.sample_rate)
}
_ => {
let f = self.audio.sample_format();
format!("Uncompressed {}-bit{} PCM, {} Hz, {ch}", f.bits(), if f.is_float() { " float" } else { "" }, r.sample_rate)
}
}
} else {
"No audio".to_string()

View File

@ -1135,3 +1135,202 @@ fn loudness_normalization_corrects_limiter_loss_and_bounds_impossible_targets()
assert_eq!(chunks.get(), 3);
assert_eq!(reads_at_cancel.get(), Some(reads.load(Ordering::Relaxed)));
}
/// 32-bit integer and float audio in every container that takes them, read back by ffprobe (an
/// external oracle) and, where FilmCraft imports the format, by FilmCraft's own readers.
#[test]
fn thirty_two_bit_audio_in_every_container() {
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
let dir = Scratch::new("pcm32");
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 2)));
let cases: [(Format, SampleFormat, &str, &str, &str); 8] = [
(Format::Wav, SampleFormat::Float32, "f.wav", "pcm_f32le", "32"),
(Format::Wav, SampleFormat::Int32, "i.wav", "pcm_s32le", "32"),
(Format::Aiff, SampleFormat::Float32, "f.aif", "pcm_f32be", "32"),
(Format::Aiff, SampleFormat::Int32, "i.aif", "pcm_s32be", "32"),
(Format::ProRes, SampleFormat::Float32, "f.mov", "pcm_f32le", "32"),
(Format::ProRes, SampleFormat::Int32, "i.mov", "pcm_s32le", "32"),
(Format::MxfOp1a, SampleFormat::Int32, "i.mxf", "pcm_s32le", "32"),
(Format::Flac, SampleFormat::Int32, "i.flac", "flac", "32"),
];
for (format, sf, name, codec, bits) in cases {
let path = dir.path(name);
let mut s = ExportSettings { format, path: path.clone(), range, ..Default::default() };
s.audio.set_sample_format(sf);
assert!(s.supports_sample_format(sf), "{name}");
export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{name}: {e}"));
let bytes = std::fs::read(&path).unwrap();
// FilmCraft's own importer: the -6 dB tone at a peak of 0.5
if !matches!(format, Format::MxfOp1a) {
let src = filmcraft_codecs::open_bytes(name, bytes.into()).unwrap_or_else(|e| panic!("{name}: {e}"));
let a = src.audio(0, 24_000, 48_000).unwrap();
let peak = a.channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs()));
assert!((peak - 0.5).abs() < 0.01, "{name}: peak {peak}");
}
if let Some(j) = ffprobe_json(&["-show_streams"], &path) {
let st = j["streams"].as_array().unwrap().iter().find(|s| s["codec_type"] == "audio").unwrap().clone();
assert_eq!(st["codec_name"].as_str(), Some(codec), "{name}");
let depth = if codec == "flac" { &st["bits_per_raw_sample"] } else { &st["bits_per_sample"] };
assert_eq!(depth.as_str().map(str::to_string).or_else(|| depth.as_u64().map(|v| v.to_string())).as_deref(), Some(bits), "{name}");
}
}
// every 32-bit file holds the same samples (float exactly, integers within one step of 2^-31)
if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("32-bit audio") {
let raw = |name: &str| {
let out = std::process::Command::new(&ffmpeg)
.args(["-v", "error", "-i", &dir.path(name), "-map", "0:a", "-f", "f32le", "-c:a", "pcm_f32le", "-"])
.output()
.unwrap();
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect::<Vec<f32>>()
};
let reference = raw("f.wav");
assert_eq!(reference.len(), 24_000 * 2);
for name in ["f.aif", "f.mov"] {
assert!(raw(name) == reference, "{name}: float samples differ from the WAV's");
}
for name in ["i.wav", "i.aif", "i.mov", "i.mxf", "i.flac"] {
let got = raw(name);
assert_eq!(got.len(), reference.len(), "{name}");
let worst = got.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst < 1e-6, "{name}: differs by {worst}");
}
}
}
/// The mix is 32-bit float: 32-bit float export keeps a +6 dB tone's peaks of 2.0, integers clip
/// them at full scale.
#[test]
fn float_export_keeps_peaks_above_full_scale() {
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(6.0));
let dir = Scratch::new("pcm-hot");
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 4)));
let peak = |sf: SampleFormat, name: &str| {
let path = dir.path(name);
let mut s = ExportSettings { format: Format::Wav, path: path.clone(), range, ..Default::default() };
s.audio.set_sample_format(sf);
export(&p, seq, &s, &m, &Progress::default()).unwrap();
let src = filmcraft_codecs::open_bytes(name, std::fs::read(&path).unwrap().into()).unwrap();
src.audio(0, 12_000, 48_000).unwrap().channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs()))
};
let float = peak(SampleFormat::Float32, "f.wav");
assert!((float - 2.0).abs() < 0.02, "float keeps +6 dBFS: {float}");
for (sf, name) in [(SampleFormat::Int32, "i32.wav"), (SampleFormat::Int24, "i24.wav"), (SampleFormat::Int16, "i16.wav")] {
let p = peak(sf, name);
assert!(p <= 1.0 && p > 0.99, "{name} clips at full scale: {p}");
}
}
#[test]
fn sample_formats_each_codec_and_container_stores() {
use SampleFormat::*;
let supported = |format: Format, codec: AudioCodec| {
let s = ExportSettings { format, audio: AudioSettings { codec, ..Default::default() }, ..Default::default() };
SampleFormat::ALL.iter().filter(|&&f| s.supports_sample_format(f)).copied().collect::<Vec<_>>()
};
assert_eq!(supported(Format::Wav, AudioCodec::Auto), [Int16, Int24, Int32, Float32]);
assert_eq!(supported(Format::Aiff, AudioCodec::Auto), [Int16, Int24, Int32, Float32]);
assert_eq!(supported(Format::ProRes, AudioCodec::Pcm), [Int16, Int24, Int32, Float32]);
assert_eq!(supported(Format::MxfOp1a, AudioCodec::Auto), [Int16, Int24, Int32]);
assert_eq!(supported(Format::MxfOpAtom, AudioCodec::Auto), [Int16, Int24, Int32]);
assert_eq!(supported(Format::Flac, AudioCodec::Auto), [Int16, Int24, Int32]);
assert_eq!(supported(Format::ProRes, AudioCodec::Flac), [Int16, Int24, Int32]);
assert!(supported(Format::H264, AudioCodec::Auto).is_empty(), "MP4 is AAC");
// `bits` rounds down to a size; `float` only with 32
let fmt = |bits, float| AudioSettings { bits, float, ..Default::default() }.sample_format();
assert_eq!(
[fmt(0, false), fmt(20, false), fmt(24, false), fmt(31, false), fmt(32, false), fmt(64, false), fmt(32, true)],
[Int16, Int16, Int24, Int24, Int32, Int32, Float32]
);
}
/// Hostile or contradictory settings (CLI, control channel, MCP, old presets) are errors, never
/// a panic or a silently different file.
#[test]
fn unsupported_sample_formats_are_refused() {
let refused = |format: Format, codec: AudioCodec, bits: u16, float: bool| {
let s = ExportSettings { format, path: "x".into(), audio: AudioSettings { codec, bits, float, ..Default::default() }, ..Default::default() };
s.validate().err().map(|e| e.to_string())
};
assert!(refused(Format::Wav, AudioCodec::Auto, 24, true).is_some_and(|e| e.contains("32")));
assert!(refused(Format::Flac, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("FLAC")));
assert!(refused(Format::ProRes, AudioCodec::Flac, 32, true).is_some_and(|e| e.contains("FLAC")));
assert!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("MXF")));
for (format, codec) in [(Format::Wav, AudioCodec::Auto), (Format::Aiff, AudioCodec::Auto), (Format::ProRes, AudioCodec::Pcm)] {
assert_eq!(refused(format, codec, 32, true), None, "{format:?}");
}
assert_eq!(refused(Format::Flac, AudioCodec::Auto, 32, false), None);
assert_eq!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, false), None);
// AAC has no sample size: the PCM / FLAC choice is ignored, as before
assert_eq!(refused(Format::H264, AudioCodec::Auto, 32, true), None);
// no audio, nothing to refuse
let s = ExportSettings {
format: Format::MxfOp1a,
include_audio: false,
audio: AudioSettings { bits: 32, float: true, ..Default::default() },
..Default::default()
};
assert!(s.validate().is_ok());
}
#[test]
fn sample_format_settings_round_trip_summary_and_estimate() {
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "wav", "audio": {"bits": 32, "float": true}})).unwrap();
assert_eq!(s.audio.sample_format(), SampleFormat::Float32);
let back: ExportSettings = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
assert_eq!(back.audio.sample_format(), SampleFormat::Float32);
// settings saved before 32-bit audio read integers
let old: AudioSettings = serde_json::from_value(serde_json::json!({"bits": 24})).unwrap();
assert_eq!((old.float, old.sample_format()), (false, SampleFormat::Int24));
let sum = |sf: SampleFormat, format: Format| {
let mut s = ExportSettings { format, ..Default::default() };
s.audio.set_sample_format(sf);
(
s.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).audio,
s.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)),
)
};
let (f, f_bytes) = sum(SampleFormat::Float32, Format::Wav);
assert!(f.starts_with("Uncompressed 32-bit float PCM, 48000 Hz"), "{f}");
let (i, _) = sum(SampleFormat::Int32, Format::Wav);
assert!(i.starts_with("Uncompressed 32-bit PCM"), "{i}");
let (_, i16_bytes) = sum(SampleFormat::Int16, Format::Wav);
assert!(f_bytes > i16_bytes * 19 / 10 && f_bytes < i16_bytes * 21 / 10, "{f_bytes} vs {i16_bytes}");
let (flac, _) = sum(SampleFormat::Int32, Format::Flac);
assert!(flac.starts_with("FLAC (lossless) 32-bit"), "{flac}");
}
/// FilmCraft imports its own 32-bit FLAC (symphonia rejected the frame header's 32-bit size code
/// as reserved: "unsupported media"), at full scale and with noise, sample for sample.
#[test]
fn own_importer_reads_32_bit_flac_at_full_scale() {
let n = 20_000usize;
let mut seed = 7u64;
let noise: Vec<i32> = (0..n)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 32) as u32 as i32
})
.collect();
let extremes: Vec<i32> = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN + 1 }).collect();
for level in [0u8, 5, 8] {
let mut enc = filmcraft_flac::Encoder::new(filmcraft_flac::EncoderConfig { level, ..filmcraft_flac::EncoderConfig::new(48_000, 2, 32) }).unwrap();
let mut file = enc.header();
file.extend(enc.encode(&[&extremes, &noise]).unwrap());
file.extend(enc.finish());
let at = filmcraft_flac::STREAMINFO_OFFSET as usize;
file[at..at + 34].copy_from_slice(&enc.streaminfo());
let src = filmcraft_codecs::open_bytes("x.flac", file.into()).unwrap_or_else(|e| panic!("level {level}: {e}"));
let a = src.audio(0, n, 48_000).unwrap();
for (c, want) in [&extremes, &noise].iter().enumerate() {
let got = &a.channels[c];
assert_eq!(got.len(), n, "level {level} channel {c}");
for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() {
let w = (f64::from(w) / 2_147_483_648.0) as f32;
assert!((g - w).abs() <= 1e-6, "level {level} channel {c} sample {i}: {g} vs {w}");
}
}
}
}

View File

@ -13,7 +13,7 @@ symphonia bootstrap decoder in `filmcraft-codecs`.
```rust
use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET};
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24)
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–32)
cfg.level = 8; // 0 fastest … 8 smallest; 5 by default
let mut enc = Encoder::new(cfg)?;
let mut file = enc.header(); // fLaC + STREAMINFO + VORBIS_COMMENT (vendor)
@ -37,10 +37,11 @@ let last_len = enc.pending_samples(); // duration
| Subframes | constant, verbatim, fixed predictors (order 0–4) and linear prediction (up to order 12), whichever is smallest |
| Linear prediction | Tukey(0.5) windowed autocorrelation, Levinson–Durbin, 12/15-bit quantised coefficients with error feedback |
| Residual | partitioned Rice codes (4- or 5-bit parameters), the partition order chosen per subframe |
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest |
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest (32-bit: independent only, a side channel would need 33 bits) |
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples,
seek tables.
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, seek tables.
32-bit samples (frame headers point at STREAMINFO for the size) decode only in recent decoders (libFLAC 1.4+,
ffmpeg 6+).
| Level | LPC order | Rice partition order | Stereo decorrelation |
|---|---|---|---|

View File

@ -53,6 +53,8 @@ fn sample_size_code(bps: u8) -> u8 {
16 => 4,
20 => 5,
24 => 6,
// 32 bits has code 7 since RFC 9639, which earlier decoders (symphonia 0.5, FilmCraft's own
// FLAC import) reject as reserved; 0 ("as STREAMINFO says") decodes everywhere
_ => 0,
}
}

View File

@ -1,6 +1,6 @@
//! Clean-room FLAC encoder (RFC 9639, Free Lossless Audio Codec).
//!
//! - [`Encoder`]: planar integer samples (8–24 bit, 1–8 channels) → a `.flac` stream: the
//! - [`Encoder`]: planar integer samples (8–32 bit, 1–8 channels) → a `.flac` stream: the
//! [`Encoder::header`] (`fLaC`, STREAMINFO, a VORBIS_COMMENT naming the encoder), then frames of
//! a fixed block size. Each channel of each frame is coded as constant, verbatim, a fixed
//! polynomial predictor or linear prediction, whichever is smallest, with partitioned Rice codes;
@ -61,7 +61,8 @@ pub struct EncoderConfig {
pub sample_rate: u32,
/// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1).
pub channels: u8,
/// 8–24.
/// 8–32. At 32 bits stereo channels are coded independently: the side channel of
/// left/side, side/right and mid/side coding would need 33 bits.
pub bits_per_sample: u8,
/// Samples per channel per frame, 16–65535 (4096 by default).
pub block_size: u16,
@ -92,8 +93,8 @@ impl Encoder {
if !(1..=8).contains(&cfg.channels) {
return Err(Error::InvalidConfig("1 to 8 channels"));
}
if !(8..=24).contains(&cfg.bits_per_sample) {
return Err(Error::InvalidConfig("8 to 24 bits per sample"));
if !(8..=32).contains(&cfg.bits_per_sample) {
return Err(Error::InvalidConfig("8 to 32 bits per sample"));
}
if !(1..=1_048_575).contains(&cfg.sample_rate) {
return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz"));
@ -170,8 +171,9 @@ impl Encoder {
if planar.iter().any(|c| c.len() != n) {
return Err(Error::UnequalChannels);
}
let max = (1i32 << (self.cfg.bits_per_sample - 1)) - 1;
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| s > max || s < -max - 1) {
// i64: at 32 bits the range is all of i32 (1 << 31 overflows i32)
let max = (1i64 << (self.cfg.bits_per_sample - 1)) - 1;
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| i64::from(s) > max || i64::from(s) < -max - 1) {
return Err(Error::SampleRange(bad));
}
for (p, c) in self.pending.iter_mut().zip(planar) {
@ -217,7 +219,8 @@ impl Encoder {
let effort = self.effort;
let code = |x: &[i32], b: u8| predict::choose(x, b, effort);
let data = match block {
[l, r] if effort.stereo => {
// the side channel takes one bit more than the samples: at most 32 (i32)
[l, r] if effort.stereo && bps < 32 => {
let side: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| a - b).collect();
let mid: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect();
let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps));

View File

@ -24,7 +24,7 @@ impl Rng {
/// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise,
/// a full-scale square, silence and a lone click.
fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
let max = f64::from((1i32 << (bps - 1)) - 1);
let max = ((1i64 << (bps - 1)) - 1) as f64;
let mut r = Rng(seed | 1);
(0..n)
.map(|i| {
@ -43,7 +43,7 @@ fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
"click" => f64::from(u8::from(i == n / 2)),
_ => 0.0,
};
((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32)
((v * max).round() as i64).clamp(-(max as i64) - 1, max as i64) as i32
})
.collect()
}
@ -93,13 +93,14 @@ fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize)
}
// STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16
// and 24 bit compare
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16,
// 24 and 32 bit compare
let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34];
let pcm = match bps {
8 => Some("s8"),
16 => Some("s16le"),
24 => Some("s24le"),
32 => Some("s32le"),
_ => None,
};
if let Some(pcm) = pcm {
@ -118,7 +119,7 @@ fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize)
#[test]
fn every_signal_round_trips_exactly() {
for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] {
for bps in [16u8, 24] {
for bps in [16u8, 24, 32] {
let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)];
if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() {
return;
@ -129,11 +130,13 @@ fn every_signal_round_trips_exactly() {
#[test]
fn channel_layouts_rates_and_sizes() {
let cases: [(&str, u8, u8, u32, usize); 6] = [
let cases: [(&str, u8, u8, u32, usize); 8] = [
("mono-8bit", 1, 8, 8_000, 3_001),
("mono-24-44k1", 1, 24, 44_100, 9_999),
("stereo-20bit", 2, 20, 96_000, 10_000),
("5.1-24", 6, 24, 48_000, 12_345),
("mono-32", 1, 32, 48_000, 7_777),
("5.1-32", 6, 32, 96_000, 12_345),
("odd-rate", 2, 16, 37_800, 5_000),
("tiny", 2, 16, 48_000, 1),
];
@ -167,7 +170,8 @@ fn levels_and_block_sizes_trade_time_for_size() {
fn bad_input_is_an_error_not_a_panic() {
assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 33)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 7)).is_err());
assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err());
assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err());
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap();
@ -179,3 +183,21 @@ fn bad_input_is_an_error_not_a_panic() {
assert!(enc.finish().is_empty());
assert_eq!(&enc.header()[..4], b"fLaC");
}
/// 32-bit stereo at the extremes of i32: left minus right would need 33 bits, so the encoder must
/// not code a side channel (it overflowed `i32` and wrote garbage); every level decodes exactly.
#[test]
fn full_scale_32_bit_stereo_round_trips() {
let n = 9_000;
let left: Vec<i32> = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN }).collect();
let right: Vec<i32> = left.iter().map(|&v| if v == i32::MAX { i32::MIN } else { i32::MAX }).collect();
let planar = vec![left, right];
for level in [0u8, 5, 8] {
let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 32) };
if round_trip(&format!("extremes-32-level{level}"), &planar, cfg, 4096).is_none() {
return;
}
}
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 32)).unwrap();
assert!(enc.encode(&[&[i32::MIN, i32::MAX], &[0, -1]]).is_ok());
}

365
crates/gpu/src/adapter.rs Normal file
View File

@ -0,0 +1,365 @@
//! Which adapter to draw with when one GPU is listed through two versions of one driver.
//!
//! On Windows the Vulkan loader takes one driver from each display adapter's driver package. A PC
//! whose GPUs were installed from different packages of one vendor (a laptop with an integrated
//! and a discrete AMD GPU, the first on the manufacturer's old package and the second on a current
//! one) loads two versions of that driver, and the newer GPU is listed once through each. wgpu
//! takes the first entry of the device type it prefers, which is whichever version the loader
//! listed first; a window drawn through the stale one kills the process inside the driver
//! (`amdvlk64.dll`, 0xc0000005), where no Rust error handling runs.
//!
//! [`choose`] is wgpu's own rule (`Instance::request_adapter`: the adapters that can draw to the
//! surface, by device type for the power preference, then as enumerated) with one addition: when
//! the adapter that rule picks is listed again by the same driver, the entry exposing the most
//! features is used, i.e. the newer version. Without such a duplicate the choice is wgpu's own.
//! Entries of one GPU from different drivers (RADV and AMDVLK, a vendor's driver and Dozen) are
//! left alone: those are installed side by side on purpose, and wgpu's order between them stays.
use std::cmp::Reverse;
use std::fmt;
use wgpu::{Adapter, Backend, DeviceType, PowerPreference, Surface};
/// One enumerated adapter as plain data, so that [`choose`] is a pure function tests run without
/// a GPU.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Candidate {
pub name: String,
pub backend: Backend,
pub device_type: DeviceType,
/// PCI vendor and device ids; both zero when the backend reports none (OpenGL).
pub vendor: u32,
pub device: u32,
/// PCI bus id such as `0000:03:00.0`; empty when the driver reports none.
pub pci_bus_id: String,
/// The driver's name (Vulkan) or version (DirectX 12).
pub driver: String,
/// More about the driver, for the log.
pub driver_info: String,
/// How many wgpu features the driver exposes; a newer version of one driver exposes more.
pub features: u32,
/// Whether the surface can be drawn through this adapter (true without a surface).
pub usable: bool,
}
impl Candidate {
pub fn of(adapter: &Adapter, surface: Option<&Surface<'_>>) -> Self {
let info = adapter.get_info();
Candidate {
name: info.name,
backend: info.backend,
device_type: info.device_type,
vendor: info.vendor,
device: info.device,
pci_bus_id: info.device_pci_bus_id,
driver: info.driver,
driver_info: info.driver_info,
features: adapter.features().bits().0.iter().map(|word| word.count_ones()).sum(),
usable: surface.is_none_or(|s| adapter.is_surface_supported(s)),
}
}
}
impl fmt::Display for Candidate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind = match self.device_type {
DeviceType::DiscreteGpu => "discrete GPU",
DeviceType::IntegratedGpu => "integrated GPU",
DeviceType::VirtualGpu => "virtual GPU",
DeviceType::Cpu => "software",
DeviceType::Other => "unknown type",
};
let driver = format!("{} {}", self.driver, self.driver_info);
write!(f, "\"{}\" ({:?}, {kind}, driver {}, {} features", self.name, self.backend, driver.trim(), self.features)?;
if !self.pci_bus_id.is_empty() {
write!(f, ", bus {}", self.pci_bus_id)?;
}
if !self.usable {
write!(f, ", cannot draw to the window")?;
}
write!(f, ")")
}
}
/// Whether `a` and `b` are one GPU listed twice by one driver, i.e. through two installed
/// versions of it: the same backend, device type, PCI vendor and device and driver name, and the
/// same bus id when both report one. Adapters without PCI ids (OpenGL reports zeros) are never
/// the same: two of them cannot be told apart.
fn same_gpu_and_driver(a: &Candidate, b: &Candidate) -> bool {
let identified = a.vendor != 0 || a.device != 0;
let same_bus = a.pci_bus_id.is_empty() || b.pci_bus_id.is_empty() || a.pci_bus_id == b.pci_bus_id;
identified && a.backend == b.backend && a.device_type == b.device_type && a.vendor == b.vendor && a.device == b.device && a.driver == b.driver && same_bus
}
/// wgpu's device type order for `power` (wgpu-core's `Instance::request_adapter`): lower first.
/// Without a preference the adapters stay as enumerated.
fn type_order(device_type: DeviceType, power: PowerPreference) -> u8 {
let prefer_integrated = match power {
PowerPreference::LowPower => true,
PowerPreference::HighPerformance => false,
PowerPreference::None => return 0,
};
match device_type {
DeviceType::DiscreteGpu if prefer_integrated => 2,
DeviceType::IntegratedGpu if prefer_integrated => 1,
DeviceType::DiscreteGpu => 1,
DeviceType::IntegratedGpu => 2,
DeviceType::Other => 3,
DeviceType::VirtualGpu => 4,
DeviceType::Cpu => 5,
}
}
/// The index of the adapter wgpu's `request_adapter` picks: the first usable one of the best
/// device type for `power`. `None` without a usable adapter.
fn wgpu_choice(candidates: &[Candidate], power: PowerPreference) -> Option<usize> {
let usable = candidates.iter().enumerate().filter(|(_, c)| c.usable);
usable.min_by_key(|(index, c)| (type_order(c.device_type, power), *index)).map(|(index, _)| index)
}
/// The index of the adapter to draw with: [`wgpu_choice`], or the entry exposing the most
/// features among those that are the same GPU listed by the same driver (the first of them when
/// several expose as many). `None` without a usable adapter.
pub fn choose(candidates: &[Candidate], power: PowerPreference) -> Option<usize> {
let first = wgpu_choice(candidates, power)?;
let Some(preferred) = candidates.get(first) else {
return Some(first);
};
let versions = candidates.iter().enumerate().filter(|(_, c)| c.usable && same_gpu_and_driver(preferred, c));
versions.max_by_key(|(index, c)| (c.features, Reverse(*index))).map(|(index, _)| index).or(Some(first))
}
/// [`choose`] over enumerated adapters, as egui-wgpu's `native_adapter_selector` for the window
/// (`surface`). The adapters and the choice go to the log.
pub fn pick(adapters: &[Adapter], surface: Option<&Surface<'_>>, power: PowerPreference) -> Result<Adapter, String> {
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::of(a, surface)).collect();
for candidate in &candidates {
log::info!("graphics adapter available: {candidate}");
}
let listed = || candidates.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
let index = choose(&candidates, power).ok_or_else(|| format!("no usable graphics adapter among {}: {}", candidates.len(), listed()))?;
let (Some(chosen), Some(adapter)) = (candidates.get(index), adapters.get(index)) else {
return Err(format!("graphics adapter {index} of {} is missing", adapters.len()));
};
let others = candidates.iter().enumerate().filter(|(i, c)| *i != index && c.usable && same_gpu_and_driver(chosen, c)).count();
if others > 0 {
log::warn!(
"{:?} lists \"{}\" {} times: several versions of its driver are installed, and a window drawn through a stale one can crash. Using the one with the most features; installing one driver package for every GPU removes the others.",
chosen.backend,
chosen.name,
others.saturating_add(1)
);
}
log::info!("graphics adapter chosen: {chosen}");
Ok(adapter.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use wgpu::Backend::{Dx12, Gl, Vulkan};
use wgpu::DeviceType::{Cpu, DiscreteGpu, IntegratedGpu, Other};
use wgpu::PowerPreference::{HighPerformance, LowPower};
const AMD: u32 = 0x1002;
const RX_6800M: u32 = 0x73df;
const RADEON_IGPU: u32 = 0x1638;
const POWERS: [PowerPreference; 3] = [PowerPreference::None, LowPower, HighPerformance];
#[expect(clippy::too_many_arguments, reason = "one line per adapter in the listings below")]
fn gpu(
name: &str,
backend: Backend,
device_type: DeviceType,
vendor: u32,
device: u32,
bus: &str,
driver: &str,
driver_info: &str,
features: u32,
) -> Candidate {
Candidate {
name: name.into(),
backend,
device_type,
vendor,
device,
pci_bus_id: bus.into(),
driver: driver.into(),
driver_info: driver_info.into(),
features,
usable: true,
}
}
/// An ASUS G513QY as wgpu 30 lists it: the integrated Radeon is on the manufacturer's 2021
/// driver package and the RX 6800M on a 2026 one, so the Vulkan loader has both versions of
/// AMD's driver, and the 2021 one lists the RX 6800M first. A window drawn through that entry
/// died inside `amdvlk64.dll` (0xc0000005) on every launch.
fn g513qy() -> Vec<Candidate> {
let amd = "AMD proprietary driver";
vec![
gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", amd, "26.9.2", 60),
gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", amd, "26.9.2 (AMD proprietary shader compiler)", 68),
gpu("AMD Radeon(TM) Graphics", Vulkan, IntegratedGpu, AMD, RADEON_IGPU, "", amd, "21.30.02.13", 56),
gpu("AMD Radeon RX 6800M", Dx12, DiscreteGpu, AMD, RX_6800M, "0000:03:00.0", "32.0.21045.11001", "", 36),
gpu("AMD Radeon(TM) Graphics", Dx12, IntegratedGpu, AMD, RADEON_IGPU, "0000:08:00.0", "30.0.13002.13003", "", 36),
gpu("Microsoft Basic Render Driver", Dx12, Cpu, 0x1414, 0x8c, "", "10.0.26100.9549", "", 36),
]
}
#[test]
fn a_gpu_listed_through_two_versions_of_its_driver_draws_with_the_newer_one() {
let adapters = g513qy();
// the window asks for high performance; `WGPU_POWER_PREF=none` for no preference
for power in [HighPerformance, PowerPreference::None] {
assert_eq!(wgpu_choice(&adapters, power), Some(0), "wgpu takes the stale entry, which crashed");
assert_eq!(choose(&adapters, power), Some(1));
}
// whichever version the loader lists first
let mut newer_first = adapters.clone();
newer_first.swap(0, 1);
assert_eq!(choose(&newer_first, HighPerformance), Some(0));
// two entries exposing as much: wgpu's, the first
let mut equal = adapters.clone();
if let Some(c) = equal.get_mut(1) {
c.features = 60;
}
assert_eq!(choose(&equal, HighPerformance), Some(0));
// three versions: the one exposing the most
let mut three = adapters.clone();
three.insert(1, gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", "AMD proprietary driver", "24.1.1", 64));
assert_eq!(choose(&three, HighPerformance), Some(2));
}
#[test]
fn a_newer_version_never_overtakes_another_gpu_or_backend() {
// low power: the integrated GPU, as wgpu picks, whatever the discrete one is listed as
assert_eq!(choose(&g513qy(), LowPower), Some(2));
// DirectX 12 enumerated first: its entry of the GPU stays the choice
let mut dx12_first = g513qy();
dx12_first.rotate_left(3);
for power in [HighPerformance, PowerPreference::None] {
assert_eq!(choose(&dx12_first, power), Some(0));
}
assert_eq!(choose(&dx12_first, LowPower), wgpu_choice(&dx12_first, LowPower));
}
#[test]
fn without_two_versions_of_one_driver_the_choice_is_wgpus() {
let nvidia = |backend, bus, driver, features| gpu("NVIDIA GeForce RTX 4070", backend, DiscreteGpu, 0x10de, 0x2786, bus, driver, "", features);
let intel = |backend, bus, driver, features| gpu("Intel(R) UHD Graphics 770", backend, IntegratedGpu, 0x8086, 0x4680, bus, driver, "", features);
let radeon = |bus, driver, features| gpu("AMD Radeon RX 7900 XT", Vulkan, DiscreteGpu, AMD, 0x744c, bus, driver, "", features);
let gl = |name: &str, features| gpu(name, Gl, Other, 0, 0, "", "", "", features);
let without = |skip: usize| g513qy().into_iter().enumerate().filter(|(i, _)| *i != skip).map(|(_, c)| c).collect::<Vec<_>>();
let listings: Vec<Vec<Candidate>> = vec![
vec![],
vec![nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70)],
// a laptop with two GPUs, through Vulkan and DirectX 12
vec![
intel(Vulkan, "0000:00:02.0", "Intel Corporation", 60),
nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70),
nvidia(Dx12, "0000:01:00.0", "32.0.15.6094", 36),
intel(Dx12, "0000:00:02.0", "31.0.101.5186", 36),
gpu("Microsoft Basic Render Driver", Dx12, Cpu, 0x1414, 0x8c, "", "10.0.26100.1", "", 36),
],
// one GPU through two different drivers, the second exposing more: Linux with AMDVLK and RADV
vec![radeon("0000:03:00.0", "AMD open-source driver", 58), radeon("0000:03:00.0", "radv", 71)],
// a vendor's driver and Dozen (Vulkan on DirectX 12)
vec![nvidia(Vulkan, "", "Dozen", 75), nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70)],
// two cards of one model, on their own buses
vec![radeon("0000:03:00.0", "AMD proprietary driver", 60), radeon("0000:04:00.0", "AMD proprietary driver", 68)],
// OpenGL reports no ids: two adapters cannot be told apart
vec![gl("ANGLE", 20), gl("ANGLE", 30)],
vec![gpu("llvmpipe (LLVM 19.1.0, 256 bits)", Vulkan, Cpu, 0x10005, 0, "", "llvmpipe", "", 50)],
// the laptop above with either version of the driver alone
without(0),
without(1),
];
for adapters in &listings {
for power in POWERS {
assert_eq!(choose(adapters, power), wgpu_choice(adapters, power), "{power:?} over {adapters:#?}");
}
}
assert_eq!(choose(&[], HighPerformance), None);
}
#[test]
fn two_entries_are_one_gpu_only_with_the_same_ids_type_driver_and_bus() {
let stale = g513qy().swap_remove(0);
let newer = g513qy().swap_remove(1);
assert!(same_gpu_and_driver(&stale, &newer) && same_gpu_and_driver(&stale, &stale));
// a bus id only one version reports does not tell them apart; two different ones do
let on_bus = |bus: &str| Candidate { pci_bus_id: bus.into(), ..newer.clone() };
assert!(same_gpu_and_driver(&stale, &on_bus("0000:03:00.0")));
assert!(same_gpu_and_driver(&on_bus("0000:03:00.0"), &on_bus("0000:03:00.0")));
assert!(!same_gpu_and_driver(&on_bus("0000:03:00.0"), &on_bus("0000:04:00.0")));
for other in [
Candidate { backend: Dx12, ..newer.clone() },
Candidate { device_type: IntegratedGpu, ..newer.clone() },
Candidate { vendor: 0x10de, ..newer.clone() },
Candidate { device: RADEON_IGPU, ..newer.clone() },
Candidate { driver: "radv".into(), ..newer.clone() },
] {
assert!(!same_gpu_and_driver(&stale, &other), "{other}");
}
let unidentified = Candidate { vendor: 0, device: 0, ..newer };
assert!(!same_gpu_and_driver(&unidentified, &unidentified));
}
#[test]
fn adapters_that_cannot_draw_to_the_window_are_left_out() {
let unusable = |which: &dyn Fn(usize, &Candidate) -> bool| {
let mut adapters = g513qy();
for (i, c) in adapters.iter_mut().enumerate() {
c.usable = !which(i, c);
}
adapters
};
// the newer version cannot: the other entry is all there is of that GPU through Vulkan
assert_eq!(choose(&unusable(&|i, _| i == 1), HighPerformance), Some(0));
assert_eq!(choose(&unusable(&|i, _| i == 0), HighPerformance), Some(1));
// Vulkan cannot at all: DirectX 12
assert_eq!(choose(&unusable(&|_, c| c.backend == Vulkan), HighPerformance), Some(3));
assert_eq!(choose(&unusable(&|_, _| true), HighPerformance), None);
}
#[test]
fn candidates_describe_themselves_for_the_log() {
let adapters = g513qy();
let text = adapters.get(1).map(ToString::to_string).unwrap_or_default();
assert_eq!(text, "\"AMD Radeon RX 6800M\" (Vulkan, discrete GPU, driver AMD proprietary driver 26.9.2 (AMD proprietary shader compiler), 68 features)");
let offscreen = adapters.get(4).map(|c| Candidate { usable: false, ..c.clone() }.to_string()).unwrap_or_default();
assert_eq!(
offscreen,
"\"AMD Radeon(TM) Graphics\" (Dx12, integrated GPU, driver 30.0.13002.13003, 36 features, bus 0000:08:00.0, cannot draw to the window)"
);
}
/// Against the adapters of the machine running the tests: [`wgpu_choice`] is what wgpu's
/// `request_adapter` returns, and [`pick`] returns that adapter too, or a version of it
/// exposing more features.
#[test]
fn on_this_host_the_choice_is_wgpus_unless_its_driver_is_listed_twice() {
if wgpu::Instance::enabled_backend_features().is_empty() {
return;
}
// no OpenGL: the app leaves it out on Windows, where creating it can crash a driver
let backends = wgpu::Backends::PRIMARY;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() });
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::of(a, None)).collect();
for power in POWERS {
let options = wgpu::RequestAdapterOptions { power_preference: power, ..Default::default() };
let wgpu_own = pollster::block_on(instance.request_adapter(&options)).ok().map(|a| Candidate::of(&a, None));
assert_eq!(wgpu_choice(&candidates, power).and_then(|i| candidates.get(i)), wgpu_own.as_ref(), "{power:?} over {candidates:#?}");
let picked = pick(&adapters, None, power).ok().map(|a| Candidate::of(&a, None));
match (&wgpu_own, &picked) {
(Some(wgpu_own), Some(picked)) if wgpu_own != picked => {
assert!(same_gpu_and_driver(wgpu_own, picked) && picked.features > wgpu_own.features, "{power:?}: {picked} instead of {wgpu_own}");
}
_ => assert_eq!(picked, wgpu_own, "{power:?}"),
}
}
}
}

View File

@ -24,7 +24,7 @@ const EXACT: f32 = 1e-4;
const FLIPS: f64 = 0.01;
fn cx(t: Tick, px_scale: f32) -> FxCtx<'static> {
FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working: filmcraft_color::WorkingSpace::Rec709 }
FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709 }
}
fn effect(id: &str, params: &[(&str, ParamValue)]) -> EffectInstance {

View File

@ -38,6 +38,7 @@ use filmcraft_frame::{Chroma, PixelData, VideoFrame};
use filmcraft_render::Blend;
use filmcraft_render::plan::{FramePlan, PlanLayer, PlanStep};
pub mod adapter;
pub mod export_renderer;
pub mod fx;
pub mod lut;

View File

@ -73,7 +73,7 @@ while let Some(p) = d.next_packet()? { // file order, all tracks
| `A_VORBIS` | `Vorbis` | three headers split from Xiph-laced CodecPrivate |
| `A_FLAC` | `Flac` | `fLaC` + metadata blocks |
| `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE` | `Pcm` | bit depth from Audio |
| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2` | `Ac3`, `Eac3`, `Mp3`, `Mp2` | |
| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2`, `A_MPEG/L1` | `Ac3`, `Eac3`, `Mp3`, `Mp2`, `Mp1` | |
| `S_TEXT/UTF8` | `SubRip` | |
| `S_TEXT/WEBVTT`, `D_WEBVTT/*` (WebM) | `WebVtt` | WebM blocks keep `id\nsettings\npayload` |
| `S_TEXT/ASS`, `S_TEXT/SSA` | `Ass` | script header |

View File

@ -45,6 +45,8 @@ pub enum Codec {
Mp3,
/// `A_MPEG/L2`.
Mp2,
/// `A_MPEG/L1`.
Mp1,
/// `S_TEXT/UTF8` (SubRip-style plain text).
SubRip,
/// `S_TEXT/WEBVTT`, or WebM's `D_WEBVTT/{SUBTITLES,CAPTIONS,DESCRIPTIONS,METADATA}`.
@ -86,6 +88,7 @@ impl Codec {
Codec::Eac3 => "eac3".into(),
Codec::Mp3 => "mp3".into(),
Codec::Mp2 => "mp2".into(),
Codec::Mp1 => "mp1".into(),
Codec::SubRip => "subrip".into(),
Codec::WebVtt => "webvtt".into(),
Codec::Ass { ssa, .. } => if *ssa { "ssa" } else { "ass" }.into(),
@ -173,6 +176,7 @@ pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInf
"A_EAC3" => Codec::Eac3,
"A_MPEG/L3" => Codec::Mp3,
"A_MPEG/L2" => Codec::Mp2,
"A_MPEG/L1" => Codec::Mp1,
"S_TEXT/UTF8" => Codec::SubRip,
"S_TEXT/WEBVTT" | "D_WEBVTT/SUBTITLES" | "D_WEBVTT/CAPTIONS" | "D_WEBVTT/DESCRIPTIONS" | "D_WEBVTT/METADATA" => Codec::WebVtt,
"S_TEXT/ASS" | "S_ASS" => Codec::Ass { header: p, ssa: false },

View File

@ -172,7 +172,7 @@ pub trait MediaSource: Send + Sync {
pub type SharedSource = Arc<dyn MediaSource>;
/// File extensions we recognise at import (the media browser filters on these).
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "exr", "dng"];
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "exr", "hdr", "dng"];
pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"];
pub const VIDEO_EXTENSIONS: &[&str] = &[
"mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv",
@ -197,6 +197,9 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result<Shar
if still::exr::sniff(&bytes) {
return Ok(Arc::new(still::exr::still(name, &bytes)?));
}
if still::hdr::sniff(&bytes) {
return Ok(Arc::new(still::hdr::still(name, &bytes)?));
}
if wav::sniff(&bytes) {
return Ok(Arc::new(wav::WavSource::parse(name, bytes)?));
}

View File

@ -221,7 +221,7 @@ impl MediaSource for ImageSequenceSource {
}
}
/// Decode one frame file: OpenEXR and DNG camera raw as linear floats (DNG with its metadata, for
/// Decode one frame file: OpenEXR, Radiance HDR and DNG camera raw as linear floats (DNG with its metadata, for
/// the sequence's timing), every other format through the still decoders as RGBA8. Returns the
/// frame, whether it has alpha, and its pixel format for the media info.
fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, Option<filmcraft_dng::Info>)> {
@ -229,6 +229,10 @@ fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, O
let d = crate::still::exr::decode(name, bytes)?;
return Ok((d.frame, d.has_alpha, d.pixel_format, None));
}
if crate::still::hdr::sniff(bytes) {
let frame = crate::still::hdr::decode(name, bytes)?;
return Ok((frame, false, "RGB 32-bit float (RGBE)".to_string(), None));
}
if crate::still::dng::sniff(bytes) {
let d = crate::still::dng::decode(name, bytes)?;
return Ok((d.frame, false, d.pixel_format, Some(d.info)));

View File

@ -1,4 +1,4 @@
//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, DNG camera raw) as media
//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, Radiance HDR, DNG camera raw) as media
//! sources. Animated PNG and GIF files open as movies instead ([`crate::animated`]); here they would
//! show their first frame.
@ -11,6 +11,8 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result,
pub mod dng;
pub mod exr;
pub mod hdr;
mod ycbcr_tiff;
/// Default still duration (Premiere's preference default is 5 seconds).
pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);
@ -18,6 +20,7 @@ pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);
/// Whether `b` (the contents of the file `name`) is a still image we decode.
pub fn sniff(name: &str, b: &[u8]) -> bool {
crate::dpx::sniff(b)
|| hdr::sniff(b)
|| crate::tga::sniff(name, b)
|| b.starts_with(b"\x89PNG")
|| b.starts_with(&[0xFF, 0xD8, 0xFF])
@ -46,6 +49,9 @@ pub fn decode_raster(name: &str, bytes: &[u8]) -> Result<Raster> {
if crate::tga::sniff(name, bytes) {
return crate::tga::decode(name, bytes);
}
if ycbcr_tiff::sniff(bytes) {
return ycbcr_tiff::decode(name, bytes);
}
let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?;
let has_alpha = img.color().has_alpha();
let rgba = img.to_rgba8();
@ -64,6 +70,9 @@ impl StillSource {
if dng::sniff(bytes) {
return dng::still(name, bytes);
}
if hdr::sniff(bytes) {
return hdr::still(name, bytes);
}
let r = decode_raster(name, bytes)?;
Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64))
}

View File

@ -0,0 +1,223 @@
//! Radiance HDR (`.hdr`, `.pic`) stills, read from Greg Ward's public RGBE format description
//! ("Real Pixels", Graphics Gems II): a text header (`#?RADIANCE` or `#?RGBE`, `FORMAT=32-bit_rle_rgbe`),
//! a blank line, a resolution line (`-Y h +X w` and the seven other axis orders), then one RGBE
//! pixel (shared exponent) per sample, flat, in the old run-length form or in the "new" per-channel
//! run-length form (scan lines of 8 to 32767 pixels). RGBE is scene-linear light, so the frame keeps
//! 32-bit floats (values above 1.0 survive into effects and colour work). The `EXPOSURE` and
//! `PRIMARIES` header lines are not applied, which is what other readers do too; the XYZE colour
//! format is refused.
use std::sync::Arc;
use filmcraft_color::{ColorInfo, Transfer};
use filmcraft_frame::VideoFrame;
use filmcraft_time::FrameRate;
use super::{DEFAULT_STILL_DURATION, StillSource};
use crate::{MediaError, MediaInfo, MediaKind, Result, VideoStreamInfo};
/// Largest picture we decode (each pixel takes 16 bytes as RGBA floats).
const MAX_PIXELS: u64 = 1 << 26;
/// Longest header we read (real ones are a few hundred bytes).
const MAX_HEADER: usize = 64 << 10;
/// The colour of HDR frames: scene-linear light, Rec.709 primaries.
const LINEAR_709: ColorInfo = ColorInfo { transfer: Transfer::Linear, ..ColorInfo::SRGB_FULL };
/// Whether `b` starts like a Radiance picture.
pub fn sniff(b: &[u8]) -> bool {
b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE")
}
fn err(name: &str, what: &str) -> MediaError {
MediaError::Decode(format!("{name}: Radiance HDR: {what}"))
}
/// Open a Radiance picture as a still.
pub fn still(name: &str, bytes: &[u8]) -> Result<StillSource> {
let frame = decode(name, bytes)?;
let info = MediaInfo {
name: name.to_string(),
kind: MediaKind::Still,
duration: DEFAULT_STILL_DURATION,
video: Some(VideoStreamInfo {
width: frame.width,
height: frame.height,
frame_rate: FrameRate::FPS_29_97,
par: (1, 1),
codec: "HDR".into(),
pixel_format: "RGB 32-bit float (RGBE)".into(),
color: LINEAR_709,
has_alpha: false,
bitrate: None,
hdr: None,
}),
audio_streams: Vec::new(),
container: "Image".into(),
start_timecode: None,
file_size: Some(bytes.len() as u64),
};
Ok(StillSource { info, frame: Arc::new(frame) })
}
/// One resolution-line axis: `(is_x, positive)`.
fn axis(tok: &str) -> Option<(bool, bool)> {
match tok {
"+X" => Some((true, true)),
"-X" => Some((true, false)),
"+Y" => Some((false, true)),
"-Y" => Some((false, false)),
_ => None,
}
}
/// RGBE to linear floats: `mantissa * 2^(e - 136)`, zero for exponent 0. (Radiance's own reader adds
/// half a mantissa step; ffmpeg and stb_image do not, which also makes 1.0 = (128, 128, 128, 129)
/// read back as exactly 1.0.)
fn rgbe(p: [u8; 4]) -> [f32; 3] {
if p[3] == 0 {
return [0.0; 3];
}
let scale = 2f32.powi(i32::from(p[3]) - 136);
[f32::from(p[0]) * scale, f32::from(p[1]) * scale, f32::from(p[2]) * scale]
}
/// Read the pixels of one scan line of `n` pixels into `line`.
fn scanline(name: &str, b: &[u8], pos: &mut usize, line: &mut [[u8; 4]]) -> Result<()> {
let n = line.len();
let take = |pos: &mut usize, k: usize| -> Result<&[u8]> {
let s = b.get(*pos..pos.saturating_add(k)).ok_or_else(|| err(name, "truncated pixel data"))?;
*pos += k;
Ok(s)
};
let head = b.get(*pos..*pos + 4);
if (8..=32767).contains(&n)
&& let Some(&[2, 2, hi, lo]) = head
&& hi & 0x80 == 0
{
if usize::from(hi) << 8 | usize::from(lo) != n {
return Err(err(name, "scan line length does not match the picture"));
}
*pos += 4;
for c in 0..4 {
let mut x = 0;
while x < n {
let code = take(pos, 1)?[0];
if code > 128 {
let run = usize::from(code - 128);
let v = take(pos, 1)?[0];
let dst = line.get_mut(x..x + run).ok_or_else(|| err(name, "run past the end of the scan line"))?;
for p in dst {
p[c] = v;
}
x += run;
} else {
let run = usize::from(code);
if run == 0 {
return Err(err(name, "empty literal run"));
}
let src = take(pos, run)?;
let dst = line.get_mut(x..x + run).ok_or_else(|| err(name, "literal past the end of the scan line"))?;
for (p, &v) in dst.iter_mut().zip(src) {
p[c] = v;
}
x += run;
}
}
}
return Ok(());
}
// flat pixels, with the old run-length form: (1, 1, 1, k) repeats the previous pixel k << shift times
let (mut x, mut shift) = (0usize, 0u32);
while x < n {
let s = take(pos, 4)?;
let p = [s[0], s[1], s[2], s[3]];
if p[..3] == [1, 1, 1] {
let count = usize::from(p[3]).checked_shl(shift).unwrap_or(usize::MAX);
let prev = if x > 0 { line.get(x - 1).copied() } else { None }.ok_or_else(|| err(name, "run with no previous pixel"))?;
let dst = line.get_mut(x..x.saturating_add(count).min(n)).ok_or_else(|| err(name, "run past the end of the scan line"))?;
dst.fill(prev);
x += dst.len();
shift = shift.saturating_add(8).min(24);
} else {
if let Some(d) = line.get_mut(x) {
*d = p;
}
x += 1;
shift = 0;
}
}
Ok(())
}
/// Decode a Radiance picture to an opaque linear float frame.
pub fn decode(name: &str, b: &[u8]) -> Result<VideoFrame> {
if !sniff(b) {
return Err(err(name, "not a Radiance picture"));
}
// header: lines up to the first empty one, then the resolution line
let mut pos = 0;
let line = |pos: &mut usize| -> Result<String> {
let rest = b.get(*pos..).ok_or_else(|| err(name, "truncated header"))?;
let end = rest.iter().take(MAX_HEADER).position(|&c| c == b'\n').ok_or_else(|| err(name, "truncated header"))?;
let s = String::from_utf8_lossy(rest.get(..end).unwrap_or_default()).trim_end_matches('\r').to_string();
*pos += end + 1;
Ok(s)
};
loop {
let l = line(&mut pos)?;
if l.is_empty() {
break;
}
if let Some(f) = l.strip_prefix("FORMAT=") {
match f.trim() {
"32-bit_rle_rgbe" => {}
"32-bit_rle_xyze" => return Err(err(name, "the XYZE colour format is not supported")),
other => return Err(err(name, &format!("unknown format {other}"))),
}
}
if pos > MAX_HEADER {
return Err(err(name, "header too long"));
}
}
let res = line(&mut pos)?;
let t: Vec<&str> = res.split_whitespace().collect();
let &[a1, n1, a2, n2] = t.as_slice().first_chunk::<4>().ok_or_else(|| err(name, "bad resolution line"))?;
let ((x1, p1), (x2, p2)) = (axis(a1).ok_or_else(|| err(name, "bad resolution line"))?, axis(a2).ok_or_else(|| err(name, "bad resolution line"))?);
if x1 == x2 {
return Err(err(name, "resolution line names one axis twice"));
}
let n1: usize = n1.parse().map_err(|_| err(name, "bad resolution line"))?;
let n2: usize = n2.parse().map_err(|_| err(name, "bad resolution line"))?;
let (w, h) = if x1 { (n1, n2) } else { (n2, n1) };
if w == 0 || h == 0 || (w as u64).saturating_mul(h as u64) > MAX_PIXELS {
return Err(err(name, "picture size out of range"));
}
// a scan line takes at least a few bits of file, so a header cannot ask for more than the data has
if (b.len().saturating_sub(pos) as u64).saturating_mul(4096) < (w as u64).saturating_mul(h as u64) {
return Err(err(name, "truncated pixel data"));
}
let mut px = vec![1f32; w * h * 4];
let mut scan = vec![[0u8; 4]; n2];
for i in 0..n1 {
scanline(name, b, &mut pos, &mut scan)?;
for (j, &p) in scan.iter().enumerate() {
// the first axis counts scan lines, the second runs along one
let (x, y) = if x1 {
(if p1 { i } else { w - 1 - i }, if p2 { h - 1 - j } else { j })
} else {
(if p2 { j } else { w - 1 - j }, if p1 { h - 1 - i } else { i })
};
let [r, g, bl] = rgbe(p);
if let Some(o) = px.get_mut((y * w + x) * 4..(y * w + x) * 4 + 3) {
o.copy_from_slice(&[r, g, bl]);
}
}
}
let mut frame = VideoFrame::rgba_f32(w as u32, h as u32, px);
frame.color = LINEAR_709;
Ok(frame)
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,134 @@
use super::*;
fn file(res: &str, body: &[u8]) -> Vec<u8> {
let mut v = format!("#?RADIANCE\n# test\nFORMAT=32-bit_rle_rgbe\nEXPOSURE=1.0\n\n{res}\n").into_bytes();
v.extend_from_slice(body);
v
}
fn px(f: &VideoFrame) -> Vec<f32> {
match &f.data {
filmcraft_frame::PixelData::RgbaF32(p) => p.to_vec(),
_ => panic!("not float"),
}
}
/// New-style RLE of one scan line, as Radiance's own writer does: per channel, runs of 3+ equal bytes.
fn rle_line(line: &[[u8; 4]]) -> Vec<u8> {
let n = line.len();
let mut out = vec![2, 2, (n >> 8) as u8, n as u8];
for c in 0..4 {
let ch: Vec<u8> = line.iter().map(|p| p[c]).collect();
let mut i = 0;
while i < n {
let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count();
if run >= 3 {
out.extend([128 + run as u8, ch[i]]);
i += run;
} else {
let mut j = i + 1;
while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) {
j += 1;
}
out.push((j - i) as u8);
out.extend_from_slice(&ch[i..j]);
i = j;
}
}
}
out
}
#[test]
fn rgbe_values() {
assert_eq!(rgbe([0, 0, 0, 0]), [0.0; 3]);
assert_eq!(rgbe([128, 128, 128, 129]), [1.0; 3]);
assert_eq!(rgbe([128, 64, 0, 136]), [128.0, 64.0, 0.0]);
assert_eq!(rgbe([128, 128, 128, 128])[0], 0.5);
assert!(rgbe([255, 255, 255, 255])[0].is_finite());
}
#[test]
fn flat_top_to_bottom() {
let body = [[10u8, 20, 30, 136], [1, 2, 3, 136], [4, 5, 6, 137], [7, 8, 9, 135]].concat();
let f = decode("a.hdr", &file("-Y 2 +X 2", &body)).unwrap();
assert_eq!((f.width, f.height), (2, 2));
let p = px(&f);
assert_eq!(&p[0..4], &[10.0, 20.0, 30.0, 1.0]);
assert_eq!(&p[4..8], &[1.0, 2.0, 3.0, 1.0]);
assert_eq!(&p[8..12], &[8.0, 10.0, 12.0, 1.0]); // exponent 137: scale 2
assert_eq!(&p[12..16], &[3.5, 4.0, 4.5, 1.0]); // exponent 135: scale 1/2
}
#[test]
fn orientations() {
// 3 wide, 2 high, pixel value = its index in the file
let body: Vec<u8> = (0..6u8).flat_map(|i| [i, 0, 0, 136]).collect();
let red = |res: &str| -> Vec<f32> {
let f = decode("a.hdr", &file(res, &body)).unwrap();
px(&f).chunks(4).map(|p| p[0]).collect()
};
assert_eq!(red("-Y 2 +X 3"), [0., 1., 2., 3., 4., 5.]);
assert_eq!(red("+Y 2 +X 3"), [3., 4., 5., 0., 1., 2.]);
assert_eq!(red("-Y 2 -X 3"), [2., 1., 0., 5., 4., 3.]);
// scan lines are columns: file index = x * 2 + (row, top first for -Y)
assert_eq!(red("+X 3 -Y 2"), [0., 2., 4., 1., 3., 5.]);
assert_eq!(red("-X 3 +Y 2"), [5., 3., 1., 4., 2., 0.]);
}
#[test]
fn run_length_lines() {
let w = 40usize;
let rows: Vec<Vec<[u8; 4]>> = (0..3).map(|y| (0..w).map(|x| if x < 20 { [100, 50, 25, 130] } else { [(x * 5 + y) as u8, 7, 9, 131] }).collect()).collect();
let body: Vec<u8> = rows.iter().flat_map(|r| rle_line(r)).collect();
let f = decode("a.hdr", &file(&format!("-Y 3 +X {w}"), &body)).unwrap();
let p = px(&f);
for (y, r) in rows.iter().enumerate() {
for (x, q) in r.iter().enumerate() {
let want = rgbe(*q);
assert_eq!(&p[(y * w + x) * 4..(y * w + x) * 4 + 3], &want, "pixel {x},{y}");
}
}
}
#[test]
fn old_style_runs() {
// one pixel, then (1,1,1,3) repeats it 3 times, then a (1,1,1,1) pair repeats 1<<0 then 1<<8 -> clipped to the line
let body = [[5u8, 6, 7, 136], [1, 1, 1, 3], [9, 9, 9, 136]].concat();
let f = decode("a.hdr", &file("-Y 1 +X 5", &body)).unwrap();
let p = px(&f);
assert_eq!(p[0], 5.0);
assert_eq!(p[12], 5.0); // fourth pixel is still a repeat
assert_eq!(p[16], 9.0);
}
#[test]
fn hostile_input_is_an_error_not_a_panic() {
let ok = file("-Y 2 +X 2", &[0; 16]);
assert!(decode("a.hdr", &ok).is_ok());
for cut in 0..ok.len() - 1 {
// a cut anywhere is an error (the header or the pixel data is incomplete), never a panic
assert!(decode("a.hdr", &ok[..cut]).is_err(), "{cut}");
}
assert!(decode("a.hdr", &file("-Y 2 +X 2", &[0; 8])).is_err());
assert!(decode("a.hdr", &file("-Y 99999999 +X 99999999", &[0; 16])).is_err());
assert!(decode("a.hdr", &file("-Y 0 +X 4", &[])).is_err());
assert!(decode("a.hdr", &file("-Y 2 -Y 2", &[0; 16])).is_err());
assert!(decode("a.hdr", &file("junk", &[0; 16])).is_err());
assert!(decode("a.hdr", b"#?RADIANCE\nFORMAT=32-bit_rle_xyze\n\n-Y 1 +X 1\n\0\0\0\0").is_err());
// an RLE line announcing the wrong width, a run past the end, a run at the start of a flat line
assert!(decode("a.hdr", &file("-Y 1 +X 10", &[2, 2, 0, 11, 0])).is_err());
assert!(decode("a.hdr", &file("-Y 1 +X 10", &[2, 2, 0, 10, 200, 1])).is_err());
assert!(decode("a.hdr", &file("-Y 1 +X 3", &[1, 1, 1, 9, 0, 0, 0, 0])).is_err());
assert!(decode("a.hdr", b"#?RADIANCE").is_err());
}
#[test]
fn opens_as_a_float_still() {
let body = [[128u8, 128, 128, 136]; 4].concat();
let s = still("a.hdr", &file("-Y 2 +X 2", &body)).unwrap();
let info = crate::MediaSource::info(&s);
assert_eq!(info.video.as_ref().map(|v| (v.width, v.height, v.codec.as_str())), Some((2, 2, "HDR")));
let src = crate::open_bytes("a.hdr", file("-Y 2 +X 2", &body).into(), &[]).unwrap();
assert_eq!(src.info().kind, MediaKind::Still);
}

View File

@ -0,0 +1,174 @@
//! Uncompressed and PackBits YCbCr TIFFs (TIFF 6.0 §21), as ffmpeg writes from YUV sources. The
//! image crate reads YCbCr only inside JPEG compression, so these are decoded here: 8-bit chunky
//! data units of any subsampling (1, 2 or 4 each way), converted to RGB with the file's
//! YCbCrCoefficients and ReferenceBlackWhite (§20), chroma repeated across each data unit.
//!
//! Every number comes from the file: sizes, strip offsets and counts are checked before use, the
//! picture is capped at [`MAX_PIXELS`], and PackBits output stops at the bytes the image needs.
use filmcraft_dng::tiff::{Tiff, tags};
use super::Raster;
use crate::{MediaError, Result};
/// Largest picture decoded (1 GiB of RGBA), as for animated images.
const MAX_PIXELS: u64 = 1 << 28;
const PHOTOMETRIC_YCBCR: u64 = 6;
const COMPRESSION_NONE: u64 = 1;
const COMPRESSION_PACKBITS: u64 = 32773;
const YCBCR_COEFFICIENTS: u16 = 529;
const YCBCR_SUBSAMPLING: u16 = 530;
const REFERENCE_BLACK_WHITE: u16 = 532;
/// Whether `bytes` is a TIFF whose first image is uncompressed or PackBits YCbCr (the ones this
/// module decodes instead of the image crate).
pub fn sniff(bytes: &[u8]) -> bool {
let Ok((t, first)) = Tiff::new(bytes) else { return false };
let Ok((ifd, _)) = t.read_ifd(first, 0) else { return false };
let num = |tag| ifd.get(tag).and_then(|e| t.uint(e).ok());
num(tags::PHOTOMETRIC) == Some(PHOTOMETRIC_YCBCR) && matches!(num(tags::COMPRESSION).unwrap_or(COMPRESSION_NONE), COMPRESSION_NONE | COMPRESSION_PACKBITS)
}
/// Decode the first image of a YCbCr TIFF (see [`sniff`]) to RGBA.
pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
let bad = |what: &str| MediaError::Decode(format!("{name}: YCbCr TIFF: {what}"));
let unsupported = |what: &str| MediaError::Unsupported(format!("{name}: YCbCr TIFF with {what}"));
let (t, first) = Tiff::new(bytes).map_err(|_| bad("not a TIFF"))?;
let (ifd, _) = t.read_ifd(first, 0).map_err(|_| bad("unreadable image directory"))?;
let uints = |tag: u16| -> Result<Option<Vec<u64>>> { ifd.get(tag).map(|e| t.uints(e).map_err(|_| bad("unreadable field"))).transpose() };
let one = |tag: u16| -> Result<Option<u64>> { Ok(uints(tag)?.and_then(|v| v.first().copied())) };
let width = one(tags::IMAGE_WIDTH)?.ok_or_else(|| bad("no ImageWidth"))?;
let height = one(tags::IMAGE_LENGTH)?.ok_or_else(|| bad("no ImageLength"))?;
if width == 0 || height == 0 {
return Err(bad("empty image"));
}
if width.saturating_mul(height) > MAX_PIXELS {
return Err(unsupported("more than 268 million pixels"));
}
if one(tags::SAMPLES_PER_PIXEL)?.unwrap_or(1) != 3 {
return Err(unsupported("other than three samples per pixel"));
}
if uints(tags::BITS_PER_SAMPLE)?.unwrap_or_else(|| vec![1]).iter().any(|&b| b != 8) {
return Err(unsupported("other than 8 bits per sample"));
}
if one(tags::PLANAR_CONFIGURATION)?.unwrap_or(1) != 1 {
return Err(unsupported("separate planes"));
}
if ifd.get(tags::TILE_OFFSETS).is_some() {
return Err(unsupported("tiles"));
}
let compression = one(tags::COMPRESSION)?.unwrap_or(COMPRESSION_NONE);
let sub = uints(YCBCR_SUBSAMPLING)?.unwrap_or_else(|| vec![2, 2]);
let (sh, sv) = match sub.as_slice() {
[h, v, ..] if [1, 2, 4].contains(h) && [1, 2, 4].contains(v) => (*h, *v),
_ => return Err(bad("invalid YCbCrSubSampling")),
};
// §21: the data units cover the image rounded up to whole units.
let (units_x, units_y) = (width.div_ceil(sh), height.div_ceil(sv));
let unit_len = sh * sv + 2;
let need = usize::try_from(units_x.saturating_mul(units_y).saturating_mul(unit_len)).map_err(|_| bad("too large"))?;
let offsets = uints(tags::STRIP_OFFSETS)?.ok_or_else(|| bad("no StripOffsets"))?;
let counts = uints(tags::STRIP_BYTE_COUNTS)?.ok_or_else(|| bad("no StripByteCounts"))?;
let mut units: Vec<u8> = Vec::with_capacity(need);
for (&off, &len) in offsets.iter().zip(&counts) {
if units.len() >= need {
break;
}
let strip = usize::try_from(off)
.ok()
.zip(usize::try_from(len).ok())
.and_then(|(o, l)| bytes.get(o..o.checked_add(l)?))
.ok_or_else(|| bad("strip outside the file"))?;
match compression {
COMPRESSION_NONE => units.extend_from_slice(strip.get(..need - units.len()).unwrap_or(strip)),
_ => unpackbits(strip, &mut units, need),
}
}
if units.len() < need {
return Err(bad("image data shorter than the image"));
}
let coeffs = match ifd.get(YCBCR_COEFFICIENTS) {
Some(e) => t.floats(e).map_err(|_| bad("unreadable YCbCrCoefficients"))?,
None => vec![0.299, 0.587, 0.114],
};
let [lr, lg, lb] = coeffs.get(..3).and_then(|c| <[f64; 3]>::try_from(c).ok()).ok_or_else(|| bad("invalid YCbCrCoefficients"))?;
if lg.abs() < 1e-6 {
return Err(bad("invalid YCbCrCoefficients"));
}
let rbw = match ifd.get(REFERENCE_BLACK_WHITE) {
Some(e) => t.floats(e).map_err(|_| bad("unreadable ReferenceBlackWhite"))?,
None => vec![0.0, 255.0, 128.0, 255.0, 128.0, 255.0],
};
let [yb, yw, cbb, cbw, crb, crw] = rbw.get(..6).and_then(|c| <[f64; 6]>::try_from(c).ok()).ok_or_else(|| bad("invalid ReferenceBlackWhite"))?;
if (yw - yb).abs() < 1e-6 || (cbw - cbb).abs() < 1e-6 || (crw - crb).abs() < 1e-6 {
return Err(bad("invalid ReferenceBlackWhite"));
}
// §20: luma to 0–255, chroma to ±127 about its reference black; §21: back to R'G'B'.
let to_rgb = |y: u8, cb: u8, cr: u8| -> [u8; 3] {
let y = (f64::from(y) - yb) * 255.0 / (yw - yb);
let cb = (f64::from(cb) - cbb) * 127.0 / (cbw - cbb);
let cr = (f64::from(cr) - crb) * 127.0 / (crw - crb);
let r = cr * (2.0 - 2.0 * lr) + y;
let b = cb * (2.0 - 2.0 * lb) + y;
let g = (y - lb * b - lr * r) / lg;
[r, g, b].map(|v| v.round().clamp(0.0, 255.0) as u8)
};
let (w, h) = (width as usize, height as usize);
let (sh, sv, units_x) = (sh as usize, sv as usize, units_x as usize);
let mut rgba = vec![255u8; w * h * 4];
for (i, unit) in units.chunks_exact(sh * sv + 2).take(need / (sh * sv + 2)).enumerate() {
let (ux, uy) = (i % units_x, i / units_x);
let (cb, cr) = (unit[sh * sv], unit[sh * sv + 1]);
for (k, &y) in unit[..sh * sv].iter().enumerate() {
let (x, row) = (ux * sh + k % sh, uy * sv + k / sh);
if x < w && row < h {
let at = (row * w + x) * 4;
rgba[at..at + 3].copy_from_slice(&to_rgb(y, cb, cr));
}
}
}
Ok(Raster { width: width as u32, height: height as u32, rgba, has_alpha: false })
}
/// PackBits (TIFF 6.0 §9) from `src` appended to `out`, stopping at `limit` bytes of output or at
/// a run that the data ends inside.
fn unpackbits(src: &[u8], out: &mut Vec<u8>, limit: usize) {
let mut p = 0;
while out.len() < limit {
let Some(&n) = src.get(p) else { return };
let n = n as i8;
p += 1;
if n >= 0 {
let len = n as usize + 1;
let Some(lit) = src.get(p..p + len) else { return };
out.extend_from_slice(&lit[..len.min(limit - out.len())]);
p += len;
} else if n != -128 {
let Some(&b) = src.get(p) else { return };
let len = (1 - n as isize) as usize;
out.extend(std::iter::repeat_n(b, len.min(limit - out.len())));
p += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn packbits_runs_literals_and_noops() {
let mut out = Vec::new();
// literal 2, a no-op, a run of 3, then a literal cut short by the end of the data
unpackbits(&[1, 10, 11, 0x80, 0xFE, 7, 4, 1], &mut out, 100);
assert_eq!(out, [10, 11, 7, 7, 7]);
let mut capped = Vec::new();
unpackbits(&[0xFE, 7, 0xFE, 8], &mut capped, 4);
assert_eq!(capped, [7, 7, 7, 8]);
}
}

View File

@ -0,0 +1,99 @@
//! Radiance HDR stills against ffmpeg (an external test oracle): we write RGBE files in the flat and
//! new run-length forms, and our decode must give the floats ffmpeg's decoder gives.
use std::path::Path;
use filmcraft_frame::PixelData;
use filmcraft_media::FrameRequest;
const W: usize = 70; // 8..=32767, so scan lines may use the new run-length form
const H: usize = 9;
/// A deterministic picture with long runs (so RLE has runs) and noisy stretches (literals).
fn picture() -> Vec<[u8; 4]> {
let mut s = 0x1234_5678u32;
(0..W * H)
.map(|i| {
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let (x, y) = (i % W, i / W);
if x < 30 {
[200, 100, 50, 120 + y as u8]
} else if i % 3 == 0 {
[(s >> 24) as u8, 0, 255, 0] // exponent 0: black
} else {
[(s >> 24) as u8, (s >> 16) as u8, (s >> 8) as u8, 100 + (s & 31) as u8]
}
})
.collect()
}
fn rle_line(line: &[[u8; 4]]) -> Vec<u8> {
let n = line.len();
let mut out = vec![2, 2, (n >> 8) as u8, n as u8];
for c in 0..4 {
let ch: Vec<u8> = line.iter().map(|p| p[c]).collect();
let mut i = 0;
while i < n {
let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count();
if run >= 3 {
out.extend([128 + run as u8, ch[i]]);
i += run;
} else {
let mut j = i + 1;
while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) {
j += 1;
}
out.push((j - i) as u8);
out.extend_from_slice(&ch[i..j]);
i = j;
}
}
}
out
}
fn write(path: &Path, res: &str, body: &[u8]) {
let mut v = format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n{res}\n").into_bytes();
v.extend_from_slice(body);
std::fs::write(path, v).unwrap();
}
fn check(ff: &Path, name: &str, res: &str, body: &[u8]) {
let dir = filmcraft_testkit::fixtures::fixtures_dir("media/hdr");
let path = dir.join(name);
write(&path, res, body);
let out = std::process::Command::new(ff).args(["-v", "error", "-i"]).arg(&path).args(["-f", "rawvideo", "-pix_fmt", "gbrpf32le", "-"]).output().unwrap();
assert!(out.status.success(), "ffmpeg decode of {name}: {}", String::from_utf8_lossy(&out.stderr));
let v: Vec<f32> = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect();
let n = W * H;
assert_eq!(v.len(), 3 * n, "{name}: ffmpeg frame size");
let src = filmcraft_media::open_bytes(name, std::fs::read(&path).unwrap().into(), &[]).unwrap();
let f = src.video_frame(FrameRequest::full(filmcraft_time::Tick::ZERO)).unwrap();
assert_eq!((f.width as usize, f.height as usize), (W, H));
let PixelData::RgbaF32(p) = &f.data else { panic!("HDR frames are float") };
let mut worst = 0f32;
for i in 0..n {
// gbrp planes: 0 = G, 1 = B, 2 = R
for (c, plane) in [2, 0, 1].into_iter().enumerate() {
let (a, b) = (p[i * 4 + c], v[plane * n + i]);
worst = worst.max((a - b).abs() / b.abs().max(1e-6));
}
assert_eq!(p[i * 4 + 3], 1.0);
}
// ffmpeg's decode and ours differ only by float rounding of the same formula
assert!(worst == 0.0, "{name}: worst relative difference {worst}");
}
#[test]
fn matches_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
let pic = picture();
let rows: Vec<&[[u8; 4]]> = pic.chunks(W).collect();
let flat: Vec<u8> = pic.iter().flatten().copied().collect();
check(&ff, "flat.hdr", &format!("-Y {H} +X {W}"), &flat);
let rle: Vec<u8> = rows.iter().flat_map(|r| rle_line(r)).collect();
check(&ff, "rle.hdr", &format!("-Y {H} +X {W}"), &rle);
// Other axis orders are covered by unit tests: ffmpeg reads every file as top-down, which the
// format description does not (+Y starts at the bottom row).
}

View File

@ -0,0 +1,156 @@
//! YCbCr TIFF stills (#1293): uncompressed and PackBits files with PhotometricInterpretation 6, as
//! ffmpeg writes from YUV sources. The files are built here from TIFF 6.0 (§21 YCbCr images, §9
//! PackBits), little-endian, one strip.
use filmcraft_media::still::decode_raster;
/// A YCbCr TIFF: `width` × `height`, subsampling `sub` (horizontal, vertical), `data` the strip
/// as stored (data units, already compressed when `compression` is 32773), plus `extra` entries
/// (tag, type, values).
fn ycbcr_tiff(width: u32, height: u32, sub: (u16, u16), compression: u16, data: &[u8], extra: &[(u16, u16, Vec<u32>)]) -> Vec<u8> {
let mut entries: Vec<(u16, u16, Vec<u32>)> = vec![
(256, 4, vec![width]),
(257, 4, vec![height]),
(258, 3, vec![8, 8, 8]),
(259, 3, vec![u32::from(compression)]),
(262, 3, vec![6]),
(273, 4, vec![0]), // patched below
(277, 3, vec![3]),
(278, 4, vec![height]),
(279, 4, vec![data.len() as u32]),
(530, 3, vec![u32::from(sub.0), u32::from(sub.1)]),
];
entries.extend(extra.iter().cloned());
entries.sort_by_key(|e| e.0);
let ifd_at = 8u32;
let ifd_len = 2 + 12 * entries.len() as u32 + 4;
let mut extra_data: Vec<u8> = Vec::new();
let mut ifd: Vec<u8> = (entries.len() as u16).to_le_bytes().to_vec();
let data_at = ifd_at + ifd_len;
let mut values_at = data_at + data.len() as u32;
for (tag, ty, vals) in &entries {
let vals = if *tag == 273 { vec![data_at] } else { vals.clone() };
let mut bytes: Vec<u8> = Vec::new();
for v in &vals {
match ty {
3 => bytes.extend((*v as u16).to_le_bytes()),
5 => bytes.extend(v.to_le_bytes()), // rationals are passed as num, den pairs
_ => bytes.extend(v.to_le_bytes()),
}
}
let count = if *ty == 5 { vals.len() / 2 } else { vals.len() };
ifd.extend(tag.to_le_bytes());
ifd.extend(ty.to_le_bytes());
ifd.extend((count as u32).to_le_bytes());
if bytes.len() <= 4 {
bytes.resize(4, 0);
ifd.extend(&bytes);
} else {
ifd.extend(values_at.to_le_bytes());
values_at += bytes.len() as u32;
extra_data.extend(bytes);
}
}
ifd.extend(0u32.to_le_bytes());
let mut out = b"II*\0".to_vec();
out.extend(ifd_at.to_le_bytes());
out.extend(ifd);
out.extend(data);
out.extend(extra_data);
out
}
fn px(r: &filmcraft_media::still::Raster, x: u32, y: u32) -> [u8; 4] {
let i = ((y * r.width + x) * 4) as usize;
[r.rgba[i], r.rgba[i + 1], r.rgba[i + 2], r.rgba[i + 3]]
}
fn near(a: [u8; 4], b: [u8; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 2)
}
#[test]
fn subsampled_ycbcr_places_each_data_unit() {
// 4 × 2 at 2 × 2: two data units (four Y, then Cb, Cr). Left grey, right red (BT.601, the
// default YCbCrCoefficients, full range: the default ReferenceBlackWhite).
let data = [128, 128, 128, 128, 128, 128, 76, 76, 76, 76, 85, 255];
let r = decode_raster("frame.tiff", &ycbcr_tiff(4, 2, (2, 2), 1, &data, &[])).unwrap();
assert_eq!((r.width, r.height), (4, 2));
for y in 0..2 {
assert!(near(px(&r, 0, y), [128, 128, 128, 255]) && near(px(&r, 1, y), [128, 128, 128, 255]), "{:?}", px(&r, 0, y));
assert!(near(px(&r, 2, y), [254, 0, 0, 255]) && near(px(&r, 3, y), [254, 0, 0, 255]), "{:?}", px(&r, 2, y));
}
}
#[test]
fn packbits_ycbcr_with_video_range_reference_levels() {
// 2 × 1 at 1 × 1 (4:4:4): white then black in video range (16–235, chroma 128 ± 112), the
// ReferenceBlackWhite ffmpeg writes. PackBits: a literal run of 6 bytes.
let units = [235, 128, 128, 16, 128, 128];
let mut data = vec![5u8];
data.extend(units);
let rbw = vec![16, 1, 235, 1, 128, 1, 240, 1, 128, 1, 240, 1];
let r = decode_raster("frame.tif", &ycbcr_tiff(2, 1, (1, 1), 32773, &data, &[(532, 5, rbw)])).unwrap();
assert!(near(px(&r, 0, 0), [255, 255, 255, 255]), "{:?}", px(&r, 0, 0));
assert!(near(px(&r, 1, 0), [0, 0, 0, 255]), "{:?}", px(&r, 1, 0));
}
#[test]
fn sizes_that_are_not_a_whole_number_of_data_units_are_padded() {
// 3 × 3 at 2 × 2: the data units cover 4 × 4 (TIFF 6.0 §21), the extra column and row dropped.
let unit = |y: u8| [y, y, y, y, 128, 128];
let data: Vec<u8> = [unit(50), unit(100), unit(150), unit(200)].concat();
let r = decode_raster("frame.tiff", &ycbcr_tiff(3, 3, (2, 2), 1, &data, &[])).unwrap();
assert_eq!((r.width, r.height, r.rgba.len()), (3, 3, 36));
assert!(near(px(&r, 0, 0), [50, 50, 50, 255]) && near(px(&r, 2, 0), [100, 100, 100, 255]));
assert!(near(px(&r, 0, 2), [150, 150, 150, 255]) && near(px(&r, 2, 2), [200, 200, 200, 255]));
}
#[test]
fn damaged_ycbcr_tiffs_fail_without_panicking() {
let data: Vec<u8> = (0..4 * 4 / 4 * 6).map(|i| (i * 37 % 256) as u8).collect();
let good = ycbcr_tiff(4, 4, (2, 2), 1, &data, &[]);
let mut cases: Vec<Vec<u8>> = (0..good.len()).map(|n| good[..n].to_vec()).collect();
for i in 0..good.len() {
for bit in [0, 3, 7] {
let mut b = good.clone();
b[i] ^= 1 << bit;
cases.push(b);
}
}
// A huge size, a zero subsampling factor, and a PackBits run past the strip.
cases.push(ycbcr_tiff(65_535, 65_535, (2, 2), 1, &data, &[]));
cases.push(ycbcr_tiff(4, 4, (0, 2), 1, &data, &[]));
cases.push(ycbcr_tiff(4, 4, (2, 2), 32773, &[0x81, 7], &[]));
for (i, b) in cases.into_iter().enumerate() {
assert!(std::panic::catch_unwind(|| decode_raster("damaged.tiff", &b).map(|r| r.rgba.len())).is_ok(), "case {i} panicked");
}
}
/// ffmpeg (an external oracle) writes YCbCr TIFFs from YUV frames; our decode must match ffmpeg's
/// own RGB decode of the same file. A smooth gradient, so the chroma upsampling (we repeat chroma
/// across a data unit) does not dominate; 95 × 63 so the data units do not line up.
#[test]
fn ffmpeg_ycbcr_tiffs_match_ffmpeg_decode() {
let Some(ff) = filmcraft_testkit::oracle::ffmpeg_or_skip("YCbCr TIFF oracle") else { return };
let dir = filmcraft_testkit::fixtures::fixtures_dir("media/tiff_ycbcr");
for pix_fmt in ["yuv420p", "yuv422p", "yuv444p"] {
let tiff = dir.join(format!("gradient_{pix_fmt}.tiff"));
let made = filmcraft_testkit::fixtures::generate(&tiff, |tmp| {
std::process::Command::new(&ff)
.args(["-y", "-v", "error", "-f", "lavfi", "-i", "gradients=size=95x63:seed=7", "-frames:v", "1", "-pix_fmt", pix_fmt])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
});
let Some(tiff) = made else { continue };
let out = std::process::Command::new(&ff).args(["-v", "error", "-i"]).arg(&tiff).args(["-f", "rawvideo", "-pix_fmt", "rgb24", "-"]).output().unwrap();
let ours = decode_raster("gradient.tiff", &std::fs::read(&tiff).unwrap()).unwrap();
assert_eq!((ours.width, ours.height), (95, 63), "{pix_fmt}");
let theirs = out.stdout;
assert_eq!(theirs.len(), 95 * 63 * 3, "{pix_fmt}");
let worst =
ours.rgba.as_chunks::<4>().0.iter().zip(theirs.as_chunks::<3>().0).flat_map(|(a, b)| (0..3).map(move |c| a[c].abs_diff(b[c]))).max().unwrap();
assert!(worst <= 6, "{pix_fmt}: worst channel difference {worst}");
}
}

View File

@ -174,7 +174,6 @@ pub struct FrameInfo {
const ADTS_RATES: [u32; 13] = [96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, 7_350];
/// Parse the frame header at the start of `b` (MPEG audio, ADTS, AC-3, E-AC-3).
/// Bytes of the audio frame whose header starts `b` (MPEG audio, ADTS, LATM, AC-3, E-AC-3), or
/// `None` without a valid header there. For containers that store frames in chunks that need not
/// follow them (AVI): the frames are found again with it.

View File

@ -218,7 +218,7 @@ pub struct Unit {
/// No other unit depends on it (B pictures of MPEG-1/2 video, non-reference H.264/HEVC).
pub disposable: bool,
pub size: u32,
/// TS: index of the packet holding the first byte; PS: byte offset of its PES packet.
/// TS: byte offset of the packet holding the first byte; PS: byte offset of its PES packet.
pub(crate) pos: u64,
/// Offset of the first byte within that packet's elementary-stream payload.
pub(crate) offset: u32,
@ -345,3 +345,5 @@ impl Unwrap {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod work_tests;

View File

@ -3,9 +3,6 @@
/// A parsed PES header.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct PesHeader {
pub stream_id: u8,
/// PES_packet_length (bytes after the length field; 0 = unbounded, video in TS).
pub packet_length: usize,
/// Bytes from the packet start code to the first payload byte.
pub header_len: usize,
/// Raw 33-bit timestamps.
@ -28,10 +25,8 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
if b.len() < 6 || b[0] != 0 || b[1] != 0 || b[2] != 1 {
return None;
}
let stream_id = b[3];
let packet_length = u16::from_be_bytes([b[4], b[5]]) as usize;
if plain_stream(stream_id) {
return Some(PesHeader { stream_id, packet_length, header_len: 6, pts: None, dts: None });
if plain_stream(b[3]) {
return Some(PesHeader { header_len: 6, pts: None, dts: None });
}
if b.len() >= 9 && b[6] & 0xC0 == 0x80 {
// ISO/IEC 13818-1 syntax
@ -47,7 +42,7 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
dts = Some(timestamp(&b[14..19]));
}
}
return Some(PesHeader { stream_id, packet_length, header_len, pts, dts });
return Some(PesHeader { header_len, pts, dts });
}
// ISO/IEC 11172-1 syntax: stuffing, STD buffer, timestamps
let mut p = 6;
@ -78,7 +73,7 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
0 if c == 0x0F => p += 1,
_ => return None,
}
Some(PesHeader { stream_id, packet_length, header_len: p, pts, dts })
Some(PesHeader { header_len: p, pts, dts })
}
#[cfg(test)]
@ -98,7 +93,7 @@ mod tests {
b.extend(ts_bytes(1, dts));
b.push(0xAA);
let h = parse(&b).unwrap();
assert_eq!((h.stream_id, h.header_len, h.pts, h.dts), (0xE0, 19, Some(pts), Some(dts)));
assert_eq!((h.header_len, h.pts, h.dts), (19, Some(pts), Some(dts)));
// MPEG-1: stuffing, STD buffer, PTS only
let mut b = vec![0, 0, 1, 0xC0, 0, 20, 0xFF, 0xFF, 0x40, 0x20];
b.extend(ts_bytes(2, 90_000));

View File

@ -12,17 +12,33 @@ struct Win<'a, S: ByteSource + ?Sized> {
src: &'a S,
buf: Vec<u8>,
start: u64,
/// Bytes zero-filled by window refills (tests count them).
#[cfg(test)]
zero_filled: usize,
}
impl<'a, S: ByteSource + ?Sized> Win<'a, S> {
fn new(src: &'a S) -> Self {
Self { src, buf: Vec::new(), start: 0 }
Self {
src,
buf: Vec::new(),
start: 0,
#[cfg(test)]
zero_filled: 0,
}
}
/// The bytes [pos, pos + n) (fewer at the end of the file).
fn get(&mut self, pos: u64, n: usize) -> Result<&[u8]> {
let have = pos >= self.start && pos + n as u64 <= self.start + self.buf.len() as u64;
if !have {
let want = n.max(1 << 20);
// never more than the file still holds past `pos`: `read_some` would not fill it, and
// zeroing a megabyte for every packet near the end of the file is wasted work
let remaining = self.src.len().saturating_sub(pos);
let want = (n.max(1 << 20) as u64).min(remaining) as usize;
#[cfg(test)]
{
self.zero_filled += want.saturating_sub(self.buf.len());
}
self.buf.resize(want, 0);
let got = read_some(self.src, pos, &mut self.buf)?;
self.buf.truncate(got);
@ -94,6 +110,9 @@ fn packet_len(b: &[u8]) -> Option<usize> {
let stuff = (*b.get(13)? & 7) as usize;
Some(14 + stuff)
} else if b[4] >> 4 == 2 {
// MPEG-1 pack header: start code, 5-byte SCR, 3-byte mux rate. A file cut inside
// it is not a pack header yet (`scan` reads the SCR from the first 9 bytes).
b.get(..12)?;
Some(12)
} else {
None
@ -116,13 +135,13 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, mpeg1: bool) -> Result<File>
let mut scr: Option<(i64, i64)> = None;
let mut resyncs = 0u64;
while pos + 4 <= len {
let head = win.get(pos, 32)?.to_vec();
let Some(plen) = packet_len(&head) else {
let head = win.get(pos, 32)?;
let Some(plen) = packet_len(head) else {
// resynchronise on the next pack header or PES start code
resyncs += 1;
let mut p = pos + 1;
let found = loop {
let w = win.get(p, 1 << 16)?.to_vec();
let w = win.get(p, 1 << 16)?;
if w.len() < 4 {
break None;
}
@ -159,7 +178,7 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, mpeg1: bool) -> Result<File>
scr = Some(scr.map_or((v, v), |(a, b): (i64, i64)| (a.min(v), b.max(v))));
} else if id == 0xBC {
// program stream map: elementary stream id → stream_type
let body = win.get(pos, plen)?.to_vec();
let body = win.get(pos, plen)?;
if body.len() >= 12 {
let info = u16::from_be_bytes([body[8], body[9]]) as usize;
let mut i = 10 + info;
@ -174,11 +193,11 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, mpeg1: bool) -> Result<File>
}
}
} else if id == 0xBD || (0xC0..=0xEF).contains(&id) || id == 0xFD {
let pkt = win.get(pos, plen)?.to_vec();
let pkt = win.get(pos, plen)?;
if pkt.len() < plen {
break; // truncated
}
if let Some(h) = pes::parse(&pkt) {
if let Some(h) = pes::parse(pkt) {
let payload = &pkt[h.header_len.min(plen)..];
let (sub, skip) = if id == 0xBD {
match payload.first() {
@ -239,14 +258,14 @@ pub(crate) fn read<S: ByteSource + ?Sized>(src: &S, st: &Stream, u: &Unit, out:
let len = src.len();
let mut win = Win::new(src);
while out.len() < need && pos + 4 <= len {
let head = win.get(pos, 32)?.to_vec();
let Some(plen) = packet_len(&head) else {
let head = win.get(pos, 32)?;
let Some(plen) = packet_len(head) else {
// corrupt: stop at what we have
return Ok(());
};
if head[3] == stream_id {
let pkt = win.get(pos, plen)?.to_vec();
if let Some(h) = pes::parse(&pkt) {
let pkt = win.get(pos, plen)?;
if let Some(h) = pes::parse(pkt) {
let payload = &pkt[h.header_len.min(pkt.len())..];
let matches = stream_id != 0xBD || payload.first().copied() == sub_id;
if matches {
@ -266,3 +285,86 @@ pub(crate) fn read<S: ByteSource + ?Sized>(src: &S, st: &Stream, u: &Unit, out:
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The window refill before the zero-fill cap: always `max(n, 1 MiB)` bytes, counted when the
/// buffer has to grow.
struct OldWin<'a> {
src: &'a [u8],
buf: Vec<u8>,
start: u64,
zero_filled: usize,
}
impl OldWin<'_> {
fn get(&mut self, pos: u64, n: usize) -> Vec<u8> {
let have = pos >= self.start && pos + n as u64 <= self.start + self.buf.len() as u64;
if !have {
let want = n.max(1 << 20);
self.zero_filled += want.saturating_sub(self.buf.len());
self.buf.resize(want, 0);
let got = read_some(self.src, pos, &mut self.buf).unwrap();
self.buf.truncate(got);
self.start = pos;
}
let i = (pos - self.start) as usize;
let end = (i + n).min(self.buf.len());
self.buf[i.min(end)..end].to_vec()
}
}
fn data(len: usize) -> Vec<u8> {
(0..len).map(|i| (i * 31 + i / 7) as u8).collect()
}
#[test]
fn capped_window_returns_what_the_uncapped_window_returned() {
for len in [0usize, 1, 31, 32, 33, 1000, (1 << 20) - 1, 1 << 20, (1 << 20) + 5, (3 << 20) + 17] {
let d = data(len);
let mut new = Win::new(d.as_slice());
let mut old = OldWin { src: &d, buf: Vec::new(), start: 0, zero_filled: 0 };
let len64 = len as u64;
let mut positions = vec![
0,
1,
31,
32,
1 << 19,
1 << 20,
(1 << 20) - 1,
(1 << 20) + 1,
2 << 20,
len64.saturating_sub(40),
len64.saturating_sub(1),
len64,
len64 + 1,
len64 + 100,
];
positions.extend((0..40).map(|k| len64.saturating_sub(100) + k * 3));
for &pos in &positions {
for n in [0usize, 1, 4, 32, 33, 1000, 65_541, 1 << 20, (1 << 20) + 1, 2 << 20] {
assert_eq!(new.get(pos, n).unwrap(), old.get(pos, n).as_slice(), "len {len} pos {pos} n {n}");
}
}
}
}
#[test]
fn misses_near_the_end_zero_at_most_the_bytes_that_are_left() {
let len = (2 << 20) + 1000;
let d = data(len);
let mut new = Win::new(d.as_slice());
let mut old = OldWin { src: &d, buf: Vec::new(), start: 0, zero_filled: 0 };
// a PES packet whose length runs past the end of the file, asked for once per packet
let first = (2u64 << 20) as usize;
for k in 0..500 {
let pos = (first + k) as u64;
assert_eq!(new.get(pos, 2000).unwrap(), old.get(pos, 2000).as_slice());
}
assert!(new.zero_filled <= 1000, "zeroed {} bytes, only 1000 are left in the file", new.zero_filled);
assert!(old.zero_filled > 499 * ((1 << 20) - 1000), "the reference zeroes about a megabyte per miss: {}", old.zero_filled);
}
}

View File

@ -276,6 +276,50 @@ fn truncation_and_mutation_never_panic() {
}
}
/// Opens `data` and reads every unit; errors are fine, panics are not.
fn open_and_read_all(data: &[u8]) {
let data = data.to_vec();
if let Ok(f) = open(&data) {
for (s, st) in f.streams.iter().enumerate() {
for i in 0..st.units.len() {
let _ = f.read_unit(&data, s, i);
}
}
}
}
#[test]
fn program_stream_cut_inside_a_pack_header_never_panics() {
// MPEG-1 pack header cut after the 4-byte start code + 3 bytes (once panicked in `scan`).
open_and_read_all(&[0, 0, 1, 0xBA, 0x21, 0, 0]);
for n in 4..=12 {
let mpeg1 = [0u8, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1];
open_and_read_all(&mpeg1[..n]);
}
}
#[test]
fn program_stream_truncation_at_every_length_never_panics() {
let frames: Vec<Vec<u8>> = (0..3).map(|k| mpeg2_frame(if k == 0 { 1 } else { 2 }, k as u16, false, 300)).collect();
let es: Vec<u8> = frames.concat();
for mpeg1 in [false, true] {
let mut out = Vec::new();
for (k, chunk) in es.chunks(500).enumerate() {
if mpeg1 {
out.extend([0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1]);
} else {
out.extend([0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 1, 0x89, 0xC3, 0xF8]);
}
out.extend(pes(0xE0, Some(3600 * k as i64), None, chunk, true));
}
out.extend([0, 0, 1, 0xB9]);
assert!(open(&out).is_ok(), "the untruncated stream opens (mpeg1: {mpeg1})");
for cut in 0..=out.len() {
open_and_read_all(&out[..cut]);
}
}
}
#[test]
fn timestamps_unwrap_across_the_33_bit_wrap() {
let mut u = Unwrap::default();
@ -286,3 +330,99 @@ fn timestamps_unwrap_across_the_33_bit_wrap() {
assert_eq!(u.unwrap(near + 1000), near + 1000);
assert_eq!(u.unwrap(4200), (1 << 33) + 4200);
}
/// A `ByteSource` that counts the bytes requested through `read_at`.
struct Counting<'a> {
data: &'a [u8],
read: std::cell::Cell<u64>,
}
impl ByteSource for Counting<'_> {
fn len(&self) -> u64 {
self.data.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
self.read.set(self.read.get() + buf.len() as u64);
self.data.read_at(offset, buf)
}
}
#[test]
fn bad_adaptation_fields_do_not_amplify_reads() {
// aligned packets whose adaptation_field_length (255) overruns the packet: the packet grid is
// intact, so this must cost one pass over the file, not one block read per packet
let mut data = Vec::new();
for _ in 0..3000 {
data.extend([0x47, 0x11, 0x00, 0x30]);
data.extend(std::iter::repeat_n(0xFF, 184));
}
let src = Counting { data: &data, read: Default::default() };
let f = open(&src).unwrap();
assert!(f.warnings.iter().any(|w| w.contains("adaptation field")), "{:?}", f.warnings);
assert!(!f.warnings.iter().any(|w| w.contains("lost packet sync")), "{:?}", f.warnings);
let read = src.read.get();
assert!(read <= 4 * data.len() as u64, "read {read} bytes of a {}-byte file", data.len());
}
#[test]
fn garbage_without_sync_does_not_amplify_reads() {
// a sync byte every 189 bytes: five-in-a-row never lines up, every 0x47 is a false candidate
let mut data = Vec::new();
for _ in 0..3000 {
data.extend([0x47]);
data.extend(std::iter::repeat_n(0x11, 188));
}
let head: Vec<u8> = (0..188 * 8).map(|i| if i % 188 == 0 { 0x47 } else { 0 }).collect();
let mut all = head;
all.extend(data);
let src = Counting { data: &all, read: Default::default() };
let f = open(&src).unwrap();
assert!(f.warnings.iter().any(|w| w.contains("lost packet sync")), "{:?}", f.warnings);
let read = src.read.get();
assert!(read <= 4 * all.len() as u64, "read {read} bytes of a {}-byte file", all.len());
}
#[test]
fn transport_stream_resyncs_after_garbage() {
const BLOCK_PACKETS: usize = 4096;
for m2ts in [false, true] {
let ps = if m2ts { 192 } else { 188 };
// garbage in front of the stream (shorter than 8 packets) and, at a few places around the
// first block boundary, between two frames; lengths that are not a multiple of the packet
for (delta, glen) in [(0usize, 100usize), (1, 37), (2, 188), (3, 301), (5, 5), (8, 1000)] {
let edge = (BLOCK_PACKETS - delta) * ps;
let mut mux = TsMux::new(m2ts);
mux.out.extend([0x11, 0x47, 0x22]); // garbage prefix with a lone 0x47
mux.psi(&[(0x02, 0x1011)]);
let mut expect = Vec::new();
let mut placed = false;
let mut k = 0usize;
while k < 3000 || !placed {
if !placed && mux.out.len() >= edge {
// garbage between two PES packets, with lone sync bytes inside
let mut g: Vec<u8> = (0..glen).map(|i| 0x10 + (i % 7) as u8).collect();
if let Some(b) = g.get_mut(3) {
*b = 0x47;
}
mux.out.extend(g);
placed = true;
}
let f = mpeg2_frame(if k == 0 { 1 } else { 2 }, (k % 1000) as u16, false, 40 + k % 300);
mux.pes(0x1011, &pes(0xE0, Some(90_000 + 3600 * k as i64), None, &f, false));
expect.push(f);
k += 1;
}
assert!(mux.out.len() > (BLOCK_PACKETS + 4) * ps, "stream must cross a block boundary");
let data = mux.out;
let f = open(&data).unwrap();
assert!(f.warnings.iter().any(|w| w.contains("lost packet sync")), "{:?}", f.warnings);
let v = f.find(Kind::Video, |_| true).unwrap();
let s = &f.streams[v];
assert_eq!(s.units.len(), expect.len(), "m2ts {m2ts} delta {delta} glen {glen}");
for (i, fr) in expect.iter().enumerate() {
assert_eq!(&f.read_unit(&data, v, i).unwrap(), fr, "unit {i} (m2ts {m2ts} delta {delta} glen {glen})");
assert_eq!(s.units[i].pts, Some(90_000 + 3600 * i as i64));
}
}
}
}

View File

@ -6,14 +6,29 @@ use crate::es::Splitter;
use crate::pes::{self, PesHeader};
use crate::{ByteSource, Codec, Error, File, Format, Result, Stream, StreamId, Unit, Unwrap, read_some};
/// Byte-wise table for the MSB-first CRC-32 (polynomial 0x04C11DB7): entry `i` is the CRC register
/// after shifting the byte `i` through eight bit steps.
const CRC_TABLE: [u32; 256] = {
let mut t = [0u32; 256];
let mut i = 0;
while i < 256 {
let mut crc = (i as u32) << 24;
let mut bit = 0;
while bit < 8 {
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
bit += 1;
}
t[i] = crc;
i += 1;
}
t
};
/// CRC-32 of PSI sections (MSB-first, polynomial 0x04C11DB7, initial value all ones; Annex A).
pub(crate) 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 = (crc << 8) ^ CRC_TABLE[((crc >> 24) as u8 ^ b) as usize];
}
crc
}
@ -29,9 +44,19 @@ struct Packet<'a> {
payload: &'a [u8],
}
fn packet(p: &[u8]) -> Option<Packet<'_>> {
/// Why a packet could not be parsed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Bad {
/// No sync byte (or too short): the packet grid is lost.
Sync,
/// Sync byte present but the adaptation field overruns the packet: the grid is intact, the
/// packet carries nothing usable.
Adaptation,
}
fn packet(p: &[u8]) -> std::result::Result<Packet<'_>, Bad> {
if p.len() < 188 || p[0] != 0x47 {
return None;
return Err(Bad::Sync);
}
let pusi = p[1] & 0x40 != 0;
let pid = (((p[1] & 0x1F) as u16) << 8) | p[2] as u16;
@ -44,7 +69,7 @@ fn packet(p: &[u8]) -> Option<Packet<'_>> {
let al = p[4] as usize;
start = 5 + al;
if start > 188 {
return None;
return Err(Bad::Adaptation);
}
if al > 0 {
let flags = p[5];
@ -57,7 +82,7 @@ fn packet(p: &[u8]) -> Option<Packet<'_>> {
}
}
let has_payload = afc & 1 != 0;
Some(Packet { pusi, pid, cc, has_payload, discontinuity, pcr, payload: if has_payload { &p[start..188] } else { &[] } })
Ok(Packet { pusi, pid, cc, has_payload, discontinuity, pcr, payload: if has_payload { &p[start..188] } else { &[] } })
}
/// Reassembles a PES header that may span packets; yields each packet's elementary-stream bytes.
@ -216,6 +241,39 @@ fn first_sync(head: &[u8], packet_size: usize, sync_offset: usize) -> Option<usi
(0..packet_size.min(head.len())).find(|&s| (0..5).all(|k| head.get(s + sync_offset + k * packet_size) == Some(&0x47)))
}
/// Find where packets line up again, searching `block` from byte `from`: the first packet start
/// `i` whose sync byte and the next four packets' sync bytes are all 0x47. Returns `(i, false)`
/// when found. Returns `(i, true)` when the block ran out first: `i` is where to refill from (a
/// candidate whose five-packet window does not fit yet, or the last place a whole packet fits).
/// At the end of the file (`at_end`) a candidate is accepted with as many sync bytes as exist.
fn resync(block: &[u8], from: usize, packet_size: usize, sync_offset: usize, at_end: bool) -> (usize, bool) {
let mut i = from;
while i + packet_size <= block.len() {
if block.get(i + sync_offset) == Some(&0x47) {
let mut aligned = true;
let mut complete = true;
for k in 1..5 {
match block.get(i + sync_offset + k * packet_size) {
Some(&0x47) => {}
Some(_) => {
aligned = false;
break;
}
None => {
complete = false;
break;
}
}
}
if aligned {
return (i, !complete && !at_end);
}
}
i += 1;
}
(i, true)
}
pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_offset: usize) -> Result<File> {
let len = src.len();
let mut head = vec![0u8; packet_size * 8];
@ -236,30 +294,39 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
let (mut lost_sync, mut cc_errors, mut crc_errors) = (0u64, 0u64, 0u64);
const BLOCK: usize = 4096;
let mut block = vec![0u8; BLOCK * packet_size];
let mut bad_adaptation = 0u64;
while off + packet_size as u64 <= len {
let got = read_some(src, off, &mut block)?;
let whole = got / packet_size;
if whole == 0 {
if got < packet_size {
break;
}
let mut k = 0;
while k < whole {
let pos = off + (k * packet_size) as u64;
let raw = &block[k * packet_size..(k + 1) * packet_size];
let Some(pk) = packet(&raw[sync_offset..]) else {
// lost sync: find the next offset where packets line up again
lost_sync += 1;
let rest = &block[k * packet_size + 1..got];
match first_sync(rest, packet_size, sync_offset).or_else(|| rest.iter().position(|&b| b == 0x47).map(|p| p.saturating_sub(sync_offset))) {
Some(s) => {
off = pos + 1 + s as u64;
}
None => off = pos + got as u64,
let at_end = off + got as u64 >= len;
// `p`: byte index of the next packet in `block`. Packets are parsed in place; a lost
// sync is repaired inside the loaded block, and the block is refilled from the file
// (at `off + p`) only when it is used up, so the bytes read stay close to the file size.
let mut p = 0usize;
while p + packet_size <= got {
let pos = off + p as u64;
let raw = &block[p..p + packet_size];
let pk = match packet(&raw[sync_offset..]) {
Ok(pk) => pk,
Err(Bad::Adaptation) => {
// the packet grid is intact: this packet just has no usable payload
bad_adaptation += 1;
p += packet_size;
continue;
}
Err(Bad::Sync) => {
lost_sync += 1;
let (next, exhausted) = resync(&block[..got], p + 1, packet_size, sync_offset, at_end);
p = next;
if exhausted {
break;
}
continue;
}
k = usize::MAX;
break;
};
k += 1;
p += packet_size;
if let Some(pcr) = pk.pcr {
// 33-bit base × 300 wraps at 2^33·300
let wrap = (1i64 << 33) * 300;
@ -362,13 +429,15 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
}
e.split.feed(pos, bytes);
}
if k != usize::MAX {
off += (whole * packet_size) as u64;
}
// `p >= 1` here (the loop ran at least once or `resync` moved past the failed packet)
off += p.max(1) as u64;
}
if lost_sync > 0 {
warnings.push(format!("lost packet sync {lost_sync} time(s)"));
}
if bad_adaptation > 0 {
warnings.push(format!("{bad_adaptation} packet(s) with an invalid adaptation field"));
}
if cc_errors > 0 {
warnings.push(format!("{cc_errors} continuity counter error(s)"));
}
@ -406,7 +475,11 @@ pub(crate) fn read<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
}
for k in 0..whole {
let raw = &block[k * packet_size..(k + 1) * packet_size];
let Some(pk) = packet(&raw[sync_offset..]) else { return Ok(()) };
let pk = match packet(&raw[sync_offset..]) {
Ok(pk) => pk,
Err(Bad::Sync) => return Ok(()),
Err(Bad::Adaptation) => continue,
};
if pk.pid != pid {
continue;
}

View File

@ -0,0 +1,113 @@
//! Equality and counting tests for the redundant-work removals: the old implementation is kept
//! here as the reference, the new one must give the same results.
use super::*;
/// The bitwise CRC-32 that `ts::crc32_mpeg` replaced.
fn crc32_bitwise(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
}
/// Deterministic xorshift bytes.
fn noise(seed: u32, n: usize) -> Vec<u8> {
let mut x = seed | 1;
(0..n)
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
(x >> 8) as u8
})
.collect()
}
#[test]
fn table_crc_equals_the_bitwise_crc() {
// the standard check value of CRC-32/MPEG-2
assert_eq!(ts::crc32_mpeg(b"123456789"), 0x0376_E6E7);
assert_eq!(ts::crc32_mpeg(&[]), 0xFFFF_FFFF);
for b in 0..=255u8 {
assert_eq!(ts::crc32_mpeg(&[b]), crc32_bitwise(&[b]));
}
for n in [0usize, 1, 2, 3, 4, 5, 15, 16, 17, 183, 184, 1023, 1024, 4096] {
for seed in 1..6u32 {
let d = noise(seed * 7919 + n as u32, n);
assert_eq!(ts::crc32_mpeg(&d), crc32_bitwise(&d), "len {n} seed {seed}");
}
}
// a section followed by its own CRC checks to zero
let mut sec = noise(5, 40);
let crc = ts::crc32_mpeg(&sec);
sec.extend(crc.to_be_bytes());
assert_eq!(ts::crc32_mpeg(&sec), 0);
}
/// MPEG-1 layer II frame, 48 kHz 192 kb/s (576 bytes), distinct per `k` and `salt`.
fn mp2_frame(k: usize, salt: u8) -> Vec<u8> {
let mut v = vec![0xFF, 0xFD, 0xA4, 0x00];
v.extend((0..572).map(|i| (i as u8) ^ (k as u8) ^ salt));
v
}
fn pes_packet(stream_id: u8, payload: &[u8]) -> Vec<u8> {
let len = 3 + payload.len();
let mut v = vec![0, 0, 1, stream_id, (len >> 8) as u8, len as u8, 0x80, 0, 0];
v.extend_from_slice(payload);
v
}
/// Two audio streams interleaved in maximal PES packets over more than three window refills,
/// with a pack header per PES packet; MPEG-2 or MPEG-1 pack headers.
fn big_program_stream(mpeg1: bool, frames: usize) -> (Vec<u8>, Vec<Vec<u8>>, Vec<Vec<u8>>) {
let a: Vec<Vec<u8>> = (0..frames).map(|k| mp2_frame(k, 0)).collect();
let b: Vec<Vec<u8>> = (0..frames).map(|k| mp2_frame(k, 0x55)).collect();
let (ea, eb) = (a.concat(), b.concat());
let mut out = Vec::new();
for (ca, cb) in ea.chunks(60_000).zip(eb.chunks(60_000)) {
for (id, chunk) in [(0xC0, ca), (0xC1, cb)] {
if mpeg1 {
out.extend([0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1]);
} else {
out.extend([0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 1, 0x89, 0xC3, 0xF8]);
}
out.extend(pes_packet(id, chunk));
}
}
out.extend([0, 0, 1, 0xB9]);
(out, a, b)
}
#[test]
fn program_stream_with_large_pes_packets_gives_back_every_frame() {
for mpeg1 in [false, true] {
let (data, a, b) = big_program_stream(mpeg1, 2000); // about 1.2 MB per stream
assert!(data.len() > 2 << 20, "more than the 1 MiB window twice");
let f = open(&data).unwrap();
assert_eq!(f.format, Format::Ps { mpeg1 });
assert_eq!(f.streams.len(), 2);
for (s, frames) in f.streams.iter().zip([&a, &b]) {
assert_eq!(s.units.len(), frames.len());
for (i, fr) in frames.iter().enumerate() {
assert_eq!(&f.read_unit(&data, f.streams.iter().position(|x| std::ptr::eq(x, s)).unwrap(), i).unwrap(), fr, "mpeg1 {mpeg1} unit {i}");
}
}
// cut inside the last packets: never panics, and the units before the cut are intact
for cut in [data.len() - 1, data.len() - 4, data.len() - 30_000, data.len() - 61_000, 100_000, 1 << 20] {
let d = &data[..cut];
let Ok(f) = open(d) else { continue };
for (si, frames) in [&a, &b].into_iter().enumerate() {
let Some(s) = f.streams.get(si) else { continue };
for i in 0..s.units.len().saturating_sub(1) {
assert_eq!(f.read_unit(d, si, i).unwrap(), frames[i], "cut {cut} stream {si} unit {i}");
}
}
}
}
}

View File

@ -97,9 +97,10 @@ fn changed_in_band_parameter_sets_switch_to_software() {
let out = decode_all(&mut d, &same);
assert!(d.is_hardware());
assert_same("repeated SPS", &out, &decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &same));
// a different SPS (same values, trailing zero byte) at the second sync sample: software
// a different SPS (same values, another nal_ref_idc: a trailing zero byte is not a change) at
// the second sync sample: software
let mut changed_sps = sps.clone();
changed_sps.push(0);
changed_sps[0] ^= 0x40;
let k = (1..s.samples.len()).find(|&i| s.sync[i]).unwrap();
let changed: Vec<_> = s.samples.iter().enumerate().map(|(i, (x, p))| (if i == k { with_sps(&changed_sps, x) } else { x.clone() }, *p)).collect();
let mut d = hybrid(&s, usize::MAX);

View File

@ -156,6 +156,26 @@ fn motion_and_masks_restrict_the_region() {
assert!(near(img.get(280, 90), [1.0, 0.0, 0.0], 1e-4));
}
#[test]
fn a_transform_replaces_the_picture_below() {
// Premiere: Transform on an adjustment layer moves the composite below; it is not a copy
// drawn over the untouched original (#1524).
let mut s = scene(2);
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
place(&mut s.p, s.seq, 0, red);
let adj = adjustment(&mut s.p, s.seq, 1);
fx(&mut s, adj, "transform");
let tf = clip(&mut s, adj).effects.iter_mut().find(|e| e.effect == "transform").unwrap();
tf.params.get_mut("scale_height").unwrap().value = ParamValue::Float(50.0);
let img = render(&s);
assert!(near(img.get(160, 90), [1.0, 0.0, 0.0], 1e-4), "{:?}", img.get(160, 90));
assert_eq!(img.get(10, 10), [0.0; 4], "the original is gone outside the scaled picture");
// at 50 % opacity: half the original, half the (empty) transformed picture
clip(&mut s, adj).effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().value = ParamValue::Float(50.0);
let c = render(&s).get(10, 10);
assert!((c[0] - 0.5).abs() < 1e-3 && (c[3] - 0.5).abs() < 1e-3, "{c:?}");
}
#[test]
fn nested_adjustment_layers_stay_in_their_sequence() {
let mut s = scene(2);
@ -184,3 +204,121 @@ fn nested_adjustment_layers_stay_in_their_sequence() {
};
assert_eq!(img.get(5, 5), plain.get(5, 5));
}
#[test]
fn transform_shutter_on_an_adjustment_layer_spans_one_sequence_frame() {
// A 60 fps sequence: at 360° the shutter is one 60 fps frame, so the frames just outside
// the move stay sharp (they were blurred with a 24 fps shutter, #1088).
use filmcraft_project::{Interpolation, Keyframe};
let r60 = FrameRate::FPS_60;
let mut s = scene(2);
s.p.sequence_mut(s.seq).unwrap().settings.frame_rate = r60;
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
let below = place(&mut s.p, s.seq, 0, red);
clip(&mut s, below).effect_mut("motion").unwrap().params.get_mut("scale").unwrap().value = ParamValue::Float(25.0);
let adj = adjustment(&mut s.p, s.seq, 1);
fx(&mut s, adj, "transform");
let key = |sec: f64, x: f64| {
let mut k = Keyframe::new(Tick::from_seconds_f64(sec), ParamValue::Vec2(filmcraft_geom::Vec2::new(x, 90.0)));
k.interp = Interpolation::Linear;
k
};
let e = clip(&mut s, adj).effect_mut("transform").unwrap();
e.params.get_mut("shutter_angle").unwrap().value = ParamValue::Float(360.0);
e.params.get_mut("position").unwrap().keyframes = vec![key(0.5, 160.0), key(1.0, 260.0)];
let mut diff = |frame: i64| {
let mut at = |shutter: bool| {
clip(&mut s, adj).effect_mut("transform").unwrap().params.get_mut("shutter_override").unwrap().value = ParamValue::Bool(shutter);
render_sequence(&s.p, s.seq, r60.tick_of(frame), RenderOptions::default(), &s.map)
};
let (sharp, blurred) = (at(false), at(true));
sharp.px.iter().zip(&blurred.px).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max)
};
assert!(diff(29) < 1e-4, "frame before the move is blurred");
assert!(diff(61) < 1e-4, "frame after the move is blurred");
assert!(diff(45) > 1e-2, "moving frame is sharp");
}
fn dissolve(s: &mut Scene, track: usize, from: Option<ClipId>, to: Option<ClipId>, start: i64) {
let tr = filmcraft_project::Transition {
id: filmcraft_project::TransitionId(900 + start as u64),
effect: find_effect("cross_dissolve").unwrap().instance(),
start: R.tick_of(start),
duration: R.tick_of(12),
from,
to,
align: Default::default(),
reverse: false,
};
s.p.sequence_mut(s.seq).unwrap().video_tracks[track].transitions.push(tr);
}
fn at(s: &Scene, frame: i64) -> [f32; 4] {
render_sequence(&s.p, s.seq, R.tick_of(frame), RenderOptions::default(), &s.map).get(160, 90)
}
/// A dissolve at the start of an adjustment layer (Cmd+D) fades its effects in, and one at its
/// end fades them out, instead of leaving them off for the transition and cutting in after it.
#[test]
fn a_dissolve_fades_the_adjustment_layers_effects_in_and_out() {
let mut s = scene(2);
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
place(&mut s.p, s.seq, 0, red);
let adj = adjustment(&mut s.p, s.seq, 1);
fx(&mut s, adj, "invert");
dissolve(&mut s, 1, None, Some(adj), 0);
dissolve(&mut s, 1, Some(adj), None, 36);
// fade in over frames 0..12: red → halfway → cyan
assert!(near(at(&s, 0), [1.0, 0.0, 0.0], 1e-3), "{:?}", at(&s, 0));
assert!(near(at(&s, 3), [0.75, 0.25, 0.25], 1e-3), "a quarter in: {:?}", at(&s, 3));
assert!(near(at(&s, 6), [0.5, 0.5, 0.5], 1e-3), "halfway in: {:?}", at(&s, 6));
assert!(near(at(&s, 24), [0.0, 1.0, 1.0], 1e-3), "{:?}", at(&s, 24));
// fade out over frames 36..48
assert!(near(at(&s, 42), [0.5, 0.5, 0.5], 1e-3), "halfway out: {:?}", at(&s, 42));
assert!(near(at(&s, 45), [0.75, 0.25, 0.25], 1e-3), "three quarters out: {:?}", at(&s, 45));
}
/// The layer's own opacity and blend mode still count during the fade.
#[test]
fn a_dissolve_on_an_adjustment_layer_keeps_its_opacity_and_blend_mode() {
let mut s = scene(2);
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
place(&mut s.p, s.seq, 0, red);
let adj = adjustment(&mut s.p, s.seq, 1);
fx(&mut s, adj, "invert");
clip(&mut s, adj).effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().value = ParamValue::Float(50.0);
dissolve(&mut s, 1, None, Some(adj), 0);
// halfway through the fade, at 50 % opacity: a quarter of the way to cyan
assert!(near(at(&s, 6), [0.75, 0.25, 0.25], 1e-3), "{:?}", at(&s, 6));
// Multiply (red × cyan = black) fades from red to black
let op = clip(&mut s, adj).effect_mut("opacity").unwrap();
op.params.get_mut("opacity").unwrap().value = ParamValue::Float(100.0);
op.params.get_mut("blend").unwrap().value =
ParamValue::Choice(filmcraft_project::effect::BLEND_MODES.iter().position(|b| *b == "Multiply").unwrap() as u32);
assert!(near(at(&s, 6), [0.5, 0.0, 0.0], 1e-3), "{:?}", at(&s, 6));
assert!(near(at(&s, 24), [0.0, 0.0, 0.0], 1e-3), "{:?}", at(&s, 24));
}
/// A frame where a transition touches an adjustment layer is composited on the CPU, which knows
/// how to fade one; the layered (GPU) plan does not.
#[test]
fn a_transition_with_an_adjustment_layer_is_not_planned_as_layers() {
let mut s = scene(2);
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
let blue = matte(&mut s.p, &mut s.map, [0.0, 0.0, 1.0, 1.0]);
place(&mut s.p, s.seq, 0, red);
// V2: a blue clip from 0 to 24, then an adjustment layer from 24, a dissolve centred on the cut
let b = place(&mut s.p, s.seq, 1, blue);
clip(&mut s, b).duration = R.tick_of(24);
let a = s.p.add_item("Adjustment Layer", Label::Lavender, ItemKind::AdjustmentLayer { width: 320, height: 180, rate: R, duration: R.tick_of(48) }, None);
let mut ti = s.p.make_track_item(a, TrackKind::Video, R.tick_of(24), TimeRange::new(Tick::ZERO, R.tick_of(24)), R).unwrap();
ti.effects.insert(0, find_effect("invert").unwrap().instance());
let ac = ti.id;
s.p.sequence_mut(s.seq).unwrap().video_tracks[1].items.push(ti);
dissolve(&mut s, 1, Some(b), Some(ac), 18);
let q = s.p.sequence(s.seq).unwrap();
// frame 20 is still on the blue clip, but the dissolve into the adjustment layer shows
assert!(!crate::plan::layered_at(&s.p, q, R.tick_of(20)));
// halfway (frame 24): half blue, half the inverted red (cyan)
assert!(near(at(&s, 24), [0.0, 0.5, 1.0], 1e-3), "{:?}", at(&s, 24));
}

View File

@ -109,9 +109,11 @@ pub(crate) fn track_input_at(
if !tr.range().overlaps(&range) {
continue;
}
// Re-mix: recompute the covered samples with crossfade gains.
let from = tr.from.and_then(|id| track.item(id));
let to = tr.to.and_then(|id| track.item(id));
// Re-mix: recompute the covered samples with crossfade gains. Both sides play over the
// whole transition, into their handles as the picture does, so a transition centred on a
// cut overlaps the two clips instead of only fading each on its own side of the cut.
let from = tr.from.and_then(|id| track.item(id)).map(|i| with_handles(i, tr.start, tr.end()));
let to = tr.to.and_then(|id| track.item(id)).map(|i| with_handles(i, tr.start, tr.end()));
let s0 = tr.start.to_units_floor(sr as i64).max(start);
let s1 = tr.end().to_units_floor(sr as i64).min(start + frames as i64);
if s1 <= s0 {
@ -124,10 +126,10 @@ pub(crate) fn track_input_at(
}
let mut a = AudioBuffer::silence(sr, w, n);
let mut b = AudioBuffer::silence(sr, w, n);
if let Some(f) = from {
if let Some(f) = &from {
mix_item(project, f, s0, n, sr, sources, &mut a, 1.0, &live);
}
if let Some(tt) = to {
if let Some(tt) = &to {
mix_item(project, tt, s0, n, sr, sources, &mut b, 1.0, &live);
}
let dur = (tr.end() - tr.start).to_units_floor(sr as i64).max(1) as f32;
@ -143,6 +145,25 @@ pub(crate) fn track_input_at(
tbuf
}
/// `item` lengthened to cover `[t0, t1)` with its media timing unchanged: the part before its in
/// point and after its out point plays the source's handles (silent where the source has none).
/// Remixed clips have no linear media to extend and stay as they are.
fn with_handles(item: &TrackItem, t0: Tick, t1: Tick) -> TrackItem {
let mut ext = item.clone();
if crate::remix::is_remixed(item) {
return ext;
}
let head = (item.start - t0).max(Tick::ZERO);
let tail = (t1 - item.end()).max(Tick::ZERO);
ext.start = item.start - head;
ext.duration = item.duration + head + tail;
// forward media time is source_in + (t - start)·speed; reversed it counts down from the out
// point, so the extra head (forward) or tail (reverse) moves source_in back
let shift = if item.reverse { tail } else { head };
ext.source_in = item.source_in - Tick((shift.0 as f64 * item.speed.abs()).round() as i64);
ext
}
/// Held Clip Mixer controls of one track.
#[derive(Default)]
struct ClipLive {

View File

@ -192,6 +192,7 @@ fn graded_stats(img: &Image, base: &EffectInstance, x: &[f32; 10], skin_protect:
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut g, &set_params(base, x), &cx);
@ -361,6 +362,7 @@ mod tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut g, &set_params(base, &x), &cx);

View File

@ -29,6 +29,8 @@ pub struct FxCtx<'a> {
/// The clip's surroundings (other frames, other tracks, sequence geometry) for temporal,
/// track-reading and reframing effects; `None` in isolated tests and adjustment layers.
pub env: Option<&'a dyn crate::vfx::FxEnv>,
/// Sequence frame rate (shutter-angle motion blur); also known where `env` is `None`.
pub frame_rate: f64,
/// The sequence's working colour space: Lumetri grades HDR (PQ / HLG) working spaces in their
/// own signal, normalised to HDR White ([`filmcraft_color::GradeSpace`]).
pub working: filmcraft_color::WorkingSpace,
@ -934,6 +936,7 @@ mod tests {
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
@ -1529,6 +1532,7 @@ mod lumetri_cpu_parity_tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
@ -1568,6 +1572,7 @@ mod lumetri_cpu_parity_tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
let render = |roundness: f64| {

View File

@ -355,7 +355,9 @@ impl FxOp {
}
}
"sharpen" => {
let r = cx.px_scale.max(0.35);
// below a sigma of ~0.58 the three boxes all round to radius 0 and the mask is a
// no-op, so Sharpen vanished at 1/2 playback resolution and below (#1351)
let r = cx.px_scale.max(0.6);
let (rx, ry) = gaussian_boxes(w, h, r, r);
FxOp::Unsharp { rx, ry, amount: f(e, "amount", cx) / 100.0, threshold: 0.0 }
}
@ -1113,6 +1115,7 @@ mod tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
// Both sides of each cutoff, including samples inside the former artificial fade.

View File

@ -189,14 +189,25 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
if let Some(tr) = track.transitions.iter().find(|tr| tr.range().contains(t)) {
let a = tr.from.and_then(|id| track.item(id)).filter(|i| i.enabled);
let b = tr.to.and_then(|id| track.item(id)).filter(|i| i.enabled);
let la = a
.and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc))
.map(|(img, op, _)| with_opacity(img, op))
.unwrap_or_else(|| Image::new(w, h));
let lb = b
.and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc))
.map(|(img, op, _)| with_opacity(img, op))
.unwrap_or_else(|| Image::new(w, h));
// An adjustment layer has no picture of its own: in a transition it is the frame below
// with its effects, so a dissolve fades the effects in or out.
let layer = |i: &TrackItem, below: &Image| -> Option<(Image, Blend)> {
if is_adjustment(project, i) {
return opts.effects.then(|| adjustment_layer(project, seq, i, t, opts, &tc, below));
}
item_layer(project, seq, i, t, opts, sources, &tc).map(|(img, op, bl)| (with_opacity(img, op), bl))
};
let below = canvas.snapshot();
let la = a.and_then(|i| layer(i, &below));
let lb = b.and_then(|i| layer(i, &below));
// a fade from or to nothing keeps the one layer's blend mode (an adjustment layer in
// Multiply ends the fade as it carries on); between two layers they mix normally
let bl = match (&la, &lb) {
(Some((_, x)), None) | (None, Some((_, x))) => *x,
_ => Blend::Normal,
};
let la = la.map(|l| l.0).unwrap_or_else(|| Image::new(w, h));
let lb = lb.map(|l| l.0).unwrap_or_else(|| Image::new(w, h));
let p = tr.progress(t) as f32;
// Reverse plays the transition backwards (e.g. an iris closing on the outgoing clip)
// while still going from A to B.
@ -205,48 +216,27 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
} else {
transitions::apply_scaled(&tr.effect, &la, &lb, p, opts.scale)
};
blend::composite(canvas.get(), &mixed, 1.0, Blend::Normal);
blend::composite(canvas.get(), &mixed, 1.0, bl);
continue;
}
let Some(item) = track.item_at(t) else { continue };
if !item.enabled {
continue;
}
let pi = project.item(item.item);
if let Some(pi) = pi
&& matches!(pi.kind, ItemKind::AdjustmentLayer { .. })
{
if is_adjustment(project, item) {
if !opts.effects {
continue;
}
let mt = item.effect_time_at(t);
let mut adjusted = canvas.snapshot();
let cx = effects::FxCtx {
t: mt,
px_scale: opts.scale,
seconds: (t - item.start).seconds(),
timecode: &tc,
clip_name: &item.name,
project: Some(project),
env: None,
working: seq.settings.color.working,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
mask::apply_effect(&mut adjusted, e, &cx);
let below = canvas.snapshot();
let (adj, bl) = adjustment_layer(project, seq, item, t, opts, &tc, &below);
if bl == Blend::Normal {
// The adjusted picture already replaces the original (see `adjustment_layer`).
canvas.set(adj);
} else {
let mut out = below;
blend::composite(&mut out, &adj, 1.0, bl);
canvas.set(out);
}
let (op, bl) = opacity_blend(item, mt);
// Adjustment layer opacity (and opacity masks, in sequence pixels) mix adjusted over original.
let mut out = canvas.snapshot();
let mut adj = adjusted;
adj.scale_alpha(op);
mask::apply_opacity_masks(&mut adj, item, mt, opts.scale);
// Motion moves / scales the adjustment layer's frame: it only applies inside it.
if let Some(region) = adjustment_region(seq, item, project, mt, opts.scale, w, h) {
let cov: Vec<f32> = region.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
mask::scale_by(&mut adj, &cov);
}
blend::composite(&mut out, &adj, 1.0, bl);
canvas.set(out);
continue;
}
match item_layer_ex(project, seq, item, t, opts, sources, &tc, true) {
@ -279,6 +269,51 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
canvas
}
fn is_adjustment(project: &Project, item: &TrackItem) -> bool {
project.item(item.item).is_some_and(|pi| matches!(pi.kind, ItemKind::AdjustmentLayer { .. }))
}
/// An adjustment layer as a layer over `below` (the composite of the tracks under it): `below`
/// with the layer's effects, faded by its opacity and opacity masks and limited to its
/// (Motion-transformed) frame, and the blend mode it mixes with.
fn adjustment_layer(project: &Project, seq: &Sequence, item: &TrackItem, t: Tick, opts: RenderOptions, tc: &str, below: &Image) -> (Image, Blend) {
let (w, h) = (below.w, below.h);
let mt = item.effect_time_at(t);
let mut adj = below.clone();
let cx = effects::FxCtx {
t: mt,
px_scale: opts.scale,
seconds: (t - item.start).seconds(),
timecode: tc,
clip_name: &item.name,
project: Some(project),
env: None,
frame_rate: seq.settings.frame_rate.as_f64(),
working: seq.settings.color.working,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
mask::apply_effect(&mut adj, e, &cx);
}
let (op, bl) = opacity_blend(item, mt);
// Adjustment layer opacity (and opacity masks, in sequence pixels) weigh adjusted against original.
let mut weight = Image::filled(w, h, [op; 4]);
mask::apply_opacity_masks(&mut weight, item, mt, opts.scale);
// Motion moves / scales the adjustment layer's frame: it only applies inside it.
if let Some(region) = adjustment_region(seq, item, project, mt, opts.scale, w, h) {
let cov: Vec<f32> = region.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
mask::scale_by(&mut weight, &cov);
}
let cov: Vec<f32> = weight.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
if bl == Blend::Normal {
// The adjusted picture replaces the original instead of being drawn over it: what
// Transform or Basic 3D leave empty shows nothing of the untransformed picture (#1524).
mask::mix(&mut adj, below, &cov);
} else {
mask::scale_by(&mut adj, &cov);
}
(adj, bl)
}
/// Coverage of an adjustment layer's (Motion-transformed) frame in output pixels, or `None` when
/// it covers the whole output.
fn adjustment_region(seq: &Sequence, item: &TrackItem, project: &Project, mt: Tick, scale: f32, w: usize, h: usize) -> Option<Image> {
@ -483,6 +518,7 @@ pub(crate) fn item_layer_ex(
clip_name: &item.name,
project: Some(project),
env: Some(&env),
frame_rate: seq.settings.frame_rate.as_f64(),
working: seq.settings.color.working,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e)) {

View File

@ -11,7 +11,7 @@ use crate::effects::{FxCtx, apply};
const RW: f32 = REFERENCE_WHITE_NITS as f32;
fn cx(working: WorkingSpace) -> FxCtx<'static> {
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working }
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, frame_rate: 24.0, working }
}
fn lumetri(params: &[(&str, ParamValue)]) -> EffectInstance {

View File

@ -228,6 +228,7 @@ pub fn thumbnail(preset: &LumetriPreset, w: usize, h: usize) -> Image {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut img, &preset.instance(), &cx);

View File

@ -7,6 +7,7 @@ use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, Medi
use filmcraft_project::mixer::{LANE_MUTE, LANE_PAN, LANE_VOLUME, send_lane};
use filmcraft_project::{
AudioChannels, AutomationMode, ItemId, ItemKind, Keyframe, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackKind, TrackSend,
Transition, TransitionId,
};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
@ -406,6 +407,49 @@ fn audio_transition_curves() {
}
}
/// A transition centred on a cut mixes both clips across its whole length: the outgoing clip plays
/// on past its out point and the incoming one starts before its in point (their handles), as the
/// picture does, so the two overlap instead of each just dipping at the cut. The incoming source is
/// a ramp, so its handle must come from the media just before its in point.
#[test]
fn audio_transition_on_a_cut_crossfades_through_the_handles() {
fn ramp(i: i64, _: usize) -> f32 {
i as f32 * 1e-6
}
let mut r = Rig::new(&[dc1, ramp]);
let (a_src, b_src) = (r.track(0).items[0].item, r.track(1).items[0].item);
let sec = |s: f64| Tick::from_seconds_f64(s);
let a = r.p.make_track_item(a_src, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, sec(1.0)), FrameRate::FPS_24).unwrap();
let b = r.p.make_track_item(b_src, TrackKind::Audio, sec(1.0), TimeRange::new(sec(2.0), sec(1.0)), FrameRate::FPS_24).unwrap();
let effect = filmcraft_project::vtransition::find_transition("constant_gain", filmcraft_project::EffectKind::AudioTransition).unwrap().instance();
let tr = Transition {
id: TransitionId(1),
effect,
start: sec(0.75),
duration: sec(0.5),
from: Some(a.id),
to: Some(b.id),
align: Default::default(),
reverse: false,
};
r.track(1).items.clear();
let t0 = r.track(0);
t0.items = vec![a, b];
t0.transitions.push(tr);
// the incoming clip plays media sample 96 000 at the cut (sample 48 000)
let b_at = |s: i64| ramp(s + 48_000, 0);
// constant gain at p = 0.25, 0.5 (the cut), 0.75: (1 - p)·A + p·B
for (s, p) in [(42_000, 0.25), (48_000, 0.5), (54_000, 0.75)] {
let want = (1.0 - p) * 0.1 + p * b_at(s);
let [l, rr] = r.mix(s, 1);
assert!(close(l[0], want, 1e-5) && close(rr[0], want, 1e-5), "sample {s}: {} (want {want})", l[0]);
}
// outside the transition each clip plays alone, at full level
for (s, want) in [(35_000, 0.1), (61_000, b_at(61_000))] {
assert!(close(r.mix(s, 1)[0][0], want, 1e-6), "sample {s}");
}
}
// ------------------------------------------------------------------------------------- 5.1
/// Distinct DC level per source channel: 0.1, 0.2, … (L, R, C, LFE, Ls, Rs for 6-channel sources).

View File

@ -228,20 +228,24 @@ fn whole_frame_on_cpu(project: &Project, seq: &Sequence, t: Tick) -> bool {
}
/// Whether the frame of `seq` at `t` can be planned as layers: no adjustment layer and no
/// transition the compositor cannot mix itself is showing.
fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool {
/// transition the compositor cannot mix itself is showing (a transition into or out of an
/// adjustment layer counts, also on the side of the cut where the other clip is).
pub(crate) fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool {
let adjustment = |it: &TrackItem| project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. }));
for tr in &seq.video_tracks {
if !tr.enabled {
continue;
}
if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t))
&& !simple_transition(&trn.effect.effect)
&& trn.effect.effect != "push"
{
return false;
if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t)) {
if !simple_transition(&trn.effect.effect) && trn.effect.effect != "push" {
return false;
}
if [trn.from, trn.to].into_iter().flatten().filter_map(|c| tr.item(c)).any(adjustment) {
return false;
}
}
if let Some(it) = tr.item_at(t)
&& project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. }))
&& adjustment(it)
{
return false;
}
@ -434,6 +438,7 @@ fn push_item(
clip_name: &item.name,
project: Some(project),
env: None,
frame_rate: seq.settings.frame_rate.as_f64(),
working: seq.settings.color.working,
};
let ops: Option<Vec<FxOp>> = chain.iter().map(|e| FxOp::eval(e, &cx, lw, lh).filter(FxOp::gpu_ok)).collect();

View File

@ -39,7 +39,7 @@
//! size and rate match the sequence. Green (a valid preview exists) is decided by the preview
//! store, which knows which hashes have files.
use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, Transition};
use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, TrackKind, Transition};
use filmcraft_time::{FrameRate, Tick};
use serde::Serialize;
@ -166,7 +166,7 @@ pub fn video_segments(project: &Project, seq_id: ItemId) -> Vec<Segment> {
match l {
Layer::Item(ti, it) => {
h.json(&("item", ti));
hash_item(&mut h, project, it, t, 0);
hash_item(&mut h, project, it, t, TrackKind::Video, 0);
absolute |= prints_time(it);
clips.push(it.id);
}
@ -180,7 +180,7 @@ pub fn video_segments(project: &Project, seq_id: ItemId) -> Vec<Segment> {
for side in [a, b] {
match side {
Some(it) => {
hash_item(&mut h, project, it, t, 0);
hash_item(&mut h, project, it, t, TrackKind::Video, 0);
absolute |= prints_time(it);
clips.push(it.id);
}
@ -277,7 +277,7 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec<AudioSegment> {
for side in [x.from, x.to] {
match side.and_then(|id| tr.item(id)).filter(|i| i.enabled) {
Some(it) => {
hash_item(&mut h, project, it, t, 0);
hash_item(&mut h, project, it, t, TrackKind::Audio, 0);
heard = true;
}
None => h.json(&"none"),
@ -285,7 +285,7 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec<AudioSegment> {
}
} else if let Some(it) = tr.item_at(t).filter(|i| i.enabled) {
h.json(&("item", ti));
hash_item(&mut h, project, it, t, 0);
hash_item(&mut h, project, it, t, TrackKind::Audio, 0);
heard = true;
}
}
@ -300,7 +300,9 @@ fn prints_time(it: &TrackItem) -> bool {
it.effects.iter().any(|e| e.enabled && e.effect == "timecode")
}
fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, depth: u32) {
/// Hash a clip and its source as heard (`kind` Audio) or seen (`kind` Video): a nested sequence
/// contributes only the tracks of that kind, so editing its picture keeps its sound's previews.
fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, kind: TrackKind, depth: u32) {
let mut c = it.clone();
c.id = Default::default();
c.start -= origin;
@ -312,10 +314,10 @@ fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, de
c.name.clear();
}
h.json(&c);
hash_source(h, project, it.item, depth);
hash_source(h, project, it.item, kind, depth);
}
fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) {
fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, kind: TrackKind, depth: u32) {
let Some(pi) = project.item(id) else {
h.json(&"missing");
return;
@ -338,18 +340,34 @@ fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) {
}
ItemKind::Subclip { parent, range, .. } => {
h.json(&("subclip", range));
hash_source(h, project, *parent, depth + 1);
hash_source(h, project, *parent, kind, depth + 1);
}
ItemKind::AdjustmentLayer { .. } => h.json(&("adjustment", &pi.kind)),
ItemKind::Graphic { .. } => h.json(&("graphic", &pi.kind)),
ItemKind::Sequence(s) => {
h.json(&("sequence", &s.settings));
if kind == TrackKind::Audio {
// the nest's sound is the nested sequence's whole mix (see `audio_segments`)
h.json(&("mix", s.master_volume_db, &s.master_effects, &s.master_mixer, &s.submix_tracks, &s.multicam));
for (ti, tr) in s.audio_tracks.iter().enumerate() {
h.json(&("track", ti, tr.muted, tr.solo, tr.volume_db, tr.pan, tr.channels, &tr.effects, &tr.mixer, tr.items.len()));
for it in &tr.items {
hash_item(h, project, it, Tick::ZERO, kind, depth + 1);
}
for x in &tr.transitions {
let mut xc = x.clone();
xc.id = Default::default();
h.json(&xc);
}
}
return;
}
// a nested sequence's captions are drawn into its picture
h.json(&("captions", &s.caption_tracks));
for (ti, tr) in s.video_tracks.iter().enumerate() {
h.json(&("track", ti, tr.enabled, tr.items.len()));
for it in &tr.items {
hash_item(h, project, it, Tick::ZERO, depth + 1);
hash_item(h, project, it, Tick::ZERO, kind, depth + 1);
}
for x in &tr.transitions {
let mut xc = x.clone();

View File

@ -5,7 +5,7 @@ use filmcraft_media::{DemoScene, Generator, MediaSource};
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackKind, find_effect};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
use crate::preview::{Need, Segment, effect_cost_ms, segment_at, video_segments};
use crate::preview::{Need, Segment, audio_segments, effect_cost_ms, segment_at, video_segments};
fn add_gen(p: &mut Project, g: GeneratorSource) -> ItemId {
let info = g.info().clone();
@ -234,6 +234,7 @@ fn effect_costs() {
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut img, &e, &cx);
@ -248,3 +249,54 @@ fn effect_costs() {
println!("{ms:8.2} ms {id:24} tier {:.0}", effect_cost_ms(id));
}
}
/// Put `item` on track 0 of `kind` in `seq` from frame `start_f` for `dur_f` frames.
fn place_kind(p: &mut Project, seq: ItemId, kind: TrackKind, item: ItemId, start_f: i64, dur_f: i64) -> ClipId {
let r = FrameRate::FPS_24;
let ti = p.make_track_item(item, kind, r.tick_of(start_f), TimeRange::new(Tick::ZERO, r.tick_of(dur_f)), r).unwrap();
let id = ti.id;
let t = &mut p.sequence_mut(seq).unwrap().tracks_mut(kind)[0];
t.items.push(ti);
t.sort();
id
}
fn audio_hashes(p: &Project, seq: ItemId) -> Vec<String> {
audio_segments(p, seq).into_iter().map(|g| g.hash).collect()
}
#[test]
fn a_nests_audio_previews_follow_the_sound_of_its_sequence() {
// Inner has a picture clip and a sound clip; Outer nests Inner (picture and sound), and Top
// nests Outer's sound
let (mut p, matte, ocean, inner) = setup();
let pic = place_kind(&mut p, inner, TrackKind::Video, matte, 0, 48);
let snd = place_kind(&mut p, inner, TrackKind::Audio, ocean, 0, 48);
let settings = p.sequence(inner).unwrap().settings.clone();
let outer = p.new_sequence("outer", settings.clone(), 1, 1, None);
place_kind(&mut p, outer, TrackKind::Video, inner, 24, 48);
place_kind(&mut p, outer, TrackKind::Audio, inner, 24, 48);
let top = p.new_sequence("top", settings, 1, 1, None);
place_kind(&mut p, top, TrackKind::Audio, outer, 12, 72);
let (audio, picture, deep) = (audio_hashes(&p, outer), video_segments(&p, outer), audio_hashes(&p, top));
assert_eq!(audio.len(), 1);
// the sound inside the nest changes: the nest's audio preview is stale, its picture is not
p.sequence_mut(inner).unwrap().find_item_mut(snd).unwrap().1.gain_db = -12.0;
assert_ne!(audio_hashes(&p, outer), audio, "a clip's gain inside the nest");
assert_ne!(audio_hashes(&p, top), deep, "a clip's gain two nests down");
assert_eq!(bounds(&video_segments(&p, outer)), bounds(&picture));
assert_eq!(by_frame(&video_segments(&p, outer), 30), by_frame(&picture, 30), "the picture did not change");
let (audio, deep) = (audio_hashes(&p, outer), audio_hashes(&p, top));
// the nested sequence's track mix counts too
p.sequence_mut(inner).unwrap().audio_tracks[0].muted = true;
assert_ne!(audio_hashes(&p, outer), audio, "a muted track inside the nest");
p.sequence_mut(inner).unwrap().audio_tracks[0].muted = false;
assert_eq!(audio_hashes(&p, outer), audio);
// the picture inside the nest changes: its audio preview stays valid
add_fx(&mut p, inner, pic, "tint");
assert_ne!(by_frame(&video_segments(&p, outer), 30), by_frame(&picture, 30));
assert_eq!(audio_hashes(&p, outer), audio, "a video effect inside the nest");
assert_eq!(audio_hashes(&p, top), deep);
}

View File

@ -233,7 +233,7 @@ pub fn rotate_3d(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
/// Transform: affine place, with optional shutter-angle motion blur when the override is on.
pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let fps = cx.env.map_or(24.0, |env| env.frame_rate()).max(1.0);
let fps = cx.frame_rate.max(1.0);
let angle = fv(e, "shutter_angle", cx);
let n = if bv(e, "shutter_override") && angle > 0.5 { 7 } else { 1 };
let span = (angle as f64 / 360.0) / fps;
@ -247,6 +247,7 @@ pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
clip_name: cx.clip_name,
project: cx.project,
env: cx.env,
frame_rate: cx.frame_rate,
working: cx.working,
};
let anchor = crate::effects::point(e, "anchor", &cx2, im);

View File

@ -103,6 +103,7 @@ impl FxEnv for ItemEnv<'_> {
clip_name: &self.item.name,
project: Some(self.project),
env: None,
frame_rate: self.seq.settings.frame_rate.as_f64(),
working: self.seq.settings.color.working,
};
for e in &self.item.effects {

View File

@ -17,6 +17,7 @@ fn cx() -> FxCtx<'static> {
clip_name: "clip",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
@ -1154,3 +1155,31 @@ mod pipeline {
assert!(e.px.iter().all(|v| v.is_finite()));
}
}
#[test]
fn transform_shutter_uses_the_sequence_frame_rate_without_env() {
// An adjustment layer has no env: the shutter must still span one 60 fps frame at 360°, so
// the frames just before the motion starts and just after it ends stay sharp (#1088).
let mut e = inst("transform");
set(&mut e, "shutter_override", ParamValue::Bool(true));
setf(&mut e, "shutter_angle", 360.0);
let mut a = Keyframe::new(Tick::from_seconds_f64(1.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)));
a.interp = Interpolation::Linear;
let mut b = Keyframe::new(Tick::from_seconds_f64(2.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(40.0, 16.0)));
b.interp = Interpolation::Linear;
e.params.insert("position".into(), Param { value: ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)), keyframes: vec![a, b] });
let img = picture(32, 32);
let mut off = e.clone();
set(&mut off, "shutter_override", ParamValue::Bool(false));
let delta = |frame: i32| {
let s = f64::from(frame) / 60.0;
let cx = FxCtx { t: Tick::from_seconds_f64(s), seconds: s, frame_rate: 60.0, ..cx() };
max_diff(&run("transform", &off, &img, &cx), &run("transform", &e, &img, &cx))
};
for frame in [59, 121] {
assert!(delta(frame) < 1e-4, "frame {frame} is blurred outside the motion ({})", delta(frame));
}
for frame in [60, 90, 120] {
assert!(delta(frame) > 1e-3, "frame {frame} moves but is sharp");
}
}

View File

@ -17,6 +17,7 @@ fn cx() -> FxCtx<'static> {
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}

View File

@ -18,6 +18,7 @@ fn render_noise(effect_id: &str, rate: FrameRate, frame: i64, amount: f64) -> Ve
clip_name: "generated matte",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut image, &effect, &context);

View File

@ -0,0 +1,66 @@
//! Sharpen must stay visible at reduced playback resolution (#1351).
//!
//! Playback at 1/2 renders with `px_scale` 0.5. Sharpen's blur radius scaled with it to a sigma the
//! three-box Gaussian rounds to zero-width boxes, so the unsharp mask subtracted the image from
//! itself and the effect vanished while playing, though it showed on the paused (Full) frame.
use filmcraft_project::{ParamValue, find_effect};
use filmcraft_render::effects::{FxCtx, apply};
use filmcraft_render::image::Image;
use filmcraft_time::Tick;
fn cx(px_scale: f32) -> FxCtx<'static> {
FxCtx {
t: Tick::ZERO,
px_scale,
seconds: 0.0,
timecode: "00:00:01:00",
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
/// Dark left half, light right half: the edge sits between x = 7 and x = 8.
fn edge() -> Image {
let (w, h) = (16usize, 8usize);
let mut img = Image::filled(w, h, [0.25, 0.25, 0.25, 1.0]);
for y in 0..h {
for x in w / 2..w {
let i = (y * w + x) * 4;
img.px[i..i + 3].copy_from_slice(&[0.75, 0.75, 0.75]);
}
}
img
}
fn sharpened(px_scale: f32) -> Image {
let mut img = edge();
let mut e = find_effect("sharpen").expect("sharpen effect").instance();
e.params.get_mut("amount").expect("amount").value = ParamValue::Float(100.0);
apply(&mut img, &e, &cx(px_scale));
img
}
#[test]
fn sharpen_shows_at_half_and_quarter_playback_resolution() {
let plain = edge();
for px_scale in [0.5, 0.25] {
let img = sharpened(px_scale);
let (dark, light) = (img.get(7, 4)[0], img.get(8, 4)[0]);
assert!(dark < plain.get(7, 4)[0] - 0.01, "px_scale {px_scale}: the dark side of the edge should darken (got {dark})");
assert!(light > plain.get(8, 4)[0] + 0.01, "px_scale {px_scale}: the light side of the edge should brighten (got {light})");
}
}
#[test]
fn sharpen_at_full_resolution_is_unchanged() {
// values rendered before the fix: the Full (paused) frame must not change
let img = sharpened(1.0);
for (x, want) in [(6, 0.25), (7, 0.25 - 1.0 / 6.0), (8, 0.75 + 1.0 / 6.0), (9, 0.75)] {
let got = img.get(x, 4)[0];
assert!((got - want).abs() < 1e-5, "x {x}: got {got}, want {want}");
}
}

View File

@ -16,7 +16,9 @@ pub mod oracle;
pub mod wav;
pub use fixtures::{fixtures_dir, temp_path, workspace_root};
pub use oracle::{ffmpeg, ffmpeg_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip};
pub use oracle::{
ffmpeg, ffmpeg_or_skip, ffmpeg_supports_format, ffmpeg_version, ffmpeg_with_format_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip,
};
/// `let ff = require_ffmpeg!();` — the ffmpeg path, or return from the test after printing
/// `SKIPPED: … ffmpeg not found` (a hard failure with `FILMCRAFT_REQUIRE_ORACLES=1`).

View File

@ -121,6 +121,59 @@ pub fn ffprobe_or_skip(context: &str) -> Option<PathBuf> {
tool_or_skip("ffprobe", ffprobe(), context)
}
/// Whether `ffmpeg -formats` output lists `format_name` (e.g. `apv`, new in
/// ffmpeg 8.0). Pure function of the command output; matches a whole
/// whitespace-separated token so `APV` in a description cannot match.
fn formats_output_lists(output: &str, format_name: &str) -> bool {
output.lines().any(|line| line.split_whitespace().any(|token| token == format_name))
}
/// Whether `ff` can read `format_name`. An ffmpeg that cannot run (or prints
/// nothing usable) counts as unsupported; the caller skips rather than fails.
pub fn ffmpeg_supports_format(ff: &Path, format_name: &str) -> bool {
let Ok(out) = std::process::Command::new(ff).args(["-hide_banner", "-formats"]).output() else {
return false;
};
if !out.status.success() {
return false;
}
formats_output_lists(&String::from_utf8_lossy(&out.stdout), format_name)
}
/// The `ffmpeg -version` version string in banner output (`6.1.1-3ubuntu5`,
/// `8.0.1`, …), if the first line is an ffmpeg banner. Pure function of the
/// command output.
fn ffmpeg_version_in(output: &str) -> Option<String> {
let first = output.lines().next()?;
let rest = first.strip_prefix("ffmpeg version")?.trim_start();
let version: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
if version.is_empty() { None } else { Some(version) }
}
/// The `ffmpeg -version` version string (`6.1.1-3ubuntu5`, `8.0.1`, …), if `ff`
/// reports one. `None` when the tool cannot run or its banner is unexpected.
pub fn ffmpeg_version(ff: &Path) -> Option<String> {
let out = std::process::Command::new(ff).arg("-version").output().ok()?;
if !out.status.success() {
return None;
}
ffmpeg_version_in(&String::from_utf8_lossy(&out.stdout))
}
/// ffmpeg that can read `format_name`, or `None` after reporting a skip (see
/// [`skip`]). An ffmpeg predating the format (APV needs 8.0+) skips with its
/// detected version named, exactly like a missing tool — and fails under
/// `FILMCRAFT_REQUIRE_ORACLES=1`.
pub fn ffmpeg_with_format_or_skip(context: &str, format_name: &str, min_version: &str) -> Option<PathBuf> {
let ff = ffmpeg_or_skip(context)?;
if ffmpeg_supports_format(&ff, format_name) {
return Some(ff);
}
let version = ffmpeg_version(&ff).map_or_else(|| "unknown version".to_string(), |v| format!("version {v}"));
skip(context, &format!("ffmpeg {version} cannot read {format_name} (needs {min_version}+)"));
None
}
#[cfg(test)]
mod tests {
use super::*;
@ -167,4 +220,38 @@ mod tests {
assert_eq!(got, Some(d.join("ffmpeg")));
assert_eq!(find_tool_in("ffmpeg-does-not-exist", None, None, &[ds.as_str()]).unwrap(), None);
}
const FORMATS_WITH_APV: &str =
"File formats:\n D mov QuickTime / MOV\n DE apv APV raw bitstream\n D mp4 MP4 (MPEG-4 Part 14)\n";
const FORMATS_WITHOUT_APV: &str =
"File formats:\n D mov QuickTime / MOV\n D mp4 MP4 (MPEG-4 Part 14)\n D oapv Obsolete APV alias\n";
#[test]
fn formats_listing_detects_a_supported_format() {
assert!(formats_output_lists(FORMATS_WITH_APV, "apv"));
// No apv entry: neither a longer name containing it (oapv) nor an
// uppercase description mention (APV) counts as support.
assert!(!formats_output_lists(FORMATS_WITHOUT_APV, "apv"));
assert!(!formats_output_lists("", "apv"));
}
#[test]
fn version_string_parses_ffmpeg_banner() {
assert_eq!(ffmpeg_version_in("ffmpeg version 7:6.1.1-3ubuntu5 Copyright (c) 2000-2023"), Some("7:6.1.1-3ubuntu5".to_string()));
assert_eq!(ffmpeg_version_in("ffmpeg version 8.0.1-full_build-www.gyan.dev xxx"), Some("8.0.1-full_build-www.gyan.dev".to_string()));
assert_eq!(ffmpeg_version_in(""), None);
assert_eq!(ffmpeg_version_in("ffprobe version 6.1"), None);
assert_eq!(ffmpeg_version_in("ffmpeg version "), None);
}
#[test]
fn live_ffmpeg_reports_its_own_apv_support() {
let Some(ff) = ffmpeg() else { return };
let supported = ffmpeg_supports_format(&ff, "apv");
assert_eq!(
supported,
formats_output_lists(&String::from_utf8_lossy(&std::process::Command::new(&ff).args(["-hide_banner", "-formats"]).output().unwrap().stdout), "apv")
);
assert!(ffmpeg_version(&ff).is_some_and(|v| !v.is_empty()));
}
}

View File

@ -860,12 +860,18 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t
ui.ctx().data_mut(|d| d.remove::<bool>(list_id));
}
}
// clicking anywhere in the panel focuses it
// Activating presses focus their tab immediately, rather than the group's old active panel.
// Otherwise entering an inspector can briefly reactivate a stale Project/Timeline selection.
// Middle-click closes a tab, so it keeps the active panel as the focus target.
if let Some(p) = active_panel
&& ui.rect_contains_pointer(g.rect)
&& ui.input(|i| i.pointer.any_pressed())
{
actions.push(DockAction::Focus(p));
let panel = ui
.input(|i| i.pointer.interact_pos().filter(|_| i.pointer.primary_pressed() || i.pointer.secondary_pressed()))
.and_then(|pos| tab_rects.iter().find(|(_, rect)| rect.contains(pos)).map(|(panel, _)| *panel))
.unwrap_or(p);
actions.push(DockAction::Focus(panel));
}
Chrome { actions, tabs: tab_rects }
}

View File

@ -1,6 +1,6 @@
//! Convert the persisted workspace schema to shared docking transactions.
use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Node};
use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Location, Node, Placement, Zone};
use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize};
use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize};
@ -96,6 +96,24 @@ pub(crate) fn apply_tree(root: &mut DockNode, action: Action<PanelKind>) -> bool
true
}
/// Dock a panel along one edge of the whole workspace. The shared model restores a panel beside a
/// complete sibling subtree, so naming every remaining docked panel targets the root itself.
pub(crate) fn dock_at_edge(layout: &mut SharedLayout<PanelKind>, panel: PanelKind, zone: Zone) -> Result<(), String> {
let size = match zone {
Zone::Left | Zone::Top => SharedSplitSize::Ratio(0.25),
Zone::Right | Zone::Bottom => SharedSplitSize::Ratio(0.75),
Zone::Center => return Err("a workspace edge needs a left, right, top or bottom zone".into()),
};
let mut proposed = layout.clone();
proposed.apply(Action::Close { panel }).map_err(|error| error.to_string())?;
let sibling: Vec<PanelKind> = SharedLayout { root: proposed.root.clone(), floating: vec![] }.panels().into_iter().copied().collect();
let anchor = *sibling.first().ok_or("another docked panel is required to dock beside")?;
let location = Location { floating: None, anchor: Some(anchor), placement: Placement::Split(zone), size: Some(size), stack: None, sibling };
proposed.restore(panel, &location).map_err(|error| error.to_string())?;
*layout = proposed;
Ok(())
}
/// Keep one docked destination available for dragging floating panels back into the workspace.
pub(crate) fn check_last_docked(layout: &SharedLayout<PanelKind>, action: &Action<PanelKind>) -> Result<(), String> {
let panel = match action {

View File

@ -48,7 +48,6 @@ pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, param
Action::Float { panel, rect }
}
_ => {
let anchor = params.get("anchor").and_then(Value::as_str).and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?;
let zone_name = match params.get("zone") {
None => "center",
Some(value) => value.as_str().ok_or("zone must be a string")?,
@ -61,6 +60,14 @@ pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, param
"center" | "tab" => Zone::Center,
_ => return Err("unknown docking zone".into()),
};
let Some(anchor) = params.get("anchor") else {
if params.get("before").is_some() {
return Err("before needs an anchor panel".into());
}
app.dock_to_edge(panel, zone)?;
return Ok(json!({"panel": panel.id()}));
};
let anchor = anchor.as_str().and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?;
let placement = match params.get("before") {
Some(value) => Placement::Tab { before: Some(value.as_str().and_then(PanelKind::from_name).ok_or("unknown before panel")?) },
None => Placement::Split(zone),
@ -90,14 +97,10 @@ impl FilmcraftApp {
Action::Activate { panel } => Some(*panel),
_ => None,
};
let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?;
shared.validate().map_err(|error| error.to_string())?;
crate::dock_shared::check_last_docked(&shared, &action)?;
shared.apply(action).map_err(|error| error.to_string())?;
let layout = crate::dock_shared::decode(shared)?;
crate::dock_shared::encode(&layout.root, &layout.floating)?;
self.ui.dock = layout.root;
self.ui.floating = layout.floating;
self.commit_dock(|shared| {
crate::dock_shared::check_last_docked(shared, &action)?;
shared.apply(action).map_err(|error| error.to_string())
})?;
if let Some(panel) = reveal {
self.pending_panel_raise = Some(panel);
}
@ -109,6 +112,24 @@ impl FilmcraftApp {
Ok(())
}
/// Dock a panel along an edge of the whole workspace rather than beside one group.
pub fn dock_to_edge(&mut self, panel: PanelKind, zone: Zone) -> Result<(), String> {
self.commit_dock(|shared| crate::dock_shared::dock_at_edge(shared, panel, zone))?;
self.ui.keys.maximized = None;
Ok(())
}
fn commit_dock(&mut self, change: impl FnOnce(&mut craft_ui::docking::Layout<PanelKind>) -> Result<(), String>) -> Result<(), String> {
let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?;
shared.validate().map_err(|error| error.to_string())?;
change(&mut shared)?;
let layout = crate::dock_shared::decode(shared)?;
crate::dock_shared::encode(&layout.root, &layout.floating)?;
self.ui.dock = layout.root;
self.ui.floating = layout.floating;
Ok(())
}
fn panel_body(&mut self, ui: &mut egui::Ui, panel: PanelKind, content: Rect) {
self.auto.add(&format!("panel.{}", panel.id()), content, panel.title());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", panel.id())));
@ -256,7 +277,7 @@ impl FilmcraftApp {
}
}
}
self.panel_drag(&ctx, &targets);
self.panel_drag(&ctx, body, &targets);
panels::panel_menu_popup(self, ui);
self.cancel_value_drag(ui.ctx());
}
@ -317,7 +338,7 @@ impl FilmcraftApp {
output
}
fn panel_drag(&mut self, ctx: &egui::Context, targets: &[DropTarget<PanelKind>]) {
fn panel_drag(&mut self, ctx: &egui::Context, body: Rect, targets: &[DropTarget<PanelKind>]) {
let Some(panel) = ctx.data(|data| data.get_temp::<PanelKind>(drag_id())) else { return };
let canceled =
ctx.input(|input| input.key_pressed(egui::Key::Escape) || !input.focused || (!input.pointer.primary_down() && !input.pointer.primary_released()));
@ -327,9 +348,14 @@ impl FilmcraftApp {
}
let Some(pos) = ctx.pointer_latest_pos() else { return };
let force_float = ctx.input(|input| input.modifiers.command);
let proposal = (!force_float).then(|| resolve_drop(&panel, pos, targets)).flatten();
let edge = (!force_float).then(|| workspace_edge(body, pos, targets)).flatten();
let proposal = (!force_float && edge.is_none()).then(|| resolve_drop(&panel, pos, targets)).flatten();
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
let t = self.tokens;
if let Some((_, _, preview)) = edge {
painter.rect_filled(preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2));
painter.rect_stroke(preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
}
if let Some(proposal) = &proposal {
painter.rect_filled(proposal.preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2));
painter.rect_stroke(proposal.preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
@ -345,6 +371,12 @@ impl FilmcraftApp {
ctx.request_repaint();
if ctx.input(|input| input.pointer.primary_released()) {
ctx.data_mut(|data| data.remove::<PanelKind>(drag_id()));
if let Some((_, zone, _)) = edge {
if crate::menus::invoke(self, ctx, "window.dockPanel", json!({"panel": panel.id(), "zone": zone})).is_ok() {
self.ui.focused = panel;
}
return;
}
let action = match proposal {
Some(proposal) => Some(Action::Move { panel, anchor: proposal.anchor, placement: proposal.placement }),
None if force_float || !targets.iter().any(|target| target.rect.contains(pos)) => {
@ -361,6 +393,36 @@ impl FilmcraftApp {
}
}
/// Width of the band along the workspace border that docks beside the whole layout.
const EDGE_BAND: f32 = 8.0;
/// The outermost drop zone: near the workspace border a drop splits the root, not the group
/// under the pointer. A floating group over the border (it rejects splits) keeps the drop.
fn workspace_edge(body: Rect, pos: egui::Pos2, targets: &[DropTarget<PanelKind>]) -> Option<(Zone, &'static str, Rect)> {
if !body.is_finite() || !body.contains(pos) || targets.iter().any(|target| !target.allow_splits && target.rect.contains(pos)) {
return None;
}
let band = EDGE_BAND.min(body.width() / 4.0).min(body.height() / 4.0);
let edges = [
(pos.x - body.left(), Zone::Left, "left"),
(body.right() - pos.x, Zone::Right, "right"),
(pos.y - body.top(), Zone::Top, "top"),
(body.bottom() - pos.y, Zone::Bottom, "bottom"),
];
let (distance, zone, name) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?;
if distance > band {
return None;
}
let size = body.size() * 0.25;
let preview = match zone {
Zone::Left => Rect::from_min_size(body.min, vec2(size.x, body.height())),
Zone::Right => Rect::from_min_max(pos2(body.right() - size.x, body.top()), body.max),
Zone::Top => Rect::from_min_size(body.min, vec2(body.width(), size.y)),
Zone::Bottom | Zone::Center => Rect::from_min_max(pos2(body.left(), body.bottom() - size.y), body.max),
};
Some((zone, name, preview))
}
fn target(group: &Group, mut tabs: Vec<(PanelKind, Rect)>, allow_splits: bool) -> Option<DropTarget<PanelKind>> {
let strip = Rect::from_min_max(group.rect.min, pos2(group.rect.max.x, group.content.min.y));
// A compact grip still identifies the source group to the shared resolver.

View File

@ -248,6 +248,8 @@
OMF… OMF…
Offline File… Offline-Datei…
Open Project… Projekt öffnen…
Open Recent Zuletzt geöffnete Dateien
Clear Recent Files Zuletzt geöffnete Dateien leeren
OpenTimelineIO… OpenTimelineIO…
Optical Flow Optischer Fluss
Overwrite Überschreiben

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

View File

@ -230,6 +230,9 @@
Normalize Mix Track… Normalizar pista de mezcla…
Offline File… Archivo sin conexión…
Open Project… Abrir proyecto…
Open Recent Archivos recientes
Clear Recent Files Vaciar archivos recientes
Recent Recientes
Optical Flow Flujo óptico
Overwrite Sobrescribir
Paste Pegar
@ -2922,6 +2925,10 @@
Sample Size Tamaño de muestra
16 bit 16 bits
24 bit 24 bits
32 bit 32 bits
32 bit float 32 bits de coma flotante
FLAC stores whole-number samples only FLAC solo guarda muestras enteras
MXF export writes integer PCM only La exportación MXF solo escribe PCM entero
Size / Opacity Tamaño / opacidad
Image Overlay Superposición de imagen
PNG or JPEG file Archivo PNG o JPEG
@ -3592,3 +3599,9 @@
Export the labels to a preset file… Exportar las etiquetas a un archivo de ajuste preestablecido…
Show the presets folder Mostrar la carpeta de ajustes preestablecidos
Delete this preset Eliminar este ajuste preestablecido
Opens in your web browser: {url} Se abre en el navegador web: {url}
Saves this session's log and shows it in your file manager. Guarda el registro de esta sesión y lo muestra en el explorador de archivos.
Shows a report on this computer. Muestra un informe sobre este equipo.
Shows the version, community links and credits in a FilmCraft window. Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft.
Couldn't open your web browser No se pudo abrir el navegador web
Open this address in your browser yourself: Abre esta dirección tú mismo en el navegador:

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

View File

@ -232,6 +232,9 @@
Normalize Mix Track… Normaliser la piste de mixage…
Offline File… Fichier hors ligne…
Open Project… Ouvrir un projet…
Open Recent Ouvrir un fichier récent
Clear Recent Files Effacer la liste des fichiers récents
Recent Récents
Optical Flow Flux optique
Overwrite Écraser
Paste Coller
@ -2922,6 +2925,10 @@
Sample Size Taille d'échantillon
16 bit 16 bits
24 bit 24 bits
32 bit 32 bits
32 bit float 32 bits à virgule flottante
FLAC stores whole-number samples only FLAC ne stocke que des échantillons entiers
MXF export writes integer PCM only L’export MXF n’écrit que du PCM entier
Size / Opacity Taille / opacité
Image Overlay Superposition d'image
PNG or JPEG file Fichier PNG ou JPEG

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

View File

@ -237,6 +237,9 @@
Normalize Mix Track… Normalisasi Trek Mix…
Offline File… Berkas Offline…
Open Project… Buka Proyek…
Open Recent Terakhir Dibuka
Clear Recent Files Hapus Daftar Berkas Terakhir
Recent Terbaru
Optical Flow Aliran Optik
Overwrite Timpa
Paste Tempel
@ -2927,6 +2930,10 @@
Sample Size Ukuran Sampel
16 bit 16 bit
24 bit 24 bit
32 bit 32 bit
32 bit float 32 bit titik mengambang
FLAC stores whole-number samples only FLAC hanya menyimpan sampel bilangan bulat
MXF export writes integer PCM only Ekspor MXF hanya menulis PCM bilangan bulat
Size / Opacity Ukuran / Opasitas
Image Overlay Overlay Gambar
PNG or JPEG file Berkas PNG atau JPEG

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

View File

@ -231,6 +231,9 @@
Normalize Mix Track… ミックストラックをノーマライズ…
Offline File… オフラインファイル…
Open Project… プロジェクトを開く…
Open Recent 最近使ったファイルを開く
Clear Recent Files 最近使ったファイルを消去
Recent 最近
Optical Flow オプティカルフロー
Overwrite 上書き
Paste 貼り付け
@ -2911,6 +2914,10 @@
Sample Size サンプルサイズ
16 bit 16 ビット
24 bit 24 ビット
32 bit 32 ビット
32 bit float 32 ビット浮動小数点
FLAC stores whole-number samples only FLAC は整数サンプルのみ保存できます
MXF export writes integer PCM only MXF 書き出しは整数 PCM のみです
Size / Opacity サイズ / 不透明度
Image Overlay 画像のオーバーレイ
PNG or JPEG file PNG または JPEG ファイル
@ -3594,3 +3601,9 @@
Export the labels to a preset file… ラベルをプリセットファイルに書き出し…
Show the presets folder プリセットフォルダーを表示
Delete this preset このプリセットを削除
Opens in your web browser: {url} ウェブブラウザーで開きます:{url}
Saves this session's log and shows it in your file manager. このセッションのログを保存し、ファイルマネージャーで表示します。
Shows a report on this computer. このコンピューターのレポートを表示します。
Shows the version, community links and credits in a FilmCraft window. バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します。
Couldn't open your web browser ウェブブラウザーを開けませんでした
Open this address in your browser yourself: このアドレスをご自身でブラウザーで開いてください:

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

View File

@ -858,11 +858,14 @@ mod tests {
fn language_commands_switch_and_persist_without_a_document() {
let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
let ctx = egui::Context::default();
crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::Ja);
let saved = serde_json::to_string(&app.ui).unwrap();
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Language::Ja);
// Japanese needs an installed font (see `japanese_needs_an_installed_font`); skip that step without one
if craft_japanese_font() || system_japanese_font().is_some() {
crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::Ja);
let saved = serde_json::to_string(&app.ui).unwrap();
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Language::Ja);
}
crate::menus::invoke(&mut app, &ctx, "app.language.spanish", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::Es);
let prefs = serde_json::to_string(&app.session.prefs).unwrap();

View File

@ -234,6 +234,9 @@
Normalize Mix Track… Normalizar faixa de mixagem…
Offline File… Arquivo offline…
Open Project… Abrir projeto…
Open Recent Abrir recentes
Clear Recent Files Limpar arquivos recentes
Recent Recentes
Optical Flow Fluxo óptico
Overwrite Sobrescrever
Paste Colar
@ -2924,6 +2927,10 @@
Sample Size Tamanho da amostra
16 bit 16 bits
24 bit 24 bits
32 bit 32 bits
32 bit float 32 bits de ponto flutuante
FLAC stores whole-number samples only O FLAC só armazena amostras inteiras
MXF export writes integer PCM only A exportação MXF só grava PCM inteiro
Size / Opacity Tamanho / opacidade
Image Overlay Sobreposição de imagem
PNG or JPEG file Arquivo PNG ou JPEG

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

View File

@ -221,6 +221,8 @@
Normalize Mix Track… Нормализовать мастер-дорожку…
Offline File… Автономный файл…
Open Project… Открыть проект…
Open Recent Открыть недавние
Clear Recent Files Очистить список недавних файлов
Optical Flow Оптический поток
Overwrite Перезаписать
Paste Вставить

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -222,6 +222,8 @@
Normalize Mix Track… Нормалізувати доріжку міксу…
Offline File… Автономний файл…
Open Project… Відкрити проєкт…
Open Recent Нещодавні файли
Clear Recent Files Очистити список нещодавніх файлів
Optical Flow Оптичний потік
Overwrite Перезаписати
Paste Вставити

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -225,6 +225,9 @@
Normalize Mix Track… 标准化混音轨道…
Offline File… 脱机文件…
Open Project… 打开项目…
Open Recent 打开最近文件
Clear Recent Files 清除最近文件
Recent 最近使用
Optical Flow 光流
Overwrite 覆盖
Paste 粘贴
@ -2905,6 +2908,10 @@
Sample Size 采样大小
16 bit 16 位
24 bit 24 位
32 bit 32 位
32 bit float 32 位浮点
FLAC stores whole-number samples only FLAC 只能存储整数采样
MXF export writes integer PCM only MXF 导出仅写入整数 PCM
Size / Opacity 大小 / 不透明度
Image Overlay 图像叠加
PNG or JPEG file PNG 或 JPEG 文件
@ -3570,3 +3577,9 @@
Export the labels to a preset file… 将标签导出为预设文件…
Show the presets folder 显示预设文件夹
Delete this preset 删除此预设
Opens in your web browser: {url} 在网页浏览器中打开:{url}
Saves this session's log and shows it in your file manager. 保存本次会话的日志并在文件管理器中显示。
Shows a report on this computer. 显示本机的报告。
Shows the version, community links and credits in a FilmCraft window. 在 FilmCraft 窗口中显示版本、社区链接和致谢。
Couldn't open your web browser 无法打开网页浏览器
Open this address in your browser yourself: 请自行在浏览器中打开此地址:

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

View File

@ -302,6 +302,12 @@ pub struct FilmcraftApp {
deferred: Vec<(ControlRequest, f64)>,
last_ui_time: f64,
pub(crate) synthetic: Vec<egui::Event>,
/// macOS: a Control+Click already translated to a secondary (context) click, so its release is
/// translated too even if Control is let go mid-click. AppKit delivers Control+Click to the
/// view as a *primary* press with the Control modifier (`rightMouseDown:` only fires for a
/// right button or two-finger click), so the app does the platform's translation itself.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mac_ctrl_click_armed: bool,
/// The mouse press in progress (its start time) and the undo history when it began, for Escape
/// to take back what the press changed (#580).
value_press: Option<(f64, filmcraft_engine::UndoMark)>,
@ -555,6 +561,7 @@ impl FilmcraftApp {
deferred: Vec::new(),
last_ui_time: 0.0,
synthetic: Vec::new(),
mac_ctrl_click_armed: false,
value_press: None,
loudness: None,
status_seen: (String::new(), 0.0),
@ -1433,6 +1440,15 @@ impl FilmcraftApp {
self.menu_sent = items;
}
/// Whether keyboard focus is on a dock divider or a floating panel's resize grip, as drawn last pass.
fn divider_focused(&self, ctx: &egui::Context) -> bool {
let Some(focused) = ctx.memory(|m| m.focused()) else { return false };
self.auto
.previous
.iter()
.any(|e| e.response_id == Some(focused) && (e.id.starts_with("dock.gutter.") || (e.id.starts_with("panel.float.") && e.id.ends_with(".resize"))))
}
fn handle_shortcuts(&mut self, ctx: &egui::Context) {
let workspaces = (dock::names(&self.workspaces), self.ui.workspace.clone());
if self.bindings_rev != self.session.shortcuts.revision || workspaces != self.menu_workspaces {
@ -1440,7 +1456,10 @@ impl FilmcraftApp {
self.bindings = menus::bindings(self);
self.bindings_rev = self.session.shortcuts.revision;
}
if ctx.egui_wants_keyboard_input() || self.dialog == Some(Dialog::Shortcuts) {
// A pane divider or floating-panel grip keeps focus after a pointer drag so arrows can
// nudge it, but it takes no other key: Space and the other shortcuts keep working (#1482).
let divider_focused = self.divider_focused(ctx);
if (ctx.egui_wants_keyboard_input() && !divider_focused) || self.dialog == Some(Dialog::Shortcuts) {
return;
}
// A focused text field takes every unmodified key, including the ones our shortest
@ -1453,6 +1472,7 @@ impl FilmcraftApp {
if self.session.trim_play.dynamic.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
let _ = self.session.execute("trim.cancelDynamic", json!({}));
}
panels::masks::pen_take_back(self, ctx, text_focused);
// Panel shortcuts of the focused panel override application shortcuts on the same chord.
// egui ignores extra Shift and Alt when matching, so the most specific chords go first
// across both scopes (the sort is stable, so the panel still wins a tie): otherwise the
@ -1498,6 +1518,10 @@ impl FilmcraftApp {
if lets_text_field_keep(*m, text_focused) {
continue;
}
// The focused divider consumes the arrows itself (egui matches them with any Shift/Alt).
if divider_focused && matches!(k, egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown) {
continue;
}
if i.consume_key(*m, *k) {
fire.push(id.clone());
}
@ -1693,6 +1717,7 @@ impl FilmcraftApp {
}
let ctx = ui.ctx().clone();
self.auto.begin_frame();
panels::metadata::follow_selection(self);
self.frames.set_context(&ctx);
self.session.poll_persistence();
panels::trim_monitor::advance(self, &ctx);
@ -1937,9 +1962,44 @@ impl FilmcraftApp {
}
}
/// On macOS a Control+Click means "right click": AppKit delivers it to the view as a *primary*
/// press carrying the Control modifier (`rightMouseDown:` only fires for a real right button or
/// two-finger click), so the app translates it to a secondary click before egui computes pointer
/// state. `armed` pairs the press with its release, so the release is still a context click when
/// Control is let go mid-click. Called from [`eframe::App::raw_input_hook`], which runs before
/// egui's pass; only wired in on macOS, where Ctrl+Click is not a selection modifier.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn synthesize_mac_ctrl_click(armed: &mut bool, raw_input: &mut egui::RawInput) {
for event in &mut raw_input.events {
match event {
egui::Event::PointerButton { button, pressed, modifiers, .. } if *button == egui::PointerButton::Primary => {
if *pressed {
if modifiers.ctrl {
*button = egui::PointerButton::Secondary;
*armed = true;
} else {
// A plain primary press ends any pairing left open (eg. the pointer left
// the window mid-click, which egui also forgets).
*armed = false;
}
} else if *armed {
*button = egui::PointerButton::Secondary;
*armed = false;
}
}
egui::Event::PointerGone => *armed = false,
_ => {}
}
}
}
impl eframe::App for FilmcraftApp {
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
self.native_window_focused = Some(raw_input.focused);
#[cfg(target_os = "macos")]
if !self.control_input_only {
synthesize_mac_ctrl_click(&mut self.mac_ctrl_click_armed, raw_input);
}
if self.control_input_only {
// Preserve screenshot responses and all viewport lifecycle/geometry metadata.
raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. }));
@ -2210,6 +2270,96 @@ mod clipboard_key_tests {
}
}
#[cfg(test)]
mod mac_ctrl_click_tests {
use egui::{Event, Modifiers, PointerButton, RawInput};
fn click(button: PointerButton, pressed: bool, ctrl: bool) -> Event {
Event::PointerButton { pos: egui::pos2(40.0, 12.0), button, pressed, modifiers: if ctrl { Modifiers::CTRL } else { Modifiers::NONE } }
}
fn buttons(raw: &mut RawInput) -> Vec<(PointerButton, bool)> {
raw.events
.iter()
.filter_map(|e| match e {
Event::PointerButton { button, pressed, .. } => Some((*button, *pressed)),
_ => None,
})
.collect()
}
/// AppKit delivers Control+Click as a primary press with the Control modifier; it must reach
/// egui as a secondary click so `secondary_clicked()` (the context menus) fires on macOS.
#[test]
fn ctrl_click_is_a_secondary_click() {
let mut raw = RawInput { events: vec![click(PointerButton::Primary, true, true), click(PointerButton::Primary, false, true)], ..Default::default() };
let mut armed = false;
super::synthesize_mac_ctrl_click(&mut armed, &mut raw);
assert!(!armed);
assert_eq!(buttons(&mut raw), vec![(PointerButton::Secondary, true), (PointerButton::Secondary, false)]);
}
/// End to end: the rewritten events are what egui's pointer state sees, so the context
/// menus (`secondary_clicked()`) fire on a Control+Click exactly like on a real right click.
#[test]
fn synthesized_click_fires_secondary_clicked() {
let ctx = egui::Context::default();
let mut raw = RawInput { events: vec![click(PointerButton::Primary, true, true), click(PointerButton::Primary, false, true)], ..Default::default() };
let mut armed = false;
super::synthesize_mac_ctrl_click(&mut armed, &mut raw);
let mut secondary = false;
// No renderer here: drop the pass output, clearing its texture deltas so epaint's drop
// guard does not panic on the font texture.
let mut out = ctx.run_ui(raw, |ctx| secondary = ctx.input(|i| i.pointer.secondary_clicked()));
out.textures_delta.clear();
assert!(secondary);
}
/// Releasing Control before the mouse button still closes the click as a context click; the
/// mouse release alone must not become a stray primary click.
#[test]
fn release_is_paired_to_the_ctrl_press() {
let mut raw = RawInput {
events: vec![
click(PointerButton::Primary, true, true),
click(PointerButton::Primary, false, false), // Control already released
],
..Default::default()
};
let mut armed = false;
super::synthesize_mac_ctrl_click(&mut armed, &mut raw);
assert!(!armed);
assert_eq!(buttons(&mut raw), vec![(PointerButton::Secondary, true), (PointerButton::Secondary, false)]);
}
/// A plain click keeps its meaning (on macOS Cmd is the selection modifier, not Control).
#[test]
fn plain_click_is_untouched() {
let mut raw = RawInput { events: vec![click(PointerButton::Primary, true, false), click(PointerButton::Primary, false, false)], ..Default::default() };
super::synthesize_mac_ctrl_click(&mut false, &mut raw);
assert_eq!(buttons(&mut raw), vec![(PointerButton::Primary, true), (PointerButton::Primary, false)]);
}
/// A half-open pairing (pointer left the window mid-click) must not turn the next plain click
/// into a context click.
#[test]
fn new_plain_press_clears_a_stale_pairing() {
let mut raw = RawInput {
events: vec![
click(PointerButton::Primary, true, true),
Event::PointerGone,
click(PointerButton::Primary, true, false),
click(PointerButton::Primary, false, false),
],
..Default::default()
};
let mut armed = false;
super::synthesize_mac_ctrl_click(&mut armed, &mut raw);
assert!(!armed);
assert_eq!(buttons(&mut raw), vec![(PointerButton::Secondary, true), (PointerButton::Primary, true), (PointerButton::Primary, false),]);
}
}
#[cfg(test)]
mod gpu_fallback_tests {
use filmcraft_render::plan::{FramePlan, PlanLayer};

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