Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee1feb185 | ||
|
|
fccddb6ea8 | ||
|
|
753c9a99e7 | ||
|
|
c34b9de976 | ||
|
|
002b3d0fb6 | ||
|
|
fae7f8a9be | ||
|
|
48cae3e5a3 | ||
|
|
5e1ded3301 | ||
|
|
23c9cdb33d | ||
|
|
ff2739f9ea | ||
|
|
81e2d083fc | ||
|
|
1d3b4f2bab | ||
|
|
13355a77a9 | ||
|
|
bdc10001fe | ||
|
|
044483689b | ||
|
|
25585fa84a | ||
|
|
5e55b144a5 | ||
|
|
7c9da91184 | ||
|
|
08d500a5c9 | ||
|
|
6b2a36e2dd | ||
|
|
c3f931d0c4 | ||
|
|
64a2391ddc | ||
|
|
e6973f5957 | ||
|
|
5ae8d50168 | ||
|
|
39f60de2f4 | ||
|
|
62e94fb2dd | ||
|
|
62aed1acc2 | ||
|
|
ee414e8b9e | ||
|
|
6c3a7ed4ef | ||
|
|
99547d183b | ||
|
|
d77ab61994 | ||
|
|
676ce6e8ff | ||
|
|
dd92e46583 | ||
|
|
9261608e1f | ||
|
|
154bc148e3 | ||
|
|
3feaf7390f | ||
|
|
9c00138b4b | ||
|
|
ade0f36544 | ||
|
|
a469a4aeb7 | ||
|
|
1267e4e172 | ||
|
|
192174c9ee | ||
|
|
c163983fc1 | ||
|
|
9418816580 | ||
|
|
0cfc02a933 | ||
|
|
dc5e488454 | ||
|
|
1990835de9 | ||
|
|
138e972010 | ||
|
|
234b3454ef | ||
|
|
4678c68d8e | ||
|
|
cc7cec89f8 | ||
|
|
629e5e9ee1 | ||
|
|
7bc739bfae | ||
|
|
f2ad4b9c94 | ||
|
|
e35a828c73 | ||
|
|
eb27d5efff | ||
|
|
9247622550 | ||
|
|
5f3b54cf44 | ||
|
|
bf96b1dcc1 | ||
|
|
524f49d994 | ||
|
|
4f2a4e9981 | ||
|
|
ba72c8944b | ||
|
|
76f423db77 | ||
|
|
1c7aa21957 | ||
|
|
e6ba11e286 | ||
|
|
4e1f673ce0 | ||
|
|
5f60d980f6 | ||
|
|
e565d4a2fd | ||
|
|
aa8d808a48 | ||
|
|
fae6adc577 | ||
|
|
bd5261c8a3 | ||
|
|
20487653ce | ||
|
|
1ee3f85c4c | ||
|
|
37c17d7073 | ||
|
|
2ee1734de4 | ||
|
|
4046ff85f6 | ||
|
|
bb2f5958b6 | ||
|
|
0f174ab0d6 | ||
|
|
0199bf3f37 | ||
|
|
a4ec057c4b | ||
|
|
18dba32df7 | ||
|
|
e61900cd3a | ||
|
|
dac0219ba8 | ||
|
|
c2363399b9 | ||
|
|
1df794b882 | ||
|
|
f25156c720 | ||
|
|
133f503851 | ||
|
|
5ecb74c73e | ||
|
|
c931005aca | ||
|
|
6c06ab14ca | ||
|
|
5b0eaa9c30 | ||
|
|
2259e2ba15 | ||
|
|
70031d5050 | ||
|
|
f5b8f11271 | ||
|
|
e8dd5ffc45 | ||
|
|
4ac34587b0 | ||
|
|
2d2e701078 | ||
|
|
22ba7dd5c2 | ||
|
|
12c9f13d73 | ||
|
|
6bbd389f41 | ||
|
|
3b177c9033 | ||
|
|
c7cc3088bd | ||
|
|
475aafe033 | ||
|
|
3a78436665 | ||
|
|
13c89bf9df | ||
|
|
c11f7b5e09 | ||
|
|
1d1cec80b1 | ||
|
|
654b2b8362 | ||
|
|
4ad860cb69 | ||
|
|
fe7efdaffc | ||
|
|
e4a1a7fee3 | ||
|
|
2343fc8fa3 | ||
|
|
bcb7902f42 | ||
|
|
9203732393 | ||
|
|
3b3599d11c | ||
|
|
f87796f0c8 | ||
|
|
b97bb74330 | ||
|
|
95297477fd | ||
|
|
c1b1e86046 | ||
|
|
bef9a6e127 | ||
|
|
eb81d531eb | ||
|
|
fa7c6ff8ab | ||
|
|
e22a49db1c | ||
|
|
ddaf78c221 | ||
|
|
d91a7b3122 | ||
|
|
55c583f62b | ||
|
|
59506d9b4a | ||
|
|
e5b1ed90a9 | ||
|
|
1ef2960514 | ||
|
|
d034c58edd | ||
|
|
5198059a7c | ||
|
|
afc54a5f38 | ||
|
|
da13964a82 | ||
|
|
1990bde6d3 | ||
|
|
5b76035c3d | ||
|
|
4d58c67745 | ||
|
|
542300e911 | ||
|
|
393284d78f | ||
|
|
c6b5943e7e | ||
|
|
cf9ad66732 | ||
|
|
db6f5194fe | ||
|
|
8457e7bf31 | ||
|
|
595f94f303 | ||
|
|
9deba4a830 | ||
|
|
af5d29310e | ||
|
|
3516207de7 | ||
|
|
20d196401a | ||
|
|
6192214def | ||
|
|
5553e26e12 | ||
|
|
1d62db0172 | ||
|
|
88675faf58 | ||
|
|
d2ae945c30 | ||
|
|
c580406234 | ||
|
|
44baf6c0f4 | ||
|
|
a6a7f95579 | ||
|
|
67f1ea1ae3 | ||
|
|
dadbfe0bf8 | ||
|
|
528e525d67 | ||
|
|
3f61c036ff | ||
|
|
6bc526b5e1 | ||
|
|
b5e05a6501 | ||
|
|
7fdccec8d7 | ||
|
|
f21434cf9f | ||
|
|
b6866db1b2 | ||
|
|
d6df5c6abd | ||
|
|
52b36f9764 | ||
|
|
69a6c4b625 | ||
|
|
4a75bc05fa | ||
|
|
eb3afad326 | ||
|
|
ec1570a4d8 | ||
|
|
d466995942 | ||
|
|
d74ed39c24 | ||
|
|
37a845bc68 | ||
|
|
8cd44f205b | ||
|
|
25aeb4d8a4 | ||
|
|
fe3c60b97a | ||
|
|
50dd69ce32 | ||
|
|
917d23fdff | ||
|
|
6c13dda2bf | ||
|
|
aa9d78cd9c | ||
|
|
369882123f | ||
|
|
d73196f3f7 | ||
|
|
518df2fb92 | ||
|
|
aff5e7f194 | ||
|
|
3f5d4385dd | ||
|
|
1186b242ba | ||
|
|
c733040a5c |
No files matched your search
@@ -5,3 +5,4 @@ plan/
|
||||
/log
|
||||
.DS_Store
|
||||
.claude/worktrees/
|
||||
__pycache__/
|
||||
@@ -9,7 +9,9 @@ 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 |
|
||||
|
||||
@@ -347,6 +347,13 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
A PC whose GPUs were installed from different driver packages of one vendor (a laptop with an
|
||||
integrated and a discrete AMD GPU, for example) can list one GPU twice, once through each installed
|
||||
version of the Vulkan driver, and a window drawn through the stale one crashes inside the driver
|
||||
(`amdvlk64.dll`, exit code `0xc0000005`). FilmCraft draws with the newer one and says so in
|
||||
`%APPDATA%\FilmCraft\Logs\filmcraft.log`; installing one driver package for every GPU removes the
|
||||
duplicate. With one driver per GPU the adapter is the one wgpu picks, as before.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|
||||
+4
-1
@@ -84,9 +84,10 @@ 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).
|
||||
@@ -106,6 +107,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088)
|
||||
- **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`).
|
||||
- **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)).
|
||||
- **2026-10-10:** ~75 community PRs merged: GPU export rendering (Auto) with Vignette, Video Limiter and Lumetri basic / creative / vignette on the GPU (#421); Linux NVDEC H.264 / HEVC decode (#549); text-to-speech narrations with Kokoro-82M (#278); Japanese for the whole interface (#449) and Ukrainian menus (#407); ALSA default-device fallback (#254, fixes #23); drop import into the timeline (#517); system appearance (#505); timecode scrub editing (#576); NVENC RGBA input (#486); marker CSV export (#454); many fixes (#526–#575).
|
||||
@@ -164,6 +166,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
|
||||
| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience |
|
||||
| 2026-10-10 | minor | Headline adds mainstream practitioner (~55%) and essentials user (~60%); ready for real work ~55% -> ~56% (formula written out, same inputs); beta distance ~19 points |
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error
|
||||
//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand.
|
||||
//!
|
||||
//! # The adapter
|
||||
//! In the default mode on Windows the adapter is wgpu's own choice, except for a GPU that Vulkan
|
||||
//! lists through two installed versions of one driver: there the newer one draws, because a
|
||||
//! window drawn through the stale one dies inside the driver (`amdvlk64.dll`; see
|
||||
//! `filmcraft_gpu::adapter`). That case is avoided up front instead of left to the fallback
|
||||
//! below, which would end in DirectX 12 only.
|
||||
//!
|
||||
//! # When a start counts as failed
|
||||
//! The data folder keeps the mode to start with and the failed starts in a row in it
|
||||
//! ([`STATE_FILE`]). Each start also leaves its own attempt file (`graphics-start/<pid>-<n>.json`,
|
||||
@@ -427,6 +434,11 @@ fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<B
|
||||
let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else {
|
||||
return;
|
||||
};
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
|
||||
// compute shader when the device is created, one that Metal can't compile on an Intel Iris
|
||||
// Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns
|
||||
// the check back on.
|
||||
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
match mode {
|
||||
// eframe's default (`WGPU_BACKEND`, else PRIMARY | GL), except on Windows: there it
|
||||
// includes GL. Creating that backend can crash inside AMD's atio6axx.dll before adapter
|
||||
@@ -435,7 +447,16 @@ fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<B
|
||||
// Vulkan and DX12 both: forcing DX12 alone sends shaders through FXC (which rejects
|
||||
// fx.wgsl, silently falling back to the CPU) and leaves machines without DX12 with no
|
||||
// backend at all.
|
||||
Mode::Default if windows => create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY),
|
||||
Mode::Default if windows => {
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY);
|
||||
// wgpu's own choice of adapter, except for a GPU that Windows lists through two
|
||||
// versions of one driver: the first entry can be a stale one that crashes inside the
|
||||
// driver (`filmcraft_gpu::adapter`). A selector replaces `power_preference`
|
||||
// (`WGPU_POWER_PREF`), so it takes that along.
|
||||
let power = create.power_preference;
|
||||
create.native_adapter_selector =
|
||||
Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| filmcraft_gpu::adapter::pick(adapters, surface, power)));
|
||||
}
|
||||
Mode::Default => {}
|
||||
// Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU.
|
||||
Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12,
|
||||
@@ -484,6 +505,22 @@ mod tests {
|
||||
(create.instance_descriptor.backends, create.native_adapter_selector.is_some())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mode_validates_indirect_calls() {
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {
|
||||
return;
|
||||
}
|
||||
for mode in [Mode::Default, Mode::Dx12, Mode::Software] {
|
||||
let mut options = NativeOptions::default();
|
||||
apply_for(&mut options, mode, None, false);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
// Intel Metal can't compile the validation's compute shader, and the device is lost.
|
||||
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_leaves_out_gl() {
|
||||
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
|
||||
@@ -495,7 +532,19 @@ mod tests {
|
||||
assert_eq!(backends, Backends::PRIMARY);
|
||||
assert!(!backends.contains(Backends::GL));
|
||||
assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN));
|
||||
assert!(!selector);
|
||||
// and chooses the adapter itself: a GPU listed through two versions of one driver
|
||||
assert!(selector);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_windows_default_and_software_modes_choose_the_adapter() {
|
||||
for backend_override in [None, Some(Backends::VULKAN), Some(Backends::DX12)] {
|
||||
assert!(configured(Mode::Default, backend_override, true).1);
|
||||
// elsewhere, and in DirectX 12 only mode, the adapter is wgpu's own choice
|
||||
assert!(!configured(Mode::Default, backend_override, false).1);
|
||||
assert!(!configured(Mode::Dx12, backend_override, true).1);
|
||||
}
|
||||
assert!(configured(Mode::Software, None, true).1 && configured(Mode::Software, None, false).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -447,11 +447,8 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog)
|
||||
let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string();
|
||||
let media = t("Media");
|
||||
let project = t("FilmCraft Project");
|
||||
let dialog = match dialog {
|
||||
FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts },
|
||||
FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts, name },
|
||||
other => other,
|
||||
};
|
||||
let project_exts = project_patterns();
|
||||
let dialog = prepare_dialog(dialog);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
|
||||
let shown = std::panic::catch_unwind(move || {
|
||||
@@ -462,9 +459,9 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog)
|
||||
}
|
||||
FileDialog::OpenFile { filter, exts } => rfd::FileDialog::new().add_filter(&filter, &exts).pick_file().map(path).into_iter().collect(),
|
||||
FileDialog::Relink { exts, .. } => rfd::FileDialog::new().add_filter(&media, &exts).pick_file().map(path).into_iter().collect(),
|
||||
FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &["fcproj"]).pick_file().map(path).into_iter().collect(),
|
||||
FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &project_exts).pick_file().map(path).into_iter().collect(),
|
||||
FileDialog::SaveProject { name } => {
|
||||
rfd::FileDialog::new().add_filter(&project, &["fcproj"]).set_file_name(name).save_file().map(path).into_iter().collect()
|
||||
rfd::FileDialog::new().add_filter(&project, &project_exts).set_file_name(name).save_file().map(path).into_iter().collect()
|
||||
}
|
||||
FileDialog::SaveAs { filter, exts, name } => {
|
||||
rfd::FileDialog::new().add_filter(&filter, &exts).set_file_name(name).save_file().map(path).into_iter().collect()
|
||||
@@ -492,6 +489,29 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog)
|
||||
rx
|
||||
}
|
||||
|
||||
/// A dialog as the Linux dialog thread shows it: filter names translated (here, on the UI thread:
|
||||
/// the interface language is per thread) and every extension list turned into the
|
||||
/// case-insensitive patterns the synchronous pickers use, so `CLIP.MP4` stays visible (#262).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn prepare_dialog(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> filmcraft_ui_egui::native_dialogs::FileDialog {
|
||||
use filmcraft_ui_egui::native_dialogs::FileDialog;
|
||||
let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string();
|
||||
let patterns = |exts: Vec<String>| file_filters::extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
match dialog {
|
||||
FileDialog::OpenFiles { exts } => FileDialog::OpenFiles { exts: patterns(exts) },
|
||||
FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts: patterns(exts) },
|
||||
FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts: patterns(exts), name },
|
||||
FileDialog::Relink { exts, hint } => FileDialog::Relink { exts: patterns(exts), hint },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The project dialogs' filter patterns (`EDIT.FCPROJ` included).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn project_patterns() -> Vec<String> {
|
||||
file_filters::extensions(&["fcproj"])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Start-up registers the hardware decoders whether or not anything is logged (no logger is
|
||||
@@ -505,4 +525,31 @@ mod tests {
|
||||
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
|
||||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
}
|
||||
|
||||
/// #262 on Linux: the dialogs shown off the UI thread (#260) filter with the same
|
||||
/// case-insensitive patterns as the synchronous pickers, so upper- and mixed-case files show.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn off_thread_dialogs_keep_case_insensitive_filters() {
|
||||
use filmcraft_ui_egui::RelinkHint;
|
||||
use filmcraft_ui_egui::native_dialogs::FileDialog;
|
||||
let mp4 = || vec!["mp4".to_string()];
|
||||
let hint = RelinkHint { command: "media.relink".into(), params: serde_json::json!({}) };
|
||||
for dialog in [
|
||||
FileDialog::OpenFiles { exts: mp4() },
|
||||
FileDialog::OpenFile { filter: "Media".into(), exts: mp4() },
|
||||
FileDialog::SaveAs { filter: "Media".into(), exts: mp4(), name: "clip.mp4".into() },
|
||||
FileDialog::Relink { exts: mp4(), hint },
|
||||
] {
|
||||
let exts = match super::prepare_dialog(dialog) {
|
||||
FileDialog::OpenFiles { exts } | FileDialog::OpenFile { exts, .. } | FileDialog::SaveAs { exts, .. } | FileDialog::Relink { exts, .. } => exts,
|
||||
other => panic!("the dialog changed kind: {other:?}"),
|
||||
};
|
||||
assert_eq!(exts.first().map(String::as_str), Some("mp4"), "the literal extension stays first (save suffix)");
|
||||
assert!(exts.iter().any(|e| e == "[mM][pP]4"), "{exts:?}");
|
||||
}
|
||||
let project = super::project_patterns();
|
||||
assert_eq!(project.first().map(String::as_str), Some("fcproj"));
|
||||
assert!(project.iter().any(|e| e == "[fF][cC][pP][rR][oO][jJ]"), "{project:?}");
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,10 @@ 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
|
||||
};
|
||||
|
||||
@@ -77,3 +77,14 @@ sync_github_name = true
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "Valentin Klein"
|
||||
sync_github_name = false
|
||||
|
||||
[people.n-delic]
|
||||
display_name = "n-delic"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
@@ -8,7 +8,9 @@ use filmcraft_apv::{
|
||||
};
|
||||
|
||||
fn ffmpeg() -> Option<PathBuf> {
|
||||
filmcraft_testkit::ffmpeg_or_skip("apv oracle")
|
||||
// The APV demuxer is new in ffmpeg 8.0; older toolchains are found but
|
||||
// cannot read the fixtures, so they skip like a missing tool (#1249).
|
||||
filmcraft_testkit::ffmpeg_with_format_or_skip("apv oracle", "apv", "8.0")
|
||||
}
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Client for the desktop app's JSON-lines control protocol. One connection, reconnect on failure.
|
||||
//! Client for the desktop app's JSON-lines control protocol. A connection per request in flight,
|
||||
//! reused once its reply is in; reconnect on failure.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -12,12 +13,17 @@ use crate::AutomationError;
|
||||
|
||||
/// How often a blocking export's app job is polled.
|
||||
const JOB_POLL: Duration = Duration::from_millis(250);
|
||||
/// Idle connections kept for reuse; the app serves at most 16 at once.
|
||||
const MAX_IDLE: usize = 4;
|
||||
|
||||
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
|
||||
|
||||
pub struct BridgeClient {
|
||||
addr: String,
|
||||
conn: Mutex<Option<Conn>>,
|
||||
/// Connections with no request in flight. A request takes one (or opens one) for its whole
|
||||
/// round trip and puts it back only once the reply is read, so a slow or abandoned request
|
||||
/// never holds up the others or leaves its late reply for the next one to read (#1538).
|
||||
idle: Mutex<Vec<Conn>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -29,7 +35,7 @@ impl BridgeClient {
|
||||
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
|
||||
return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`")));
|
||||
}
|
||||
Ok(Self { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1) })
|
||||
Ok(Self { addr, idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
|
||||
}
|
||||
|
||||
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
|
||||
@@ -71,20 +77,14 @@ impl BridgeClient {
|
||||
|
||||
/// Call a control method; returns `result` or the app's error.
|
||||
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
for attempt in 0..2 {
|
||||
if guard.is_none() {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control <port>`", self.addr)))?;
|
||||
let (r, w) = s.into_split();
|
||||
*guard = Some((BufReader::new(r), w));
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(conn) = guard.as_mut() else {
|
||||
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
|
||||
// A retry gets a fresh connection: the failed one may have been closed by an app restart.
|
||||
let idle = if attempt == 0 { self.idle.lock().await.pop() } else { None };
|
||||
let mut conn = match idle {
|
||||
Some(c) => c,
|
||||
None => self.connect().await?,
|
||||
};
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
|
||||
let res: Result<Value, AutomationError> = async {
|
||||
conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?;
|
||||
@@ -98,21 +98,32 @@ impl BridgeClient {
|
||||
.await;
|
||||
match res {
|
||||
Ok(v) => {
|
||||
let mut idle = self.idle.lock().await;
|
||||
if idle.len() < MAX_IDLE {
|
||||
idle.push(conn);
|
||||
}
|
||||
drop(idle);
|
||||
return if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()))
|
||||
};
|
||||
}
|
||||
Err(e) if attempt == 0 => {
|
||||
*guard = None;
|
||||
let _ = e;
|
||||
}
|
||||
Err(_) if attempt == 0 => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(AutomationError::Bridge("unreachable".into()))
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<Conn, AutomationError> {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control <port>`", self.addr)))?;
|
||||
let (r, w) = s.into_split();
|
||||
Ok((BufReader::new(r), w))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -195,6 +206,71 @@ mod tests {
|
||||
assert!(e.to_string().contains("export failed: encode: disk full"), "{e}");
|
||||
}
|
||||
|
||||
/// A stand-in for the app that serves any number of connections and answers each request on
|
||||
/// its own, as the app does: `slow` after `slow_reply` (never when `None`), anything else at
|
||||
/// once with its command id. The ids of the requests it receives are sent to the returned channel.
|
||||
async fn slow_app(slow_reply: Option<Duration>) -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let (seen, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((s, _)) = listener.accept().await {
|
||||
let seen = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
let (r, w) = s.into_split();
|
||||
let w = Arc::new(Mutex::new(w));
|
||||
let mut lines = BufReader::new(r).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
let cmd = req["params"]["command"].as_str().unwrap_or_default().to_string();
|
||||
let _ = seen.send(cmd.clone());
|
||||
let delay = if cmd == "slow" { slow_reply } else { Some(Duration::ZERO) };
|
||||
let w = w.clone();
|
||||
tokio::spawn(async move {
|
||||
let Some(d) = delay else { return };
|
||||
tokio::time::sleep(d).await;
|
||||
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": cmd}));
|
||||
let _ = w.lock().await.write_all(line.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(addr, rx)
|
||||
}
|
||||
|
||||
/// A command the app is slow to answer holds up only itself: a later call is answered at once
|
||||
/// instead of queueing behind it, and behind every call queued after it (#1538).
|
||||
#[tokio::test]
|
||||
async fn a_slow_command_does_not_block_later_calls() {
|
||||
let (addr, mut seen) = slow_app(None).await;
|
||||
let b = Arc::new(BridgeClient::new(addr).unwrap());
|
||||
let slow = tokio::spawn({
|
||||
let b = b.clone();
|
||||
async move { b.execute("slow", json!({})).await }
|
||||
});
|
||||
assert_eq!(seen.recv().await.as_deref(), Some("slow"));
|
||||
let fast = tokio::time::timeout(Duration::from_secs(5), b.execute("fast", json!({}))).await;
|
||||
assert_eq!(fast.expect("the later call waited on the slow one").unwrap(), json!("fast"));
|
||||
slow.abort();
|
||||
}
|
||||
|
||||
/// A call given up while it waits leaves no late reply on a connection for the next call to read
|
||||
/// as its own (#1538).
|
||||
#[tokio::test]
|
||||
async fn an_abandoned_call_leaves_no_late_reply_behind() {
|
||||
let (addr, mut seen) = slow_app(Some(Duration::from_millis(100))).await;
|
||||
let b = Arc::new(BridgeClient::new(addr).unwrap());
|
||||
let slow = tokio::spawn({
|
||||
let b = b.clone();
|
||||
async move { b.execute("slow", json!({})).await }
|
||||
});
|
||||
assert_eq!(seen.recv().await.as_deref(), Some("slow"));
|
||||
slow.abort();
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(b.execute("fast", json!({})).await.unwrap(), json!("fast"));
|
||||
}
|
||||
|
||||
/// Everything else is forwarded unchanged.
|
||||
#[tokio::test]
|
||||
async fn other_commands_are_forwarded() {
|
||||
|
||||
@@ -122,7 +122,7 @@ fn opendml_file_past_1_gb() {
|
||||
];
|
||||
let Some(path) = fixture("large_opendml.avi", &args) else { return };
|
||||
let file = std::fs::File::open(&path).unwrap();
|
||||
let src = FileSource(file);
|
||||
let src = FileSource(std::sync::Mutex::new(file));
|
||||
let f = open(&src).unwrap();
|
||||
assert_eq!(f.index, IndexSource::OpenDml);
|
||||
assert_eq!(f.streams[0].chunks.len(), packets(&path, 0));
|
||||
@@ -139,13 +139,54 @@ fn opendml_file_past_1_gb() {
|
||||
}
|
||||
|
||||
/// A file on disk as a byte source, read where asked.
|
||||
struct FileSource(std::fs::File);
|
||||
struct FileSource(std::sync::Mutex<std::fs::File>);
|
||||
|
||||
impl filmcraft_avi::ByteSource for FileSource {
|
||||
fn len(&self) -> u64 {
|
||||
self.0.metadata().map(|m| m.len()).unwrap_or(0)
|
||||
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).metadata().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
|
||||
std::os::unix::fs::FileExt::read_exact_at(&self.0, buf, offset)
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
if buf.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
file.read_exact(buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_source_reads_exact_offsets_and_reports_eof() {
|
||||
use filmcraft_avi::ByteSource as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
let path = filmcraft_testkit::fixtures::temp_path(&filmcraft_testkit::fixtures_dir("avi").join("read-at-test.bin"));
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
|
||||
file.write_all(b"0123456789abcdef").unwrap();
|
||||
drop(file);
|
||||
let source = FileSource(std::sync::Mutex::new(std::fs::File::open(&path).unwrap()));
|
||||
assert_eq!(source.len(), 16);
|
||||
let mut bytes = [0; 4];
|
||||
source.read_at(7, &mut bytes).unwrap();
|
||||
assert_eq!(&bytes, b"789a");
|
||||
source.read_at(1, &mut bytes).unwrap();
|
||||
assert_eq!(&bytes, b"1234");
|
||||
assert_eq!(source.read_at(15, &mut bytes).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof);
|
||||
assert!(source.read_at(u64::MAX, &mut bytes).is_err());
|
||||
source.read_at(u64::MAX, &mut []).unwrap();
|
||||
std::thread::scope(|scope| {
|
||||
for (offset, expected) in [(0, b"0123"), (8, b"89ab")] {
|
||||
let source = &source;
|
||||
scope.spawn(move || {
|
||||
for _ in 0..64 {
|
||||
let mut bytes = [0; 4];
|
||||
source.read_at(offset, &mut bytes).unwrap();
|
||||
assert_eq!(&bytes, expected);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
drop(source);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
@@ -117,8 +117,8 @@ impl<'a> BitReader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unsigned Exp-Golomb `ue(v)`.
|
||||
pub fn read_ue(&mut self) -> Result<u32> {
|
||||
/// Raw Exp-Golomb codeNum, up to 32 leading zeros (so below 2^33).
|
||||
fn read_code_num(&mut self) -> Result<u64> {
|
||||
let mut zeros = 0u32;
|
||||
while !self.read_bit()? {
|
||||
zeros += 1;
|
||||
@@ -130,14 +130,20 @@ impl<'a> BitReader<'a> {
|
||||
return Ok(0);
|
||||
}
|
||||
let v = self.read_bits_u64(zeros)?;
|
||||
let r = (1u64 << zeros) - 1 + v;
|
||||
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
|
||||
Ok((1u64 << zeros) - 1 + v)
|
||||
}
|
||||
|
||||
/// Unsigned Exp-Golomb `ue(v)`.
|
||||
pub fn read_ue(&mut self) -> Result<u32> {
|
||||
u32::try_from(self.read_code_num()?).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Signed Exp-Golomb `se(v)`.
|
||||
pub fn read_se(&mut self) -> Result<i32> {
|
||||
let k = self.read_ue()? as i64;
|
||||
Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) })
|
||||
// codeNum 2^32 is -2^31, so the mapping is done before narrowing to 32 bits.
|
||||
let k = self.read_code_num()? as i64;
|
||||
let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) };
|
||||
i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Truncated Exp-Golomb `te(v)` with range `max`.
|
||||
@@ -196,7 +202,11 @@ impl BitWriter {
|
||||
self.write_bits(b as u32, 1);
|
||||
}
|
||||
pub fn write_ue(&mut self, v: u32) {
|
||||
let x = v as u64 + 1;
|
||||
self.write_code_num(u64::from(v));
|
||||
}
|
||||
/// Exp-Golomb codeNum up to 2^32 (the code of `se(v)` -2^31).
|
||||
fn write_code_num(&mut self, v: u64) {
|
||||
let x = v + 1;
|
||||
let len = 64 - x.leading_zeros();
|
||||
let zeros = len - 1;
|
||||
for _ in 0..zeros {
|
||||
@@ -210,8 +220,8 @@ impl BitWriter {
|
||||
}
|
||||
}
|
||||
pub fn write_se(&mut self, v: i32) {
|
||||
let k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 };
|
||||
self.write_ue(k);
|
||||
let m = u64::from(v.unsigned_abs()) * 2;
|
||||
self.write_code_num(if v > 0 { m - 1 } else { m });
|
||||
}
|
||||
pub fn bit_len(&self) -> usize {
|
||||
self.buf.len() * 8 + self.nbits as usize
|
||||
@@ -377,6 +387,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_exp_golomb_extremes_roundtrip() {
|
||||
let vals = [0, 1, -1, i32::MAX, i32::MIN];
|
||||
let mut w = BitWriter::new();
|
||||
for v in vals {
|
||||
w.write_se(v);
|
||||
}
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for v in vals {
|
||||
assert_eq!(r.read_se(), Ok(v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_exp_golomb_reads_minimum_from_spec_bits() {
|
||||
// 32 zero bits, 1, then the 32-bit suffix 1: codeNum = 2^32 -> -2^31.
|
||||
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80];
|
||||
assert_eq!(BitReader::new(&bytes).read_se(), Ok(i32::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_exp_golomb_rejects_positive_overflow() {
|
||||
// 32 zero bits, 1, then 32 zero bits: codeNum = 2^32 - 1 -> +2^31, not an i32.
|
||||
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0];
|
||||
assert_eq!(BitReader::new(&bytes).read_se(), Err(BitError::InvalidExpGolomb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_exp_golomb_keeps_u32_range() {
|
||||
// codeNum = 2^32 is a valid se(v) code but does not fit ue(v)'s u32.
|
||||
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80];
|
||||
assert_eq!(BitReader::new(&bytes).read_ue(), Err(BitError::InvalidExpGolomb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulation_prevention() {
|
||||
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
|
||||
|
||||
@@ -23,6 +23,27 @@ All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 00
|
||||
frames map through `FrameRate::FPS_29_97`. `Document::snap_to_frames` snaps cues to a sequence's
|
||||
frame grid (the engine does this on import) and removes overlaps.
|
||||
|
||||
### TTML speaker metadata
|
||||
|
||||
TTML and DFXP exports define each distinct speaker label once in the document head and refer to
|
||||
it with a stable, unique `ttm:agent` ID. Names are XML-escaped full-name text with preserved
|
||||
whitespace; punctuation, Unicode and literal underscores survive a round trip. Agent type is
|
||||
`other` because a caption's speaker label does not say whether it denotes a person or a group.
|
||||
|
||||
On import, agent definitions directly under `head` or `head/metadata` resolve paragraph and timed
|
||||
span speakers. Speaker metadata uses the standard TTML namespace or the April and October 2006
|
||||
TTaf1 draft namespaces. Agent IDs use `xml:id`, except in the April draft, which uses unqualified
|
||||
`id`. Agent definitions from unrelated application metadata are ignored. Recognized `ttm:agent`
|
||||
attributes take precedence over the legacy local-name `agent` fallback. A full name is preferred,
|
||||
otherwise the first nonempty name (or an explicitly empty name) is used. Multiple agent references become one display label, joined with `, ` in
|
||||
reference order; the project does not retain the individual agent identities. Older FilmCraft
|
||||
files without agent definitions remain readable through the previous underscore-to-space
|
||||
fallback. Other agent metadata, including actor relationships, is not retained.
|
||||
|
||||
This mapping follows [W3C TTML1, third edition (2018), §§12.1.5–12.2.1](https://www.w3.org/TR/2018/REC-ttml1-20181108/#metadata-vocabulary-agent).
|
||||
The [April 2006 TTaf1 draft, §§7.2.1 and 12.1.5–12.2.1](https://www.w3.org/TR/2006/WD-ttaf1-dfxp-20060427/)
|
||||
defines its earlier metadata namespace and agent ID form.
|
||||
|
||||
### SCC writer
|
||||
|
||||
Each caption is sent as a pop-on load (RCL, ENM, then per row a preamble address code and tab
|
||||
|
||||
+118
-19
@@ -8,12 +8,14 @@
|
||||
//! offsets (`123f`) at `ttp:frameRate` / `ttp:frameRateMultiplier`, which are exact; without
|
||||
//! one, and always for DFXP (for older players), times are clock times with milliseconds.
|
||||
//!
|
||||
//! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read):
|
||||
//! **Reading** accepts TTML1 2010, the 2006 `ttaf1` drafts and TTML2:
|
||||
//! clock times (`HH:MM:SS`, `.fraction`, `:FF` frames and `.subframes`), offset times (`h`, `m`,
|
||||
//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested `par` time
|
||||
//! containers (`body` / `div` begins add up and their ends clip what is inside), timed `<span>`s inside an untimed `<p>`, `<br/>`, whitespace
|
||||
//! collapsing, styles referenced by id (italic / bold / underline) and `ttm:agent` / `xml:id`.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
||||
use crate::{Cue, Document, Error, Result, TICKS_PER_MS};
|
||||
@@ -31,6 +33,9 @@ const NS_TT: &str = "http://www.w3.org/ns/ttml";
|
||||
const NS_TTP: &str = "http://www.w3.org/ns/ttml#parameter";
|
||||
const NS_TTS: &str = "http://www.w3.org/ns/ttml#styling";
|
||||
const NS_TTM: &str = "http://www.w3.org/ns/ttml#metadata";
|
||||
const NS_TTM_DRAFT: &str = "http://www.w3.org/2006/10/ttaf1#metadata";
|
||||
const NS_TTM_DRAFT_APRIL: &str = "http://www.w3.org/2006/04/ttaf1#metadata";
|
||||
const NS_XML: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
const IMSC1_TEXT: &str = "http://www.w3.org/ns/ttml/profile/imsc1/text";
|
||||
const DFXP_PROFILE: &str = "http://www.w3.org/ns/ttml/profile/dfxp-presentation";
|
||||
|
||||
@@ -122,6 +127,10 @@ fn is_ncname(s: &str) -> bool {
|
||||
c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && c.all(|x| x.is_alphanumeric() || matches!(x, '_' | '-' | '.'))
|
||||
}
|
||||
|
||||
fn cue_id(cue: &Cue, index: usize) -> String {
|
||||
cue.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", index + 1))
|
||||
}
|
||||
|
||||
/// Write `doc` as TTML. `rate` (IMSC1 only) makes times exact frame offsets; `lang` is the
|
||||
/// document language (`xml:lang`).
|
||||
pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str) -> String {
|
||||
@@ -140,13 +149,38 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
|
||||
}
|
||||
}
|
||||
o.push_str(&format!(" xml:lang=\"{}\">\n", esc(if lang.is_empty() { "en" } else { lang })));
|
||||
// Keep agent identifiers distinct from the IDs already emitted for cues, style and region.
|
||||
let mut ids: HashSet<String> = ["s1".into(), "r1".into()].into_iter().collect();
|
||||
ids.extend(doc.cues.iter().enumerate().map(|(i, c)| cue_id(c, i)));
|
||||
let mut speakers = Vec::new();
|
||||
let mut speaker_ids = HashMap::new();
|
||||
for speaker in doc.cues.iter().filter_map(|c| c.speaker.as_deref()) {
|
||||
if speaker_ids.contains_key(speaker) {
|
||||
continue;
|
||||
}
|
||||
let mut id = format!("agent{}", speakers.len() + 1);
|
||||
// Each collision consumes a distinct reserved ID, so this is bounded by ids.len().
|
||||
while ids.contains(&id) {
|
||||
id.push('_');
|
||||
}
|
||||
ids.insert(id.clone());
|
||||
speaker_ids.insert(speaker, id.clone());
|
||||
speakers.push((speaker, id));
|
||||
}
|
||||
o.push_str(" <head>\n");
|
||||
for (speaker, id) in speakers {
|
||||
o.push_str(&format!(
|
||||
" <ttm:agent xml:id=\"{id}\" type=\"other\"><ttm:name type=\"full\" xml:space=\"preserve\">{}</ttm:name></ttm:agent>\n",
|
||||
// XML 1.0 §2.11 normalizes literal CR even with xml:space="preserve".
|
||||
esc(speaker).replace('\r', " ")
|
||||
));
|
||||
}
|
||||
o.push_str(" <styling>\n <style xml:id=\"s1\" tts:color=\"white\" tts:fontFamily=\"proportionalSansSerif\" tts:fontSize=\"100%\" tts:textAlign=\"center\"/>\n </styling>\n");
|
||||
o.push_str(" <layout>\n <region xml:id=\"r1\" tts:origin=\"10% 10%\" tts:extent=\"80% 80%\" tts:displayAlign=\"after\"/>\n </layout>\n");
|
||||
o.push_str(" </head>\n <body region=\"r1\" style=\"s1\">\n <div>\n");
|
||||
for (i, c) in doc.cues.iter().enumerate() {
|
||||
let id = c.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", i + 1));
|
||||
let agent = c.speaker.as_deref().map(|s| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{}\"", esc(&agent_id(s)))).unwrap_or_default();
|
||||
let id = cue_id(c, i);
|
||||
let agent = c.speaker.as_deref().and_then(|s| speaker_ids.get(s)).map(|id| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{id}\"")).unwrap_or_default();
|
||||
o.push_str(&format!(
|
||||
" <p xml:id=\"{}\" begin=\"{}\" end=\"{}\"{agent}>{}</p>\n",
|
||||
esc(&id),
|
||||
@@ -159,12 +193,6 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
|
||||
o
|
||||
}
|
||||
|
||||
/// `ttm:agent` refers to an agent id; we write the speaker name itself (made an NCName).
|
||||
fn agent_id(s: &str) -> String {
|
||||
let v: String = s.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect();
|
||||
if v.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { v } else { format!("_{v}") }
|
||||
}
|
||||
|
||||
fn gcd(a: i64, b: i64) -> i64 {
|
||||
if b == 0 { a.abs().max(1) } else { gcd(b, a % b) }
|
||||
}
|
||||
@@ -203,6 +231,56 @@ fn attr<'a>(n: roxmltree::Node<'a, '_>, local: &str) -> Option<&'a str> {
|
||||
n.attributes().find(|a| a.name() == local).map(|a| a.value())
|
||||
}
|
||||
|
||||
fn metadata_namespace(namespace: Option<&str>) -> bool {
|
||||
matches!(namespace, Some(NS_TTM) | Some(NS_TTM_DRAFT) | Some(NS_TTM_DRAFT_APRIL))
|
||||
}
|
||||
|
||||
/// Significant TTML agents are children of head or of head's metadata children (TTML1 §12.1.5).
|
||||
fn speaker_names(root: roxmltree::Node) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
let content_namespace = root.tag_name().namespace();
|
||||
let Some(head) = root.children().find(|n| n.is_element() && n.tag_name().name() == "head" && n.tag_name().namespace() == content_namespace) else {
|
||||
return out;
|
||||
};
|
||||
for agent in head.descendants().filter(|n| n.is_element() && n.tag_name().name() == "agent" && metadata_namespace(n.tag_name().namespace())) {
|
||||
if !agent
|
||||
.parent()
|
||||
.is_some_and(|p| p == head || (p.tag_name().name() == "metadata" && p.tag_name().namespace() == content_namespace && p.parent() == Some(head)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let id = if agent.tag_name().namespace() == Some(NS_TTM_DRAFT_APRIL) {
|
||||
// The April 2006 draft defines an unqualified id, before TTML adopted xml:id.
|
||||
agent.attributes().find(|a| a.name() == "id" && a.namespace().is_none()).map(|a| a.value())
|
||||
} else {
|
||||
agent.attribute((NS_XML, "id"))
|
||||
};
|
||||
let Some(id) = id else { continue };
|
||||
let names: Vec<_> = agent
|
||||
.children()
|
||||
.filter(|n| n.is_element() && n.tag_name().name() == "name" && metadata_namespace(n.tag_name().namespace()))
|
||||
.map(|n| {
|
||||
let kind = n.attributes().find(|a| a.name() == "type" && a.namespace().is_none()).map(|a| a.value());
|
||||
(kind, n.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>())
|
||||
})
|
||||
.collect();
|
||||
let name = names.iter().find(|(kind, _)| *kind == Some("full")).or_else(|| names.iter().find(|(_, text)| !text.is_empty())).or_else(|| names.first());
|
||||
if let Some((_, name)) = name {
|
||||
// An explicitly empty full name remains empty, including our own Some("") speakers.
|
||||
out.insert(id.to_string(), name.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn speaker(n: roxmltree::Node, names: &HashMap<String, String>) -> Option<String> {
|
||||
let refs = n.attributes().find(|a| a.name() == "agent" && metadata_namespace(a.namespace())).map(|a| a.value()).or_else(|| n.attribute("agent"));
|
||||
refs.map(|refs| {
|
||||
// The project stores one speaker label; multiple agents become an ordered display label.
|
||||
refs.split_whitespace().map(|id| names.get(id).cloned().unwrap_or_else(|| id.trim_start_matches('_').replace('_', " "))).collect::<Vec<_>>().join(", ")
|
||||
})
|
||||
}
|
||||
|
||||
fn timing(root: roxmltree::Node) -> Result<Timing> {
|
||||
let invalid =
|
||||
|name: &str| Error::Syntax { line: root.document().text_pos_at(root.range().start).row as usize, msg: format!("invalid TTML timing parameter {name}") };
|
||||
@@ -294,10 +372,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
||||
}
|
||||
whole.0 as i128 + decimal_ticks("0", vf, fd)?
|
||||
}
|
||||
"t" => {
|
||||
let n: i128 = vi.parse().ok()?;
|
||||
n * tps / tm.tick_rate.max(1) as i128
|
||||
}
|
||||
"t" => decimal_ticks(vi, vf, tps)? / tm.tick_rate.max(1) as i128,
|
||||
_ => return None,
|
||||
};
|
||||
i64::try_from(t).ok().map(Tick)
|
||||
@@ -411,6 +486,7 @@ pub fn parse(text: &str) -> Result<Document> {
|
||||
return Err(Error::NotFormat("TTML"));
|
||||
}
|
||||
let tm = timing(root)?;
|
||||
let speakers = speaker_names(root);
|
||||
let mut styles = std::collections::HashMap::new();
|
||||
for s in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
|
||||
if let Some(id) = s.attributes().find(|a| a.name() == "id").map(|a| a.value()) {
|
||||
@@ -428,7 +504,7 @@ pub fn parse(text: &str) -> Result<Document> {
|
||||
}
|
||||
// the body is a time container like a div: its begin offsets, and its end clips, everything inside
|
||||
let t = times(body, Scope { begin: Tick::ZERO, end: None }, tm, &mut doc);
|
||||
walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, Fmt::default(), &mut doc);
|
||||
walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, &speakers, Fmt::default(), &mut doc);
|
||||
doc.cues.sort_by_key(|c| (c.start, c.end));
|
||||
Ok(doc)
|
||||
}
|
||||
@@ -480,13 +556,13 @@ fn times(n: roxmltree::Node, parent: Scope, tm: Timing, doc: &mut Document) -> T
|
||||
Times { begin, end, bad }
|
||||
}
|
||||
|
||||
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collections::HashMap<String, Fmt>, fmt: Fmt, doc: &mut Document) {
|
||||
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &HashMap<String, Fmt>, speakers: &HashMap<String, String>, fmt: Fmt, doc: &mut Document) {
|
||||
for c in n.children().filter(|c| c.is_element()) {
|
||||
let f = style_of(c, styles, fmt);
|
||||
match c.tag_name().name() {
|
||||
"div" => {
|
||||
let t = times(c, parent, *tm, doc);
|
||||
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, f, doc);
|
||||
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, speakers, f, doc);
|
||||
}
|
||||
"p" => {
|
||||
let t = times(c, parent, *tm, doc);
|
||||
@@ -494,7 +570,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
|
||||
continue;
|
||||
}
|
||||
let (b, e) = (t.begin.unwrap_or(parent.begin), t.end);
|
||||
let speaker = attr(c, "agent").map(|a| a.trim().trim_start_matches('_').replace('_', " "));
|
||||
let cue_speaker = speaker(c, speakers);
|
||||
let id = c.attributes().find(|a| a.name() == "id").map(|a| a.value().to_string());
|
||||
let spans: Vec<roxmltree::Node> = c.children().filter(|x| x.is_element() && x.tag_name().name() == "span" && has_timing(*x)).collect();
|
||||
// a timed paragraph, or one without timed spans in a container with an end, is one cue
|
||||
@@ -512,7 +588,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
|
||||
}
|
||||
let text = clean(&o.s);
|
||||
if e > b && !text.is_empty() {
|
||||
doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() });
|
||||
doc.cues.push(Cue { start: b, end: e, text, speaker: cue_speaker, id, ..Default::default() });
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -526,7 +602,13 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
|
||||
collect(sp, styles, style_of(sp, styles, f), &mut o);
|
||||
let text = clean(&o.s);
|
||||
if se > sb && !text.is_empty() {
|
||||
doc.cues.push(Cue { start: sb, end: se, text, speaker: speaker.clone(), ..Default::default() });
|
||||
doc.cues.push(Cue {
|
||||
start: sb,
|
||||
end: se,
|
||||
text,
|
||||
speaker: speaker(sp, speakers).or_else(|| cue_speaker.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -631,6 +713,23 @@ mod tests {
|
||||
assert!(!doc.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_tick_offsets_keep_exact_caption_times() {
|
||||
for format in [crate::Format::Ttml, crate::Format::Dfxp] {
|
||||
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:tickRate="10"><body><div><p begin="1.5t" end="12.5t">Hello</p></div></body></tt>"#;
|
||||
let doc = crate::parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].start, Tick(TICKS_PER_SECOND * 3 / 20));
|
||||
assert_eq!(doc.cues[0].end, Tick(TICKS_PER_SECOND * 5 / 4));
|
||||
assert!(doc.warnings.is_empty());
|
||||
}
|
||||
let timing = Timing { tick_rate: 3, ..tm(FrameRate::FPS_25) };
|
||||
assert_eq!(parse_time("0.5t", timing), Some(Tick(TICKS_PER_SECOND / 6)));
|
||||
for expr in ["1.xt", "1.1234567890123t", "999999999999999.5t"] {
|
||||
assert_eq!(parse_time(expr, timing), None, "{expr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_styles_spans_and_nesting() {
|
||||
let x = r#"<?xml version="1.0"?>
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn split_voice(text: &str) -> (Option<String>, String) {
|
||||
Some(_) => return (None, text.to_string()),
|
||||
None => body.to_string(),
|
||||
};
|
||||
if body.contains("<v ") || body.contains("<v.") {
|
||||
if body.contains("<v ") || body.contains("<v.") || body.contains("<v\t") {
|
||||
return (None, text.to_string());
|
||||
}
|
||||
return ((!name.is_empty()).then(|| name.to_string()), body);
|
||||
@@ -177,6 +177,19 @@ mod tests {
|
||||
assert_eq!(split_voice("<vx>hi"), (None, "<vx>hi".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_voices_with_tab_separator() {
|
||||
let text = "<v Ann>Hello <v\tBob>there";
|
||||
assert_eq!(split_voice(text), (None, text.to_string()));
|
||||
let source = format!("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n{text}\n");
|
||||
let doc = parse(&source).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker, None);
|
||||
assert_eq!(doc.cues[0].text, text);
|
||||
assert_eq!(parse(&write(&doc)).unwrap().cues, doc.cues);
|
||||
let closed = "<v Ann>Hi <v\tBob>x</v>";
|
||||
assert_eq!(split_voice(closed), (None, closed.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_signature() {
|
||||
let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap();
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use filmcraft_captions::{Cue, Document, Format, WriteOptions, parse, write};
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
const TT: &str = "http://www.w3.org/ns/ttml";
|
||||
const META: &str = "http://www.w3.org/ns/ttml#metadata";
|
||||
const XML: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
|
||||
#[test]
|
||||
fn imports_speaker_names_from_significant_ttml_metadata() {
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
for metadata in [
|
||||
r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="alias">Ann</ttm:name><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>"#
|
||||
.to_string(),
|
||||
r#"<metadata><ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent></metadata>"#.to_string(),
|
||||
r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann<!-- editorial note --> Lee</ttm:name></ttm:agent>"#.to_string(),
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>{metadata}</head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
|
||||
assert_eq!(doc.cues[0].text, "Hello");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_speakers_have_unique_resolvable_ids_and_exact_names() {
|
||||
let rate = FrameRate::FPS_25;
|
||||
let speakers =
|
||||
["Ann Lee", "Ann_Lee", "O’Connor & <Guest>", "_Ann", "山田 花子", "Ann Lee", "", " Ann Lee ", "Ann\rLee", "Ann\r\nLee", "Ann\nLee", "Ann\tLee"];
|
||||
let ids = ["agent1", "agent2", "speaker1", "speaker2", "c5", "c6", "c7", "c8", "c9", "c10", "c11", "c12"];
|
||||
let doc = Document {
|
||||
cues: speakers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| Cue {
|
||||
start: rate.tick_of(i as i64 * 50),
|
||||
end: rate.tick_of(i as i64 * 50 + 25),
|
||||
text: format!("Line {}", i + 1),
|
||||
speaker: Some((*name).into()),
|
||||
id: Some(ids[i].into()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let bytes = write(&doc, format, WriteOptions { rate: Some(rate), ..Default::default() });
|
||||
assert_eq!(bytes, write(&doc, format, WriteOptions { rate: Some(rate), ..Default::default() }), "stable output");
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
let xml = roxmltree::Document::parse(text).unwrap();
|
||||
let mut all_ids = HashSet::new();
|
||||
for id in xml.descendants().filter_map(|n| n.attribute((XML, "id"))) {
|
||||
assert!(all_ids.insert(id), "duplicate xml:id {id}");
|
||||
}
|
||||
let mut agents = HashMap::new();
|
||||
for agent in xml.descendants().filter(|n| n.has_tag_name((META, "agent"))) {
|
||||
assert_eq!(agent.attribute("type"), Some("other"));
|
||||
let name = agent.children().find(|n| n.has_tag_name((META, "name"))).unwrap();
|
||||
assert_eq!(name.attribute("type"), Some("full"));
|
||||
assert_eq!(name.attribute((XML, "space")), Some("preserve"));
|
||||
agents.insert(agent.attribute((XML, "id")).unwrap(), name.text().unwrap_or(""));
|
||||
}
|
||||
assert_eq!(agents.len(), 11, "each exact name is defined once");
|
||||
let paragraphs: Vec<_> = xml.descendants().filter(|n| n.has_tag_name((TT, "p"))).collect();
|
||||
assert_eq!(paragraphs.len(), speakers.len());
|
||||
for (paragraph, expected) in paragraphs.iter().zip(speakers) {
|
||||
let id = paragraph.attribute((META, "agent")).unwrap();
|
||||
assert_eq!(agents.get(id).copied(), Some(expected), "reference {id}");
|
||||
}
|
||||
let back = parse(&bytes, format).unwrap();
|
||||
assert_eq!(back.cues, doc.cues, "{format:?} names and cue content survive");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_multiple_agent_names_in_reference_order() {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>
|
||||
<ttm:agent xml:id="ann" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
|
||||
<ttm:agent xml:id="guest" type="person"><ttm:name type="alias">O’Connor</ttm:name></ttm:agent>
|
||||
</head><body><div><p begin="1s" end="2s" ttm:agent="guest ann">Together</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("O’Connor, Ann Lee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_legacy_filmcraft_speaker_references_readable() {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><body><div>
|
||||
<p begin="1s" end="2s" ttm:agent="Ann_Lee">Hello</p>
|
||||
</div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_span_can_name_its_own_speaker() {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>
|
||||
<ttm:agent xml:id="ann" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
|
||||
<ttm:agent xml:id="guest" type="person"><ttm:name type="full">Guest</ttm:name></ttm:agent>
|
||||
</head><body><div><p ttm:agent="ann"><span begin="1s" end="2s" ttm:agent="guest">Hello</span>
|
||||
<span begin="3s" end="4s">Welcome</span></p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues.len(), 2);
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Guest"));
|
||||
assert_eq!(doc.cues[1].speaker.as_deref(), Some("Ann Lee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_metadata_does_not_replace_ttml_speakers() {
|
||||
let real = r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>"#;
|
||||
for (metadata, attributes) in [
|
||||
(
|
||||
format!(r#"{real}<metadata><app:agent id="speaker1"><app:name type="full">Library editor</app:name></app:agent></metadata>"#),
|
||||
r#"ttm:agent="speaker1""#,
|
||||
),
|
||||
(real.into(), r#"app:agent="library" ttm:agent="speaker1""#),
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library"><head>{metadata}</head><body><div><p begin="1s" end="2s" {attributes}>Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"), "{xml}");
|
||||
}
|
||||
// A TTML agent nested inside another application's metadata container is not significant.
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library"><head><app:metadata>{real}</app:metadata></head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("speaker1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_draft_metadata_and_legacy_unqualified_speaker_references() {
|
||||
for (tt, metadata, attributes) in
|
||||
[("http://www.w3.org/2006/10/ttaf1", "http://www.w3.org/2006/10/ttaf1#metadata", r#"ttm:agent="speaker1""#), (TT, META, r#"agent="speaker1""#)]
|
||||
{
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{tt}" xmlns:ttm="{metadata}"><head><ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent></head><body><div><p begin="1s" end="2s" {attributes}>Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_april_2006_draft_speaker_references_readable() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xml:lang="en" xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:ttm="http://www.w3.org/2006/04/ttaf1#metadata">
|
||||
<head><metadata>
|
||||
<ttm:agent id="Ann_Lee" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
|
||||
</metadata></head>
|
||||
<body><div><p begin="1s" end="2s" ttm:agent="Ann_Lee">Hello</p></div></body>
|
||||
</tt>"#;
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].text, "Hello");
|
||||
assert_eq!(doc.cues[0].start, FrameRate::FPS_25.tick_of(25));
|
||||
assert_eq!(doc.cues[0].end, FrameRate::FPS_25.tick_of(50));
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"), "the documented draft keeps its previously readable speaker");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_april_2006_agent_definitions_with_unqualified_ids() {
|
||||
let xml = r#"<tt xml:lang="en" xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:ttm="http://www.w3.org/2006/04/ttaf1#metadata">
|
||||
<head><ttm:agent id="speaker1" type="person"><ttm:name type="full">Ann-Marie Lee</ttm:name></ttm:agent></head>
|
||||
<body><div><p begin="1s" end="2s" ttm:agent="speaker1">Welcome</p></div></body></tt>"#;
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].text, "Welcome");
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann-Marie Lee"), "the name resolves through the draft's unqualified id");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_full_speaker_name_with_type(attributes: &str) {
|
||||
let xml = format!(
|
||||
r#"<tt xml:lang="en" xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library">
|
||||
<head><ttm:agent xml:id="speaker1" type="person">
|
||||
<ttm:name type="alias">Ann</ttm:name><ttm:name {attributes}>Ann Lee</ttm:name>
|
||||
</ttm:agent></head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let mut speakers = Vec::new();
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].text, "Hello");
|
||||
assert_eq!(doc.cues[0].start, FrameRate::FPS_25.tick_of(25));
|
||||
assert_eq!(doc.cues[0].end, FrameRate::FPS_25.tick_of(50));
|
||||
speakers.push(doc.cues[0].speaker.clone());
|
||||
}
|
||||
assert_eq!(speakers, [Some("Ann Lee".into()), Some("Ann Lee".into())], "TTML and DFXP prefer the full name: {attributes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_type_without_application_attribute_prefers_full_name() {
|
||||
assert_full_speaker_name_with_type(r#"type="full""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_type_after_application_attribute_prefers_full_name() {
|
||||
assert_full_speaker_name_with_type(r#"app:type="contact" type="full""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_type_before_application_attribute_prefers_full_name() {
|
||||
assert_full_speaker_name_with_type(r#"type="full" app:type="contact""#);
|
||||
}
|
||||
@@ -814,6 +814,21 @@ impl AudioFileSource {
|
||||
};
|
||||
return Ok(Self { info, rate, samples });
|
||||
}
|
||||
if ext == "aac" {
|
||||
let (rate, samples) = decode_adts(&bytes)?.ok_or_else(|| CodecError::Decode(format!("{name}: no ADTS AAC frames")))?;
|
||||
let frames = samples.first().map_or(0, Vec::len);
|
||||
let info = MediaInfo {
|
||||
name: name.to_string(),
|
||||
kind: MediaKind::AudioOnly,
|
||||
duration: Tick::from_units(frames as i64, rate as i64),
|
||||
video: None,
|
||||
audio_streams: vec![AudioStreamInfo { sample_rate: rate, channels: samples.len() as u32, codec: "AAC".into(), bits_per_sample: None }],
|
||||
container: "ADTS".into(),
|
||||
start_timecode: None,
|
||||
file_size: Some(bytes.len() as u64),
|
||||
};
|
||||
return Ok(Self { info, rate, samples });
|
||||
}
|
||||
let mss = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.clone())), Default::default());
|
||||
let mut hint = Hint::new();
|
||||
hint.with_extension(&ext);
|
||||
@@ -871,20 +886,94 @@ impl AudioFileSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// The length of an ID3v2 tag at the start of `bytes` (0 without one): a 10-byte header, a
|
||||
/// syncsafe size, and a 10-byte footer when flagged (ID3v2.4 §3).
|
||||
fn id3v2_len(bytes: &[u8]) -> usize {
|
||||
match bytes {
|
||||
[b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => {
|
||||
let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize);
|
||||
size.saturating_add(if flags & 0x10 != 0 { 20 } else { 10 })
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Most samples (all channels together) a standalone ADTS file decodes into memory: 4 GiB of f32,
|
||||
/// about three hours of 48 kHz stereo. Longer files are refused rather than allocated.
|
||||
const MAX_ADTS_SAMPLES: usize = 1 << 30;
|
||||
|
||||
/// The length of the ADTS frame starting `b` (ISO/IEC 13818-7 §6.2: 12-bit sync word, layer `00`,
|
||||
/// 13-bit `frame_length` including the header), when it starts with a header and fits in `b`.
|
||||
fn adts_frame_len(b: &[u8]) -> Option<usize> {
|
||||
let [0xFF, b1, _, b3, b4, b5, ..] = *b else { return None };
|
||||
if b1 & 0xF6 != 0xF0 {
|
||||
return None;
|
||||
}
|
||||
let len = ((b3 as usize & 3) << 11) | (b4 as usize) << 3 | (b5 as usize) >> 5;
|
||||
let hdr = if b1 & 1 != 0 { 7 } else { 9 };
|
||||
(len > hdr && len <= b.len()).then_some(len)
|
||||
}
|
||||
|
||||
/// A raw ADTS stream (`.aac`) decoded with our AAC decoder: (output sample rate, planar samples).
|
||||
/// Frames follow each other from the first header (after an ID3v2 tag); damaged data is skipped
|
||||
/// up to the next header, frames with another configuration than the first are skipped, and a
|
||||
/// frame that does not decode keeps its duration as silence. HE-AAC is upsampled to its output
|
||||
/// rate. `Ok(None)` when no frame decodes.
|
||||
fn decode_adts(bytes: &[u8]) -> Result<Option<(u32, Vec<Vec<f32>>)>> {
|
||||
let mut frames: Vec<(Vec<u8>, &[u8])> = Vec::new();
|
||||
let mut p = id3v2_len(bytes);
|
||||
while let Some(rest) = bytes.get(p..).filter(|r| r.len() >= 7) {
|
||||
match adts_frame_len(rest).and_then(|len| Some((len, adts_split(rest.get(..len)?)?))) {
|
||||
Some((len, f)) => {
|
||||
frames.push(f);
|
||||
p += len;
|
||||
}
|
||||
None => p += 1,
|
||||
}
|
||||
}
|
||||
let Some(asc) = frames.first().map(|f| f.0.clone()) else { return Ok(None) };
|
||||
let Some(rate) = aac_output_rate(&asc, frames.iter().take(8).map(|f| f.1)) else { return Ok(None) };
|
||||
let mut dec = PacketDecoder::aac(&asc, rate)?;
|
||||
let mut out: Vec<Vec<f32>> = Vec::new();
|
||||
let mut total = 0usize;
|
||||
let mut last = 0usize;
|
||||
for (cfg, payload) in frames {
|
||||
if cfg != asc {
|
||||
continue;
|
||||
}
|
||||
let decoded = dec.decode(payload, 0).ok().filter(|c| out.is_empty() || c.len() == out.len());
|
||||
match decoded {
|
||||
Some(c) if out.is_empty() => {
|
||||
last = c.first().map_or(0, Vec::len);
|
||||
out = c;
|
||||
}
|
||||
Some(c) => {
|
||||
last = c.first().map_or(0, Vec::len);
|
||||
for (o, s) in out.iter_mut().zip(c) {
|
||||
o.extend(s);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for o in out.iter_mut() {
|
||||
o.resize(o.len() + last, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
total = total.saturating_add(last.saturating_mul(out.len()));
|
||||
if total > MAX_ADTS_SAMPLES {
|
||||
return Err(CodecError::Unsupported("AAC file too long to import as raw ADTS (over 4 GiB decoded): remux it to .m4a".into()));
|
||||
}
|
||||
}
|
||||
Ok(out.first().is_some_and(|c| !c.is_empty()).then_some((rate, out)))
|
||||
}
|
||||
|
||||
/// A raw MPEG audio Layer I / II stream (`.mp2`) decoded with our decoder: (layer, sample rate,
|
||||
/// planar samples). Frames follow each other from the first frame header (after an ID3v2 tag);
|
||||
/// damaged data is skipped up to the next frame header of the same layer and rate, and a frame
|
||||
/// with another channel count keeps its duration as silence. `None` when the stream does not start
|
||||
/// with a Layer I / II frame followed by another frame (or the end), e.g. MP3.
|
||||
fn decode_mpeg_layer12(bytes: &[u8]) -> Option<(u8, u32, Vec<Vec<f32>>)> {
|
||||
let mut p = match bytes {
|
||||
// ID3v2: 10-byte header, a syncsafe size, and a 10-byte footer when flagged
|
||||
[b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => {
|
||||
let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize);
|
||||
size.saturating_add(if flags & 0x10 != 0 { 20 } else { 10 })
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
let mut p = id3v2_len(bytes);
|
||||
let frame_at = |p: usize| bytes.get(p..).and_then(|b| filmcraft_mpa::parse_header(b).ok()).filter(|h| h.frame_bytes > 0);
|
||||
let first = frame_at(p)?;
|
||||
let next = p.saturating_add(first.frame_bytes);
|
||||
@@ -928,7 +1017,7 @@ impl MediaSource for AudioFileSource {
|
||||
|
||||
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<std::result::Result<SharedSource, MediaError>> {
|
||||
let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
|
||||
if !matches!(ext.as_str(), "mp3" | "mp2" | "flac" | "ogg" | "oga" | "aif" | "aiff" | "aifc") {
|
||||
if !matches!(ext.as_str(), "mp3" | "mp2" | "aac" | "flac" | "ogg" | "oga" | "aif" | "aiff" | "aifc") {
|
||||
return None;
|
||||
}
|
||||
Some(AudioFileSource::decode(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
|
||||
+38
-8
@@ -175,9 +175,10 @@ impl StreamInfo {
|
||||
/// with (parameter sets, a sequence header, a key frame of another format).
|
||||
pub fn parameters_changed(&self, sample: &[u8]) -> bool {
|
||||
match self {
|
||||
Self::Nal(i) => {
|
||||
i.nals(sample).into_iter().any(|n| n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) && !i.parameter_sets.iter().any(|p| p == n))
|
||||
}
|
||||
Self::Nal(i) => i.nals(sample).into_iter().any(|n| {
|
||||
n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h)))
|
||||
&& !i.parameter_sets.iter().any(|p| without_trailing_zeros(p) == without_trailing_zeros(n))
|
||||
}),
|
||||
Self::Frame(i) => i.parameters_changed(sample),
|
||||
}
|
||||
}
|
||||
@@ -199,6 +200,14 @@ impl StreamInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// A NAL unit without the zero bytes after its last byte: a NAL unit never ends with 0x00
|
||||
/// (H.264 7.4.1, H.265 7.4.2), so they are trailing_zero_8bits some muxers keep (ffmpeg in `hvcC`), not
|
||||
/// part of the parameter set.
|
||||
fn without_trailing_zeros(nal: &[u8]) -> &[u8] {
|
||||
let end = nal.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
|
||||
nal.get(..end).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Length-prefixed parameter-set entries (`u16` length + bytes) starting at `pos`.
|
||||
fn read_sets(rec: &[u8], pos: &mut usize, count: usize, out: &mut Vec<Vec<u8>>) -> Result<()> {
|
||||
for _ in 0..count {
|
||||
@@ -402,13 +411,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nal_splitting_is_bounded() {
|
||||
let info = NalStreamInfo {
|
||||
fn hevc_info(parameter_sets: Vec<Vec<u8>>) -> NalStreamInfo {
|
||||
NalStreamInfo {
|
||||
codec: NalCodec::Hevc,
|
||||
length_size: 4,
|
||||
highest_tid: None,
|
||||
parameter_sets: Vec::new(),
|
||||
parameter_sets,
|
||||
coded: (16, 16),
|
||||
crop: (0, 0, 16, 16),
|
||||
chroma_format_idc: 1,
|
||||
@@ -419,11 +427,33 @@ mod tests {
|
||||
color: filmcraft_color::ColorInfo::REC709,
|
||||
par: (1, 1),
|
||||
reorder: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nal_splitting_is_bounded() {
|
||||
let info = hevc_info(Vec::new());
|
||||
// CRA (21) then a length that runs past the end
|
||||
let s = [0, 0, 0, 2, 21 << 1, 1, 0xff, 0xff, 0xff, 0xff, 1];
|
||||
assert_eq!(info.nal_types(&s), vec![21]);
|
||||
assert!(info.is_irap(&s));
|
||||
assert!(!info.is_irap(&[0, 0, 0, 2, 1 << 1, 1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_sets_compare_without_trailing_zero_bytes() {
|
||||
// the hvcC PPS of an ffmpeg-muxed file and the in-band one of its first sample (#1483)
|
||||
let hvcc_pps = vec![0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x90, 0x00];
|
||||
let info = StreamInfo::Nal(hevc_info(vec![hvcc_pps.clone()]));
|
||||
let sample = |nal: &[u8]| {
|
||||
let mut v = (nal.len() as u32).to_be_bytes().to_vec();
|
||||
v.extend_from_slice(nal);
|
||||
v
|
||||
};
|
||||
assert!(!info.parameters_changed(&sample(&hvcc_pps)), "the same PPS");
|
||||
assert!(!info.parameters_changed(&sample(&hvcc_pps[..7])), "a trailing zero byte is not part of the NAL unit");
|
||||
assert!(!info.parameters_changed(&sample(&[&hvcc_pps[..], &[0, 0]].concat())), "nor are several");
|
||||
assert!(info.parameters_changed(&sample(&hvcc_pps[..6])), "a missing byte that is not zero is a change");
|
||||
assert!(info.parameters_changed(&sample(&[0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x91])), "another PPS is a change");
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
//! - [`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))]
|
||||
@@ -34,6 +35,7 @@ pub mod ogg;
|
||||
mod stream_color;
|
||||
pub mod video;
|
||||
mod webm_alpha;
|
||||
pub mod y4m;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
@@ -47,6 +49,7 @@ 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 {
|
||||
@@ -128,9 +131,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, standalone audio).
|
||||
/// MPEG-1/2 video elementary streams, APV raw bitstreams, YUV4MPEG2, standalone audio).
|
||||
pub fn openers() -> Vec<filmcraft_media::Opener> {
|
||||
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, audio::opener]
|
||||
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, y4m::opener, audio::opener]
|
||||
}
|
||||
|
||||
fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
|
||||
@@ -143,6 +146,7 @@ fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
|
||||
ogg::reader_opener,
|
||||
mpeg::reader_opener,
|
||||
apv::reader_opener,
|
||||
y4m::reader_opener,
|
||||
avi::reader_opener,
|
||||
])
|
||||
})
|
||||
|
||||
@@ -83,7 +83,7 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec
|
||||
let out_rate = u32::try_from(rate).unwrap_or(0);
|
||||
let frame = match &t.codec {
|
||||
Codec::Aac { asc } => fixed_packet_samples(FixedFrames::Aac(asc), &[], out_rate),
|
||||
Codec::Mp3 | Codec::Mp2 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate),
|
||||
Codec::Mp3 | Codec::Mp2 | Codec::Mp1 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate),
|
||||
Codec::Ac3 => fixed_packet_samples(FixedFrames::Ac3, &first(), out_rate),
|
||||
Codec::Eac3 => fixed_packet_samples(FixedFrames::Eac3, &first(), out_rate),
|
||||
_ => None,
|
||||
@@ -317,7 +317,7 @@ fn codec_label(c: &Codec) -> String {
|
||||
Codec::Flac { .. } => "FLAC".into(),
|
||||
Codec::Pcm { bits, float, .. } => format!("PCM {bits}-bit{}", if *float { " float" } else { "" }),
|
||||
Codec::Mp3 => "MP3".into(),
|
||||
Codec::Mp2 => "MPEG Audio".into(),
|
||||
Codec::Mp2 | Codec::Mp1 => "MPEG Audio".into(),
|
||||
Codec::Other(id) if id == "V_MPEG2" => "MPEG-2 Video".into(),
|
||||
Codec::Other(id) if id == "V_MPEG1" => "MPEG-1 Video".into(),
|
||||
other => other.name().to_string(),
|
||||
@@ -486,6 +486,7 @@ impl MkvSource {
|
||||
Codec::Opus { head } => PacketDecoder::opus(filmcraft_opus::OpusHead::parse(head).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?),
|
||||
Codec::Mp3 => PacketDecoder::new(CODEC_TYPE_MP3, rate, None),
|
||||
Codec::Mp2 => PacketDecoder::mpeg_audio(2, rate),
|
||||
Codec::Mp1 => PacketDecoder::mpeg_audio(1, rate),
|
||||
Codec::Ac3 | Codec::Eac3 => PacketDecoder::ac3(),
|
||||
// symphonia wants the STREAMINFO block body: skip `fLaC` + the 4-byte block header.
|
||||
Codec::Flac { private } => PacketDecoder::new(CODEC_TYPE_FLAC, rate, private.get(8..42).map(<[u8]>::to_vec)),
|
||||
|
||||
@@ -347,6 +347,7 @@ impl Mp4Source {
|
||||
Some(Mp4Audio { track: i, state, starts, offset, preroll })
|
||||
})
|
||||
.collect();
|
||||
snap_to_frame_grid(&mut file, vtrack);
|
||||
rebase_unedited_video(&mut file, vtrack, &mut audios);
|
||||
Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation).with_crop(aperture), audios })
|
||||
}
|
||||
@@ -666,6 +667,66 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, Media
|
||||
Some(Mp4Source::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
|
||||
/// Put the video samples of a constant-rate file whose pictures sit just off its frame grid back
|
||||
/// on the grid (#1282). Phone footage stamps frames up to a millisecond after where they belong (a
|
||||
/// first frame 34.1 ms long at 30 fps), and the frame shown at a time is the last one that starts at
|
||||
/// or before it: a sequence frame asked at the exact grid time got the picture before.
|
||||
///
|
||||
/// The grid starts at the first picture and steps by the nominal frame duration. A file is snapped
|
||||
/// only when every picture is less than a quarter of a frame from its own grid point and no two land
|
||||
/// on the same one: a frame that far off was placed there (variable frame rate), so a file with one
|
||||
/// such picture keeps all its times. A picture at most one unit off is left where it is: that can't
|
||||
/// be told from the timescale's rounding. Snapping keeps the samples' presentation order. Only the video samples move: the first picture, which the audio and
|
||||
/// `rebase_unedited_video` go by, is the grid's origin, and the clip's duration comes from the
|
||||
/// track header.
|
||||
fn snap_to_frame_grid(file: &mut Mp4File, vtrack: Option<usize>) {
|
||||
let Some(t) = vtrack.and_then(|i| file.tracks.get_mut(i)) else { return };
|
||||
let Some(first) = t.samples.iter().map(|s| s.pts).min() else { return };
|
||||
// The nominal duration: the median sample duration, averaged with the durations one unit from
|
||||
// it. Those are the same duration rounded to the timescale (1501 and 1502 at 59.94 fps over
|
||||
// 90 kHz): the median alone drifts half a unit a frame, enough to snap a short clip onto the
|
||||
// wrong points.
|
||||
let mut durs: Vec<u32> = t.samples.iter().map(|s| s.duration).collect();
|
||||
durs.sort_unstable();
|
||||
let Some(&median) = durs.get(durs.len() / 2) else { return };
|
||||
let near: Vec<f64> = durs.iter().filter(|&&d| d.abs_diff(median) <= 1).map(|&d| f64::from(d)).collect();
|
||||
let mean = near.iter().sum::<f64>() / near.len().max(1) as f64;
|
||||
if mean <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let rate = FrameRate::from_f64(f64::from(t.timescale) / mean);
|
||||
// one frame lasts `step_num / step_den` track units
|
||||
let (step_num, step_den) = (i128::from(t.timescale) * i128::from(rate.den), i128::from(rate.num));
|
||||
if step_num <= 0 || step_den <= 0 {
|
||||
return;
|
||||
}
|
||||
// a sample's grid index and its new time
|
||||
let snap = |pts: i64| -> Option<(i128, i64)> {
|
||||
let off = i128::from(pts) - i128::from(first);
|
||||
// the nearest grid index, then its grid point (both rounded half up)
|
||||
let n = (2 * off * step_den + step_num).div_euclid(2 * step_num);
|
||||
let at = i128::from(first) + (2 * n * step_num + step_den).div_euclid(2 * step_den);
|
||||
let off_grid = (i128::from(pts) - at).abs();
|
||||
if off_grid * 4 * step_den >= step_num {
|
||||
return None;
|
||||
}
|
||||
Some((n, if off_grid <= 1 { pts } else { i64::try_from(at).ok()? }))
|
||||
};
|
||||
let Some(snapped) = t.samples.iter().map(|s| snap(s.pts)).collect::<Option<Vec<_>>>() else { return };
|
||||
// Two pictures by one grid point: not a constant rate. Checked on the index, not the new time:
|
||||
// one picture kept a unit after the point and another moved onto it would be distinct times in
|
||||
// the wrong order.
|
||||
let mut points: Vec<i128> = snapped.iter().map(|&(n, _)| n).collect();
|
||||
points.sort_unstable();
|
||||
points.dedup();
|
||||
if points.len() != snapped.len() {
|
||||
return;
|
||||
}
|
||||
for (s, (_, p)) in t.samples.iter_mut().zip(snapped) {
|
||||
s.pts = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the file at its first picture when its video track has no edit list (#714). Without an
|
||||
/// edit list the media timeline is the presentation, so a B-frame stream's first picture sits at its
|
||||
/// composition delay (two frames on an x264 MP4 written without `elst`, and on fragmented MP4s):
|
||||
|
||||
@@ -134,3 +134,104 @@ fn webm_with_1ms_timestamps_shows_every_frame_once() {
|
||||
fn mp4_with_a_1ms_timescale_shows_every_frame_once() {
|
||||
check("mov", mp4_1ms);
|
||||
}
|
||||
|
||||
/// An MP4 at a 90 kHz timescale whose frames after the first start `late` units after the frame
|
||||
/// grid, as phone footage does (issue #1282): the first sample lasts `late` units longer.
|
||||
fn mp4_90k_late(rate: FrameRate, late: i64) -> Arc<[u8]> {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
|
||||
let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 90_000)).unwrap();
|
||||
let start = |k: usize| {
|
||||
let grid = (k as i128 * 90_000 * rate.den as i128 + rate.num as i128 / 2) / rate.num as i128;
|
||||
grid as i64 + if k > 0 { late } else { 0 }
|
||||
};
|
||||
for k in 0..frame_count(rate) {
|
||||
let duration = (start(k + 1) - start(k)) as u32;
|
||||
w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner().into()
|
||||
}
|
||||
|
||||
fn check_90k_late(late: i64) {
|
||||
let mut failures = Vec::new();
|
||||
for rate in RATES {
|
||||
let src = crate::open_bytes("synthetic.mov", mp4_90k_late(rate, late)).unwrap();
|
||||
for p in problems(src.as_ref(), rate) {
|
||||
failures.push(format!("{rate}, frames {late}/90000 s late: {p}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "mov:\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_with_frames_1ms_after_the_grid_shows_every_frame_once() {
|
||||
check_90k_late(90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_with_frames_on_the_grid_shows_every_frame_once() {
|
||||
check_90k_late(0);
|
||||
}
|
||||
|
||||
/// An MP4 at 90 kHz with these sample durations.
|
||||
fn mp4_90k(durations: &[u32]) -> Arc<[u8]> {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
|
||||
let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 90_000)).unwrap();
|
||||
for (k, &duration) in durations.iter().enumerate() {
|
||||
w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner().into()
|
||||
}
|
||||
|
||||
/// Files that are not a constant rate gone slightly off its grid keep their own times: whatever is
|
||||
/// asked (each frame's start, the unit before it, every 30 fps grid point), the frame shown is the
|
||||
/// last one starting at or before it.
|
||||
#[test]
|
||||
fn mp4_variable_rate_and_on_grid_frame_times_stay_put() {
|
||||
let cases: [(&str, Vec<u32>); 5] = [
|
||||
// 1/30 s and 1/20 s in turn
|
||||
("alternating", (0..60).map(|k| if k % 2 == 0 { 3000 } else { 4500 }).collect()),
|
||||
// 30 fps, then every frame 0.4 frame later (one frame lasts 1.4 frames)
|
||||
("shifted", (0..80).map(|k| if k == 40 { 4200 } else { 3000 }).collect()),
|
||||
// 30 fps with a frame shown for a fifth of a frame: it and the next are both near one grid point
|
||||
(
|
||||
"stutter",
|
||||
(0..80)
|
||||
.map(|k| match k {
|
||||
40 => 600,
|
||||
41 => 5400,
|
||||
_ => 3000,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
// 30 fps with two frames by one grid point, one a unit after it and one five units after
|
||||
(
|
||||
"one unit and five after one point",
|
||||
(0..80)
|
||||
.map(|k| match k {
|
||||
39 => 3001,
|
||||
40 => 4,
|
||||
41 => 2995,
|
||||
_ => 3000,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
// 59.94 fps on its grid, stamps truncated to the timescale (1501, 1502, …)
|
||||
("59.94 truncated", (0..120).map(|k: u64| ((k + 1) * 1_501_500 / 1000 - k * 1_501_500 / 1000) as u32).collect()),
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
for (name, durations) in cases {
|
||||
let src = crate::open_bytes("synthetic.mov", mp4_90k(&durations)).unwrap();
|
||||
let starts: Vec<i64> = durations.iter().scan(0i64, |t, &d| Some(std::mem::replace(t, *t + i64::from(d)))).collect();
|
||||
let end = *starts.last().unwrap();
|
||||
let mut asked: Vec<i64> = starts.iter().flat_map(|&s| [s, s - 1]).filter(|&s| s >= 0).collect();
|
||||
asked.extend((0..).map(|k| k * 3000).take_while(|&t| t <= end));
|
||||
for t in asked {
|
||||
let want = starts.iter().rposition(|&s| s <= t).unwrap();
|
||||
let got = index_of(&src.video_frame(FrameRequest::full(Tick::from_rational(t, 1, 90_000))).unwrap());
|
||||
if got != want {
|
||||
failures.push(format!("{name}: at {t}/90000 s wanted frame {want}, got {got}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures[..failures.len().min(8)].join("\n"));
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Standalone ADTS `.aac` files (#1291): File ▸ Import offers them, and FilmCraft's own AAC export
|
||||
//! writes them (#814), so they must open as audio. The streams are made with our AAC encoder, which
|
||||
//! prefixes each access unit with an ADTS header (ISO/IEC 13818-7 §6.2).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_aac::{Encoder, EncoderConfig};
|
||||
|
||||
const RATE: u32 = 48_000;
|
||||
|
||||
/// `secs` of a 440 Hz tone at half scale in every channel, as an ADTS stream.
|
||||
fn adts_tone(channels: usize, secs: u32) -> Vec<u8> {
|
||||
let tone: Vec<f32> = (0..RATE * secs).map(|i| (std::f32::consts::TAU * 440.0 * i as f32 / RATE as f32).sin() * 0.5).collect();
|
||||
let planar: Vec<&[f32]> = (0..channels).map(|_| tone.as_slice()).collect();
|
||||
let mut enc = Encoder::new(EncoderConfig { adts: true, ..EncoderConfig::cbr(RATE, channels, 128_000) }).unwrap();
|
||||
let mut out = Vec::new();
|
||||
for au in enc.encode(&planar).into_iter().chain(enc.flush()) {
|
||||
out.extend(au);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn rms(s: &[f32]) -> f32 {
|
||||
(s.iter().map(|v| v * v).sum::<f32>() / s.len().max(1) as f32).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adts_aac_files_import_as_audio() {
|
||||
let src = filmcraft_codecs::open_bytes("tone.aac", Arc::from(adts_tone(2, 1))).unwrap();
|
||||
let info = src.info();
|
||||
let a = info.audio().unwrap();
|
||||
assert_eq!((a.sample_rate, a.channels), (RATE, 2));
|
||||
// One second plus the encoder's priming and the last frame's padding (at most two frames).
|
||||
let secs = info.duration.seconds();
|
||||
assert!((1.0..=1.0 + 2048.0 / RATE as f64).contains(&secs), "duration {secs}");
|
||||
let buf = src.audio(RATE as i64 / 4, RATE as usize / 2, RATE).unwrap();
|
||||
for (c, ch) in buf.channels.iter().enumerate() {
|
||||
let level = rms(ch);
|
||||
assert!((level - 0.5 / 2f32.sqrt()).abs() < 0.05, "channel {c} rms {level}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_adts_files_fail_without_panicking() {
|
||||
let good = adts_tone(1, 1);
|
||||
let mut cases: Vec<Vec<u8>> = (0..good.len().min(4096)).step_by(7).map(|n| good[..n].to_vec()).collect();
|
||||
for i in (0..good.len()).step_by(97) {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 1 << (i % 8);
|
||||
cases.push(b);
|
||||
}
|
||||
// A frame length of zero, and one running past the end of the file.
|
||||
let mut zero = good.clone();
|
||||
zero[3] &= !3;
|
||||
zero[4] = 0;
|
||||
zero[5] &= 0x1F;
|
||||
cases.push(zero);
|
||||
let mut long = good[..64].to_vec();
|
||||
long[3] |= 3;
|
||||
long[4] = 0xFF;
|
||||
cases.push(long);
|
||||
for (i, b) in cases.into_iter().enumerate() {
|
||||
let r = std::panic::catch_unwind(|| filmcraft_codecs::open_bytes("damaged.aac", Arc::from(b)).map(|s| s.audio(0, 4800, RATE).is_ok()));
|
||||
assert!(r.is_ok(), "case {i} panicked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! MPEG audio Layer I (MP1) in Matroska (`A_MPEG/L1`, #1402) against ffmpeg's decode. No
|
||||
//! installed encoder writes Layer I, so the frames are synthetic (valid random Layer I frames,
|
||||
//! as in `crates/mpa/tests/oracle.rs`) and ffmpeg only remuxes them into Matroska and decodes.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
struct Bits(Vec<u8>, usize);
|
||||
impl Bits {
|
||||
fn put(&mut self, v: u32, n: u32) {
|
||||
for i in (0..n).rev() {
|
||||
if self.1.is_multiple_of(8) {
|
||||
self.0.push(0);
|
||||
}
|
||||
if (v >> i) & 1 == 1 {
|
||||
let last = self.0.len() - 1;
|
||||
self.0[last] |= 0x80 >> (self.1 % 8);
|
||||
}
|
||||
self.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mono Layer I frames, MPEG-1, 48 kHz, 192 kbit/s (192-byte frames): allocations on the first 8
|
||||
/// subbands only, so every frame fits.
|
||||
fn layer1_stream(frames: usize) -> Vec<u8> {
|
||||
let mut seed = 0x2545_F491u32;
|
||||
let mut rnd = |n: u32| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed % n
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..frames {
|
||||
let mut b = Bits(Vec::new(), 0);
|
||||
for byte in [0xFFu32, 0xFF, 6 << 4 | 1 << 2, 3 << 6] {
|
||||
b.put(byte, 8);
|
||||
}
|
||||
let alloc: Vec<u32> = (0..32).map(|sb| if sb < 8 { 1 + rnd(8) } else { 0 }).collect();
|
||||
for &a in &alloc {
|
||||
b.put(a, 4);
|
||||
}
|
||||
for &a in &alloc {
|
||||
if a != 0 {
|
||||
b.put(10 + rnd(30), 6);
|
||||
}
|
||||
}
|
||||
for _ in 0..12 {
|
||||
for &a in &alloc {
|
||||
if a != 0 {
|
||||
b.put(rnd((1 << (a + 1)) - 1), a + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut f = b.0;
|
||||
f.resize(192, 0);
|
||||
out.extend(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp1_in_matroska_matches_ffmpeg() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let raw = dir().join("synthetic_layer1.mp1");
|
||||
std::fs::create_dir_all(dir()).unwrap();
|
||||
std::fs::write(&raw, layer1_stream(250)).unwrap();
|
||||
let Some(mkv) = fixture(&ff, "synthetic_layer1.mkv", &["-f", "mp3", "-i", raw.to_str().unwrap(), "-c:a", "copy"]) else {
|
||||
eprintln!("SKIPPED: this ffmpeg can't remux Layer I into Matroska");
|
||||
return;
|
||||
};
|
||||
let m = filmcraft_matroska::open(bytes(&mkv)).unwrap();
|
||||
assert!(matches!(m.tracks.first().map(|t| &t.codec), Some(filmcraft_matroska::Codec::Mp1)), "A_MPEG/L1 maps to Codec::Mp1");
|
||||
let src = filmcraft_codecs::open_bytes("synthetic_layer1.mkv", bytes(&mkv)).unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
assert_eq!((a.sample_rate, a.channels), (48_000, 1));
|
||||
let want = ffmpeg_audio_f32(&ff, &mkv, &[]);
|
||||
let n = want.len().min(250 * 384) - 800;
|
||||
let got = src.audio(0, n, 48_000).unwrap();
|
||||
let peak = want.iter().fold(0f32, |m, x| m.max(x.abs()));
|
||||
// ffmpeg's CLI may or may not drop the decoder delay of a stream copy; search a few lags
|
||||
let (worst, lag) = (-2i64..=2)
|
||||
.map(|lag| ((2..n - 2).map(|i| (want[(i as i64 + lag) as usize] - got.channels[0][i]).abs()).fold(0f32, f32::max), lag))
|
||||
.fold((f32::INFINITY, 0), |m, x| if x.0 < m.0 { x } else { m });
|
||||
println!("mkv Layer I: max error {worst} at lag {lag}, peak {peak}");
|
||||
assert!(peak > 0.01 && worst <= 2e-4, "peak {peak}, max error {worst} at lag {lag}");
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! 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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-7
@@ -559,7 +559,8 @@ 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
|
||||
// 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)
|
||||
let carried = carried_transitions(&work, &placed);
|
||||
let mut ids = HashMap::new();
|
||||
let mut links = HashMap::new();
|
||||
@@ -596,9 +597,10 @@ pub fn copy_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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).
|
||||
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)?;
|
||||
@@ -607,9 +609,14 @@ 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 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)) {
|
||||
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))
|
||||
{
|
||||
carried.push((dest, Transition { start: tr.start + delta, ..tr.clone() }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 other clip's fade stays with it
|
||||
// a crossfade whose partner stays behind is dropped; the moving clip's own 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();
|
||||
|
||||
+126
-12
@@ -345,8 +345,23 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option<ItemId> {
|
||||
fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "timeline.move";
|
||||
let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new();
|
||||
let mut new_moves = Vec::new();
|
||||
for m in p.get("moves").and_then(Value::as_array).into_iter().flatten() {
|
||||
let track = track_p(s, m, "track", CMD)?;
|
||||
let track = if str_p(m, "track").is_some_and(|t| t.eq_ignore_ascii_case("new")) {
|
||||
let clip = clip_p(m, "clip").ok_or_else(|| bad(CMD, "new track needs a clip"))?;
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let (source, _) = q.find_item(clip).ok_or(edit::EditError::NoItem(clip))?;
|
||||
if q.track(source).is_some_and(|t| t.locked) {
|
||||
return Err(edit::EditError::Locked.into());
|
||||
}
|
||||
if m.get("time").and_then(Value::as_i64).is_none() {
|
||||
return Err(bad(CMD, "new track needs a time"));
|
||||
}
|
||||
new_moves.push((clip, source));
|
||||
Some(source)
|
||||
} else {
|
||||
track_p(s, m, "track", CMD)?
|
||||
};
|
||||
if let (Some(clip), Some(track), Some(time)) = (clip_p(m, "clip"), track, m.get("time").and_then(Value::as_i64)) {
|
||||
listed.push((clip, track, Tick(time)));
|
||||
}
|
||||
@@ -391,16 +406,48 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q.into_iter().flat_map(|q| q.all_tracks()).flat_map(|t| t.items.iter().map(move |i| (i.id.0, t.id, i.start, i.end(), i.item))).collect()
|
||||
};
|
||||
let before = spots(s);
|
||||
// Allocate destinations in the same transaction as the move: an invalid edit leaves
|
||||
// neither empty tracks nor history entries. Each source track keeps its clips together.
|
||||
let prepare = |q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>| -> Result<Vec<(ClipId, TrackId, Tick)>> {
|
||||
let sources: Vec<_> = q.all_tracks().filter(|t| new_moves.iter().any(|m| m.1 == t.id)).map(|t| (t.id, t.kind, t.channels)).collect();
|
||||
for kind in [TrackKind::Video, TrackKind::Audio] {
|
||||
let added = sources.iter().filter(|s| s.1 == kind).count();
|
||||
if added > 0 && q.tracks(kind).len().saturating_add(added) > crate::sequence_tools::MAX_TRACKS {
|
||||
return Err(bad(CMD, "new tracks exceed the sequence track limit"));
|
||||
}
|
||||
}
|
||||
let mut destinations = Vec::new();
|
||||
for (source, kind, channels) in sources {
|
||||
let tracks = if kind == TrackKind::Video { &mut q.video_tracks } else { &mut q.audio_tracks };
|
||||
let prefix = if kind == TrackKind::Video { "Video" } else { "Audio" };
|
||||
let id = TrackId(ctx.alloc());
|
||||
let mut track = Track::new(id, kind, format!("{prefix} {}", tracks.len() + 1));
|
||||
track.channels = channels;
|
||||
tracks.push(track);
|
||||
destinations.push((source, id));
|
||||
}
|
||||
Ok(moves
|
||||
.iter()
|
||||
.map(|&(clip, track, time)| {
|
||||
let dest = new_moves.iter().find(|m| m.0 == clip).and_then(|m| destinations.iter().find(|d| d.0 == m.1)).map_or(track, |d| d.1);
|
||||
(clip, dest, time)
|
||||
})
|
||||
.collect())
|
||||
};
|
||||
let copied = if copy {
|
||||
let label = if ins { "Copy (Insert)" } else { "Copy" };
|
||||
s.edit_sequence(label, |q, ctx, st| {
|
||||
let ids = edit::copy_items(q, &moves, ins, ctx)?;
|
||||
let destinations = prepare(q, ctx)?;
|
||||
let ids = edit::copy_items(q, &destinations, ins, ctx)?;
|
||||
st.selection = ids.clone();
|
||||
st.transition_selection.clear();
|
||||
Ok(ids)
|
||||
})?
|
||||
} else {
|
||||
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
|
||||
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| {
|
||||
let destinations = prepare(q, ctx)?;
|
||||
Ok(edit::move_items(q, &destinations, ins, ctx)?)
|
||||
})?;
|
||||
Vec::new()
|
||||
};
|
||||
// An overwrite shortens, splits or removes whatever already sits where a clip lands, and a
|
||||
@@ -1630,6 +1677,58 @@ 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;
|
||||
@@ -2528,12 +2627,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
None,
|
||||
r#"{"moves":[{"clip":id,"track":id|"V2"|"new" (after the last of its kind),"time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
if crate::sequence_tools::move_wants_new_track(p) {
|
||||
return crate::sequence_tools::move_to_new_tracks(s, p);
|
||||
}
|
||||
move_clips(s, p)
|
||||
}
|
||||
move_clips
|
||||
),
|
||||
cmd!(
|
||||
"timeline.trim",
|
||||
@@ -3148,20 +3242,40 @@ fn build() -> Vec<CommandSpec> {
|
||||
items.sort_by_key(|i| i.0);
|
||||
items.dedup();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for it in s.project.sequences() {
|
||||
for it in s.project.sequences().filter(|it| !items.contains(&it.id)) {
|
||||
if let Some(q) = it.as_sequence() {
|
||||
for t in q.all_tracks() {
|
||||
used.extend(t.items.iter().map(|x| x.item));
|
||||
for clip in t
|
||||
.items
|
||||
.iter()
|
||||
.filter(|clip| !items.contains(&clip.item) || s.project.item(clip.item).is_some_and(|item| matches!(item.kind, ItemKind::Media(_))))
|
||||
{
|
||||
// Preserve the whole valid file-backed chain, not just its root.
|
||||
if !s.project.resolve_media(clip.item).is_some_and(|(_, media, _)| matches!(media.media, MediaRef::File { .. })) {
|
||||
continue;
|
||||
}
|
||||
let mut id = clip.item;
|
||||
for _ in 0..16 {
|
||||
used.insert(id);
|
||||
match s.project.item(id).map(|item| &item.kind) {
|
||||
Some(ItemKind::Subclip { parent, .. }) => id = *parent,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut gone = Vec::new();
|
||||
let mut offlined = Vec::new();
|
||||
let mut retained_subclips = Vec::new();
|
||||
for i in items {
|
||||
let keep = used.contains(&i)
|
||||
&& s.project.item(i).and_then(filmcraft_project::ProjectItem::as_media).is_some_and(|m| matches!(m.media, MediaRef::File { .. }));
|
||||
if keep {
|
||||
offlined.push(i);
|
||||
} else if used.contains(&i) && s.project.item(i).is_some_and(|item| matches!(item.kind, ItemKind::Subclip { .. })) {
|
||||
retained_subclips.push(i);
|
||||
} else {
|
||||
gone.push(i);
|
||||
}
|
||||
@@ -3175,7 +3289,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
for b in &bins {
|
||||
pr.root.remove_bin(*b);
|
||||
}
|
||||
for i in &offlined {
|
||||
for i in offlined.iter().chain(&retained_subclips) {
|
||||
if pr.root.parent_of(*i).is_none() {
|
||||
pr.root.children.push(filmcraft_project::BinEntry::Item(*i));
|
||||
}
|
||||
|
||||
@@ -360,6 +360,13 @@ fn user_presets_persist_with_favourites_import_and_export() {
|
||||
assert_eq!(favs, 2);
|
||||
let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len();
|
||||
assert_eq!(hits, 8, "seven QuickTime ProRes presets and MXF OP1a ProRes 422 HQ");
|
||||
// #1371: the query also matches the format id and the normalised label
|
||||
let h264_only = s.execute("export.presets.list", json!({"format": "h264"})).unwrap()["presets"].as_array().unwrap().len();
|
||||
let mut count = |q: &str| s.execute("export.presets.list", json!({"query": q})).unwrap()["presets"].as_array().unwrap().len();
|
||||
assert!(count("h264") > 0, "format id `h264` finds the H.264 presets");
|
||||
assert!(count("h264") >= h264_only, "every h264 preset is found by its format id");
|
||||
assert_eq!(count("h.264"), count("H.264"));
|
||||
assert!(count("h.264") >= h264_only);
|
||||
// persisted: a fresh session with the same data directory sees both
|
||||
let mut s2 = demo();
|
||||
s2.export_presets.set_dir(&dir.0);
|
||||
@@ -661,8 +668,11 @@ fn ranges_resolve_and_sidecar_captions_are_written() {
|
||||
let out = dir.path("cap.wav");
|
||||
s.execute("file.exportMedia", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": out, "captionSidecar": "srt", "wait": true})).unwrap();
|
||||
let srt = std::fs::read_to_string(dir.path("cap.srt")).unwrap();
|
||||
// timed from the start of the export range (In at 1 s; the caption starts on frame 35)
|
||||
assert!(srt.contains("Hello sidecar") && srt.contains("00:00:00,460 --> 00:00:01,461"), "{srt}");
|
||||
// Timed from In at 1 s (the caption starts on frame 35), clipped to the export duration.
|
||||
let resolved = s.execute("export.resolve", json!({"preset": "Waveform Audio 48 kHz 16-bit"})).unwrap();
|
||||
let duration = filmcraft_time::Tick(resolved["range"]["duration"].as_i64().unwrap());
|
||||
let timing = format!("00:00:00,460 --> {}", filmcraft_captions::format_clock(duration, ','));
|
||||
assert!(srt.contains("Hello sidecar") && srt.contains(&timing), "{srt}");
|
||||
s.execute("file.exportMedia", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": out, "captionSidecar": "vtt", "wait": true})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.path("cap.vtt")).unwrap().starts_with("WEBVTT"));
|
||||
// a bad preset name is a clean error
|
||||
@@ -706,3 +716,102 @@ fn unknown_settings_keys_are_errors_not_ignored() {
|
||||
assert_eq!(ok.effects.loudness.target_lufs, -14.0, "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_sidecars_keep_only_the_exported_interval() {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use filmcraft_captions::{Document, Format};
|
||||
use filmcraft_media::MediaSource;
|
||||
use filmcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryFiles(Mutex<BTreeMap<String, Vec<u8>>>);
|
||||
impl crate::Services for MemoryFiles {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
self.0.lock().unwrap().get(path).cloned().ok_or_else(|| std::io::ErrorKind::NotFound.into())
|
||||
}
|
||||
fn write_file(&self, path: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
self.0.lock().unwrap().insert(path.to_owned(), bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
fn export_in_memory(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn session(text: &str) -> (Session, Arc<MemoryFiles>) {
|
||||
let files = Arc::new(MemoryFiles::default());
|
||||
files.0.lock().unwrap().insert("input.vtt".into(), text.as_bytes().to_vec());
|
||||
let mut s = Session::new(files.clone());
|
||||
s.execute("file.newSequence", json!({"name":"Caption range", "fps":25, "width":16, "height":16, "video":0, "audio":0})).unwrap();
|
||||
s.execute("captions.import", json!({"path":"input.vtt"})).unwrap();
|
||||
s.execute("markers.markIn", json!({"time":2 * TICKS_PER_SECOND})).unwrap();
|
||||
// The UI Out point includes that frame; the export range ends at exactly five seconds.
|
||||
s.execute("markers.markOut", json!({"time":5 * TICKS_PER_SECOND - TICKS_PER_SECOND / 25})).unwrap();
|
||||
(s, files)
|
||||
}
|
||||
|
||||
fn sidecar(s: &mut Session, files: &MemoryFiles, format: Format, range: Value, duration: Tick) -> Document {
|
||||
let ext = format.extension();
|
||||
let mut params = json!({"format":"wav", "path":"output.wav", "captionSidecar":ext, "wait":true});
|
||||
params.as_object_mut().unwrap().extend(range.as_object().unwrap().clone());
|
||||
let result = s.execute("file.exportMedia", params).unwrap();
|
||||
assert_eq!(result["result"]["path"], "output.wav");
|
||||
assert!(result["result"]["bytes"].as_u64().is_some_and(|n| n > 0), "{result}");
|
||||
let wav = files.0.lock().unwrap().get("output.wav").unwrap().clone();
|
||||
let audio = filmcraft_media::wav::WavSource::parse("output.wav", wav.into()).unwrap();
|
||||
assert_eq!(audio.info().duration, duration, "exported audio duration");
|
||||
let bytes = files.0.lock().unwrap().get(&format!("output.{ext}")).unwrap().clone();
|
||||
let doc = filmcraft_captions::parse(&bytes, format).unwrap();
|
||||
assert!(doc.warnings.is_empty(), "{doc:?}");
|
||||
doc
|
||||
}
|
||||
|
||||
// Every time is both a millisecond and an exact frame boundary at 25 fps.
|
||||
let fixture = "WEBVTT\n\nSTYLE\n::cue { color: yellow }\n\nREGION\nid:bottom\n\nNOTE original fixture\n\nbefore\n00:00:00.000 --> 00:00:01.000\nBefore\n\nin\n00:00:01.600 --> 00:00:02.400 line:90% align:center\n<v Ann>Crossing In\n\ninside\n00:00:03.000 --> 00:00:04.000\nInside\n\nout\n00:00:04.600 --> 00:00:05.400\nCrossing Out\n\nafter\n00:00:06.000 --> 00:00:07.000\nAfter\n";
|
||||
let (mut s, files) = session(fixture);
|
||||
let original = serde_json::to_value(&s.project).unwrap();
|
||||
let history = s.execute("history.list", json!({})).unwrap();
|
||||
let ticks = |tenths: i64| Tick(tenths * (TICKS_PER_SECOND / 10));
|
||||
let expected = [(ticks(0), ticks(4), "Crossing In"), (ticks(10), ticks(20), "Inside"), (ticks(26), ticks(30), "Crossing Out")];
|
||||
for format in [Format::Srt, Format::WebVtt] {
|
||||
for range in [json!({"range":"custom", "startTime":2 * TICKS_PER_SECOND, "endTime":5 * TICKS_PER_SECOND}), json!({"range":"inOut"}), json!({})] {
|
||||
let doc = sidecar(&mut s, &files, format, range, ticks(30));
|
||||
let actual: Vec<_> = doc.cues.iter().map(|c| (c.start, c.end, c.text.as_str())).collect();
|
||||
assert_eq!(actual, expected, "{format:?}: exclude later cues and clip both crossing boundaries");
|
||||
if format == Format::WebVtt {
|
||||
assert_eq!(doc.cues[0].id.as_deref(), Some("in"));
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann"));
|
||||
assert_eq!(doc.cues[0].settings, "line:90% align:center");
|
||||
assert_eq!(doc.blocks, ["STYLE\n::cue { color: yellow }", "REGION\nid:bottom", "NOTE original fixture"]);
|
||||
}
|
||||
}
|
||||
let all = sidecar(&mut s, &files, format, json!({"range":"entire"}), ticks(70));
|
||||
let actual: Vec<_> = all.cues.iter().map(|c| (c.start, c.end, c.text.as_str())).collect();
|
||||
assert_eq!(
|
||||
actual,
|
||||
[
|
||||
(ticks(0), ticks(10), "Before"),
|
||||
(ticks(16), ticks(24), "Crossing In"),
|
||||
(ticks(30), ticks(40), "Inside"),
|
||||
(ticks(46), ticks(54), "Crossing Out"),
|
||||
(ticks(60), ticks(70), "After")
|
||||
]
|
||||
);
|
||||
// A standalone caption export still exports the whole track even with sequence In/Out set.
|
||||
let path = format!("standalone.{}", format.extension());
|
||||
s.execute("captions.export", json!({"path":path})).unwrap();
|
||||
let standalone = filmcraft_captions::parse(files.0.lock().unwrap().get(&path).unwrap(), format).unwrap();
|
||||
assert_eq!(standalone, all);
|
||||
}
|
||||
assert_eq!(serde_json::to_value(&s.project).unwrap(), original, "export must not trim the project captions");
|
||||
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
|
||||
|
||||
let (mut edge, edge_files) = session("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nEnds at In\n\n00:00:05.000 --> 00:00:06.000\nStarts at Out\n");
|
||||
for format in [Format::Srt, Format::WebVtt] {
|
||||
let doc = sidecar(&mut edge, &edge_files, format, json!({"range":"inOut"}), ticks(30));
|
||||
assert!(doc.cues.is_empty(), "half-open export excludes cues touching either boundary: {doc:?}");
|
||||
}
|
||||
}
|
||||
@@ -449,7 +449,11 @@ fn write_sidecar(s: &Session, project: &Project, seq: ItemId, settings: &ExportS
|
||||
let Some(track) = q.caption_tracks.first() else { return Ok(None) };
|
||||
let format = filmcraft_captions::Format::from_name(kind).ok_or_else(|| bad("export", format!("caption sidecar `{kind}`: srt | vtt")))?;
|
||||
let range = filmcraft_export::export_range(project, seq, settings).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let doc = filmcraft_captions::document_from_track(track, range.start);
|
||||
let mut doc = filmcraft_captions::document_from_track(track, range.start);
|
||||
doc.cues.retain_mut(|cue| {
|
||||
cue.end = cue.end.min(range.duration);
|
||||
cue.start < cue.end
|
||||
});
|
||||
let bytes =
|
||||
filmcraft_captions::write(&doc, format, filmcraft_captions::WriteOptions { drop_frame: q.settings.drop_frame, rate: Some(q.settings.frame_rate) });
|
||||
let ext = if format == filmcraft_captions::Format::WebVtt { "vtt" } else { kind };
|
||||
@@ -953,8 +957,14 @@ fn preset_json(s: &Session, pr: &ExportPreset, full: bool) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// Lower-cased alphanumerics only, so `h264`, `h.264` and `H.264` compare equal.
|
||||
fn squash_alnum(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
fn presets_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let query = str_p(p, "query").unwrap_or("").to_ascii_lowercase();
|
||||
let squashed = squash_alnum(&query);
|
||||
let cat = str_p(p, "category").map(str::to_ascii_lowercase);
|
||||
let fav = bool_p(p, "favorites").unwrap_or(false);
|
||||
let fmt = str_p(p, "format").and_then(Format::from_name);
|
||||
@@ -967,6 +977,10 @@ fn presets_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|| x.name.to_ascii_lowercase().contains(&query)
|
||||
|| x.description.to_ascii_lowercase().contains(&query)
|
||||
|| x.category.to_ascii_lowercase().contains(&query)
|
||||
// The format id (`h264`) and label (`H.264`) are equivalent spellings.
|
||||
|| (!squashed.is_empty()
|
||||
&& (squash_alnum(x.settings.format.id()).contains(&squashed)
|
||||
|| squash_alnum(x.settings.format.label()).contains(&squashed)))
|
||||
})
|
||||
.filter(|x| cat.as_ref().is_none_or(|c| x.category.to_ascii_lowercase() == *c))
|
||||
.filter(|x| !fav || s.export_presets.is_favorite(&x.name))
|
||||
|
||||
@@ -514,8 +514,79 @@ fn union_box(boxes: &[(usize, Vec2, [f64; 4])]) -> [f64; 4] {
|
||||
boxes.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, b| [a[0].min(b.2[0]), a[1].min(b.2[1]), a[2].max(b.2[2]), a[3].max(b.2[3])])
|
||||
}
|
||||
|
||||
/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection.
|
||||
/// Video clips (not graphics) the align commands move by their Motion ▸ Position (#1081): `clip`,
|
||||
/// else the selected ones. Empty when a graphic clip is named or selected: its layers align.
|
||||
fn align_target_clips(s: &Session, p: &Value) -> Vec<ClipId> {
|
||||
let Some(seq) = s.active_sequence() else { return Vec::new() };
|
||||
let video = |c: &ClipId| seq.find_item(*c).is_some_and(|(t, _)| seq.track(t).is_some_and(|t| t.kind == TrackKind::Video)) && !is_graphic(s, seq, *c);
|
||||
match u64_p(p, "clip").map(ClipId) {
|
||||
Some(c) => [c].into_iter().filter(video).collect(),
|
||||
None if s.state.selection.iter().any(|c| is_graphic(s, seq, *c)) => Vec::new(),
|
||||
None => s.state.selection.iter().copied().filter(video).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_graphic_or_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
if align_target_clips(s, &Value::Null).is_empty() { has_graphic(s).map_err(|_| "select a graphic or video clip".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Align video clips to the frame (each, as a group, or to their union) by moving Motion ▸
|
||||
/// Position only, from the clip's scaled and rotated bounds at the playhead. Position gets a
|
||||
/// keyframe there only when it is already animated, as typing a value would.
|
||||
fn align_clips(s: &mut Session, clips: &[ClipId], how: &str, mode: AlignTo) -> Result<Value> {
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let t = s.playhead();
|
||||
let at = |it: &filmcraft_project::TrackItem| it.source_time_at(t.min(it.end() - Tick(1)).max(it.start));
|
||||
let frame = [0.0, 0.0, seq.settings.width as f64, seq.settings.height as f64];
|
||||
let mut boxes = Vec::new();
|
||||
for (i, &c) in clips.iter().enumerate() {
|
||||
let Some((_, it)) = seq.find_item(c) else { continue };
|
||||
let Some(src) = s.project.source_size(it.item) else { continue };
|
||||
let Some(motion) = it.effect("motion") else { continue };
|
||||
let mt = at(it);
|
||||
let m = filmcraft_render::motion_matrix(seq, it, src, s.project.source_par(it.item), mt);
|
||||
let (w, h) = (src.0 as f64, src.1 as f64);
|
||||
let corners = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].map(|(x, y)| m.apply(Vec2::new(x, y)));
|
||||
let b = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.x), b[1].min(p.y), b[2].max(p.x), b[3].max(p.y)]);
|
||||
let pos = motion.vec2_at("position", mt);
|
||||
let pos = if pos.x.is_nan() { Vec2::new(frame[2] / 2.0, frame[3] / 2.0) } else { pos };
|
||||
boxes.push((i, pos, b));
|
||||
}
|
||||
if boxes.is_empty() {
|
||||
return Err(bad("graphics.align", "the selected clips have no picture to align"));
|
||||
}
|
||||
let union = union_box(&boxes);
|
||||
let group = align_delta(how, frame, union)?;
|
||||
let mut moves = Vec::new();
|
||||
for (i, pos, b) in &boxes {
|
||||
let (dx, dy) = match mode {
|
||||
AlignTo::Frame => align_delta(how, frame, *b)?,
|
||||
AlignTo::FrameGroup => group,
|
||||
AlignTo::Selection => align_delta(how, union, *b)?,
|
||||
};
|
||||
moves.extend(clips.get(*i).map(|c| (*c, Vec2::new(pos.x + dx, pos.y + dy))));
|
||||
}
|
||||
s.edit_sequence("Align Clips", |q, _, _| {
|
||||
for (c, pos) in &moves {
|
||||
let Some((_, it)) = q.find_item_mut(*c) else { continue };
|
||||
let mt = at(it);
|
||||
if let Some(prm) = it.effects.iter_mut().find(|e| e.effect == "motion").and_then(|e| e.params.get_mut("position")) {
|
||||
prm.set_at(mt, ParamValue::Vec2(*pos));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"clips": moves.iter().map(|(c, _)| c.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection. With video
|
||||
/// clips selected instead of a graphic, the clips align (#1081).
|
||||
fn align_layers(s: &mut Session, p: &Value, how: &str, mode: AlignTo) -> Result<Value> {
|
||||
let clips = align_target_clips(s, p);
|
||||
if !clips.is_empty() {
|
||||
return align_clips(s, &clips, how, mode);
|
||||
}
|
||||
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.align", "no graphic clip"))?;
|
||||
let layers = layers_p(s, clip, p)?;
|
||||
let (boxes, size) = layer_boxes(s, clip, &layers)?;
|
||||
@@ -669,7 +740,7 @@ fn has_graphics_in_seq(s: &Session) -> std::result::Result<(), String> {
|
||||
if graphic_clips(s).is_empty() { Err("the sequence has no graphics".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers)}"#;
|
||||
const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers; selected video clips move by Motion Position)}"#;
|
||||
|
||||
macro_rules! align_cmd {
|
||||
($id:literal, $label:literal, $menu:expr, $how:literal, $mode:expr, $en:expr) => {
|
||||
@@ -787,12 +858,12 @@ fn menu_commands() -> Vec<CommandSpec> {
|
||||
Ok(json!({"clip": clip.0, "item": item.0}))
|
||||
},
|
||||
),
|
||||
align_cmd!("graphics.alignFrame.left", "Left", F, "left", AlignTo::Frame, has_graphic),
|
||||
align_cmd!("graphics.alignFrame.hcenter", "Center Horizontally", F, "hcenter", AlignTo::Frame, has_graphic),
|
||||
align_cmd!("graphics.alignFrame.right", "Right", F, "right", AlignTo::Frame, has_graphic),
|
||||
align_cmd!("graphics.alignFrame.top", "Top", F, "top", AlignTo::Frame, has_graphic),
|
||||
align_cmd!("graphics.alignFrame.vcenter", "Center Vertically", F, "vcenter", AlignTo::Frame, has_graphic),
|
||||
align_cmd!("graphics.alignFrame.bottom", "Bottom", F, "bottom", AlignTo::Frame, has_graphic),
|
||||
align_cmd!("graphics.alignFrame.left", "Left", F, "left", AlignTo::Frame, has_graphic_or_clip),
|
||||
align_cmd!("graphics.alignFrame.hcenter", "Center Horizontally", F, "hcenter", AlignTo::Frame, has_graphic_or_clip),
|
||||
align_cmd!("graphics.alignFrame.right", "Right", F, "right", AlignTo::Frame, has_graphic_or_clip),
|
||||
align_cmd!("graphics.alignFrame.top", "Top", F, "top", AlignTo::Frame, has_graphic_or_clip),
|
||||
align_cmd!("graphics.alignFrame.vcenter", "Center Vertically", F, "vcenter", AlignTo::Frame, has_graphic_or_clip),
|
||||
align_cmd!("graphics.alignFrame.bottom", "Bottom", F, "bottom", AlignTo::Frame, has_graphic_or_clip),
|
||||
align_cmd!("graphics.alignGroup.left", "Left", G, "left", AlignTo::FrameGroup, has_two_layers),
|
||||
align_cmd!("graphics.alignGroup.hcenter", "Center Horizontally", G, "hcenter", AlignTo::FrameGroup, has_two_layers),
|
||||
align_cmd!("graphics.alignGroup.right", "Right", G, "right", AlignTo::FrameGroup, has_two_layers),
|
||||
|
||||
@@ -658,3 +658,34 @@ fn a_layer_saved_before_the_box_height_existed_can_still_get_one() {
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "props": {"box_height": 100}})).unwrap();
|
||||
assert_eq!(s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"][0]["box"], json!([500.0, 100.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_to_frame_moves_selected_video_clips_by_position_only() {
|
||||
// #1081
|
||||
let mut s = demo();
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (fw, fh) = (q.settings.width as f64, q.settings.height as f64);
|
||||
let it = q.video_tracks[0].items[0].clone();
|
||||
let (sw, _) = s.project.source_size(it.item).unwrap();
|
||||
let c = it.id.0;
|
||||
assert!(!s.is_enabled("graphics.alignFrame.hcenter"), "nothing selected");
|
||||
s.execute("timeline.select", json!({"clips": [c]})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "scale", "value": 50.0})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "position", "value": [300.0, 200.0]})).unwrap();
|
||||
let pos = |s: &Session| {
|
||||
let it = s.active_sequence().unwrap().find_item(ClipId(c)).unwrap().1.clone();
|
||||
let m = it.effect("motion").unwrap();
|
||||
(m.vec2_at("position", Tick::ZERO), m.f64_at("scale", Tick::ZERO))
|
||||
};
|
||||
s.execute("graphics.alignFrame.hcenter", json!({})).unwrap();
|
||||
let (p, scale) = pos(&s);
|
||||
assert!((p.x - fw / 2.0).abs() < 1e-6 && (p.y - 200.0).abs() < 1e-6, "{p:?}");
|
||||
assert_eq!(scale, 50.0, "scale untouched");
|
||||
s.execute("graphics.alignFrame.left", json!({})).unwrap();
|
||||
assert!((pos(&s).0.x - sw as f64 * 0.25).abs() < 1e-6, "left edge on the frame's: {:?}", pos(&s).0);
|
||||
s.execute("graphics.alignFrame.bottom", json!({})).unwrap();
|
||||
let h = s.project.source_size(it.item).unwrap().1 as f64;
|
||||
assert!((pos(&s).0.y - (fh - h * 0.25)).abs() < 1e-6, "{:?}", pos(&s).0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!((pos(&s).0.y - 200.0).abs() < 1e-6, "one undo step per alignment");
|
||||
}
|
||||
@@ -225,6 +225,7 @@ fn nudge_time(s: &mut Session, frames: i64) -> Result<Value> {
|
||||
fn nudge_track(s: &mut Session, up: bool) -> Result<Value> {
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let mut moves = Vec::new();
|
||||
let mut blocked = 0usize;
|
||||
for c in &s.state.selection {
|
||||
let Some((tid, it)) = seq.find_item(*c) else { continue };
|
||||
let Some(tr) = seq.track(tid) else { continue };
|
||||
@@ -234,11 +235,17 @@ fn nudge_track(s: &mut Session, up: bool) -> Result<Value> {
|
||||
(TrackKind::Video, true) | (TrackKind::Audio, false) => 1,
|
||||
_ => -1,
|
||||
};
|
||||
let dest = usize::try_from(idx + step).ok().and_then(|i| list.get(i)).ok_or_else(|| EngineError::Other("no track to move to".into()))?;
|
||||
// a clip already on the outermost track stays put (a linked video+audio selection must not
|
||||
// fail because its audio has nowhere to go)
|
||||
let Some(dest) = usize::try_from(idx + step).ok().and_then(|i| list.get(i)) else {
|
||||
blocked += 1;
|
||||
continue;
|
||||
};
|
||||
moves.push((*c, dest.id, it.start));
|
||||
}
|
||||
if moves.is_empty() {
|
||||
return Err(EngineError::Other("no clips selected".into()));
|
||||
let why = if blocked > 0 { "no track to move to" } else { "no clips selected" };
|
||||
return Err(EngineError::Other(why.into()));
|
||||
}
|
||||
s.edit_sequence("Nudge", |q, ctx, _| Ok(edit::move_items(q, &moves, false, ctx)?))?;
|
||||
Ok(json!({"moved": moves.len()}))
|
||||
|
||||
@@ -190,6 +190,14 @@ fn nudge_slip_and_slide_the_selection_with_undo() {
|
||||
s.execute("timeline.nudgeDown", json!({})).unwrap();
|
||||
assert_eq!(track_of(&s, last.0), v1_id);
|
||||
assert!(s.execute("timeline.nudgeDown", json!({})).is_err(), "there is no track below V1");
|
||||
// a linked video+audio selection: the video goes up, the audio (already on the outermost
|
||||
// track in that direction) stays, instead of the whole nudge failing (#1368)
|
||||
s.execute("timeline.select", json!({"clips": [last.0]})).unwrap();
|
||||
assert!(s.state.selection.len() > 1, "the linked audio is selected too");
|
||||
s.execute("timeline.nudgeUp", json!({})).unwrap();
|
||||
assert_eq!(track_of(&s, last.0), s.active_sequence().unwrap().video_tracks[1].id);
|
||||
s.execute("timeline.nudgeDown", json!({})).unwrap();
|
||||
assert_eq!(track_of(&s, last.0), v1_id);
|
||||
// slip: the source In moves, the clip stays
|
||||
let mid = v1[1];
|
||||
s.execute("timeline.select", json!({"clips": [mid.0]})).unwrap();
|
||||
|
||||
@@ -1288,6 +1288,8 @@ 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;
|
||||
|
||||
@@ -73,7 +73,9 @@ 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"];
|
||||
|
||||
const PROJECT_EXTENSIONS: [&str; 6] = ["fcproj", "edl", "xml", "fcpxml", "otio", "ale"];
|
||||
/// 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 CAPTION_EXTENSIONS: [&str; 3] = ["srt", "vtt", "scc"];
|
||||
const RECENT_MAX: usize = 10;
|
||||
|
||||
|
||||
@@ -318,3 +318,12 @@ 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"));
|
||||
}
|
||||
@@ -378,6 +378,56 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"job": job, "preset": pr.id, "outputs": outputs, "skipped": skipped}))
|
||||
}
|
||||
|
||||
/// Copy to a new file, numbering collisions without replacing another take. Open the input
|
||||
/// first and create the output exclusively, so a failed read or a competing copy cannot replace
|
||||
/// an existing destination. Relink only after the copy's identity has been checked.
|
||||
fn copy_for_ingest(s: &mut Session, item: ItemId, path: &str, dir: &Path) -> Result<String> {
|
||||
let source = Path::new(path);
|
||||
let name = source.file_name().ok_or_else(|| bad("ingest", "source has no file name"))?;
|
||||
let mut input = std::fs::File::open(source).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
let mut number = 1u64;
|
||||
let (out, mut output) = loop {
|
||||
let mut next = if number == 1 { name.to_os_string() } else { source.file_stem().unwrap_or(name).to_os_string() };
|
||||
if number != 1 {
|
||||
next.push(format!(" {number}"));
|
||||
if let Some(extension) = source.extension() {
|
||||
next.push(".");
|
||||
next.push(extension);
|
||||
}
|
||||
}
|
||||
let out = dir.join(next);
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&out) {
|
||||
Ok(output) => break (out, output),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
number = number.checked_add(1).ok_or_else(|| bad("ingest", "no unused destination name"))?;
|
||||
}
|
||||
Err(e) => return Err(EngineError::Other(format!("{}: {e}", out.display()))),
|
||||
}
|
||||
};
|
||||
let copied = std::io::copy(&mut input, &mut output).and_then(|_| output.set_permissions(input.metadata()?.permissions()));
|
||||
drop(output);
|
||||
let out_s = out.to_string_lossy().into_owned();
|
||||
let linked = copied.map_err(|e| EngineError::Other(format!("copying {path}: {e}"))).and_then(|_| {
|
||||
let a = crate::relink::identity_of(&*s.services, path).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let b = crate::relink::identity_of(&*s.services, &out_s).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if a != b {
|
||||
return Err(EngineError::Other(format!("the copy of {} doesn't match the original", name.to_string_lossy())));
|
||||
}
|
||||
// The generated suffix may change the name; every other relink check still applies.
|
||||
crate::relink::relink(s, &json!({"item": item.0, "path": out_s, "relinkOthers": false, "match": {"fileName": false}}))?;
|
||||
Ok(())
|
||||
});
|
||||
if let Err(error) = linked {
|
||||
// Only this operation's exclusively created file can be removed on failure.
|
||||
if let Err(cleanup) = std::fs::remove_file(&out) {
|
||||
return Err(EngineError::Other(format!("{error}; removing incomplete copy {}: {cleanup}", out.display())));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(out_s)
|
||||
}
|
||||
|
||||
/// Ingest freshly imported items per the project's ingest settings.
|
||||
pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
|
||||
let ing = s.project.settings.ingest.clone();
|
||||
@@ -393,21 +443,10 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
|
||||
Some(d) if !d.is_empty() => PathBuf::from(d),
|
||||
_ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"),
|
||||
};
|
||||
let fname = Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let out = dir.join(&fname);
|
||||
if Path::new(&path) == out {
|
||||
if Path::new(&path).file_name().is_some_and(|name| Path::new(&path) == dir.join(name)) {
|
||||
continue;
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
std::fs::copy(&path, &out).map_err(|e| EngineError::Other(format!("copying {fname}: {e}")))?;
|
||||
let out_s = out.to_string_lossy().into_owned();
|
||||
// verify the copy before switching to it
|
||||
let a = crate::relink::identity_of(&*s.services, &path).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let b = crate::relink::identity_of(&*s.services, &out_s).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if a != b {
|
||||
return Err(EngineError::Other(format!("the copy of {fname} doesn't match the original")));
|
||||
}
|
||||
crate::relink::relink(s, &json!({"item": i.0, "path": out_s, "relinkOthers": false}))?;
|
||||
let out_s = copy_for_ingest(s, i, &path, &dir)?;
|
||||
copied.push(json!({"item": i.0, "path": out_s}));
|
||||
}
|
||||
}
|
||||
@@ -423,6 +462,7 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
|
||||
let pr =
|
||||
preset(if ing.preset.is_empty() { DEFAULT_TRANSCODE_PRESET } else { ing.preset.as_str() }).ok_or_else(|| bad("ingest", "unknown preset"))?;
|
||||
let mut work = Vec::new();
|
||||
let mut taken = Vec::new();
|
||||
for &i in items {
|
||||
let Some((clip, path, name)) = media_path(&s.project, i) else { continue };
|
||||
if clip.info.video.is_none() {
|
||||
@@ -433,7 +473,9 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
|
||||
_ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"),
|
||||
};
|
||||
let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name);
|
||||
work.push((i, unique_path(&dir, &stem, "", pr.extension, &[]).to_string_lossy().into_owned(), pr));
|
||||
let out = unique_path(&dir, &stem, "", pr.extension, &taken).to_string_lossy().into_owned();
|
||||
taken.push(out.clone());
|
||||
work.push((i, out, pr));
|
||||
}
|
||||
if !work.is_empty() {
|
||||
let id = start_job(s, format!("Ingest: transcode {} clip(s)", work.len()), work, OnDone::ReplaceMedia, false)?;
|
||||
|
||||
@@ -124,6 +124,155 @@ fn ingest_copies_and_creates_proxies_on_import() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Import two different camera takes with the same basename into a folder that already has
|
||||
/// that name. Check the saved project with a new media pool so cached decoders cannot hide a
|
||||
/// replaced file.
|
||||
fn check_ingest_collision(action: &str) {
|
||||
use filmcraft_media::FrameRequest;
|
||||
use filmcraft_project::{ItemId, MediaRef};
|
||||
use filmcraft_time::FrameRate;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let root = tmp_dir(&format!("ingest-collision-{action}"));
|
||||
let paths = [root.join("Card A/A001.mov"), root.join("Card B/A001.mov")];
|
||||
for (path, scene) in paths.iter().zip([DemoScene::Aurora, DemoScene::Plasma]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
make_movie(path, scene, 160, 96, 4);
|
||||
}
|
||||
let original: Vec<Vec<u8>> = paths.iter().map(|p| std::fs::read(p).unwrap()).collect();
|
||||
assert_ne!(original[0], original[1], "different source takes");
|
||||
let dest = root.join("Ingest");
|
||||
std::fs::create_dir_all(&dest).unwrap();
|
||||
let existing = dest.join("A001.mov");
|
||||
let existing_bytes = b"existing destination must survive ingest";
|
||||
std::fs::write(&existing, existing_bytes).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("project.ingestSettings", json!({"enabled": true, "action": action, "destination": dest.to_string_lossy(), "preset": "prores_proxy_half"}))
|
||||
.unwrap();
|
||||
let result = s.execute("file.import", json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>()})).unwrap();
|
||||
assert_eq!(result["errors"], json!([]), "{result}");
|
||||
let items: Vec<ItemId> = result["items"].as_array().unwrap().iter().map(|i| ItemId(i.as_u64().unwrap())).collect();
|
||||
assert_eq!(items.len(), 2, "both takes imported");
|
||||
if let Some(id) = result["ingest"]["job"]["job"].as_u64() {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
let job = s.jobs.iter().find(|j| j.id == id).unwrap();
|
||||
if job.progress.finished.load(Ordering::Relaxed) && job.result.lock().unwrap().is_some() {
|
||||
assert!(job.result.lock().unwrap().as_ref().unwrap().is_ok(), "ingest job failed");
|
||||
break;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "ingest job did not finish");
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
}
|
||||
s.poll_persistence();
|
||||
}
|
||||
assert_eq!(std::fs::read(&existing).unwrap(), existing_bytes, "ingest must preserve existing destination files");
|
||||
let project_path = root.join("ingested.fcproj");
|
||||
s.execute("file.save", json!({"path": project_path.to_string_lossy()})).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("file.open", json!({"path": project_path.to_string_lossy()})).unwrap();
|
||||
let request = FrameRequest { time: FrameRate::FPS_24.tick_of(2), scale: 1.0 };
|
||||
let mut linked = BTreeSet::new();
|
||||
let mut proxies = BTreeSet::new();
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
let media = reopened.project.item(*item).unwrap().as_media().unwrap();
|
||||
let MediaRef::File { path } = &media.media else { panic!("ingest must retain file media") };
|
||||
assert!(linked.insert(path.clone()), "different takes must have different ingest paths");
|
||||
assert_eq!(std::path::Path::new(path).parent(), Some(dest.as_path()));
|
||||
assert_eq!(std::fs::read(&paths[index]).unwrap(), original[index], "source card is unchanged");
|
||||
if action != "transcode" {
|
||||
assert_eq!(std::fs::read(path).unwrap(), original[index], "each copy retains its own take");
|
||||
assert_eq!(media.identity, Some(crate::relink::identity_of_bytes(&original[index])));
|
||||
}
|
||||
let source = reopened.media.open_file(&paths[index].to_string_lossy(), &*reopened.services).unwrap();
|
||||
let expected = source.video_frame(request).unwrap();
|
||||
let ingested = reopened.source(*item).unwrap().video_frame(request).unwrap();
|
||||
let quality = |frame: &filmcraft_frame::VideoFrame| {
|
||||
let rgba = image::RgbaImage::from_raw(frame.width, frame.height, frame.to_rgba8()).unwrap();
|
||||
let expected = image::RgbaImage::from_raw(expected.width, expected.height, expected.to_rgba8()).unwrap();
|
||||
let expected = image::imageops::resize(&expected, frame.width, frame.height, image::imageops::FilterType::Triangle);
|
||||
psnr(rgba.as_raw(), expected.as_raw())
|
||||
};
|
||||
assert!(quality(&ingested) > 30.0, "saved project retains the correct picture for take {index}");
|
||||
if action == "copyAndCreateProxies" {
|
||||
let Some(MediaRef::File { path }) = &media.proxy else { panic!("copy should have its proxy attached") };
|
||||
assert!(proxies.insert(path.clone()), "different takes must have different proxy paths");
|
||||
let proxy = reopened.media.open_file(path, &*reopened.services).unwrap().video_frame(request).unwrap();
|
||||
assert!(quality(&proxy) > 30.0, "proxy retains the correct picture for take {index}");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_copy_preserves_same_named_takes_and_existing_files() {
|
||||
check_ingest_collision("copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_copy_and_proxy_preserves_same_named_takes_and_existing_files() {
|
||||
check_ingest_collision("copyAndCreateProxies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_transcode_preserves_same_named_takes_and_existing_files() {
|
||||
check_ingest_collision("transcode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_copy_keeps_sources_already_at_destination_and_extensionless_names() {
|
||||
use filmcraft_project::{ItemId, MediaRef};
|
||||
|
||||
for (name, numbered) in [("Take.wav", "Take 2.wav"), ("Take", "Take 2")] {
|
||||
let root = tmp_dir("ingest-copy-existing-source");
|
||||
let dest = root.join("Ingest");
|
||||
let card = root.join("Card");
|
||||
std::fs::create_dir_all(&dest).unwrap();
|
||||
std::fs::create_dir_all(&card).unwrap();
|
||||
let paths = [dest.join(name), card.join(name)];
|
||||
let takes = [filmcraft_media::wav::write_wav16(&[0.25; 960], 1, 48_000), filmcraft_media::wav::write_wav16(&[-0.25; 960], 1, 48_000)];
|
||||
for (path, bytes) in paths.iter().zip(&takes) {
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
let mut s = Session::default();
|
||||
s.execute("project.ingestSettings", json!({"enabled": true, "action": "copy", "destination": dest.to_string_lossy()})).unwrap();
|
||||
let result = s.execute("file.import", json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>()})).unwrap();
|
||||
assert_eq!(result["errors"], json!([]), "{result}");
|
||||
assert_eq!(result["items"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(result["ingest"]["copied"].as_array().unwrap().len(), 1, "source already at destination is kept");
|
||||
for (index, path) in [dest.join(name), dest.join(numbered)].iter().enumerate() {
|
||||
let item = ItemId(result["items"][index].as_u64().unwrap());
|
||||
let media = s.project.item(item).unwrap().as_media().unwrap();
|
||||
assert_eq!(media.media, MediaRef::File { path: path.to_string_lossy().into_owned() });
|
||||
assert_eq!(std::fs::read(path).unwrap(), takes[index]);
|
||||
assert_eq!(std::fs::read(&paths[index]).unwrap(), takes[index]);
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dest).unwrap().count(), 2, "no unnecessary copies or trailing-dot filenames");
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_copy_does_not_create_a_destination_when_the_source_is_missing() {
|
||||
use filmcraft_project::ItemId;
|
||||
|
||||
let root = tmp_dir("ingest-copy-missing-source");
|
||||
let source = root.join("Take.wav");
|
||||
std::fs::write(&source, filmcraft_media::wav::write_wav16(&[0.25; 960], 1, 48_000)).unwrap();
|
||||
let mut s = Session::default();
|
||||
let imported = s.execute("file.import", json!({"paths": [source.to_string_lossy()]})).unwrap();
|
||||
let item = ItemId(imported["items"][0].as_u64().unwrap());
|
||||
let dest = root.join("Ingest");
|
||||
s.execute("project.ingestSettings", json!({"enabled": true, "action": "copy", "destination": dest.to_string_lossy()})).unwrap();
|
||||
let before = s.project.clone();
|
||||
std::fs::rename(&source, root.join("Moved.wav")).unwrap();
|
||||
assert!(crate::proxies::ingest(&mut s, &[item]).is_err());
|
||||
assert_eq!(s.project, before, "failed copy does not relink the item");
|
||||
assert!(!dest.exists(), "no destination is created before the source can be opened");
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
/// Measure: Program-monitor playback cost of 4K H.264 with and without proxies (sequential frames
|
||||
/// at ½ resolution). Run with `RAYON_NUM_THREADS=1` so wall time ≈ CPU time:
|
||||
/// `cargo test --release -p filmcraft-engine proxy_playback_perf_4k -- --ignored --nocapture`.
|
||||
|
||||
@@ -270,3 +270,100 @@ fn jobs_list_reports_an_exports_offline_media() {
|
||||
assert_eq!(j["offlineMedia"][0]["item"], json!(items[1].0), "{j}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_bin_keeps_subclip_source_but_removes_unused_media() {
|
||||
let root = tmp_dir("delete-bin-used-media");
|
||||
let (path, items) = saved_project(&root);
|
||||
let mut s = open(&path);
|
||||
let p = std::sync::Arc::make_mut(&mut s.project);
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let range = filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, p.item(items[0]).unwrap().duration());
|
||||
let sub =
|
||||
p.add_item("Subclip", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
|
||||
for track in p.sequence_mut(seq).unwrap().all_tracks_mut() {
|
||||
track.items.retain(|clip| clip.item != items[1]);
|
||||
for clip in &mut track.items {
|
||||
clip.item = sub;
|
||||
}
|
||||
}
|
||||
let bin = p.add_bin("Sources", None);
|
||||
p.move_to_bin(&items, Some(bin)).unwrap();
|
||||
let before = s.active_sequence().unwrap().clone();
|
||||
s.execute("project.delete", json!({"items": [bin.0, items[0].0, bin.0]})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap(), &before);
|
||||
assert!(s.project.root.find_bin(bin).is_none());
|
||||
assert!(s.project.item(items[0]).unwrap().as_media().unwrap().offline);
|
||||
assert!(s.project.item(items[1]).is_none());
|
||||
assert!(s.project.resolve_media(sub).unwrap().1.offline);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.root.parent_of(items[0]), Some(bin));
|
||||
assert!(s.project.item(items[1]).is_some());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_sequence_and_its_media_does_not_keep_an_unused_source() {
|
||||
let root = tmp_dir("delete-sequence-and-media");
|
||||
let (path, items) = saved_project(&root);
|
||||
let mut s = open(&path);
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
s.execute("project.delete", json!({"items": [seq.0, items[0].0]})).unwrap();
|
||||
assert!(s.project.item(seq).is_none());
|
||||
assert!(s.project.item(items[0]).is_none());
|
||||
assert!(s.project.item(items[1]).is_some());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_subclip_and_source_does_not_keep_an_unused_source() {
|
||||
let root = tmp_dir("delete-subclip-and-source");
|
||||
let (path, items) = saved_project(&root);
|
||||
let mut s = open(&path);
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let p = std::sync::Arc::make_mut(&mut s.project);
|
||||
let range = filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, p.item(items[0]).unwrap().duration());
|
||||
let sub =
|
||||
p.add_item("Subclip", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
|
||||
for t in p.sequence_mut(seq).unwrap().all_tracks_mut() {
|
||||
for c in &mut t.items {
|
||||
if c.item == items[0] {
|
||||
c.item = sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
s.execute("project.delete", json!({"items": [sub.0, items[0].0]})).unwrap();
|
||||
assert!(s.project.item(sub).is_none());
|
||||
assert!(s.project.item(items[0]).is_none(), "a removed timeline subclip must not retain its unused source");
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_bin_preserves_intermediate_subclip_needed_by_surviving_clip() {
|
||||
let root = tmp_dir("delete-intermediate-subclip");
|
||||
let (path, items) = saved_project(&root);
|
||||
let mut s = open(&path);
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let p = std::sync::Arc::make_mut(&mut s.project);
|
||||
let range = filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, p.item(items[0]).unwrap().duration());
|
||||
let parent =
|
||||
p.add_item("Parent", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
|
||||
let outer = p.add_item("Outer", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent, range, restrict_trims: false }, None);
|
||||
for t in p.sequence_mut(seq).unwrap().all_tracks_mut() {
|
||||
for c in &mut t.items {
|
||||
if c.item == items[0] {
|
||||
c.item = outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
let bin = p.add_bin("Sources", None);
|
||||
p.move_to_bin(&[items[0], parent], Some(bin)).unwrap();
|
||||
let before = s.active_sequence().unwrap().clone();
|
||||
s.execute("project.delete", json!({"items": [bin.0]})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap(), &before);
|
||||
assert!(s.project.resolve_media(outer).is_some(), "the surviving nested subclip must still resolve");
|
||||
assert!(s.project.resolve_media(outer).unwrap().1.offline);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.root.parent_of(parent), Some(bin));
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! #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");
|
||||
}
|
||||
@@ -276,3 +276,40 @@ fn flash_cue_markers_round_trip() {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().markers.iter().all(|m| m.kind != MarkerKind::FlashCue));
|
||||
}
|
||||
|
||||
/// Apply Video Transition (Cmd+D) at both ends of an adjustment layer with Lumetri Color fades the
|
||||
/// grade in and out, as on a clip: halfway through each dissolve the picture is about halfway
|
||||
/// between the original and the graded frame, not the original with a hard cut after it.
|
||||
#[test]
|
||||
fn cmd_d_on_an_adjustment_layer_fades_its_effects_in_and_out() {
|
||||
let mut s = demo();
|
||||
let item = s.execute("file.newAdjustmentLayer", json!({"seconds": 4})).unwrap()["item"].as_u64().unwrap();
|
||||
let layer = s.execute("timeline.place", json!({"item": item, "track": "V3", "seconds": 1.5})).unwrap()["clips"][0].as_u64().unwrap();
|
||||
s.execute("effects.apply", json!({"clips": [layer], "effect": "lumetri"})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": layer, "effect": "lumetri", "param": "exposure", "value": 2.0})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [layer]})).unwrap();
|
||||
for edge in ["in", "out"] {
|
||||
s.execute("sequence.applyVideoTransition", json!({"clip": layer, "edge": edge, "frames": 24})).unwrap();
|
||||
}
|
||||
let transitions: Vec<_> = s.active_sequence().unwrap().video_tracks[2].transitions.clone();
|
||||
assert_eq!(transitions.len(), 2, "a dissolve at each end");
|
||||
let mean = |s: &Session, t: Tick| {
|
||||
let img = s.render_program_at(0.25, t).unwrap();
|
||||
img.px.chunks(4).map(|c| f64::from(c[0] + c[1] + c[2])).sum::<f64>() / (img.w * img.h) as f64
|
||||
};
|
||||
for tr in &transitions {
|
||||
let mid = tr.start + Tick(tr.duration.0 / 2);
|
||||
let mf = mean(&s, mid);
|
||||
// the original frame: the layer switched off; the graded frame: without the dissolves
|
||||
s.execute("clip.enable", json!({"clips": [layer]})).unwrap();
|
||||
let m0 = mean(&s, mid);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let ids: Vec<u64> = transitions.iter().map(|t| t.id.0).collect();
|
||||
s.execute("sequence.removeTransition", json!({"transitions": ids})).unwrap();
|
||||
let m1 = mean(&s, mid);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(m1 > m0 * 1.2, "the grade brightens the frame: {m0} → {m1}");
|
||||
let k = (mf - m0) / (m1 - m0);
|
||||
assert!((0.25..0.75).contains(&k), "halfway through the dissolve the grade is about half on: {k:.3}");
|
||||
}
|
||||
}
|
||||
@@ -774,65 +774,6 @@ pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `timeline.move` names `"new"` as the `track` of a move.
|
||||
pub(crate) fn move_wants_new_track(p: &Value) -> bool {
|
||||
p.get("moves").and_then(Value::as_array).is_some_and(|m| m.iter().any(is_new_move))
|
||||
}
|
||||
|
||||
/// One move of `timeline.move` with `"track":"new"`.
|
||||
fn is_new_move(m: &Value) -> bool {
|
||||
m.get("track").and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new"))
|
||||
}
|
||||
|
||||
/// `timeline.move` with `"track":"new"` on some moves (#1104: a clip dragged into the empty space
|
||||
/// above the top video track or below the last audio track): one track is added after the last one
|
||||
/// of each kind the `"new"` clips come from, they all land on it, and the track and the move are
|
||||
/// one undo step. When the move fails, the new tracks go away again.
|
||||
pub(crate) fn move_to_new_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.move";
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let (mut new_v, mut new_a) = (false, false);
|
||||
for m in p.get("moves").and_then(Value::as_array).into_iter().flatten().filter(|m| is_new_move(m)) {
|
||||
let clip = crate::commands::clip_p(m, "clip").ok_or_else(|| bad(cmd, "need `clip` in each move"))?;
|
||||
let (track, _) = q.find_item(clip).ok_or_else(|| bad(cmd, "no such clip"))?;
|
||||
if q.video_tracks.iter().any(|t| t.id == track) {
|
||||
new_v = true;
|
||||
} else {
|
||||
new_a = true;
|
||||
}
|
||||
}
|
||||
let added = add_tracks(s, &json!({"video": u64::from(new_v), "audio": u64::from(new_a)}))?;
|
||||
let mark = s.history.undo.len();
|
||||
let (video, audio) = (added.get("video").and_then(|v| v.get(0)).cloned(), added.get("audio").and_then(|v| v.get(0)).cloned());
|
||||
let mut moves = p.get("moves").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
for m in moves.iter_mut().filter(|m| is_new_move(m)) {
|
||||
let on_video = crate::commands::clip_p(m, "clip")
|
||||
.and_then(|c| s.active_sequence().and_then(|q| q.find_item(c).map(|(t, _)| q.video_tracks.iter().any(|v| v.id == t))))
|
||||
.unwrap_or(true);
|
||||
m["track"] = if on_video { video.clone() } else { audio.clone() }.unwrap_or(Value::Null);
|
||||
}
|
||||
let mut q = p.clone();
|
||||
q["moves"] = Value::Array(moves);
|
||||
let run = crate::commands::find(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?.run;
|
||||
match run(s, &q) {
|
||||
Ok(v) => {
|
||||
// the new tracks and the move are one undo step
|
||||
if mark >= 1
|
||||
&& s.history.undo.len() == mark + 1
|
||||
&& let (Some((label, _)), Some((_, before))) = (s.history.undo.pop(), s.history.undo.pop())
|
||||
{
|
||||
s.history.undo.push((label, before));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
s.undo();
|
||||
s.history.redo.pop();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
|
||||
@@ -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`, or `system` (the default: the operating system's preferred language when the
|
||||
/// `zh-cn`, `de`, `ru`, `fr`, `id`, `it`, 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,6 +1007,8 @@ static CATEGORIES: &[Category] = &[
|
||||
("ru", "Русский"),
|
||||
("fr", "Français"),
|
||||
("id", "Bahasa Indonesia"),
|
||||
("it", "Italiano"),
|
||||
("ko", "한국어"),
|
||||
]),
|
||||
true,
|
||||
),
|
||||
|
||||
@@ -44,6 +44,12 @@ 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");
|
||||
|
||||
@@ -737,7 +737,8 @@ impl Shortcuts {
|
||||
let nb = Binding { command: command.into(), keys: chord.canonical(), panel: panel.map(str::to_string) };
|
||||
let before = self.snapshot();
|
||||
if !add {
|
||||
self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && b.context() == nb.context()));
|
||||
let copies = if nb.panel.is_none() { self.app_chords(command, None) } else { Vec::new() };
|
||||
self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && (b.context() == nb.context() || is_copy(b, &copies))));
|
||||
}
|
||||
let mut reassigned = Vec::new();
|
||||
if !keep_conflicts {
|
||||
@@ -769,6 +770,7 @@ impl Shortcuts {
|
||||
let before = self.snapshot();
|
||||
let n0 = self.bindings.len();
|
||||
let twins = shortcut_twins(command);
|
||||
let copies = if panel == Some(None) { self.app_chords(command, chord.as_ref()) } else { Vec::new() };
|
||||
self.bindings.retain(|b| {
|
||||
let cmd = same_shortcut_command(&b.command, command);
|
||||
let key_ok = chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c));
|
||||
@@ -778,7 +780,7 @@ impl Shortcuts {
|
||||
let panel_ok = match &panel {
|
||||
None => true,
|
||||
Some(p) if p.is_none() && twins => true,
|
||||
Some(p) => &b.panel == p,
|
||||
Some(p) => &b.panel == p || is_copy(b, &copies),
|
||||
};
|
||||
!(cmd && key_ok && panel_ok)
|
||||
});
|
||||
@@ -916,6 +918,16 @@ impl Shortcuts {
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// The application-wide keys of `command` (only `key`, when given).
|
||||
fn app_chords(&self, command: &str, key: Option<&Chord>) -> Vec<Chord> {
|
||||
self.bindings
|
||||
.iter()
|
||||
.filter(|b| same_shortcut_command(&b.command, command) && b.panel.is_none())
|
||||
.filter_map(Binding::chord)
|
||||
.filter(|c| key.is_none_or(|k| k == c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- queries
|
||||
|
||||
pub fn for_command(&self, id: &str) -> Vec<&Binding> {
|
||||
@@ -1017,6 +1029,12 @@ fn same_shortcut_command(bound: &str, target: &str) -> bool {
|
||||
bound == target || (shortcut_twins(bound) && shortcut_twins(target))
|
||||
}
|
||||
|
||||
/// A panel binding on one of the command's application keys (New Bin's Cmd+B in the Project
|
||||
/// panel) is a copy of that shortcut, so editing the application shortcut edits it too (#1333).
|
||||
fn is_copy(b: &Binding, app_chords: &[Chord]) -> bool {
|
||||
b.panel.is_some() && b.chord().is_some_and(|c| app_chords.contains(&c))
|
||||
}
|
||||
|
||||
fn check_panel(panel: Option<&str>) -> std::result::Result<Option<&'static str>, String> {
|
||||
match panel {
|
||||
None => Ok(None),
|
||||
|
||||
@@ -133,6 +133,42 @@ fn clear_close_sequence_also_clears_file_close() {
|
||||
assert_eq!(s.shortcuts.resolve(&w, None, Platform::current()).unwrap().command, "sequence.addEdit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_new_bin_frees_cmd_b_in_the_project_panel_too() {
|
||||
// #1333: New Bin has Cmd+B application-wide and again in the Project panel. The editor's Clear
|
||||
// sends panel Application, so only the first went; the Project panel kept Cmd+B.
|
||||
let dir = temp_dir("new-bin");
|
||||
let mut s = Session::default();
|
||||
s.shortcuts.set_dir(&dir);
|
||||
let b = Chord::parse("Cmd+B").unwrap();
|
||||
let p = Platform::current();
|
||||
assert_eq!(s.shortcuts.resolve(&b, Some("Project"), p).unwrap().command, "file.newBin");
|
||||
s.execute("shortcuts.clear", json!({"command": "file.newBin", "panel": "Application"})).unwrap();
|
||||
assert!(s.shortcuts.for_command("file.newBin").is_empty(), "{:?}", s.shortcuts.for_command("file.newBin"));
|
||||
s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+B"})).unwrap();
|
||||
let mut t = Shortcuts::new();
|
||||
t.set_dir(&dir);
|
||||
for sc in [&s.shortcuts, &t] {
|
||||
assert_eq!(sc.resolve(&b, None, p).unwrap().command, "sequence.addEdit");
|
||||
assert_eq!(sc.resolve(&b, Some("Project"), p).unwrap().command, "sequence.addEdit");
|
||||
assert!(sc.for_command("file.newBin").is_empty());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassigning_new_bin_moves_its_project_panel_key_too() {
|
||||
// #1333: assigning New Bin another key replaced Cmd+B application-wide only.
|
||||
let mut s = Session::default();
|
||||
s.execute("shortcuts.set", json!({"command": "file.newBin", "keys": "Cmd+Alt+B"})).unwrap();
|
||||
let keys: Vec<(&str, Option<&str>)> = s.shortcuts.for_command("file.newBin").iter().map(|b| (b.keys.as_str(), b.panel.as_deref())).collect();
|
||||
assert_eq!(keys, [("Cmd+Alt+B", None)]);
|
||||
assert!(s.shortcuts.resolve(&Chord::parse("Cmd+B").unwrap(), Some("Project"), Platform::Mac).is_none());
|
||||
// a panel key of its own (History ▸ Left for Undo) is not a copy of the application one: it stays
|
||||
s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Alt+Z"})).unwrap();
|
||||
assert!(s.shortcuts.for_command("edit.undo").iter().any(|b| b.panel.as_deref() == Some("History") && b.keys == "Left"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_shortcuts_override_application_ones_with_focus() {
|
||||
let s = Session::default();
|
||||
|
||||
@@ -299,3 +299,116 @@ fn copy_leaves_the_originals_and_links_the_copies() {
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::<usize>(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_track_moves_and_copies_are_one_atomic_edit() {
|
||||
for copy in [false, true] {
|
||||
let mut s = demo();
|
||||
let (vt, v, at, a) = last_pair(&s);
|
||||
let before = s.project.clone();
|
||||
let count = s.history.undo.len();
|
||||
let q = s.active_sequence().unwrap();
|
||||
let counts = (q.video_tracks.len(), q.audio_tracks.len());
|
||||
let r = s
|
||||
.execute(
|
||||
"timeline.move",
|
||||
json!({"moves":[{"clip":v.id.0,"track":"new","time":v.start.0},{"clip":a.id.0,"track":"new","time":a.start.0}],"linked":false,"copy":copy}),
|
||||
)
|
||||
.unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (counts.0 + 1, counts.1 + 1));
|
||||
let new_v = q.video_tracks.last().unwrap();
|
||||
let new_a = q.audio_tracks.last().unwrap();
|
||||
assert_eq!(new_a.channels, before.sequence(s.state.active_sequence.unwrap()).unwrap().track(at).unwrap().channels);
|
||||
assert_eq!((new_v.items[0].item, new_a.items[0].item), (v.item, a.item));
|
||||
assert_eq!((new_v.items[0].start, new_a.items[0].start), (v.start, a.start));
|
||||
if copy {
|
||||
assert_eq!(state(&s, v.id), (vt, v.start));
|
||||
assert_eq!(state(&s, a.id), (at, a.start));
|
||||
assert_ne!(new_v.items[0].id, v.id);
|
||||
assert!(new_v.items[0].link.is_some() && new_v.items[0].link == new_a.items[0].link);
|
||||
assert_ne!(new_v.items[0].link, v.link);
|
||||
assert_eq!(s.state.selection.len(), 2);
|
||||
assert_eq!(r["copied"].as_array().unwrap().len(), 2);
|
||||
} else {
|
||||
assert_eq!(state(&s, v.id), (new_v.id, v.start));
|
||||
assert_eq!(state(&s, a.id), (new_a.id, a.start));
|
||||
}
|
||||
q.check().unwrap();
|
||||
assert_eq!(s.history.undo.len(), count + 1);
|
||||
let after = s.project.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_track_group_keeps_clips_from_the_same_source_together() {
|
||||
let mut s = demo();
|
||||
let q = s.active_sequence().unwrap();
|
||||
let clips = q.video_tracks[0].items[..2].to_vec();
|
||||
let tracks = q.video_tracks.len();
|
||||
let before = s.project.clone();
|
||||
let moves: Vec<_> = clips.iter().map(|c| json!({"clip":c.id.0,"track":"new","time":c.start.0})).collect();
|
||||
s.execute("timeline.move", json!({"moves":moves,"linked":false})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(q.video_tracks.len(), tracks + 1);
|
||||
let t = q.video_tracks.last().unwrap();
|
||||
assert_eq!(t.items, clips);
|
||||
q.check().unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_new_track_moves_preserve_document_and_both_history_stacks() {
|
||||
let mut s = demo();
|
||||
let (_, v, _, _) = last_pair(&s);
|
||||
s.execute("timeline.setTrack", json!({"track":"V1","locked":true})).unwrap();
|
||||
for moves in [
|
||||
json!([{"clip":v.id.0,"track":"new","time":v.start.0}]),
|
||||
json!([{"clip":u64::MAX,"track":"new","time":0}]),
|
||||
json!([{"clip":v.id.0,"track":"new","time":i64::MAX}]),
|
||||
json!([{"clip":v.id.0,"track":"new","time":0},{"clip":u64::MAX,"track":"V2","time":0}]),
|
||||
] {
|
||||
let before = s.project.clone();
|
||||
let history = s.execute("history.list", json!({})).unwrap();
|
||||
assert!(s.execute("timeline.move", json!({"moves":moves,"linked":false})).is_err());
|
||||
assert_eq!(*s.project, *before);
|
||||
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let before = s.project.clone();
|
||||
let history = s.execute("history.list", json!({})).unwrap();
|
||||
assert!(!history["redo"].as_array().unwrap().is_empty());
|
||||
for moves in [
|
||||
json!([{"clip":v.id.0,"track":"new","time":0},{"clip":u64::MAX,"track":"V2","time":0}]),
|
||||
json!([{"clip":v.id.0,"track":"new","time":i64::MAX}]),
|
||||
json!([{"clip":v.id.0,"track":"new"}]),
|
||||
json!([{"clip":v.id.0,"track":"new","time":"invalid"}]),
|
||||
] {
|
||||
assert!(s.execute("timeline.move", json!({"moves":moves,"linked":false})).is_err());
|
||||
assert_eq!(*s.project, *before);
|
||||
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_track_moves_respect_the_track_limit() {
|
||||
let mut s = demo();
|
||||
let (_, v, _, _) = last_pair(&s);
|
||||
s.edit_sequence("Limit fixture", |q, ctx, _| {
|
||||
while q.video_tracks.len() < crate::sequence_tools::MAX_TRACKS {
|
||||
q.video_tracks.push(filmcraft_project::Track::new(TrackId(ctx.alloc()), filmcraft_project::TrackKind::Video, "Limit".into()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.project.clone();
|
||||
let history = s.execute("history.list", json!({})).unwrap();
|
||||
assert!(s.execute("timeline.move", json!({"moves":[{"clip":v.id.0,"track":"new","time":v.start.0}],"linked":false})).is_err());
|
||||
assert_eq!(*s.project, *before);
|
||||
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
|
||||
}
|
||||
+12
-14
@@ -363,18 +363,9 @@ impl Exporter {
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let bps = if self.settings.audio.bits >= 24 { 3 } else { 2 };
|
||||
let mut pcm = Vec::with_capacity(n * buf.len() * bps);
|
||||
for i in 0..n {
|
||||
for c in &buf {
|
||||
let s = c[i].clamp(-1.0, 1.0);
|
||||
if bps == 3 {
|
||||
pcm.extend_from_slice(&((s * 8_388_607.0).round() as i32).to_le_bytes()[..3]);
|
||||
} else {
|
||||
pcm.extend_from_slice(&((s * 32767.0).round() as i16).to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
// little-endian, like the track's `lpcm` / `sowt` description says
|
||||
let mut pcm = Vec::new();
|
||||
crate::pcm::encode(crate::pcm::PcmContainer::Wav, &crate::audio_out::interleave(&buf), self.settings.audio.sample_format(), &mut pcm);
|
||||
mux.write_sample(at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true })
|
||||
.map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
}
|
||||
@@ -428,8 +419,15 @@ impl Exporter {
|
||||
Some(mux.add_track(c).map_err(|e| ExportError::Io(e.to_string()))?)
|
||||
}
|
||||
(Some(a), None) => {
|
||||
let bits = if self.settings.audio.bits >= 24 { 24 } else { 16 };
|
||||
let pcm = PcmConfig { bits, float: false, big_endian: false, signed: true, channels: a.channels as _, sample_rate: a.sr as f64 };
|
||||
let f = self.settings.audio.sample_format();
|
||||
let pcm = PcmConfig {
|
||||
bits: f.bits() as _,
|
||||
float: f.is_float(),
|
||||
big_endian: false,
|
||||
signed: true,
|
||||
channels: a.channels as _,
|
||||
sample_rate: a.sr as f64,
|
||||
};
|
||||
Some(mux.add_track(TrackConfig::new(SampleEntry::pcm(pcm), a.sr)).map_err(|e| ExportError::Io(e.to_string()))?)
|
||||
}
|
||||
};
|
||||
|
||||
+19
-11
@@ -625,6 +625,14 @@ impl ExportSettings {
|
||||
if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) {
|
||||
return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into()));
|
||||
}
|
||||
if self.audio.float && self.audio.bits != 32 {
|
||||
return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into()));
|
||||
}
|
||||
let sample_format = self.audio.sample_format();
|
||||
if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) {
|
||||
let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() };
|
||||
return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label())));
|
||||
}
|
||||
if self.field_order != FieldOrder::Progressive && self.has_video() {
|
||||
return Err(ExportError::Unsupported(format!("{} field order: FilmCraft's encoders write progressive frames", self.field_order.label())));
|
||||
}
|
||||
@@ -1108,19 +1116,19 @@ impl AudioEncoder for AacEncoder {
|
||||
/// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit.
|
||||
pub(crate) struct FlacAudio {
|
||||
enc: filmcraft_flac::Encoder,
|
||||
bits: u16,
|
||||
format: SampleFormat,
|
||||
/// Samples of the frame `flush` returns (shorter than a full block).
|
||||
tail: u32,
|
||||
}
|
||||
|
||||
impl FlacAudio {
|
||||
pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result<Self> {
|
||||
let bits = if s.audio.bits >= 24 { 24 } else { 16 };
|
||||
let format = s.audio.sample_format();
|
||||
let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?;
|
||||
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, bits as u8);
|
||||
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, format.bits() as u8);
|
||||
cfg.level = s.audio.flac_level.min(8);
|
||||
let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?;
|
||||
Ok(FlacAudio { enc, bits, tail: 0 })
|
||||
Ok(FlacAudio { enc, format, tail: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,7 +1137,7 @@ impl AudioEncoder for FlacAudio {
|
||||
SampleEntry::flac(&self.enc.streaminfo())
|
||||
}
|
||||
fn encode(&mut self, planar: &[Vec<f32>]) -> Result<Vec<Vec<u8>>> {
|
||||
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect();
|
||||
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| self.format.quantise(s)).collect()).collect();
|
||||
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
|
||||
self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))
|
||||
}
|
||||
@@ -1851,12 +1859,12 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?,
|
||||
Format::Wav | Format::Aiff | Format::Flac => {
|
||||
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
|
||||
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
|
||||
let (ch, sr, fmt) = (a.channels as u16, a.sr, settings.audio.sample_format());
|
||||
let frames = a.remaining();
|
||||
// the sizes are known up front: refuse an AIFF that cannot hold them before measuring
|
||||
let mut writer = match settings.format {
|
||||
Format::Flac => {
|
||||
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 });
|
||||
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, fmt.bits() as u8);
|
||||
cfg.level = settings.audio.flac_level.min(8);
|
||||
AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?))
|
||||
}
|
||||
@@ -1864,7 +1872,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
_ => AudioWriter::Pcm(pcm::PcmContainer::Aiff),
|
||||
};
|
||||
let head = match &writer {
|
||||
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?,
|
||||
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, fmt, frames).map_err(ExportError::Unsupported)?,
|
||||
AudioWriter::Flac(enc) => enc.header(),
|
||||
};
|
||||
// one second per chunk: memory stays flat however long the range is
|
||||
@@ -1885,9 +1893,9 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
}
|
||||
buf.clear();
|
||||
match &mut writer {
|
||||
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf),
|
||||
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), fmt, &mut buf),
|
||||
AudioWriter::Flac(enc) => {
|
||||
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect();
|
||||
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| fmt.quantise(s)).collect()).collect();
|
||||
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
|
||||
buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?;
|
||||
}
|
||||
@@ -1898,7 +1906,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
}
|
||||
}
|
||||
match &mut writer {
|
||||
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?,
|
||||
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, fmt)).map_err(io)?,
|
||||
AudioWriter::Flac(enc) => {
|
||||
out.write_all(&enc.finish()).map_err(io)?;
|
||||
// the frame sizes and MD5 are known now: fill them into STREAMINFO
|
||||
|
||||
@@ -108,7 +108,8 @@ impl MxfMux {
|
||||
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
|
||||
let seed = format!("{}|{now}", settings.path);
|
||||
let ids = PackageIds::from_seed(&seed, &s.name);
|
||||
let bits = if settings.audio.bits >= 24 { 24 } else { 16 };
|
||||
// integers only: `ExportSettings::validate` refuses float PCM in MXF
|
||||
let bits = u32::from(settings.audio.sample_format().bits());
|
||||
let mut cfg = WriterConfig::new(if atom { Pattern::OpAtom } else { Pattern::Op1a }, rate, ids.clone());
|
||||
cfg.picture = Some(picture(settings, s.width, s.height));
|
||||
cfg.modified = Timestamp::from_unix((now / 1_000_000_000) as i64);
|
||||
|
||||
+190
-59
@@ -1,10 +1,13 @@
|
||||
//! Audio-only files: WAV (RIFF, little-endian PCM; RF64 past 4 GiB) and AIFF (big-endian PCM),
|
||||
//! 16 or 24 bit, plus image-sequence file naming.
|
||||
//! 16, 24 or 32-bit integer or 32-bit float ([`SampleFormat`]), plus image-sequence file naming.
|
||||
//! Float WAV is `WAVE_FORMAT_IEEE_FLOAT` with a `fact` chunk; float AIFF is AIFF-C (`fl32`).
|
||||
//!
|
||||
//! The header is written first from the known length ([`header`]), then the samples in chunks
|
||||
//! ([`encode`]) and the pad byte ([`pad`]), so an export streams to disk instead of building the
|
||||
//! file in memory.
|
||||
|
||||
use crate::SampleFormat;
|
||||
|
||||
/// The container of an audio-only export.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PcmContainer {
|
||||
@@ -12,47 +15,55 @@ pub enum PcmContainer {
|
||||
Aiff,
|
||||
}
|
||||
|
||||
/// Quantise one sample to a signed integer of `bits` (16 or 24).
|
||||
pub(crate) fn quantise(s: f32, bits: u16) -> i32 {
|
||||
let max = if bits >= 24 { 8_388_607.0 } else { 32_767.0 };
|
||||
(s.clamp(-1.0, 1.0) * max).round() as i32
|
||||
}
|
||||
|
||||
/// The sample size written for a requested `bits`: 24, else 16.
|
||||
fn sample_bits(bits: u16) -> u16 {
|
||||
if bits >= 24 { 24 } else { 16 }
|
||||
}
|
||||
|
||||
/// Bytes of sample data for `frames` frames, or None when that does not fit 64 bits.
|
||||
fn data_len(frames: u64, channels: u16, bits: u16) -> Option<u64> {
|
||||
frames.checked_mul(u64::from(channels))?.checked_mul(u64::from(sample_bits(bits) / 8))
|
||||
fn data_len(frames: u64, channels: u16, format: SampleFormat) -> Option<u64> {
|
||||
frames.checked_mul(u64::from(channels))?.checked_mul(format.bytes() as u64)
|
||||
}
|
||||
|
||||
/// The pad byte after an odd-sized data chunk (RIFF and AIFF chunks are word-aligned).
|
||||
pub fn pad(frames: u64, channels: u16, bits: u16) -> &'static [u8] {
|
||||
if data_len(frames, channels, bits).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
|
||||
pub fn pad(frames: u64, channels: u16, format: SampleFormat) -> &'static [u8] {
|
||||
if data_len(frames, channels, format).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
|
||||
}
|
||||
|
||||
/// `KSDATAFORMAT_SUBTYPE_PCM` / `_IEEE_FLOAT` after their first two bytes (the format tag).
|
||||
const SUBTYPE_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
|
||||
|
||||
/// AIFF-C format version 1 (`FVER`).
|
||||
const AIFC_VERSION_1: u32 = 0xA280_5140;
|
||||
|
||||
/// The AIFF-C compression name of `fl32`, as a Pascal string of even length.
|
||||
const FL32_NAME: &[u8] = b"\x1532-bit floating point";
|
||||
|
||||
/// Everything before the samples of a file of `frames` frames. A WAV whose RIFF size would not
|
||||
/// fit 32 bits (4 GiB: 24-bit 5.1 at 96 kHz after about 41 minutes) is written as RF64 (EBU Tech
|
||||
/// 3306), with the 64-bit sizes in a `ds64` chunk. AIFF has no 64-bit form, so a longer AIFF is
|
||||
/// an error naming WAV instead.
|
||||
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u16, frames: u64) -> Result<Vec<u8>, String> {
|
||||
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, format: SampleFormat, frames: u64) -> Result<Vec<u8>, String> {
|
||||
let too_long = || "the audio is too long to write".to_string();
|
||||
let bits = sample_bits(bits);
|
||||
let bits = format.bits();
|
||||
let bps = bits / 8;
|
||||
let data = data_len(frames, channels, bits).ok_or_else(too_long)?;
|
||||
let float = format.is_float();
|
||||
let data = data_len(frames, channels, format).ok_or_else(too_long)?;
|
||||
let block_align = channels.checked_mul(bps).ok_or("too many audio channels")?;
|
||||
match container {
|
||||
PcmContainer::Wav => {
|
||||
let byte_rate = u32::try_from(u64::from(sample_rate) * u64::from(block_align)).map_err(|_| "the audio sample rate is too high".to_string())?;
|
||||
let ext = channels > 2;
|
||||
let fmt_len: u32 = if ext { 40 } else { 16 };
|
||||
// plain PCM has no cbSize; IEEE float has an empty one
|
||||
let fmt_len: u32 = if ext {
|
||||
40
|
||||
} else if float {
|
||||
18
|
||||
} else {
|
||||
16
|
||||
};
|
||||
// every format but integer PCM has a `fact` chunk (the frame count; RF64: in `ds64`)
|
||||
let fact_len: u64 = if float { 12 } else { 0 };
|
||||
let padded = data.checked_add(data & 1).ok_or_else(too_long)?;
|
||||
// "WAVE" + fmt chunk + data chunk header + samples
|
||||
let riff = (4 + 8 + u64::from(fmt_len) + 8).checked_add(padded).ok_or_else(too_long)?;
|
||||
// "WAVE" + fmt chunk + fact chunk + data chunk header + samples
|
||||
let riff = (4 + 8 + u64::from(fmt_len) + fact_len + 8).checked_add(padded).ok_or_else(too_long)?;
|
||||
let rf64 = riff > u64::from(u32::MAX);
|
||||
let mut v = Vec::with_capacity(80);
|
||||
let mut v = Vec::with_capacity(100);
|
||||
if rf64 {
|
||||
const DS64: u64 = 8 + 28;
|
||||
v.extend_from_slice(b"RF64");
|
||||
@@ -68,21 +79,30 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
|
||||
v.extend_from_slice(&(riff as u32).to_le_bytes());
|
||||
v.extend_from_slice(b"WAVE");
|
||||
}
|
||||
let tag: u16 = if float { 3 } else { 1 };
|
||||
v.extend_from_slice(b"fmt ");
|
||||
v.extend_from_slice(&fmt_len.to_le_bytes());
|
||||
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes());
|
||||
v.extend_from_slice(&(if ext { 0xFFFE } else { tag }).to_le_bytes());
|
||||
v.extend_from_slice(&channels.to_le_bytes());
|
||||
v.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
v.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
v.extend_from_slice(&block_align.to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
if ext {
|
||||
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
|
||||
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM / _IEEE_FLOAT
|
||||
v.extend_from_slice(&22u16.to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
let mask: u32 = if channels == 6 { 0x3F } else { 0 };
|
||||
v.extend_from_slice(&mask.to_le_bytes());
|
||||
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
|
||||
v.extend_from_slice(&tag.to_le_bytes());
|
||||
v.extend_from_slice(&SUBTYPE_TAIL);
|
||||
} else if float {
|
||||
v.extend_from_slice(&0u16.to_le_bytes());
|
||||
}
|
||||
if float {
|
||||
v.extend_from_slice(b"fact");
|
||||
v.extend_from_slice(&4u32.to_le_bytes());
|
||||
v.extend_from_slice(&(if rf64 { u32::MAX } else { u32::try_from(frames).unwrap_or(u32::MAX) }).to_le_bytes());
|
||||
}
|
||||
v.extend_from_slice(b"data");
|
||||
v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes());
|
||||
@@ -93,16 +113,31 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
|
||||
let frames32 = u32::try_from(frames).map_err(|_| aiff_too_long())?;
|
||||
// SSND: offset + block size + samples
|
||||
let ssnd = u32::try_from(data.checked_add(8).ok_or_else(aiff_too_long)?).map_err(|_| aiff_too_long())?;
|
||||
let form = (4u32 + (8 + 18)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
|
||||
let mut v = Vec::with_capacity(54);
|
||||
// floats need AIFF-C: a version chunk, and the compression type and name in COMM
|
||||
let comm: u32 = if float { 18 + 4 + FL32_NAME.len() as u32 } else { 18 };
|
||||
let fver: u32 = if float { 8 + 4 } else { 0 };
|
||||
let form =
|
||||
(4u32 + fver + (8 + comm)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
|
||||
let mut v = Vec::with_capacity(90);
|
||||
v.extend_from_slice(b"FORM");
|
||||
v.extend_from_slice(&form.to_be_bytes());
|
||||
v.extend_from_slice(b"AIFFCOMM");
|
||||
v.extend_from_slice(&18u32.to_be_bytes());
|
||||
if float {
|
||||
v.extend_from_slice(b"AIFCFVER");
|
||||
v.extend_from_slice(&4u32.to_be_bytes());
|
||||
v.extend_from_slice(&AIFC_VERSION_1.to_be_bytes());
|
||||
} else {
|
||||
v.extend_from_slice(b"AIFF");
|
||||
}
|
||||
v.extend_from_slice(b"COMM");
|
||||
v.extend_from_slice(&comm.to_be_bytes());
|
||||
v.extend_from_slice(&channels.to_be_bytes());
|
||||
v.extend_from_slice(&frames32.to_be_bytes());
|
||||
v.extend_from_slice(&bits.to_be_bytes());
|
||||
v.extend_from_slice(&extended(sample_rate));
|
||||
if float {
|
||||
v.extend_from_slice(b"fl32");
|
||||
v.extend_from_slice(FL32_NAME);
|
||||
}
|
||||
v.extend_from_slice(b"SSND");
|
||||
v.extend_from_slice(&ssnd.to_be_bytes());
|
||||
v.extend_from_slice(&0u32.to_be_bytes()); // offset
|
||||
@@ -112,13 +147,12 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
|
||||
}
|
||||
}
|
||||
|
||||
/// Append interleaved samples as `bits`-bit PCM (little-endian for WAV, big-endian for AIFF).
|
||||
pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut Vec<u8>) {
|
||||
let bits = sample_bits(bits);
|
||||
let bps = usize::from(bits / 8);
|
||||
/// Append interleaved samples in `format` (little-endian for WAV, big-endian for AIFF).
|
||||
pub fn encode(container: PcmContainer, interleaved: &[f32], format: SampleFormat, out: &mut Vec<u8>) {
|
||||
let bps = format.bytes();
|
||||
out.reserve(interleaved.len().saturating_mul(bps));
|
||||
for &s in interleaved {
|
||||
let q = quantise(s, bits);
|
||||
let q = if format.is_float() { SampleFormat::float(s).to_bits() as i32 } else { format.quantise(s) };
|
||||
match container {
|
||||
PcmContainer::Wav => out.extend_from_slice(q.to_le_bytes().get(..bps).unwrap_or_default()),
|
||||
PcmContainer::Aiff => out.extend_from_slice(q.to_be_bytes().get(4 - bps..).unwrap_or_default()),
|
||||
@@ -127,19 +161,19 @@ pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut
|
||||
}
|
||||
|
||||
/// A whole file in memory (short audio; exports stream with [`header`] and [`encode`]).
|
||||
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
|
||||
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
|
||||
let frames = (interleaved.len() / usize::from(channels.max(1))) as u64;
|
||||
let mut v = header(container, channels, sample_rate, bits, frames)?;
|
||||
encode(container, interleaved, bits, &mut v);
|
||||
v.extend_from_slice(pad(frames, channels, bits));
|
||||
let mut v = header(container, channels, sample_rate, format, frames)?;
|
||||
encode(container, interleaved, format, &mut v);
|
||||
v.extend_from_slice(pad(frames, channels, format));
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// A RIFF/WAVE file of interleaved samples. More than two channels are written as
|
||||
/// `WAVE_FORMAT_EXTENSIBLE` with the speaker mask of the layout (6 channels: L, R, C, LFE, Ls, Rs
|
||||
/// = `0x3F`).
|
||||
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
|
||||
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits)
|
||||
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
|
||||
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, format)
|
||||
}
|
||||
|
||||
/// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`).
|
||||
@@ -156,9 +190,9 @@ fn extended(rate: u32) -> [u8; 10] {
|
||||
out
|
||||
}
|
||||
|
||||
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`) of interleaved samples.
|
||||
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
|
||||
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, bits)
|
||||
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`; AIFF-C for floats) of interleaved samples.
|
||||
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
|
||||
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, format)
|
||||
}
|
||||
|
||||
/// File name of frame `index` of an image sequence of `count` frames exported to `path`:
|
||||
@@ -181,6 +215,11 @@ pub fn image_sequence_path(path: &str, index: u64, count: u64) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const I16: SampleFormat = SampleFormat::Int16;
|
||||
const I24: SampleFormat = SampleFormat::Int24;
|
||||
const I32: SampleFormat = SampleFormat::Int32;
|
||||
const F32: SampleFormat = SampleFormat::Float32;
|
||||
|
||||
#[test]
|
||||
fn extended_sample_rates() {
|
||||
assert_eq!(extended(44_100), [0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]);
|
||||
@@ -198,10 +237,10 @@ mod tests {
|
||||
#[test]
|
||||
fn wav_and_aiff_layout() {
|
||||
let s = [0.5f32, -0.5, 1.0, -1.0];
|
||||
let w = write_wav(&s, 2, 48_000, 24).unwrap();
|
||||
let w = write_wav(&s, 2, 48_000, I24).unwrap();
|
||||
assert_eq!(w.len(), 44 + 12);
|
||||
assert_eq!(&w[34..36], &24u16.to_le_bytes());
|
||||
let a = write_aiff(&s, 2, 48_000, 16).unwrap();
|
||||
let a = write_aiff(&s, 2, 48_000, I16).unwrap();
|
||||
assert_eq!(&a[..4], b"FORM");
|
||||
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
|
||||
assert_eq!(&a[a.len() - 2..], &(-32767i16).to_be_bytes());
|
||||
@@ -220,7 +259,7 @@ mod tests {
|
||||
#[test]
|
||||
fn wav_past_4_gib_is_rf64() {
|
||||
let frames = 96_000u64 * 60 * 60; // an hour: 6.2 GB
|
||||
let h = header(PcmContainer::Wav, 6, 96_000, 24, frames).unwrap();
|
||||
let h = header(PcmContainer::Wav, 6, 96_000, I24, frames).unwrap();
|
||||
let data = frames * 6 * 3;
|
||||
assert_eq!(&h[..4], b"RF64");
|
||||
assert_eq!(le32(&h, 4), u32::MAX);
|
||||
@@ -238,37 +277,129 @@ mod tests {
|
||||
fn wav_up_to_4_gib_stays_riff() {
|
||||
// the longest stereo 16-bit RIFF: 36 bytes of header after the size field
|
||||
let max = (u64::from(u32::MAX) - 36) / 4;
|
||||
let h = header(PcmContainer::Wav, 2, 48_000, 16, max).unwrap();
|
||||
let h = header(PcmContainer::Wav, 2, 48_000, I16, max).unwrap();
|
||||
assert_eq!(&h[..4], b"RIFF");
|
||||
assert_eq!(u64::from(le32(&h, 4)), 36 + max * 4);
|
||||
assert_eq!(u64::from(le32(&h, 40)), max * 4);
|
||||
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, 16, max + 1).unwrap()[..4], b"RF64");
|
||||
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, I16, max + 1).unwrap()[..4], b"RF64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_past_4_gib_is_refused_not_wrapped() {
|
||||
let e = header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60 * 8).unwrap_err();
|
||||
let e = header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60 * 8).unwrap_err();
|
||||
assert!(e.contains("export WAV"), "{e}");
|
||||
assert!(header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60).is_ok());
|
||||
assert!(header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_are_errors() {
|
||||
for c in [PcmContainer::Wav, PcmContainer::Aiff] {
|
||||
assert!(header(c, 2, 48_000, 24, u64::MAX).is_err());
|
||||
assert!(header(c, u16::MAX, 48_000, 24, 1).is_err());
|
||||
assert!(header(c, 2, 48_000, I24, u64::MAX).is_err());
|
||||
assert!(header(c, u16::MAX, 48_000, I24, 1).is_err());
|
||||
}
|
||||
assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 1).is_err());
|
||||
assert!(header(PcmContainer::Wav, 6, u32::MAX, I24, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_data_chunks_are_padded() {
|
||||
// one 24-bit mono frame: 3 bytes of data, one pad byte, counted in the RIFF size
|
||||
let w = write_wav(&[0.25], 1, 48_000, 24).unwrap();
|
||||
let w = write_wav(&[0.25], 1, 48_000, I24).unwrap();
|
||||
assert_eq!((w.len(), le32(&w, 4) as usize, le32(&w, 40)), (48, 40, 3));
|
||||
let a = write_aiff(&[0.25], 1, 48_000, 24).unwrap();
|
||||
let a = write_aiff(&[0.25], 1, 48_000, I24).unwrap();
|
||||
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
|
||||
assert!(pad(2, 1, 24).is_empty());
|
||||
assert!(pad(2, 1, I24).is_empty());
|
||||
}
|
||||
|
||||
fn be32(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_be_bytes(b[at..at + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_samples_clip_at_full_scale() {
|
||||
assert_eq!([1.0, -1.0, 2.0, -2.0, f32::NAN, 0.5].map(|s| I32.quantise(s)), [i32::MAX, -i32::MAX, i32::MAX, -i32::MAX, 0, 1 << 30]);
|
||||
assert_eq!([1.0, -1.0, 4.0].map(|s| I24.quantise(s)), [8_388_607, -8_388_607, 8_388_607]);
|
||||
assert_eq!([1.0, -1.0, f32::INFINITY].map(|s| I16.quantise(s)), [32_767, -32_767, 32_767]);
|
||||
assert_eq!(F32.quantise(0.5), 0);
|
||||
assert_eq!([2.5, f32::NAN, f32::INFINITY, f32::NEG_INFINITY].map(SampleFormat::float), [2.5, 0.0, f32::MAX, f32::MIN]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_32_bit_integer() {
|
||||
let w = write_wav(&[1.0, -1.0], 1, 48_000, I32).unwrap();
|
||||
assert_eq!(w.len(), 44 + 8);
|
||||
assert_eq!(u16::from_le_bytes([w[20], w[21]]), 1, "integer PCM");
|
||||
assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]])), (4, 32));
|
||||
assert_eq!(&w[44..], [i32::MAX.to_le_bytes(), (-i32::MAX).to_le_bytes()].concat());
|
||||
let a = write_aiff(&[1.0, -1.0], 1, 48_000, I32).unwrap();
|
||||
assert_eq!(&a[8..12], b"AIFF");
|
||||
assert_eq!(u16::from_be_bytes([a[26], a[27]]), 32);
|
||||
assert_eq!(&a[a.len() - 8..], [i32::MAX.to_be_bytes(), (-i32::MAX).to_be_bytes()].concat());
|
||||
}
|
||||
|
||||
/// Float keeps the mix as it is: peaks above full scale survive.
|
||||
#[test]
|
||||
fn wav_32_bit_float() {
|
||||
let s = [0.25f32, -2.0, 1.5, f32::NAN];
|
||||
let w = write_wav(&s, 2, 48_000, F32).unwrap();
|
||||
assert_eq!(&w[12..16], b"fmt ");
|
||||
assert_eq!(le32(&w, 16), 18);
|
||||
assert_eq!(u16::from_le_bytes([w[20], w[21]]), 3, "WAVE_FORMAT_IEEE_FLOAT");
|
||||
assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]]), u16::from_le_bytes([w[36], w[37]])), (8, 32, 0));
|
||||
assert_eq!((&w[38..42], le32(&w, 42), le32(&w, 46)), (&b"fact"[..], 4, 2));
|
||||
assert_eq!((&w[50..54], le32(&w, 54)), (&b"data"[..], 16));
|
||||
assert_eq!(le32(&w, 4) as usize, w.len() - 8);
|
||||
let back: Vec<f32> = w[58..].chunks(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect();
|
||||
assert_eq!(back, [0.25, -2.0, 1.5, 0.0]);
|
||||
// 5.1: WAVE_FORMAT_EXTENSIBLE with the IEEE float sub-format, still with `fact`
|
||||
let w = write_wav(&[0.5; 12], 6, 48_000, F32).unwrap();
|
||||
assert_eq!((le32(&w, 16), u16::from_le_bytes([w[20], w[21]])), (40, 0xFFFE));
|
||||
assert_eq!(&w[44..60], [&3u16.to_le_bytes()[..], &SUBTYPE_TAIL].concat());
|
||||
assert_eq!((&w[60..64], le32(&w, 68)), (&b"fact"[..], 2));
|
||||
// RF64: the frame count is in `ds64`
|
||||
let h = header(PcmContainer::Wav, 2, 48_000, F32, 48_000 * 60 * 60 * 4).unwrap();
|
||||
assert_eq!(&h[..4], b"RF64");
|
||||
let fact = h.windows(4).position(|c| c == b"fact").unwrap();
|
||||
assert_eq!(le32(&h, fact + 8), u32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_32_bit_float_is_aifc() {
|
||||
let a = write_aiff(&[0.25, -3.0], 1, 44_100, F32).unwrap();
|
||||
assert_eq!((&a[..4], be32(&a, 4) as usize, &a[8..12]), (&b"FORM"[..], a.len() - 8, &b"AIFC"[..]));
|
||||
assert_eq!((&a[12..16], be32(&a, 16), be32(&a, 20)), (&b"FVER"[..], 4, AIFC_VERSION_1));
|
||||
assert_eq!((&a[24..28], be32(&a, 28)), (&b"COMM"[..], 44));
|
||||
assert_eq!((u16::from_be_bytes([a[32], a[33]]), be32(&a, 34), u16::from_be_bytes([a[38], a[39]])), (1, 2, 32));
|
||||
assert_eq!(&a[50..54], b"fl32");
|
||||
assert_eq!(&a[54..76], FL32_NAME);
|
||||
assert_eq!((&a[76..80], be32(&a, 80)), (&b"SSND"[..], 8 + 8));
|
||||
assert_eq!(&a[92..], [0.25f32.to_be_bytes(), (-3.0f32).to_be_bytes()].concat());
|
||||
}
|
||||
|
||||
/// ffprobe (an external oracle; skipped where it is not installed) reads every new format.
|
||||
#[test]
|
||||
fn ffprobe_reads_32_bit_formats() {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-pcm32-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let s: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.01).sin() * 1.5).collect();
|
||||
let cases = [
|
||||
("i32.wav", write_wav(&s, 2, 48_000, I32).unwrap(), "pcm_s32le"),
|
||||
("f32.wav", write_wav(&s, 2, 48_000, F32).unwrap(), "pcm_f32le"),
|
||||
("f32-6.wav", write_wav(&s, 6, 48_000, F32).unwrap(), "pcm_f32le"),
|
||||
("i32.aiff", write_aiff(&s, 2, 48_000, I32).unwrap(), "pcm_s32be"),
|
||||
("f32.aifc", write_aiff(&s, 2, 48_000, F32).unwrap(), "pcm_f32be"),
|
||||
];
|
||||
for (name, bytes, codec) in cases {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let out = std::process::Command::new("ffprobe")
|
||||
.args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,bits_per_sample", "-of", "default=nw=1"])
|
||||
.arg(&path)
|
||||
.output();
|
||||
let Ok(out) = out else { break };
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains(&format!("codec_name={codec}")) && text.contains("bits_per_sample=32"), "{name}: {text}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it).
|
||||
@@ -278,8 +409,8 @@ mod tests {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("long.wav");
|
||||
let frames = 48_000u64 * 60 * 60 * 5; // 5 hours of 24-bit stereo: 5.2 GB
|
||||
let mut f = header(PcmContainer::Wav, 2, 48_000, 24, frames).unwrap();
|
||||
encode(PcmContainer::Wav, &[0.0; 9_600], 24, &mut f);
|
||||
let mut f = header(PcmContainer::Wav, 2, 48_000, I24, frames).unwrap();
|
||||
encode(PcmContainer::Wav, &[0.0; 9_600], I24, &mut f);
|
||||
std::fs::write(&path, &f).unwrap();
|
||||
let out = std::process::Command::new("ffprobe")
|
||||
.args(["-v", "error", "-show_entries", "format=format_name,duration", "-of", "default=nw=1"])
|
||||
|
||||
+101
-10
@@ -223,15 +223,96 @@ pub struct AudioSettings {
|
||||
pub channels: u32,
|
||||
/// AAC bitrate.
|
||||
pub bitrate_kbps: u32,
|
||||
/// PCM and FLAC sample size: 16 or 24.
|
||||
/// PCM and FLAC sample size: 16, 24 or 32 (other values round down to one of them, 16 at least).
|
||||
pub bits: u16,
|
||||
/// 32-bit floating-point PCM instead of integers (needs `bits` 32; WAV, AIFF and QuickTime).
|
||||
pub float: bool,
|
||||
/// FLAC compression level, 0 (fastest) to 8 (smallest). Every level is lossless.
|
||||
pub flac_level: u8,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
fn default() -> Self {
|
||||
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 }
|
||||
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, float: false, flac_level: 5 }
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioSettings {
|
||||
/// The sample format [`AudioSettings::bits`] and [`AudioSettings::float`] ask for.
|
||||
pub fn sample_format(&self) -> SampleFormat {
|
||||
match self.bits {
|
||||
32.. if self.float => SampleFormat::Float32,
|
||||
32.. => SampleFormat::Int32,
|
||||
24.. => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int16,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for `f` ([`AudioSettings::bits`] and [`AudioSettings::float`]).
|
||||
pub fn set_sample_format(&mut self, f: SampleFormat) {
|
||||
self.bits = f.bits();
|
||||
self.float = f.is_float();
|
||||
}
|
||||
}
|
||||
|
||||
/// How PCM and FLAC audio stores its samples. The mix is 32-bit float: the integer formats
|
||||
/// clip it at full scale, 32-bit float keeps it as it is (peaks above 0 dBFS included).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SampleFormat {
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
Float32,
|
||||
}
|
||||
|
||||
impl SampleFormat {
|
||||
pub const ALL: [SampleFormat; 4] = [SampleFormat::Int16, SampleFormat::Int24, SampleFormat::Int32, SampleFormat::Float32];
|
||||
|
||||
pub fn bits(self) -> u16 {
|
||||
match self {
|
||||
SampleFormat::Int16 => 16,
|
||||
SampleFormat::Int24 => 24,
|
||||
SampleFormat::Int32 | SampleFormat::Float32 => 32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes per sample.
|
||||
pub fn bytes(self) -> usize {
|
||||
usize::from(self.bits() / 8)
|
||||
}
|
||||
|
||||
pub fn is_float(self) -> bool {
|
||||
self == SampleFormat::Float32
|
||||
}
|
||||
|
||||
/// The name the Export settings show.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
SampleFormat::Int16 => "16 bit",
|
||||
SampleFormat::Int24 => "24 bit",
|
||||
SampleFormat::Int32 => "32 bit",
|
||||
SampleFormat::Float32 => "32 bit float",
|
||||
}
|
||||
}
|
||||
|
||||
/// One mix sample as a signed integer of this size, clipped at full scale (0 for
|
||||
/// [`SampleFormat::Float32`], which is never quantised). Not-a-number becomes silence.
|
||||
pub fn quantise(self, s: f32) -> i32 {
|
||||
// f32 products for 16 and 24 bit (the bytes every earlier export wrote); f64 for 32 bit,
|
||||
// whose full scale 2^31 - 1 f32 cannot hold. NaN casts to 0, the casts saturate.
|
||||
let s = s.clamp(-1.0, 1.0);
|
||||
match self {
|
||||
SampleFormat::Int16 => (s * 32_767.0).round() as i32,
|
||||
SampleFormat::Int24 => (s * 8_388_607.0).round() as i32,
|
||||
SampleFormat::Int32 => (f64::from(s) * 2_147_483_647.0).round() as i32,
|
||||
SampleFormat::Float32 => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// One mix sample as stored by [`SampleFormat::Float32`]: as it is, NaN as silence and
|
||||
/// infinities as the largest finite values.
|
||||
pub fn float(s: f32) -> f32 {
|
||||
if s.is_nan() { 0.0 } else { s.clamp(f32::MIN, f32::MAX) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +554,16 @@ impl ExportSettings {
|
||||
Resolved { width: w, height: h, rate, sample_rate: self.audio.sample_rate.unwrap_or(seq_sr), channels, target_kbps: target, max_kbps: max, keyint }
|
||||
}
|
||||
|
||||
/// Whether the audio codec and container of this export store samples as `f`: PCM in WAV,
|
||||
/// AIFF and QuickTime takes all four, MXF integers only; FLAC integers only; AAC none.
|
||||
pub fn supports_sample_format(&self, f: SampleFormat) -> bool {
|
||||
match self.audio_codec() {
|
||||
AudioCodec::Aac => false,
|
||||
AudioCodec::Flac => !f.is_float(),
|
||||
AudioCodec::Pcm | AudioCodec::Auto => !(f.is_float() && self.format.is_mxf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio codec actually used.
|
||||
pub fn audio_codec(&self) -> AudioCodec {
|
||||
match (self.format, self.audio.codec) {
|
||||
@@ -548,7 +639,7 @@ impl ExportSettings {
|
||||
Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
|
||||
};
|
||||
let audio_bps = if self.has_audio() || self.format.is_audio_only() {
|
||||
let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 };
|
||||
let pcm = r.sample_rate as f64 * r.channels as f64 * f64::from(self.audio.sample_format().bits());
|
||||
match self.audio_codec() {
|
||||
AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0,
|
||||
// typical of music and dialogue; noise compresses less, silence far more
|
||||
@@ -649,13 +740,13 @@ impl ExportSettings {
|
||||
};
|
||||
match self.audio_codec() {
|
||||
AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate),
|
||||
AudioCodec::Flac => format!(
|
||||
"FLAC (lossless) {}-bit, level {}, {} Hz, {ch}",
|
||||
if self.audio.bits >= 24 { 24 } else { 16 },
|
||||
self.audio.flac_level.min(8),
|
||||
r.sample_rate
|
||||
),
|
||||
_ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate),
|
||||
AudioCodec::Flac => {
|
||||
format!("FLAC (lossless) {}-bit, level {}, {} Hz, {ch}", self.audio.sample_format().bits(), self.audio.flac_level.min(8), r.sample_rate)
|
||||
}
|
||||
_ => {
|
||||
let f = self.audio.sample_format();
|
||||
format!("Uncompressed {}-bit{} PCM, {} Hz, {ch}", f.bits(), if f.is_float() { " float" } else { "" }, r.sample_rate)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"No audio".to_string()
|
||||
|
||||
@@ -1135,3 +1135,202 @@ fn loudness_normalization_corrects_limiter_loss_and_bounds_impossible_targets()
|
||||
assert_eq!(chunks.get(), 3);
|
||||
assert_eq!(reads_at_cancel.get(), Some(reads.load(Ordering::Relaxed)));
|
||||
}
|
||||
|
||||
/// 32-bit integer and float audio in every container that takes them, read back by ffprobe (an
|
||||
/// external oracle) and, where FilmCraft imports the format, by FilmCraft's own readers.
|
||||
#[test]
|
||||
fn thirty_two_bit_audio_in_every_container() {
|
||||
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
|
||||
let dir = Scratch::new("pcm32");
|
||||
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 2)));
|
||||
let cases: [(Format, SampleFormat, &str, &str, &str); 8] = [
|
||||
(Format::Wav, SampleFormat::Float32, "f.wav", "pcm_f32le", "32"),
|
||||
(Format::Wav, SampleFormat::Int32, "i.wav", "pcm_s32le", "32"),
|
||||
(Format::Aiff, SampleFormat::Float32, "f.aif", "pcm_f32be", "32"),
|
||||
(Format::Aiff, SampleFormat::Int32, "i.aif", "pcm_s32be", "32"),
|
||||
(Format::ProRes, SampleFormat::Float32, "f.mov", "pcm_f32le", "32"),
|
||||
(Format::ProRes, SampleFormat::Int32, "i.mov", "pcm_s32le", "32"),
|
||||
(Format::MxfOp1a, SampleFormat::Int32, "i.mxf", "pcm_s32le", "32"),
|
||||
(Format::Flac, SampleFormat::Int32, "i.flac", "flac", "32"),
|
||||
];
|
||||
for (format, sf, name, codec, bits) in cases {
|
||||
let path = dir.path(name);
|
||||
let mut s = ExportSettings { format, path: path.clone(), range, ..Default::default() };
|
||||
s.audio.set_sample_format(sf);
|
||||
assert!(s.supports_sample_format(sf), "{name}");
|
||||
export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
// FilmCraft's own importer: the -6 dB tone at a peak of 0.5
|
||||
if !matches!(format, Format::MxfOp1a) {
|
||||
let src = filmcraft_codecs::open_bytes(name, bytes.into()).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let a = src.audio(0, 24_000, 48_000).unwrap();
|
||||
let peak = a.channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
assert!((peak - 0.5).abs() < 0.01, "{name}: peak {peak}");
|
||||
}
|
||||
if let Some(j) = ffprobe_json(&["-show_streams"], &path) {
|
||||
let st = j["streams"].as_array().unwrap().iter().find(|s| s["codec_type"] == "audio").unwrap().clone();
|
||||
assert_eq!(st["codec_name"].as_str(), Some(codec), "{name}");
|
||||
let depth = if codec == "flac" { &st["bits_per_raw_sample"] } else { &st["bits_per_sample"] };
|
||||
assert_eq!(depth.as_str().map(str::to_string).or_else(|| depth.as_u64().map(|v| v.to_string())).as_deref(), Some(bits), "{name}");
|
||||
}
|
||||
}
|
||||
// every 32-bit file holds the same samples (float exactly, integers within one step of 2^-31)
|
||||
if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("32-bit audio") {
|
||||
let raw = |name: &str| {
|
||||
let out = std::process::Command::new(&ffmpeg)
|
||||
.args(["-v", "error", "-i", &dir.path(name), "-map", "0:a", "-f", "f32le", "-c:a", "pcm_f32le", "-"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect::<Vec<f32>>()
|
||||
};
|
||||
let reference = raw("f.wav");
|
||||
assert_eq!(reference.len(), 24_000 * 2);
|
||||
for name in ["f.aif", "f.mov"] {
|
||||
assert!(raw(name) == reference, "{name}: float samples differ from the WAV's");
|
||||
}
|
||||
for name in ["i.wav", "i.aif", "i.mov", "i.mxf", "i.flac"] {
|
||||
let got = raw(name);
|
||||
assert_eq!(got.len(), reference.len(), "{name}");
|
||||
let worst = got.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-6, "{name}: differs by {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mix is 32-bit float: 32-bit float export keeps a +6 dB tone's peaks of 2.0, integers clip
|
||||
/// them at full scale.
|
||||
#[test]
|
||||
fn float_export_keeps_peaks_above_full_scale() {
|
||||
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(6.0));
|
||||
let dir = Scratch::new("pcm-hot");
|
||||
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 4)));
|
||||
let peak = |sf: SampleFormat, name: &str| {
|
||||
let path = dir.path(name);
|
||||
let mut s = ExportSettings { format: Format::Wav, path: path.clone(), range, ..Default::default() };
|
||||
s.audio.set_sample_format(sf);
|
||||
export(&p, seq, &s, &m, &Progress::default()).unwrap();
|
||||
let src = filmcraft_codecs::open_bytes(name, std::fs::read(&path).unwrap().into()).unwrap();
|
||||
src.audio(0, 12_000, 48_000).unwrap().channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
};
|
||||
let float = peak(SampleFormat::Float32, "f.wav");
|
||||
assert!((float - 2.0).abs() < 0.02, "float keeps +6 dBFS: {float}");
|
||||
for (sf, name) in [(SampleFormat::Int32, "i32.wav"), (SampleFormat::Int24, "i24.wav"), (SampleFormat::Int16, "i16.wav")] {
|
||||
let p = peak(sf, name);
|
||||
assert!(p <= 1.0 && p > 0.99, "{name} clips at full scale: {p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_formats_each_codec_and_container_stores() {
|
||||
use SampleFormat::*;
|
||||
let supported = |format: Format, codec: AudioCodec| {
|
||||
let s = ExportSettings { format, audio: AudioSettings { codec, ..Default::default() }, ..Default::default() };
|
||||
SampleFormat::ALL.iter().filter(|&&f| s.supports_sample_format(f)).copied().collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(supported(Format::Wav, AudioCodec::Auto), [Int16, Int24, Int32, Float32]);
|
||||
assert_eq!(supported(Format::Aiff, AudioCodec::Auto), [Int16, Int24, Int32, Float32]);
|
||||
assert_eq!(supported(Format::ProRes, AudioCodec::Pcm), [Int16, Int24, Int32, Float32]);
|
||||
assert_eq!(supported(Format::MxfOp1a, AudioCodec::Auto), [Int16, Int24, Int32]);
|
||||
assert_eq!(supported(Format::MxfOpAtom, AudioCodec::Auto), [Int16, Int24, Int32]);
|
||||
assert_eq!(supported(Format::Flac, AudioCodec::Auto), [Int16, Int24, Int32]);
|
||||
assert_eq!(supported(Format::ProRes, AudioCodec::Flac), [Int16, Int24, Int32]);
|
||||
assert!(supported(Format::H264, AudioCodec::Auto).is_empty(), "MP4 is AAC");
|
||||
// `bits` rounds down to a size; `float` only with 32
|
||||
let fmt = |bits, float| AudioSettings { bits, float, ..Default::default() }.sample_format();
|
||||
assert_eq!(
|
||||
[fmt(0, false), fmt(20, false), fmt(24, false), fmt(31, false), fmt(32, false), fmt(64, false), fmt(32, true)],
|
||||
[Int16, Int16, Int24, Int24, Int32, Int32, Float32]
|
||||
);
|
||||
}
|
||||
|
||||
/// Hostile or contradictory settings (CLI, control channel, MCP, old presets) are errors, never
|
||||
/// a panic or a silently different file.
|
||||
#[test]
|
||||
fn unsupported_sample_formats_are_refused() {
|
||||
let refused = |format: Format, codec: AudioCodec, bits: u16, float: bool| {
|
||||
let s = ExportSettings { format, path: "x".into(), audio: AudioSettings { codec, bits, float, ..Default::default() }, ..Default::default() };
|
||||
s.validate().err().map(|e| e.to_string())
|
||||
};
|
||||
assert!(refused(Format::Wav, AudioCodec::Auto, 24, true).is_some_and(|e| e.contains("32")));
|
||||
assert!(refused(Format::Flac, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("FLAC")));
|
||||
assert!(refused(Format::ProRes, AudioCodec::Flac, 32, true).is_some_and(|e| e.contains("FLAC")));
|
||||
assert!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("MXF")));
|
||||
for (format, codec) in [(Format::Wav, AudioCodec::Auto), (Format::Aiff, AudioCodec::Auto), (Format::ProRes, AudioCodec::Pcm)] {
|
||||
assert_eq!(refused(format, codec, 32, true), None, "{format:?}");
|
||||
}
|
||||
assert_eq!(refused(Format::Flac, AudioCodec::Auto, 32, false), None);
|
||||
assert_eq!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, false), None);
|
||||
// AAC has no sample size: the PCM / FLAC choice is ignored, as before
|
||||
assert_eq!(refused(Format::H264, AudioCodec::Auto, 32, true), None);
|
||||
// no audio, nothing to refuse
|
||||
let s = ExportSettings {
|
||||
format: Format::MxfOp1a,
|
||||
include_audio: false,
|
||||
audio: AudioSettings { bits: 32, float: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
assert!(s.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_format_settings_round_trip_summary_and_estimate() {
|
||||
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "wav", "audio": {"bits": 32, "float": true}})).unwrap();
|
||||
assert_eq!(s.audio.sample_format(), SampleFormat::Float32);
|
||||
let back: ExportSettings = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
|
||||
assert_eq!(back.audio.sample_format(), SampleFormat::Float32);
|
||||
// settings saved before 32-bit audio read integers
|
||||
let old: AudioSettings = serde_json::from_value(serde_json::json!({"bits": 24})).unwrap();
|
||||
assert_eq!((old.float, old.sample_format()), (false, SampleFormat::Int24));
|
||||
let sum = |sf: SampleFormat, format: Format| {
|
||||
let mut s = ExportSettings { format, ..Default::default() };
|
||||
s.audio.set_sample_format(sf);
|
||||
(
|
||||
s.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).audio,
|
||||
s.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)),
|
||||
)
|
||||
};
|
||||
let (f, f_bytes) = sum(SampleFormat::Float32, Format::Wav);
|
||||
assert!(f.starts_with("Uncompressed 32-bit float PCM, 48000 Hz"), "{f}");
|
||||
let (i, _) = sum(SampleFormat::Int32, Format::Wav);
|
||||
assert!(i.starts_with("Uncompressed 32-bit PCM"), "{i}");
|
||||
let (_, i16_bytes) = sum(SampleFormat::Int16, Format::Wav);
|
||||
assert!(f_bytes > i16_bytes * 19 / 10 && f_bytes < i16_bytes * 21 / 10, "{f_bytes} vs {i16_bytes}");
|
||||
let (flac, _) = sum(SampleFormat::Int32, Format::Flac);
|
||||
assert!(flac.starts_with("FLAC (lossless) 32-bit"), "{flac}");
|
||||
}
|
||||
|
||||
/// FilmCraft imports its own 32-bit FLAC (symphonia rejected the frame header's 32-bit size code
|
||||
/// as reserved: "unsupported media"), at full scale and with noise, sample for sample.
|
||||
#[test]
|
||||
fn own_importer_reads_32_bit_flac_at_full_scale() {
|
||||
let n = 20_000usize;
|
||||
let mut seed = 7u64;
|
||||
let noise: Vec<i32> = (0..n)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 32) as u32 as i32
|
||||
})
|
||||
.collect();
|
||||
let extremes: Vec<i32> = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN + 1 }).collect();
|
||||
for level in [0u8, 5, 8] {
|
||||
let mut enc = filmcraft_flac::Encoder::new(filmcraft_flac::EncoderConfig { level, ..filmcraft_flac::EncoderConfig::new(48_000, 2, 32) }).unwrap();
|
||||
let mut file = enc.header();
|
||||
file.extend(enc.encode(&[&extremes, &noise]).unwrap());
|
||||
file.extend(enc.finish());
|
||||
let at = filmcraft_flac::STREAMINFO_OFFSET as usize;
|
||||
file[at..at + 34].copy_from_slice(&enc.streaminfo());
|
||||
let src = filmcraft_codecs::open_bytes("x.flac", file.into()).unwrap_or_else(|e| panic!("level {level}: {e}"));
|
||||
let a = src.audio(0, n, 48_000).unwrap();
|
||||
for (c, want) in [&extremes, &noise].iter().enumerate() {
|
||||
let got = &a.channels[c];
|
||||
assert_eq!(got.len(), n, "level {level} channel {c}");
|
||||
for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() {
|
||||
let w = (f64::from(w) / 2_147_483_648.0) as f32;
|
||||
assert!((g - w).abs() <= 1e-6, "level {level} channel {c} sample {i}: {g} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ symphonia bootstrap decoder in `filmcraft-codecs`.
|
||||
```rust
|
||||
use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET};
|
||||
|
||||
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24)
|
||||
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–32)
|
||||
cfg.level = 8; // 0 fastest … 8 smallest; 5 by default
|
||||
let mut enc = Encoder::new(cfg)?;
|
||||
let mut file = enc.header(); // fLaC + STREAMINFO + VORBIS_COMMENT (vendor)
|
||||
@@ -37,10 +37,11 @@ let last_len = enc.pending_samples(); // duration
|
||||
| Subframes | constant, verbatim, fixed predictors (order 0–4) and linear prediction (up to order 12), whichever is smallest |
|
||||
| Linear prediction | Tukey(0.5) windowed autocorrelation, Levinson–Durbin, 12/15-bit quantised coefficients with error feedback |
|
||||
| Residual | partitioned Rice codes (4- or 5-bit parameters), the partition order chosen per subframe |
|
||||
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest |
|
||||
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest (32-bit: independent only, a side channel would need 33 bits) |
|
||||
|
||||
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples,
|
||||
seek tables.
|
||||
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, seek tables.
|
||||
32-bit samples (frame headers point at STREAMINFO for the size) decode only in recent decoders (libFLAC 1.4+,
|
||||
ffmpeg 6+).
|
||||
|
||||
| Level | LPC order | Rice partition order | Stereo decorrelation |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -53,6 +53,8 @@ fn sample_size_code(bps: u8) -> u8 {
|
||||
16 => 4,
|
||||
20 => 5,
|
||||
24 => 6,
|
||||
// 32 bits has code 7 since RFC 9639, which earlier decoders (symphonia 0.5, FilmCraft's own
|
||||
// FLAC import) reject as reserved; 0 ("as STREAMINFO says") decodes everywhere
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
+10
-7
@@ -1,6 +1,6 @@
|
||||
//! Clean-room FLAC encoder (RFC 9639, Free Lossless Audio Codec).
|
||||
//!
|
||||
//! - [`Encoder`]: planar integer samples (8–24 bit, 1–8 channels) → a `.flac` stream: the
|
||||
//! - [`Encoder`]: planar integer samples (8–32 bit, 1–8 channels) → a `.flac` stream: the
|
||||
//! [`Encoder::header`] (`fLaC`, STREAMINFO, a VORBIS_COMMENT naming the encoder), then frames of
|
||||
//! a fixed block size. Each channel of each frame is coded as constant, verbatim, a fixed
|
||||
//! polynomial predictor or linear prediction, whichever is smallest, with partitioned Rice codes;
|
||||
@@ -61,7 +61,8 @@ pub struct EncoderConfig {
|
||||
pub sample_rate: u32,
|
||||
/// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1).
|
||||
pub channels: u8,
|
||||
/// 8–24.
|
||||
/// 8–32. At 32 bits stereo channels are coded independently: the side channel of
|
||||
/// left/side, side/right and mid/side coding would need 33 bits.
|
||||
pub bits_per_sample: u8,
|
||||
/// Samples per channel per frame, 16–65535 (4096 by default).
|
||||
pub block_size: u16,
|
||||
@@ -92,8 +93,8 @@ impl Encoder {
|
||||
if !(1..=8).contains(&cfg.channels) {
|
||||
return Err(Error::InvalidConfig("1 to 8 channels"));
|
||||
}
|
||||
if !(8..=24).contains(&cfg.bits_per_sample) {
|
||||
return Err(Error::InvalidConfig("8 to 24 bits per sample"));
|
||||
if !(8..=32).contains(&cfg.bits_per_sample) {
|
||||
return Err(Error::InvalidConfig("8 to 32 bits per sample"));
|
||||
}
|
||||
if !(1..=1_048_575).contains(&cfg.sample_rate) {
|
||||
return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz"));
|
||||
@@ -170,8 +171,9 @@ impl Encoder {
|
||||
if planar.iter().any(|c| c.len() != n) {
|
||||
return Err(Error::UnequalChannels);
|
||||
}
|
||||
let max = (1i32 << (self.cfg.bits_per_sample - 1)) - 1;
|
||||
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| s > max || s < -max - 1) {
|
||||
// i64: at 32 bits the range is all of i32 (1 << 31 overflows i32)
|
||||
let max = (1i64 << (self.cfg.bits_per_sample - 1)) - 1;
|
||||
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| i64::from(s) > max || i64::from(s) < -max - 1) {
|
||||
return Err(Error::SampleRange(bad));
|
||||
}
|
||||
for (p, c) in self.pending.iter_mut().zip(planar) {
|
||||
@@ -217,7 +219,8 @@ impl Encoder {
|
||||
let effort = self.effort;
|
||||
let code = |x: &[i32], b: u8| predict::choose(x, b, effort);
|
||||
let data = match block {
|
||||
[l, r] if effort.stereo => {
|
||||
// the side channel takes one bit more than the samples: at most 32 (i32)
|
||||
[l, r] if effort.stereo && bps < 32 => {
|
||||
let side: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| a - b).collect();
|
||||
let mid: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect();
|
||||
let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps));
|
||||
|
||||
@@ -24,7 +24,7 @@ impl Rng {
|
||||
/// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise,
|
||||
/// a full-scale square, silence and a lone click.
|
||||
fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
|
||||
let max = f64::from((1i32 << (bps - 1)) - 1);
|
||||
let max = ((1i64 << (bps - 1)) - 1) as f64;
|
||||
let mut r = Rng(seed | 1);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
@@ -43,7 +43,7 @@ fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
|
||||
"click" => f64::from(u8::from(i == n / 2)),
|
||||
_ => 0.0,
|
||||
};
|
||||
((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32)
|
||||
((v * max).round() as i64).clamp(-(max as i64) - 1, max as i64) as i32
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -93,13 +93,14 @@ fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize)
|
||||
}
|
||||
|
||||
// STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC
|
||||
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16
|
||||
// and 24 bit compare
|
||||
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16,
|
||||
// 24 and 32 bit compare
|
||||
let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34];
|
||||
let pcm = match bps {
|
||||
8 => Some("s8"),
|
||||
16 => Some("s16le"),
|
||||
24 => Some("s24le"),
|
||||
32 => Some("s32le"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(pcm) = pcm {
|
||||
@@ -118,7 +119,7 @@ fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize)
|
||||
#[test]
|
||||
fn every_signal_round_trips_exactly() {
|
||||
for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] {
|
||||
for bps in [16u8, 24] {
|
||||
for bps in [16u8, 24, 32] {
|
||||
let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)];
|
||||
if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() {
|
||||
return;
|
||||
@@ -129,11 +130,13 @@ fn every_signal_round_trips_exactly() {
|
||||
|
||||
#[test]
|
||||
fn channel_layouts_rates_and_sizes() {
|
||||
let cases: [(&str, u8, u8, u32, usize); 6] = [
|
||||
let cases: [(&str, u8, u8, u32, usize); 8] = [
|
||||
("mono-8bit", 1, 8, 8_000, 3_001),
|
||||
("mono-24-44k1", 1, 24, 44_100, 9_999),
|
||||
("stereo-20bit", 2, 20, 96_000, 10_000),
|
||||
("5.1-24", 6, 24, 48_000, 12_345),
|
||||
("mono-32", 1, 32, 48_000, 7_777),
|
||||
("5.1-32", 6, 32, 96_000, 12_345),
|
||||
("odd-rate", 2, 16, 37_800, 5_000),
|
||||
("tiny", 2, 16, 48_000, 1),
|
||||
];
|
||||
@@ -167,7 +170,8 @@ fn levels_and_block_sizes_trade_time_for_size() {
|
||||
fn bad_input_is_an_error_not_a_panic() {
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 33)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 7)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err());
|
||||
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap();
|
||||
@@ -179,3 +183,21 @@ fn bad_input_is_an_error_not_a_panic() {
|
||||
assert!(enc.finish().is_empty());
|
||||
assert_eq!(&enc.header()[..4], b"fLaC");
|
||||
}
|
||||
|
||||
/// 32-bit stereo at the extremes of i32: left minus right would need 33 bits, so the encoder must
|
||||
/// not code a side channel (it overflowed `i32` and wrote garbage); every level decodes exactly.
|
||||
#[test]
|
||||
fn full_scale_32_bit_stereo_round_trips() {
|
||||
let n = 9_000;
|
||||
let left: Vec<i32> = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN }).collect();
|
||||
let right: Vec<i32> = left.iter().map(|&v| if v == i32::MAX { i32::MIN } else { i32::MAX }).collect();
|
||||
let planar = vec![left, right];
|
||||
for level in [0u8, 5, 8] {
|
||||
let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 32) };
|
||||
if round_trip(&format!("extremes-32-level{level}"), &planar, cfg, 4096).is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 32)).unwrap();
|
||||
assert!(enc.encode(&[&[i32::MIN, i32::MAX], &[0, -1]]).is_ok());
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Which adapter to draw with when one GPU is listed through two versions of one driver.
|
||||
//!
|
||||
//! On Windows the Vulkan loader takes one driver from each display adapter's driver package. A PC
|
||||
//! whose GPUs were installed from different packages of one vendor (a laptop with an integrated
|
||||
//! and a discrete AMD GPU, the first on the manufacturer's old package and the second on a current
|
||||
//! one) loads two versions of that driver, and the newer GPU is listed once through each. wgpu
|
||||
//! takes the first entry of the device type it prefers, which is whichever version the loader
|
||||
//! listed first; a window drawn through the stale one kills the process inside the driver
|
||||
//! (`amdvlk64.dll`, 0xc0000005), where no Rust error handling runs.
|
||||
//!
|
||||
//! [`choose`] is wgpu's own rule (`Instance::request_adapter`: the adapters that can draw to the
|
||||
//! surface, by device type for the power preference, then as enumerated) with one addition: when
|
||||
//! the adapter that rule picks is listed again by the same driver, the entry exposing the most
|
||||
//! features is used, i.e. the newer version. Without such a duplicate the choice is wgpu's own.
|
||||
//! Entries of one GPU from different drivers (RADV and AMDVLK, a vendor's driver and Dozen) are
|
||||
//! left alone: those are installed side by side on purpose, and wgpu's order between them stays.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
use std::fmt;
|
||||
|
||||
use wgpu::{Adapter, Backend, DeviceType, PowerPreference, Surface};
|
||||
|
||||
/// One enumerated adapter as plain data, so that [`choose`] is a pure function tests run without
|
||||
/// a GPU.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
pub name: String,
|
||||
pub backend: Backend,
|
||||
pub device_type: DeviceType,
|
||||
/// PCI vendor and device ids; both zero when the backend reports none (OpenGL).
|
||||
pub vendor: u32,
|
||||
pub device: u32,
|
||||
/// PCI bus id such as `0000:03:00.0`; empty when the driver reports none.
|
||||
pub pci_bus_id: String,
|
||||
/// The driver's name (Vulkan) or version (DirectX 12).
|
||||
pub driver: String,
|
||||
/// More about the driver, for the log.
|
||||
pub driver_info: String,
|
||||
/// How many wgpu features the driver exposes; a newer version of one driver exposes more.
|
||||
pub features: u32,
|
||||
/// Whether the surface can be drawn through this adapter (true without a surface).
|
||||
pub usable: bool,
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
pub fn of(adapter: &Adapter, surface: Option<&Surface<'_>>) -> Self {
|
||||
let info = adapter.get_info();
|
||||
Candidate {
|
||||
name: info.name,
|
||||
backend: info.backend,
|
||||
device_type: info.device_type,
|
||||
vendor: info.vendor,
|
||||
device: info.device,
|
||||
pci_bus_id: info.device_pci_bus_id,
|
||||
driver: info.driver,
|
||||
driver_info: info.driver_info,
|
||||
features: adapter.features().bits().0.iter().map(|word| word.count_ones()).sum(),
|
||||
usable: surface.is_none_or(|s| adapter.is_surface_supported(s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Candidate {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let kind = match self.device_type {
|
||||
DeviceType::DiscreteGpu => "discrete GPU",
|
||||
DeviceType::IntegratedGpu => "integrated GPU",
|
||||
DeviceType::VirtualGpu => "virtual GPU",
|
||||
DeviceType::Cpu => "software",
|
||||
DeviceType::Other => "unknown type",
|
||||
};
|
||||
let driver = format!("{} {}", self.driver, self.driver_info);
|
||||
write!(f, "\"{}\" ({:?}, {kind}, driver {}, {} features", self.name, self.backend, driver.trim(), self.features)?;
|
||||
if !self.pci_bus_id.is_empty() {
|
||||
write!(f, ", bus {}", self.pci_bus_id)?;
|
||||
}
|
||||
if !self.usable {
|
||||
write!(f, ", cannot draw to the window")?;
|
||||
}
|
||||
write!(f, ")")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` are one GPU listed twice by one driver, i.e. through two installed
|
||||
/// versions of it: the same backend, device type, PCI vendor and device and driver name, and the
|
||||
/// same bus id when both report one. Adapters without PCI ids (OpenGL reports zeros) are never
|
||||
/// the same: two of them cannot be told apart.
|
||||
fn same_gpu_and_driver(a: &Candidate, b: &Candidate) -> bool {
|
||||
let identified = a.vendor != 0 || a.device != 0;
|
||||
let same_bus = a.pci_bus_id.is_empty() || b.pci_bus_id.is_empty() || a.pci_bus_id == b.pci_bus_id;
|
||||
identified && a.backend == b.backend && a.device_type == b.device_type && a.vendor == b.vendor && a.device == b.device && a.driver == b.driver && same_bus
|
||||
}
|
||||
|
||||
/// wgpu's device type order for `power` (wgpu-core's `Instance::request_adapter`): lower first.
|
||||
/// Without a preference the adapters stay as enumerated.
|
||||
fn type_order(device_type: DeviceType, power: PowerPreference) -> u8 {
|
||||
let prefer_integrated = match power {
|
||||
PowerPreference::LowPower => true,
|
||||
PowerPreference::HighPerformance => false,
|
||||
PowerPreference::None => return 0,
|
||||
};
|
||||
match device_type {
|
||||
DeviceType::DiscreteGpu if prefer_integrated => 2,
|
||||
DeviceType::IntegratedGpu if prefer_integrated => 1,
|
||||
DeviceType::DiscreteGpu => 1,
|
||||
DeviceType::IntegratedGpu => 2,
|
||||
DeviceType::Other => 3,
|
||||
DeviceType::VirtualGpu => 4,
|
||||
DeviceType::Cpu => 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of the adapter wgpu's `request_adapter` picks: the first usable one of the best
|
||||
/// device type for `power`. `None` without a usable adapter.
|
||||
fn wgpu_choice(candidates: &[Candidate], power: PowerPreference) -> Option<usize> {
|
||||
let usable = candidates.iter().enumerate().filter(|(_, c)| c.usable);
|
||||
usable.min_by_key(|(index, c)| (type_order(c.device_type, power), *index)).map(|(index, _)| index)
|
||||
}
|
||||
|
||||
/// The index of the adapter to draw with: [`wgpu_choice`], or the entry exposing the most
|
||||
/// features among those that are the same GPU listed by the same driver (the first of them when
|
||||
/// several expose as many). `None` without a usable adapter.
|
||||
pub fn choose(candidates: &[Candidate], power: PowerPreference) -> Option<usize> {
|
||||
let first = wgpu_choice(candidates, power)?;
|
||||
let Some(preferred) = candidates.get(first) else {
|
||||
return Some(first);
|
||||
};
|
||||
let versions = candidates.iter().enumerate().filter(|(_, c)| c.usable && same_gpu_and_driver(preferred, c));
|
||||
versions.max_by_key(|(index, c)| (c.features, Reverse(*index))).map(|(index, _)| index).or(Some(first))
|
||||
}
|
||||
|
||||
/// [`choose`] over enumerated adapters, as egui-wgpu's `native_adapter_selector` for the window
|
||||
/// (`surface`). The adapters and the choice go to the log.
|
||||
pub fn pick(adapters: &[Adapter], surface: Option<&Surface<'_>>, power: PowerPreference) -> Result<Adapter, String> {
|
||||
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::of(a, surface)).collect();
|
||||
for candidate in &candidates {
|
||||
log::info!("graphics adapter available: {candidate}");
|
||||
}
|
||||
let listed = || candidates.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
|
||||
let index = choose(&candidates, power).ok_or_else(|| format!("no usable graphics adapter among {}: {}", candidates.len(), listed()))?;
|
||||
let (Some(chosen), Some(adapter)) = (candidates.get(index), adapters.get(index)) else {
|
||||
return Err(format!("graphics adapter {index} of {} is missing", adapters.len()));
|
||||
};
|
||||
let others = candidates.iter().enumerate().filter(|(i, c)| *i != index && c.usable && same_gpu_and_driver(chosen, c)).count();
|
||||
if others > 0 {
|
||||
log::warn!(
|
||||
"{:?} lists \"{}\" {} times: several versions of its driver are installed, and a window drawn through a stale one can crash. Using the one with the most features; installing one driver package for every GPU removes the others.",
|
||||
chosen.backend,
|
||||
chosen.name,
|
||||
others.saturating_add(1)
|
||||
);
|
||||
}
|
||||
log::info!("graphics adapter chosen: {chosen}");
|
||||
Ok(adapter.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wgpu::Backend::{Dx12, Gl, Vulkan};
|
||||
use wgpu::DeviceType::{Cpu, DiscreteGpu, IntegratedGpu, Other};
|
||||
use wgpu::PowerPreference::{HighPerformance, LowPower};
|
||||
|
||||
const AMD: u32 = 0x1002;
|
||||
const RX_6800M: u32 = 0x73df;
|
||||
const RADEON_IGPU: u32 = 0x1638;
|
||||
const POWERS: [PowerPreference; 3] = [PowerPreference::None, LowPower, HighPerformance];
|
||||
|
||||
#[expect(clippy::too_many_arguments, reason = "one line per adapter in the listings below")]
|
||||
fn gpu(
|
||||
name: &str,
|
||||
backend: Backend,
|
||||
device_type: DeviceType,
|
||||
vendor: u32,
|
||||
device: u32,
|
||||
bus: &str,
|
||||
driver: &str,
|
||||
driver_info: &str,
|
||||
features: u32,
|
||||
) -> Candidate {
|
||||
Candidate {
|
||||
name: name.into(),
|
||||
backend,
|
||||
device_type,
|
||||
vendor,
|
||||
device,
|
||||
pci_bus_id: bus.into(),
|
||||
driver: driver.into(),
|
||||
driver_info: driver_info.into(),
|
||||
features,
|
||||
usable: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// An ASUS G513QY as wgpu 30 lists it: the integrated Radeon is on the manufacturer's 2021
|
||||
/// driver package and the RX 6800M on a 2026 one, so the Vulkan loader has both versions of
|
||||
/// AMD's driver, and the 2021 one lists the RX 6800M first. A window drawn through that entry
|
||||
/// died inside `amdvlk64.dll` (0xc0000005) on every launch.
|
||||
fn g513qy() -> Vec<Candidate> {
|
||||
let amd = "AMD proprietary driver";
|
||||
vec![
|
||||
gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", amd, "26.9.2", 60),
|
||||
gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", amd, "26.9.2 (AMD proprietary shader compiler)", 68),
|
||||
gpu("AMD Radeon(TM) Graphics", Vulkan, IntegratedGpu, AMD, RADEON_IGPU, "", amd, "21.30.02.13", 56),
|
||||
gpu("AMD Radeon RX 6800M", Dx12, DiscreteGpu, AMD, RX_6800M, "0000:03:00.0", "32.0.21045.11001", "", 36),
|
||||
gpu("AMD Radeon(TM) Graphics", Dx12, IntegratedGpu, AMD, RADEON_IGPU, "0000:08:00.0", "30.0.13002.13003", "", 36),
|
||||
gpu("Microsoft Basic Render Driver", Dx12, Cpu, 0x1414, 0x8c, "", "10.0.26100.9549", "", 36),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gpu_listed_through_two_versions_of_its_driver_draws_with_the_newer_one() {
|
||||
let adapters = g513qy();
|
||||
// the window asks for high performance; `WGPU_POWER_PREF=none` for no preference
|
||||
for power in [HighPerformance, PowerPreference::None] {
|
||||
assert_eq!(wgpu_choice(&adapters, power), Some(0), "wgpu takes the stale entry, which crashed");
|
||||
assert_eq!(choose(&adapters, power), Some(1));
|
||||
}
|
||||
// whichever version the loader lists first
|
||||
let mut newer_first = adapters.clone();
|
||||
newer_first.swap(0, 1);
|
||||
assert_eq!(choose(&newer_first, HighPerformance), Some(0));
|
||||
// two entries exposing as much: wgpu's, the first
|
||||
let mut equal = adapters.clone();
|
||||
if let Some(c) = equal.get_mut(1) {
|
||||
c.features = 60;
|
||||
}
|
||||
assert_eq!(choose(&equal, HighPerformance), Some(0));
|
||||
// three versions: the one exposing the most
|
||||
let mut three = adapters.clone();
|
||||
three.insert(1, gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", "AMD proprietary driver", "24.1.1", 64));
|
||||
assert_eq!(choose(&three, HighPerformance), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_version_never_overtakes_another_gpu_or_backend() {
|
||||
// low power: the integrated GPU, as wgpu picks, whatever the discrete one is listed as
|
||||
assert_eq!(choose(&g513qy(), LowPower), Some(2));
|
||||
// DirectX 12 enumerated first: its entry of the GPU stays the choice
|
||||
let mut dx12_first = g513qy();
|
||||
dx12_first.rotate_left(3);
|
||||
for power in [HighPerformance, PowerPreference::None] {
|
||||
assert_eq!(choose(&dx12_first, power), Some(0));
|
||||
}
|
||||
assert_eq!(choose(&dx12_first, LowPower), wgpu_choice(&dx12_first, LowPower));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_two_versions_of_one_driver_the_choice_is_wgpus() {
|
||||
let nvidia = |backend, bus, driver, features| gpu("NVIDIA GeForce RTX 4070", backend, DiscreteGpu, 0x10de, 0x2786, bus, driver, "", features);
|
||||
let intel = |backend, bus, driver, features| gpu("Intel(R) UHD Graphics 770", backend, IntegratedGpu, 0x8086, 0x4680, bus, driver, "", features);
|
||||
let radeon = |bus, driver, features| gpu("AMD Radeon RX 7900 XT", Vulkan, DiscreteGpu, AMD, 0x744c, bus, driver, "", features);
|
||||
let gl = |name: &str, features| gpu(name, Gl, Other, 0, 0, "", "", "", features);
|
||||
let without = |skip: usize| g513qy().into_iter().enumerate().filter(|(i, _)| *i != skip).map(|(_, c)| c).collect::<Vec<_>>();
|
||||
let listings: Vec<Vec<Candidate>> = vec![
|
||||
vec![],
|
||||
vec![nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70)],
|
||||
// a laptop with two GPUs, through Vulkan and DirectX 12
|
||||
vec![
|
||||
intel(Vulkan, "0000:00:02.0", "Intel Corporation", 60),
|
||||
nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70),
|
||||
nvidia(Dx12, "0000:01:00.0", "32.0.15.6094", 36),
|
||||
intel(Dx12, "0000:00:02.0", "31.0.101.5186", 36),
|
||||
gpu("Microsoft Basic Render Driver", Dx12, Cpu, 0x1414, 0x8c, "", "10.0.26100.1", "", 36),
|
||||
],
|
||||
// one GPU through two different drivers, the second exposing more: Linux with AMDVLK and RADV
|
||||
vec![radeon("0000:03:00.0", "AMD open-source driver", 58), radeon("0000:03:00.0", "radv", 71)],
|
||||
// a vendor's driver and Dozen (Vulkan on DirectX 12)
|
||||
vec![nvidia(Vulkan, "", "Dozen", 75), nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70)],
|
||||
// two cards of one model, on their own buses
|
||||
vec![radeon("0000:03:00.0", "AMD proprietary driver", 60), radeon("0000:04:00.0", "AMD proprietary driver", 68)],
|
||||
// OpenGL reports no ids: two adapters cannot be told apart
|
||||
vec![gl("ANGLE", 20), gl("ANGLE", 30)],
|
||||
vec![gpu("llvmpipe (LLVM 19.1.0, 256 bits)", Vulkan, Cpu, 0x10005, 0, "", "llvmpipe", "", 50)],
|
||||
// the laptop above with either version of the driver alone
|
||||
without(0),
|
||||
without(1),
|
||||
];
|
||||
for adapters in &listings {
|
||||
for power in POWERS {
|
||||
assert_eq!(choose(adapters, power), wgpu_choice(adapters, power), "{power:?} over {adapters:#?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(choose(&[], HighPerformance), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_entries_are_one_gpu_only_with_the_same_ids_type_driver_and_bus() {
|
||||
let stale = g513qy().swap_remove(0);
|
||||
let newer = g513qy().swap_remove(1);
|
||||
assert!(same_gpu_and_driver(&stale, &newer) && same_gpu_and_driver(&stale, &stale));
|
||||
// a bus id only one version reports does not tell them apart; two different ones do
|
||||
let on_bus = |bus: &str| Candidate { pci_bus_id: bus.into(), ..newer.clone() };
|
||||
assert!(same_gpu_and_driver(&stale, &on_bus("0000:03:00.0")));
|
||||
assert!(same_gpu_and_driver(&on_bus("0000:03:00.0"), &on_bus("0000:03:00.0")));
|
||||
assert!(!same_gpu_and_driver(&on_bus("0000:03:00.0"), &on_bus("0000:04:00.0")));
|
||||
for other in [
|
||||
Candidate { backend: Dx12, ..newer.clone() },
|
||||
Candidate { device_type: IntegratedGpu, ..newer.clone() },
|
||||
Candidate { vendor: 0x10de, ..newer.clone() },
|
||||
Candidate { device: RADEON_IGPU, ..newer.clone() },
|
||||
Candidate { driver: "radv".into(), ..newer.clone() },
|
||||
] {
|
||||
assert!(!same_gpu_and_driver(&stale, &other), "{other}");
|
||||
}
|
||||
let unidentified = Candidate { vendor: 0, device: 0, ..newer };
|
||||
assert!(!same_gpu_and_driver(&unidentified, &unidentified));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapters_that_cannot_draw_to_the_window_are_left_out() {
|
||||
let unusable = |which: &dyn Fn(usize, &Candidate) -> bool| {
|
||||
let mut adapters = g513qy();
|
||||
for (i, c) in adapters.iter_mut().enumerate() {
|
||||
c.usable = !which(i, c);
|
||||
}
|
||||
adapters
|
||||
};
|
||||
// the newer version cannot: the other entry is all there is of that GPU through Vulkan
|
||||
assert_eq!(choose(&unusable(&|i, _| i == 1), HighPerformance), Some(0));
|
||||
assert_eq!(choose(&unusable(&|i, _| i == 0), HighPerformance), Some(1));
|
||||
// Vulkan cannot at all: DirectX 12
|
||||
assert_eq!(choose(&unusable(&|_, c| c.backend == Vulkan), HighPerformance), Some(3));
|
||||
assert_eq!(choose(&unusable(&|_, _| true), HighPerformance), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_describe_themselves_for_the_log() {
|
||||
let adapters = g513qy();
|
||||
let text = adapters.get(1).map(ToString::to_string).unwrap_or_default();
|
||||
assert_eq!(text, "\"AMD Radeon RX 6800M\" (Vulkan, discrete GPU, driver AMD proprietary driver 26.9.2 (AMD proprietary shader compiler), 68 features)");
|
||||
let offscreen = adapters.get(4).map(|c| Candidate { usable: false, ..c.clone() }.to_string()).unwrap_or_default();
|
||||
assert_eq!(
|
||||
offscreen,
|
||||
"\"AMD Radeon(TM) Graphics\" (Dx12, integrated GPU, driver 30.0.13002.13003, 36 features, bus 0000:08:00.0, cannot draw to the window)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Against the adapters of the machine running the tests: [`wgpu_choice`] is what wgpu's
|
||||
/// `request_adapter` returns, and [`pick`] returns that adapter too, or a version of it
|
||||
/// exposing more features.
|
||||
#[test]
|
||||
fn on_this_host_the_choice_is_wgpus_unless_its_driver_is_listed_twice() {
|
||||
if wgpu::Instance::enabled_backend_features().is_empty() {
|
||||
return;
|
||||
}
|
||||
// no OpenGL: the app leaves it out on Windows, where creating it can crash a driver
|
||||
let backends = wgpu::Backends::PRIMARY;
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() });
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
|
||||
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::of(a, None)).collect();
|
||||
for power in POWERS {
|
||||
let options = wgpu::RequestAdapterOptions { power_preference: power, ..Default::default() };
|
||||
let wgpu_own = pollster::block_on(instance.request_adapter(&options)).ok().map(|a| Candidate::of(&a, None));
|
||||
assert_eq!(wgpu_choice(&candidates, power).and_then(|i| candidates.get(i)), wgpu_own.as_ref(), "{power:?} over {candidates:#?}");
|
||||
let picked = pick(&adapters, None, power).ok().map(|a| Candidate::of(&a, None));
|
||||
match (&wgpu_own, &picked) {
|
||||
(Some(wgpu_own), Some(picked)) if wgpu_own != picked => {
|
||||
assert!(same_gpu_and_driver(wgpu_own, picked) && picked.features > wgpu_own.features, "{power:?}: {picked} instead of {wgpu_own}");
|
||||
}
|
||||
_ => assert_eq!(picked, wgpu_own, "{power:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const EXACT: f32 = 1e-4;
|
||||
const FLIPS: f64 = 0.01;
|
||||
|
||||
fn cx(t: Tick, px_scale: f32) -> FxCtx<'static> {
|
||||
FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working: filmcraft_color::WorkingSpace::Rec709 }
|
||||
FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709 }
|
||||
}
|
||||
|
||||
fn effect(id: &str, params: &[(&str, ParamValue)]) -> EffectInstance {
|
||||
|
||||
@@ -38,6 +38,7 @@ use filmcraft_frame::{Chroma, PixelData, VideoFrame};
|
||||
use filmcraft_render::Blend;
|
||||
use filmcraft_render::plan::{FramePlan, PlanLayer, PlanStep};
|
||||
|
||||
pub mod adapter;
|
||||
pub mod export_renderer;
|
||||
pub mod fx;
|
||||
pub mod lut;
|
||||
|
||||
@@ -569,8 +569,9 @@ 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 = r.read_ue()? + 1;
|
||||
ensure!(num_slice_groups <= 8, "num_slice_groups out of range");
|
||||
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 mut slice_group_map_type = 0;
|
||||
if num_slice_groups > 1 {
|
||||
// Parsed for completeness; FMO decoding is unsupported.
|
||||
@@ -592,7 +593,10 @@ impl Pps {
|
||||
r.read_ue()?;
|
||||
}
|
||||
6 => {
|
||||
let n = r.read_ue()? + 1;
|
||||
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 bits = 32 - (num_slice_groups - 1).leading_zeros();
|
||||
for _ in 0..n {
|
||||
r.read_bits(bits)?;
|
||||
@@ -601,13 +605,23 @@ impl Pps {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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 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 weighted_pred = r.read_flag()?;
|
||||
let weighted_bipred_idc = r.read_bits(2)?;
|
||||
let pic_init_qp = 26 + r.read_se()?;
|
||||
let pic_init_qs = 26 + r.read_se()?;
|
||||
// 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 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()?;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! #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}");
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ while let Some(p) = d.next_packet()? { // file order, all tracks
|
||||
| `A_VORBIS` | `Vorbis` | three headers split from Xiph-laced CodecPrivate |
|
||||
| `A_FLAC` | `Flac` | `fLaC` + metadata blocks |
|
||||
| `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE` | `Pcm` | bit depth from Audio |
|
||||
| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2` | `Ac3`, `Eac3`, `Mp3`, `Mp2` | |
|
||||
| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2`, `A_MPEG/L1` | `Ac3`, `Eac3`, `Mp3`, `Mp2`, `Mp1` | |
|
||||
| `S_TEXT/UTF8` | `SubRip` | |
|
||||
| `S_TEXT/WEBVTT`, `D_WEBVTT/*` (WebM) | `WebVtt` | WebM blocks keep `id\nsettings\npayload` |
|
||||
| `S_TEXT/ASS`, `S_TEXT/SSA` | `Ass` | script header |
|
||||
|
||||
@@ -45,6 +45,8 @@ pub enum Codec {
|
||||
Mp3,
|
||||
/// `A_MPEG/L2`.
|
||||
Mp2,
|
||||
/// `A_MPEG/L1`.
|
||||
Mp1,
|
||||
/// `S_TEXT/UTF8` (SubRip-style plain text).
|
||||
SubRip,
|
||||
/// `S_TEXT/WEBVTT`, or WebM's `D_WEBVTT/{SUBTITLES,CAPTIONS,DESCRIPTIONS,METADATA}`.
|
||||
@@ -86,6 +88,7 @@ impl Codec {
|
||||
Codec::Eac3 => "eac3".into(),
|
||||
Codec::Mp3 => "mp3".into(),
|
||||
Codec::Mp2 => "mp2".into(),
|
||||
Codec::Mp1 => "mp1".into(),
|
||||
Codec::SubRip => "subrip".into(),
|
||||
Codec::WebVtt => "webvtt".into(),
|
||||
Codec::Ass { ssa, .. } => if *ssa { "ssa" } else { "ass" }.into(),
|
||||
@@ -173,6 +176,7 @@ pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInf
|
||||
"A_EAC3" => Codec::Eac3,
|
||||
"A_MPEG/L3" => Codec::Mp3,
|
||||
"A_MPEG/L2" => Codec::Mp2,
|
||||
"A_MPEG/L1" => Codec::Mp1,
|
||||
"S_TEXT/UTF8" => Codec::SubRip,
|
||||
"S_TEXT/WEBVTT" | "D_WEBVTT/SUBTITLES" | "D_WEBVTT/CAPTIONS" | "D_WEBVTT/DESCRIPTIONS" | "D_WEBVTT/METADATA" => Codec::WebVtt,
|
||||
"S_TEXT/ASS" | "S_ASS" => Codec::Ass { header: p, ssa: false },
|
||||
|
||||
@@ -172,7 +172,7 @@ pub trait MediaSource: Send + Sync {
|
||||
pub type SharedSource = Arc<dyn MediaSource>;
|
||||
|
||||
/// File extensions we recognise at import (the media browser filters on these).
|
||||
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "exr", "dng"];
|
||||
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "exr", "hdr", "dng"];
|
||||
pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"];
|
||||
pub const VIDEO_EXTENSIONS: &[&str] = &[
|
||||
"mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv",
|
||||
@@ -197,6 +197,9 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result<Shar
|
||||
if still::exr::sniff(&bytes) {
|
||||
return Ok(Arc::new(still::exr::still(name, &bytes)?));
|
||||
}
|
||||
if still::hdr::sniff(&bytes) {
|
||||
return Ok(Arc::new(still::hdr::still(name, &bytes)?));
|
||||
}
|
||||
if wav::sniff(&bytes) {
|
||||
return Ok(Arc::new(wav::WavSource::parse(name, bytes)?));
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ impl MediaSource for ImageSequenceSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one frame file: OpenEXR and DNG camera raw as linear floats (DNG with its metadata, for
|
||||
/// Decode one frame file: OpenEXR, Radiance HDR and DNG camera raw as linear floats (DNG with its metadata, for
|
||||
/// the sequence's timing), every other format through the still decoders as RGBA8. Returns the
|
||||
/// frame, whether it has alpha, and its pixel format for the media info.
|
||||
fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, Option<filmcraft_dng::Info>)> {
|
||||
@@ -229,6 +229,10 @@ fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, O
|
||||
let d = crate::still::exr::decode(name, bytes)?;
|
||||
return Ok((d.frame, d.has_alpha, d.pixel_format, None));
|
||||
}
|
||||
if crate::still::hdr::sniff(bytes) {
|
||||
let frame = crate::still::hdr::decode(name, bytes)?;
|
||||
return Ok((frame, false, "RGB 32-bit float (RGBE)".to_string(), None));
|
||||
}
|
||||
if crate::still::dng::sniff(bytes) {
|
||||
let d = crate::still::dng::decode(name, bytes)?;
|
||||
return Ok((d.frame, false, d.pixel_format, Some(d.info)));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, DNG camera raw) as media
|
||||
//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, Radiance HDR, DNG camera raw) as media
|
||||
//! sources. Animated PNG and GIF files open as movies instead ([`crate::animated`]); here they would
|
||||
//! show their first frame.
|
||||
|
||||
@@ -11,6 +11,8 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result,
|
||||
|
||||
pub mod dng;
|
||||
pub mod exr;
|
||||
pub mod hdr;
|
||||
mod ycbcr_tiff;
|
||||
|
||||
/// Default still duration (Premiere's preference default is 5 seconds).
|
||||
pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);
|
||||
@@ -18,6 +20,7 @@ pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);
|
||||
/// Whether `b` (the contents of the file `name`) is a still image we decode.
|
||||
pub fn sniff(name: &str, b: &[u8]) -> bool {
|
||||
crate::dpx::sniff(b)
|
||||
|| hdr::sniff(b)
|
||||
|| crate::tga::sniff(name, b)
|
||||
|| b.starts_with(b"\x89PNG")
|
||||
|| b.starts_with(&[0xFF, 0xD8, 0xFF])
|
||||
@@ -46,6 +49,9 @@ pub fn decode_raster(name: &str, bytes: &[u8]) -> Result<Raster> {
|
||||
if crate::tga::sniff(name, bytes) {
|
||||
return crate::tga::decode(name, bytes);
|
||||
}
|
||||
if ycbcr_tiff::sniff(bytes) {
|
||||
return ycbcr_tiff::decode(name, bytes);
|
||||
}
|
||||
let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?;
|
||||
let has_alpha = img.color().has_alpha();
|
||||
let rgba = img.to_rgba8();
|
||||
@@ -64,6 +70,9 @@ impl StillSource {
|
||||
if dng::sniff(bytes) {
|
||||
return dng::still(name, bytes);
|
||||
}
|
||||
if hdr::sniff(bytes) {
|
||||
return hdr::still(name, bytes);
|
||||
}
|
||||
let r = decode_raster(name, bytes)?;
|
||||
Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 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) or ZIP (16 lines) compression,
|
||||
//! single-part scan-line files with no, RLE, ZIPS (one line per chunk), ZIP or PXR24 (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
|
||||
@@ -7,7 +8,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, PXR24, B44 and DWA compressions are refused with a clear error.
|
||||
//! subsampled channels and the PIZ, B44 and DWA compressions are refused with a clear error.
|
||||
|
||||
mod inflate;
|
||||
|
||||
@@ -134,12 +135,15 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -227,6 +231,7 @@ 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")));
|
||||
@@ -329,6 +334,45 @@ 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)?;
|
||||
@@ -403,6 +447,12 @@ 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() {
|
||||
|
||||
@@ -25,6 +25,35 @@ 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();
|
||||
@@ -105,7 +134,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 { 16 } else { 1 };
|
||||
let lpc = if comp == 3 || comp == 5 { 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);
|
||||
@@ -121,6 +150,10 @@ 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;
|
||||
@@ -178,7 +211,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] {
|
||||
for comp in [1, 2, 3, 5] {
|
||||
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}"));
|
||||
@@ -186,6 +219,43 @@ 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);
|
||||
@@ -242,6 +312,8 @@ 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);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Radiance HDR (`.hdr`, `.pic`) stills, read from Greg Ward's public RGBE format description
|
||||
//! ("Real Pixels", Graphics Gems II): a text header (`#?RADIANCE` or `#?RGBE`, `FORMAT=32-bit_rle_rgbe`),
|
||||
//! a blank line, a resolution line (`-Y h +X w` and the seven other axis orders), then one RGBE
|
||||
//! pixel (shared exponent) per sample, flat, in the old run-length form or in the "new" per-channel
|
||||
//! run-length form (scan lines of 8 to 32767 pixels). RGBE is scene-linear light, so the frame keeps
|
||||
//! 32-bit floats (values above 1.0 survive into effects and colour work). The `EXPOSURE` and
|
||||
//! `PRIMARIES` header lines are not applied, which is what other readers do too; the XYZE colour
|
||||
//! format is refused.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_color::{ColorInfo, Transfer};
|
||||
use filmcraft_frame::VideoFrame;
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
use super::{DEFAULT_STILL_DURATION, StillSource};
|
||||
use crate::{MediaError, MediaInfo, MediaKind, Result, VideoStreamInfo};
|
||||
|
||||
/// Largest picture we decode (each pixel takes 16 bytes as RGBA floats).
|
||||
const MAX_PIXELS: u64 = 1 << 26;
|
||||
/// Longest header we read (real ones are a few hundred bytes).
|
||||
const MAX_HEADER: usize = 64 << 10;
|
||||
|
||||
/// The colour of HDR frames: scene-linear light, Rec.709 primaries.
|
||||
const LINEAR_709: ColorInfo = ColorInfo { transfer: Transfer::Linear, ..ColorInfo::SRGB_FULL };
|
||||
|
||||
/// Whether `b` starts like a Radiance picture.
|
||||
pub fn sniff(b: &[u8]) -> bool {
|
||||
b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE")
|
||||
}
|
||||
|
||||
fn err(name: &str, what: &str) -> MediaError {
|
||||
MediaError::Decode(format!("{name}: Radiance HDR: {what}"))
|
||||
}
|
||||
|
||||
/// Open a Radiance picture as a still.
|
||||
pub fn still(name: &str, bytes: &[u8]) -> Result<StillSource> {
|
||||
let frame = decode(name, bytes)?;
|
||||
let info = MediaInfo {
|
||||
name: name.to_string(),
|
||||
kind: MediaKind::Still,
|
||||
duration: DEFAULT_STILL_DURATION,
|
||||
video: Some(VideoStreamInfo {
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
frame_rate: FrameRate::FPS_29_97,
|
||||
par: (1, 1),
|
||||
codec: "HDR".into(),
|
||||
pixel_format: "RGB 32-bit float (RGBE)".into(),
|
||||
color: LINEAR_709,
|
||||
has_alpha: false,
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
}),
|
||||
audio_streams: Vec::new(),
|
||||
container: "Image".into(),
|
||||
start_timecode: None,
|
||||
file_size: Some(bytes.len() as u64),
|
||||
};
|
||||
Ok(StillSource { info, frame: Arc::new(frame) })
|
||||
}
|
||||
|
||||
/// One resolution-line axis: `(is_x, positive)`.
|
||||
fn axis(tok: &str) -> Option<(bool, bool)> {
|
||||
match tok {
|
||||
"+X" => Some((true, true)),
|
||||
"-X" => Some((true, false)),
|
||||
"+Y" => Some((false, true)),
|
||||
"-Y" => Some((false, false)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// RGBE to linear floats: `mantissa * 2^(e - 136)`, zero for exponent 0. (Radiance's own reader adds
|
||||
/// half a mantissa step; ffmpeg and stb_image do not, which also makes 1.0 = (128, 128, 128, 129)
|
||||
/// read back as exactly 1.0.)
|
||||
fn rgbe(p: [u8; 4]) -> [f32; 3] {
|
||||
if p[3] == 0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
let scale = 2f32.powi(i32::from(p[3]) - 136);
|
||||
[f32::from(p[0]) * scale, f32::from(p[1]) * scale, f32::from(p[2]) * scale]
|
||||
}
|
||||
|
||||
/// Read the pixels of one scan line of `n` pixels into `line`.
|
||||
fn scanline(name: &str, b: &[u8], pos: &mut usize, line: &mut [[u8; 4]]) -> Result<()> {
|
||||
let n = line.len();
|
||||
let take = |pos: &mut usize, k: usize| -> Result<&[u8]> {
|
||||
let s = b.get(*pos..pos.saturating_add(k)).ok_or_else(|| err(name, "truncated pixel data"))?;
|
||||
*pos += k;
|
||||
Ok(s)
|
||||
};
|
||||
let head = b.get(*pos..*pos + 4);
|
||||
if (8..=32767).contains(&n)
|
||||
&& let Some(&[2, 2, hi, lo]) = head
|
||||
&& hi & 0x80 == 0
|
||||
{
|
||||
if usize::from(hi) << 8 | usize::from(lo) != n {
|
||||
return Err(err(name, "scan line length does not match the picture"));
|
||||
}
|
||||
*pos += 4;
|
||||
for c in 0..4 {
|
||||
let mut x = 0;
|
||||
while x < n {
|
||||
let code = take(pos, 1)?[0];
|
||||
if code > 128 {
|
||||
let run = usize::from(code - 128);
|
||||
let v = take(pos, 1)?[0];
|
||||
let dst = line.get_mut(x..x + run).ok_or_else(|| err(name, "run past the end of the scan line"))?;
|
||||
for p in dst {
|
||||
p[c] = v;
|
||||
}
|
||||
x += run;
|
||||
} else {
|
||||
let run = usize::from(code);
|
||||
if run == 0 {
|
||||
return Err(err(name, "empty literal run"));
|
||||
}
|
||||
let src = take(pos, run)?;
|
||||
let dst = line.get_mut(x..x + run).ok_or_else(|| err(name, "literal past the end of the scan line"))?;
|
||||
for (p, &v) in dst.iter_mut().zip(src) {
|
||||
p[c] = v;
|
||||
}
|
||||
x += run;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// flat pixels, with the old run-length form: (1, 1, 1, k) repeats the previous pixel k << shift times
|
||||
let (mut x, mut shift) = (0usize, 0u32);
|
||||
while x < n {
|
||||
let s = take(pos, 4)?;
|
||||
let p = [s[0], s[1], s[2], s[3]];
|
||||
if p[..3] == [1, 1, 1] {
|
||||
let count = usize::from(p[3]).checked_shl(shift).unwrap_or(usize::MAX);
|
||||
let prev = if x > 0 { line.get(x - 1).copied() } else { None }.ok_or_else(|| err(name, "run with no previous pixel"))?;
|
||||
let dst = line.get_mut(x..x.saturating_add(count).min(n)).ok_or_else(|| err(name, "run past the end of the scan line"))?;
|
||||
dst.fill(prev);
|
||||
x += dst.len();
|
||||
shift = shift.saturating_add(8).min(24);
|
||||
} else {
|
||||
if let Some(d) = line.get_mut(x) {
|
||||
*d = p;
|
||||
}
|
||||
x += 1;
|
||||
shift = 0;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode a Radiance picture to an opaque linear float frame.
|
||||
pub fn decode(name: &str, b: &[u8]) -> Result<VideoFrame> {
|
||||
if !sniff(b) {
|
||||
return Err(err(name, "not a Radiance picture"));
|
||||
}
|
||||
// header: lines up to the first empty one, then the resolution line
|
||||
let mut pos = 0;
|
||||
let line = |pos: &mut usize| -> Result<String> {
|
||||
let rest = b.get(*pos..).ok_or_else(|| err(name, "truncated header"))?;
|
||||
let end = rest.iter().take(MAX_HEADER).position(|&c| c == b'\n').ok_or_else(|| err(name, "truncated header"))?;
|
||||
let s = String::from_utf8_lossy(rest.get(..end).unwrap_or_default()).trim_end_matches('\r').to_string();
|
||||
*pos += end + 1;
|
||||
Ok(s)
|
||||
};
|
||||
loop {
|
||||
let l = line(&mut pos)?;
|
||||
if l.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(f) = l.strip_prefix("FORMAT=") {
|
||||
match f.trim() {
|
||||
"32-bit_rle_rgbe" => {}
|
||||
"32-bit_rle_xyze" => return Err(err(name, "the XYZE colour format is not supported")),
|
||||
other => return Err(err(name, &format!("unknown format {other}"))),
|
||||
}
|
||||
}
|
||||
if pos > MAX_HEADER {
|
||||
return Err(err(name, "header too long"));
|
||||
}
|
||||
}
|
||||
let res = line(&mut pos)?;
|
||||
let t: Vec<&str> = res.split_whitespace().collect();
|
||||
let &[a1, n1, a2, n2] = t.as_slice().first_chunk::<4>().ok_or_else(|| err(name, "bad resolution line"))?;
|
||||
let ((x1, p1), (x2, p2)) = (axis(a1).ok_or_else(|| err(name, "bad resolution line"))?, axis(a2).ok_or_else(|| err(name, "bad resolution line"))?);
|
||||
if x1 == x2 {
|
||||
return Err(err(name, "resolution line names one axis twice"));
|
||||
}
|
||||
let n1: usize = n1.parse().map_err(|_| err(name, "bad resolution line"))?;
|
||||
let n2: usize = n2.parse().map_err(|_| err(name, "bad resolution line"))?;
|
||||
let (w, h) = if x1 { (n1, n2) } else { (n2, n1) };
|
||||
if w == 0 || h == 0 || (w as u64).saturating_mul(h as u64) > MAX_PIXELS {
|
||||
return Err(err(name, "picture size out of range"));
|
||||
}
|
||||
// a scan line takes at least a few bits of file, so a header cannot ask for more than the data has
|
||||
if (b.len().saturating_sub(pos) as u64).saturating_mul(4096) < (w as u64).saturating_mul(h as u64) {
|
||||
return Err(err(name, "truncated pixel data"));
|
||||
}
|
||||
let mut px = vec![1f32; w * h * 4];
|
||||
let mut scan = vec![[0u8; 4]; n2];
|
||||
for i in 0..n1 {
|
||||
scanline(name, b, &mut pos, &mut scan)?;
|
||||
for (j, &p) in scan.iter().enumerate() {
|
||||
// the first axis counts scan lines, the second runs along one
|
||||
let (x, y) = if x1 {
|
||||
(if p1 { i } else { w - 1 - i }, if p2 { h - 1 - j } else { j })
|
||||
} else {
|
||||
(if p2 { j } else { w - 1 - j }, if p1 { h - 1 - i } else { i })
|
||||
};
|
||||
let [r, g, bl] = rgbe(p);
|
||||
if let Some(o) = px.get_mut((y * w + x) * 4..(y * w + x) * 4 + 3) {
|
||||
o.copy_from_slice(&[r, g, bl]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut frame = VideoFrame::rgba_f32(w as u32, h as u32, px);
|
||||
frame.color = LINEAR_709;
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,134 @@
|
||||
use super::*;
|
||||
|
||||
fn file(res: &str, body: &[u8]) -> Vec<u8> {
|
||||
let mut v = format!("#?RADIANCE\n# test\nFORMAT=32-bit_rle_rgbe\nEXPOSURE=1.0\n\n{res}\n").into_bytes();
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
fn px(f: &VideoFrame) -> Vec<f32> {
|
||||
match &f.data {
|
||||
filmcraft_frame::PixelData::RgbaF32(p) => p.to_vec(),
|
||||
_ => panic!("not float"),
|
||||
}
|
||||
}
|
||||
|
||||
/// New-style RLE of one scan line, as Radiance's own writer does: per channel, runs of 3+ equal bytes.
|
||||
fn rle_line(line: &[[u8; 4]]) -> Vec<u8> {
|
||||
let n = line.len();
|
||||
let mut out = vec![2, 2, (n >> 8) as u8, n as u8];
|
||||
for c in 0..4 {
|
||||
let ch: Vec<u8> = line.iter().map(|p| p[c]).collect();
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count();
|
||||
if run >= 3 {
|
||||
out.extend([128 + run as u8, ch[i]]);
|
||||
i += run;
|
||||
} else {
|
||||
let mut j = i + 1;
|
||||
while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) {
|
||||
j += 1;
|
||||
}
|
||||
out.push((j - i) as u8);
|
||||
out.extend_from_slice(&ch[i..j]);
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgbe_values() {
|
||||
assert_eq!(rgbe([0, 0, 0, 0]), [0.0; 3]);
|
||||
assert_eq!(rgbe([128, 128, 128, 129]), [1.0; 3]);
|
||||
assert_eq!(rgbe([128, 64, 0, 136]), [128.0, 64.0, 0.0]);
|
||||
assert_eq!(rgbe([128, 128, 128, 128])[0], 0.5);
|
||||
assert!(rgbe([255, 255, 255, 255])[0].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_top_to_bottom() {
|
||||
let body = [[10u8, 20, 30, 136], [1, 2, 3, 136], [4, 5, 6, 137], [7, 8, 9, 135]].concat();
|
||||
let f = decode("a.hdr", &file("-Y 2 +X 2", &body)).unwrap();
|
||||
assert_eq!((f.width, f.height), (2, 2));
|
||||
let p = px(&f);
|
||||
assert_eq!(&p[0..4], &[10.0, 20.0, 30.0, 1.0]);
|
||||
assert_eq!(&p[4..8], &[1.0, 2.0, 3.0, 1.0]);
|
||||
assert_eq!(&p[8..12], &[8.0, 10.0, 12.0, 1.0]); // exponent 137: scale 2
|
||||
assert_eq!(&p[12..16], &[3.5, 4.0, 4.5, 1.0]); // exponent 135: scale 1/2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientations() {
|
||||
// 3 wide, 2 high, pixel value = its index in the file
|
||||
let body: Vec<u8> = (0..6u8).flat_map(|i| [i, 0, 0, 136]).collect();
|
||||
let red = |res: &str| -> Vec<f32> {
|
||||
let f = decode("a.hdr", &file(res, &body)).unwrap();
|
||||
px(&f).chunks(4).map(|p| p[0]).collect()
|
||||
};
|
||||
assert_eq!(red("-Y 2 +X 3"), [0., 1., 2., 3., 4., 5.]);
|
||||
assert_eq!(red("+Y 2 +X 3"), [3., 4., 5., 0., 1., 2.]);
|
||||
assert_eq!(red("-Y 2 -X 3"), [2., 1., 0., 5., 4., 3.]);
|
||||
// scan lines are columns: file index = x * 2 + (row, top first for -Y)
|
||||
assert_eq!(red("+X 3 -Y 2"), [0., 2., 4., 1., 3., 5.]);
|
||||
assert_eq!(red("-X 3 +Y 2"), [5., 3., 1., 4., 2., 0.]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_length_lines() {
|
||||
let w = 40usize;
|
||||
let rows: Vec<Vec<[u8; 4]>> = (0..3).map(|y| (0..w).map(|x| if x < 20 { [100, 50, 25, 130] } else { [(x * 5 + y) as u8, 7, 9, 131] }).collect()).collect();
|
||||
let body: Vec<u8> = rows.iter().flat_map(|r| rle_line(r)).collect();
|
||||
let f = decode("a.hdr", &file(&format!("-Y 3 +X {w}"), &body)).unwrap();
|
||||
let p = px(&f);
|
||||
for (y, r) in rows.iter().enumerate() {
|
||||
for (x, q) in r.iter().enumerate() {
|
||||
let want = rgbe(*q);
|
||||
assert_eq!(&p[(y * w + x) * 4..(y * w + x) * 4 + 3], &want, "pixel {x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_style_runs() {
|
||||
// one pixel, then (1,1,1,3) repeats it 3 times, then a (1,1,1,1) pair repeats 1<<0 then 1<<8 -> clipped to the line
|
||||
let body = [[5u8, 6, 7, 136], [1, 1, 1, 3], [9, 9, 9, 136]].concat();
|
||||
let f = decode("a.hdr", &file("-Y 1 +X 5", &body)).unwrap();
|
||||
let p = px(&f);
|
||||
assert_eq!(p[0], 5.0);
|
||||
assert_eq!(p[12], 5.0); // fourth pixel is still a repeat
|
||||
assert_eq!(p[16], 9.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_an_error_not_a_panic() {
|
||||
let ok = file("-Y 2 +X 2", &[0; 16]);
|
||||
assert!(decode("a.hdr", &ok).is_ok());
|
||||
for cut in 0..ok.len() - 1 {
|
||||
// a cut anywhere is an error (the header or the pixel data is incomplete), never a panic
|
||||
assert!(decode("a.hdr", &ok[..cut]).is_err(), "{cut}");
|
||||
}
|
||||
assert!(decode("a.hdr", &file("-Y 2 +X 2", &[0; 8])).is_err());
|
||||
assert!(decode("a.hdr", &file("-Y 99999999 +X 99999999", &[0; 16])).is_err());
|
||||
assert!(decode("a.hdr", &file("-Y 0 +X 4", &[])).is_err());
|
||||
assert!(decode("a.hdr", &file("-Y 2 -Y 2", &[0; 16])).is_err());
|
||||
assert!(decode("a.hdr", &file("junk", &[0; 16])).is_err());
|
||||
assert!(decode("a.hdr", b"#?RADIANCE\nFORMAT=32-bit_rle_xyze\n\n-Y 1 +X 1\n\0\0\0\0").is_err());
|
||||
// an RLE line announcing the wrong width, a run past the end, a run at the start of a flat line
|
||||
assert!(decode("a.hdr", &file("-Y 1 +X 10", &[2, 2, 0, 11, 0])).is_err());
|
||||
assert!(decode("a.hdr", &file("-Y 1 +X 10", &[2, 2, 0, 10, 200, 1])).is_err());
|
||||
assert!(decode("a.hdr", &file("-Y 1 +X 3", &[1, 1, 1, 9, 0, 0, 0, 0])).is_err());
|
||||
assert!(decode("a.hdr", b"#?RADIANCE").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_as_a_float_still() {
|
||||
let body = [[128u8, 128, 128, 136]; 4].concat();
|
||||
let s = still("a.hdr", &file("-Y 2 +X 2", &body)).unwrap();
|
||||
let info = crate::MediaSource::info(&s);
|
||||
assert_eq!(info.video.as_ref().map(|v| (v.width, v.height, v.codec.as_str())), Some((2, 2, "HDR")));
|
||||
let src = crate::open_bytes("a.hdr", file("-Y 2 +X 2", &body).into(), &[]).unwrap();
|
||||
assert_eq!(src.info().kind, MediaKind::Still);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Uncompressed and PackBits YCbCr TIFFs (TIFF 6.0 §21), as ffmpeg writes from YUV sources. The
|
||||
//! image crate reads YCbCr only inside JPEG compression, so these are decoded here: 8-bit chunky
|
||||
//! data units of any subsampling (1, 2 or 4 each way), converted to RGB with the file's
|
||||
//! YCbCrCoefficients and ReferenceBlackWhite (§20), chroma repeated across each data unit.
|
||||
//!
|
||||
//! Every number comes from the file: sizes, strip offsets and counts are checked before use, the
|
||||
//! picture is capped at [`MAX_PIXELS`], and PackBits output stops at the bytes the image needs.
|
||||
|
||||
use filmcraft_dng::tiff::{Tiff, tags};
|
||||
|
||||
use super::Raster;
|
||||
use crate::{MediaError, Result};
|
||||
|
||||
/// Largest picture decoded (1 GiB of RGBA), as for animated images.
|
||||
const MAX_PIXELS: u64 = 1 << 28;
|
||||
|
||||
const PHOTOMETRIC_YCBCR: u64 = 6;
|
||||
const COMPRESSION_NONE: u64 = 1;
|
||||
const COMPRESSION_PACKBITS: u64 = 32773;
|
||||
const YCBCR_COEFFICIENTS: u16 = 529;
|
||||
const YCBCR_SUBSAMPLING: u16 = 530;
|
||||
const REFERENCE_BLACK_WHITE: u16 = 532;
|
||||
|
||||
/// Whether `bytes` is a TIFF whose first image is uncompressed or PackBits YCbCr (the ones this
|
||||
/// module decodes instead of the image crate).
|
||||
pub fn sniff(bytes: &[u8]) -> bool {
|
||||
let Ok((t, first)) = Tiff::new(bytes) else { return false };
|
||||
let Ok((ifd, _)) = t.read_ifd(first, 0) else { return false };
|
||||
let num = |tag| ifd.get(tag).and_then(|e| t.uint(e).ok());
|
||||
num(tags::PHOTOMETRIC) == Some(PHOTOMETRIC_YCBCR) && matches!(num(tags::COMPRESSION).unwrap_or(COMPRESSION_NONE), COMPRESSION_NONE | COMPRESSION_PACKBITS)
|
||||
}
|
||||
|
||||
/// Decode the first image of a YCbCr TIFF (see [`sniff`]) to RGBA.
|
||||
pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
|
||||
let bad = |what: &str| MediaError::Decode(format!("{name}: YCbCr TIFF: {what}"));
|
||||
let unsupported = |what: &str| MediaError::Unsupported(format!("{name}: YCbCr TIFF with {what}"));
|
||||
let (t, first) = Tiff::new(bytes).map_err(|_| bad("not a TIFF"))?;
|
||||
let (ifd, _) = t.read_ifd(first, 0).map_err(|_| bad("unreadable image directory"))?;
|
||||
let uints = |tag: u16| -> Result<Option<Vec<u64>>> { ifd.get(tag).map(|e| t.uints(e).map_err(|_| bad("unreadable field"))).transpose() };
|
||||
let one = |tag: u16| -> Result<Option<u64>> { Ok(uints(tag)?.and_then(|v| v.first().copied())) };
|
||||
|
||||
let width = one(tags::IMAGE_WIDTH)?.ok_or_else(|| bad("no ImageWidth"))?;
|
||||
let height = one(tags::IMAGE_LENGTH)?.ok_or_else(|| bad("no ImageLength"))?;
|
||||
if width == 0 || height == 0 {
|
||||
return Err(bad("empty image"));
|
||||
}
|
||||
if width.saturating_mul(height) > MAX_PIXELS {
|
||||
return Err(unsupported("more than 268 million pixels"));
|
||||
}
|
||||
if one(tags::SAMPLES_PER_PIXEL)?.unwrap_or(1) != 3 {
|
||||
return Err(unsupported("other than three samples per pixel"));
|
||||
}
|
||||
if uints(tags::BITS_PER_SAMPLE)?.unwrap_or_else(|| vec![1]).iter().any(|&b| b != 8) {
|
||||
return Err(unsupported("other than 8 bits per sample"));
|
||||
}
|
||||
if one(tags::PLANAR_CONFIGURATION)?.unwrap_or(1) != 1 {
|
||||
return Err(unsupported("separate planes"));
|
||||
}
|
||||
if ifd.get(tags::TILE_OFFSETS).is_some() {
|
||||
return Err(unsupported("tiles"));
|
||||
}
|
||||
let compression = one(tags::COMPRESSION)?.unwrap_or(COMPRESSION_NONE);
|
||||
let sub = uints(YCBCR_SUBSAMPLING)?.unwrap_or_else(|| vec![2, 2]);
|
||||
let (sh, sv) = match sub.as_slice() {
|
||||
[h, v, ..] if [1, 2, 4].contains(h) && [1, 2, 4].contains(v) => (*h, *v),
|
||||
_ => return Err(bad("invalid YCbCrSubSampling")),
|
||||
};
|
||||
// §21: the data units cover the image rounded up to whole units.
|
||||
let (units_x, units_y) = (width.div_ceil(sh), height.div_ceil(sv));
|
||||
let unit_len = sh * sv + 2;
|
||||
let need = usize::try_from(units_x.saturating_mul(units_y).saturating_mul(unit_len)).map_err(|_| bad("too large"))?;
|
||||
|
||||
let offsets = uints(tags::STRIP_OFFSETS)?.ok_or_else(|| bad("no StripOffsets"))?;
|
||||
let counts = uints(tags::STRIP_BYTE_COUNTS)?.ok_or_else(|| bad("no StripByteCounts"))?;
|
||||
let mut units: Vec<u8> = Vec::with_capacity(need);
|
||||
for (&off, &len) in offsets.iter().zip(&counts) {
|
||||
if units.len() >= need {
|
||||
break;
|
||||
}
|
||||
let strip = usize::try_from(off)
|
||||
.ok()
|
||||
.zip(usize::try_from(len).ok())
|
||||
.and_then(|(o, l)| bytes.get(o..o.checked_add(l)?))
|
||||
.ok_or_else(|| bad("strip outside the file"))?;
|
||||
match compression {
|
||||
COMPRESSION_NONE => units.extend_from_slice(strip.get(..need - units.len()).unwrap_or(strip)),
|
||||
_ => unpackbits(strip, &mut units, need),
|
||||
}
|
||||
}
|
||||
if units.len() < need {
|
||||
return Err(bad("image data shorter than the image"));
|
||||
}
|
||||
|
||||
let coeffs = match ifd.get(YCBCR_COEFFICIENTS) {
|
||||
Some(e) => t.floats(e).map_err(|_| bad("unreadable YCbCrCoefficients"))?,
|
||||
None => vec![0.299, 0.587, 0.114],
|
||||
};
|
||||
let [lr, lg, lb] = coeffs.get(..3).and_then(|c| <[f64; 3]>::try_from(c).ok()).ok_or_else(|| bad("invalid YCbCrCoefficients"))?;
|
||||
if lg.abs() < 1e-6 {
|
||||
return Err(bad("invalid YCbCrCoefficients"));
|
||||
}
|
||||
let rbw = match ifd.get(REFERENCE_BLACK_WHITE) {
|
||||
Some(e) => t.floats(e).map_err(|_| bad("unreadable ReferenceBlackWhite"))?,
|
||||
None => vec![0.0, 255.0, 128.0, 255.0, 128.0, 255.0],
|
||||
};
|
||||
let [yb, yw, cbb, cbw, crb, crw] = rbw.get(..6).and_then(|c| <[f64; 6]>::try_from(c).ok()).ok_or_else(|| bad("invalid ReferenceBlackWhite"))?;
|
||||
if (yw - yb).abs() < 1e-6 || (cbw - cbb).abs() < 1e-6 || (crw - crb).abs() < 1e-6 {
|
||||
return Err(bad("invalid ReferenceBlackWhite"));
|
||||
}
|
||||
// §20: luma to 0–255, chroma to ±127 about its reference black; §21: back to R'G'B'.
|
||||
let to_rgb = |y: u8, cb: u8, cr: u8| -> [u8; 3] {
|
||||
let y = (f64::from(y) - yb) * 255.0 / (yw - yb);
|
||||
let cb = (f64::from(cb) - cbb) * 127.0 / (cbw - cbb);
|
||||
let cr = (f64::from(cr) - crb) * 127.0 / (crw - crb);
|
||||
let r = cr * (2.0 - 2.0 * lr) + y;
|
||||
let b = cb * (2.0 - 2.0 * lb) + y;
|
||||
let g = (y - lb * b - lr * r) / lg;
|
||||
[r, g, b].map(|v| v.round().clamp(0.0, 255.0) as u8)
|
||||
};
|
||||
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
let (sh, sv, units_x) = (sh as usize, sv as usize, units_x as usize);
|
||||
let mut rgba = vec![255u8; w * h * 4];
|
||||
for (i, unit) in units.chunks_exact(sh * sv + 2).take(need / (sh * sv + 2)).enumerate() {
|
||||
let (ux, uy) = (i % units_x, i / units_x);
|
||||
let (cb, cr) = (unit[sh * sv], unit[sh * sv + 1]);
|
||||
for (k, &y) in unit[..sh * sv].iter().enumerate() {
|
||||
let (x, row) = (ux * sh + k % sh, uy * sv + k / sh);
|
||||
if x < w && row < h {
|
||||
let at = (row * w + x) * 4;
|
||||
rgba[at..at + 3].copy_from_slice(&to_rgb(y, cb, cr));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Raster { width: width as u32, height: height as u32, rgba, has_alpha: false })
|
||||
}
|
||||
|
||||
/// PackBits (TIFF 6.0 §9) from `src` appended to `out`, stopping at `limit` bytes of output or at
|
||||
/// a run that the data ends inside.
|
||||
fn unpackbits(src: &[u8], out: &mut Vec<u8>, limit: usize) {
|
||||
let mut p = 0;
|
||||
while out.len() < limit {
|
||||
let Some(&n) = src.get(p) else { return };
|
||||
let n = n as i8;
|
||||
p += 1;
|
||||
if n >= 0 {
|
||||
let len = n as usize + 1;
|
||||
let Some(lit) = src.get(p..p + len) else { return };
|
||||
out.extend_from_slice(&lit[..len.min(limit - out.len())]);
|
||||
p += len;
|
||||
} else if n != -128 {
|
||||
let Some(&b) = src.get(p) else { return };
|
||||
let len = (1 - n as isize) as usize;
|
||||
out.extend(std::iter::repeat_n(b, len.min(limit - out.len())));
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn packbits_runs_literals_and_noops() {
|
||||
let mut out = Vec::new();
|
||||
// literal 2, a no-op, a run of 3, then a literal cut short by the end of the data
|
||||
unpackbits(&[1, 10, 11, 0x80, 0xFE, 7, 4, 1], &mut out, 100);
|
||||
assert_eq!(out, [10, 11, 7, 7, 7]);
|
||||
let mut capped = Vec::new();
|
||||
unpackbits(&[0xFE, 7, 0xFE, 8], &mut capped, 4);
|
||||
assert_eq!(capped, [7, 7, 7, 8]);
|
||||
}
|
||||
}
|
||||
@@ -141,3 +141,51 @@ 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Radiance HDR stills against ffmpeg (an external test oracle): we write RGBE files in the flat and
|
||||
//! new run-length forms, and our decode must give the floats ffmpeg's decoder gives.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use filmcraft_frame::PixelData;
|
||||
use filmcraft_media::FrameRequest;
|
||||
|
||||
const W: usize = 70; // 8..=32767, so scan lines may use the new run-length form
|
||||
const H: usize = 9;
|
||||
|
||||
/// A deterministic picture with long runs (so RLE has runs) and noisy stretches (literals).
|
||||
fn picture() -> Vec<[u8; 4]> {
|
||||
let mut s = 0x1234_5678u32;
|
||||
(0..W * H)
|
||||
.map(|i| {
|
||||
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
let (x, y) = (i % W, i / W);
|
||||
if x < 30 {
|
||||
[200, 100, 50, 120 + y as u8]
|
||||
} else if i % 3 == 0 {
|
||||
[(s >> 24) as u8, 0, 255, 0] // exponent 0: black
|
||||
} else {
|
||||
[(s >> 24) as u8, (s >> 16) as u8, (s >> 8) as u8, 100 + (s & 31) as u8]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rle_line(line: &[[u8; 4]]) -> Vec<u8> {
|
||||
let n = line.len();
|
||||
let mut out = vec![2, 2, (n >> 8) as u8, n as u8];
|
||||
for c in 0..4 {
|
||||
let ch: Vec<u8> = line.iter().map(|p| p[c]).collect();
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count();
|
||||
if run >= 3 {
|
||||
out.extend([128 + run as u8, ch[i]]);
|
||||
i += run;
|
||||
} else {
|
||||
let mut j = i + 1;
|
||||
while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) {
|
||||
j += 1;
|
||||
}
|
||||
out.push((j - i) as u8);
|
||||
out.extend_from_slice(&ch[i..j]);
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn write(path: &Path, res: &str, body: &[u8]) {
|
||||
let mut v = format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n{res}\n").into_bytes();
|
||||
v.extend_from_slice(body);
|
||||
std::fs::write(path, v).unwrap();
|
||||
}
|
||||
|
||||
fn check(ff: &Path, name: &str, res: &str, body: &[u8]) {
|
||||
let dir = filmcraft_testkit::fixtures::fixtures_dir("media/hdr");
|
||||
let path = dir.join(name);
|
||||
write(&path, res, body);
|
||||
let out = std::process::Command::new(ff).args(["-v", "error", "-i"]).arg(&path).args(["-f", "rawvideo", "-pix_fmt", "gbrpf32le", "-"]).output().unwrap();
|
||||
assert!(out.status.success(), "ffmpeg decode of {name}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let v: Vec<f32> = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect();
|
||||
let n = W * H;
|
||||
assert_eq!(v.len(), 3 * n, "{name}: ffmpeg frame size");
|
||||
|
||||
let src = filmcraft_media::open_bytes(name, std::fs::read(&path).unwrap().into(), &[]).unwrap();
|
||||
let f = src.video_frame(FrameRequest::full(filmcraft_time::Tick::ZERO)).unwrap();
|
||||
assert_eq!((f.width as usize, f.height as usize), (W, H));
|
||||
let PixelData::RgbaF32(p) = &f.data else { panic!("HDR frames are float") };
|
||||
let mut worst = 0f32;
|
||||
for i in 0..n {
|
||||
// gbrp planes: 0 = G, 1 = B, 2 = R
|
||||
for (c, plane) in [2, 0, 1].into_iter().enumerate() {
|
||||
let (a, b) = (p[i * 4 + c], v[plane * n + i]);
|
||||
worst = worst.max((a - b).abs() / b.abs().max(1e-6));
|
||||
}
|
||||
assert_eq!(p[i * 4 + 3], 1.0);
|
||||
}
|
||||
// ffmpeg's decode and ours differ only by float rounding of the same formula
|
||||
assert!(worst == 0.0, "{name}: worst relative difference {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_ffmpeg() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let pic = picture();
|
||||
let rows: Vec<&[[u8; 4]]> = pic.chunks(W).collect();
|
||||
let flat: Vec<u8> = pic.iter().flatten().copied().collect();
|
||||
check(&ff, "flat.hdr", &format!("-Y {H} +X {W}"), &flat);
|
||||
let rle: Vec<u8> = rows.iter().flat_map(|r| rle_line(r)).collect();
|
||||
check(&ff, "rle.hdr", &format!("-Y {H} +X {W}"), &rle);
|
||||
// Other axis orders are covered by unit tests: ffmpeg reads every file as top-down, which the
|
||||
// format description does not (+Y starts at the bottom row).
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! YCbCr TIFF stills (#1293): uncompressed and PackBits files with PhotometricInterpretation 6, as
|
||||
//! ffmpeg writes from YUV sources. The files are built here from TIFF 6.0 (§21 YCbCr images, §9
|
||||
//! PackBits), little-endian, one strip.
|
||||
|
||||
use filmcraft_media::still::decode_raster;
|
||||
|
||||
/// A YCbCr TIFF: `width` × `height`, subsampling `sub` (horizontal, vertical), `data` the strip
|
||||
/// as stored (data units, already compressed when `compression` is 32773), plus `extra` entries
|
||||
/// (tag, type, values).
|
||||
fn ycbcr_tiff(width: u32, height: u32, sub: (u16, u16), compression: u16, data: &[u8], extra: &[(u16, u16, Vec<u32>)]) -> Vec<u8> {
|
||||
let mut entries: Vec<(u16, u16, Vec<u32>)> = vec![
|
||||
(256, 4, vec![width]),
|
||||
(257, 4, vec![height]),
|
||||
(258, 3, vec![8, 8, 8]),
|
||||
(259, 3, vec![u32::from(compression)]),
|
||||
(262, 3, vec![6]),
|
||||
(273, 4, vec![0]), // patched below
|
||||
(277, 3, vec![3]),
|
||||
(278, 4, vec![height]),
|
||||
(279, 4, vec![data.len() as u32]),
|
||||
(530, 3, vec![u32::from(sub.0), u32::from(sub.1)]),
|
||||
];
|
||||
entries.extend(extra.iter().cloned());
|
||||
entries.sort_by_key(|e| e.0);
|
||||
let ifd_at = 8u32;
|
||||
let ifd_len = 2 + 12 * entries.len() as u32 + 4;
|
||||
let mut extra_data: Vec<u8> = Vec::new();
|
||||
let mut ifd: Vec<u8> = (entries.len() as u16).to_le_bytes().to_vec();
|
||||
let data_at = ifd_at + ifd_len;
|
||||
let mut values_at = data_at + data.len() as u32;
|
||||
for (tag, ty, vals) in &entries {
|
||||
let vals = if *tag == 273 { vec![data_at] } else { vals.clone() };
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
for v in &vals {
|
||||
match ty {
|
||||
3 => bytes.extend((*v as u16).to_le_bytes()),
|
||||
5 => bytes.extend(v.to_le_bytes()), // rationals are passed as num, den pairs
|
||||
_ => bytes.extend(v.to_le_bytes()),
|
||||
}
|
||||
}
|
||||
let count = if *ty == 5 { vals.len() / 2 } else { vals.len() };
|
||||
ifd.extend(tag.to_le_bytes());
|
||||
ifd.extend(ty.to_le_bytes());
|
||||
ifd.extend((count as u32).to_le_bytes());
|
||||
if bytes.len() <= 4 {
|
||||
bytes.resize(4, 0);
|
||||
ifd.extend(&bytes);
|
||||
} else {
|
||||
ifd.extend(values_at.to_le_bytes());
|
||||
values_at += bytes.len() as u32;
|
||||
extra_data.extend(bytes);
|
||||
}
|
||||
}
|
||||
ifd.extend(0u32.to_le_bytes());
|
||||
let mut out = b"II*\0".to_vec();
|
||||
out.extend(ifd_at.to_le_bytes());
|
||||
out.extend(ifd);
|
||||
out.extend(data);
|
||||
out.extend(extra_data);
|
||||
out
|
||||
}
|
||||
|
||||
fn px(r: &filmcraft_media::still::Raster, x: u32, y: u32) -> [u8; 4] {
|
||||
let i = ((y * r.width + x) * 4) as usize;
|
||||
[r.rgba[i], r.rgba[i + 1], r.rgba[i + 2], r.rgba[i + 3]]
|
||||
}
|
||||
|
||||
fn near(a: [u8; 4], b: [u8; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subsampled_ycbcr_places_each_data_unit() {
|
||||
// 4 × 2 at 2 × 2: two data units (four Y, then Cb, Cr). Left grey, right red (BT.601, the
|
||||
// default YCbCrCoefficients, full range: the default ReferenceBlackWhite).
|
||||
let data = [128, 128, 128, 128, 128, 128, 76, 76, 76, 76, 85, 255];
|
||||
let r = decode_raster("frame.tiff", &ycbcr_tiff(4, 2, (2, 2), 1, &data, &[])).unwrap();
|
||||
assert_eq!((r.width, r.height), (4, 2));
|
||||
for y in 0..2 {
|
||||
assert!(near(px(&r, 0, y), [128, 128, 128, 255]) && near(px(&r, 1, y), [128, 128, 128, 255]), "{:?}", px(&r, 0, y));
|
||||
assert!(near(px(&r, 2, y), [254, 0, 0, 255]) && near(px(&r, 3, y), [254, 0, 0, 255]), "{:?}", px(&r, 2, y));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packbits_ycbcr_with_video_range_reference_levels() {
|
||||
// 2 × 1 at 1 × 1 (4:4:4): white then black in video range (16–235, chroma 128 ± 112), the
|
||||
// ReferenceBlackWhite ffmpeg writes. PackBits: a literal run of 6 bytes.
|
||||
let units = [235, 128, 128, 16, 128, 128];
|
||||
let mut data = vec![5u8];
|
||||
data.extend(units);
|
||||
let rbw = vec![16, 1, 235, 1, 128, 1, 240, 1, 128, 1, 240, 1];
|
||||
let r = decode_raster("frame.tif", &ycbcr_tiff(2, 1, (1, 1), 32773, &data, &[(532, 5, rbw)])).unwrap();
|
||||
assert!(near(px(&r, 0, 0), [255, 255, 255, 255]), "{:?}", px(&r, 0, 0));
|
||||
assert!(near(px(&r, 1, 0), [0, 0, 0, 255]), "{:?}", px(&r, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_that_are_not_a_whole_number_of_data_units_are_padded() {
|
||||
// 3 × 3 at 2 × 2: the data units cover 4 × 4 (TIFF 6.0 §21), the extra column and row dropped.
|
||||
let unit = |y: u8| [y, y, y, y, 128, 128];
|
||||
let data: Vec<u8> = [unit(50), unit(100), unit(150), unit(200)].concat();
|
||||
let r = decode_raster("frame.tiff", &ycbcr_tiff(3, 3, (2, 2), 1, &data, &[])).unwrap();
|
||||
assert_eq!((r.width, r.height, r.rgba.len()), (3, 3, 36));
|
||||
assert!(near(px(&r, 0, 0), [50, 50, 50, 255]) && near(px(&r, 2, 0), [100, 100, 100, 255]));
|
||||
assert!(near(px(&r, 0, 2), [150, 150, 150, 255]) && near(px(&r, 2, 2), [200, 200, 200, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_ycbcr_tiffs_fail_without_panicking() {
|
||||
let data: Vec<u8> = (0..4 * 4 / 4 * 6).map(|i| (i * 37 % 256) as u8).collect();
|
||||
let good = ycbcr_tiff(4, 4, (2, 2), 1, &data, &[]);
|
||||
let mut cases: Vec<Vec<u8>> = (0..good.len()).map(|n| good[..n].to_vec()).collect();
|
||||
for i in 0..good.len() {
|
||||
for bit in [0, 3, 7] {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 1 << bit;
|
||||
cases.push(b);
|
||||
}
|
||||
}
|
||||
// A huge size, a zero subsampling factor, and a PackBits run past the strip.
|
||||
cases.push(ycbcr_tiff(65_535, 65_535, (2, 2), 1, &data, &[]));
|
||||
cases.push(ycbcr_tiff(4, 4, (0, 2), 1, &data, &[]));
|
||||
cases.push(ycbcr_tiff(4, 4, (2, 2), 32773, &[0x81, 7], &[]));
|
||||
for (i, b) in cases.into_iter().enumerate() {
|
||||
assert!(std::panic::catch_unwind(|| decode_raster("damaged.tiff", &b).map(|r| r.rgba.len())).is_ok(), "case {i} panicked");
|
||||
}
|
||||
}
|
||||
|
||||
/// ffmpeg (an external oracle) writes YCbCr TIFFs from YUV frames; our decode must match ffmpeg's
|
||||
/// own RGB decode of the same file. A smooth gradient, so the chroma upsampling (we repeat chroma
|
||||
/// across a data unit) does not dominate; 95 × 63 so the data units do not line up.
|
||||
#[test]
|
||||
fn ffmpeg_ycbcr_tiffs_match_ffmpeg_decode() {
|
||||
let Some(ff) = filmcraft_testkit::oracle::ffmpeg_or_skip("YCbCr TIFF oracle") else { return };
|
||||
let dir = filmcraft_testkit::fixtures::fixtures_dir("media/tiff_ycbcr");
|
||||
for pix_fmt in ["yuv420p", "yuv422p", "yuv444p"] {
|
||||
let tiff = dir.join(format!("gradient_{pix_fmt}.tiff"));
|
||||
let made = filmcraft_testkit::fixtures::generate(&tiff, |tmp| {
|
||||
std::process::Command::new(&ff)
|
||||
.args(["-y", "-v", "error", "-f", "lavfi", "-i", "gradients=size=95x63:seed=7", "-frames:v", "1", "-pix_fmt", pix_fmt])
|
||||
.arg(tmp)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
});
|
||||
let Some(tiff) = made else { continue };
|
||||
let out = std::process::Command::new(&ff).args(["-v", "error", "-i"]).arg(&tiff).args(["-f", "rawvideo", "-pix_fmt", "rgb24", "-"]).output().unwrap();
|
||||
let ours = decode_raster("gradient.tiff", &std::fs::read(&tiff).unwrap()).unwrap();
|
||||
assert_eq!((ours.width, ours.height), (95, 63), "{pix_fmt}");
|
||||
let theirs = out.stdout;
|
||||
assert_eq!(theirs.len(), 95 * 63 * 3, "{pix_fmt}");
|
||||
let worst =
|
||||
ours.rgba.as_chunks::<4>().0.iter().zip(theirs.as_chunks::<3>().0).flat_map(|(a, b)| (0..3).map(move |c| a[c].abs_diff(b[c]))).max().unwrap();
|
||||
assert!(worst <= 6, "{pix_fmt}: worst channel difference {worst}");
|
||||
}
|
||||
}
|
||||
+91
-6
@@ -91,6 +91,9 @@ 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,
|
||||
@@ -174,7 +177,6 @@ pub struct FrameInfo {
|
||||
|
||||
const ADTS_RATES: [u32; 13] = [96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, 7_350];
|
||||
|
||||
/// Parse the frame header at the start of `b` (MPEG audio, ADTS, AC-3, E-AC-3).
|
||||
/// Bytes of the audio frame whose header starts `b` (MPEG audio, ADTS, LATM, AC-3, E-AC-3), or
|
||||
/// `None` without a valid header there. For containers that store frames in chunks that need not
|
||||
/// follow them (AVI): the frames are found again with it.
|
||||
@@ -220,7 +222,8 @@ 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 {
|
||||
([24_000, 22_050, 16_000, 0][((b[4] >> 4) & 3) as usize], 6)
|
||||
// fscod2 3 is reserved (no sample rate)
|
||||
(*[24_000, 22_050, 16_000].get(((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])
|
||||
};
|
||||
@@ -230,7 +233,8 @@ 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 acmod = b[6] >> 5;
|
||||
let b6 = *b.get(6)?;
|
||||
let acmod = b6 >> 5;
|
||||
let mut bit = 3; // bits of byte 6 consumed
|
||||
if acmod & 1 != 0 && acmod != 1 {
|
||||
bit += 2;
|
||||
@@ -241,7 +245,7 @@ pub fn frame_info(codec: &Codec, b: &[u8]) -> Option<FrameInfo> {
|
||||
if acmod == 2 {
|
||||
bit += 2;
|
||||
}
|
||||
let word = u16::from_be_bytes([b[6], *b.get(7)?]);
|
||||
let word = u16::from_be_bytes([b6, *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 })
|
||||
}
|
||||
@@ -258,6 +262,18 @@ 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];
|
||||
|
||||
@@ -289,7 +305,8 @@ 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
|
||||
Some(((((b[2] & 7) as usize) << 8) | b[3] as usize) * 2 + 2)
|
||||
let len = ((((b[2] & 7) as usize) << 8) | b[3] as usize) * 2 + 2;
|
||||
(len >= EAC3_MIN_FRAME).then_some(len)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -309,6 +326,8 @@ impl Splitter {
|
||||
open: None,
|
||||
units: Vec::new(),
|
||||
skipped: 0,
|
||||
truncated: false,
|
||||
max_units: MAX_UNITS,
|
||||
header_start: None,
|
||||
seq_in_unit: false,
|
||||
gop: None,
|
||||
@@ -373,6 +392,10 @@ 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 });
|
||||
}
|
||||
@@ -382,7 +405,12 @@ impl Splitter {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.chunks.push_back((pos, self.es_pos));
|
||||
// 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));
|
||||
}
|
||||
if let Some(o) = self.open.as_mut()
|
||||
&& o.pos == u64::MAX
|
||||
&& o.start == self.es_pos
|
||||
@@ -700,4 +728,61 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ pub struct Unit {
|
||||
/// No other unit depends on it (B pictures of MPEG-1/2 video, non-reference H.264/HEVC).
|
||||
pub disposable: bool,
|
||||
pub size: u32,
|
||||
/// TS: index of the packet holding the first byte; PS: byte offset of its PES packet.
|
||||
/// TS: byte offset of the packet holding the first byte; PS: byte offset of its PES packet.
|
||||
pub(crate) pos: u64,
|
||||
/// Offset of the first byte within that packet's elementary-stream payload.
|
||||
pub(crate) offset: u32,
|
||||
@@ -284,6 +284,13 @@ 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> {
|
||||
@@ -294,7 +301,11 @@ 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()))?;
|
||||
let mut out = Vec::with_capacity(u.size as usize);
|
||||
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));
|
||||
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)?,
|
||||
@@ -345,3 +356,5 @@ impl Unwrap {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod work_tests;
|
||||
@@ -3,9 +3,6 @@
|
||||
/// A parsed PES header.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PesHeader {
|
||||
pub stream_id: u8,
|
||||
/// PES_packet_length (bytes after the length field; 0 = unbounded, video in TS).
|
||||
pub packet_length: usize,
|
||||
/// Bytes from the packet start code to the first payload byte.
|
||||
pub header_len: usize,
|
||||
/// Raw 33-bit timestamps.
|
||||
@@ -28,10 +25,8 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
|
||||
if b.len() < 6 || b[0] != 0 || b[1] != 0 || b[2] != 1 {
|
||||
return None;
|
||||
}
|
||||
let stream_id = b[3];
|
||||
let packet_length = u16::from_be_bytes([b[4], b[5]]) as usize;
|
||||
if plain_stream(stream_id) {
|
||||
return Some(PesHeader { stream_id, packet_length, header_len: 6, pts: None, dts: None });
|
||||
if plain_stream(b[3]) {
|
||||
return Some(PesHeader { header_len: 6, pts: None, dts: None });
|
||||
}
|
||||
if b.len() >= 9 && b[6] & 0xC0 == 0x80 {
|
||||
// ISO/IEC 13818-1 syntax
|
||||
@@ -47,7 +42,7 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
|
||||
dts = Some(timestamp(&b[14..19]));
|
||||
}
|
||||
}
|
||||
return Some(PesHeader { stream_id, packet_length, header_len, pts, dts });
|
||||
return Some(PesHeader { header_len, pts, dts });
|
||||
}
|
||||
// ISO/IEC 11172-1 syntax: stuffing, STD buffer, timestamps
|
||||
let mut p = 6;
|
||||
@@ -78,7 +73,7 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
|
||||
0 if c == 0x0F => p += 1,
|
||||
_ => return None,
|
||||
}
|
||||
Some(PesHeader { stream_id, packet_length, header_len: p, pts, dts })
|
||||
Some(PesHeader { header_len: p, pts, dts })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -98,7 +93,7 @@ mod tests {
|
||||
b.extend(ts_bytes(1, dts));
|
||||
b.push(0xAA);
|
||||
let h = parse(&b).unwrap();
|
||||
assert_eq!((h.stream_id, h.header_len, h.pts, h.dts), (0xE0, 19, Some(pts), Some(dts)));
|
||||
assert_eq!((h.header_len, h.pts, h.dts), (19, Some(pts), Some(dts)));
|
||||
// MPEG-1: stuffing, STD buffer, PTS only
|
||||
let mut b = vec![0, 0, 1, 0xC0, 0, 20, 0xFF, 0xFF, 0x40, 0x20];
|
||||
b.extend(ts_bytes(2, 90_000));
|
||||
|
||||
+114
-12
@@ -12,17 +12,33 @@ struct Win<'a, S: ByteSource + ?Sized> {
|
||||
src: &'a S,
|
||||
buf: Vec<u8>,
|
||||
start: u64,
|
||||
/// Bytes zero-filled by window refills (tests count them).
|
||||
#[cfg(test)]
|
||||
zero_filled: usize,
|
||||
}
|
||||
|
||||
impl<'a, S: ByteSource + ?Sized> Win<'a, S> {
|
||||
fn new(src: &'a S) -> Self {
|
||||
Self { src, buf: Vec::new(), start: 0 }
|
||||
Self {
|
||||
src,
|
||||
buf: Vec::new(),
|
||||
start: 0,
|
||||
#[cfg(test)]
|
||||
zero_filled: 0,
|
||||
}
|
||||
}
|
||||
/// The bytes [pos, pos + n) (fewer at the end of the file).
|
||||
fn get(&mut self, pos: u64, n: usize) -> Result<&[u8]> {
|
||||
let have = pos >= self.start && pos + n as u64 <= self.start + self.buf.len() as u64;
|
||||
if !have {
|
||||
let want = n.max(1 << 20);
|
||||
// never more than the file still holds past `pos`: `read_some` would not fill it, and
|
||||
// zeroing a megabyte for every packet near the end of the file is wasted work
|
||||
let remaining = self.src.len().saturating_sub(pos);
|
||||
let want = (n.max(1 << 20) as u64).min(remaining) as usize;
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.zero_filled += want.saturating_sub(self.buf.len());
|
||||
}
|
||||
self.buf.resize(want, 0);
|
||||
let got = read_some(self.src, pos, &mut self.buf)?;
|
||||
self.buf.truncate(got);
|
||||
@@ -94,6 +110,9 @@ fn packet_len(b: &[u8]) -> Option<usize> {
|
||||
let stuff = (*b.get(13)? & 7) as usize;
|
||||
Some(14 + stuff)
|
||||
} else if b[4] >> 4 == 2 {
|
||||
// MPEG-1 pack header: start code, 5-byte SCR, 3-byte mux rate. A file cut inside
|
||||
// it is not a pack header yet (`scan` reads the SCR from the first 9 bytes).
|
||||
b.get(..12)?;
|
||||
Some(12)
|
||||
} else {
|
||||
None
|
||||
@@ -116,13 +135,13 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, mpeg1: bool) -> Result<File>
|
||||
let mut scr: Option<(i64, i64)> = None;
|
||||
let mut resyncs = 0u64;
|
||||
while pos + 4 <= len {
|
||||
let head = win.get(pos, 32)?.to_vec();
|
||||
let Some(plen) = packet_len(&head) else {
|
||||
let head = win.get(pos, 32)?;
|
||||
let Some(plen) = packet_len(head) else {
|
||||
// resynchronise on the next pack header or PES start code
|
||||
resyncs += 1;
|
||||
let mut p = pos + 1;
|
||||
let found = loop {
|
||||
let w = win.get(p, 1 << 16)?.to_vec();
|
||||
let w = win.get(p, 1 << 16)?;
|
||||
if w.len() < 4 {
|
||||
break None;
|
||||
}
|
||||
@@ -159,7 +178,7 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, mpeg1: bool) -> Result<File>
|
||||
scr = Some(scr.map_or((v, v), |(a, b): (i64, i64)| (a.min(v), b.max(v))));
|
||||
} else if id == 0xBC {
|
||||
// program stream map: elementary stream id → stream_type
|
||||
let body = win.get(pos, plen)?.to_vec();
|
||||
let body = win.get(pos, plen)?;
|
||||
if body.len() >= 12 {
|
||||
let info = u16::from_be_bytes([body[8], body[9]]) as usize;
|
||||
let mut i = 10 + info;
|
||||
@@ -174,11 +193,11 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, mpeg1: bool) -> Result<File>
|
||||
}
|
||||
}
|
||||
} else if id == 0xBD || (0xC0..=0xEF).contains(&id) || id == 0xFD {
|
||||
let pkt = win.get(pos, plen)?.to_vec();
|
||||
let pkt = win.get(pos, plen)?;
|
||||
if pkt.len() < plen {
|
||||
break; // truncated
|
||||
}
|
||||
if let Some(h) = pes::parse(&pkt) {
|
||||
if let Some(h) = pes::parse(pkt) {
|
||||
let payload = &pkt[h.header_len.min(plen)..];
|
||||
let (sub, skip) = if id == 0xBD {
|
||||
match payload.first() {
|
||||
@@ -239,14 +258,14 @@ pub(crate) fn read<S: ByteSource + ?Sized>(src: &S, st: &Stream, u: &Unit, out:
|
||||
let len = src.len();
|
||||
let mut win = Win::new(src);
|
||||
while out.len() < need && pos + 4 <= len {
|
||||
let head = win.get(pos, 32)?.to_vec();
|
||||
let Some(plen) = packet_len(&head) else {
|
||||
let head = win.get(pos, 32)?;
|
||||
let Some(plen) = packet_len(head) else {
|
||||
// corrupt: stop at what we have
|
||||
return Ok(());
|
||||
};
|
||||
if head[3] == stream_id {
|
||||
let pkt = win.get(pos, plen)?.to_vec();
|
||||
if let Some(h) = pes::parse(&pkt) {
|
||||
let pkt = win.get(pos, plen)?;
|
||||
if let Some(h) = pes::parse(pkt) {
|
||||
let payload = &pkt[h.header_len.min(pkt.len())..];
|
||||
let matches = stream_id != 0xBD || payload.first().copied() == sub_id;
|
||||
if matches {
|
||||
@@ -266,3 +285,86 @@ pub(crate) fn read<S: ByteSource + ?Sized>(src: &S, st: &Stream, u: &Unit, out:
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The window refill before the zero-fill cap: always `max(n, 1 MiB)` bytes, counted when the
|
||||
/// buffer has to grow.
|
||||
struct OldWin<'a> {
|
||||
src: &'a [u8],
|
||||
buf: Vec<u8>,
|
||||
start: u64,
|
||||
zero_filled: usize,
|
||||
}
|
||||
|
||||
impl OldWin<'_> {
|
||||
fn get(&mut self, pos: u64, n: usize) -> Vec<u8> {
|
||||
let have = pos >= self.start && pos + n as u64 <= self.start + self.buf.len() as u64;
|
||||
if !have {
|
||||
let want = n.max(1 << 20);
|
||||
self.zero_filled += want.saturating_sub(self.buf.len());
|
||||
self.buf.resize(want, 0);
|
||||
let got = read_some(self.src, pos, &mut self.buf).unwrap();
|
||||
self.buf.truncate(got);
|
||||
self.start = pos;
|
||||
}
|
||||
let i = (pos - self.start) as usize;
|
||||
let end = (i + n).min(self.buf.len());
|
||||
self.buf[i.min(end)..end].to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
fn data(len: usize) -> Vec<u8> {
|
||||
(0..len).map(|i| (i * 31 + i / 7) as u8).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capped_window_returns_what_the_uncapped_window_returned() {
|
||||
for len in [0usize, 1, 31, 32, 33, 1000, (1 << 20) - 1, 1 << 20, (1 << 20) + 5, (3 << 20) + 17] {
|
||||
let d = data(len);
|
||||
let mut new = Win::new(d.as_slice());
|
||||
let mut old = OldWin { src: &d, buf: Vec::new(), start: 0, zero_filled: 0 };
|
||||
let len64 = len as u64;
|
||||
let mut positions = vec![
|
||||
0,
|
||||
1,
|
||||
31,
|
||||
32,
|
||||
1 << 19,
|
||||
1 << 20,
|
||||
(1 << 20) - 1,
|
||||
(1 << 20) + 1,
|
||||
2 << 20,
|
||||
len64.saturating_sub(40),
|
||||
len64.saturating_sub(1),
|
||||
len64,
|
||||
len64 + 1,
|
||||
len64 + 100,
|
||||
];
|
||||
positions.extend((0..40).map(|k| len64.saturating_sub(100) + k * 3));
|
||||
for &pos in &positions {
|
||||
for n in [0usize, 1, 4, 32, 33, 1000, 65_541, 1 << 20, (1 << 20) + 1, 2 << 20] {
|
||||
assert_eq!(new.get(pos, n).unwrap(), old.get(pos, n).as_slice(), "len {len} pos {pos} n {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misses_near_the_end_zero_at_most_the_bytes_that_are_left() {
|
||||
let len = (2 << 20) + 1000;
|
||||
let d = data(len);
|
||||
let mut new = Win::new(d.as_slice());
|
||||
let mut old = OldWin { src: &d, buf: Vec::new(), start: 0, zero_filled: 0 };
|
||||
// a PES packet whose length runs past the end of the file, asked for once per packet
|
||||
let first = (2u64 << 20) as usize;
|
||||
for k in 0..500 {
|
||||
let pos = (first + k) as u64;
|
||||
assert_eq!(new.get(pos, 2000).unwrap(), old.get(pos, 2000).as_slice());
|
||||
}
|
||||
assert!(new.zero_filled <= 1000, "zeroed {} bytes, only 1000 are left in the file", new.zero_filled);
|
||||
assert!(old.zero_filled > 499 * ((1 << 20) - 1000), "the reference zeroes about a megabyte per miss: {}", old.zero_filled);
|
||||
}
|
||||
}
|
||||
@@ -276,6 +276,50 @@ fn truncation_and_mutation_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `data` and reads every unit; errors are fine, panics are not.
|
||||
fn open_and_read_all(data: &[u8]) {
|
||||
let data = data.to_vec();
|
||||
if let Ok(f) = open(&data) {
|
||||
for (s, st) in f.streams.iter().enumerate() {
|
||||
for i in 0..st.units.len() {
|
||||
let _ = f.read_unit(&data, s, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_stream_cut_inside_a_pack_header_never_panics() {
|
||||
// MPEG-1 pack header cut after the 4-byte start code + 3 bytes (once panicked in `scan`).
|
||||
open_and_read_all(&[0, 0, 1, 0xBA, 0x21, 0, 0]);
|
||||
for n in 4..=12 {
|
||||
let mpeg1 = [0u8, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1];
|
||||
open_and_read_all(&mpeg1[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_stream_truncation_at_every_length_never_panics() {
|
||||
let frames: Vec<Vec<u8>> = (0..3).map(|k| mpeg2_frame(if k == 0 { 1 } else { 2 }, k as u16, false, 300)).collect();
|
||||
let es: Vec<u8> = frames.concat();
|
||||
for mpeg1 in [false, true] {
|
||||
let mut out = Vec::new();
|
||||
for (k, chunk) in es.chunks(500).enumerate() {
|
||||
if mpeg1 {
|
||||
out.extend([0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1]);
|
||||
} else {
|
||||
out.extend([0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 1, 0x89, 0xC3, 0xF8]);
|
||||
}
|
||||
out.extend(pes(0xE0, Some(3600 * k as i64), None, chunk, true));
|
||||
}
|
||||
out.extend([0, 0, 1, 0xB9]);
|
||||
assert!(open(&out).is_ok(), "the untruncated stream opens (mpeg1: {mpeg1})");
|
||||
for cut in 0..=out.len() {
|
||||
open_and_read_all(&out[..cut]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_unwrap_across_the_33_bit_wrap() {
|
||||
let mut u = Unwrap::default();
|
||||
@@ -286,3 +330,171 @@ 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
-29
@@ -6,14 +6,29 @@ use crate::es::Splitter;
|
||||
use crate::pes::{self, PesHeader};
|
||||
use crate::{ByteSource, Codec, Error, File, Format, Result, Stream, StreamId, Unit, Unwrap, read_some};
|
||||
|
||||
/// Byte-wise table for the MSB-first CRC-32 (polynomial 0x04C11DB7): entry `i` is the CRC register
|
||||
/// after shifting the byte `i` through eight bit steps.
|
||||
const CRC_TABLE: [u32; 256] = {
|
||||
let mut t = [0u32; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
let mut crc = (i as u32) << 24;
|
||||
let mut bit = 0;
|
||||
while bit < 8 {
|
||||
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
|
||||
bit += 1;
|
||||
}
|
||||
t[i] = crc;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// CRC-32 of PSI sections (MSB-first, polynomial 0x04C11DB7, initial value all ones; Annex A).
|
||||
pub(crate) fn crc32_mpeg(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
crc ^= (b as u32) << 24;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
|
||||
}
|
||||
crc = (crc << 8) ^ CRC_TABLE[((crc >> 24) as u8 ^ b) as usize];
|
||||
}
|
||||
crc
|
||||
}
|
||||
@@ -29,9 +44,19 @@ struct Packet<'a> {
|
||||
payload: &'a [u8],
|
||||
}
|
||||
|
||||
fn packet(p: &[u8]) -> Option<Packet<'_>> {
|
||||
/// Why a packet could not be parsed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Bad {
|
||||
/// No sync byte (or too short): the packet grid is lost.
|
||||
Sync,
|
||||
/// Sync byte present but the adaptation field overruns the packet: the grid is intact, the
|
||||
/// packet carries nothing usable.
|
||||
Adaptation,
|
||||
}
|
||||
|
||||
fn packet(p: &[u8]) -> std::result::Result<Packet<'_>, Bad> {
|
||||
if p.len() < 188 || p[0] != 0x47 {
|
||||
return None;
|
||||
return Err(Bad::Sync);
|
||||
}
|
||||
let pusi = p[1] & 0x40 != 0;
|
||||
let pid = (((p[1] & 0x1F) as u16) << 8) | p[2] as u16;
|
||||
@@ -44,7 +69,7 @@ fn packet(p: &[u8]) -> Option<Packet<'_>> {
|
||||
let al = p[4] as usize;
|
||||
start = 5 + al;
|
||||
if start > 188 {
|
||||
return None;
|
||||
return Err(Bad::Adaptation);
|
||||
}
|
||||
if al > 0 {
|
||||
let flags = p[5];
|
||||
@@ -57,7 +82,7 @@ fn packet(p: &[u8]) -> Option<Packet<'_>> {
|
||||
}
|
||||
}
|
||||
let has_payload = afc & 1 != 0;
|
||||
Some(Packet { pusi, pid, cc, has_payload, discontinuity, pcr, payload: if has_payload { &p[start..188] } else { &[] } })
|
||||
Ok(Packet { pusi, pid, cc, has_payload, discontinuity, pcr, payload: if has_payload { &p[start..188] } else { &[] } })
|
||||
}
|
||||
|
||||
/// Reassembles a PES header that may span packets; yields each packet's elementary-stream bytes.
|
||||
@@ -65,14 +90,25 @@ fn packet(p: &[u8]) -> Option<Packet<'_>> {
|
||||
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);
|
||||
}
|
||||
@@ -88,6 +124,7 @@ 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, &[]);
|
||||
}
|
||||
@@ -216,6 +253,39 @@ fn first_sync(head: &[u8], packet_size: usize, sync_offset: usize) -> Option<usi
|
||||
(0..packet_size.min(head.len())).find(|&s| (0..5).all(|k| head.get(s + sync_offset + k * packet_size) == Some(&0x47)))
|
||||
}
|
||||
|
||||
/// Find where packets line up again, searching `block` from byte `from`: the first packet start
|
||||
/// `i` whose sync byte and the next four packets' sync bytes are all 0x47. Returns `(i, false)`
|
||||
/// when found. Returns `(i, true)` when the block ran out first: `i` is where to refill from (a
|
||||
/// candidate whose five-packet window does not fit yet, or the last place a whole packet fits).
|
||||
/// At the end of the file (`at_end`) a candidate is accepted with as many sync bytes as exist.
|
||||
fn resync(block: &[u8], from: usize, packet_size: usize, sync_offset: usize, at_end: bool) -> (usize, bool) {
|
||||
let mut i = from;
|
||||
while i + packet_size <= block.len() {
|
||||
if block.get(i + sync_offset) == Some(&0x47) {
|
||||
let mut aligned = true;
|
||||
let mut complete = true;
|
||||
for k in 1..5 {
|
||||
match block.get(i + sync_offset + k * packet_size) {
|
||||
Some(&0x47) => {}
|
||||
Some(_) => {
|
||||
aligned = false;
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if aligned {
|
||||
return (i, !complete && !at_end);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
(i, true)
|
||||
}
|
||||
|
||||
pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_offset: usize) -> Result<File> {
|
||||
let len = src.len();
|
||||
let mut head = vec![0u8; packet_size * 8];
|
||||
@@ -236,30 +306,39 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
|
||||
let (mut lost_sync, mut cc_errors, mut crc_errors) = (0u64, 0u64, 0u64);
|
||||
const BLOCK: usize = 4096;
|
||||
let mut block = vec![0u8; BLOCK * packet_size];
|
||||
let mut bad_adaptation = 0u64;
|
||||
while off + packet_size as u64 <= len {
|
||||
let got = read_some(src, off, &mut block)?;
|
||||
let whole = got / packet_size;
|
||||
if whole == 0 {
|
||||
if got < packet_size {
|
||||
break;
|
||||
}
|
||||
let mut k = 0;
|
||||
while k < whole {
|
||||
let pos = off + (k * packet_size) as u64;
|
||||
let raw = &block[k * packet_size..(k + 1) * packet_size];
|
||||
let Some(pk) = packet(&raw[sync_offset..]) else {
|
||||
// lost sync: find the next offset where packets line up again
|
||||
lost_sync += 1;
|
||||
let rest = &block[k * packet_size + 1..got];
|
||||
match first_sync(rest, packet_size, sync_offset).or_else(|| rest.iter().position(|&b| b == 0x47).map(|p| p.saturating_sub(sync_offset))) {
|
||||
Some(s) => {
|
||||
off = pos + 1 + s as u64;
|
||||
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;
|
||||
}
|
||||
None => off = pos + got as u64,
|
||||
continue;
|
||||
}
|
||||
k = usize::MAX;
|
||||
break;
|
||||
};
|
||||
k += 1;
|
||||
p += packet_size;
|
||||
if let Some(pcr) = pk.pcr {
|
||||
// 33-bit base × 300 wraps at 2^33·300
|
||||
let wrap = (1i64 << 33) * 300;
|
||||
@@ -362,13 +441,15 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
|
||||
}
|
||||
e.split.feed(pos, bytes);
|
||||
}
|
||||
if k != usize::MAX {
|
||||
off += (whole * packet_size) as u64;
|
||||
}
|
||||
// `p >= 1` here (the loop ran at least once or `resync` moved past the failed packet)
|
||||
off += p.max(1) as u64;
|
||||
}
|
||||
if lost_sync > 0 {
|
||||
warnings.push(format!("lost packet sync {lost_sync} time(s)"));
|
||||
}
|
||||
if bad_adaptation > 0 {
|
||||
warnings.push(format!("{bad_adaptation} packet(s) with an invalid adaptation field"));
|
||||
}
|
||||
if cc_errors > 0 {
|
||||
warnings.push(format!("{cc_errors} continuity counter error(s)"));
|
||||
}
|
||||
@@ -384,6 +465,9 @@ 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 })
|
||||
@@ -406,7 +490,11 @@ pub(crate) fn read<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
|
||||
}
|
||||
for k in 0..whole {
|
||||
let raw = &block[k * packet_size..(k + 1) * packet_size];
|
||||
let Some(pk) = packet(&raw[sync_offset..]) else { return Ok(()) };
|
||||
let pk = match packet(&raw[sync_offset..]) {
|
||||
Ok(pk) => pk,
|
||||
Err(Bad::Sync) => return Ok(()),
|
||||
Err(Bad::Adaptation) => continue,
|
||||
};
|
||||
if pk.pid != pid {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Equality and counting tests for the redundant-work removals: the old implementation is kept
|
||||
//! here as the reference, the new one must give the same results.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The bitwise CRC-32 that `ts::crc32_mpeg` replaced.
|
||||
fn crc32_bitwise(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
crc ^= (b as u32) << 24;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Deterministic xorshift bytes.
|
||||
fn noise(seed: u32, n: usize) -> Vec<u8> {
|
||||
let mut x = seed | 1;
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
(x >> 8) as u8
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_crc_equals_the_bitwise_crc() {
|
||||
// the standard check value of CRC-32/MPEG-2
|
||||
assert_eq!(ts::crc32_mpeg(b"123456789"), 0x0376_E6E7);
|
||||
assert_eq!(ts::crc32_mpeg(&[]), 0xFFFF_FFFF);
|
||||
for b in 0..=255u8 {
|
||||
assert_eq!(ts::crc32_mpeg(&[b]), crc32_bitwise(&[b]));
|
||||
}
|
||||
for n in [0usize, 1, 2, 3, 4, 5, 15, 16, 17, 183, 184, 1023, 1024, 4096] {
|
||||
for seed in 1..6u32 {
|
||||
let d = noise(seed * 7919 + n as u32, n);
|
||||
assert_eq!(ts::crc32_mpeg(&d), crc32_bitwise(&d), "len {n} seed {seed}");
|
||||
}
|
||||
}
|
||||
// a section followed by its own CRC checks to zero
|
||||
let mut sec = noise(5, 40);
|
||||
let crc = ts::crc32_mpeg(&sec);
|
||||
sec.extend(crc.to_be_bytes());
|
||||
assert_eq!(ts::crc32_mpeg(&sec), 0);
|
||||
}
|
||||
|
||||
/// MPEG-1 layer II frame, 48 kHz 192 kb/s (576 bytes), distinct per `k` and `salt`.
|
||||
fn mp2_frame(k: usize, salt: u8) -> Vec<u8> {
|
||||
let mut v = vec![0xFF, 0xFD, 0xA4, 0x00];
|
||||
v.extend((0..572).map(|i| (i as u8) ^ (k as u8) ^ salt));
|
||||
v
|
||||
}
|
||||
|
||||
fn pes_packet(stream_id: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let len = 3 + payload.len();
|
||||
let mut v = vec![0, 0, 1, stream_id, (len >> 8) as u8, len as u8, 0x80, 0, 0];
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
/// Two audio streams interleaved in maximal PES packets over more than three window refills,
|
||||
/// with a pack header per PES packet; MPEG-2 or MPEG-1 pack headers.
|
||||
fn big_program_stream(mpeg1: bool, frames: usize) -> (Vec<u8>, Vec<Vec<u8>>, Vec<Vec<u8>>) {
|
||||
let a: Vec<Vec<u8>> = (0..frames).map(|k| mp2_frame(k, 0)).collect();
|
||||
let b: Vec<Vec<u8>> = (0..frames).map(|k| mp2_frame(k, 0x55)).collect();
|
||||
let (ea, eb) = (a.concat(), b.concat());
|
||||
let mut out = Vec::new();
|
||||
for (ca, cb) in ea.chunks(60_000).zip(eb.chunks(60_000)) {
|
||||
for (id, chunk) in [(0xC0, ca), (0xC1, cb)] {
|
||||
if mpeg1 {
|
||||
out.extend([0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1]);
|
||||
} else {
|
||||
out.extend([0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 1, 0x89, 0xC3, 0xF8]);
|
||||
}
|
||||
out.extend(pes_packet(id, chunk));
|
||||
}
|
||||
}
|
||||
out.extend([0, 0, 1, 0xB9]);
|
||||
(out, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_stream_with_large_pes_packets_gives_back_every_frame() {
|
||||
for mpeg1 in [false, true] {
|
||||
let (data, a, b) = big_program_stream(mpeg1, 2000); // about 1.2 MB per stream
|
||||
assert!(data.len() > 2 << 20, "more than the 1 MiB window twice");
|
||||
let f = open(&data).unwrap();
|
||||
assert_eq!(f.format, Format::Ps { mpeg1 });
|
||||
assert_eq!(f.streams.len(), 2);
|
||||
for (s, frames) in f.streams.iter().zip([&a, &b]) {
|
||||
assert_eq!(s.units.len(), frames.len());
|
||||
for (i, fr) in frames.iter().enumerate() {
|
||||
assert_eq!(&f.read_unit(&data, f.streams.iter().position(|x| std::ptr::eq(x, s)).unwrap(), i).unwrap(), fr, "mpeg1 {mpeg1} unit {i}");
|
||||
}
|
||||
}
|
||||
// cut inside the last packets: never panics, and the units before the cut are intact
|
||||
for cut in [data.len() - 1, data.len() - 4, data.len() - 30_000, data.len() - 61_000, 100_000, 1 << 20] {
|
||||
let d = &data[..cut];
|
||||
let Ok(f) = open(d) else { continue };
|
||||
for (si, frames) in [&a, &b].into_iter().enumerate() {
|
||||
let Some(s) = f.streams.get(si) else { continue };
|
||||
for i in 0..s.units.len().saturating_sub(1) {
|
||||
assert_eq!(f.read_unit(d, si, i).unwrap(), frames[i], "cut {cut} stream {si} unit {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,9 +97,10 @@ fn changed_in_band_parameter_sets_switch_to_software() {
|
||||
let out = decode_all(&mut d, &same);
|
||||
assert!(d.is_hardware());
|
||||
assert_same("repeated SPS", &out, &decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &same));
|
||||
// a different SPS (same values, trailing zero byte) at the second sync sample: software
|
||||
// a different SPS (same values, another nal_ref_idc: a trailing zero byte is not a change) at
|
||||
// the second sync sample: software
|
||||
let mut changed_sps = sps.clone();
|
||||
changed_sps.push(0);
|
||||
changed_sps[0] ^= 0x40;
|
||||
let k = (1..s.samples.len()).find(|&i| s.sync[i]).unwrap();
|
||||
let changed: Vec<_> = s.samples.iter().enumerate().map(|(i, (x, p))| (if i == k { with_sps(&changed_sps, x) } else { x.clone() }, *p)).collect();
|
||||
let mut d = hybrid(&s, usize::MAX);
|
||||
|
||||
@@ -156,6 +156,26 @@ fn motion_and_masks_restrict_the_region() {
|
||||
assert!(near(img.get(280, 90), [1.0, 0.0, 0.0], 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_replaces_the_picture_below() {
|
||||
// Premiere: Transform on an adjustment layer moves the composite below; it is not a copy
|
||||
// drawn over the untouched original (#1524).
|
||||
let mut s = scene(2);
|
||||
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
|
||||
place(&mut s.p, s.seq, 0, red);
|
||||
let adj = adjustment(&mut s.p, s.seq, 1);
|
||||
fx(&mut s, adj, "transform");
|
||||
let tf = clip(&mut s, adj).effects.iter_mut().find(|e| e.effect == "transform").unwrap();
|
||||
tf.params.get_mut("scale_height").unwrap().value = ParamValue::Float(50.0);
|
||||
let img = render(&s);
|
||||
assert!(near(img.get(160, 90), [1.0, 0.0, 0.0], 1e-4), "{:?}", img.get(160, 90));
|
||||
assert_eq!(img.get(10, 10), [0.0; 4], "the original is gone outside the scaled picture");
|
||||
// at 50 % opacity: half the original, half the (empty) transformed picture
|
||||
clip(&mut s, adj).effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().value = ParamValue::Float(50.0);
|
||||
let c = render(&s).get(10, 10);
|
||||
assert!((c[0] - 0.5).abs() < 1e-3 && (c[3] - 0.5).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_adjustment_layers_stay_in_their_sequence() {
|
||||
let mut s = scene(2);
|
||||
@@ -184,3 +204,121 @@ fn nested_adjustment_layers_stay_in_their_sequence() {
|
||||
};
|
||||
assert_eq!(img.get(5, 5), plain.get(5, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_shutter_on_an_adjustment_layer_spans_one_sequence_frame() {
|
||||
// A 60 fps sequence: at 360° the shutter is one 60 fps frame, so the frames just outside
|
||||
// the move stay sharp (they were blurred with a 24 fps shutter, #1088).
|
||||
use filmcraft_project::{Interpolation, Keyframe};
|
||||
let r60 = FrameRate::FPS_60;
|
||||
let mut s = scene(2);
|
||||
s.p.sequence_mut(s.seq).unwrap().settings.frame_rate = r60;
|
||||
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
|
||||
let below = place(&mut s.p, s.seq, 0, red);
|
||||
clip(&mut s, below).effect_mut("motion").unwrap().params.get_mut("scale").unwrap().value = ParamValue::Float(25.0);
|
||||
let adj = adjustment(&mut s.p, s.seq, 1);
|
||||
fx(&mut s, adj, "transform");
|
||||
let key = |sec: f64, x: f64| {
|
||||
let mut k = Keyframe::new(Tick::from_seconds_f64(sec), ParamValue::Vec2(filmcraft_geom::Vec2::new(x, 90.0)));
|
||||
k.interp = Interpolation::Linear;
|
||||
k
|
||||
};
|
||||
let e = clip(&mut s, adj).effect_mut("transform").unwrap();
|
||||
e.params.get_mut("shutter_angle").unwrap().value = ParamValue::Float(360.0);
|
||||
e.params.get_mut("position").unwrap().keyframes = vec![key(0.5, 160.0), key(1.0, 260.0)];
|
||||
let mut diff = |frame: i64| {
|
||||
let mut at = |shutter: bool| {
|
||||
clip(&mut s, adj).effect_mut("transform").unwrap().params.get_mut("shutter_override").unwrap().value = ParamValue::Bool(shutter);
|
||||
render_sequence(&s.p, s.seq, r60.tick_of(frame), RenderOptions::default(), &s.map)
|
||||
};
|
||||
let (sharp, blurred) = (at(false), at(true));
|
||||
sharp.px.iter().zip(&blurred.px).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max)
|
||||
};
|
||||
assert!(diff(29) < 1e-4, "frame before the move is blurred");
|
||||
assert!(diff(61) < 1e-4, "frame after the move is blurred");
|
||||
assert!(diff(45) > 1e-2, "moving frame is sharp");
|
||||
}
|
||||
|
||||
fn dissolve(s: &mut Scene, track: usize, from: Option<ClipId>, to: Option<ClipId>, start: i64) {
|
||||
let tr = filmcraft_project::Transition {
|
||||
id: filmcraft_project::TransitionId(900 + start as u64),
|
||||
effect: find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: R.tick_of(start),
|
||||
duration: R.tick_of(12),
|
||||
from,
|
||||
to,
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
};
|
||||
s.p.sequence_mut(s.seq).unwrap().video_tracks[track].transitions.push(tr);
|
||||
}
|
||||
|
||||
fn at(s: &Scene, frame: i64) -> [f32; 4] {
|
||||
render_sequence(&s.p, s.seq, R.tick_of(frame), RenderOptions::default(), &s.map).get(160, 90)
|
||||
}
|
||||
|
||||
/// A dissolve at the start of an adjustment layer (Cmd+D) fades its effects in, and one at its
|
||||
/// end fades them out, instead of leaving them off for the transition and cutting in after it.
|
||||
#[test]
|
||||
fn a_dissolve_fades_the_adjustment_layers_effects_in_and_out() {
|
||||
let mut s = scene(2);
|
||||
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
|
||||
place(&mut s.p, s.seq, 0, red);
|
||||
let adj = adjustment(&mut s.p, s.seq, 1);
|
||||
fx(&mut s, adj, "invert");
|
||||
dissolve(&mut s, 1, None, Some(adj), 0);
|
||||
dissolve(&mut s, 1, Some(adj), None, 36);
|
||||
// fade in over frames 0..12: red → halfway → cyan
|
||||
assert!(near(at(&s, 0), [1.0, 0.0, 0.0], 1e-3), "{:?}", at(&s, 0));
|
||||
assert!(near(at(&s, 3), [0.75, 0.25, 0.25], 1e-3), "a quarter in: {:?}", at(&s, 3));
|
||||
assert!(near(at(&s, 6), [0.5, 0.5, 0.5], 1e-3), "halfway in: {:?}", at(&s, 6));
|
||||
assert!(near(at(&s, 24), [0.0, 1.0, 1.0], 1e-3), "{:?}", at(&s, 24));
|
||||
// fade out over frames 36..48
|
||||
assert!(near(at(&s, 42), [0.5, 0.5, 0.5], 1e-3), "halfway out: {:?}", at(&s, 42));
|
||||
assert!(near(at(&s, 45), [0.75, 0.25, 0.25], 1e-3), "three quarters out: {:?}", at(&s, 45));
|
||||
}
|
||||
|
||||
/// The layer's own opacity and blend mode still count during the fade.
|
||||
#[test]
|
||||
fn a_dissolve_on_an_adjustment_layer_keeps_its_opacity_and_blend_mode() {
|
||||
let mut s = scene(2);
|
||||
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
|
||||
place(&mut s.p, s.seq, 0, red);
|
||||
let adj = adjustment(&mut s.p, s.seq, 1);
|
||||
fx(&mut s, adj, "invert");
|
||||
clip(&mut s, adj).effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().value = ParamValue::Float(50.0);
|
||||
dissolve(&mut s, 1, None, Some(adj), 0);
|
||||
// halfway through the fade, at 50 % opacity: a quarter of the way to cyan
|
||||
assert!(near(at(&s, 6), [0.75, 0.25, 0.25], 1e-3), "{:?}", at(&s, 6));
|
||||
// Multiply (red × cyan = black) fades from red to black
|
||||
let op = clip(&mut s, adj).effect_mut("opacity").unwrap();
|
||||
op.params.get_mut("opacity").unwrap().value = ParamValue::Float(100.0);
|
||||
op.params.get_mut("blend").unwrap().value =
|
||||
ParamValue::Choice(filmcraft_project::effect::BLEND_MODES.iter().position(|b| *b == "Multiply").unwrap() as u32);
|
||||
assert!(near(at(&s, 6), [0.5, 0.0, 0.0], 1e-3), "{:?}", at(&s, 6));
|
||||
assert!(near(at(&s, 24), [0.0, 0.0, 0.0], 1e-3), "{:?}", at(&s, 24));
|
||||
}
|
||||
|
||||
/// A frame where a transition touches an adjustment layer is composited on the CPU, which knows
|
||||
/// how to fade one; the layered (GPU) plan does not.
|
||||
#[test]
|
||||
fn a_transition_with_an_adjustment_layer_is_not_planned_as_layers() {
|
||||
let mut s = scene(2);
|
||||
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
|
||||
let blue = matte(&mut s.p, &mut s.map, [0.0, 0.0, 1.0, 1.0]);
|
||||
place(&mut s.p, s.seq, 0, red);
|
||||
// V2: a blue clip from 0 to 24, then an adjustment layer from 24, a dissolve centred on the cut
|
||||
let b = place(&mut s.p, s.seq, 1, blue);
|
||||
clip(&mut s, b).duration = R.tick_of(24);
|
||||
let a = s.p.add_item("Adjustment Layer", Label::Lavender, ItemKind::AdjustmentLayer { width: 320, height: 180, rate: R, duration: R.tick_of(48) }, None);
|
||||
let mut ti = s.p.make_track_item(a, TrackKind::Video, R.tick_of(24), TimeRange::new(Tick::ZERO, R.tick_of(24)), R).unwrap();
|
||||
ti.effects.insert(0, find_effect("invert").unwrap().instance());
|
||||
let ac = ti.id;
|
||||
s.p.sequence_mut(s.seq).unwrap().video_tracks[1].items.push(ti);
|
||||
dissolve(&mut s, 1, Some(b), Some(ac), 18);
|
||||
let q = s.p.sequence(s.seq).unwrap();
|
||||
// frame 20 is still on the blue clip, but the dissolve into the adjustment layer shows
|
||||
assert!(!crate::plan::layered_at(&s.p, q, R.tick_of(20)));
|
||||
// halfway (frame 24): half blue, half the inverted red (cyan)
|
||||
assert!(near(at(&s, 24), [0.0, 0.5, 1.0], 1e-3), "{:?}", at(&s, 24));
|
||||
}
|
||||
@@ -109,9 +109,11 @@ pub(crate) fn track_input_at(
|
||||
if !tr.range().overlaps(&range) {
|
||||
continue;
|
||||
}
|
||||
// Re-mix: recompute the covered samples with crossfade gains.
|
||||
let from = tr.from.and_then(|id| track.item(id));
|
||||
let to = tr.to.and_then(|id| track.item(id));
|
||||
// Re-mix: recompute the covered samples with crossfade gains. Both sides play over the
|
||||
// whole transition, into their handles as the picture does, so a transition centred on a
|
||||
// cut overlaps the two clips instead of only fading each on its own side of the cut.
|
||||
let from = tr.from.and_then(|id| track.item(id)).map(|i| with_handles(i, tr.start, tr.end()));
|
||||
let to = tr.to.and_then(|id| track.item(id)).map(|i| with_handles(i, tr.start, tr.end()));
|
||||
let s0 = tr.start.to_units_floor(sr as i64).max(start);
|
||||
let s1 = tr.end().to_units_floor(sr as i64).min(start + frames as i64);
|
||||
if s1 <= s0 {
|
||||
@@ -124,10 +126,10 @@ pub(crate) fn track_input_at(
|
||||
}
|
||||
let mut a = AudioBuffer::silence(sr, w, n);
|
||||
let mut b = AudioBuffer::silence(sr, w, n);
|
||||
if let Some(f) = from {
|
||||
if let Some(f) = &from {
|
||||
mix_item(project, f, s0, n, sr, sources, &mut a, 1.0, &live);
|
||||
}
|
||||
if let Some(tt) = to {
|
||||
if let Some(tt) = &to {
|
||||
mix_item(project, tt, s0, n, sr, sources, &mut b, 1.0, &live);
|
||||
}
|
||||
let dur = (tr.end() - tr.start).to_units_floor(sr as i64).max(1) as f32;
|
||||
@@ -143,6 +145,25 @@ pub(crate) fn track_input_at(
|
||||
tbuf
|
||||
}
|
||||
|
||||
/// `item` lengthened to cover `[t0, t1)` with its media timing unchanged: the part before its in
|
||||
/// point and after its out point plays the source's handles (silent where the source has none).
|
||||
/// Remixed clips have no linear media to extend and stay as they are.
|
||||
fn with_handles(item: &TrackItem, t0: Tick, t1: Tick) -> TrackItem {
|
||||
let mut ext = item.clone();
|
||||
if crate::remix::is_remixed(item) {
|
||||
return ext;
|
||||
}
|
||||
let head = (item.start - t0).max(Tick::ZERO);
|
||||
let tail = (t1 - item.end()).max(Tick::ZERO);
|
||||
ext.start = item.start - head;
|
||||
ext.duration = item.duration + head + tail;
|
||||
// forward media time is source_in + (t - start)·speed; reversed it counts down from the out
|
||||
// point, so the extra head (forward) or tail (reverse) moves source_in back
|
||||
let shift = if item.reverse { tail } else { head };
|
||||
ext.source_in = item.source_in - Tick((shift.0 as f64 * item.speed.abs()).round() as i64);
|
||||
ext
|
||||
}
|
||||
|
||||
/// Held Clip Mixer controls of one track.
|
||||
#[derive(Default)]
|
||||
struct ClipLive {
|
||||
|
||||
@@ -192,6 +192,7 @@ fn graded_stats(img: &Image, base: &EffectInstance, x: &[f32; 10], skin_protect:
|
||||
clip_name: "",
|
||||
project: None,
|
||||
env: None,
|
||||
frame_rate: 24.0,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
};
|
||||
apply(&mut g, &set_params(base, x), &cx);
|
||||
@@ -361,6 +362,7 @@ mod tests {
|
||||
clip_name: "",
|
||||
project: None,
|
||||
env: None,
|
||||
frame_rate: 24.0,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
};
|
||||
apply(&mut g, &set_params(base, &x), &cx);
|
||||
|
||||
@@ -29,6 +29,8 @@ pub struct FxCtx<'a> {
|
||||
/// The clip's surroundings (other frames, other tracks, sequence geometry) for temporal,
|
||||
/// track-reading and reframing effects; `None` in isolated tests and adjustment layers.
|
||||
pub env: Option<&'a dyn crate::vfx::FxEnv>,
|
||||
/// Sequence frame rate (shutter-angle motion blur); also known where `env` is `None`.
|
||||
pub frame_rate: f64,
|
||||
/// The sequence's working colour space: Lumetri grades HDR (PQ / HLG) working spaces in their
|
||||
/// own signal, normalised to HDR White ([`filmcraft_color::GradeSpace`]).
|
||||
pub working: filmcraft_color::WorkingSpace,
|
||||
@@ -934,6 +936,7 @@ mod tests {
|
||||
clip_name: "x",
|
||||
project: None,
|
||||
env: None,
|
||||
frame_rate: 24.0,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
}
|
||||
}
|
||||
@@ -1529,6 +1532,7 @@ mod lumetri_cpu_parity_tests {
|
||||
clip_name: "",
|
||||
project: None,
|
||||
env: None,
|
||||
frame_rate: 24.0,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
};
|
||||
|
||||
@@ -1568,6 +1572,7 @@ mod lumetri_cpu_parity_tests {
|
||||
clip_name: "",
|
||||
project: None,
|
||||
env: None,
|
||||
frame_rate: 24.0,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
};
|
||||
let render = |roundness: f64| {
|
||||
|
||||
@@ -355,7 +355,9 @@ impl FxOp {
|
||||
}
|
||||
}
|
||||
"sharpen" => {
|
||||
let r = cx.px_scale.max(0.35);
|
||||
// below a sigma of ~0.58 the three boxes all round to radius 0 and the mask is a
|
||||
// no-op, so Sharpen vanished at 1/2 playback resolution and below (#1351)
|
||||
let r = cx.px_scale.max(0.6);
|
||||
let (rx, ry) = gaussian_boxes(w, h, r, r);
|
||||
FxOp::Unsharp { rx, ry, amount: f(e, "amount", cx) / 100.0, threshold: 0.0 }
|
||||
}
|
||||
@@ -1113,6 +1115,7 @@ mod tests {
|
||||
clip_name: "",
|
||||
project: None,
|
||||
env: None,
|
||||
frame_rate: 24.0,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
};
|
||||
// Both sides of each cutoff, including samples inside the former artificial fade.
|
||||
|
||||
+76
-40
@@ -189,14 +189,25 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
|
||||
if let Some(tr) = track.transitions.iter().find(|tr| tr.range().contains(t)) {
|
||||
let a = tr.from.and_then(|id| track.item(id)).filter(|i| i.enabled);
|
||||
let b = tr.to.and_then(|id| track.item(id)).filter(|i| i.enabled);
|
||||
let la = a
|
||||
.and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc))
|
||||
.map(|(img, op, _)| with_opacity(img, op))
|
||||
.unwrap_or_else(|| Image::new(w, h));
|
||||
let lb = b
|
||||
.and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc))
|
||||
.map(|(img, op, _)| with_opacity(img, op))
|
||||
.unwrap_or_else(|| Image::new(w, h));
|
||||
// An adjustment layer has no picture of its own: in a transition it is the frame below
|
||||
// with its effects, so a dissolve fades the effects in or out.
|
||||
let layer = |i: &TrackItem, below: &Image| -> Option<(Image, Blend)> {
|
||||
if is_adjustment(project, i) {
|
||||
return opts.effects.then(|| adjustment_layer(project, seq, i, t, opts, &tc, below));
|
||||
}
|
||||
item_layer(project, seq, i, t, opts, sources, &tc).map(|(img, op, bl)| (with_opacity(img, op), bl))
|
||||
};
|
||||
let below = canvas.snapshot();
|
||||
let la = a.and_then(|i| layer(i, &below));
|
||||
let lb = b.and_then(|i| layer(i, &below));
|
||||
// a fade from or to nothing keeps the one layer's blend mode (an adjustment layer in
|
||||
// Multiply ends the fade as it carries on); between two layers they mix normally
|
||||
let bl = match (&la, &lb) {
|
||||
(Some((_, x)), None) | (None, Some((_, x))) => *x,
|
||||
_ => Blend::Normal,
|
||||
};
|
||||
let la = la.map(|l| l.0).unwrap_or_else(|| Image::new(w, h));
|
||||
let lb = lb.map(|l| l.0).unwrap_or_else(|| Image::new(w, h));
|
||||
let p = tr.progress(t) as f32;
|
||||
// Reverse plays the transition backwards (e.g. an iris closing on the outgoing clip)
|
||||
// while still going from A to B.
|
||||
@@ -205,48 +216,27 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
|
||||
} else {
|
||||
transitions::apply_scaled(&tr.effect, &la, &lb, p, opts.scale)
|
||||
};
|
||||
blend::composite(canvas.get(), &mixed, 1.0, Blend::Normal);
|
||||
blend::composite(canvas.get(), &mixed, 1.0, bl);
|
||||
continue;
|
||||
}
|
||||
let Some(item) = track.item_at(t) else { continue };
|
||||
if !item.enabled {
|
||||
continue;
|
||||
}
|
||||
let pi = project.item(item.item);
|
||||
if let Some(pi) = pi
|
||||
&& matches!(pi.kind, ItemKind::AdjustmentLayer { .. })
|
||||
{
|
||||
if is_adjustment(project, item) {
|
||||
if !opts.effects {
|
||||
continue;
|
||||
}
|
||||
let mt = item.effect_time_at(t);
|
||||
let mut adjusted = canvas.snapshot();
|
||||
let cx = effects::FxCtx {
|
||||
t: mt,
|
||||
px_scale: opts.scale,
|
||||
seconds: (t - item.start).seconds(),
|
||||
timecode: &tc,
|
||||
clip_name: &item.name,
|
||||
project: Some(project),
|
||||
env: None,
|
||||
working: seq.settings.color.working,
|
||||
};
|
||||
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
|
||||
mask::apply_effect(&mut adjusted, e, &cx);
|
||||
let below = canvas.snapshot();
|
||||
let (adj, bl) = adjustment_layer(project, seq, item, t, opts, &tc, &below);
|
||||
if bl == Blend::Normal {
|
||||
// The adjusted picture already replaces the original (see `adjustment_layer`).
|
||||
canvas.set(adj);
|
||||
} else {
|
||||
let mut out = below;
|
||||
blend::composite(&mut out, &adj, 1.0, bl);
|
||||
canvas.set(out);
|
||||
}
|
||||
let (op, bl) = opacity_blend(item, mt);
|
||||
// Adjustment layer opacity (and opacity masks, in sequence pixels) mix adjusted over original.
|
||||
let mut out = canvas.snapshot();
|
||||
let mut adj = adjusted;
|
||||
adj.scale_alpha(op);
|
||||
mask::apply_opacity_masks(&mut adj, item, mt, opts.scale);
|
||||
// Motion moves / scales the adjustment layer's frame: it only applies inside it.
|
||||
if let Some(region) = adjustment_region(seq, item, project, mt, opts.scale, w, h) {
|
||||
let cov: Vec<f32> = region.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
|
||||
mask::scale_by(&mut adj, &cov);
|
||||
}
|
||||
blend::composite(&mut out, &adj, 1.0, bl);
|
||||
canvas.set(out);
|
||||
continue;
|
||||
}
|
||||
match item_layer_ex(project, seq, item, t, opts, sources, &tc, true) {
|
||||
@@ -279,6 +269,51 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
|
||||
canvas
|
||||
}
|
||||
|
||||
fn is_adjustment(project: &Project, item: &TrackItem) -> bool {
|
||||
project.item(item.item).is_some_and(|pi| matches!(pi.kind, ItemKind::AdjustmentLayer { .. }))
|
||||
}
|
||||
|
||||
/// An adjustment layer as a layer over `below` (the composite of the tracks under it): `below`
|
||||
/// with the layer's effects, faded by its opacity and opacity masks and limited to its
|
||||
/// (Motion-transformed) frame, and the blend mode it mixes with.
|
||||
fn adjustment_layer(project: &Project, seq: &Sequence, item: &TrackItem, t: Tick, opts: RenderOptions, tc: &str, below: &Image) -> (Image, Blend) {
|
||||
let (w, h) = (below.w, below.h);
|
||||
let mt = item.effect_time_at(t);
|
||||
let mut adj = below.clone();
|
||||
let cx = effects::FxCtx {
|
||||
t: mt,
|
||||
px_scale: opts.scale,
|
||||
seconds: (t - item.start).seconds(),
|
||||
timecode: tc,
|
||||
clip_name: &item.name,
|
||||
project: Some(project),
|
||||
env: None,
|
||||
frame_rate: seq.settings.frame_rate.as_f64(),
|
||||
working: seq.settings.color.working,
|
||||
};
|
||||
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
|
||||
mask::apply_effect(&mut adj, e, &cx);
|
||||
}
|
||||
let (op, bl) = opacity_blend(item, mt);
|
||||
// Adjustment layer opacity (and opacity masks, in sequence pixels) weigh adjusted against original.
|
||||
let mut weight = Image::filled(w, h, [op; 4]);
|
||||
mask::apply_opacity_masks(&mut weight, item, mt, opts.scale);
|
||||
// Motion moves / scales the adjustment layer's frame: it only applies inside it.
|
||||
if let Some(region) = adjustment_region(seq, item, project, mt, opts.scale, w, h) {
|
||||
let cov: Vec<f32> = region.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
|
||||
mask::scale_by(&mut weight, &cov);
|
||||
}
|
||||
let cov: Vec<f32> = weight.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
|
||||
if bl == Blend::Normal {
|
||||
// The adjusted picture replaces the original instead of being drawn over it: what
|
||||
// Transform or Basic 3D leave empty shows nothing of the untransformed picture (#1524).
|
||||
mask::mix(&mut adj, below, &cov);
|
||||
} else {
|
||||
mask::scale_by(&mut adj, &cov);
|
||||
}
|
||||
(adj, bl)
|
||||
}
|
||||
|
||||
/// Coverage of an adjustment layer's (Motion-transformed) frame in output pixels, or `None` when
|
||||
/// it covers the whole output.
|
||||
fn adjustment_region(seq: &Sequence, item: &TrackItem, project: &Project, mt: Tick, scale: f32, w: usize, h: usize) -> Option<Image> {
|
||||
@@ -483,6 +518,7 @@ pub(crate) fn item_layer_ex(
|
||||
clip_name: &item.name,
|
||||
project: Some(project),
|
||||
env: Some(&env),
|
||||
frame_rate: seq.settings.frame_rate.as_f64(),
|
||||
working: seq.settings.color.working,
|
||||
};
|
||||
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e)) {
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::effects::{FxCtx, apply};
|
||||
const RW: f32 = REFERENCE_WHITE_NITS as f32;
|
||||
|
||||
fn cx(working: WorkingSpace) -> FxCtx<'static> {
|
||||
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working }
|
||||
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, frame_rate: 24.0, working }
|
||||
}
|
||||
|
||||
fn lumetri(params: &[(&str, ParamValue)]) -> EffectInstance {
|
||||
|
||||
@@ -228,6 +228,7 @@ pub fn thumbnail(preset: &LumetriPreset, w: usize, h: usize) -> Image {
|
||||
clip_name: "",
|
||||
project: None,
|
||||
env: None,
|
||||
frame_rate: 24.0,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
};
|
||||
apply(&mut img, &preset.instance(), &cx);
|
||||
|
||||
@@ -7,6 +7,7 @@ use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, Medi
|
||||
use filmcraft_project::mixer::{LANE_MUTE, LANE_PAN, LANE_VOLUME, send_lane};
|
||||
use filmcraft_project::{
|
||||
AudioChannels, AutomationMode, ItemId, ItemKind, Keyframe, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackKind, TrackSend,
|
||||
Transition, TransitionId,
|
||||
};
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
|
||||
|
||||
@@ -406,6 +407,49 @@ fn audio_transition_curves() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A transition centred on a cut mixes both clips across its whole length: the outgoing clip plays
|
||||
/// on past its out point and the incoming one starts before its in point (their handles), as the
|
||||
/// picture does, so the two overlap instead of each just dipping at the cut. The incoming source is
|
||||
/// a ramp, so its handle must come from the media just before its in point.
|
||||
#[test]
|
||||
fn audio_transition_on_a_cut_crossfades_through_the_handles() {
|
||||
fn ramp(i: i64, _: usize) -> f32 {
|
||||
i as f32 * 1e-6
|
||||
}
|
||||
let mut r = Rig::new(&[dc1, ramp]);
|
||||
let (a_src, b_src) = (r.track(0).items[0].item, r.track(1).items[0].item);
|
||||
let sec = |s: f64| Tick::from_seconds_f64(s);
|
||||
let a = r.p.make_track_item(a_src, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, sec(1.0)), FrameRate::FPS_24).unwrap();
|
||||
let b = r.p.make_track_item(b_src, TrackKind::Audio, sec(1.0), TimeRange::new(sec(2.0), sec(1.0)), FrameRate::FPS_24).unwrap();
|
||||
let effect = filmcraft_project::vtransition::find_transition("constant_gain", filmcraft_project::EffectKind::AudioTransition).unwrap().instance();
|
||||
let tr = Transition {
|
||||
id: TransitionId(1),
|
||||
effect,
|
||||
start: sec(0.75),
|
||||
duration: sec(0.5),
|
||||
from: Some(a.id),
|
||||
to: Some(b.id),
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
};
|
||||
r.track(1).items.clear();
|
||||
let t0 = r.track(0);
|
||||
t0.items = vec![a, b];
|
||||
t0.transitions.push(tr);
|
||||
// the incoming clip plays media sample 96 000 at the cut (sample 48 000)
|
||||
let b_at = |s: i64| ramp(s + 48_000, 0);
|
||||
// constant gain at p = 0.25, 0.5 (the cut), 0.75: (1 - p)·A + p·B
|
||||
for (s, p) in [(42_000, 0.25), (48_000, 0.5), (54_000, 0.75)] {
|
||||
let want = (1.0 - p) * 0.1 + p * b_at(s);
|
||||
let [l, rr] = r.mix(s, 1);
|
||||
assert!(close(l[0], want, 1e-5) && close(rr[0], want, 1e-5), "sample {s}: {} (want {want})", l[0]);
|
||||
}
|
||||
// outside the transition each clip plays alone, at full level
|
||||
for (s, want) in [(35_000, 0.1), (61_000, b_at(61_000))] {
|
||||
assert!(close(r.mix(s, 1)[0][0], want, 1e-6), "sample {s}");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- 5.1
|
||||
|
||||
/// Distinct DC level per source channel: 0.1, 0.2, … (L, R, C, LFE, Ls, Rs for 6-channel sources).
|
||||
|
||||
@@ -228,20 +228,24 @@ fn whole_frame_on_cpu(project: &Project, seq: &Sequence, t: Tick) -> bool {
|
||||
}
|
||||
|
||||
/// Whether the frame of `seq` at `t` can be planned as layers: no adjustment layer and no
|
||||
/// transition the compositor cannot mix itself is showing.
|
||||
fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool {
|
||||
/// transition the compositor cannot mix itself is showing (a transition into or out of an
|
||||
/// adjustment layer counts, also on the side of the cut where the other clip is).
|
||||
pub(crate) fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool {
|
||||
let adjustment = |it: &TrackItem| project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. }));
|
||||
for tr in &seq.video_tracks {
|
||||
if !tr.enabled {
|
||||
continue;
|
||||
}
|
||||
if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t))
|
||||
&& !simple_transition(&trn.effect.effect)
|
||||
&& trn.effect.effect != "push"
|
||||
{
|
||||
return false;
|
||||
if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t)) {
|
||||
if !simple_transition(&trn.effect.effect) && trn.effect.effect != "push" {
|
||||
return false;
|
||||
}
|
||||
if [trn.from, trn.to].into_iter().flatten().filter_map(|c| tr.item(c)).any(adjustment) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(it) = tr.item_at(t)
|
||||
&& project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. }))
|
||||
&& adjustment(it)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -434,6 +438,7 @@ fn push_item(
|
||||
clip_name: &item.name,
|
||||
project: Some(project),
|
||||
env: None,
|
||||
frame_rate: seq.settings.frame_rate.as_f64(),
|
||||
working: seq.settings.color.working,
|
||||
};
|
||||
let ops: Option<Vec<FxOp>> = chain.iter().map(|e| FxOp::eval(e, &cx, lw, lh).filter(FxOp::gpu_ok)).collect();
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
//! size and rate match the sequence. Green (a valid preview exists) is decided by the preview
|
||||
//! store, which knows which hashes have files.
|
||||
|
||||
use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, Transition};
|
||||
use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, TrackKind, Transition};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -166,7 +166,7 @@ pub fn video_segments(project: &Project, seq_id: ItemId) -> Vec<Segment> {
|
||||
match l {
|
||||
Layer::Item(ti, it) => {
|
||||
h.json(&("item", ti));
|
||||
hash_item(&mut h, project, it, t, 0);
|
||||
hash_item(&mut h, project, it, t, TrackKind::Video, 0);
|
||||
absolute |= prints_time(it);
|
||||
clips.push(it.id);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ pub fn video_segments(project: &Project, seq_id: ItemId) -> Vec<Segment> {
|
||||
for side in [a, b] {
|
||||
match side {
|
||||
Some(it) => {
|
||||
hash_item(&mut h, project, it, t, 0);
|
||||
hash_item(&mut h, project, it, t, TrackKind::Video, 0);
|
||||
absolute |= prints_time(it);
|
||||
clips.push(it.id);
|
||||
}
|
||||
@@ -277,7 +277,7 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec<AudioSegment> {
|
||||
for side in [x.from, x.to] {
|
||||
match side.and_then(|id| tr.item(id)).filter(|i| i.enabled) {
|
||||
Some(it) => {
|
||||
hash_item(&mut h, project, it, t, 0);
|
||||
hash_item(&mut h, project, it, t, TrackKind::Audio, 0);
|
||||
heard = true;
|
||||
}
|
||||
None => h.json(&"none"),
|
||||
@@ -285,7 +285,7 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec<AudioSegment> {
|
||||
}
|
||||
} else if let Some(it) = tr.item_at(t).filter(|i| i.enabled) {
|
||||
h.json(&("item", ti));
|
||||
hash_item(&mut h, project, it, t, 0);
|
||||
hash_item(&mut h, project, it, t, TrackKind::Audio, 0);
|
||||
heard = true;
|
||||
}
|
||||
}
|
||||
@@ -300,7 +300,9 @@ fn prints_time(it: &TrackItem) -> bool {
|
||||
it.effects.iter().any(|e| e.enabled && e.effect == "timecode")
|
||||
}
|
||||
|
||||
fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, depth: u32) {
|
||||
/// Hash a clip and its source as heard (`kind` Audio) or seen (`kind` Video): a nested sequence
|
||||
/// contributes only the tracks of that kind, so editing its picture keeps its sound's previews.
|
||||
fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, kind: TrackKind, depth: u32) {
|
||||
let mut c = it.clone();
|
||||
c.id = Default::default();
|
||||
c.start -= origin;
|
||||
@@ -312,10 +314,10 @@ fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, de
|
||||
c.name.clear();
|
||||
}
|
||||
h.json(&c);
|
||||
hash_source(h, project, it.item, depth);
|
||||
hash_source(h, project, it.item, kind, depth);
|
||||
}
|
||||
|
||||
fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) {
|
||||
fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, kind: TrackKind, depth: u32) {
|
||||
let Some(pi) = project.item(id) else {
|
||||
h.json(&"missing");
|
||||
return;
|
||||
@@ -338,18 +340,34 @@ fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) {
|
||||
}
|
||||
ItemKind::Subclip { parent, range, .. } => {
|
||||
h.json(&("subclip", range));
|
||||
hash_source(h, project, *parent, depth + 1);
|
||||
hash_source(h, project, *parent, kind, depth + 1);
|
||||
}
|
||||
ItemKind::AdjustmentLayer { .. } => h.json(&("adjustment", &pi.kind)),
|
||||
ItemKind::Graphic { .. } => h.json(&("graphic", &pi.kind)),
|
||||
ItemKind::Sequence(s) => {
|
||||
h.json(&("sequence", &s.settings));
|
||||
if kind == TrackKind::Audio {
|
||||
// the nest's sound is the nested sequence's whole mix (see `audio_segments`)
|
||||
h.json(&("mix", s.master_volume_db, &s.master_effects, &s.master_mixer, &s.submix_tracks, &s.multicam));
|
||||
for (ti, tr) in s.audio_tracks.iter().enumerate() {
|
||||
h.json(&("track", ti, tr.muted, tr.solo, tr.volume_db, tr.pan, tr.channels, &tr.effects, &tr.mixer, tr.items.len()));
|
||||
for it in &tr.items {
|
||||
hash_item(h, project, it, Tick::ZERO, kind, depth + 1);
|
||||
}
|
||||
for x in &tr.transitions {
|
||||
let mut xc = x.clone();
|
||||
xc.id = Default::default();
|
||||
h.json(&xc);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// a nested sequence's captions are drawn into its picture
|
||||
h.json(&("captions", &s.caption_tracks));
|
||||
for (ti, tr) in s.video_tracks.iter().enumerate() {
|
||||
h.json(&("track", ti, tr.enabled, tr.items.len()));
|
||||
for it in &tr.items {
|
||||
hash_item(h, project, it, Tick::ZERO, depth + 1);
|
||||
hash_item(h, project, it, Tick::ZERO, kind, depth + 1);
|
||||
}
|
||||
for x in &tr.transitions {
|
||||
let mut xc = x.clone();
|
||||
|
||||
@@ -5,7 +5,7 @@ use filmcraft_media::{DemoScene, Generator, MediaSource};
|
||||
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackKind, find_effect};
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
|
||||
|
||||
use crate::preview::{Need, Segment, effect_cost_ms, segment_at, video_segments};
|
||||
use crate::preview::{Need, Segment, audio_segments, effect_cost_ms, segment_at, video_segments};
|
||||
|
||||
fn add_gen(p: &mut Project, g: GeneratorSource) -> ItemId {
|
||||
let info = g.info().clone();
|
||||
@@ -234,6 +234,7 @@ fn effect_costs() {
|
||||
clip_name: "x",
|
||||
project: None,
|
||||
env: None,
|
||||
frame_rate: 24.0,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
};
|
||||
apply(&mut img, &e, &cx);
|
||||
@@ -248,3 +249,54 @@ fn effect_costs() {
|
||||
println!("{ms:8.2} ms {id:24} tier {:.0}", effect_cost_ms(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `item` on track 0 of `kind` in `seq` from frame `start_f` for `dur_f` frames.
|
||||
fn place_kind(p: &mut Project, seq: ItemId, kind: TrackKind, item: ItemId, start_f: i64, dur_f: i64) -> ClipId {
|
||||
let r = FrameRate::FPS_24;
|
||||
let ti = p.make_track_item(item, kind, r.tick_of(start_f), TimeRange::new(Tick::ZERO, r.tick_of(dur_f)), r).unwrap();
|
||||
let id = ti.id;
|
||||
let t = &mut p.sequence_mut(seq).unwrap().tracks_mut(kind)[0];
|
||||
t.items.push(ti);
|
||||
t.sort();
|
||||
id
|
||||
}
|
||||
|
||||
fn audio_hashes(p: &Project, seq: ItemId) -> Vec<String> {
|
||||
audio_segments(p, seq).into_iter().map(|g| g.hash).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nests_audio_previews_follow_the_sound_of_its_sequence() {
|
||||
// Inner has a picture clip and a sound clip; Outer nests Inner (picture and sound), and Top
|
||||
// nests Outer's sound
|
||||
let (mut p, matte, ocean, inner) = setup();
|
||||
let pic = place_kind(&mut p, inner, TrackKind::Video, matte, 0, 48);
|
||||
let snd = place_kind(&mut p, inner, TrackKind::Audio, ocean, 0, 48);
|
||||
let settings = p.sequence(inner).unwrap().settings.clone();
|
||||
let outer = p.new_sequence("outer", settings.clone(), 1, 1, None);
|
||||
place_kind(&mut p, outer, TrackKind::Video, inner, 24, 48);
|
||||
place_kind(&mut p, outer, TrackKind::Audio, inner, 24, 48);
|
||||
let top = p.new_sequence("top", settings, 1, 1, None);
|
||||
place_kind(&mut p, top, TrackKind::Audio, outer, 12, 72);
|
||||
let (audio, picture, deep) = (audio_hashes(&p, outer), video_segments(&p, outer), audio_hashes(&p, top));
|
||||
assert_eq!(audio.len(), 1);
|
||||
|
||||
// the sound inside the nest changes: the nest's audio preview is stale, its picture is not
|
||||
p.sequence_mut(inner).unwrap().find_item_mut(snd).unwrap().1.gain_db = -12.0;
|
||||
assert_ne!(audio_hashes(&p, outer), audio, "a clip's gain inside the nest");
|
||||
assert_ne!(audio_hashes(&p, top), deep, "a clip's gain two nests down");
|
||||
assert_eq!(bounds(&video_segments(&p, outer)), bounds(&picture));
|
||||
assert_eq!(by_frame(&video_segments(&p, outer), 30), by_frame(&picture, 30), "the picture did not change");
|
||||
let (audio, deep) = (audio_hashes(&p, outer), audio_hashes(&p, top));
|
||||
// the nested sequence's track mix counts too
|
||||
p.sequence_mut(inner).unwrap().audio_tracks[0].muted = true;
|
||||
assert_ne!(audio_hashes(&p, outer), audio, "a muted track inside the nest");
|
||||
p.sequence_mut(inner).unwrap().audio_tracks[0].muted = false;
|
||||
assert_eq!(audio_hashes(&p, outer), audio);
|
||||
|
||||
// the picture inside the nest changes: its audio preview stays valid
|
||||
add_fx(&mut p, inner, pic, "tint");
|
||||
assert_ne!(by_frame(&video_segments(&p, outer), 30), by_frame(&picture, 30));
|
||||
assert_eq!(audio_hashes(&p, outer), audio, "a video effect inside the nest");
|
||||
assert_eq!(audio_hashes(&p, top), deep);
|
||||
}
|
||||
@@ -233,7 +233,7 @@ pub fn rotate_3d(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
|
||||
/// Transform: affine place, with optional shutter-angle motion blur when the override is on.
|
||||
pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
let fps = cx.env.map_or(24.0, |env| env.frame_rate()).max(1.0);
|
||||
let fps = cx.frame_rate.max(1.0);
|
||||
let angle = fv(e, "shutter_angle", cx);
|
||||
let n = if bv(e, "shutter_override") && angle > 0.5 { 7 } else { 1 };
|
||||
let span = (angle as f64 / 360.0) / fps;
|
||||
@@ -247,6 +247,7 @@ pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
clip_name: cx.clip_name,
|
||||
project: cx.project,
|
||||
env: cx.env,
|
||||
frame_rate: cx.frame_rate,
|
||||
working: cx.working,
|
||||
};
|
||||
let anchor = crate::effects::point(e, "anchor", &cx2, im);
|
||||
|
||||
@@ -103,6 +103,7 @@ impl FxEnv for ItemEnv<'_> {
|
||||
clip_name: &self.item.name,
|
||||
project: Some(self.project),
|
||||
env: None,
|
||||
frame_rate: self.seq.settings.frame_rate.as_f64(),
|
||||
working: self.seq.settings.color.working,
|
||||
};
|
||||
for e in &self.item.effects {
|
||||
|
||||
@@ -17,6 +17,7 @@ fn cx() -> FxCtx<'static> {
|
||||
clip_name: "clip",
|
||||
project: None,
|
||||
env: None,
|
||||
frame_rate: 24.0,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
}
|
||||
}
|
||||
@@ -1154,3 +1155,31 @@ mod pipeline {
|
||||
assert!(e.px.iter().all(|v| v.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_shutter_uses_the_sequence_frame_rate_without_env() {
|
||||
// An adjustment layer has no env: the shutter must still span one 60 fps frame at 360°, so
|
||||
// the frames just before the motion starts and just after it ends stay sharp (#1088).
|
||||
let mut e = inst("transform");
|
||||
set(&mut e, "shutter_override", ParamValue::Bool(true));
|
||||
setf(&mut e, "shutter_angle", 360.0);
|
||||
let mut a = Keyframe::new(Tick::from_seconds_f64(1.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)));
|
||||
a.interp = Interpolation::Linear;
|
||||
let mut b = Keyframe::new(Tick::from_seconds_f64(2.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(40.0, 16.0)));
|
||||
b.interp = Interpolation::Linear;
|
||||
e.params.insert("position".into(), Param { value: ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)), keyframes: vec![a, b] });
|
||||
let img = picture(32, 32);
|
||||
let mut off = e.clone();
|
||||
set(&mut off, "shutter_override", ParamValue::Bool(false));
|
||||
let delta = |frame: i32| {
|
||||
let s = f64::from(frame) / 60.0;
|
||||
let cx = FxCtx { t: Tick::from_seconds_f64(s), seconds: s, frame_rate: 60.0, ..cx() };
|
||||
max_diff(&run("transform", &off, &img, &cx), &run("transform", &e, &img, &cx))
|
||||
};
|
||||
for frame in [59, 121] {
|
||||
assert!(delta(frame) < 1e-4, "frame {frame} is blurred outside the motion ({})", delta(frame));
|
||||
}
|
||||
for frame in [60, 90, 120] {
|
||||
assert!(delta(frame) > 1e-3, "frame {frame} moves but is sharp");
|
||||
}
|
||||
}
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user