Compare commits

..

No commits in common. "main" and "release" have entirely different histories.

172 changed files with 795 additions and 16153 deletions

1
.gitignore vendored
View File

@ -5,4 +5,3 @@ plan/
/log
.DS_Store
.claude/worktrees/
__pycache__/

View File

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

View File

@ -347,13 +347,6 @@ 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

@ -84,10 +84,9 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings
| French | partial (whole interface; native review welcome) | ~100% |
| Portuguese (Brazil) | partial (whole interface; native review welcome) | ~100% |
| Indonesian | partial (whole interface; native review welcome) | ~100% |
| Italian | partial (whole interface; native review welcome) | ~100% |
| Korean | partial (whole interface; needs an installed Hangul font; native review welcome) | ~100% |
| Japanese | partial (whole interface) | 99.7% |
| German | menus only (native-reviewed) | 10.6% |
| Korean | none | 0% |
| Vietnamese | none | 0% |
Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md).
@ -107,7 +106,6 @@ 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).
@ -166,7 +164,6 @@ 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,13 +5,6 @@
//! `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`,
@ -434,11 +427,6 @@ 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
@ -447,16 +435,7 @@ 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);
// 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 if windows => create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY),
Mode::Default => {}
// Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU.
Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12,
@ -505,22 +484,6 @@ 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.
@ -532,19 +495,7 @@ mod tests {
assert_eq!(backends, Backends::PRIMARY);
assert!(!backends.contains(Backends::GL));
assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN));
// 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);
assert!(!selector);
}
#[test]

View File

@ -447,8 +447,11 @@ 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 project_exts = project_patterns();
let dialog = prepare_dialog(dialog);
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 (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 || {
@ -459,9 +462,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, &project_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::SaveProject { name } => {
rfd::FileDialog::new().add_filter(&project, &project_exts).set_file_name(name).save_file().map(path).into_iter().collect()
rfd::FileDialog::new().add_filter(&project, &["fcproj"]).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()
@ -489,29 +492,6 @@ 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
@ -525,31 +505,4 @@ 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

@ -131,10 +131,6 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
filmcraft_ui_egui::i18n::Language::Fr
} else if checked("app.language.indonesian") {
filmcraft_ui_egui::i18n::Language::Id
} else if checked("app.language.italian") {
filmcraft_ui_egui::i18n::Language::It
} else if checked("app.language.korean") {
filmcraft_ui_egui::i18n::Language::Ko
} else {
filmcraft_ui_egui::i18n::Language::En
};

View File

@ -77,14 +77,3 @@ 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,9 +8,7 @@ use filmcraft_apv::{
};
fn ffmpeg() -> Option<PathBuf> {
// 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")
filmcraft_testkit::ffmpeg_or_skip("apv oracle")
}
fn fixture_dir() -> PathBuf {

View File

@ -1,5 +1,4 @@
//! 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.
//! Client for the desktop app's JSON-lines control protocol. One connection, reconnect on failure.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
@ -13,17 +12,12 @@ 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,
/// 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>>,
conn: Mutex<Option<Conn>>,
next_id: AtomicU64,
}
@ -35,7 +29,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, idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
Ok(Self { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1) })
}
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
@ -77,14 +71,20 @@ 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 {
// 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?,
};
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)));
};
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,32 +98,21 @@ 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(_) if attempt == 0 => {}
Err(e) if attempt == 0 => {
*guard = None;
let _ = e;
}
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)]
@ -206,71 +195,6 @@ 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(std::sync::Mutex::new(file));
let src = FileSource(file);
let f = open(&src).unwrap();
assert_eq!(f.index, IndexSource::OpenDml);
assert_eq!(f.streams[0].chunks.len(), packets(&path, 0));
@ -139,54 +139,13 @@ fn opendml_file_past_1_gb() {
}
/// A file on disk as a byte source, read where asked.
struct FileSource(std::sync::Mutex<std::fs::File>);
struct FileSource(std::fs::File);
impl filmcraft_avi::ByteSource for FileSource {
fn len(&self) -> u64 {
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).metadata().map(|m| m.len()).unwrap_or(0)
self.0.metadata().map(|m| m.len()).unwrap_or(0)
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
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)
std::os::unix::fs::FileExt::read_exact_at(&self.0, buf, offset)
}
}
#[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(())
}
/// Raw Exp-Golomb codeNum, up to 32 leading zeros (so below 2^33).
fn read_code_num(&mut self) -> Result<u64> {
/// Unsigned Exp-Golomb `ue(v)`.
pub fn read_ue(&mut self) -> Result<u32> {
let mut zeros = 0u32;
while !self.read_bit()? {
zeros += 1;
@ -130,20 +130,14 @@ impl<'a> BitReader<'a> {
return Ok(0);
}
let v = self.read_bits_u64(zeros)?;
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)
let r = (1u64 << zeros) - 1 + v;
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
}
/// Signed Exp-Golomb `se(v)`.
pub fn read_se(&mut self) -> Result<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)
let k = self.read_ue()? as i64;
Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) })
}
/// Truncated Exp-Golomb `te(v)` with range `max`.
@ -202,11 +196,7 @@ impl BitWriter {
self.write_bits(b as u32, 1);
}
pub fn write_ue(&mut self, v: u32) {
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 x = v as u64 + 1;
let len = 64 - x.leading_zeros();
let zeros = len - 1;
for _ in 0..zeros {
@ -220,8 +210,8 @@ impl BitWriter {
}
}
pub fn write_se(&mut self, v: i32) {
let m = u64::from(v.unsigned_abs()) * 2;
self.write_code_num(if v > 0 { m - 1 } else { m });
let k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 };
self.write_ue(k);
}
pub fn bit_len(&self) -> usize {
self.buf.len() * 8 + self.nbits as usize
@ -387,41 +377,6 @@ 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,27 +23,6 @@ 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,14 +8,12 @@
//! 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** accepts TTML1 2010, the 2006 `ttaf1` drafts and TTML2:
//! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read):
//! 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};
@ -33,9 +31,6 @@ 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";
@ -127,10 +122,6 @@ 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 {
@ -149,38 +140,13 @@ 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 = 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();
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();
o.push_str(&format!(
" <p xml:id=\"{}\" begin=\"{}\" end=\"{}\"{agent}>{}</p>\n",
esc(&id),
@ -193,6 +159,12 @@ 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) }
}
@ -231,56 +203,6 @@ 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}") };
@ -372,7 +294,10 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
}
whole.0 as i128 + decimal_ticks("0", vf, fd)?
}
"t" => decimal_ticks(vi, vf, tps)? / tm.tick_rate.max(1) as i128,
"t" => {
let n: i128 = vi.parse().ok()?;
n * tps / tm.tick_rate.max(1) as i128
}
_ => return None,
};
i64::try_from(t).ok().map(Tick)
@ -486,7 +411,6 @@ 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()) {
@ -504,7 +428,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, &speakers, Fmt::default(), &mut doc);
walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, Fmt::default(), &mut doc);
doc.cues.sort_by_key(|c| (c.start, c.end));
Ok(doc)
}
@ -556,13 +480,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: &HashMap<String, Fmt>, speakers: &HashMap<String, String>, fmt: Fmt, doc: &mut Document) {
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collections::HashMap<String, Fmt>, 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, speakers, f, doc);
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, f, doc);
}
"p" => {
let t = times(c, parent, *tm, doc);
@ -570,7 +494,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &HashMap<String,
continue;
}
let (b, e) = (t.begin.unwrap_or(parent.begin), t.end);
let cue_speaker = speaker(c, speakers);
let speaker = attr(c, "agent").map(|a| a.trim().trim_start_matches('_').replace('_', " "));
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
@ -588,7 +512,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &HashMap<String,
}
let text = clean(&o.s);
if e > b && !text.is_empty() {
doc.cues.push(Cue { start: b, end: e, text, speaker: cue_speaker, id, ..Default::default() });
doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() });
}
}
None => {
@ -602,13 +526,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &HashMap<String,
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(sp, speakers).or_else(|| cue_speaker.clone()),
..Default::default()
});
doc.cues.push(Cue { start: sb, end: se, text, speaker: speaker.clone(), ..Default::default() });
}
}
}
@ -713,23 +631,6 @@ 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.") || body.contains("<v\t") {
if body.contains("<v ") || body.contains("<v.") {
return (None, text.to_string());
}
return ((!name.is_empty()).then(|| name.to_string()), body);
@ -177,19 +177,6 @@ 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

@ -1,219 +0,0 @@
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,21 +814,6 @@ 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);
@ -886,94 +871,20 @@ 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 = id3v2_len(bytes);
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 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);
@ -1017,7 +928,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" | "aac" | "flac" | "ogg" | "oga" | "aif" | "aiff" | "aifc") {
if !matches!(ext.as_str(), "mp3" | "mp2" | "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,10 +175,9 @@ 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| without_trailing_zeros(p) == without_trailing_zeros(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| p == n))
}
Self::Frame(i) => i.parameters_changed(sample),
}
}
@ -200,14 +199,6 @@ 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 {
@ -411,12 +402,13 @@ mod tests {
}
}
fn hevc_info(parameter_sets: Vec<Vec<u8>>) -> NalStreamInfo {
NalStreamInfo {
#[test]
fn nal_splitting_is_bounded() {
let info = NalStreamInfo {
codec: NalCodec::Hevc,
length_size: 4,
highest_tid: None,
parameter_sets,
parameter_sets: Vec::new(),
coded: (16, 16),
crop: (0, 0, 16, 16),
chroma_format_idc: 1,
@ -427,33 +419,11 @@ 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

@ -16,7 +16,6 @@
//! - [`AviSource`]: AVI (OpenDML included): Motion JPEG, H.264, HEVC and uncompressed video; PCM,
//! MP3 / MP2 and AC-3 audio.
//! - [`AudioFileSource`]: standalone compressed audio files (MP3, FLAC, AIFF, …).
//! - [`Y4mSource`]: uncompressed YUV4MPEG2 (`.y4m`) video, frames read on demand.
//! - [`openers`]: the openers to register with the engine's media pool.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
@ -35,7 +34,6 @@ pub mod ogg;
mod stream_color;
pub mod video;
mod webm_alpha;
pub mod y4m;
use std::sync::{Arc, RwLock};
@ -49,7 +47,6 @@ pub use mpeg::MpegSource;
pub use mxf::MxfSource;
pub use ogg::OggSource;
pub use video::{DecodedFrame, VideoDecoder, VideoDecoderFactory};
pub use y4m::Y4mSource;
#[derive(Debug, thiserror::Error)]
pub enum CodecError {
@ -131,9 +128,9 @@ pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<
}
/// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and
/// MPEG-1/2 video elementary streams, APV raw bitstreams, YUV4MPEG2, standalone audio).
/// MPEG-1/2 video elementary streams, APV raw bitstreams, standalone audio).
pub fn openers() -> Vec<filmcraft_media::Opener> {
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, y4m::opener, audio::opener]
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, audio::opener]
}
fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
@ -146,7 +143,6 @@ fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
ogg::reader_opener,
mpeg::reader_opener,
apv::reader_opener,
y4m::reader_opener,
avi::reader_opener,
])
})

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 | Codec::Mp1 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate),
Codec::Mp3 | Codec::Mp2 => 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 | Codec::Mp1 => "MPEG Audio".into(),
Codec::Mp2 => "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,7 +486,6 @@ 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,7 +347,6 @@ 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 })
}
@ -667,66 +666,6 @@ 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,104 +134,3 @@ 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

@ -1,299 +0,0 @@
//! YUV4MPEG2 (`.y4m`) files: uncompressed planar video, as described by the format's public
//! documentation (the `yuv4mpeg(5)` manual page of mjpegtools, and the `C` colour space names
//! other tools added for high bit depths).
//!
//! The file starts with `YUV4MPEG2` and space-separated tagged parameters up to a newline: `W` and
//! `H` (size), `F` (frame rate, `n:d`), `A` (pixel aspect, `n:d`, `0:0` unknown), `I` (interlacing),
//! `C` (colour space; `420jpeg` when absent) and `X` (extensions; `XCOLORRANGE=FULL` is read). Each
//! frame is a `FRAME` line (with optional parameters of its own) followed by the Y, Cb and Cr planes
//! (and an alpha plane for `444alpha`); samples above 8 bits are 16-bit little-endian.
//!
//! Frames are found once at open (their header lines only) and read on demand, so a large file
//! is never loaded whole. Every number comes from the file and is checked before use.
use std::sync::Arc;
use filmcraft_color::{ColorInfo, Range};
use filmcraft_frame::{AudioBuffer, Chroma, PixelData, VideoFrame};
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
use filmcraft_time::{FrameRate, Tick};
use crate::CodecError;
const SIGNATURE: &[u8] = b"YUV4MPEG2";
/// Longest stream header or `FRAME` line read.
const MAX_LINE: usize = 64 * 1024;
/// Largest picture (luma samples) accepted.
const MAX_PIXELS: u64 = 1 << 28;
pub fn sniff(head: &[u8]) -> bool {
head.starts_with(SIGNATURE) && matches!(head.get(SIGNATURE.len()), Some(b' ' | b'\n'))
}
/// The sample layout a `C` parameter names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Layout {
/// `None` for luma only (`mono`).
chroma: Option<Chroma>,
bits: u32,
alpha: bool,
}
impl Layout {
fn parse(c: &str) -> Option<Layout> {
let (base, bits) = match c.split_once('p') {
Some((base, bits)) if !bits.is_empty() && bits.bytes().all(|b| b.is_ascii_digit()) => (base, bits.parse().ok()?),
_ => (c, 8),
};
let (base, bits) = match base.strip_prefix("mono") {
Some("") => ("mono", bits),
Some(digits) if bits == 8 => ("mono", digits.parse().ok()?),
_ => (base, bits),
};
if !(8..=16).contains(&bits) {
return None;
}
let (chroma, alpha) = match base {
"420" | "420jpeg" | "420paldv" | "420mpeg2" => (Some(Chroma::C420), false),
"422" => (Some(Chroma::C422), false),
"444" => (Some(Chroma::C444), false),
"444alpha" if bits == 8 => (Some(Chroma::C444), true),
"mono" => (None, false),
_ => return None,
};
Some(Layout { chroma, bits, alpha })
}
fn label(self) -> String {
let sub = match (self.chroma, self.alpha) {
(None, _) => "Monochrome",
(Some(_), true) => "YUVA 4:4:4:4",
(Some(c), false) => return format!("YUV {} {}-bit", c.label(), self.bits),
};
format!("{sub} {}-bit", self.bits)
}
}
/// Sizes of one frame's planes in samples: luma, each chroma plane (0 for mono), alpha.
fn plane_samples(w: u32, h: u32, l: Layout) -> (usize, usize, usize) {
let luma = w as usize * h as usize;
let chroma = l.chroma.map_or(0, |c| {
let (sx, sy) = c.shifts();
w.div_ceil(1 << sx) as usize * h.div_ceil(1 << sy) as usize
});
(luma, chroma, if l.alpha { luma } else { 0 })
}
pub struct Y4mSource {
info: MediaInfo,
reader: filmcraft_media::SharedReader,
/// File offset of each frame's planes.
frames: Vec<u64>,
frame_bytes: usize,
layout: Layout,
rate: FrameRate,
}
fn parse_ratio(s: &str) -> Option<(i64, i64)> {
let (n, d) = s.split_once(':')?;
Some((n.parse().ok()?, d.parse().ok()?))
}
/// The line starting at `pos` (without its newline) and the offset after it.
fn line_at(reader: &dyn filmcraft_media::reader::ByteReader, pos: u64) -> Option<(Vec<u8>, u64)> {
let mut want = 128usize;
loop {
let len = (reader.len().saturating_sub(pos)).min(want as u64) as usize;
let buf = filmcraft_media::reader::read_range(reader, pos, len).ok()?;
if let Some(end) = buf.iter().position(|&b| b == b'\n') {
let mut line = buf;
line.truncate(end);
return Some((line, pos + end as u64 + 1));
}
if len < want || want >= MAX_LINE {
return None;
}
want = (want * 8).min(MAX_LINE);
}
}
impl Y4mSource {
pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result<Self> {
Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes)))
}
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
let bad = |what: &str| CodecError::Container(format!("{name}: Y4M: {what}"));
let (header, mut pos) = line_at(&*reader, 0).ok_or_else(|| bad("no stream header"))?;
let header = String::from_utf8_lossy(&header).into_owned();
let mut params = header.split(' ');
if params.next() != Some("YUV4MPEG2") {
return Err(bad("not a YUV4MPEG2 file"));
}
let (mut w, mut h, mut rate, mut par, mut colour, mut full) = (0u32, 0u32, None, (1u32, 1u32), "420jpeg".to_string(), false);
for p in params.filter(|p| !p.is_empty()) {
let (tag, v) = p.split_at(1);
match tag {
"W" => w = v.parse().map_err(|_| bad("invalid width"))?,
"H" => h = v.parse().map_err(|_| bad("invalid height"))?,
"F" => match parse_ratio(v) {
Some((n, d)) if n > 0 && d > 0 => rate = Some(FrameRate::new(n, d)),
_ => return Err(bad("invalid frame rate")),
},
"A" => {
if let Some((n, d)) = parse_ratio(v).filter(|&(n, d)| n > 0 && d > 0)
&& let (Ok(n), Ok(d)) = (u32::try_from(n), u32::try_from(d))
{
par = (n, d);
}
}
"C" => colour = v.to_string(),
"X" => full |= v.eq_ignore_ascii_case("COLORRANGE=FULL"),
_ => {}
}
}
if w == 0 || h == 0 {
return Err(bad("missing or zero size"));
}
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return Err(CodecError::Unsupported(format!("{name}: Y4M frames larger than 268 million pixels")));
}
let layout = Layout::parse(&colour).ok_or_else(|| CodecError::Unsupported(format!("{name}: Y4M colour space {colour}")))?;
// ffmpeg's default when the file names no rate
let rate = rate.unwrap_or(FrameRate::FPS_25);
let (luma, chroma, alpha) = plane_samples(w, h, layout);
let per_sample = if layout.bits > 8 { 2 } else { 1 };
let frame_bytes = (luma + 2 * chroma + alpha) * per_sample;
let len = reader.len();
let mut frames = Vec::new();
while pos < len {
let Some((line, data)) = line_at(&*reader, pos) else { break };
if !line.starts_with(b"FRAME") {
break;
}
let Some(end) = data.checked_add(frame_bytes as u64).filter(|&e| e <= len) else { break };
frames.push(data);
pos = end;
}
if frames.is_empty() {
return Err(bad("no complete frames"));
}
let color = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), range: if full { Range::Full } else { Range::Limited }, ..ColorInfo::REC709 };
let duration = Tick::from_rational(frames.len() as i64, rate.den, rate.num);
let bitrate = (frame_bytes as u64 * 8).checked_mul(rate.num as u64).map(|b| b / rate.den as u64);
let info = MediaInfo {
name: name.to_string(),
kind: MediaKind::Movie,
duration,
video: Some(VideoStreamInfo {
width: w,
height: h,
frame_rate: rate,
par,
codec: "Uncompressed YUV".into(),
pixel_format: layout.label(),
color,
has_alpha: layout.alpha,
bitrate,
hdr: None,
}),
audio_streams: Vec::new(),
container: "Y4M".into(),
start_timecode: None,
file_size: Some(len),
};
Ok(Self { info, reader, frames, frame_bytes, layout, rate })
}
fn frame(&self, idx: usize) -> crate::Result<VideoFrame> {
let (Some(&at), Some(v)) = (self.frames.get(idx), self.info.video.as_ref()) else {
return Err(CodecError::Container("Y4M: frame index out of range".into()));
};
let data = filmcraft_media::reader::read_range(&*self.reader, at, self.frame_bytes).map_err(|e| CodecError::Container(e.to_string()))?;
let (luma, chroma, alpha) = plane_samples(v.width, v.height, self.layout);
// mono: neutral chroma at 4:2:0
let mono = Layout { chroma: Some(Chroma::C420), ..self.layout };
let grey = plane_samples(v.width, v.height, mono).1;
let pixels = if self.layout.bits == 8 {
let plane = |i: usize, n: usize| Arc::new(data.get(i..i + n).map(<[u8]>::to_vec).unwrap_or_else(|| vec![0; n]));
let (y, cb, cr) = match self.layout.chroma {
Some(_) => (plane(0, luma), plane(luma, chroma), plane(luma + chroma, chroma)),
None => (plane(0, luma), Arc::new(vec![128; grey]), Arc::new(vec![128; grey])),
};
let a = self.layout.alpha.then(|| plane(luma + 2 * chroma, alpha));
PixelData::Yuv8 { planes: [y, cb, cr], chroma: self.layout.chroma.unwrap_or(Chroma::C420), alpha: a }
} else {
let max = (1u32 << self.layout.bits) - 1;
let plane = |i: usize, n: usize| {
Arc::new(match data.get(i * 2..(i + n) * 2) {
Some(b) => b.as_chunks::<2>().0.iter().map(|&s| u32::from(u16::from_le_bytes(s)).min(max) as u16).collect(),
None => vec![0; n],
})
};
let mid = 1u16 << (self.layout.bits - 1);
let (y, cb, cr) = match self.layout.chroma {
Some(_) => (plane(0, luma), plane(luma, chroma), plane(luma + chroma, chroma)),
None => (plane(0, luma), Arc::new(vec![mid; grey]), Arc::new(vec![mid; grey])),
};
PixelData::Yuv16 { planes: [y, cb, cr], chroma: self.layout.chroma.unwrap_or(Chroma::C420), bits: self.layout.bits, alpha: None }
};
Ok(VideoFrame {
width: v.width,
height: v.height,
data: pixels,
color: v.color,
par: v.par,
pts: Tick::from_rational(idx as i64, self.rate.den, self.rate.num),
})
}
}
impl MediaSource for Y4mSource {
fn info(&self) -> &MediaInfo {
&self.info
}
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>, MediaError> {
let last = self.frames.len().saturating_sub(1);
let idx = usize::try_from(self.rate.frame_at(req.time.max(Tick::ZERO))).unwrap_or(last).min(last);
Ok(Arc::new(self.frame(idx)?))
}
fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result<AudioBuffer, MediaError> {
Err(MediaError::NoStream("audio"))
}
}
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, MediaError>> {
if !sniff(&bytes) {
return None;
}
Some(Y4mSource::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option<Result<SharedSource, MediaError>> {
if !sniff(head) {
return None;
}
Some(Y4mSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn colour_space_names() {
let l = |c: Option<Chroma>, bits, alpha| Some(Layout { chroma: c, bits, alpha });
assert_eq!(Layout::parse("420jpeg"), l(Some(Chroma::C420), 8, false));
assert_eq!(Layout::parse("420p10"), l(Some(Chroma::C420), 10, false));
assert_eq!(Layout::parse("422p12"), l(Some(Chroma::C422), 12, false));
assert_eq!(Layout::parse("444alpha"), l(Some(Chroma::C444), 8, true));
assert_eq!(Layout::parse("mono"), l(None, 8, false));
assert_eq!(Layout::parse("mono16"), l(None, 16, false));
for bad in ["411", "420p", "420p7", "420p17", "mono10p12", "444alphap10", "", "p10"] {
assert_eq!(Layout::parse(bad), None, "{bad}");
}
}
}

View File

@ -1,66 +0,0 @@
//! 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

@ -1,89 +0,0 @@
//! 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

@ -1,171 +0,0 @@
//! YUV4MPEG2 (`.y4m`) files (#1292): File ▸ Import and the Media Browser offer them, so they must
//! open. Files are built here: a stream header of tagged parameters, then `FRAME` lines each
//! followed by the planes (Y, Cb, Cr, 16-bit little-endian above 8 bits).
use std::sync::Arc;
use filmcraft_color::Range;
use filmcraft_frame::{Chroma, PixelData};
use filmcraft_media::{FrameRequest, MediaKind, SharedSource};
use filmcraft_time::{FrameRate, Tick};
/// A Y4M file: `header` after the signature, `frames` as raw plane bytes, each after `frame_line`.
fn y4m(header: &str, frame_line: &str, frames: &[Vec<u8>]) -> Vec<u8> {
let mut out = format!("YUV4MPEG2 {header}\n").into_bytes();
for f in frames {
out.extend_from_slice(frame_line.as_bytes());
out.push(b'\n');
out.extend_from_slice(f);
}
out
}
/// One 8-bit frame: every Y sample `y`, every Cb `cb`, every Cr `cr`.
fn planes8(luma: usize, chroma: usize, y: u8, cb: u8, cr: u8) -> Vec<u8> {
[vec![y; luma], vec![cb; chroma], vec![cr; chroma]].concat()
}
fn open(name: &str, bytes: Vec<u8>) -> SharedSource {
filmcraft_codecs::open_bytes(name, Arc::from(bytes)).unwrap()
}
/// The middle of frame `k` at `fps`.
fn at(k: i64, fps: i64) -> FrameRequest {
FrameRequest::full(Tick::from_rational(2 * k + 1, 1, 2 * fps))
}
#[test]
fn y4m_420_opens_as_a_movie_frame_by_frame() {
// 6 × 4, 4:2:0 (chroma 3 × 2), three frames at 25 fps.
let frames: Vec<Vec<u8>> = (0..3).map(|k| planes8(24, 6, 16 + 50 * k, 100 + k, 200 - k)).collect();
let src = open("clip.y4m", y4m("W6 H4 F25:1 Ip A1:1 C420jpeg", "FRAME", &frames));
let info = src.info();
assert_eq!(info.kind, MediaKind::Movie);
let v = info.video.as_ref().unwrap();
assert_eq!((v.width, v.height, v.frame_rate), (6, 4, FrameRate::new(25, 1)));
assert_eq!(info.duration, Tick::from_units(3, 25));
for k in 0..3u8 {
let f = src.video_frame(at(i64::from(k), 25)).unwrap();
let PixelData::Yuv8 { planes, chroma, .. } = &f.data else { panic!("{:?}", f.data) };
assert_eq!(*chroma, Chroma::C420);
assert_eq!((planes[0].len(), planes[1].len(), planes[2].len()), (24, 6, 6));
assert!(planes[0].iter().all(|&s| s == 16 + 50 * k) && planes[1].iter().all(|&s| s == 100 + k) && planes[2].iter().all(|&s| s == 200 - k));
}
}
#[test]
fn y4m_high_bit_depth_frame_parameters_aspect_and_range() {
// 4 × 2, 4:2:2 10-bit (little-endian), 30000:1001, 4:3 pixels, full range, and frame lines
// carrying their own parameters.
let mut frame = Vec::new();
for (n, v) in [(8, 940u16), (4, 512), (4, 64)] {
for _ in 0..n {
frame.extend(v.to_le_bytes());
}
}
let src = open("ten.y4m", y4m("W4 H2 F30000:1001 It A4:3 C422p10 XCOLORRANGE=FULL", "FRAME Ip XFOO=1", &[frame.clone(), frame]));
let v = src.info().video.clone().unwrap();
assert_eq!((v.frame_rate, v.par, v.color.range), (FrameRate::new(30000, 1001), (4, 3), Range::Full));
let f = src.video_frame(at(1, 30)).unwrap();
let PixelData::Yuv16 { planes, chroma, bits, .. } = &f.data else { panic!("{:?}", f.data) };
assert_eq!((*chroma, *bits), (Chroma::C422, 10));
assert!(planes[0].iter().all(|&s| s == 940) && planes[1].iter().all(|&s| s == 512) && planes[2].iter().all(|&s| s == 64));
}
#[test]
fn y4m_mono_has_neutral_chroma() {
let src = open("grey.y4m", y4m("W2 H2 F24:1 Cmono", "FRAME", &[vec![77; 4]]));
let f = src.video_frame(at(0, 24)).unwrap();
let PixelData::Yuv8 { planes, .. } = &f.data else { panic!("{:?}", f.data) };
assert!(planes[0].iter().all(|&s| s == 77) && planes[1].iter().chain(planes[2].iter()).all(|&s| s == 128));
}
#[test]
fn large_y4m_files_open_from_a_reader() {
let bytes: Arc<[u8]> = Arc::from(y4m("W2 H2 F25:1 C444", "FRAME", &[planes8(4, 4, 50, 60, 70), planes8(4, 4, 90, 60, 70)]));
let reader: filmcraft_media::SharedReader = Arc::new(filmcraft_media::reader::MemReader(bytes));
let src = filmcraft_media::reader::open_reader("big.y4m", reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers()).unwrap();
let f = src.video_frame(at(1, 25)).unwrap();
let PixelData::Yuv8 { planes, chroma, .. } = &f.data else { panic!("{:?}", f.data) };
assert_eq!((*chroma, planes[0][0]), (Chroma::C444, 90));
}
#[test]
fn damaged_and_hostile_y4m_files_fail_without_panicking() {
let good = y4m("W6 H4 F25:1 C420jpeg", "FRAME", &[planes8(24, 6, 1, 2, 3), planes8(24, 6, 4, 5, 6)]);
let mut cases: Vec<Vec<u8>> = (0..good.len()).map(|n| good[..n].to_vec()).collect();
for i in 0..good.len() {
let mut b = good.clone();
b[i] ^= 0x41;
cases.push(b);
}
for header in [
"W99999999 H99999999 F25:1",
"W0 H4 F25:1",
"W6 H4 F0:0",
"W6 H4 F25:0",
"W6 H4 F25:1 C411",
"W6 H4 F25:1 Cbogus",
"W6 H4 A0:0 F-5:1",
"H4 F25:1",
"W6 H4 F1:9223372036854775807",
] {
cases.push(y4m(header, "FRAME", &[vec![0; 36]]));
}
for (i, b) in cases.into_iter().enumerate() {
let r = std::panic::catch_unwind(|| {
filmcraft_codecs::open_bytes("damaged.y4m", Arc::from(b)).map(|s| (0..3).filter(|&k| s.video_frame(at(k, 25)).is_ok()).count())
});
assert!(r.is_ok(), "case {i} panicked");
}
}
/// ffmpeg (an external oracle) writes `.y4m` files; every frame we read must equal, byte for byte,
/// the planes ffmpeg's own rawvideo output gives for the same file.
#[test]
fn ffmpeg_y4m_files_match_ffmpeg_rawvideo() {
let Some(ff) = filmcraft_testkit::oracle::ffmpeg_or_skip("Y4M oracle") else { return };
let dir = filmcraft_testkit::fixtures::fixtures_dir("codecs/y4m");
for (pix_fmt, bytes_per_sample) in [("yuv420p", 1usize), ("yuv422p10le", 2)] {
let y4m = dir.join(format!("testsrc2_{pix_fmt}.y4m"));
let made = filmcraft_testkit::fixtures::generate(&y4m, |tmp| {
std::process::Command::new(&ff)
.args([
"-y",
"-v",
"error",
"-f",
"lavfi",
"-i",
"testsrc2=size=94x62:rate=25",
"-frames:v",
"5",
"-pix_fmt",
pix_fmt,
"-strict",
"-1",
"-f",
"yuv4mpegpipe",
])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
});
let Some(y4m) = made else { continue };
let raw =
std::process::Command::new(&ff).args(["-v", "error", "-i"]).arg(&y4m).args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]).output().unwrap().stdout;
let src = open("oracle.y4m", std::fs::read(&y4m).unwrap());
assert_eq!(src.info().duration, Tick::from_units(5, 25), "{pix_fmt}");
let frame_len = raw.len() / 5;
for k in 0..5 {
let f = src.video_frame(at(k as i64, 25)).unwrap();
let ours: Vec<u8> = match &f.data {
PixelData::Yuv8 { planes, .. } => planes.iter().flat_map(|p| p.iter().copied()).collect(),
PixelData::Yuv16 { planes, .. } => planes.iter().flat_map(|p| p.iter().flat_map(|s| s.to_le_bytes())).collect(),
other => panic!("{other:?}"),
};
assert_eq!(ours.len(), frame_len, "{pix_fmt} frame {k}");
assert!(ours == raw[k * frame_len..(k + 1) * frame_len], "{pix_fmt} frame {k} differs ({bytes_per_sample} bytes/sample)");
}
}
}

View File

@ -559,8 +559,7 @@ pub fn copy_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_
it.start = (*start).max(Tick::ZERO);
placed.push((*dest, it));
}
// worked out on the original ids, then moved over to the copies (only transitions whose every
// clip is copied travel; one whose partner stays with the originals is not duplicated)
// worked out on the original ids, then moved over to the copies
let carried = carried_transitions(&work, &placed);
let mut ids = HashMap::new();
let mut links = HashMap::new();
@ -597,10 +596,9 @@ pub fn copy_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_
Ok(out)
}
/// The transitions that travel with a move (#227, #374): only those whose every clip moves, onto
/// one track and by one offset (a fade at a moved clip's edge, or the cut between two clips that
/// move together), shifted to where those clips land. A transition whose partner stays behind is
/// dropped, as in Premiere (the drag preview leaves it where it is until the release).
/// The transitions that travel with a move (#227, #374): those whose clips all move, onto one
/// track and by one offset (a fade at a moved clip's edge, or the cut between two clips that move
/// together), shifted to where those clips land.
fn carried_transitions(seq: &Sequence, placed: &[(TrackId, TrackItem)]) -> Vec<(TrackId, Transition)> {
let landing = |c: ClipId| {
let (dest, it) = placed.iter().find(|(_, it)| it.id == c)?;
@ -609,14 +607,9 @@ fn carried_transitions(seq: &Sequence, placed: &[(TrackId, TrackItem)]) -> Vec<(
};
let mut carried = Vec::new();
for tr in seq.all_tracks().filter(|t| !t.locked).flat_map(|t| t.transitions.iter()) {
let endpoints: Vec<ClipId> = [tr.from, tr.to].into_iter().flatten().collect();
let lands: Vec<(TrackId, Tick)> = endpoints.iter().filter_map(|c| landing(*c)).collect();
// every clip it sits on must move, onto one track by one offset, or the transition is
// dropped with the delete below (a partner that stayed behind ends the move)
if lands.len() == endpoints.len()
&& let Some(&(dest, delta)) = lands.first()
&& lands.iter().skip(1).all(|l| *l == (dest, delta))
{
let lands: Option<Vec<(TrackId, Tick)>> = [tr.from, tr.to].into_iter().flatten().map(landing).collect();
let Some((&(dest, delta), rest)) = lands.as_deref().and_then(<[_]>::split_first) else { continue };
if rest.iter().all(|l| *l == (dest, delta)) {
carried.push((dest, Transition { start: tr.start + delta, ..tr.clone() }));
}
}

View File

@ -554,7 +554,7 @@ fn transitions_travel_with_moved_clips() {
// a cut whose two clips move together keeps its transition, in an insert move as well
move_items(&mut fx.seq, &[(b, v1, f(60)), (c, v1, f(70))], true, &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(at(&fx, v1), vec![(2, 68), (3, 76)]);
// a crossfade whose partner stays behind is dropped; the moving clip's own fade stays with it
// a crossfade whose partner stays behind is dropped; the other clip's fade stays with it
move_items(&mut fx.seq, &[(c, v1, f(90))], false, &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(at(&fx, v1), vec![(3, 96)]);
fx.seq.check().unwrap();

View File

@ -345,23 +345,8 @@ 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 = 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)?
};
let track = 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)));
}
@ -406,48 +391,16 @@ 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 destinations = prepare(q, ctx)?;
let ids = edit::copy_items(q, &destinations, ins, ctx)?;
let ids = edit::copy_items(q, &moves, ins, ctx)?;
st.selection = ids.clone();
st.transition_selection.clear();
Ok(ids)
})?
} else {
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| {
let destinations = prepare(q, ctx)?;
Ok(edit::move_items(q, &destinations, ins, ctx)?)
})?;
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
Vec::new()
};
// An overwrite shortens, splits or removes whatever already sits where a clip lands, and a
@ -1677,58 +1630,6 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("edit.rippleDeleteHead", "Ripple Delete Head Frame", ["Edit"], None, r#"{"clips":[id]?,"clip":id?}"#, has_selection, |s, p| {
// #1357: remove the head — from each selected clip's in point through the playhead —
// and ripple the sequence closed. Linked clips ripple as one group (the engine's
// group trim moves every member's in point by one delta and shifts the synced
// tracks once). The playhead past every member's out point deletes the group whole.
let ph = s.playhead();
let clips = with_links(s, &clips_p(s, p));
// groups keyed by link id (unlinked clips: their own id)
let mut groups: Vec<(u64, Vec<ClipId>)> = Vec::new();
{
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
for c in &clips {
let (_, item) = q.find_item(*c).ok_or_else(|| EngineError::Other("clip not in the active sequence".into()))?;
if (ph - item.start) <= Tick::ZERO {
return Err(EngineError::Other("the playhead is at or before the clip's in point".into()));
}
let key = item.link.unwrap_or(c.0);
match groups.iter_mut().find(|(k, _)| *k == key) {
Some((_, members)) => members.push(*c),
None => groups.push((key, vec![*c])),
}
}
}
s.edit_sequence("Ripple Delete Head Frame", |q, ctx, st| {
for (_, members) in &groups {
let live: Vec<(ClipId, Tick, Tick)> = members.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| (*c, i.start, i.duration))).collect();
if live.is_empty() {
continue;
}
let head = live.iter().map(|(_, s, _)| (ph - *s).max(Tick::ZERO)).max().unwrap_or(Tick::ZERO);
let all_whole = live.iter().all(|(_, _, d)| head >= *d);
let ids: Vec<ClipId> = live.iter().map(|(c, _, _)| *c).collect();
if all_whole {
let spans = edit::ripple_delete_items(q, &ids)?;
if st.ripple_sequence_markers {
for r in spans.iter().rev() {
crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration);
}
}
continue;
}
let start = live.iter().map(|(_, s, _)| *s).min().unwrap_or(ph);
edit::ripple_trim_group(q, &ids, Edge::In, head, ctx)?;
if st.ripple_sequence_markers {
// the head [start, start + head) closes: later markers move back by it
crate::sequence_tools::ripple_markers(&mut q.markers, start + head, Tick::ZERO - head);
}
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_seq, |s, _| {
let all: Vec<ClipId> = s.active_sequence().map(|q| q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect()).unwrap_or_default();
s.state.selection = all;
@ -2627,7 +2528,12 @@ 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,
move_clips
|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)
}
),
cmd!(
"timeline.trim",
@ -3242,40 +3148,20 @@ 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().filter(|it| !items.contains(&it.id)) {
for it in s.project.sequences() {
if let Some(q) = it.as_sequence() {
for t in q.all_tracks() {
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,
}
}
}
used.extend(t.items.iter().map(|x| x.item));
}
}
}
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);
}
@ -3289,7 +3175,7 @@ fn build() -> Vec<CommandSpec> {
for b in &bins {
pr.root.remove_bin(*b);
}
for i in offlined.iter().chain(&retained_subclips) {
for i in &offlined {
if pr.root.parent_of(*i).is_none() {
pr.root.children.push(filmcraft_project::BinEntry::Item(*i));
}

View File

@ -360,13 +360,6 @@ 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);
@ -668,11 +661,8 @@ 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 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}");
// 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}");
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
@ -716,102 +706,3 @@ 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,11 +449,7 @@ 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 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 doc = filmcraft_captions::document_from_track(track, range.start);
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 };
@ -957,14 +953,8 @@ 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);
@ -977,10 +967,6 @@ 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,79 +514,8 @@ 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])])
}
/// 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).
/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection.
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)?;
@ -740,7 +669,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; selected video clips move by Motion Position)}"#;
const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers)}"#;
macro_rules! align_cmd {
($id:literal, $label:literal, $menu:expr, $how:literal, $mode:expr, $en:expr) => {
@ -858,12 +787,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_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.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.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,34 +658,3 @@ 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,7 +225,6 @@ 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 };
@ -235,17 +234,11 @@ fn nudge_track(s: &mut Session, up: bool) -> Result<Value> {
(TrackKind::Video, true) | (TrackKind::Audio, false) => 1,
_ => -1,
};
// 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;
};
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()))?;
moves.push((*c, dest.id, it.start));
}
if moves.is_empty() {
let why = if blocked > 0 { "no track to move to" } else { "no clips selected" };
return Err(EngineError::Other(why.into()));
return Err(EngineError::Other("no clips selected".into()));
}
s.edit_sequence("Nudge", |q, ctx, _| Ok(edit::move_items(q, &moves, false, ctx)?))?;
Ok(json!({"moved": moves.len()}))

View File

@ -190,14 +190,6 @@ 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

@ -1288,8 +1288,6 @@ mod remix_tests;
#[cfg(test)]
mod ripple_delete_tests;
#[cfg(test)]
mod ripple_head_tests;
#[cfg(test)]
mod roll_linked_tests;
#[cfg(test)]
mod scopes_tests;

View File

@ -73,9 +73,7 @@ pub const ALL_COLUMNS: [&str; 10] =
["Name", "Media Type", "Size", "Date Modified", "Frame Rate", "Media Duration", "Video Info", "Audio Info", "Video Codec", "Audio Codec"];
pub const DEFAULT_COLUMNS: [&str; 5] = ["Name", "Frame Rate", "Media Duration", "Video Info", "Size"];
/// Project and interchange files the browser can import. Avid Log Exchange (`.ale`) is left out:
/// FilmCraft writes it (File ▸ Export) but, like Premiere, does not read it (#1292).
const PROJECT_EXTENSIONS: [&str; 5] = ["fcproj", "edl", "xml", "fcpxml", "otio"];
const PROJECT_EXTENSIONS: [&str; 6] = ["fcproj", "edl", "xml", "fcpxml", "otio", "ale"];
const CAPTION_EXTENSIONS: [&str; 3] = ["srt", "vtt", "scc"];
const RECENT_MAX: usize = 10;

View File

@ -318,12 +318,3 @@ fn probing_large_unstreamable_files_does_not_read_them_whole() {
assert_eq!(r["items"].as_array().unwrap().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
/// #1292: an Avid Log Exchange file is something FilmCraft writes (File ▸ Export), not a project
/// it can import, so the browser doesn't list it with the project files.
#[test]
fn ale_logs_are_not_offered_for_import() {
assert_eq!(media_browser::kind_of("clips.ale"), None);
assert!(!media_browser::extensions().contains(&"ale"));
assert_eq!(media_browser::kind_of("cut.edl"), Some("project"));
}

View File

@ -378,56 +378,6 @@ 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();
@ -443,10 +393,21 @@ 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"),
};
if Path::new(&path).file_name().is_some_and(|name| Path::new(&path) == dir.join(name)) {
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 {
continue;
}
let out_s = copy_for_ingest(s, i, &path, &dir)?;
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}))?;
copied.push(json!({"item": i.0, "path": out_s}));
}
}
@ -462,7 +423,6 @@ 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() {
@ -473,9 +433,7 @@ 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);
let out = unique_path(&dir, &stem, "", pr.extension, &taken).to_string_lossy().into_owned();
taken.push(out.clone());
work.push((i, out, pr));
work.push((i, unique_path(&dir, &stem, "", pr.extension, &[]).to_string_lossy().into_owned(), pr));
}
if !work.is_empty() {
let id = start_job(s, format!("Ingest: transcode {} clip(s)", work.len()), work, OnDone::ReplaceMedia, false)?;

View File

@ -124,155 +124,6 @@ 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,100 +270,3 @@ 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

@ -1,108 +0,0 @@
//! #1357: `edit.rippleDeleteHead` removes the portion of the selected clip from its in point
//! through the playhead and ripples the sequence closed.
use crate::Session;
use filmcraft_project::ClipId;
use filmcraft_time::Tick;
use serde_json::json;
fn f(s: &Session, n: i64) -> Tick {
s.sequence_rate().tick_of(n)
}
fn range(s: &Session, c: ClipId) -> (Tick, Tick) {
s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap()
}
/// Four 48-frame clips in a row, pictures on V1 and their linked sounds on A1: [(picture, sound); 4].
fn four_clips(s: &mut Session) -> [(ClipId, ClipId); 4] {
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name": "Head", "video": 1, "audio": 1})).unwrap();
let rate = s.sequence_rate();
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
for n in 0..4 {
s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap();
}
let q = s.active_sequence().unwrap();
[0, 1, 2, 3].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id))
}
/// The head (in point .. playhead) goes and the rest of the sequence closes up: the clip keeps its
/// tail at the left of its old range, later clips shift left.
#[test]
fn ripple_delete_head_closes_the_head_and_shifts_later_clips() {
let mut s = Session::default();
let [(_, _), (pic2, snd2), (pic3, snd3), (pic4, snd4)] = four_clips(&mut s);
s.set_playhead(f(&s, 60));
s.state.selection = vec![pic2];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert_eq!(range(&s, pic2), (f(&s, 48), f(&s, 84)), "the head is gone and the tail slid left");
assert_eq!(range(&s, snd2), (f(&s, 48), f(&s, 84)), "the linked sound follows");
assert_eq!(range(&s, pic3), (f(&s, 84), f(&s, 132)), "the next picture closes up by the head length");
assert_eq!((range(&s, snd3), range(&s, pic4), range(&s, snd4)), ((f(&s, 84), f(&s, 132)), (f(&s, 132), f(&s, 180)), (f(&s, 132), f(&s, 180))));
assert_eq!(q.duration(), f(&s, 180), "the sequence is 12 frames shorter");
}
/// The trimmed clip's media in point advances by the head length (the head frames are dropped,
/// not hidden).
#[test]
fn ripple_delete_head_advances_the_source_in_point() {
let mut s = Session::default();
let [(pic1, _), ..] = four_clips(&mut s);
let in_before = s.active_sequence().unwrap().find_item(pic1).unwrap().1.source_in;
s.set_playhead(f(&s, 12));
s.state.selection = vec![pic1];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let item = s.active_sequence().unwrap().find_item(pic1).unwrap().1;
assert_eq!(item.source_in, in_before + f(&s, 12), "the head frames are dropped from the source");
assert_eq!((item.start, item.end()), (Tick::ZERO, f(&s, 36)), "the clip starts at zero, 36 frames long");
}
/// The playhead at or before the in point has no head to remove.
#[test]
fn ripple_delete_head_needs_the_playhead_past_the_in_point() {
let mut s = Session::default();
let [(pic1, _), ..] = four_clips(&mut s);
s.set_playhead(Tick::ZERO);
s.state.selection = vec![pic1];
s.execute("edit.rippleDeleteHead", json!({})).unwrap_err();
assert_eq!(range(&s, pic1), (Tick::ZERO, s.sequence_rate().tick_of(48)), "nothing moved");
}
/// The playhead past the clip's out point deletes the whole clip (the head covers all of it).
#[test]
fn ripple_delete_head_past_the_end_removes_the_whole_clip() {
let mut s = Session::default();
let [(_, _), (pic2, snd2), (pic3, snd3), ..] = four_clips(&mut s);
s.set_playhead(f(&s, 120)); // past clip 2's out point at 96
s.state.selection = vec![pic2];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert!(q.find_item(pic2).is_none() && q.find_item(snd2).is_none(), "the whole clip went");
assert_eq!(range(&s, pic3), (f(&s, 48), f(&s, 96)), "later clips closed the whole 48-frame gap");
assert_eq!(range(&s, snd3), (f(&s, 48), f(&s, 96)));
}
/// With Ripple Sequence Markers on, markers after the removed head move back with the clips, and
/// markers inside the head go with it.
#[test]
fn ripple_delete_head_ripples_sequence_markers() {
let mut s = Session::default();
let [(_, _), (pic2, _), (_, _), (_, _)] = four_clips(&mut s);
s.state.ripple_sequence_markers = true;
let at = |s: &Session, n: i64| s.sequence_rate().tick_of(n).0;
for (frame, name) in [(30, "before"), (52, "inside"), (120, "after")] {
s.execute("markers.add", json!({"time": at(&s, frame), "name": name})).unwrap();
}
s.set_playhead(f(&s, 60));
s.state.selection = vec![pic2];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let q = s.active_sequence().unwrap();
let markers: Vec<(String, Tick)> = q.markers.iter().map(|m| (m.name.clone(), m.start)).collect();
assert_eq!(markers, vec![("before".to_string(), f(&s, 30)), ("after".to_string(), f(&s, 108))], "the 12-frame head closes");
}

View File

@ -276,40 +276,3 @@ 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,6 +774,65 @@ 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

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

View File

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

View File

@ -737,8 +737,7 @@ impl Shortcuts {
let nb = Binding { command: command.into(), keys: chord.canonical(), panel: panel.map(str::to_string) };
let before = self.snapshot();
if !add {
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))));
self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && b.context() == nb.context()));
}
let mut reassigned = Vec::new();
if !keep_conflicts {
@ -770,7 +769,6 @@ 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));
@ -780,7 +778,7 @@ impl Shortcuts {
let panel_ok = match &panel {
None => true,
Some(p) if p.is_none() && twins => true,
Some(p) => &b.panel == p || is_copy(b, &copies),
Some(p) => &b.panel == p,
};
!(cmd && key_ok && panel_ok)
});
@ -918,16 +916,6 @@ 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> {
@ -1029,12 +1017,6 @@ 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,42 +133,6 @@ 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,116 +299,3 @@ 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,9 +363,18 @@ impl Exporter {
}
}
None => {
// 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);
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());
}
}
}
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()))?;
}
@ -419,15 +428,8 @@ impl Exporter {
Some(mux.add_track(c).map_err(|e| ExportError::Io(e.to_string()))?)
}
(Some(a), None) => {
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,
};
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 };
Some(mux.add_track(TrackConfig::new(SampleEntry::pcm(pcm), a.sr)).map_err(|e| ExportError::Io(e.to_string()))?)
}
};

View File

@ -625,14 +625,6 @@ 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())));
}
@ -1116,19 +1108,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,
format: SampleFormat,
bits: u16,
/// 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 format = s.audio.sample_format();
let bits = if s.audio.bits >= 24 { 24 } else { 16 };
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, format.bits() as u8);
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, 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, format, tail: 0 })
Ok(FlacAudio { enc, bits, tail: 0 })
}
}
@ -1137,7 +1129,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| self.format.quantise(s)).collect()).collect();
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).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}")))
}
@ -1859,12 +1851,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, fmt) = (a.channels as u16, a.sr, settings.audio.sample_format());
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
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, fmt.bits() as u8);
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 });
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}")))?))
}
@ -1872,7 +1864,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, fmt, frames).map_err(ExportError::Unsupported)?,
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?,
AudioWriter::Flac(enc) => enc.header(),
};
// one second per chunk: memory stays flat however long the range is
@ -1893,9 +1885,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), fmt, &mut buf),
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf),
AudioWriter::Flac(enc) => {
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| fmt.quantise(s)).collect()).collect();
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).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}")))?;
}
@ -1906,7 +1898,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
}
match &mut writer {
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, fmt)).map_err(io)?,
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).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,8 +108,7 @@ 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);
// integers only: `ExportSettings::validate` refuses float PCM in MXF
let bits = u32::from(settings.audio.sample_format().bits());
let bits = if settings.audio.bits >= 24 { 24 } else { 16 };
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,13 +1,10 @@
//! Audio-only files: WAV (RIFF, little-endian PCM; RF64 past 4 GiB) and AIFF (big-endian PCM),
//! 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`).
//! 16 or 24 bit, plus image-sequence file naming.
//!
//! 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 {
@ -15,55 +12,47 @@ 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, format: SampleFormat) -> Option<u64> {
frames.checked_mul(u64::from(channels))?.checked_mul(format.bytes() as u64)
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))
}
/// The pad byte after an odd-sized data chunk (RIFF and AIFF chunks are word-aligned).
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 { &[] }
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 { &[] }
}
/// `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, format: SampleFormat, frames: u64) -> Result<Vec<u8>, String> {
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u16, frames: u64) -> Result<Vec<u8>, String> {
let too_long = || "the audio is too long to write".to_string();
let bits = format.bits();
let bits = sample_bits(bits);
let bps = bits / 8;
let float = format.is_float();
let data = data_len(frames, channels, format).ok_or_else(too_long)?;
let data = data_len(frames, channels, bits).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;
// 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 fmt_len: u32 = if ext { 40 } else { 16 };
let padded = data.checked_add(data & 1).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)?;
// "WAVE" + fmt chunk + data chunk header + samples
let riff = (4 + 8 + u64::from(fmt_len) + 8).checked_add(padded).ok_or_else(too_long)?;
let rf64 = riff > u64::from(u32::MAX);
let mut v = Vec::with_capacity(100);
let mut v = Vec::with_capacity(80);
if rf64 {
const DS64: u64 = 8 + 28;
v.extend_from_slice(b"RF64");
@ -79,30 +68,21 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, format:
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 { 0xFFFE } else { tag }).to_le_bytes());
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).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 / _IEEE_FLOAT
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
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(&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(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
}
v.extend_from_slice(b"data");
v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes());
@ -113,31 +93,16 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, format:
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())?;
// 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);
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);
v.extend_from_slice(b"FORM");
v.extend_from_slice(&form.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(b"AIFFCOMM");
v.extend_from_slice(&18u32.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
@ -147,12 +112,13 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, format:
}
}
/// 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();
/// 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);
out.reserve(interleaved.len().saturating_mul(bps));
for &s in interleaved {
let q = if format.is_float() { SampleFormat::float(s).to_bits() as i32 } else { format.quantise(s) };
let q = quantise(s, bits);
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()),
@ -161,19 +127,19 @@ pub fn encode(container: PcmContainer, interleaved: &[f32], format: SampleFormat
}
/// 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, format: SampleFormat) -> Result<Vec<u8>, String> {
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
let frames = (interleaved.len() / usize::from(channels.max(1))) as u64;
let mut v = header(container, channels, sample_rate, format, frames)?;
encode(container, interleaved, format, &mut v);
v.extend_from_slice(pad(frames, channels, format));
let mut v = header(container, channels, sample_rate, bits, frames)?;
encode(container, interleaved, bits, &mut v);
v.extend_from_slice(pad(frames, channels, bits));
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, format: SampleFormat) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, format)
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)
}
/// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`).
@ -190,9 +156,9 @@ fn extended(rate: u32) -> [u8; 10] {
out
}
/// 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)
/// 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)
}
/// File name of frame `index` of an image sequence of `count` frames exported to `path`:
@ -215,11 +181,6 @@ 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]);
@ -237,10 +198,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, I24).unwrap();
let w = write_wav(&s, 2, 48_000, 24).unwrap();
assert_eq!(w.len(), 44 + 12);
assert_eq!(&w[34..36], &24u16.to_le_bytes());
let a = write_aiff(&s, 2, 48_000, I16).unwrap();
let a = write_aiff(&s, 2, 48_000, 16).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());
@ -259,7 +220,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, I24, frames).unwrap();
let h = header(PcmContainer::Wav, 6, 96_000, 24, frames).unwrap();
let data = frames * 6 * 3;
assert_eq!(&h[..4], b"RF64");
assert_eq!(le32(&h, 4), u32::MAX);
@ -277,129 +238,37 @@ 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, I16, max).unwrap();
let h = header(PcmContainer::Wav, 2, 48_000, 16, 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, I16, max + 1).unwrap()[..4], b"RF64");
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, 16, max + 1).unwrap()[..4], b"RF64");
}
#[test]
fn aiff_past_4_gib_is_refused_not_wrapped() {
let e = header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60 * 8).unwrap_err();
let e = header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60 * 8).unwrap_err();
assert!(e.contains("export WAV"), "{e}");
assert!(header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60).is_ok());
assert!(header(PcmContainer::Aiff, 2, 48_000, 24, 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, I24, u64::MAX).is_err());
assert!(header(c, u16::MAX, 48_000, I24, 1).is_err());
assert!(header(c, 2, 48_000, 24, u64::MAX).is_err());
assert!(header(c, u16::MAX, 48_000, 24, 1).is_err());
}
assert!(header(PcmContainer::Wav, 6, u32::MAX, I24, 1).is_err());
assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 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, I24).unwrap();
let w = write_wav(&[0.25], 1, 48_000, 24).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, I24).unwrap();
let a = write_aiff(&[0.25], 1, 48_000, 24).unwrap();
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
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);
assert!(pad(2, 1, 24).is_empty());
}
/// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it).
@ -409,8 +278,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, I24, frames).unwrap();
encode(PcmContainer::Wav, &[0.0; 9_600], I24, &mut f);
let mut f = header(PcmContainer::Wav, 2, 48_000, 24, frames).unwrap();
encode(PcmContainer::Wav, &[0.0; 9_600], 24, &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,96 +223,15 @@ pub struct AudioSettings {
pub channels: u32,
/// AAC bitrate.
pub bitrate_kbps: u32,
/// PCM and FLAC sample size: 16, 24 or 32 (other values round down to one of them, 16 at least).
/// PCM and FLAC sample size: 16 or 24.
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, 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) }
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 }
}
}
@ -554,16 +473,6 @@ 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) {
@ -639,7 +548,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 * f64::from(self.audio.sample_format().bits());
let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 };
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
@ -740,13 +649,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}", 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)
}
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),
}
} else {
"No audio".to_string()

View File

@ -1135,202 +1135,3 @@ 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–32)
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24)
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,11 +37,10 @@ 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 (32-bit: independent only, a side channel would need 33 bits) |
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest |
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+).
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples,
seek tables.
| Level | LPC order | Rice partition order | Stereo decorrelation |
|---|---|---|---|

View File

@ -53,8 +53,6 @@ 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–32 bit, 1–8 channels) → a `.flac` stream: the
//! - [`Encoder`]: planar integer samples (8–24 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,8 +61,7 @@ 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–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.
/// 8–24.
pub bits_per_sample: u8,
/// Samples per channel per frame, 16–65535 (4096 by default).
pub block_size: u16,
@ -93,8 +92,8 @@ impl Encoder {
if !(1..=8).contains(&cfg.channels) {
return Err(Error::InvalidConfig("1 to 8 channels"));
}
if !(8..=32).contains(&cfg.bits_per_sample) {
return Err(Error::InvalidConfig("8 to 32 bits per sample"));
if !(8..=24).contains(&cfg.bits_per_sample) {
return Err(Error::InvalidConfig("8 to 24 bits per sample"));
}
if !(1..=1_048_575).contains(&cfg.sample_rate) {
return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz"));
@ -171,9 +170,8 @@ impl Encoder {
if planar.iter().any(|c| c.len() != n) {
return Err(Error::UnequalChannels);
}
// 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) {
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) {
return Err(Error::SampleRange(bad));
}
for (p, c) in self.pending.iter_mut().zip(planar) {
@ -219,8 +217,7 @@ impl Encoder {
let effort = self.effort;
let code = |x: &[i32], b: u8| predict::choose(x, b, effort);
let data = match block {
// the side channel takes one bit more than the samples: at most 32 (i32)
[l, r] if effort.stereo && bps < 32 => {
[l, r] if effort.stereo => {
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 = ((1i64 << (bps - 1)) - 1) as f64;
let max = f64::from((1i32 << (bps - 1)) - 1);
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 i64).clamp(-(max as i64) - 1, max as i64) as i32
((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32)
})
.collect()
}
@ -93,14 +93,13 @@ 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,
// 24 and 32 bit compare
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16
// and 24 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 {
@ -119,7 +118,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, 32] {
for bps in [16u8, 24] {
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;
@ -130,13 +129,11 @@ fn every_signal_round_trips_exactly() {
#[test]
fn channel_layouts_rates_and_sizes() {
let cases: [(&str, u8, u8, u32, usize); 8] = [
let cases: [(&str, u8, u8, u32, usize); 6] = [
("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),
];
@ -170,8 +167,7 @@ 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, 33)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 7)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).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();
@ -183,21 +179,3 @@ 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());
}

View File

@ -1,365 +0,0 @@
//! 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, frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709 }
FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working: filmcraft_color::WorkingSpace::Rec709 }
}
fn effect(id: &str, params: &[(&str, ParamValue)]) -> EffectInstance {

View File

@ -38,7 +38,6 @@ 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

@ -569,9 +569,8 @@ impl Pps {
let sps = spss.get(sps_id as usize).and_then(|s| s.as_ref()).ok_or_else(|| crate::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?;
let entropy_coding_mode = r.read_flag()?;
let bottom_field_pic_order_in_frame_present = r.read_flag()?;
let num_slice_groups_minus1 = r.read_ue()?;
ensure!(num_slice_groups_minus1 < 8, "num_slice_groups out of range");
let num_slice_groups = num_slice_groups_minus1 + 1;
let num_slice_groups = r.read_ue()? + 1;
ensure!(num_slice_groups <= 8, "num_slice_groups out of range");
let mut slice_group_map_type = 0;
if num_slice_groups > 1 {
// Parsed for completeness; FMO decoding is unsupported.
@ -593,10 +592,7 @@ impl Pps {
r.read_ue()?;
}
6 => {
let n_minus1 = r.read_ue()?;
// pic_size_in_map_units_minus1: one slice_group_id per map unit of the picture.
ensure!(n_minus1 < sps.pic_width_in_mbs.saturating_mul(sps.pic_height_in_map_units), "pic_size_in_map_units out of range");
let n = n_minus1 + 1;
let n = r.read_ue()? + 1;
let bits = 32 - (num_slice_groups - 1).leading_zeros();
for _ in 0..n {
r.read_bits(bits)?;
@ -605,23 +601,13 @@ impl Pps {
_ => {}
}
}
let num_ref_idx_l0_minus1 = r.read_ue()?;
let num_ref_idx_l1_minus1 = r.read_ue()?;
ensure!(num_ref_idx_l0_minus1 < 32 && num_ref_idx_l1_minus1 < 32, "num_ref_idx_default_active out of range");
let num_ref_idx_l0_default_active = num_ref_idx_l0_minus1 + 1;
let num_ref_idx_l1_default_active = num_ref_idx_l1_minus1 + 1;
let num_ref_idx_l0_default_active = r.read_ue()? + 1;
let num_ref_idx_l1_default_active = r.read_ue()? + 1;
ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range");
let weighted_pred = r.read_flag()?;
// 7.4.2.2.1 fall-back: a value of 3 shall be considered equal to 2.
let weighted_bipred_idc = (r.read_bits(2)?).min(2);
// 7.4.2.2: pic_init_qp_minus26 in -(26 + QpBdOffsetY)..=25, pic_init_qs_minus26 in -26..=25.
let qp_bd_offset_y = 6 * (sps.bit_depth_luma.saturating_sub(8)) as i32;
let qp_min = -(26 + qp_bd_offset_y);
let pic_init_qp_minus26 = r.read_se()?;
ensure!((qp_min..=25).contains(&pic_init_qp_minus26), "pic_init_qp_minus26 out of range");
let pic_init_qs_minus26 = r.read_se()?;
ensure!((-26..=25).contains(&pic_init_qs_minus26), "pic_init_qs_minus26 out of range");
let pic_init_qp = 26 + pic_init_qp_minus26;
let pic_init_qs = 26 + pic_init_qs_minus26;
let weighted_bipred_idc = r.read_bits(2)?;
let pic_init_qp = 26 + r.read_se()?;
let pic_init_qs = 26 + r.read_se()?;
let chroma_qp_index_offset = r.read_se()?;
ensure!((-12..=12).contains(&chroma_qp_index_offset), "chroma_qp_index_offset out of range");
let deblocking_filter_control_present = r.read_flag()?;

View File

@ -1,142 +0,0 @@
//! #1441: malformed PPS fields must return `Error::Invalid`, not panic.
use filmcraft_h264::Decoder;
/// Valid, synthetic Baseline SPS (176x144, 8-bit).
const SPS: [u8; 12] = [0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1e, 0xda, 0x0b, 0x13, 0x90];
fn decode_pps(pps: &[u8]) -> Result<(), filmcraft_h264::Error> {
let mut decoder = Decoder::with_threads(1);
decoder.decode(&SPS, 0).expect("sps decodes");
decoder.decode(pps, 0).map(|_| ())
}
/// The reporter's literal: PPS with `num_ref_idx_l0_default_active_minus1 = u32::MAX`.
#[test]
fn hostile_pps_returns_an_error_instead_of_panicking() {
let pps = [0x00, 0x00, 0x00, 0x01, 0x68, 0xc8, 0x00, 0x00, 0x03, 0x00, 0x04, 0x00, 0x00, 0x03, 0x00, 0x02, 0x3c, 0x80];
let outcome = std::panic::catch_unwind(|| {
let mut decoder = Decoder::with_threads(1);
decoder.decode(&SPS, 0).unwrap();
decoder.decode(&pps, 0)
});
assert!(outcome.is_ok_and(|result| result.is_err()));
}
/// Write ue(v) as bits (helper for building synthetic PPS payloads).
fn push_ue(bits: &mut Vec<bool>, v: u32) {
let x = v as u64 + 1;
let len = 64 - x.leading_zeros();
for _ in 1..len {
bits.push(false);
}
for i in (0..len).rev() {
bits.push(x & (1 << i) != 0);
}
}
fn push_bits(bits: &mut Vec<bool>, v: u32, n: u32) {
for i in (0..n).rev() {
bits.push(v & (1 << i) != 0);
}
}
fn to_bytes(bits: &[bool]) -> Vec<u8> {
let mut out = Vec::new();
for chunk in bits.chunks(8) {
let mut b = 0u8;
for (i, v) in chunk.iter().enumerate() {
b |= (*v as u8) << (7 - i);
}
out.push(b);
}
out
}
/// Build a PPS NAL (header byte 0x68 + payload) with `num_slice_groups_minus1` = u32::MAX.
#[test]
fn hostile_num_slice_groups_is_rejected() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, u32::MAX); // num_slice_groups_minus1
bits.extend(std::iter::repeat_n(false, 64));
let payload = to_bytes(&bits);
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&payload);
assert!(std::panic::catch_unwind(|| decode_pps(&nal)).is_ok_and(|r| r.is_err()));
}
/// `num_ref_idx_l1_default_active_minus1 = u32::MAX` (l0 valid) must error, not panic.
#[test]
fn hostile_num_ref_idx_l1_is_rejected() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0); // num_slice_groups_minus1
push_ue(&mut bits, 1); // num_ref_idx_l0_minus1
push_ue(&mut bits, u32::MAX); // num_ref_idx_l1_minus1
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
assert!(std::panic::catch_unwind(|| decode_pps(&nal)).is_ok_and(|r| r.is_err()));
}
/// `pic_init_qp_minus26 = i32::MAX` must error, not overflow.
#[test]
fn hostile_pic_init_qp_is_rejected() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0); // num_slice_groups_minus1
push_ue(&mut bits, 0); // num_ref_idx_l0_minus1
push_ue(&mut bits, 0); // num_ref_idx_l1_minus1
bits.push(false); // weighted_pred
push_bits(&mut bits, 0, 2); // weighted_bipred_idc
// se(i32::MAX): codeNum = 2*MAX-1 = u32::MAX-2... encode via ue of huge codeNum
push_ue(&mut bits, u32::MAX - 2); // codeNum for +2^31-1... any out-of-range codeNum works
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
assert!(std::panic::catch_unwind(|| decode_pps(&nal)).is_ok_and(|r| r.is_err()));
}
/// `weighted_bipred_idc = 3` is concealed to 2 per 7.4.2.2.1 (not rejected, not a panic).
#[test]
fn reserved_weighted_bipred_idc_is_concealed() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
bits.push(false); // weighted_pred
push_bits(&mut bits, 3, 2); // weighted_bipred_idc = 3 (reserved)
push_ue(&mut bits, 0); // pic_init_qp_minus26 = 0
push_ue(&mut bits, 0); // pic_init_qs_minus26 = 0
push_ue(&mut bits, 0); // chroma_qp_index_offset = 0
bits.push(false); // deblocking_filter_control_present
bits.push(false); // constrained_intra_pred
bits.push(false); // redundant_pic_cnt_present
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
// parses fine; the decoder treats the field as 2
decode_pps(&nal).expect("weighted_bipred_idc=3 concealed to 2");
}
/// A well-formed minimal PPS still parses (no regression).
#[test]
fn minimal_valid_pps_parses() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
bits.push(false); // weighted_pred
push_bits(&mut bits, 0, 2); // weighted_bipred_idc
push_ue(&mut bits, 0); // pic_init_qp_minus26
push_ue(&mut bits, 0); // pic_init_qs_minus26
push_ue(&mut bits, 0); // chroma_qp_index_offset
bits.push(true); // deblocking_filter_control_present
bits.push(false); // constrained_intra_pred
bits.push(false); // redundant_pic_cnt_present
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
if let Err(e) = decode_pps(&nal) {
panic!("minimal PPS rejected: {e}");
}
}

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`, `A_MPEG/L1` | `Ac3`, `Eac3`, `Mp3`, `Mp2`, `Mp1` | |
| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2` | `Ac3`, `Eac3`, `Mp3`, `Mp2` | |
| `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,8 +45,6 @@ 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}`.
@ -88,7 +86,6 @@ 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(),
@ -176,7 +173,6 @@ 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", "hdr", "dng"];
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "exr", "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,9 +197,6 @@ 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, Radiance HDR and DNG camera raw as linear floats (DNG with its metadata, for
/// Decode one frame file: OpenEXR 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,10 +229,6 @@ 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, Radiance HDR, DNG camera raw) as media
//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, 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,8 +11,6 @@ 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);
@ -20,7 +18,6 @@ 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])
@ -49,9 +46,6 @@ 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();
@ -70,9 +64,6 @@ 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

@ -1,6 +1,5 @@
//! OpenEXR stills (and the frames of EXR image sequences), read from the public OpenEXR file layout:
//! single-part scan-line files with no, RLE, ZIPS (one line per chunk), ZIP or PXR24 (16 lines)
//! compression,
//! single-part scan-line files with no, RLE, ZIPS (one line per chunk) or ZIP (16 lines) compression,
//! half, float or uint channels. R, G, B and A become the frame's channels (missing colour channels
//! are 0, missing alpha is opaque); a luminance-only file (Y) is shown grey. EXR values are
//! scene-linear with premultiplied alpha, which is exactly the compositor's working format, so the
@ -8,7 +7,7 @@
//!
//! The display window is the frame; pixels of the data window outside it are cut, and the parts of
//! it the data window does not cover are transparent. Tiled, deep and multi-part files, chroma
//! subsampled channels and the PIZ, B44 and DWA compressions are refused with a clear error.
//! subsampled channels and the PIZ, PXR24, B44 and DWA compressions are refused with a clear error.
mod inflate;
@ -135,15 +134,12 @@ enum Compression {
None,
Rle,
Zip,
/// Lossy 24-bit floats: deflated byte planes of per-line differences.
Pxr24,
}
impl Compression {
fn lines_per_chunk(self, code: u8) -> usize {
match self {
Compression::Zip if code == 3 => 16,
Compression::Pxr24 => 16,
_ => 1,
}
}
@ -231,7 +227,6 @@ fn header(name: &str, b: &[u8]) -> Result<Header> {
0 => Compression::None,
1 => Compression::Rle,
2 | 3 => Compression::Zip,
5 => Compression::Pxr24,
_ => {
let what = ["PIZ", "PXR24", "B44", "B44A", "DWAA", "DWAB"].get(usize::from(code).wrapping_sub(4)).copied().unwrap_or("unknown");
return Err(MediaError::Unsupported(format!("{name}: {what} compressed OpenEXR is not supported yet")));
@ -334,45 +329,6 @@ fn unrle(src: &[u8], expected: usize) -> Option<Vec<u8>> {
(out.len() == expected).then_some(out)
}
/// Bytes of one sample in PXR24's byte planes (a float keeps its top 24 bits only).
fn plane_count(ty: PixelType) -> usize {
match ty {
PixelType::Half => 2,
PixelType::Float => 3,
PixelType::Uint => 4,
}
}
/// Undo PXR24: per scan line and channel, the samples' differences (to the previous sample of the
/// line, modulo 2^16 / 2^24 / 2^32) are stored as byte planes, most significant first. Returns the
/// lines in the plain layout (little-endian samples).
fn unpxr24(planes: &[u8], channels: &[Channel], width: usize, lines: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(planes.len().checked_mul(2)?);
let mut at = 0usize;
for _ in 0..lines {
for c in channels {
let n = plane_count(c.ty);
let len = n.checked_mul(width)?;
let plane = planes.get(at..at.checked_add(len)?)?;
at += len;
let mut acc = 0u32;
for x in 0..width {
let mut d = 0u32;
for k in 0..n {
d = d << 8 | u32::from(*plane.get(k * width + x)?);
}
acc = acc.wrapping_add(d);
match c.ty {
PixelType::Half => out.extend_from_slice(&(acc as u16).to_le_bytes()),
PixelType::Float => out.extend_from_slice(&((acc & 0xFF_FFFF) << 8).to_le_bytes()),
PixelType::Uint => out.extend_from_slice(&acc.to_le_bytes()),
}
}
}
}
(at == planes.len()).then_some(out)
}
/// Decode an EXR file into premultiplied linear RGBA floats.
pub fn decode(name: &str, b: &[u8]) -> Result<Decoded> {
let h = header(name, b)?;
@ -447,12 +403,6 @@ pub fn decode(name: &str, b: &[u8]) -> Result<Decoded> {
Compression::None => return Err(bad(format!("chunk {i} has the wrong size"))),
Compression::Rle => unpredict(unrle(data, expected).ok_or_else(|| bad(format!("chunk {i}: bad RLE data")))?).into(),
Compression::Zip => unpredict(inflate::zlib(data, expected).map_err(|e| bad(format!("chunk {i}: {e}")))?).into(),
Compression::Pxr24 => {
let per_line = h.channels.iter().try_fold(0usize, |t, c| t.checked_add(plane_count(c.ty).checked_mul(dw)?));
let planes = per_line.and_then(|l| l.checked_mul(n)).ok_or_else(|| bad("chunk size".into()))?;
let inflated = inflate::zlib(data, planes).map_err(|e| bad(format!("chunk {i}: {e}")))?;
unpxr24(&inflated, &h.channels, dw, n).ok_or_else(|| bad(format!("chunk {i}: bad PXR24 data")))?.into()
}
}
};
for (l, out) in rows.chunks_exact_mut(row).enumerate() {

View File

@ -25,35 +25,6 @@ fn predict(raw: &[u8]) -> Vec<u8> {
t
}
/// The test-side PXR24 packing of the `lines` of `raw` (plain layout): per line and channel, the
/// differences to the previous sample as byte planes, floats cut to their top 24 bits.
fn pxr24(raw: &[u8], chans: &[Chan], width: usize, lines: usize) -> Vec<u8> {
let mut at = 0;
let mut out = Vec::new();
for _ in 0..lines {
for (_, t) in chans {
let size = if *t == 1 { 2 } else { 4 };
let mut prev = 0u32;
let mut planes = vec![Vec::new(); size];
for x in 0..width {
let b = &raw[at + x * size..at + (x + 1) * size];
let v = match *t {
1 => u32::from(u16::from_le_bytes([b[0], b[1]])),
2 => u32::from_le_bytes([b[0], b[1], b[2], b[3]]) >> 8,
_ => u32::from_le_bytes([b[0], b[1], b[2], b[3]]),
};
let d = v.wrapping_sub(prev);
prev = v;
let n = if *t == 2 { 3 } else { size };
planes[0..n].iter_mut().enumerate().for_each(|(k, p)| p.push((d >> (8 * (n - 1 - k))) as u8));
}
at += width * size;
out.extend(planes.concat());
}
}
out
}
/// OpenEXR RLE: runs of 3+ equal bytes as runs, the rest as literals.
fn rle(t: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
@ -134,7 +105,7 @@ fn exr_file(chans: &[Chan], data: [i32; 4], display: [i32; 4], comp: u8, value:
attr(&mut f, "lineOrder", "lineOrder", &[0]);
attr(&mut f, "pixelAspectRatio", "float", &1f32.to_le_bytes());
f.push(0);
let lpc = if comp == 3 || comp == 5 { 16 } else { 1 };
let lpc = if comp == 3 { 16 } else { 1 };
let ys: Vec<i32> = (data[1]..=data[3]).step_by(lpc).collect();
let table = f.len();
f.resize(table + ys.len() * 8, 0);
@ -150,10 +121,6 @@ fn exr_file(chans: &[Chan], data: [i32; 4], display: [i32; 4], comp: u8, value:
let packed = match comp {
0 => raw,
1 => rle(&predict(&raw)),
5 => {
let lines = (data[3] - y0 + 1).min(16) as usize;
zlib_stored(&pxr24(&raw, chans, (data[2] - data[0] + 1) as usize, lines))
}
_ => zlib_stored(&predict(&raw)),
};
let off = f.len() as u64;
@ -211,7 +178,7 @@ fn rgba_half_every_compression() {
// pixel (3, 2): R G B A
let p = &px[(2 * 7 + 3) * 4..][..4];
assert_eq!(p, &[pattern(3, 3, 2), pattern(2, 3, 2), pattern(1, 3, 2), pattern(0, 3, 2)]);
for comp in [1, 2, 3, 5] {
for comp in [1, 2, 3] {
let f = exr_file(RGBA_HALF, [0, 0, w - 1, h - 1], [0, 0, w - 1, h - 1], comp, pattern);
assert_ne!(f, want, "compression {comp} packs differently");
let d = decode("a.exr", &f).unwrap_or_else(|e| panic!("compression {comp}: {e}"));
@ -219,43 +186,6 @@ fn rgba_half_every_compression() {
}
}
#[test]
fn pxr24_keeps_half_and_uint_exactly_and_floats_to_24_bits() {
// 40 wide, 35 tall: three chunks, the last of 3 lines; one channel of every type
let chans: &[Chan] = &[("B", 2), ("G", 1), ("R", 2), ("Z", 0)];
let value = |c: usize, x: i32, y: i32| match c {
0 => 1.0 / 3.0 + x as f32 * 0.37 - y as f32 * 1.5, // not exact in 24 bits
1 => x as f32 * 0.0625 + y as f32,
2 => 6000.7 - (x * y) as f32 * 1.25,
_ => (x * 1000 + y) as f32,
};
let f = exr_file(chans, [0, 0, 39, 34], [0, 0, 39, 34], 5, value);
let d = decode("p.exr", &f).unwrap();
assert_eq!((d.frame.width, d.frame.height), (40, 35));
assert_eq!(d.pixel_format, "RGB 32-bit float");
let px = floats(&d);
for (x, y) in [(0, 0), (1, 0), (39, 0), (0, 16), (7, 17), (39, 34), (13, 33)] {
let p = &px[(y * 40 + x) * 4..][..4];
let (x, y) = (x as i32, y as i32);
let cut = |v: f32| f32::from_bits(v.to_bits() & 0xFFFF_FF00);
assert_eq!(p, &[cut(value(2, x, y)), half(f16(value(1, x, y))), cut(value(0, x, y)), 1.0], "pixel ({x}, {y})");
}
}
#[test]
fn damaged_pxr24_is_an_error() {
let mut f = exr_file(RGBA_HALF, [0, 0, 9, 19], [0, 0, 9, 19], 5, pattern);
assert!(decode("p.exr", &f).is_ok());
// cut the file inside the last chunk
f.truncate(f.len() - 20);
assert!(decode("p.exr", &f).is_err());
// a chunk whose deflate data is garbage
let mut g = exr_file(RGBA_HALF, [0, 0, 9, 19], [0, 0, 9, 19], 5, pattern);
let at = g.windows(2).rposition(|w| w == [0x78, 0x01]).unwrap();
g[at] = 0;
assert!(decode("p.exr", &g).is_err());
}
#[test]
fn luminance_only_is_grey_and_opaque() {
let f = exr_file(&[("Y", 2)], [0, 0, 3, 1], [0, 0, 3, 1], 2, |_, x, y| x as f32 * 0.25 + y as f32 * 4.0);
@ -312,8 +242,6 @@ fn unsupported_files_say_why() {
assert!(matches!(decode("t.exr", &tiled), Err(MediaError::Unsupported(m)) if m.contains("tiled")));
let piz = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 4, pattern);
assert!(matches!(decode("p.exr", &piz), Err(MediaError::Unsupported(m)) if m.contains("PIZ")));
let b44 = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 6, pattern);
assert!(matches!(decode("b.exr", &b44), Err(MediaError::Unsupported(m)) if m.contains("B44")));
let dwab = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 9, pattern);
assert!(matches!(decode("d.exr", &dwab), Err(MediaError::Unsupported(m)) if m.contains("DWAB")));
let ry = exr_file(&[("BY", 1), ("RY", 1), ("Y", 1)], [0, 0, 1, 1], [0, 0, 1, 1], 0, pattern);

View File

@ -1,223 +0,0 @@
//! 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

@ -1,134 +0,0 @@
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

@ -1,174 +0,0 @@
//! 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

@ -141,51 +141,3 @@ fn exr_sequence_plays_linear_float_frames() {
assert!(max_diff(&ours, &want) == 0.0, "frame {i}");
}
}
/// PXR24 (lossy 24-bit floats; ffmpeg's encoder cannot write it): OpenImageIO writes the files and
/// re-writes them uncompressed (its OpenEXR reader undoes the compression), and our decode of the
/// PXR24 file must give exactly the floats of that uncompressed copy.
#[test]
fn exr_pxr24_matches_openexr() {
let tool = |args: &[&str]| std::process::Command::new("oiiotool").args(args).status().is_ok_and(|s| s.success());
if !tool(&["--version"]) {
eprintln!("SKIPPED: oiiotool is not installed");
return;
}
for (name, depth, channels, size) in [("f4", "float", 4, "95x63"), ("h4", "half", 4, "40x35"), ("f3", "float", 3, "17x16"), ("h3", "half", 3, "16x17")] {
let packed = dir().join(format!("pxr24_{name}.exr"));
let plain = dir().join(format!("pxr24_{name}_plain.exr"));
let ok = |p: &Path| {
filmcraft_testkit::fixtures::generate(p, |tmp| {
let t = tmp.to_string_lossy().to_string();
if p == packed.as_path() {
tool(&[
"--pattern",
"fill:topleft=1,0,0,0.5:topright=0,1,0,0.5:bottomleft=0,0,1,1:bottomright=1,1,1,1",
size,
&channels.to_string(),
"--noise:type=gaussian:mean=0:stddev=0.3",
"-d",
depth,
"--compression",
"pxr24",
"-o",
&t,
])
} else {
tool(&[packed.to_string_lossy().as_ref(), "-d", depth, "--compression", "none", "-o", &t])
}
})
};
let (Some(packed), Some(_)) = (ok(&packed), ok(&plain)) else {
eprintln!("SKIPPED: oiiotool cannot write {name}");
continue;
};
let plain = dir().join(format!("pxr24_{name}_plain.exr"));
let decode = |p: &Path| {
floats(&filmcraft_media::open_bytes("still.exr", std::fs::read(p).unwrap().into(), &[]).unwrap().video_frame(FrameRequest::full(Tick(0))).unwrap())
};
let (ours, want) = (decode(&packed), decode(&plain));
assert_eq!(max_diff(&ours, &want), 0.0, "{name}");
}
}

View File

@ -1,99 +0,0 @@
//! 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

@ -1,156 +0,0 @@
//! 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

@ -91,9 +91,6 @@ pub(crate) struct Splitter {
open: Option<Open>,
pub units: Vec<Unit>,
pub skipped: u64,
/// Units beyond [`MAX_UNITS`] were dropped.
pub truncated: bool,
max_units: usize,
// MPEG video state
header_start: Option<u64>,
seq_in_unit: bool,
@ -177,6 +174,7 @@ 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.
@ -222,8 +220,7 @@ pub fn frame_info(codec: &Codec, b: &[u8]) -> Option<FrameInfo> {
// E-AC-3: fscod(2) numblkscod(2) acmod(3) lfeon(1)
let fscod = b[4] >> 6;
let (rate, blocks) = if fscod == 3 {
// fscod2 3 is reserved (no sample rate)
(*[24_000, 22_050, 16_000].get(((b[4] >> 4) & 3) as usize)?, 6)
([24_000, 22_050, 16_000, 0][((b[4] >> 4) & 3) as usize], 6)
} else {
([48_000, 44_100, 32_000][fscod as usize], [1, 2, 3, 6][((b[4] >> 4) & 3) as usize])
};
@ -233,8 +230,7 @@ pub fn frame_info(codec: &Codec, b: &[u8]) -> Option<FrameInfo> {
let fscod = b[4] >> 6;
let rate = *[48_000u32, 44_100, 32_000].get(fscod as usize)?;
// bsi: bsid(5) bsmod(3) acmod(3) [cmixlev(2)] [surmixlev(2)] [dsurmod(2)] lfeon(1)
let b6 = *b.get(6)?;
let acmod = b6 >> 5;
let acmod = b[6] >> 5;
let mut bit = 3; // bits of byte 6 consumed
if acmod & 1 != 0 && acmod != 1 {
bit += 2;
@ -245,7 +241,7 @@ pub fn frame_info(codec: &Codec, b: &[u8]) -> Option<FrameInfo> {
if acmod == 2 {
bit += 2;
}
let word = u16::from_be_bytes([b6, *b.get(7)?]);
let word = u16::from_be_bytes([b[6], *b.get(7)?]);
let lfe = (word >> (15 - bit)) & 1;
Some(FrameInfo { sample_rate: rate, channels: AC3_CHANNELS[acmod as usize] + lfe as u32, samples: 1536, variant: bsid })
}
@ -262,18 +258,6 @@ fn eac3_joins_unit(b: &[u8]) -> bool {
}
}
/// Shortest E-AC-3 syncframe accepted. A real syncframe holds the 6 header bytes read here plus
/// bsi and audio blocks (the smallest real ones are tens of bytes), so shorter "frames"
/// (`0B 77 00 01` is 4 bytes) are noise; they would otherwise turn every 4 input bytes into a
/// 64-byte `Unit`.
const EAC3_MIN_FRAME: usize = 8;
/// Most units one stream may index. A `Unit` is 64 bytes, so this bounds a stream's table to
/// 512 MiB. It is 47 hours of 48 kHz AAC (the densest audio, 46.9 units/s), 9 hours of 240 fps
/// video: far beyond any real file, while a hostile file (a unit per 7 input bytes) cannot grow
/// the table without limit.
pub(crate) const MAX_UNITS: usize = 1 << 23;
/// Full-bandwidth channels per AC-3 audio coding mode (A/52 Table 5.8).
const AC3_CHANNELS: [u32; 8] = [2, 1, 2, 3, 3, 4, 4, 5];
@ -305,8 +289,7 @@ fn frame_len(sync: AudioSync, b: &[u8]) -> Option<usize> {
ac3_frame_bytes(b[4] >> 6, b[4] & 0x3F)
} else if bsid <= 16 {
// E-AC-3: frmsiz
let len = ((((b[2] & 7) as usize) << 8) | b[3] as usize) * 2 + 2;
(len >= EAC3_MIN_FRAME).then_some(len)
Some(((((b[2] & 7) as usize) << 8) | b[3] as usize) * 2 + 2)
} else {
None
}
@ -326,8 +309,6 @@ impl Splitter {
open: None,
units: Vec::new(),
skipped: 0,
truncated: false,
max_units: MAX_UNITS,
header_start: None,
seq_in_unit: false,
gop: None,
@ -392,10 +373,6 @@ impl Splitter {
if end <= o.start || o.pos == u64::MAX {
return;
}
if self.units.len() >= self.max_units {
self.truncated = true;
return;
}
let size = (end - o.start).min(u32::MAX as u64) as u32;
self.units.push(Unit { pts: o.pts, dts: o.dts, key: o.key, disposable: o.disposable, size, pos: o.pos, offset: o.offset, picture: o.picture });
}
@ -405,12 +382,7 @@ impl Splitter {
if data.is_empty() {
return;
}
// PES-delimited modes resolve a unit's position from `pos` right here and never look a
// chunk up again, so remembering chunks there would only grow without bound (NAL streams
// keep theirs: an access unit can start inside a PES; `keep_from` prunes them)
if matches!(self.mode, Mode::MpegVideo | Mode::Audio(_) | Mode::Nal { .. }) {
self.chunks.push_back((pos, self.es_pos));
}
self.chunks.push_back((pos, self.es_pos));
if let Some(o) = self.open.as_mut()
&& o.pos == u64::MAX
&& o.start == self.es_pos
@ -728,61 +700,4 @@ mod tests {
// AC-3 frames are never joined
assert!(!eac3_joins_unit(&[0x0B, 0x77, 0x40, 0, 0, 8 << 3]));
}
#[test]
fn ac3_header_prefix_of_six_bytes_is_not_indexed_out_of_bounds() {
// exactly the six bytes frame_len() guarantees: byte 6 (acmod) is missing
assert_eq!(frame_info(&Codec::Ac3, &[0x0B, 0x77, 0, 0, 0x1E, 0x40]), None);
assert_eq!(frame_info(&Codec::Ac3, &[0x0B, 0x77, 0, 0, 0x1E, 0x40, 0]), None);
}
#[test]
fn eac3_reserved_sample_rate_is_refused() {
// fscod 3 and fscod2 3: no sample rate
let mut f = vec![0x0B, 0x77, 0, 7, 0xF0, 16 << 3];
f.resize(16, 0);
assert_eq!(frame_info(&Codec::Eac3, &f), None);
f[4] = 0xE0; // fscod2 2: 16 kHz
assert_eq!(frame_info(&Codec::Eac3, &f).map(|i| i.sample_rate), Some(16_000));
}
#[test]
fn four_byte_eac3_frames_make_no_units() {
let es: Vec<u8> = [0x0B, 0x77, 0x00, 0x01].repeat(10_000);
let mut s = Splitter::new(&Codec::Eac3);
s.feed(0, &es);
s.finish();
assert!(s.units.is_empty(), "{} units from 40000 noise bytes", s.units.len());
}
#[test]
fn pes_delimited_modes_do_not_retain_chunks() {
for codec in [Codec::LpcmBluray, Codec::Unknown(0x99)] {
let mut s = Splitter::new(&codec);
for k in 0..5000u64 {
s.pes_start(Some(k as i64), None);
s.feed(k * 188, &[0x11; 184]);
}
s.finish();
assert!(s.chunks.len() <= 2, "{codec:?}: {} chunks retained", s.chunks.len());
assert_eq!(s.units.len(), 5000, "{codec:?}");
assert!(s.units.iter().enumerate().all(|(k, u)| u.pos == k as u64 * 188 && u.offset == 0), "{codec:?}");
}
}
#[test]
fn unit_table_is_capped() {
let frame = |k: u8| {
let mut v = vec![0x0B, 0x77, 0, 7, (3 << 4) | (2 << 1), 16 << 3];
v.resize(16, k);
v
};
let mut s = Splitter::new(&Codec::Eac3);
s.max_units = 10;
let es: Vec<u8> = (0..100).flat_map(frame).collect();
s.feed(0, &es);
s.finish();
assert_eq!(s.units.len(), 10);
assert!(s.truncated);
}
}

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: byte offset of the packet holding the first byte; PS: byte offset of its PES packet.
/// TS: index 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,
@ -284,13 +284,6 @@ pub struct File {
pub warnings: Vec<String>,
}
/// Largest unit `read_unit` returns. The biggest real units are intra pictures (a few MB at
/// 4K/8K broadcast rates); a stream without PES timestamps can make one unit of a whole PID.
pub const MAX_UNIT_BYTES: u32 = 128 << 20;
/// Bytes reserved up front by `read_unit`; the buffer grows beyond this only as data is read.
const UNIT_PREALLOC: usize = 1 << 20;
impl File {
/// The first stream of `kind` whose codec satisfies `f`.
pub fn find(&self, kind: Kind, f: impl Fn(&Codec) -> bool) -> Option<usize> {
@ -301,11 +294,7 @@ impl File {
pub fn read_unit<S: ByteSource + ?Sized>(&self, src: &S, s: usize, i: usize) -> Result<Vec<u8>> {
let st = self.streams.get(s).ok_or_else(|| Error::Invalid("no such stream".into()))?;
let u = st.units.get(i).ok_or_else(|| Error::Invalid("no such unit".into()))?;
if u.size > MAX_UNIT_BYTES {
return Err(Error::Invalid(format!("unit {i} is {} bytes (limit {MAX_UNIT_BYTES})", u.size)));
}
// grow as bytes arrive: a damaged table must not reserve more than the file can supply
let mut out = Vec::with_capacity((u.size as usize).min(UNIT_PREALLOC));
let mut out = Vec::with_capacity(u.size as usize);
match self.format {
Format::Ts { packet_size, sync_offset } => ts::read(src, packet_size, sync_offset, st, u, &mut out)?,
Format::Ps { .. } => ps::read(src, st, u, &mut out)?,
@ -356,5 +345,3 @@ impl Unwrap {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod work_tests;

View File

@ -3,6 +3,9 @@
/// 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.
@ -25,8 +28,10 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
if b.len() < 6 || b[0] != 0 || b[1] != 0 || b[2] != 1 {
return None;
}
if plain_stream(b[3]) {
return Some(PesHeader { header_len: 6, pts: None, dts: 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 b.len() >= 9 && b[6] & 0xC0 == 0x80 {
// ISO/IEC 13818-1 syntax
@ -42,7 +47,7 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
dts = Some(timestamp(&b[14..19]));
}
}
return Some(PesHeader { header_len, pts, dts });
return Some(PesHeader { stream_id, packet_length, header_len, pts, dts });
}
// ISO/IEC 11172-1 syntax: stuffing, STD buffer, timestamps
let mut p = 6;
@ -73,7 +78,7 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
0 if c == 0x0F => p += 1,
_ => return None,
}
Some(PesHeader { header_len: p, pts, dts })
Some(PesHeader { stream_id, packet_length, header_len: p, pts, dts })
}
#[cfg(test)]
@ -93,7 +98,7 @@ mod tests {
b.extend(ts_bytes(1, dts));
b.push(0xAA);
let h = parse(&b).unwrap();
assert_eq!((h.header_len, h.pts, h.dts), (19, Some(pts), Some(dts)));
assert_eq!((h.stream_id, h.header_len, h.pts, h.dts), (0xE0, 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,33 +12,17 @@ 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,
#[cfg(test)]
zero_filled: 0,
}
Self { src, buf: Vec::new(), start: 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 {
// 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());
}
let want = n.max(1 << 20);
self.buf.resize(want, 0);
let got = read_some(self.src, pos, &mut self.buf)?;
self.buf.truncate(got);
@ -110,9 +94,6 @@ 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
@ -135,13 +116,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)?;
let Some(plen) = packet_len(head) else {
let head = win.get(pos, 32)?.to_vec();
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)?;
let w = win.get(p, 1 << 16)?.to_vec();
if w.len() < 4 {
break None;
}
@ -178,7 +159,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)?;
let body = win.get(pos, plen)?.to_vec();
if body.len() >= 12 {
let info = u16::from_be_bytes([body[8], body[9]]) as usize;
let mut i = 10 + info;
@ -193,11 +174,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)?;
let pkt = win.get(pos, plen)?.to_vec();
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() {
@ -258,14 +239,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)?;
let Some(plen) = packet_len(head) else {
let head = win.get(pos, 32)?.to_vec();
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)?;
if let Some(h) = pes::parse(pkt) {
let pkt = win.get(pos, plen)?.to_vec();
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 {
@ -285,86 +266,3 @@ 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,50 +276,6 @@ 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();
@ -330,171 +286,3 @@ fn timestamps_unwrap_across_the_33_bit_wrap() {
assert_eq!(u.unwrap(near + 1000), near + 1000);
assert_eq!(u.unwrap(4200), (1 << 33) + 4200);
}
fn adts_frame(k: u8) -> Vec<u8> {
let mut v = vec![0xFF, 0xF1, 0x50, 0x80, 0x0C, 0x9F, 0xFC];
v.extend([k; 93]);
v
}
#[test]
fn non_pes_payload_is_dropped_until_the_next_unit_start() {
// a unit start whose payload is not a PES header, then continuation packets that happen to
// hold ADTS frames: nothing of it may become a unit
let mut mux = TsMux::new(false);
mux.psi(&[(0x0F, 0x1100)]);
mux.packet(0x1100, true, &[0x55; 184]);
for k in 0..10 {
mux.packet(0x1100, false, &adts_frame(k));
}
let f = open(&mux.out).unwrap();
assert_eq!(f.streams.first().map(|s| s.units.len()), Some(0));
// a real PES packet afterwards is indexed again
let mut mux2 = TsMux::new(false);
mux2.psi(&[(0x0F, 0x1100)]);
mux2.packet(0x1100, true, &[0x55; 184]);
mux2.packet(0x1100, false, &adts_frame(1));
mux2.pes(0x1100, &pes(0xC0, Some(9000), None, &adts_frame(2), true));
let f = open(&mux2.out).unwrap();
let u = &f.streams[0].units;
assert_eq!(u.len(), 1);
assert_eq!(u[0].pts, Some(9000));
}
#[test]
fn adaptation_field_only_unit_start_keeps_the_pes_in_progress() {
let mut mux = TsMux::new(false);
mux.psi(&[(0x03, 0x1100)]);
let data = pes(0xC0, Some(9000), None, &[mp2_frame(0), mp2_frame(1)].concat(), true);
mux.packet(0x1100, true, &data[..184]);
// PUSI set, adaptation field only (no payload)
let cc = mux.cc.get(&0x1100).copied().unwrap_or(0);
let mut af = vec![0x47, 0x40 | 0x11, 0x00, 0x20 | cc, 183, 0x00];
af.resize(188, 0xFF);
mux.out.extend(af);
for chunk in data[184..].chunks(184) {
mux.packet(0x1100, false, chunk);
}
let f = open(&mux.out).unwrap();
let s = &f.streams[0];
assert_eq!(s.units.len(), 2);
assert_eq!(s.units[0].pts, Some(9000));
assert_eq!(f.read_unit(&mux.out, 0, 1).unwrap(), mp2_frame(1));
}
#[test]
fn oversized_unit_is_refused_without_reserving_it() {
let f = File {
format: Format::Ts { packet_size: 188, sync_offset: 0 },
streams: vec![Stream {
id: StreamId::Pid(0x100),
stream_type: 0x1B,
codec: Codec::H264,
language: None,
units: vec![Unit { pts: None, dts: None, key: true, disposable: false, size: u32::MAX, pos: 0, offset: 0, picture: None }],
lpcm: None,
pes_packets: 0,
skipped_bytes: 0,
}],
program: None,
pcr_range: None,
warnings: Vec::new(),
};
assert!(matches!(f.read_unit(&Vec::<u8>::new(), 0, 0), Err(Error::Invalid(_))));
}
/// 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,29 +6,14 @@ 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 = (crc << 8) ^ CRC_TABLE[((crc >> 24) as u8 ^ b) as usize];
crc ^= (b as u32) << 24;
for _ in 0..8 {
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
}
}
crc
}
@ -44,19 +29,9 @@ struct Packet<'a> {
payload: &'a [u8],
}
/// 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> {
fn packet(p: &[u8]) -> Option<Packet<'_>> {
if p.len() < 188 || p[0] != 0x47 {
return Err(Bad::Sync);
return None;
}
let pusi = p[1] & 0x40 != 0;
let pid = (((p[1] & 0x1F) as u16) << 8) | p[2] as u16;
@ -69,7 +44,7 @@ fn packet(p: &[u8]) -> std::result::Result<Packet<'_>, Bad> {
let al = p[4] as usize;
start = 5 + al;
if start > 188 {
return Err(Bad::Adaptation);
return None;
}
if al > 0 {
let flags = p[5];
@ -82,7 +57,7 @@ fn packet(p: &[u8]) -> std::result::Result<Packet<'_>, Bad> {
}
}
let has_payload = afc & 1 != 0;
Ok(Packet { pusi, pid, cc, has_payload, discontinuity, pcr, payload: if has_payload { &p[start..188] } else { &[] } })
Some(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.
@ -90,25 +65,14 @@ fn packet(p: &[u8]) -> std::result::Result<Packet<'_>, Bad> {
pub(crate) struct PesAsm {
hdr: Vec<u8>,
waiting: bool,
/// The packet that started the current unit was not a PES packet: its continuation packets
/// are dropped until the next unit start.
dropping: bool,
}
impl PesAsm {
/// The PES header completed by this packet (if any) and the packet's ES payload bytes.
pub fn packet<'a>(&mut self, pusi: bool, payload: &'a [u8]) -> (Option<PesHeader>, &'a [u8]) {
if pusi {
if payload.is_empty() {
// an adaptation-field-only packet carries no PES data: it must not cut short
// the PES packet in progress
return (None, &[]);
}
self.hdr.clear();
self.waiting = true;
self.dropping = false;
} else if self.dropping {
return (None, &[]);
} else if !self.waiting {
return (None, payload);
}
@ -124,7 +88,6 @@ impl PesAsm {
if self.hdr.len() > 1024 || (self.hdr.len() >= 6 && !(self.hdr[0] == 0 && self.hdr[1] == 0 && self.hdr[2] == 1)) {
// not a PES packet after all: drop until the next unit start
self.waiting = false;
self.dropping = true;
self.hdr.clear();
return (None, &[]);
}
@ -253,39 +216,6 @@ 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];
@ -306,39 +236,30 @@ 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)?;
if got < packet_size {
let whole = got / packet_size;
if whole == 0 {
break;
}
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;
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;
}
continue;
None => off = pos + got as u64,
}
k = usize::MAX;
break;
};
p += packet_size;
k += 1;
if let Some(pcr) = pk.pcr {
// 33-bit base × 300 wraps at 2^33·300
let wrap = (1i64 << 33) * 300;
@ -441,15 +362,13 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
}
e.split.feed(pos, bytes);
}
// `p >= 1` here (the loop ran at least once or `resync` moved past the failed packet)
off += p.max(1) as u64;
if k != usize::MAX {
off += (whole * packet_size) 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)"));
}
@ -465,9 +384,6 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
e.split.finish();
e.stream.units = std::mem::take(&mut e.split.units);
e.stream.skipped_bytes = e.split.skipped;
if e.split.truncated {
warnings.push(format!("PID {pid}: more than {} access units, the rest are not indexed", crate::es::MAX_UNITS));
}
streams.push(e.stream);
}
Ok(File { format: Format::Ts { packet_size, sync_offset }, streams, program, pcr_range, warnings })
@ -490,11 +406,7 @@ 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 pk = match packet(&raw[sync_offset..]) {
Ok(pk) => pk,
Err(Bad::Sync) => return Ok(()),
Err(Bad::Adaptation) => continue,
};
let Some(pk) = packet(&raw[sync_offset..]) else { return Ok(()) };
if pk.pid != pid {
continue;
}

View File

@ -1,113 +0,0 @@
//! 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,10 +97,9 @@ 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, another nal_ref_idc: a trailing zero byte is not a change) at
// the second sync sample: software
// a different SPS (same values, trailing zero byte) at the second sync sample: software
let mut changed_sps = sps.clone();
changed_sps[0] ^= 0x40;
changed_sps.push(0);
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,26 +156,6 @@ 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);
@ -204,121 +184,3 @@ 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,11 +109,9 @@ pub(crate) fn track_input_at(
if !tr.range().overlaps(&range) {
continue;
}
// 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()));
// 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));
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 {
@ -126,10 +124,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;
@ -145,25 +143,6 @@ 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,7 +192,6 @@ 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);
@ -362,7 +361,6 @@ 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,8 +29,6 @@ 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,
@ -936,7 +934,6 @@ mod tests {
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
@ -1532,7 +1529,6 @@ mod lumetri_cpu_parity_tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
@ -1572,7 +1568,6 @@ 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,9 +355,7 @@ impl FxOp {
}
}
"sharpen" => {
// 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 r = cx.px_scale.max(0.35);
let (rx, ry) = gaussian_boxes(w, h, r, r);
FxOp::Unsharp { rx, ry, amount: f(e, "amount", cx) / 100.0, threshold: 0.0 }
}
@ -1115,7 +1113,6 @@ 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,25 +189,14 @@ 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);
// 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 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));
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.
@ -216,27 +205,48 @@ 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, bl);
blend::composite(canvas.get(), &mixed, 1.0, Blend::Normal);
continue;
}
let Some(item) = track.item_at(t) else { continue };
if !item.enabled {
continue;
}
if is_adjustment(project, item) {
let pi = project.item(item.item);
if let Some(pi) = pi
&& matches!(pi.kind, ItemKind::AdjustmentLayer { .. })
{
if !opts.effects {
continue;
}
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 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 (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) {
@ -269,51 +279,6 @@ 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> {
@ -518,7 +483,6 @@ 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, frame_rate: 24.0, working }
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working }
}
fn lumetri(params: &[(&str, ParamValue)]) -> EffectInstance {

View File

@ -228,7 +228,6 @@ 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,7 +7,6 @@ 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};
@ -407,49 +406,6 @@ 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,24 +228,20 @@ 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 (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 { .. }));
/// transition the compositor cannot mix itself is showing.
fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool {
for tr in &seq.video_tracks {
if !tr.enabled {
continue;
}
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(trn) = tr.transitions.iter().find(|x| x.range().contains(t))
&& !simple_transition(&trn.effect.effect)
&& trn.effect.effect != "push"
{
return false;
}
if let Some(it) = tr.item_at(t)
&& adjustment(it)
&& project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. }))
{
return false;
}
@ -438,7 +434,6 @@ 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, TrackKind, Transition};
use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, 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, TrackKind::Video, 0);
hash_item(&mut h, project, it, t, 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, TrackKind::Video, 0);
hash_item(&mut h, project, it, t, 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, TrackKind::Audio, 0);
hash_item(&mut h, project, it, t, 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, TrackKind::Audio, 0);
hash_item(&mut h, project, it, t, 0);
heard = true;
}
}
@ -300,9 +300,7 @@ fn prints_time(it: &TrackItem) -> bool {
it.effects.iter().any(|e| e.enabled && e.effect == "timecode")
}
/// 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) {
fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, depth: u32) {
let mut c = it.clone();
c.id = Default::default();
c.start -= origin;
@ -314,10 +312,10 @@ fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, ki
c.name.clear();
}
h.json(&c);
hash_source(h, project, it.item, kind, depth);
hash_source(h, project, it.item, depth);
}
fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, kind: TrackKind, depth: u32) {
fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) {
let Some(pi) = project.item(id) else {
h.json(&"missing");
return;
@ -340,34 +338,18 @@ fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, kind: TrackKind, d
}
ItemKind::Subclip { parent, range, .. } => {
h.json(&("subclip", range));
hash_source(h, project, *parent, kind, depth + 1);
hash_source(h, project, *parent, 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, kind, depth + 1);
hash_item(h, project, it, Tick::ZERO, 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, audio_segments, effect_cost_ms, segment_at, video_segments};
use crate::preview::{Need, Segment, effect_cost_ms, segment_at, video_segments};
fn add_gen(p: &mut Project, g: GeneratorSource) -> ItemId {
let info = g.info().clone();
@ -234,7 +234,6 @@ fn effect_costs() {
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut img, &e, &cx);
@ -249,54 +248,3 @@ 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.frame_rate.max(1.0);
let fps = cx.env.map_or(24.0, |env| env.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,7 +247,6 @@ 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,7 +103,6 @@ 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,7 +17,6 @@ fn cx() -> FxCtx<'static> {
clip_name: "clip",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
@ -1155,31 +1154,3 @@ 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");
}
}

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