diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 2c4b8cb1..b19dc402 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -12,6 +12,14 @@ updates:
cargo-dependencies:
patterns:
- "*"
+ # craft-ui is on egui 0.36 until storytold/craft-libs#199 lands.
+ ignore:
+ - dependency-name: "egui"
+ update-types: ["version-update:semver-major", "version-update:semver-minor"]
+ - dependency-name: "eframe"
+ update-types: ["version-update:semver-major", "version-update:semver-minor"]
+ - dependency-name: "egui_kittest"
+ update-types: ["version-update:semver-major", "version-update:semver-minor"]
commit-message:
prefix: "chore(deps)"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..2095acad
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
+# suite's shared gate workflow in craft-common-actions.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+# A newer push to a PR branch cancels its older run; every push to main runs to the end.
+concurrency:
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ gate:
+ # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ packages: >-
+ pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
+ libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+ libgl1-mesa-dev libasound2-dev
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index d819e138..cfcae78b 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -3,20 +3,19 @@
# player falls back to its silent clock when the VM has no sound device.
name: FreeBSD
-# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
-# or dependency change: a drifted dependency still shows up within a day, and always before a
-# release ships. PRs that change the FreeBSD packaging itself still run it.
+# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
+# request: a drifted dependency still shows up within a day, and always before a release ships.
+# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
- schedule:
- - cron: "47 3 * * *"
push:
branches: [release]
pull_request:
paths:
- - "crates/playback/**"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
+ schedule:
+ - cron: "47 3 * * *"
workflow_dispatch:
concurrency:
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 01cb9cd6..318f6bba 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -21,3 +21,41 @@ jobs:
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+
+ git-sources:
+ name: Git dependencies come from storytold
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Git dependencies come from storytold
+ run: |
+ set -euo pipefail
+ test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
+ # Source replacement, patches and path overrides in cargo config never show up
+ # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
+ # Cargo still reads the legacy .cargo/config.
+ python3 - <<'PY'
+ import os, sys, tomllib
+ for path in (".cargo/config", ".cargo/config.toml"):
+ if not os.path.exists(path):
+ continue
+ config = tomllib.load(open(path, "rb"))
+ replaced = [name for name, source in config.get("source", {}).items()
+ if isinstance(source, dict) and "replace-with" in source]
+ if replaced or "patch" in config or "paths" in config:
+ print(f"{path} replaces, patches or overrides a source")
+ sys.exit(1)
+ PY
+ # Fails when Cargo.lock is out of date with the manifests.
+ cargo metadata --locked --format-version 1 > /dev/null
+ python3 - <<'PY'
+ import sys, tomllib
+ lock = tomllib.load(open("Cargo.lock", "rb"))
+ bad = sorted({p["source"] for p in lock.get("package", [])
+ if p.get("source", "").startswith("git+")
+ and not p["source"].startswith("git+https://github.com/storytold/")})
+ if bad:
+ print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
+ sys.exit(1)
+ PY
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d60e8202..bf2e1796 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,9 +10,6 @@ on:
type: string
default: ""
-permissions:
- contents: write
-
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index ecee45a6..e78fe1ea 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
- cancel-in-progress: true
+ # A push to main is the run that publishes coverage; superseded runs there would never finish.
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
diff --git a/Cargo.lock b/Cargo.lock
index f36f359d..6158b44b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -906,6 +906,14 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+[[package]]
+name = "craft-media"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -3798,6 +3806,7 @@ dependencies = [
name = "soundcraft-video"
version = "0.12.1"
dependencies = [
+ "craft-media",
"image",
"proptest",
"rayon",
diff --git a/Cargo.toml b/Cargo.toml
index b5ea0ddc..f5637624 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -47,6 +47,9 @@ cpal = "0.18"
# Which file paths stored in a session may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
+# Video containers and bitstreams, shared with the other craft apps.
+craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" }
+
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands.
@@ -67,9 +70,11 @@ type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
+debug = false
[profile.dev]
opt-level = 1
+debug = "line-tables-only"
[profile.release]
opt-level = 3
diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs
index 9a51cc54..58cc0098 100644
--- a/apps/soundcraft-cli/src/main.rs
+++ b/apps/soundcraft-cli/src/main.rs
@@ -110,7 +110,7 @@ fn info(args: &[String]) -> ExitCode {
Ok(b) => b,
Err(e) => return fail(format!("{path}: {e}")),
};
- let ext = std::path::Path::new(path).extension().and_then(|x| x.to_str());
+ let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str);
match soundcraft_audio_io::probe(&bytes, ext) {
Ok(i) => {
outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default());
@@ -134,7 +134,7 @@ fn convert(args: &[String]) -> ExitCode {
Ok(b) => b,
Err(e) => return fail(format!("{input}: {e}")),
};
- let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str());
+ let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
Ok(x) => x,
Err(e) => return fail(format!("{input}: {e}")),
@@ -146,7 +146,7 @@ fn convert(args: &[String]) -> ExitCode {
if args.iter().any(|a| a == "--normalize") {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
}
- let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
+ let out_ext = std::path::Path::new(output).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
};
diff --git a/apps/soundcraft-cli/tests/cli.rs b/apps/soundcraft-cli/tests/it/cli.rs
similarity index 99%
rename from apps/soundcraft-cli/tests/cli.rs
rename to apps/soundcraft-cli/tests/it/cli.rs
index ef2fb881..c10bcf60 100644
--- a/apps/soundcraft-cli/tests/cli.rs
+++ b/apps/soundcraft-cli/tests/it/cli.rs
@@ -1,6 +1,5 @@
//! `soundcraft-cli mcp` with the automation root flags, run as the real binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::path::PathBuf;
diff --git a/apps/soundcraft-cli/tests/closed_stdout.rs b/apps/soundcraft-cli/tests/it/closed_stdout.rs
similarity index 100%
rename from apps/soundcraft-cli/tests/closed_stdout.rs
rename to apps/soundcraft-cli/tests/it/closed_stdout.rs
diff --git a/apps/soundcraft-cli/tests/it/main.rs b/apps/soundcraft-cli/tests/it/main.rs
new file mode 100644
index 00000000..c573712b
--- /dev/null
+++ b/apps/soundcraft-cli/tests/it/main.rs
@@ -0,0 +1,5 @@
+//! Integration tests, built as one test binary: each file is a module.
+
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod cli;
+mod closed_stdout;
diff --git a/apps/soundcraft-web/index.html b/apps/soundcraft-web/index.html
index af72b21b..124e0b71 100644
--- a/apps/soundcraft-web/index.html
+++ b/apps/soundcraft-web/index.html
@@ -2,7 +2,7 @@
-
+
SoundCraft
diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs
index 808cdef8..cd27ec44 100644
--- a/apps/soundcraft/src/main.rs
+++ b/apps/soundcraft/src/main.rs
@@ -182,7 +182,7 @@ fn services() -> Services {
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
d = d.set_directory(dir);
}
- if let Some(n) = path.file_name().and_then(|n| n.to_str()) {
+ if let Some(n) = path.file_name().and_then(std::ffi::OsStr::to_str) {
d = d.set_file_name(n);
}
d.save_file().map(|p| p.to_string_lossy().into_owned())
diff --git a/crates/au-host/src/lib.rs b/crates/au-host/src/lib.rs
index 39172c0b..3a664a4d 100644
--- a/crates/au-host/src/lib.rs
+++ b/crates/au-host/src/lib.rs
@@ -192,7 +192,7 @@ pub enum AuError {
#[cfg(target_os = "macos")]
mod api {
- use super::*;
+ use super::{AuDescriptor, AuError, Plugin, PluginInfo};
pub fn scan() -> Vec {
registry::scan()
@@ -210,7 +210,7 @@ mod api {
#[cfg(not(target_os = "macos"))]
mod api {
- use super::*;
+ use super::{AuDescriptor, AuError, Plugin, PluginInfo};
pub fn scan() -> Vec {
Vec::new()
diff --git a/crates/au-host/tests/it/main.rs b/crates/au-host/tests/it/main.rs
new file mode 100644
index 00000000..a4505476
--- /dev/null
+++ b/crates/au-host/tests/it/main.rs
@@ -0,0 +1,4 @@
+//! Integration tests, built as one test binary: each file is a module.
+
+#[cfg(target_os = "macos")]
+mod system_units;
diff --git a/crates/au-host/tests/system_units.rs b/crates/au-host/tests/it/system_units.rs
similarity index 99%
rename from crates/au-host/tests/system_units.rs
rename to crates/au-host/tests/it/system_units.rs
index 8f4e6924..3d16749e 100644
--- a/crates/au-host/tests/system_units.rs
+++ b/crates/au-host/tests/it/system_units.rs
@@ -1,6 +1,5 @@
//! End to end against the Audio Units Apple ships with macOS: scan → instantiate → params →
//! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing.
-#![cfg(target_os = "macos")]
use soundcraft_dsp::{Category, Plugin};
diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs
index db6efbbb..d6563e1a 100644
--- a/crates/audio-io/src/symph.rs
+++ b/crates/audio-io/src/symph.rs
@@ -95,12 +95,16 @@ fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] {
/// check has to run before the stream is decoded.
const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"];
-/// Find the first container marker at or after `at`, within symphonia's probe depth.
+/// Find the first container marker at or after `at`, within symphonia's probe depth. A RIFF form
+/// other than WAVE is skipped, as the probe skips it and reads a WAVE behind it.
fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> {
let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH));
for pos in at..end.saturating_sub(3) {
let window = bytes.get(pos..pos + 4)?;
if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) {
+ if matches!(*marker, b"RIFF" | b"RF64" | b"BW64") && bytes.get(pos + 8..pos + 12) != Some(&b"WAVE"[..]) {
+ continue;
+ }
return Some((pos, *marker));
}
}
diff --git a/crates/audio-io/tests/encode_format.rs b/crates/audio-io/tests/it/encode_format.rs
similarity index 100%
rename from crates/audio-io/tests/encode_format.rs
rename to crates/audio-io/tests/it/encode_format.rs
diff --git a/crates/audio-io/tests/it/main.rs b/crates/audio-io/tests/it/main.rs
new file mode 100644
index 00000000..36b5129f
--- /dev/null
+++ b/crates/audio-io/tests/it/main.rs
@@ -0,0 +1,7 @@
+//! Integration tests, built as one test binary: each file is a module.
+
+mod encode_format;
+mod malformed;
+mod roundtrip;
+mod stream;
+mod wav_empty_data;
diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/it/malformed.rs
similarity index 95%
rename from crates/audio-io/tests/malformed.rs
rename to crates/audio-io/tests/it/malformed.rs
index f7da8746..f029b537 100644
--- a/crates/audio-io/tests/malformed.rs
+++ b/crates/audio-io/tests/it/malformed.rs
@@ -328,6 +328,18 @@ fn wav_info_entries_beyond_the_file_are_refused() {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
+#[test]
+fn a_wave_behind_another_riff_form_is_checked() {
+ let info = [&b"INFO"[..], b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
+ let mut wav = riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]);
+ wav[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
+ let list = wav.windows(4).position(|w| w == b"LIST").unwrap();
+ wav[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
+ // The probe skips the AVI form and reads the WAVE behind it.
+ let f = [&b"RIFF"[..], &4u32.to_le_bytes(), b"AVI ", &wav].concat();
+ assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
+}
+
#[test]
fn wav_info_entries_are_found_where_symphonia_reads_them() {
let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
@@ -455,12 +467,10 @@ fn garbage_in_compressed_containers_errors() {
/// without its own checks: the reader is refused when the probed format disagrees.
#[test]
fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() {
- let flac = small(FileFormat::Flac, BitDepth::Int24, 2);
- let mut bytes = b"RIFF".to_vec();
- bytes.extend_from_slice(&3u32.to_le_bytes());
- bytes.extend_from_slice(b"FAKE");
- bytes.extend_from_slice(&flac);
- match decode(&bytes, None) {
+ // The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the
+ // probe reads the WAVE before it.
+ let bytes = [vec![0], small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Flac, BitDepth::Int24, 2)].concat();
+ match decode(&bytes, Some("flac")) {
Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"),
Err(other) => panic!("unexpected error kind: {other:?}"),
Ok(_) => panic!("the mismatched container must be refused"),
diff --git a/crates/audio-io/tests/roundtrip.rs b/crates/audio-io/tests/it/roundtrip.rs
similarity index 100%
rename from crates/audio-io/tests/roundtrip.rs
rename to crates/audio-io/tests/it/roundtrip.rs
diff --git a/crates/audio-io/tests/stream.rs b/crates/audio-io/tests/it/stream.rs
similarity index 100%
rename from crates/audio-io/tests/stream.rs
rename to crates/audio-io/tests/it/stream.rs
diff --git a/crates/audio-io/tests/wav_empty_data.rs b/crates/audio-io/tests/it/wav_empty_data.rs
similarity index 100%
rename from crates/audio-io/tests/wav_empty_data.rs
rename to crates/audio-io/tests/it/wav_empty_data.rs
diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/it/agent_tasks.rs
similarity index 100%
rename from crates/automation/tests/agent_tasks.rs
rename to crates/automation/tests/it/agent_tasks.rs
diff --git a/crates/automation/tests/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs
similarity index 99%
rename from crates/automation/tests/automation_roots.rs
rename to crates/automation/tests/it/automation_roots.rs
index 2c4fa160..58d4aad2 100644
--- a/crates/automation/tests/automation_roots.rs
+++ b/crates/automation/tests/it/automation_roots.rs
@@ -1,7 +1,6 @@
//! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads
//! stays inside the read root, every file it writes inside the write root.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use soundcraft_automation::{Headless, mcp::Server};
diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs
new file mode 100644
index 00000000..fc738242
--- /dev/null
+++ b/crates/automation/tests/it/main.rs
@@ -0,0 +1,5 @@
+//! Integration tests, built as one test binary: each file is a module.
+
+mod agent_tasks;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod automation_roots;
diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs
index 955b6614..a5e042a3 100644
--- a/crates/clap-host/src/lib.rs
+++ b/crates/clap-host/src/lib.rs
@@ -88,7 +88,7 @@ pub enum ClapError {
#[cfg(not(target_arch = "wasm32"))]
mod api {
- use super::*;
+ use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo, registry};
pub fn cached() -> Option> {
registry::cached()
@@ -118,7 +118,7 @@ mod api {
#[cfg(target_arch = "wasm32")]
mod api {
- use super::*;
+ use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo};
pub fn cached() -> Option> {
Some(Vec::new())
diff --git a/crates/clap-host/src/scan.rs b/crates/clap-host/src/scan.rs
index 92a6f6d3..6bfbadd6 100644
--- a/crates/clap-host/src/scan.rs
+++ b/crates/clap-host/src/scan.rs
@@ -51,7 +51,7 @@ pub fn os_search_paths(home: Option, common_program_files: Option bool {
- p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
+ p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
}
/// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped).
diff --git a/crates/clap-host/tests/fixture_e2e.rs b/crates/clap-host/tests/it/fixture_e2e.rs
similarity index 99%
rename from crates/clap-host/tests/fixture_e2e.rs
rename to crates/clap-host/tests/it/fixture_e2e.rs
index 8525d740..f531db54 100644
--- a/crates/clap-host/tests/fixture_e2e.rs
+++ b/crates/clap-host/tests/it/fixture_e2e.rs
@@ -3,8 +3,7 @@
use soundcraft_dsp::{Category, Unit};
-mod common;
-use common::fixture_dir;
+use crate::common::fixture_dir;
const GAIN: &str = "clap:org.soundcraft.test.gain";
const SYNTH: &str = "clap:org.soundcraft.test.synth";
diff --git a/crates/clap-host/tests/it/main.rs b/crates/clap-host/tests/it/main.rs
new file mode 100644
index 00000000..ce956746
--- /dev/null
+++ b/crates/clap-host/tests/it/main.rs
@@ -0,0 +1,6 @@
+//! Integration tests, built as one test binary: each file is a module.
+
+#[path = "../common/mod.rs"]
+mod common;
+
+mod fixture_e2e;
diff --git a/crates/dsp/tests/fuzz.rs b/crates/dsp/tests/it/fuzz.rs
similarity index 100%
rename from crates/dsp/tests/fuzz.rs
rename to crates/dsp/tests/it/fuzz.rs
diff --git a/crates/dsp/tests/it/main.rs b/crates/dsp/tests/it/main.rs
new file mode 100644
index 00000000..c214cd3e
--- /dev/null
+++ b/crates/dsp/tests/it/main.rs
@@ -0,0 +1,7 @@
+//! Integration tests, built as one test binary: each file is a module.
+
+mod fuzz;
+mod offline_meters;
+mod offline_normalize;
+mod plugins;
+mod transients;
diff --git a/crates/dsp/tests/offline_meters.rs b/crates/dsp/tests/it/offline_meters.rs
similarity index 100%
rename from crates/dsp/tests/offline_meters.rs
rename to crates/dsp/tests/it/offline_meters.rs
diff --git a/crates/dsp/tests/offline_normalize.rs b/crates/dsp/tests/it/offline_normalize.rs
similarity index 100%
rename from crates/dsp/tests/offline_normalize.rs
rename to crates/dsp/tests/it/offline_normalize.rs
diff --git a/crates/dsp/tests/plugins.rs b/crates/dsp/tests/it/plugins.rs
similarity index 100%
rename from crates/dsp/tests/plugins.rs
rename to crates/dsp/tests/it/plugins.rs
diff --git a/crates/dsp/tests/transients.rs b/crates/dsp/tests/it/transients.rs
similarity index 100%
rename from crates/dsp/tests/transients.rs
rename to crates/dsp/tests/it/transients.rs
diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs
index 01899d2f..fd56478b 100644
--- a/crates/engine/src/autosave.rs
+++ b/crates/engine/src/autosave.rs
@@ -176,11 +176,25 @@ struct Autosave {
}
impl Autosave {
- /// A new folder under `root` for the session called `name`.
+ /// A new folder under `root` for the session called `name`. Two sessions started within the
+ /// same millisecond get folders of their own, never a shared one.
fn create(root: &Path, name: &str) -> Result {
let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis();
- let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id()));
- let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?;
+ let base = format!("{} {stamp}-{}", sanitize_name(name), std::process::id());
+ std::fs::create_dir_all(root).map_err(|err| io_error(root, err))?;
+ let mut dir = root.join(&base);
+ let mut n = 1;
+ loop {
+ match std::fs::create_dir(&dir) {
+ Ok(()) => break,
+ Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
+ n += 1;
+ dir = root.join(format!("{base}-{n}"));
+ }
+ Err(err) => return Err(io_error(&dir, err)),
+ }
+ }
+ let lock = std::fs::File::create(dir.join(LOCK)).map_err(|err| io_error(&dir, err))?;
lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false })
}
@@ -427,6 +441,14 @@ mod tests {
dir
}
+ /// Moves a saved session file's mtime a second into the past. Filesystems with coarse
+ /// timestamps (tmpfs) can give it and an autosave written right after it the same mtime,
+ /// and `leftovers` only offers autosaves strictly newer than the file they came from.
+ fn backdate(path: &Path) {
+ let then = SystemTime::now() - Duration::from_secs(1);
+ std::fs::File::options().write(true).open(path).unwrap().set_modified(then).unwrap();
+ }
+
fn record(e: &mut Engine, level: f32) {
let before = e.session_arc();
let s = e.session_mut();
@@ -453,6 +475,7 @@ mod tests {
let mut e = Engine::default();
record(&mut e, 0.5);
crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap();
+ backdate(&path);
record(&mut e, -0.25);
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap();
@@ -590,6 +613,21 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ #[test]
+ fn sessions_started_together_get_folders_of_their_own() {
+ // #579: the folder name has millisecond resolution, so two sessions created back to back
+ // shared one folder and the second overwrote the first's unsaved work.
+ let dir = folder("autosave-unique");
+ let root = dir.join("Autosave");
+ let made: Vec = (0..20).map(|_| Autosave::create(&root, "Song").unwrap()).collect();
+ let mut dirs: Vec<&PathBuf> = made.iter().map(|a| &a.dir).collect();
+ dirs.sort();
+ dirs.dedup();
+ assert_eq!(dirs.len(), made.len());
+ drop(made);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn unsaved_work_is_offered_once_its_session_closes() {
let dir = folder("autosave-leftovers");
@@ -598,6 +636,7 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let mut e = Engine::default();
crate::io::save_session(&mut e, &path).unwrap();
+ backdate(Path::new(&path));
record(&mut e, 0.5);
let mut a = Autosave::create(&root, "Song").unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap();
diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs
index 35bb2df3..9a44cca5 100644
--- a/crates/engine/src/clip_group_file.rs
+++ b/crates/engine/src/clip_group_file.rs
@@ -50,7 +50,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result {
};
let n = clips.len();
lanes.sort_by_key(|(i, _)| *i);
- let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string();
+ let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Clip Group").to_string();
let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem));
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
let mut audio = BTreeMap::new();
@@ -80,26 +80,17 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result {
Ok(n)
}
-/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks
-/// are made when the session runs out). Returns the ids of the placed clips.
-pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) -> Result> {
- let id = "file.import_clip_groups";
- let text = crate::io::read_text(path)?;
- let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
- if file.format != FORMAT {
- return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
- }
- if file.lanes.iter().map(|l| l.len().max(1)).sum::() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
- return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
- }
- let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
- // Decode audio first so a bad file changes nothing, and each file once however many ids name it.
+fn decode_group_audio(
+ audio_entries: &BTreeMap,
+ dir: &Path,
+ rate: u32,
+ cmd_id: &str,
+) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap)> {
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
- let rate = e.session().sample_rate.hz();
- for (old, rel) in &file.audio {
+ for (old, rel) in audio_entries {
let full = dir.join(rel);
- if !full.starts_with(&dir) || rel.contains("..") {
- return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}")));
+ if !full.starts_with(dir) || rel.contains("..") {
+ return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
}
let key = crate::io::file_key(&full);
let n = match index.get(&key) {
@@ -116,6 +107,25 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) -
};
file_of.insert(*old, n);
}
+ Ok((decoded, file_of))
+}
+
+/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks
+/// are made when the session runs out). Returns the ids of the placed clips.
+pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) -> Result> {
+ let id = "file.import_clip_groups";
+ let text = crate::io::read_text(path)?;
+ let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
+ if file.format != FORMAT {
+ return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
+ }
+ if file.lanes.iter().map(|l| l.len().max(1)).sum::() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
+ return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
+ }
+ let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
+ // Decode audio first so a bad file changes nothing, and each file once however many ids name it.
+ let rate = e.session().sample_rate.hz();
+ let (decoded, file_of) = decode_group_audio(&file.audio, &dir, rate, id)?;
let src_rate = f64::from(file.sample_rate.max(1));
let s = e.session_mut();
let ratio = f64::from(s.sample_rate.hz()) / src_rate;
@@ -123,7 +133,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) -
let sources: Vec = decoded
.into_iter()
.map(|(rel, buf)| {
- let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string();
+ let name = Path::new(&rel).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("clip group").to_string();
crate::io::add_source(s, &name, buf, None, FileFormat::Wav)
})
.collect();
diff --git a/crates/engine/src/cmd/audio_midi.rs b/crates/engine/src/cmd/audio_midi.rs
index 218e3953..267f4789 100644
--- a/crates/engine/src/cmd/audio_midi.rs
+++ b/crates/engine/src/cmd/audio_midi.rs
@@ -1,9 +1,9 @@
//! Edit › Copy Special › Audio as MIDI: pitch-track the selected audio into MIDI notes on the
//! clipboard (paste them onto a MIDI or instrument track).
+use super::{CommandSpec, bad, has_range, range_param, tracks_required};
-use super::*;
-use crate::{Clipboard, cmd};
-use serde_json::json;
+use crate::{Clipboard, Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, ClipId};
diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs
index 911ccca5..302d1eb3 100644
--- a/crates/engine/src/cmd/audiosuite.rs
+++ b/crates/engine/src/cmd/audiosuite.rs
@@ -1,8 +1,9 @@
//! AudioSuite-style offline processing of selected clips.
+use super::{CommandSpec, bad, clip_ids_param, has_selection, range_param, str_param, tracks_param};
+use soundcraft_time::Range;
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::{Clip, ClipContent, TrackKind};
diff --git a/crates/engine/src/cmd/clap.rs b/crates/engine/src/cmd/clap.rs
index 15ddcd74..4dfade07 100644
--- a/crates/engine/src/cmd/clap.rs
+++ b/crates/engine/src/cmd/clap.rs
@@ -1,9 +1,9 @@
//! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer
//! commands. Hosting itself lives in `soundcraft-clap-host`.
+use super::{CommandSpec, always};
-use super::*;
use crate::cmd;
-use serde_json::json;
+use serde_json::{Value, json};
use soundcraft_dsp::PluginInfo;
pub fn specs() -> Vec {
diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs
index aff6d8cc..916c439e 100644
--- a/crates/engine/src/cmd/clip.rs
+++ b/crates/engine/src/cmd/clip.rs
@@ -1,8 +1,10 @@
//! Clip menu commands.
+use super::{CommandSpec, always, bad, clip_ids_param, f64_or, has_range, has_selection, i64_or, position_param, str_param, track_param};
+use soundcraft_model::ClipId;
+use soundcraft_time::Samples;
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
pub fn specs() -> Vec {
vec![
diff --git a/crates/engine/src/cmd/clip_list.rs b/crates/engine/src/cmd/clip_list.rs
index a8529881..ae9cd499 100644
--- a/crates/engine/src/cmd/clip_list.rs
+++ b/crates/engine/src/cmd/clip_list.rs
@@ -1,9 +1,9 @@
//! Clip List commands.
+use super::{CommandSpec, bad, bool_or};
-use super::*;
-use crate::cmd;
-use serde_json::json;
-use soundcraft_model::SourceId;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
+use soundcraft_model::{ClipId, SourceId};
use std::collections::BTreeSet;
const CLEAR: &str = "clip.clear";
diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs
index 59e57179..98bed8c7 100644
--- a/crates/engine/src/cmd/clip_more.rs
+++ b/crates/engine/src/cmd/clip_more.rs
@@ -1,10 +1,10 @@
//! More Clip menu commands: Regroup, Unloop, Clip Effects bypass/render, Conform to Tempo and
//! Remove Pitch Shift.
+use super::{CommandSpec, bad, clip_ids_param, has_selection, str_param};
use super::more_util::{render_stretched, replace_clip};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_model::{Clip, ClipContent, ClipId, Session, TrackId};
use soundcraft_time::Samples;
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 468170ca..5536668b 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -1,11 +1,15 @@
//! Edit menu commands.
+use super::{
+ CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection,
+ has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
+};
+use soundcraft_time::Samples;
use super::more_util::toggle_flag;
-use super::*;
use crate::edit as ops;
-use crate::{Clipboard, cmd};
-use serde_json::json;
-use soundcraft_model::{EditMode, Fade, FadeShape, Session};
+use crate::{Clipboard, Engine, Result, cmd};
+use serde_json::{Value, json};
+use soundcraft_model::{ClipId, EditMode, Fade, FadeShape, Session, TrackId};
pub fn specs() -> Vec {
vec![
diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs
index fdd265c1..57e134aa 100644
--- a/crates/engine/src/cmd/edit_more.rs
+++ b/crates/engine/src/cmd/edit_more.rs
@@ -1,11 +1,13 @@
//! More Edit menu commands: Cut/Copy/Clear Special variants, Paste Special, selection helpers,
//! Space Clips, Snap to, Copy/Move Selection to playlists, TCE, and automation coalesce/trim/glide.
+use super::{
+ CommandSpec, always, bad, clip_ids_param, has_clipboard, has_range, has_selection, position_param, range_param, str_param, tracks_required,
+};
use super::more_util::{clip_db, render_stretched, replace_clip, set_clip_env, target_playlist};
-use super::*;
use crate::edit as ops;
-use crate::{Clipboard, TransportRequest, cmd};
-use serde_json::json;
+use crate::{Clipboard, Engine, Result, TransportRequest, cmd};
+use serde_json::{Value, json};
use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session};
use soundcraft_time::{Range, Samples};
diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs
index 80af311f..018209b6 100644
--- a/crates/engine/src/cmd/event.rs
+++ b/crates/engine/src/cmd/event.rs
@@ -1,8 +1,8 @@
//! Event menu: time/tempo operations, MIDI operations, markers.
+use super::{CommandSpec, always, bad, clip_ids_param, f32_or, f64_or, has_selection, has_tracks, i64_or, position_param, range_param, str_param};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp};
use soundcraft_model::{ClipContent, MarkerKind};
use soundcraft_time::TICKS_PER_QUARTER;
diff --git a/crates/engine/src/cmd/event_more.rs b/crates/engine/src/cmd/event_more.rs
index 7e7d3e86..51016627 100644
--- a/crates/engine/src/cmd/event_more.rs
+++ b/crates/engine/src/cmd/event_more.rs
@@ -2,15 +2,18 @@
//! operations (split notes, input quantize, step input, restore/flatten performance), MIDI track
//! offsets and real-time properties, chord extraction, Beat Detective, Identify Beat and
//! Retrospective Record.
+use super::{
+ CommandSpec, always, bad, bool_or, clip_ids_param, f32_or, f64_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param,
+ str_param, track_param, tracks_param, tracks_required,
+};
use super::more_util::{store_value, track_key, values_with_prefix};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_midi::Note;
use soundcraft_midi::ops::QuantizeOptions;
use soundcraft_model::{ChannelFormat, Clip, ClipContent, ClipId, MarkerKind, Session, TrackId, TrackKind};
-use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER, TempoMap};
+use soundcraft_time::{MAX_POSITION, Range, Samples, TICKS_PER_QUARTER, TempoMap};
/// Most tempo events one curve may write.
const MAX_TEMPO_EVENTS: i64 = 10_000;
@@ -782,7 +785,6 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result {
#[cfg(test)]
mod tests {
use super::*;
-
fn midi_engine() -> (Engine, TrackId) {
let mut e = Engine::default();
e.execute("track.new", &json!({"kind": "midi"})).unwrap();
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 87bba997..d5b18db1 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -1,9 +1,12 @@
//! File menu commands.
+use super::{
+ CommandSpec, always, bad, bool_or, clip_ids_param, has_selection, has_tracks, i64_or, position_param, range_param, str_param, track_param,
+ tracks_param, tracks_required,
+};
-use super::*;
-use crate::cmd;
-use serde_json::json;
-use soundcraft_model::{Session, TrackKind};
+use crate::{Engine, EngineError, Result, cmd};
+use serde_json::{Value, json};
+use soundcraft_model::{Session, TrackId, TrackKind};
use soundcraft_time::SampleRate;
pub fn specs() -> Vec {
@@ -238,7 +241,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result {
let mut out = Vec::new();
for path in &paths {
let bytes = crate::io::read_file(path)?;
- let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string();
+ let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string();
let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
out.push(r);
}
@@ -299,7 +302,7 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result {
let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string();
let r = range_param(e, "file.bounce_mix", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
- let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
+ let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let format_name = str_param(p, "format").unwrap_or(ext.as_str());
let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| {
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS))
diff --git a/crates/engine/src/cmd/health.rs b/crates/engine/src/cmd/health.rs
index d2971e48..36819c99 100644
--- a/crates/engine/src/cmd/health.rs
+++ b/crates/engine/src/cmd/health.rs
@@ -1,8 +1,8 @@
//! Media availability checks without decoding files or changing the session.
+use super::{CommandSpec, always};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_model::ClipContent;
use std::collections::BTreeMap;
@@ -12,6 +12,55 @@ pub fn specs() -> Vec {
]
}
+struct ClipContext<'a> {
+ track: &'a soundcraft_model::Track,
+ playlist: &'a soundcraft_model::Playlist,
+ active: bool,
+ session_sample_rate: u32,
+}
+
+fn check_audio_clip(
+ ctx: &ClipContext<'_>,
+ clip: &soundcraft_model::Clip,
+ source: soundcraft_model::SourceId,
+ offset: i64,
+ metadata: Option<&soundcraft_model::Source>,
+ audio: Option<&soundcraft_model::SourceAudio>,
+ issues: &mut Vec,
+) {
+ let mut add = |code: &str, message: &str| {
+ issues.push(json!({"code": code, "message": message, "track": ctx.track.name, "track_id": ctx.track.id.0,
+ "playlist": ctx.playlist.name, "active": ctx.active, "clip": clip.name, "clip_id": clip.id.0,
+ "source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
+ };
+ if metadata.is_none() {
+ add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
+ }
+ let Some(audio) = audio else {
+ add("unavailable_audio", "Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.");
+ return;
+ };
+ if audio.buffer.sample_rate != ctx.session_sample_rate {
+ add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
+ }
+ if audio.buffer.channels.is_empty() || audio.frames() == 0 {
+ add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
+ return;
+ }
+ if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
+ add("uneven_channels", "Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.");
+ }
+ if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
+ add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
+ }
+ let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
+ // Match the mixer's last requested source sample, including elastic playback.
+ let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
+ if offset < 0 || last >= audio.frames() as f64 {
+ add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
+ }
+}
+
fn report(e: &mut Engine, _: &Value) -> Result {
let s = e.session();
let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect();
@@ -24,51 +73,16 @@ fn report(e: &mut Engine, _: &Value) -> Result {
for track in &s.tracks {
for (index, playlist) in track.playlists.iter().enumerate() {
let active = index == track.active_playlist;
+ let ctx = ClipContext { track, playlist, active, session_sample_rate: s.sample_rate.hz() };
for clip in &playlist.clips {
let ClipContent::Audio { source, offset } = clip.content else { continue };
clips += 1;
if active {
active_end = active_end.max(clip.end());
}
- let metadata = sources.get(&source);
- let audio = s.pool.get(source);
- let mut add = |code: &str, message: &str| {
- issues.push(json!({"code": code, "message": message, "track": track.name, "track_id": track.id.0,
- "playlist": playlist.name, "active": active, "clip": clip.name, "clip_id": clip.id.0,
- "source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
- };
- if metadata.is_none() {
- add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
- }
- let Some(audio) = audio else {
- add(
- "unavailable_audio",
- "Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.",
- );
- continue;
- };
- if audio.buffer.sample_rate != s.sample_rate.hz() {
- add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
- }
- if audio.buffer.channels.is_empty() || audio.frames() == 0 {
- add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
- continue;
- }
- if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
- add(
- "uneven_channels",
- "Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.",
- );
- }
- if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
- add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
- }
- let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
- // Match the mixer's last requested source sample, including elastic playback.
- let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
- if offset < 0 || last >= audio.frames() as f64 {
- add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
- }
+ let metadata = sources.get(&source).copied();
+ let audio = s.pool.get(source).map(|a| a.as_ref());
+ check_audio_clip(&ctx, clip, source, offset, metadata, audio, &mut issues);
}
}
}
diff --git a/crates/engine/src/cmd/instrument.rs b/crates/engine/src/cmd/instrument.rs
index c7e51f11..2a71ba50 100644
--- a/crates/engine/src/cmd/instrument.rs
+++ b/crates/engine/src/cmd/instrument.rs
@@ -1,10 +1,10 @@
//! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI
//! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`;
//! the instrument is otherwise only set when the track is created (`track.new`).
+use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_model::{Insert, TrackKind};
pub fn specs() -> Vec {
diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs
index 129fd070..1d2584e4 100644
--- a/crates/engine/src/cmd/midi.rs
+++ b/crates/engine/src/cmd/midi.rs
@@ -1,8 +1,8 @@
//! MIDI note editing (MIDI Editor, pencil on MIDI tracks).
+use super::{CommandSpec, always, bad, clip_id, has_tracks, i64_or, range_param, tracks_required};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, ClipContent, ClipId};
use soundcraft_time::TICKS_PER_QUARTER;
diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs
index 7502b4f2..8fb5f1f4 100644
--- a/crates/engine/src/cmd/mix.rs
+++ b/crates/engine/src/cmd/mix.rs
@@ -1,8 +1,10 @@
//! Mixer, insert, send and automation commands.
+use super::{
+ CommandSpec, always, bad, bool_or, f32_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param, str_param, tracks_required,
+};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, EngineError, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
pub fn specs() -> Vec {
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index b6f379bd..9a984d62 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -81,7 +81,7 @@ impl CommandSpec {
CommandInfo {
id: self.id.into(),
label: self.label.into(),
- menu: self.menu.iter().map(|s| s.to_string()).collect(),
+ menu: self.menu.iter().map(ToString::to_string).collect(),
shortcut: self.shortcut.map(str::to_string),
params: self.params.into(),
enabled: en.is_ok(),
diff --git a/crates/engine/src/cmd/more_util.rs b/crates/engine/src/cmd/more_util.rs
index bf169821..3d68b6c0 100644
--- a/crates/engine/src/cmd/more_util.rs
+++ b/crates/engine/src/cmd/more_util.rs
@@ -1,8 +1,9 @@
//! Helpers shared by the newer command modules: settings kept in `EditState.flags`/`values`,
//! per-track settings, target playlists, clip-gain envelopes and stretch rendering.
+use super::bad;
+use crate::{Engine, Result};
-use super::*;
-use serde_json::json;
+use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId};
use soundcraft_time::Samples;
@@ -187,7 +188,6 @@ pub fn replace_clip(s: &mut Session, t: TrackId, old: soundcraft_model::ClipId,
#[cfg(test)]
mod tests {
use super::*;
-
#[test]
fn text_settings_round_trip() {
let mut ed = EditState::default();
diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs
index 47356f54..45c4b63a 100644
--- a/crates/engine/src/cmd/options.rs
+++ b/crates/engine/src/cmd/options.rs
@@ -1,8 +1,8 @@
//! Options menu toggles and Setup › Session settings.
+use super::{CommandSpec, always, bad, bool_or, position_param, str_param};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_model::{BitDepthSetting, EditState};
use soundcraft_time::FrameRate;
diff --git a/crates/engine/src/cmd/plugin_state.rs b/crates/engine/src/cmd/plugin_state.rs
index e999c863..bc86ff17 100644
--- a/crates/engine/src/cmd/plugin_state.rs
+++ b/crates/engine/src/cmd/plugin_state.rs
@@ -1,9 +1,10 @@
//! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's
//! state right before saving and stores it on its insert (base64), from where the mixer restores
//! it into new instances.
+use super::{CommandSpec, bad, has_tracks, track_param};
+use crate::{Engine, Result};
-use super::*;
-use serde_json::json;
+use serde_json::{Value, json};
use soundcraft_model::{INSERT_SLOTS, b64};
pub fn specs() -> Vec {
diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs
index 72eb09ea..3aefa852 100644
--- a/crates/engine/src/cmd/query.rs
+++ b/crates/engine/src/cmd/query.rs
@@ -1,8 +1,8 @@
//! Read-only queries for agents.
+use super::{CommandInfo, CommandSpec, always, bad, command_specs, has_tracks, position_param, str_param, track_param};
-use super::*;
use crate::cmd;
-use serde_json::json;
+use serde_json::{Value, json};
pub fn specs() -> Vec {
vec![
diff --git a/crates/engine/src/cmd/sampler.rs b/crates/engine/src/cmd/sampler.rs
index fc3bf182..f4634e3d 100644
--- a/crates/engine/src/cmd/sampler.rs
+++ b/crates/engine/src/cmd/sampler.rs
@@ -4,11 +4,11 @@
//! The source is saved in `Audio Files/` with the session like any other audio, so a session that
//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set
//! with `mix.instrument_param`, or all at once with `params` here.
+use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
-use super::*;
-use crate::cmd;
-use serde_json::json;
-use soundcraft_model::{Insert, TrackKind};
+use crate::{Engine, EngineError, Result, cmd};
+use serde_json::{Value, json};
+use soundcraft_model::{Insert, TrackId, TrackKind};
use std::path::Path;
const SAMPLER: &str = "sampler";
@@ -56,7 +56,7 @@ fn load(e: &mut Engine, p: &Value) -> Result {
}
}
let bytes = crate::io::read_file(&path)?;
- let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string();
+ let name = Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Sample").to_string();
attach(e, t, &name, &bytes, Some(&path), values)
}
@@ -69,7 +69,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
return Err(bad(id, "only instrument tracks have a sampler"));
}
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
- let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
+ let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs
index aee13ba1..369e1542 100644
--- a/crates/engine/src/cmd/setup_more.rs
+++ b/crates/engine/src/cmd/setup_more.rs
@@ -1,10 +1,10 @@
//! More Options toggles, the Setup menu (hardware, playback engine, I/O, timecode, click,
//! shortcuts, MIDI setup) and File › Import › Session Data.
+use super::{CommandSpec, always, bad, command_specs, i64_or, str_param, track_param, tracks_param};
use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, EngineError, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind};
use soundcraft_time::Timecode;
use std::path::Path;
@@ -503,7 +503,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result {
#[cfg(test)]
mod tests {
use super::*;
-
#[test]
fn option_flags_toggle() {
let mut e = Engine::default();
diff --git a/crates/engine/src/cmd/surround.rs b/crates/engine/src/cmd/surround.rs
index bd23be54..6d6f2138 100644
--- a/crates/engine/src/cmd/surround.rs
+++ b/crates/engine/src/cmd/surround.rs
@@ -1,8 +1,8 @@
//! Surround mixing: the main output format and the surround panners of tracks and sends.
+use super::{CommandSpec, always, bad, bool_or, f32_or, has_selection, str_param, tracks_required};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_model::{ChannelFormat, Mixer, OutputPath, Route, SEND_SLOTS, Session, SurroundPan, TrackKind};
pub fn specs() -> Vec {
diff --git a/crates/engine/src/cmd/tab.rs b/crates/engine/src/cmd/tab.rs
index af4c783e..432a6386 100644
--- a/crates/engine/src/cmd/tab.rs
+++ b/crates/engine/src/cmd/tab.rs
@@ -1,10 +1,10 @@
//! Tab and Tab to Transient: the insertion point or selection end to the next or previous clip boundary, transient or note.
+use super::{CommandSpec, always, bool_or};
-use super::*;
-use crate::{TransportRequest, cmd};
-use serde_json::json;
-use soundcraft_model::{Clip, ClipContent, Session, SourceAudio};
-use soundcraft_time::SampleRate;
+use crate::{Engine, Result, TransportRequest, cmd};
+use serde_json::{Value, json};
+use soundcraft_model::{Clip, ClipContent, Session, SourceAudio, TrackId};
+use soundcraft_time::{Range, SampleRate, Samples};
use std::sync::{Arc, Mutex, PoisonError, Weak};
/// Transients are found in fixed blocks of each clip, so a long clip costs a block per press and
diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs
index 4929b468..f76664a0 100644
--- a/crates/engine/src/cmd/track.rs
+++ b/crates/engine/src/cmd/track.rs
@@ -1,9 +1,11 @@
//! Track menu commands.
+use super::{
+ CommandSpec, always, bad, bool_or, has_selection, has_tracks, i64_or, range_param, str_param, track_param, tracks_param, tracks_required,
+};
-use super::*;
-use crate::cmd;
-use serde_json::json;
-use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind};
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
+use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
pub fn specs() -> Vec {
vec![
@@ -944,7 +946,6 @@ fn route(e: &mut Engine, p: &Value, input: bool) -> Result {
#[cfg(test)]
mod tests {
use super::*;
-
#[test]
fn comp_from_alternate_playlist() {
let mut e = crate::demo::demo_engine();
diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs
index 9f547597..99255344 100644
--- a/crates/engine/src/cmd/track_more.rs
+++ b/crates/engine/src/cmd/track_more.rs
@@ -1,10 +1,10 @@
//! More Track menu commands: Convert Aux to Routing Folder, Extract MIDI, track presets, insert /
//! send bypass subsets, MIDI real-time properties, trim/VCA coalescing and target playlists.
+use super::{CommandSpec, bad, has_selection, has_tracks, range_param, str_param, tracks_param, tracks_required};
use super::more_util::{activate_playlist, target_playlist, track_key};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, EngineError, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_dsp::Category;
use soundcraft_model::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipContent, Insert, Route, Session, Track, TrackId, TrackKind};
use soundcraft_time::Samples;
@@ -547,7 +547,6 @@ fn toggle_recent(e: &mut Engine, p: &Value) -> Result {
#[cfg(test)]
mod tests {
use super::*;
-
#[test]
fn bypass_by_category_and_range() {
let mut e = crate::demo::demo_engine();
diff --git a/crates/engine/src/cmd/transport.rs b/crates/engine/src/cmd/transport.rs
index 5fbd48c0..50827802 100644
--- a/crates/engine/src/cmd/transport.rs
+++ b/crates/engine/src/cmd/transport.rs
@@ -1,9 +1,9 @@
//! Transport commands. They update the session's stopped playhead and queue requests for the
//! host's audio engine (which may be absent when headless).
+use super::{CommandSpec, always, bad, f64_or, position_param};
-use super::*;
-use crate::{TransportRequest, cmd};
-use serde_json::json;
+use crate::{Engine, Result, TransportRequest, cmd};
+use serde_json::{Value, json};
pub fn specs() -> Vec {
vec![
diff --git a/crates/engine/src/cmd/video.rs b/crates/engine/src/cmd/video.rs
index c65e40d6..b3a6402a 100644
--- a/crates/engine/src/cmd/video.rs
+++ b/crates/engine/src/cmd/video.rs
@@ -2,10 +2,11 @@
//!
//! Movies are imported with `file.import_video`; the session only references them
//! (`Session::videos`). Decoding happens in the UI (`soundcraft-video`), never in the engine.
+use super::{CommandSpec, always, bad, position_param, track_param};
+use soundcraft_time::MAX_POSITION;
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
use soundcraft_model::{ClipContent, TrackKind};
/// `edit.flags` key set while the Video track is offline (online is the default).
diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs
index 6dee816c..b214a81f 100644
--- a/crates/engine/src/cmd/view.rs
+++ b/crates/engine/src/cmd/view.rs
@@ -1,9 +1,10 @@
//! View menu commands that change saved view state (zoom, rulers, counters, grid/nudge).
+use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_param, str_param};
-use super::*;
-use crate::cmd;
-use serde_json::json;
-use soundcraft_time::{GridValue, NoteValue, TimeFormat};
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
+use soundcraft_time::TimeFormat;
+use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
pub fn specs() -> Vec {
vec![
@@ -160,7 +161,7 @@ pub fn grid_param(p: &Value, key: &str) -> Option {
#[cfg(test)]
mod tests {
use super::*;
-
+ use soundcraft_time::Samples;
#[test]
fn grid_values_parse() {
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
diff --git a/crates/engine/src/cmd/view_more.rs b/crates/engine/src/cmd/view_more.rs
index 79997ed2..e5bd9313 100644
--- a/crates/engine/src/cmd/view_more.rs
+++ b/crates/engine/src/cmd/view_more.rs
@@ -1,11 +1,11 @@
//! More View menu commands: Edit-window column views, extra rulers, marker displays, clip
//! display options, waveform styles, expanded sends, transport views and other view toggles.
//! Every setting is saved with the session in `edit.flags` (or `edit.rulers` for rulers).
+use super::{CommandSpec, always, str_param};
use super::more_util::{exclusive_flag, set_flags, toggle_flag};
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
/// A view toggle: `{value?: bool}` sets it, otherwise it flips.
macro_rules! flag {
@@ -219,7 +219,6 @@ fn toggle_ruler(e: &mut Engine, p: &Value, id: &str) -> Result {
#[cfg(test)]
mod tests {
use super::*;
-
#[test]
fn flags_toggle_and_persist() {
let mut e = Engine::default();
diff --git a/crates/engine/src/cmd/vst3.rs b/crates/engine/src/cmd/vst3.rs
index 927bb433..338c466c 100644
--- a/crates/engine/src/cmd/vst3.rs
+++ b/crates/engine/src/cmd/vst3.rs
@@ -1,10 +1,10 @@
//! Third-party VST3 plugins: discovery (`engine.vst3_plugins`). Hosting itself lives in
//! `soundcraft-vst3-host`; id lookup for the mixer commands is shared with CLAP in
//! `cmd::clap::plugin_info`.
+use super::{CommandSpec, always};
-use super::*;
use crate::cmd;
-use serde_json::json;
+use serde_json::{Value, json};
pub fn specs() -> Vec {
vec![cmd!(
diff --git a/crates/engine/src/cmd/zoom_toggle.rs b/crates/engine/src/cmd/zoom_toggle.rs
index d91d515e..24781759 100644
--- a/crates/engine/src/cmd/zoom_toggle.rs
+++ b/crates/engine/src/cmd/zoom_toggle.rs
@@ -1,9 +1,10 @@
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before.
-
-use super::*;
-use crate::cmd;
-use serde_json::json;
+use super::{CommandSpec, always, f64_or};
use soundcraft_model::{TrackHeight, ZoomToggle};
+use soundcraft_time::MAX_POSITION;
+
+use crate::{Engine, Result, cmd};
+use serde_json::{Value, json};
pub fn specs() -> Vec {
vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)]
@@ -37,8 +38,10 @@ fn toggle(e: &mut Engine, p: &Value) -> Result {
#[cfg(test)]
mod tests {
+ use super::TrackHeight::{Fit, Large, Medium};
use super::*;
- use TrackHeight::{Fit, Large, Medium};
+ use soundcraft_model::TrackId;
+ use soundcraft_time::Samples;
fn engine(select: Value) -> Engine {
let mut e = Engine::default();
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
index bf36f781..b0413431 100644
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::{Range, Samples};
+use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -68,13 +69,7 @@ pub fn file_found(path: impl AsRef) -> bool {
/// Paths the person picks in this session (imports) are not stored paths and skip this.
pub fn stored_path(stored: &str, session: &Path) -> std::result::Result {
let session = std::path::absolute(session).unwrap_or_else(|_| session.to_path_buf());
- let path = craft_docpath::resolve(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))?;
- // A path that is still relative would be taken from the working directory, which the session
- // does not choose.
- if path.is_relative() {
- return Err("not opened: the path is not absolute".into());
- }
- Ok(path)
+ craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))
}
/// Create `dir` and its parents, inside the automation write root when there is one.
@@ -120,7 +115,7 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
buf.sample_rate = sr;
}
let id = SourceId(s.alloc());
- let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string();
+ let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or(name).to_string();
let stored = match path {
Some(p) => p.to_string(),
None => unique_audio_rel(s, &stem),
@@ -151,7 +146,7 @@ fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
/// Decode and place an audio file. With no target track, a new track of matching width is made.
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option, at: Samples) -> Result {
- let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
+ let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES)
.ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
@@ -170,7 +165,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
let track = match target {
Some(t) => t,
None => {
- let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string();
+ let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or("Audio").to_string();
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem))
}
};
@@ -194,8 +189,8 @@ pub fn load_sampler_bytes(e: &mut Engine, track: TrackId, name: &str, bytes: &[u
/// never copied) plus its audio on a new audio track when it has a usable audio stream. Fails
/// only when the file has neither a readable picture nor audio.
pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec, at: Samples) -> Result {
- let stem = Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Video").to_string();
- let ext = Path::new(path).extension().and_then(|x| x.to_str()).unwrap_or("mp4").to_ascii_lowercase();
+ let stem = Path::new(path).file_stem().and_then(OsStr::to_str).unwrap_or("Video").to_string();
+ let ext = Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("mp4").to_ascii_lowercase();
let bytes: Arc<[u8]> = bytes.into();
let movie = soundcraft_video::Movie::open(Arc::clone(&bytes)).map(|m| m.info().clone());
let audio_name = format!("{stem} audio.{}", if ext == "mov" { "mp4" } else { ext.as_str() });
@@ -216,7 +211,7 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec, at: Sample
let vid = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource {
id: vid,
- name: Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("movie").to_string(),
+ name: Path::new(path).file_name().and_then(OsStr::to_str).unwrap_or("movie").to_string(),
path: abs,
width: info.width,
height: info.height,
@@ -998,7 +993,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
sync_dir(&audio_dir);
}
s.folder = Some(dir.clone());
- if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) {
+ if !overwrite && let Some(stem) = p.file_stem().and_then(OsStr::to_str) {
s.name = stem.to_string();
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
@@ -1058,7 +1053,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
/// Copy the session file about to be replaced to `Backups/ backup .`, keeping the
/// newest ten.
fn back_up(file: &Path) {
- let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(|x| x.to_str()), file.extension().and_then(|x| x.to_str()))
+ let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(OsStr::to_str), file.extension().and_then(OsStr::to_str))
else {
return;
};
@@ -1201,7 +1196,7 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std:
.entry(file_key(p))
.or_insert_with(|| {
let bytes = read_file(p).map_err(|_| p.display().to_string())?;
- decode_source(&bytes, p.extension().and_then(|x| x.to_str()), rate).map_err(|err| format!("{}: {err}", p.display()))
+ decode_source(&bytes, p.extension().and_then(OsStr::to_str), rate).map_err(|err| format!("{}: {err}", p.display()))
})
.clone()
}
@@ -1233,7 +1228,7 @@ pub fn set_deliver(h: DeliverHook) {
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
if cfg!(target_arch = "wasm32") {
let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?;
- let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled");
+ let name = Path::new(path).file_name().and_then(OsStr::to_str).filter(|n| !n.is_empty()).unwrap_or("Untitled");
return deliver(name, bytes).map_err(EngineError::Io);
}
crate::file_access::check_write(path).map_err(EngineError::Io)?;
@@ -1278,7 +1273,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec, Session)> {
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
fn save_session_bundle(e: &mut Engine, path: &str) -> Result {
- let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string);
+ let stem = Path::new(path).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).map(str::to_string);
let mut s = e.session().clone();
if let Some(stem) = stem {
s.name = stem;
@@ -1317,7 +1312,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result match decode_source(&bytes, Path::new(&src.path).extension().and_then(|x| x.to_str()), s.sample_rate.hz()) {
+ Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(OsStr::to_str), s.sample_rate.hz()) {
Ok(audio) => Some(audio),
Err(err) => {
missing.push(format!("{}: {err}", src.path));
@@ -1336,7 +1331,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result Result<(String, std::collections::HashMap String {
- p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into())
+ p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into())
}
#[cfg(test)]
diff --git a/crates/engine/tests/bounce.rs b/crates/engine/tests/it/bounce.rs
similarity index 100%
rename from crates/engine/tests/bounce.rs
rename to crates/engine/tests/it/bounce.rs
diff --git a/crates/engine/tests/clap_e2e.rs b/crates/engine/tests/it/clap_e2e.rs
similarity index 100%
rename from crates/engine/tests/clap_e2e.rs
rename to crates/engine/tests/it/clap_e2e.rs
diff --git a/crates/engine/tests/consolidate_selection.rs b/crates/engine/tests/it/consolidate_selection.rs
similarity index 100%
rename from crates/engine/tests/consolidate_selection.rs
rename to crates/engine/tests/it/consolidate_selection.rs
diff --git a/crates/engine/tests/current_feet_frames_position.rs b/crates/engine/tests/it/current_feet_frames_position.rs
similarity index 100%
rename from crates/engine/tests/current_feet_frames_position.rs
rename to crates/engine/tests/it/current_feet_frames_position.rs
diff --git a/crates/engine/tests/edit_modes.rs b/crates/engine/tests/it/edit_modes.rs
similarity index 100%
rename from crates/engine/tests/edit_modes.rs
rename to crates/engine/tests/it/edit_modes.rs
diff --git a/crates/engine/tests/fader_merge_full_history.rs b/crates/engine/tests/it/fader_merge_full_history.rs
similarity index 100%
rename from crates/engine/tests/fader_merge_full_history.rs
rename to crates/engine/tests/it/fader_merge_full_history.rs
diff --git a/crates/engine/tests/hostile_params.proptest-regressions b/crates/engine/tests/it/hostile_params.proptest-regressions
similarity index 100%
rename from crates/engine/tests/hostile_params.proptest-regressions
rename to crates/engine/tests/it/hostile_params.proptest-regressions
diff --git a/crates/engine/tests/hostile_params.rs b/crates/engine/tests/it/hostile_params.rs
similarity index 100%
rename from crates/engine/tests/hostile_params.rs
rename to crates/engine/tests/it/hostile_params.rs
diff --git a/crates/engine/tests/hostile_sessions.proptest-regressions b/crates/engine/tests/it/hostile_sessions.proptest-regressions
similarity index 100%
rename from crates/engine/tests/hostile_sessions.proptest-regressions
rename to crates/engine/tests/it/hostile_sessions.proptest-regressions
diff --git a/crates/engine/tests/hostile_sessions.rs b/crates/engine/tests/it/hostile_sessions.rs
similarity index 100%
rename from crates/engine/tests/hostile_sessions.rs
rename to crates/engine/tests/it/hostile_sessions.rs
diff --git a/crates/engine/tests/long_renders.rs b/crates/engine/tests/it/long_renders.rs
similarity index 100%
rename from crates/engine/tests/long_renders.rs
rename to crates/engine/tests/it/long_renders.rs
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
new file mode 100644
index 00000000..f021c292
--- /dev/null
+++ b/crates/engine/tests/it/main.rs
@@ -0,0 +1,17 @@
+//! Integration tests, built as one test binary: each file is a module.
+
+mod bounce;
+mod clap_e2e;
+mod consolidate_selection;
+mod current_feet_frames_position;
+mod edit_modes;
+mod fader_merge_full_history;
+mod hostile_params;
+mod hostile_sessions;
+mod long_renders;
+mod midi_offset;
+mod mix_required_params;
+mod random_edits;
+mod recording;
+mod session_transport;
+mod trim_to_selection;
diff --git a/crates/engine/tests/midi_offset.rs b/crates/engine/tests/it/midi_offset.rs
similarity index 100%
rename from crates/engine/tests/midi_offset.rs
rename to crates/engine/tests/it/midi_offset.rs
diff --git a/crates/engine/tests/mix_required_params.rs b/crates/engine/tests/it/mix_required_params.rs
similarity index 100%
rename from crates/engine/tests/mix_required_params.rs
rename to crates/engine/tests/it/mix_required_params.rs
diff --git a/crates/engine/tests/random_edits.proptest-regressions b/crates/engine/tests/it/random_edits.proptest-regressions
similarity index 100%
rename from crates/engine/tests/random_edits.proptest-regressions
rename to crates/engine/tests/it/random_edits.proptest-regressions
diff --git a/crates/engine/tests/random_edits.rs b/crates/engine/tests/it/random_edits.rs
similarity index 100%
rename from crates/engine/tests/random_edits.rs
rename to crates/engine/tests/it/random_edits.rs
diff --git a/crates/engine/tests/recording.rs b/crates/engine/tests/it/recording.rs
similarity index 100%
rename from crates/engine/tests/recording.rs
rename to crates/engine/tests/it/recording.rs
diff --git a/crates/engine/tests/session_transport.rs b/crates/engine/tests/it/session_transport.rs
similarity index 100%
rename from crates/engine/tests/session_transport.rs
rename to crates/engine/tests/it/session_transport.rs
diff --git a/crates/engine/tests/trim_to_selection.rs b/crates/engine/tests/it/trim_to_selection.rs
similarity index 100%
rename from crates/engine/tests/trim_to_selection.rs
rename to crates/engine/tests/it/trim_to_selection.rs
diff --git a/crates/midi/tests/it/main.rs b/crates/midi/tests/it/main.rs
new file mode 100644
index 00000000..26fb0ad9
--- /dev/null
+++ b/crates/midi/tests/it/main.rs
@@ -0,0 +1,4 @@
+//! Integration tests, built as one test binary: each file is a module.
+
+mod ops;
+mod smf;
diff --git a/crates/midi/tests/ops.rs b/crates/midi/tests/it/ops.rs
similarity index 100%
rename from crates/midi/tests/ops.rs
rename to crates/midi/tests/it/ops.rs
diff --git a/crates/midi/tests/smf.rs b/crates/midi/tests/it/smf.rs
similarity index 100%
rename from crates/midi/tests/smf.rs
rename to crates/midi/tests/it/smf.rs
diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs
index c04559af..f73f0a71 100644
--- a/crates/mix/src/lib.rs
+++ b/crates/mix/src/lib.rs
@@ -1360,7 +1360,9 @@ fn receives_solo(s: &Session, t: &Track) -> bool {
/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of
/// each bar. Settings come from `setup.click_countoff` (`click.*` values).
mod click {
- use super::*;
+ use super::db_to_gain;
+ use soundcraft_model::Session;
+ use soundcraft_time::Samples;
/// Blip length in seconds.
const LEN_S: f64 = 0.03;
diff --git a/crates/mix/tests/bus_aux_interval.rs b/crates/mix/tests/it/bus_aux_interval.rs
similarity index 98%
rename from crates/mix/tests/bus_aux_interval.rs
rename to crates/mix/tests/it/bus_aux_interval.rs
index ff3b6813..e672e536 100644
--- a/crates/mix/tests/bus_aux_interval.rs
+++ b/crates/mix/tests/it/bus_aux_interval.rs
@@ -1,6 +1,5 @@
//! A bounce of a bus keeps the requested timeline interval when an aux returns onto the bus
//! (its insert's lookahead is compensated like the main mix's).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use soundcraft_audio_io::AudioBuffer;
use soundcraft_mix::{render_bus, render_range};
diff --git a/crates/mix/tests/it/main.rs b/crates/mix/tests/it/main.rs
new file mode 100644
index 00000000..77997334
--- /dev/null
+++ b/crates/mix/tests/it/main.rs
@@ -0,0 +1,6 @@
+//! Integration tests, built as one test binary: each file is a module.
+
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod bus_aux_interval;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod master_insert_latency;
diff --git a/crates/mix/tests/master_insert_latency.rs b/crates/mix/tests/it/master_insert_latency.rs
similarity index 97%
rename from crates/mix/tests/master_insert_latency.rs
rename to crates/mix/tests/it/master_insert_latency.rs
index d4aed976..981c080f 100644
--- a/crates/mix/tests/master_insert_latency.rs
+++ b/crates/mix/tests/it/master_insert_latency.rs
@@ -1,6 +1,5 @@
//! Delay compensation covers the inserts of the master faders: a bounce of the main mix lines up
//! with the same mix without them.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use soundcraft_audio_io::AudioBuffer;
use soundcraft_mix::{MixEngine, render_range};
diff --git a/crates/model/src/mixer.rs b/crates/model/src/mixer.rs
index bef493e8..7f0a24e9 100644
--- a/crates/model/src/mixer.rs
+++ b/crates/model/src/mixer.rs
@@ -196,7 +196,7 @@ impl ChannelFormat {
pub fn channel_label(self, ch: usize) -> String {
match self.speakers().get(ch) {
Some(s) => s.label().to_string(),
- None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), |s| s.to_string()),
+ None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), ToString::to_string),
None => format!("{}", ch + 1),
}
}
diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs
index b72818dc..cac69d99 100644
--- a/crates/model/src/session.rs
+++ b/crates/model/src/session.rs
@@ -288,7 +288,7 @@ impl Default for EditState {
timeline_selection: Range::default(),
flags: ["view.clip.name", "view.clip.gain_line", "waveform.peak", "view.clip.overlap_shadows", "view.marker.ruler_lines"]
.iter()
- .map(|s| s.to_string())
+ .map(ToString::to_string)
.collect(),
values: std::collections::BTreeMap::new(),
}
diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs
index 3c11c844..1244db86 100644
--- a/crates/time/src/format.rs
+++ b/crates/time/src/format.rs
@@ -124,7 +124,7 @@ pub fn parse_position(
}
TimeFormat::BarsBeats => {
let parts: Vec =
- t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(|p| p.parse::()).collect::>().map_err(|_| err())?;
+ t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(str::parse::).collect::>().map_err(|_| err())?;
let bb = BarBeat {
bar: *parts.first().ok_or_else(err)?,
beat: parts.get(1).copied().unwrap_or(1).max(1),
diff --git a/crates/time/tests/it/main.rs b/crates/time/tests/it/main.rs
new file mode 100644
index 00000000..a43e92bf
--- /dev/null
+++ b/crates/time/tests/it/main.rs
@@ -0,0 +1,4 @@
+//! Integration tests, built as one test binary: each file is a module.
+
+mod meter_grid_contract;
+mod min_secs_exact_ms;
diff --git a/crates/time/tests/meter_grid_contract.rs b/crates/time/tests/it/meter_grid_contract.rs
similarity index 100%
rename from crates/time/tests/meter_grid_contract.rs
rename to crates/time/tests/it/meter_grid_contract.rs
diff --git a/crates/time/tests/min_secs_exact_ms.rs b/crates/time/tests/it/min_secs_exact_ms.rs
similarity index 100%
rename from crates/time/tests/min_secs_exact_ms.rs
rename to crates/time/tests/it/min_secs_exact_ms.rs
diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs
index bd58c366..a53128cb 100644
--- a/crates/ui-egui/src/credits.rs
+++ b/crates/ui-egui/src/credits.rs
@@ -210,10 +210,7 @@ impl Default for View {
}
}
-/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
-pub fn contributors_ui(ui: &mut egui::Ui) {
- let id = egui::Id::new("credits_view");
- let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default();
+fn contributors_toolbar(ui: &mut egui::Ui, v: &mut View) {
ui.horizontal_wrapped(|ui| {
ui.label("Show");
for m in NameMode::ALL {
@@ -242,6 +239,24 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
v.table = true;
}
});
+}
+
+fn contributors_grab_bag(ui: &mut egui::Ui, list: &[&Contributor], names: NameMode) {
+ ui.horizontal_wrapped(|ui| {
+ for (i, c) in list.iter().enumerate() {
+ if i > 0 {
+ ui.label(RichText::new("·").weak());
+ }
+ ui.hyperlink_to(c.name(names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
+ }
+ });
+}
+
+/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
+pub fn contributors_ui(ui: &mut egui::Ui) {
+ let id = egui::Id::new("credits_view");
+ let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default();
+ contributors_toolbar(ui, &mut v);
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
ui.separator();
@@ -251,14 +266,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
} else if v.table {
table(ui, &list, &mut v);
} else {
- ui.horizontal_wrapped(|ui| {
- for (i, c) in list.iter().enumerate() {
- if i > 0 {
- ui.label(RichText::new("·").weak());
- }
- ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
- }
- });
+ contributors_grab_bag(ui, &list, v.names);
}
});
ui.data_mut(|d| d.insert_temp(id, v));
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 3fdd9892..8b532400 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -776,7 +776,7 @@ impl SoundApp {
for d in std::fs::read_dir(dir).ok()?.flatten() {
for f in std::fs::read_dir(d.path()).into_iter().flatten().flatten() {
let p = f.path();
- if p.extension().and_then(|x| x.to_str()) == Some("scraft")
+ if p.extension().and_then(std::ffi::OsStr::to_str) == Some("scraft")
&& let Ok(m) = f.metadata().and_then(|m| m.modified())
&& best.as_ref().is_none_or(|(t, _)| m > *t)
{
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 99e4b503..9ba795ca 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -86,7 +86,7 @@ pub fn ui_aliases() -> Vec<(&'static str, &'static str)> {
/// Mix-view menu paths, allocated once. Rebuilding them on every menu draw leaked a copy per frame.
fn mix_view_aliases() -> &'static [(&'static str, &'static str)] {
- static ALIASES: std::sync::OnceLock> = std::sync::OnceLock::new();
+ static ALIASES: std::sync::OnceLock> = std::sync::OnceLock::new();
ALIASES.get_or_init(|| {
[
"Inserts A-E",
@@ -633,7 +633,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option {
app.ui.mix_views =
- ["inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "io", "comments", "color"].iter().map(|s| s.to_string()).collect()
+ ["inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "io", "comments", "color"].iter().map(ToString::to_string).collect()
}
"minimal" => app.ui.mix_views.clear(),
"" => return Some(Err("`section` required".into())),
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index a208511f..07664994 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -371,6 +371,29 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_big_counter = open;
}
+fn memory_location_row(app: &mut SoundApp, ui: &mut Ui, n: u32, name: &str, loc: &str, len: &str, kind: &str) {
+ if ui.button(n.to_string()).on_hover_text("Recall").clicked() {
+ let _ = app.run("markers.recall", json!({"number": n}));
+ }
+ let key = egui::Id::new(("mem_name", n));
+ let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.to_string());
+ let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0));
+ if r.changed() {
+ ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
+ }
+ if r.lost_focus() && edit != name {
+ let _ = app.run("markers.edit", json!({"number": n, "rename": edit}));
+ ui.ctx().memory_mut(|m| m.data.remove::(key));
+ }
+ ui.label(egui::RichText::new(loc).font(mono(11.0)));
+ ui.label(egui::RichText::new(len).font(mono(11.0)));
+ ui.label(kind);
+ if ui.small_button("✕").on_hover_text("Delete").clicked() {
+ let _ = app.run("markers.delete", json!({"number": n}));
+ }
+ ui.end_row();
+}
+
pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
ui.horizontal(|ui| {
if ui.button("+ Marker").clicked() {
@@ -402,27 +425,8 @@ pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
ui.label(egui::RichText::new(h).strong());
}
ui.end_row();
- for (n, name, loc, len, kind) in rows {
- if ui.button(n.to_string()).on_hover_text("Recall").clicked() {
- let _ = app.run("markers.recall", json!({"number": n}));
- }
- let key = egui::Id::new(("mem_name", n));
- let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone());
- let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0));
- if r.changed() {
- ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
- }
- if r.lost_focus() && edit != name {
- let _ = app.run("markers.edit", json!({"number": n, "rename": edit}));
- ui.ctx().memory_mut(|m| m.data.remove::(key));
- }
- ui.label(egui::RichText::new(loc).font(mono(11.0)));
- ui.label(egui::RichText::new(len).font(mono(11.0)));
- ui.label(kind);
- if ui.small_button("✕").on_hover_text("Delete").clicked() {
- let _ = app.run("markers.delete", json!({"number": n}));
- }
- ui.end_row();
+ for (n, name, loc, len, kind) in &rows {
+ memory_location_row(app, ui, *n, name, loc, len, kind);
}
});
});
@@ -669,7 +673,7 @@ fn preset_menu(
return;
};
let mut names: Vec = std::fs::read_dir(&dir)
- .map(|rd| rd.flatten().filter_map(|e| e.path().file_stem().and_then(|x| x.to_str()).map(str::to_string)).collect())
+ .map(|rd| rd.flatten().filter_map(|e| e.path().file_stem().and_then(std::ffi::OsStr::to_str).map(str::to_string)).collect())
.unwrap_or_default();
names.sort();
for n in &names {
diff --git a/crates/ui-egui/src/recent.rs b/crates/ui-egui/src/recent.rs
index 12be3980..597cc99c 100644
--- a/crates/ui-egui/src/recent.rs
+++ b/crates/ui-egui/src/recent.rs
@@ -13,6 +13,7 @@
use crate::SoundApp;
use serde_json::json;
+use std::ffi::OsStr;
use std::path::Path;
/// How many sessions the menu keeps.
@@ -45,8 +46,8 @@ pub fn note(list: &mut Vec, path: &str) -> bool {
/// "name — folder" for a session path.
fn label(path: &str) -> String {
let p = Path::new(path);
- let name = p.file_stem().and_then(|n| n.to_str()).unwrap_or(path);
- match p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str()) {
+ let name = p.file_stem().and_then(OsStr::to_str).unwrap_or(path);
+ match p.parent().and_then(Path::file_name).and_then(OsStr::to_str) {
Some(folder) => format!("{name} — {folder}"),
None => name.to_string(),
}
diff --git a/crates/ui-egui/src/sampler_window.rs b/crates/ui-egui/src/sampler_window.rs
index b259343d..45f9697b 100644
--- a/crates/ui-egui/src/sampler_window.rs
+++ b/crates/ui-egui/src/sampler_window.rs
@@ -8,6 +8,7 @@ use crate::theme::{Tokens, mono, regular};
use egui::{Align2, Color32, CursorIcon, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_model::{Insert, TrackId};
+use std::ffi::OsStr;
use std::path::Path;
/// The built-in Sampler's plugin id.
@@ -21,7 +22,7 @@ const MARKERS: [(&str, bool); 3] = [("start", false), ("loop_start", true), ("lo
/// Whether a file name looks like audio the sampler can load.
pub fn is_audio_name(name: &str) -> bool {
- Path::new(name).extension().and_then(|x| x.to_str()).is_some_and(|x| AUDIO_EXTS.contains(&x.to_ascii_lowercase().as_str()))
+ Path::new(name).extension().and_then(OsStr::to_str).is_some_and(|x| AUDIO_EXTS.contains(&x.to_ascii_lowercase().as_str()))
}
/// Sampler parameters that are MIDI notes.
diff --git a/crates/ui-egui/src/spot.rs b/crates/ui-egui/src/spot.rs
index c9b4102b..e92798fe 100644
--- a/crates/ui-egui/src/spot.rs
+++ b/crates/ui-egui/src/spot.rs
@@ -113,9 +113,44 @@ fn recorded_at(s: &Session, time_reference: u64) -> Samples {
i64::try_from(time_reference).unwrap_or(0).saturating_sub(s.timecode_start)
}
+fn spot_field_row(ui: &mut Ui, s: &Session, d: &mut Spot, i: usize, label: &str) {
+ let show = |at: Samples, f: TimeFormat| s.format_position(at, f);
+ ui.label(label);
+ if d.editable(i) {
+ let id = Id::new(("spot_field", i));
+ if !ui.memory(|m| m.has_focus(id)) {
+ d.text[i] = show(d.value(i), d.format);
+ }
+ let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0));
+ if r.changed()
+ && let Ok(v) = s.parse_position(&d.text[i], d.format)
+ && (-MAX_POSITION..=MAX_POSITION).contains(&v)
+ {
+ d.set(i, v);
+ }
+ if std::mem::take(&mut d.fresh) {
+ r.request_focus();
+ select_all(ui, id, &d.text[i]);
+ }
+ } else {
+ ui.label(RichText::new(show(d.value(i), d.format)).font(mono(13.0)));
+ }
+ ui.end_row();
+}
+
+fn original_time_stamp_row(ui: &mut Ui, s: &Session, d: &mut Spot, at: Samples) {
+ ui.label("Original Time Stamp");
+ ui.horizontal(|ui| {
+ ui.label(RichText::new(s.format_position(at, TimeFormat::Timecode)).font(mono(13.0)));
+ if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() {
+ d.start = at;
+ }
+ });
+ ui.end_row();
+}
+
/// The dialog's fields; the caller adds OK and Cancel.
pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) {
- let show = |at: Samples, f: TimeFormat| s.format_position(at, f);
egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label("Time Scale");
egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| {
@@ -128,40 +163,13 @@ pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) {
if i == 1 && matches!(d.target, Target::Trim { .. }) {
continue;
}
- ui.label(label);
- if d.editable(i) {
- let id = Id::new(("spot_field", i));
- if !ui.memory(|m| m.has_focus(id)) {
- d.text[i] = show(d.value(i), d.format);
- }
- let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0));
- if r.changed()
- && let Ok(v) = s.parse_position(&d.text[i], d.format)
- && (-MAX_POSITION..=MAX_POSITION).contains(&v)
- {
- d.set(i, v);
- }
- if std::mem::take(&mut d.fresh) {
- r.request_focus();
- select_all(ui, id, &d.text[i]);
- }
- } else {
- ui.label(RichText::new(show(d.value(i), d.format)).font(mono(13.0)));
- }
- ui.end_row();
+ spot_field_row(ui, s, d, i, label);
}
ui.label("Duration");
ui.label(RichText::new(format_length(d.start, d.len, d.format, s.sample_rate, &s.tempo, s.frame_rate)).font(mono(13.0)));
ui.end_row();
if let Some(at) = d.stamp {
- ui.label("Original Time Stamp");
- ui.horizontal(|ui| {
- ui.label(RichText::new(show(at, TimeFormat::Timecode)).font(mono(13.0)));
- if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() {
- d.start = at;
- }
- });
- ui.end_row();
+ original_time_stamp_row(ui, s, d, at);
}
});
}
diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs
index 41d83ac4..c3169790 100644
--- a/crates/ui-egui/src/toolbar.rs
+++ b/crates/ui-egui/src/toolbar.rs
@@ -43,10 +43,40 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
});
}
+struct ModeCellState {
+ cur: EditMode,
+ snap: bool,
+ locked: bool,
+}
+
+fn edit_mode_tooltip(m: EditMode, locked: bool, cmd: &str, fkey: &str) -> String {
+ match m {
+ EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"),
+ EditMode::Shuffle => format!("Shuffle mode ({fkey}); {cmd}-click locks it out"),
+ EditMode::Grid => format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes"),
+ _ => format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }),
+ }
+}
+
+fn draw_mode_cell(p: &egui::Painter, cell: Rect, resp: &egui::Response, t: &Tokens, m: EditMode, state: &ModeCellState, label: &str) {
+ let on = state.cur == m || (m == EditMode::Grid && state.cur == EditMode::GridRelative);
+ key(p, cell, resp, if on { t.mode_on } else { t.mode_off });
+ let fg = if on { t.mode_on_text } else { t.mode_off_text };
+ if m == EditMode::Grid && state.snap {
+ p.rect_stroke(cell, 1.0, Stroke::new(1.5, t.mode_on), StrokeKind::Inside);
+ }
+ if m == EditMode::Shuffle && state.locked {
+ icons::draw(p, Rect::from_center_size(cell.center(), vec2(15.0, 15.0)), "lock", fg);
+ } else {
+ let txt = if m == EditMode::Grid && state.cur == EditMode::GridRelative { "REL GRID" } else { label };
+ p.text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), fg);
+ }
+}
+
fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let ed = &app.engine.session().edit;
- let (cur, snap, locked) = (ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK));
+ let state = ModeCellState { cur: ed.edit_mode, snap: ed.flag(SNAP_TO_GRID), locked: ed.flag(SHUFFLE_LOCK) };
let cmd = crate::shortcuts::shortcut_label("Cmd", ui.ctx().os() == egui::os::OperatingSystem::Mac);
let modes = [
(EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"),
@@ -63,28 +93,13 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
for (m, cx, cy, label, fkey) in modes {
let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0);
let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click());
- let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative);
- key(ui.painter(), cell, &resp, if on { t.mode_on } else { t.mode_off });
- let fg = if on { t.mode_on_text } else { t.mode_off_text };
- if m == EditMode::Grid && snap {
- ui.painter().rect_stroke(cell, 1.0, Stroke::new(1.5, t.mode_on), StrokeKind::Inside);
- }
- if m == EditMode::Shuffle && locked {
- icons::draw(ui.painter(), Rect::from_center_size(cell.center(), vec2(15.0, 15.0)), "lock", fg);
- } else {
- let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label };
- ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), fg);
- }
+ draw_mode_cell(ui.painter(), cell, &resp, &t, m, &state, label);
+ let on = state.cur == m || (m == EditMode::Grid && state.cur == EditMode::GridRelative);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label));
if resp.clicked() {
mode_click(app, m, ui.input(|i| i.modifiers));
}
- resp.on_hover_text(match m {
- EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"),
- EditMode::Shuffle => format!("Shuffle mode ({fkey}); {cmd}-click locks it out"),
- EditMode::Grid => format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes"),
- _ => format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }),
- });
+ resp.on_hover_text(edit_mode_tooltip(m, state.locked, &cmd, fkey));
}
});
}
diff --git a/crates/ui-egui/src/video_window.rs b/crates/ui-egui/src/video_window.rs
index 61809e78..3e66e165 100644
--- a/crates/ui-egui/src/video_window.rs
+++ b/crates/ui-egui/src/video_window.rs
@@ -18,6 +18,43 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
}
+fn video_status_text(app: &SoundApp, pic: Option<&(MovieKey, Result, f64, f64)>) -> String {
+ match pic {
+ Some((_, Err(why), ..)) => why.clone(),
+ Some((k, ..)) => match app.video.info(*k) {
+ Some(Ok(i)) => format!("{}x{} {:.3} fps {}", i.width, i.height, i.frame_rate, i.codec_detail),
+ Some(Err(e)) => e.clone(),
+ None => "opening…".into(),
+ },
+ None => String::new(),
+ }
+}
+
+fn draw_video_surface(app: &SoundApp, painter: &egui::Painter, img: Rect, pic: Option<&(MovieKey, Result, f64, f64)>, on: bool) {
+ let msg = if !on {
+ Some("Video Track Offline".to_string())
+ } else {
+ match pic {
+ None => None,
+ Some((_, Err(why), ..)) => Some(why.clone()),
+ Some((k, ..)) => match (app.video.current(*k), app.video.current_error(*k)) {
+ (Some(tex), _) => {
+ painter.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
+ None
+ }
+ (None, Some(e)) => Some(e.to_string()),
+ (None, None) => match app.video.info(*k) {
+ Some(Err(e)) => Some(e.clone()),
+ _ => None,
+ },
+ },
+ }
+ };
+ if let Some(m) = msg {
+ painter.text(img.center(), Align2::CENTER_CENTER, m, regular(12.0), Color32::from_gray(170));
+ }
+}
+
fn video(app: &mut SoundApp, ctx: &egui::Context) {
app.video.poll(ctx);
let pos = app.position();
@@ -38,15 +75,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
egui::Window::new("Video").open(&mut open).default_size(vec2(480.0, 480.0 / aspect + 28.0)).resizable(true).show(ctx, |ui| {
ui.horizontal(|ui| {
crate::widgets::checkbox(ui, &mut burn, "Timecode burn-in");
- let text = match &pic {
- Some((_, Err(why), ..)) => why.clone(),
- Some((k, ..)) => match app.video.info(*k) {
- Some(Ok(i)) => format!("{}x{} {:.3} fps {}", i.width, i.height, i.frame_rate, i.codec_detail),
- Some(Err(e)) => e.clone(),
- None => "opening…".into(),
- },
- None => String::new(),
- };
+ let text = video_status_text(app, pic.as_ref());
ui.label(egui::RichText::new(text).font(regular(10.5)).color(Color32::from_gray(150)));
});
let avail = ui.available_rect_before_wrap();
@@ -60,28 +89,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
w = h * aspect;
}
let img = Rect::from_center_size(r.center(), vec2(w, h));
- let msg = if !on {
- Some("Video Track Offline".to_string())
- } else {
- match &pic {
- None => None,
- Some((_, Err(why), ..)) => Some(why.clone()),
- Some((k, ..)) => match (app.video.current(*k), app.video.current_error(*k)) {
- (Some(tex), _) => {
- painter.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
- None
- }
- (None, Some(e)) => Some(e.to_string()),
- (None, None) => match app.video.info(*k) {
- Some(Err(e)) => Some(e.clone()),
- _ => None,
- },
- },
- }
- };
- if let Some(m) = msg {
- painter.text(img.center(), Align2::CENTER_CENTER, m, regular(12.0), Color32::from_gray(170));
- }
+ draw_video_surface(app, &painter, img, pic.as_ref(), on);
if burn {
let tc = s.format_position(pos, TimeFormat::Timecode);
let size = (h / 12.0).clamp(10.0, 36.0);
diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs
index 78a91081..62bb3ae2 100644
--- a/crates/ui-egui/src/windows.rs
+++ b/crates/ui-egui/src/windows.rs
@@ -341,7 +341,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
return None;
}
let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
- let ext = std::path::Path::new(&name).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase();
+ let ext = std::path::Path::new(&name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
(is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "m4b", "caf", "mid", "midi", "scraft"].contains(&ext.as_str()))
.then_some((name, is_dir))
})
diff --git a/crates/ui-egui/tests/appearance.rs b/crates/ui-egui/tests/it/appearance.rs
similarity index 100%
rename from crates/ui-egui/tests/appearance.rs
rename to crates/ui-egui/tests/it/appearance.rs
diff --git a/crates/ui-egui/tests/audio_device.rs b/crates/ui-egui/tests/it/audio_device.rs
similarity index 100%
rename from crates/ui-egui/tests/audio_device.rs
rename to crates/ui-egui/tests/it/audio_device.rs
diff --git a/crates/ui-egui/tests/bounce.rs b/crates/ui-egui/tests/it/bounce.rs
similarity index 100%
rename from crates/ui-egui/tests/bounce.rs
rename to crates/ui-egui/tests/it/bounce.rs
diff --git a/crates/ui-egui/tests/bounce_dialog.rs b/crates/ui-egui/tests/it/bounce_dialog.rs
similarity index 100%
rename from crates/ui-egui/tests/bounce_dialog.rs
rename to crates/ui-egui/tests/it/bounce_dialog.rs
diff --git a/crates/ui-egui/tests/clip_gain_fader.rs b/crates/ui-egui/tests/it/clip_gain_fader.rs
similarity index 100%
rename from crates/ui-egui/tests/clip_gain_fader.rs
rename to crates/ui-egui/tests/it/clip_gain_fader.rs
diff --git a/crates/ui-egui/tests/clip_menu.rs b/crates/ui-egui/tests/it/clip_menu.rs
similarity index 100%
rename from crates/ui-egui/tests/clip_menu.rs
rename to crates/ui-egui/tests/it/clip_menu.rs
diff --git a/crates/ui-egui/tests/command_reachability.rs b/crates/ui-egui/tests/it/command_reachability.rs
similarity index 100%
rename from crates/ui-egui/tests/command_reachability.rs
rename to crates/ui-egui/tests/it/command_reachability.rs
diff --git a/crates/ui-egui/tests/commands_filter.rs b/crates/ui-egui/tests/it/commands_filter.rs
similarity index 100%
rename from crates/ui-egui/tests/commands_filter.rs
rename to crates/ui-egui/tests/it/commands_filter.rs
diff --git a/crates/ui-egui/tests/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs
similarity index 100%
rename from crates/ui-egui/tests/docking_workflows.rs
rename to crates/ui-egui/tests/it/docking_workflows.rs
diff --git a/crates/ui-egui/tests/edit_menu.rs b/crates/ui-egui/tests/it/edit_menu.rs
similarity index 100%
rename from crates/ui-egui/tests/edit_menu.rs
rename to crates/ui-egui/tests/it/edit_menu.rs
diff --git a/crates/ui-egui/tests/edit_modes.rs b/crates/ui-egui/tests/it/edit_modes.rs
similarity index 100%
rename from crates/ui-egui/tests/edit_modes.rs
rename to crates/ui-egui/tests/it/edit_modes.rs
diff --git a/crates/ui-egui/tests/edit_window_headers.rs b/crates/ui-egui/tests/it/edit_window_headers.rs
similarity index 100%
rename from crates/ui-egui/tests/edit_window_headers.rs
rename to crates/ui-egui/tests/it/edit_window_headers.rs
diff --git a/crates/ui-egui/tests/group_dialog.rs b/crates/ui-egui/tests/it/group_dialog.rs
similarity index 100%
rename from crates/ui-egui/tests/group_dialog.rs
rename to crates/ui-egui/tests/it/group_dialog.rs
diff --git a/crates/ui-egui/tests/hide_floating.rs b/crates/ui-egui/tests/it/hide_floating.rs
similarity index 100%
rename from crates/ui-egui/tests/hide_floating.rs
rename to crates/ui-egui/tests/it/hide_floating.rs
diff --git a/crates/ui-egui/tests/idle.rs b/crates/ui-egui/tests/it/idle.rs
similarity index 100%
rename from crates/ui-egui/tests/idle.rs
rename to crates/ui-egui/tests/it/idle.rs
diff --git a/crates/ui-egui/tests/key_families.rs b/crates/ui-egui/tests/it/key_families.rs
similarity index 100%
rename from crates/ui-egui/tests/key_families.rs
rename to crates/ui-egui/tests/it/key_families.rs
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
new file mode 100644
index 00000000..d9eb9fe6
--- /dev/null
+++ b/crates/ui-egui/tests/it/main.rs
@@ -0,0 +1,53 @@
+//! Integration tests, built as one test binary: each file is a module.
+//!
+//! Tests that switch the process-wide Light/Dark palette, and `recovery.rs` (whose autosave locks
+//! leak into child processes other tests start), are separate binaries in `tests/`.
+
+mod appearance;
+mod audio_device;
+mod bounce;
+mod bounce_dialog;
+mod clip_gain_fader;
+mod clip_menu;
+mod command_reachability;
+mod commands_filter;
+mod docking_workflows;
+mod edit_menu;
+mod edit_modes;
+mod edit_window_headers;
+mod group_dialog;
+mod hide_floating;
+mod idle;
+mod key_families;
+mod marker_rename;
+mod menu_search;
+mod midi_double_click;
+mod midi_editor_keys;
+mod midi_front_visibility;
+mod midi_panel;
+mod midi_quantize;
+mod mix_narrow_headers;
+mod mix_separate;
+mod new_midi_clip;
+mod new_tracks;
+mod open_on_insert;
+mod plugin_window;
+mod pro_tools_keys;
+mod record_hint;
+mod rename_dialog;
+mod sampler_window;
+mod save_as_replace;
+mod save_changes;
+mod send_window;
+mod shared_controls;
+mod shortcuts;
+mod side_lists_access;
+mod toolbar_tempo;
+mod track_height;
+mod track_list;
+mod track_name_menu;
+mod trackpad;
+mod tracks_scroll_bar;
+mod waveform;
+mod window_shortcuts;
+mod zoom_toggle;
diff --git a/crates/ui-egui/tests/marker_rename.rs b/crates/ui-egui/tests/it/marker_rename.rs
similarity index 100%
rename from crates/ui-egui/tests/marker_rename.rs
rename to crates/ui-egui/tests/it/marker_rename.rs
diff --git a/crates/ui-egui/tests/menu_search.rs b/crates/ui-egui/tests/it/menu_search.rs
similarity index 100%
rename from crates/ui-egui/tests/menu_search.rs
rename to crates/ui-egui/tests/it/menu_search.rs
diff --git a/crates/ui-egui/tests/midi_double_click.rs b/crates/ui-egui/tests/it/midi_double_click.rs
similarity index 100%
rename from crates/ui-egui/tests/midi_double_click.rs
rename to crates/ui-egui/tests/it/midi_double_click.rs
diff --git a/crates/ui-egui/tests/midi_editor_keys.rs b/crates/ui-egui/tests/it/midi_editor_keys.rs
similarity index 100%
rename from crates/ui-egui/tests/midi_editor_keys.rs
rename to crates/ui-egui/tests/it/midi_editor_keys.rs
diff --git a/crates/ui-egui/tests/midi_front_visibility.rs b/crates/ui-egui/tests/it/midi_front_visibility.rs
similarity index 100%
rename from crates/ui-egui/tests/midi_front_visibility.rs
rename to crates/ui-egui/tests/it/midi_front_visibility.rs
diff --git a/crates/ui-egui/tests/midi_panel.rs b/crates/ui-egui/tests/it/midi_panel.rs
similarity index 100%
rename from crates/ui-egui/tests/midi_panel.rs
rename to crates/ui-egui/tests/it/midi_panel.rs
diff --git a/crates/ui-egui/tests/midi_quantize.rs b/crates/ui-egui/tests/it/midi_quantize.rs
similarity index 100%
rename from crates/ui-egui/tests/midi_quantize.rs
rename to crates/ui-egui/tests/it/midi_quantize.rs
diff --git a/crates/ui-egui/tests/mix_narrow_headers.rs b/crates/ui-egui/tests/it/mix_narrow_headers.rs
similarity index 100%
rename from crates/ui-egui/tests/mix_narrow_headers.rs
rename to crates/ui-egui/tests/it/mix_narrow_headers.rs
diff --git a/crates/ui-egui/tests/mix_separate.rs b/crates/ui-egui/tests/it/mix_separate.rs
similarity index 100%
rename from crates/ui-egui/tests/mix_separate.rs
rename to crates/ui-egui/tests/it/mix_separate.rs
diff --git a/crates/ui-egui/tests/new_midi_clip.rs b/crates/ui-egui/tests/it/new_midi_clip.rs
similarity index 100%
rename from crates/ui-egui/tests/new_midi_clip.rs
rename to crates/ui-egui/tests/it/new_midi_clip.rs
diff --git a/crates/ui-egui/tests/new_tracks.rs b/crates/ui-egui/tests/it/new_tracks.rs
similarity index 100%
rename from crates/ui-egui/tests/new_tracks.rs
rename to crates/ui-egui/tests/it/new_tracks.rs
diff --git a/crates/ui-egui/tests/open_on_insert.rs b/crates/ui-egui/tests/it/open_on_insert.rs
similarity index 100%
rename from crates/ui-egui/tests/open_on_insert.rs
rename to crates/ui-egui/tests/it/open_on_insert.rs
diff --git a/crates/ui-egui/tests/plugin_window.rs b/crates/ui-egui/tests/it/plugin_window.rs
similarity index 100%
rename from crates/ui-egui/tests/plugin_window.rs
rename to crates/ui-egui/tests/it/plugin_window.rs
diff --git a/crates/ui-egui/tests/pro_tools_keys.rs b/crates/ui-egui/tests/it/pro_tools_keys.rs
similarity index 100%
rename from crates/ui-egui/tests/pro_tools_keys.rs
rename to crates/ui-egui/tests/it/pro_tools_keys.rs
diff --git a/crates/ui-egui/tests/record_hint.rs b/crates/ui-egui/tests/it/record_hint.rs
similarity index 100%
rename from crates/ui-egui/tests/record_hint.rs
rename to crates/ui-egui/tests/it/record_hint.rs
diff --git a/crates/ui-egui/tests/rename_dialog.rs b/crates/ui-egui/tests/it/rename_dialog.rs
similarity index 100%
rename from crates/ui-egui/tests/rename_dialog.rs
rename to crates/ui-egui/tests/it/rename_dialog.rs
diff --git a/crates/ui-egui/tests/sampler_window.rs b/crates/ui-egui/tests/it/sampler_window.rs
similarity index 100%
rename from crates/ui-egui/tests/sampler_window.rs
rename to crates/ui-egui/tests/it/sampler_window.rs
diff --git a/crates/ui-egui/tests/save_as_replace.rs b/crates/ui-egui/tests/it/save_as_replace.rs
similarity index 100%
rename from crates/ui-egui/tests/save_as_replace.rs
rename to crates/ui-egui/tests/it/save_as_replace.rs
diff --git a/crates/ui-egui/tests/save_changes.rs b/crates/ui-egui/tests/it/save_changes.rs
similarity index 100%
rename from crates/ui-egui/tests/save_changes.rs
rename to crates/ui-egui/tests/it/save_changes.rs
diff --git a/crates/ui-egui/tests/send_window.rs b/crates/ui-egui/tests/it/send_window.rs
similarity index 100%
rename from crates/ui-egui/tests/send_window.rs
rename to crates/ui-egui/tests/it/send_window.rs
diff --git a/crates/ui-egui/tests/shared_controls.rs b/crates/ui-egui/tests/it/shared_controls.rs
similarity index 100%
rename from crates/ui-egui/tests/shared_controls.rs
rename to crates/ui-egui/tests/it/shared_controls.rs
diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/it/shortcuts.rs
similarity index 100%
rename from crates/ui-egui/tests/shortcuts.rs
rename to crates/ui-egui/tests/it/shortcuts.rs
diff --git a/crates/ui-egui/tests/side_lists_access.rs b/crates/ui-egui/tests/it/side_lists_access.rs
similarity index 100%
rename from crates/ui-egui/tests/side_lists_access.rs
rename to crates/ui-egui/tests/it/side_lists_access.rs
diff --git a/crates/ui-egui/tests/toolbar_tempo.rs b/crates/ui-egui/tests/it/toolbar_tempo.rs
similarity index 100%
rename from crates/ui-egui/tests/toolbar_tempo.rs
rename to crates/ui-egui/tests/it/toolbar_tempo.rs
diff --git a/crates/ui-egui/tests/track_height.rs b/crates/ui-egui/tests/it/track_height.rs
similarity index 100%
rename from crates/ui-egui/tests/track_height.rs
rename to crates/ui-egui/tests/it/track_height.rs
diff --git a/crates/ui-egui/tests/track_list.rs b/crates/ui-egui/tests/it/track_list.rs
similarity index 100%
rename from crates/ui-egui/tests/track_list.rs
rename to crates/ui-egui/tests/it/track_list.rs
diff --git a/crates/ui-egui/tests/track_name_menu.rs b/crates/ui-egui/tests/it/track_name_menu.rs
similarity index 100%
rename from crates/ui-egui/tests/track_name_menu.rs
rename to crates/ui-egui/tests/it/track_name_menu.rs
diff --git a/crates/ui-egui/tests/trackpad.rs b/crates/ui-egui/tests/it/trackpad.rs
similarity index 100%
rename from crates/ui-egui/tests/trackpad.rs
rename to crates/ui-egui/tests/it/trackpad.rs
diff --git a/crates/ui-egui/tests/tracks_scroll_bar.rs b/crates/ui-egui/tests/it/tracks_scroll_bar.rs
similarity index 100%
rename from crates/ui-egui/tests/tracks_scroll_bar.rs
rename to crates/ui-egui/tests/it/tracks_scroll_bar.rs
diff --git a/crates/ui-egui/tests/waveform.rs b/crates/ui-egui/tests/it/waveform.rs
similarity index 100%
rename from crates/ui-egui/tests/waveform.rs
rename to crates/ui-egui/tests/it/waveform.rs
diff --git a/crates/ui-egui/tests/window_shortcuts.rs b/crates/ui-egui/tests/it/window_shortcuts.rs
similarity index 100%
rename from crates/ui-egui/tests/window_shortcuts.rs
rename to crates/ui-egui/tests/it/window_shortcuts.rs
diff --git a/crates/ui-egui/tests/zoom_toggle.rs b/crates/ui-egui/tests/it/zoom_toggle.rs
similarity index 100%
rename from crates/ui-egui/tests/zoom_toggle.rs
rename to crates/ui-egui/tests/it/zoom_toggle.rs
diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs
index 00ea2654..5c295c80 100644
--- a/crates/ui-egui/tests/recovery.rs
+++ b/crates/ui-egui/tests/recovery.rs
@@ -52,6 +52,10 @@ fn an_exit_without_quit_offers_unsaved_changes_at_the_next_launch() {
let path = dir.join("Song.scraft");
let mut first = launch(&root);
first.run("session.save_as", json!({"path": path})).unwrap();
+ // Leftovers are offered only when newer than their session file; on filesystems with coarse
+ // timestamps (tmpfs) the save and the autosave right after it can share one mtime (#579).
+ let then = std::time::SystemTime::now() - std::time::Duration::from_secs(1);
+ std::fs::File::options().write(true).open(&path).unwrap().set_modified(then).unwrap();
first.run("track.new", json!({"name": "Vocal"})).unwrap();
first.close_autosave();
drop(first);
diff --git a/crates/video/Cargo.toml b/crates/video/Cargo.toml
index d8165ab2..3289b021 100644
--- a/crates/video/Cargo.toml
+++ b/crates/video/Cargo.toml
@@ -14,6 +14,7 @@ default = []
threads = ["dep:rayon"]
[dependencies]
+craft-media.workspace = true
thiserror.workspace = true
serde.workspace = true
image = { workspace = true, features = ["jpeg"] }
diff --git a/crates/video/src/bitstream.rs b/crates/video/src/bitstream.rs
deleted file mode 100644
index b80e702a..00000000
--- a/crates/video/src/bitstream.rs
+++ /dev/null
@@ -1,475 +0,0 @@
-//! Bit-level reading and writing shared by FilmCraft's codec crates.
-//!
-//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
-//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
-//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
-//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
-
-use std::fmt;
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum BitError {
- /// Tried to read past the end of the buffer.
- Eof,
- /// An Exp-Golomb code longer than 32 leading zeros.
- InvalidExpGolomb,
- /// A NAL length-prefix width outside the AVCC/HVCC range of 1..=4 bytes.
- InvalidLengthSize,
-}
-
-impl fmt::Display for BitError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- BitError::Eof => f.write_str("unexpected end of bitstream"),
- BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
- BitError::InvalidLengthSize => f.write_str("invalid NAL length size"),
- }
- }
-}
-impl std::error::Error for BitError {}
-
-pub type Result = std::result::Result;
-
-/// MSB-first bit reader over a byte slice.
-#[derive(Clone)]
-pub struct BitReader<'a> {
- data: &'a [u8],
- /// Position in bits.
- pos: usize,
-}
-
-impl<'a> BitReader<'a> {
- pub fn new(data: &'a [u8]) -> Self {
- Self { data, pos: 0 }
- }
- pub fn data(&self) -> &'a [u8] {
- self.data
- }
- /// Bit position from the start.
- pub fn position(&self) -> usize {
- self.pos
- }
- pub fn bits_left(&self) -> usize {
- (self.data.len() * 8).saturating_sub(self.pos)
- }
- pub fn is_byte_aligned(&self) -> bool {
- self.pos.is_multiple_of(8)
- }
- pub fn byte_align(&mut self) {
- self.pos = self.pos.div_ceil(8) * 8;
- }
- /// Byte offset of the current position (rounded down).
- pub fn byte_pos(&self) -> usize {
- self.pos / 8
- }
- pub fn seek_bits(&mut self, pos: usize) {
- self.pos = pos;
- }
-
- /// Peek up to 32 bits without consuming (zero-padded past the end).
- #[inline]
- pub fn peek(&self, n: u32) -> u32 {
- debug_assert!(n <= 32);
- if n == 0 {
- return 0;
- }
- let byte = self.pos / 8;
- let shift = self.pos % 8;
- let mut v: u64 = 0;
- for i in 0..5 {
- v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64;
- }
- // v holds 40 bits starting at `byte`
- ((v << (24 + shift)) >> (64 - n)) as u32
- }
-
- #[inline]
- pub fn read_bits(&mut self, n: u32) -> Result {
- if n as usize > self.bits_left() {
- return Err(BitError::Eof);
- }
- let v = self.peek(n);
- self.pos += n as usize;
- Ok(v)
- }
-
- pub fn read_bits_u64(&mut self, n: u32) -> Result {
- if n <= 32 {
- return self.read_bits(n).map(u64::from);
- }
- let hi = self.read_bits(n - 32)? as u64;
- let lo = self.read_bits(32)? as u64;
- Ok((hi << 32) | lo)
- }
-
- #[inline]
- pub fn read_bit(&mut self) -> Result {
- self.read_bits(1).map(|b| b == 1)
- }
- pub fn read_flag(&mut self) -> Result {
- self.read_bit()
- }
-
- pub fn skip(&mut self, n: usize) -> Result<()> {
- if n > self.bits_left() {
- return Err(BitError::Eof);
- }
- self.pos += n;
- Ok(())
- }
-
- /// Unsigned Exp-Golomb `ue(v)`.
- pub fn read_ue(&mut self) -> Result {
- let mut zeros = 0u32;
- while !self.read_bit()? {
- zeros += 1;
- if zeros > 32 {
- return Err(BitError::InvalidExpGolomb);
- }
- }
- if zeros == 0 {
- return Ok(0);
- }
- let v = self.read_bits_u64(zeros)?;
- let r = (1u64 << zeros) - 1 + v;
- u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
- }
-
- /// Signed Exp-Golomb `se(v)`.
- pub fn read_se(&mut self) -> Result {
- let k = self.read_ue()? as i64;
- Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) })
- }
-
- /// Truncated Exp-Golomb `te(v)` with range `max`.
- pub fn read_te(&mut self, max: u32) -> Result {
- if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) }
- }
-
- /// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits.
- pub fn more_rbsp_data(&self) -> bool {
- let total = self.data.len() * 8;
- if self.pos >= total {
- return false;
- }
- // find last set bit (the rbsp_stop_one_bit)
- let mut last = None;
- for (i, &b) in self.data.iter().enumerate().rev() {
- if b != 0 {
- last = Some(i * 8 + 7 - b.trailing_zeros() as usize);
- break;
- }
- }
- match last {
- Some(stop) => self.pos < stop,
- None => false,
- }
- }
-}
-
-/// MSB-first bit writer.
-#[derive(Default, Clone)]
-pub struct BitWriter {
- buf: Vec,
- acc: u64,
- nbits: u32,
-}
-
-impl BitWriter {
- pub fn new() -> Self {
- Self::default()
- }
- pub fn write_bits(&mut self, value: u32, n: u32) {
- debug_assert!(n <= 32);
- if n == 0 {
- return;
- }
- let v = (value as u64) & ((1u64 << n) - 1);
- self.acc = (self.acc << n) | v;
- self.nbits += n;
- while self.nbits >= 8 {
- self.nbits -= 8;
- self.buf.push((self.acc >> self.nbits) as u8);
- }
- self.acc &= (1u64 << self.nbits) - 1;
- }
- pub fn write_bit(&mut self, b: bool) {
- self.write_bits(b as u32, 1);
- }
- pub fn write_ue(&mut self, v: u32) {
- let x = v as u64 + 1;
- let len = 64 - x.leading_zeros();
- let zeros = len - 1;
- for _ in 0..zeros {
- self.write_bit(false);
- }
- if len > 32 {
- self.write_bits((x >> 32) as u32, len - 32);
- self.write_bits(x as u32, 32);
- } else {
- self.write_bits(x as u32, len);
- }
- }
- 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);
- }
- pub fn bit_len(&self) -> usize {
- self.buf.len() * 8 + self.nbits as usize
- }
- pub fn is_byte_aligned(&self) -> bool {
- self.nbits == 0
- }
- /// Pad with zero bits to a byte boundary.
- pub fn align_zero(&mut self) {
- if self.nbits > 0 {
- self.write_bits(0, 8 - self.nbits);
- }
- }
- /// `rbsp_trailing_bits()`: a one bit then zeros to alignment.
- pub fn rbsp_trailing(&mut self) {
- self.write_bit(true);
- self.align_zero();
- }
- pub fn write_bytes(&mut self, bytes: &[u8]) {
- if self.nbits == 0 {
- self.buf.extend_from_slice(bytes);
- } else {
- for &b in bytes {
- self.write_bits(b as u32, 8);
- }
- }
- }
- pub fn finish(mut self) -> Vec {
- self.align_zero();
- self.buf
- }
- pub fn bytes(&self) -> &[u8] {
- &self.buf
- }
-}
-
-/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload.
-pub fn unescape_rbsp(data: &[u8]) -> Vec {
- let mut out = Vec::with_capacity(data.len());
- let mut zeros = 0;
- for &b in data {
- if zeros >= 2 && b == 3 {
- zeros = 0;
- continue;
- }
- out.push(b);
- zeros = if b == 0 { zeros + 1 } else { 0 };
- }
- out
-}
-
-/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears.
-pub fn escape_rbsp(data: &[u8]) -> Vec {
- let mut out = Vec::with_capacity(data.len() + data.len() / 64);
- let mut zeros = 0;
- for &b in data {
- if zeros >= 2 && b <= 3 {
- out.push(3);
- zeros = 0;
- }
- out.push(b);
- zeros = if b == 0 { zeros + 1 } else { 0 };
- }
- out
-}
-
-/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads).
-pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
- let mut starts = Vec::new();
- let mut i = 0;
- while i + 3 <= data.len() {
- if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
- starts.push(i + 3);
- i += 3;
- } else {
- i += 1;
- }
- }
- let mut out = Vec::with_capacity(starts.len());
- for (k, &s) in starts.iter().enumerate() {
- let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() };
- // trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits)
- while e > s && data[e - 1] == 0 {
- e -= 1;
- }
- if e > s {
- out.push(&data[s..e]);
- }
- }
- out
-}
-
-/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1..=4 bytes) into NAL units.
-///
-/// The width comes from the stream's configuration record (`(byte & 3) + 1`), so anything
-/// outside 1..=4 is a programming or parsing error and is rejected up front: a width of 0
-/// would never advance the cursor, looping forever and appending empty NALs until the
-/// process runs out of memory, and a wider prefix cannot occur in a real sample.
-pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result> {
- if !(1..=4).contains(&length_size) {
- return Err(BitError::InvalidLengthSize);
- }
- let mut out = Vec::new();
- let mut i = 0;
- while i < data.len() {
- let Some(start) = i.checked_add(length_size) else {
- return Err(BitError::Eof);
- };
- if start > data.len() {
- return Err(BitError::Eof);
- }
- let mut len = 0usize;
- for &b in &data[i..start] {
- len = (len << 8) | b as usize;
- }
- let Some(end) = start.checked_add(len) else {
- return Err(BitError::Eof);
- };
- if end > data.len() {
- return Err(BitError::Eof);
- }
- out.push(&data[start..end]);
- i = end;
- }
- Ok(out)
-}
-
-/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG).
-pub fn crc32(data: &[u8]) -> u32 {
- let mut crc = 0xFFFF_FFFFu32;
- for &b in data {
- crc ^= b as u32;
- for _ in 0..8 {
- crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
- }
- }
- !crc
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use proptest::prelude::*;
-
- #[test]
- fn reads_bits() {
- let d = [0b1010_1100, 0b0101_0011];
- let mut r = BitReader::new(&d);
- assert_eq!(r.read_bits(1).unwrap(), 1);
- assert_eq!(r.read_bits(3).unwrap(), 0b010);
- assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101);
- assert_eq!(r.bits_left(), 4);
- assert_eq!(r.read_bits(5), Err(BitError::Eof));
- }
-
- #[test]
- fn exp_golomb_known() {
- // 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4
- let mut w = BitWriter::new();
- for v in 0..5 {
- w.write_ue(v);
- }
- let b = w.finish();
- let mut r = BitReader::new(&b);
- for v in 0..5 {
- assert_eq!(r.read_ue().unwrap(), v);
- }
- let mut w = BitWriter::new();
- for v in [0, 1, -1, 2, -2, 1000, -1000] {
- w.write_se(v);
- }
- let b = w.finish();
- let mut r = BitReader::new(&b);
- for v in [0, 1, -1, 2, -2, 1000, -1000] {
- assert_eq!(r.read_se().unwrap(), v);
- }
- }
-
- #[test]
- fn emulation_prevention() {
- let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
- let esc = escape_rbsp(&raw);
- assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]);
- assert_eq!(unescape_rbsp(&esc), raw);
- }
-
- #[test]
- fn annexb_split() {
- let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9];
- let n = annexb_nals(&s);
- assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]);
- let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68];
- assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
- }
-
- #[test]
- fn length_prefixed_rejects_bad_length_size() {
- // Regression test for #469: with a width of 0 neither the prefix nor a
- // zero-length payload advanced the cursor, so any non-empty sample
- // looped forever appending empty NALs. Widths above 4 cannot come
- // from an AVCC/HVCC record either.
- let d = [0, 0, 0, 2, 0x67, 1];
- assert_eq!(length_prefixed_nals(&d, 0), Err(BitError::InvalidLengthSize));
- assert_eq!(length_prefixed_nals(&d, 5), Err(BitError::InvalidLengthSize));
- assert_eq!(length_prefixed_nals(&d, usize::MAX), Err(BitError::InvalidLengthSize));
- assert_eq!(length_prefixed_nals(&[], 0), Err(BitError::InvalidLengthSize));
- }
-
- #[test]
- fn length_prefixed_widths() {
- // 1-byte prefixes.
- assert_eq!(length_prefixed_nals(&[2, 0x67, 1, 1, 0x68], 1).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
- // 2-byte prefixes.
- assert_eq!(length_prefixed_nals(&[0, 2, 0x67, 1, 0, 1, 0x68], 2).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
- // 3-byte prefixes (lengthSizeMinusOne == 2 in the configuration record).
- assert_eq!(length_prefixed_nals(&[0, 0, 2, 0x67, 1], 3).unwrap(), vec![&[0x67, 1][..]]);
- // A truncated prefix or payload is Eof, not a panic or a short read.
- assert_eq!(length_prefixed_nals(&[0, 0], 4), Err(BitError::Eof));
- assert_eq!(length_prefixed_nals(&[0, 0, 0, 9, 0x67], 4), Err(BitError::Eof));
- // Empty input splits to no NALs.
- assert!(length_prefixed_nals(&[], 4).unwrap().is_empty());
- }
-
- #[test]
- fn more_rbsp() {
- let d = [0b1100_0000];
- let mut r = BitReader::new(&d);
- assert!(r.more_rbsp_data());
- r.read_bit().unwrap();
- assert!(!r.more_rbsp_data());
- }
-
- #[test]
- fn crc() {
- assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
- }
-
- proptest! {
- #[test]
- fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) {
- let mut w = BitWriter::new();
- for &(v, n) in &vals { w.write_bits(v, n); }
- for &u in &ues { w.write_ue(u); }
- let b = w.finish();
- let mut r = BitReader::new(&b);
- for &(v, n) in &vals {
- let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 };
- prop_assert_eq!(r.read_bits(n).unwrap(), v & mask);
- }
- for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); }
- }
-
- #[test]
- fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) {
- let e = escape_rbsp(&data);
- prop_assert_eq!(unescape_rbsp(&e), data);
- for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); }
- }
- }
-}
diff --git a/crates/video/src/h264/cabac_mb.rs b/crates/video/src/h264/cabac_mb.rs
index 4a3125e5..3379e193 100644
--- a/crates/video/src/h264/cabac_mb.rs
+++ b/crates/video/src/h264/cabac_mb.rs
@@ -3,7 +3,7 @@
use crate::h264::cabac::Cabac;
use crate::h264::error::{Result, ensure, invalid};
-use crate::h264::mbtypes::*;
+use crate::h264::mbtypes::{MbTypeInfo, Part, SubMbInfo, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
use crate::h264::picture::MbKind;
use crate::h264::slice::SliceType;
use crate::h264::slicedec::{MbCur, SliceDecoder};
@@ -240,11 +240,11 @@ impl SliceDecoder<'_> {
if c.decode_decision(base + inc) == 0 {
return 0;
}
- let mut prefix = 1;
- while prefix < 9 && c.decode_decision(base + (prefix + 2).min(6)) == 1 {
+ let mut prefix: i32 = 1;
+ while prefix < 9 && c.decode_decision(base + (prefix + 2).min(6) as usize) == 1 {
prefix += 1;
}
- let mut val = prefix as i32;
+ let mut val = prefix;
if prefix >= 9 {
// EG3 suffix
let mut k = 3;
diff --git a/crates/video/src/h264/cavlc.rs b/crates/video/src/h264/cavlc.rs
index 2126a451..9611868e 100644
--- a/crates/video/src/h264/cavlc.rs
+++ b/crates/video/src/h264/cavlc.rs
@@ -1,7 +1,7 @@
//! CAVLC residual block parsing (9.2).
use crate::bitstream::BitReader;
-use crate::h264::cavlc_tables::*;
+use crate::h264::cavlc_tables::{COEFF_TOKEN, RUN_BEFORE, TOTAL_ZEROS_2X2, TOTAL_ZEROS_2X4, TOTAL_ZEROS_4X4};
use crate::h264::error::{Result, ensure};
use std::sync::OnceLock;
diff --git a/crates/video/src/h264/params.rs b/crates/video/src/h264/params.rs
index 583c96bd..0970a106 100644
--- a/crates/video/src/h264/params.rs
+++ b/crates/video/src/h264/params.rs
@@ -2,7 +2,7 @@
use crate::bitstream::BitReader;
use crate::h264::error::{Error, Result, ensure, unsupported};
-use crate::h264::tables::*;
+use crate::h264::tables::{DEFAULT_4X4_INTER, DEFAULT_4X4_INTRA, DEFAULT_8X8_INTER, DEFAULT_8X8_INTRA, ZIGZAG4, ZIGZAG8};
/// `v + k` for a coded `*_minus*` field, or the error its range check gives.
fn plus(v: u32, k: u32, err: &str) -> Result {
@@ -575,7 +575,7 @@ impl Pps {
ensure!(sps_id < 32, "seq_parameter_set_id out of range");
let sps = spss
.get(sps_id as usize)
- .and_then(|s| s.as_ref())
+ .and_then(Option::as_ref)
.ok_or_else(|| crate::h264::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()?;
diff --git a/crates/video/src/h264/slicedec.rs b/crates/video/src/h264/slicedec.rs
index a71a6b4e..dfab85b5 100644
--- a/crates/video/src/h264/slicedec.rs
+++ b/crates/video/src/h264/slicedec.rs
@@ -6,11 +6,11 @@ use crate::h264::deblock;
use crate::h264::error::{Result, ensure, invalid};
use crate::h264::inter::{self, Weight};
use crate::h264::intra::{self, Avail};
-use crate::h264::mbtypes::*;
+use crate::h264::mbtypes::{CBP_ME, MbTypeInfo, Part, SubMbInfo, SubPart, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
use crate::h264::params::{Pps, Sps};
-use crate::h264::picture::*;
+use crate::h264::picture::{Frame, FrameRef, MbKind, MbState, Planes, RefPic};
use crate::h264::slice::{SliceHeader, SliceType};
-use crate::h264::tables::*;
+use crate::h264::tables::{BLK4_XY, QPC_TABLE, RASTER_TO_BLK4, ZIGZAG4, ZIGZAG8};
use crate::h264::transform::{self, LevelScale};
/// Per-slice data kept with the picture (deblocking, motion field export).
diff --git a/crates/video/src/isobmff/bytes.rs b/crates/video/src/isobmff/bytes.rs
deleted file mode 100644
index 7f59443e..00000000
--- a/crates/video/src/isobmff/bytes.rs
+++ /dev/null
@@ -1,361 +0,0 @@
-//! Low-level helpers: four-character codes, a bounds-checked big-endian cursor, box iteration
-//! over in-memory payloads, and a box builder for the muxers.
-
-use crate::isobmff::error::{Error, Result};
-use std::fmt;
-
-/// A four-character code (box type, sample entry format, brand, handler type…).
-#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
-pub struct FourCc(pub [u8; 4]);
-
-impl FourCc {
- pub const fn new(b: &[u8; 4]) -> Self {
- FourCc(*b)
- }
- pub fn as_u32(self) -> u32 {
- u32::from_be_bytes(self.0)
- }
- /// Lossy string form; `©` (0xA9) is mapped to U+00A9, other non-printables to `?`.
- pub fn to_string_lossy(self) -> String {
- self.0
- .iter()
- .map(|&b| match b {
- 0xA9 => '©',
- 0x20..=0x7E => b as char,
- _ => '?',
- })
- .collect()
- }
-}
-
-impl fmt::Display for FourCc {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.write_str(&self.to_string_lossy())
- }
-}
-impl fmt::Debug for FourCc {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "FourCc({:?})", self.to_string_lossy())
- }
-}
-impl From<&[u8; 4]> for FourCc {
- fn from(b: &[u8; 4]) -> Self {
- FourCc(*b)
- }
-}
-impl PartialEq<[u8; 4]> for FourCc {
- fn eq(&self, other: &[u8; 4]) -> bool {
- &self.0 == other
- }
-}
-impl PartialEq<&[u8; 4]> for FourCc {
- fn eq(&self, other: &&[u8; 4]) -> bool {
- &self.0 == *other
- }
-}
-impl PartialEq<&&[u8; 4]> for FourCc {
- fn eq(&self, other: &&&[u8; 4]) -> bool {
- &self.0 == **other
- }
-}
-
-/// Bounds-checked big-endian cursor over a byte slice.
-#[derive(Clone)]
-pub(crate) struct Cur<'a> {
- pub data: &'a [u8],
- pub pos: usize,
- what: &'static str,
-}
-
-impl<'a> Cur<'a> {
- pub fn new(data: &'a [u8], what: &'static str) -> Self {
- Cur { data, pos: 0, what }
- }
- pub fn remaining(&self) -> usize {
- self.data.len().saturating_sub(self.pos)
- }
- pub fn rest(&self) -> &'a [u8] {
- self.data.get(self.pos..).unwrap_or(&[])
- }
- pub fn bytes(&mut self, n: usize) -> Result<&'a [u8]> {
- let end = self.pos.checked_add(n).ok_or(Error::Truncated(self.what))?;
- let s = self.data.get(self.pos..end).ok_or(Error::Truncated(self.what))?;
- self.pos = end;
- Ok(s)
- }
- pub fn skip(&mut self, n: usize) -> Result<()> {
- self.bytes(n).map(|_| ())
- }
- pub fn array(&mut self) -> Result<[u8; N]> {
- let s = self.bytes(N)?;
- let mut a = [0u8; N];
- a.copy_from_slice(s);
- Ok(a)
- }
- pub fn u8(&mut self) -> Result {
- Ok(self.array::<1>()?[0])
- }
- pub fn u16(&mut self) -> Result {
- Ok(u16::from_be_bytes(self.array()?))
- }
- pub fn i16(&mut self) -> Result {
- Ok(i16::from_be_bytes(self.array()?))
- }
- pub fn u24(&mut self) -> Result {
- let a = self.array::<3>()?;
- Ok(((a[0] as u32) << 16) | ((a[1] as u32) << 8) | a[2] as u32)
- }
- pub fn u32(&mut self) -> Result {
- Ok(u32::from_be_bytes(self.array()?))
- }
- pub fn i32(&mut self) -> Result {
- Ok(i32::from_be_bytes(self.array()?))
- }
- pub fn u64(&mut self) -> Result {
- Ok(u64::from_be_bytes(self.array()?))
- }
- pub fn i64(&mut self) -> Result {
- Ok(i64::from_be_bytes(self.array()?))
- }
- pub fn f64(&mut self) -> Result {
- Ok(f64::from_be_bytes(self.array()?))
- }
- pub fn fourcc(&mut self) -> Result {
- Ok(FourCc(self.array()?))
- }
- /// Full-box header: (version, flags).
- pub fn full_header(&mut self) -> Result<(u8, u32)> {
- let v = self.u32()?;
- Ok(((v >> 24) as u8, v & 0x00FF_FFFF))
- }
- /// Read a count and verify that `count * entry_size` bytes remain (allocation guard).
- pub fn count(&mut self, entry_size: usize) -> Result {
- let n = self.u32()? as usize;
- self.check_count(n, entry_size)?;
- Ok(n)
- }
- pub fn check_count(&self, n: usize, entry_size: usize) -> Result<()> {
- match n.checked_mul(entry_size) {
- Some(b) if b <= self.remaining() => Ok(()),
- _ => Err(Error::Truncated(self.what)),
- }
- }
-}
-
-/// Header of one box as found in a byte stream.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub(crate) struct BoxHeader {
- pub kind: FourCc,
- /// Header length including largesize and uuid extended type.
- pub header_len: u64,
- /// Total box size including header; `None` means "extends to end of the enclosing container".
- pub size: Option,
- pub uuid: Option<[u8; 16]>,
-}
-
-/// Parse a box header from `buf` (needs up to 32 bytes; fewer if available).
-pub(crate) fn parse_box_header(buf: &[u8]) -> Result {
- let mut c = Cur::new(buf, "box header");
- let size32 = c.u32()?;
- let kind = c.fourcc()?;
- let mut header_len = 8u64;
- let size = match size32 {
- 0 => None,
- 1 => {
- header_len += 8;
- let s = c.u64()?;
- Some(s)
- }
- s => Some(s as u64),
- };
- let uuid = if kind == b"uuid" {
- header_len += 16;
- Some(c.array::<16>()?)
- } else {
- None
- };
- if let Some(s) = size
- && s < header_len
- {
- return Err(Error::Invalid(format!("box '{kind}' size {s} smaller than its header")));
- }
- Ok(BoxHeader { kind, header_len, size, uuid })
-}
-
-/// One child box inside an in-memory payload.
-#[derive(Clone, Copy, Debug)]
-pub(crate) struct RawBox<'a> {
- pub kind: FourCc,
- pub payload: &'a [u8],
- #[allow(dead_code)]
- pub uuid: Option<[u8; 16]>,
-}
-
-/// Iterator over the child boxes of a payload. Yields an error for a malformed box and then stops.
-pub(crate) struct Boxes<'a> {
- data: &'a [u8],
- pos: usize,
- done: bool,
-}
-
-pub(crate) fn boxes(data: &[u8]) -> Boxes<'_> {
- Boxes { data, pos: 0, done: false }
-}
-
-impl<'a> Iterator for Boxes<'a> {
- type Item = Result>;
- fn next(&mut self) -> Option {
- if self.done {
- return None;
- }
- let rest = &self.data[self.pos..];
- if rest.is_empty() {
- self.done = true;
- return None;
- }
- // QuickTime allows a 32-bit zero terminator at the end of some containers.
- if rest.len() < 8 {
- self.done = true;
- if rest.iter().all(|&b| b == 0) {
- return None;
- }
- return Some(Err(Error::Truncated("box header")));
- }
- let h = match parse_box_header(rest) {
- Ok(h) => h,
- Err(e) => {
- self.done = true;
- return Some(Err(e));
- }
- };
- let size = h.size.unwrap_or(rest.len() as u64);
- if size > rest.len() as u64 {
- self.done = true;
- return Some(Err(Error::Invalid(format!("box '{}' size {} exceeds its container ({} bytes left)", h.kind, size, rest.len()))));
- }
- let size = size as usize;
- let payload = &rest[h.header_len as usize..size];
- self.pos += size;
- Some(Ok(RawBox { kind: h.kind, payload, uuid: h.uuid }))
- }
-}
-
-/// Find the first child of the given type (malformed trailing children are ignored).
-pub(crate) fn find<'a>(data: &'a [u8], kind: &[u8; 4]) -> Option<&'a [u8]> {
- boxes(data).map_while(|b| b.ok()).find(|b| b.kind == kind).map(|b| b.payload)
-}
-
-/// Growable buffer for building boxes.
-#[derive(Default, Clone, Debug)]
-pub(crate) struct BoxBuf {
- pub buf: Vec,
-}
-
-impl BoxBuf {
- pub fn new() -> Self {
- Self::default()
- }
- /// Begin a box; returns a marker to pass to [`end`](Self::end).
- pub fn start(&mut self, kind: &[u8; 4]) -> usize {
- let at = self.buf.len();
- self.buf.extend_from_slice(&[0, 0, 0, 0]);
- self.buf.extend_from_slice(kind);
- at
- }
- pub fn start_full(&mut self, kind: &[u8; 4], version: u8, flags: u32) -> usize {
- let at = self.start(kind);
- self.u32(((version as u32) << 24) | (flags & 0xFF_FFFF));
- at
- }
- pub fn end(&mut self, at: usize) {
- let size = (self.buf.len() - at) as u32;
- self.buf[at..at + 4].copy_from_slice(&size.to_be_bytes());
- }
- /// Append a complete box with the given payload.
- pub fn leaf(&mut self, kind: &[u8; 4], payload: &[u8]) {
- let at = self.start(kind);
- self.bytes(payload);
- self.end(at);
- }
- pub fn u8(&mut self, v: u8) {
- self.buf.push(v);
- }
- pub fn u16(&mut self, v: u16) {
- self.buf.extend_from_slice(&v.to_be_bytes());
- }
- pub fn i16(&mut self, v: i16) {
- self.buf.extend_from_slice(&v.to_be_bytes());
- }
- pub fn u32(&mut self, v: u32) {
- self.buf.extend_from_slice(&v.to_be_bytes());
- }
- pub fn i32(&mut self, v: i32) {
- self.buf.extend_from_slice(&v.to_be_bytes());
- }
- pub fn u64(&mut self, v: u64) {
- self.buf.extend_from_slice(&v.to_be_bytes());
- }
- pub fn i64(&mut self, v: i64) {
- self.buf.extend_from_slice(&v.to_be_bytes());
- }
- pub fn f64(&mut self, v: f64) {
- self.buf.extend_from_slice(&v.to_be_bytes());
- }
- pub fn bytes(&mut self, b: &[u8]) {
- self.buf.extend_from_slice(b);
- }
- pub fn zeros(&mut self, n: usize) {
- self.buf.resize(self.buf.len() + n, 0);
- }
- pub fn len(&self) -> usize {
- self.buf.len()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn header_forms() {
- let h = parse_box_header(&[0, 0, 0, 16, b'f', b'r', b'e', b'e']).unwrap();
- assert_eq!((h.kind, h.header_len, h.size), (FourCc(*b"free"), 8, Some(16)));
- let h = parse_box_header(&[0, 0, 0, 0, b'm', b'd', b'a', b't']).unwrap();
- assert_eq!(h.size, None);
- let mut v = vec![0, 0, 0, 1, b'm', b'd', b'a', b't'];
- v.extend_from_slice(&100u64.to_be_bytes());
- let h = parse_box_header(&v).unwrap();
- assert_eq!((h.header_len, h.size), (16, Some(100)));
- let mut v = vec![0, 0, 0, 30, b'u', b'u', b'i', b'd'];
- v.extend_from_slice(&[7u8; 16]);
- let h = parse_box_header(&v).unwrap();
- assert_eq!((h.header_len, h.uuid), (24, Some([7u8; 16])));
- assert!(parse_box_header(&[0, 0, 0, 4, b'f', b'r', b'e', b'e']).is_err());
- assert!(parse_box_header(&[0, 0, 0]).is_err());
- }
-
- #[test]
- fn iterate_children() {
- let mut b = BoxBuf::new();
- b.leaf(b"aaaa", &[1, 2]);
- let m = b.start(b"bbbb");
- b.leaf(b"cccc", &[]);
- b.end(m);
- b.u32(0); // QT terminator
- let kids: Vec<_> = boxes(&b.buf).collect::>>().unwrap();
- assert_eq!(kids.len(), 2);
- assert_eq!(kids[0].payload, &[1, 2]);
- assert_eq!(find(&b.buf, b"bbbb").map(|p| p.len()), Some(8));
- // Oversized child → error, no panic.
- let bad = [0u8, 0, 0, 99, b'x', b'x', b'x', b'x', 0];
- assert!(boxes(&bad).next().unwrap().is_err());
- }
-
- #[test]
- fn cursor_bounds() {
- let mut c = Cur::new(&[0, 0, 0, 5, 1], "t");
- assert!(c.count(1).is_err());
- let mut c = Cur::new(&[0xFF, 0xFF, 0xFF, 0xFF], "t");
- assert!(c.count(usize::MAX).is_err());
- }
-}
diff --git a/crates/video/src/isobmff/codec.rs b/crates/video/src/isobmff/codec.rs
deleted file mode 100644
index 45f08bd9..00000000
--- a/crates/video/src/isobmff/codec.rs
+++ /dev/null
@@ -1,1579 +0,0 @@
-//! Sample entries (`stsd` children) and codec configuration records: parsing and serialization.
-
-use crate::bitstream::BitReader;
-use crate::isobmff::bytes::{BoxBuf, Cur, FourCc, boxes};
-use crate::isobmff::error::{Error, Result};
-
-/// One sample description (an entry of `stsd`).
-#[derive(Clone, Debug, PartialEq)]
-pub struct SampleEntry {
- /// Sample entry format (`avc1`, `mp4a`, `apch`, …).
- pub format: FourCc,
- pub data_reference_index: u16,
- pub codec: CodecConfig,
- /// Present for visual sample entries.
- pub video: Option,
- /// Present for audio sample entries.
- pub audio: Option,
- /// `btrt` box.
- pub bitrate: Option,
-}
-
-/// Fields common to all visual sample entries plus the optional descriptive boxes.
-#[derive(Clone, Debug, PartialEq, Default)]
-pub struct VideoParams {
- pub width: u16,
- pub height: u16,
- /// Horizontal/vertical resolution in pixels per inch (16.16 fixed point; 72 dpi = 0x0048_0000).
- pub horiz_resolution: u32,
- pub vert_resolution: u32,
- pub frame_count: u16,
- pub compressor_name: String,
- pub depth: u16,
- /// QuickTime `vendor` / version fields (zero in ISO files).
- pub vendor: FourCc,
- pub temporal_quality: u32,
- pub spatial_quality: u32,
- pub color: Option,
- /// `pasp`: (hSpacing, vSpacing).
- pub pixel_aspect: Option<(u32, u32)>,
- pub clean_aperture: Option,
- pub field_info: Option,
- /// QuickTime `gama` (16.16 fixed point).
- pub gamma: Option,
- /// `mdcv`: mastering display colour volume (SMPTE ST 2086).
- pub mastering_display: Option,
- /// `clli`: content light level (MaxCLL, MaxFALL in cd/m²; 0 = unknown).
- pub content_light: Option<(u16, u16)>,
-}
-
-/// SMPTE ST 2086 mastering display metadata, in the units of the `mdcv` box and the H.264/HEVC
-/// SEI: chromaticities in 0.00002 steps (order G, B, R as the SEI), luminance in 0.0001 cd/m².
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct MasteringDisplay {
- pub primaries: [(u16, u16); 3],
- pub white_point: (u16, u16),
- pub max_luminance: u32,
- pub min_luminance: u32,
-}
-
-impl MasteringDisplay {
- /// BT.2020 primaries, D65, with the given peak and black in cd/m².
- pub fn bt2020(max_nits: f64, min_nits: f64) -> Self {
- let q = |v: f64| (v / 0.00002).round() as u16;
- MasteringDisplay {
- primaries: [(q(0.170), q(0.797)), (q(0.131), q(0.046)), (q(0.708), q(0.292))],
- white_point: (q(0.3127), q(0.3290)),
- max_luminance: (max_nits * 10_000.0).round() as u32,
- min_luminance: (min_nits * 10_000.0).round() as u32,
- }
- }
- pub fn max_nits(&self) -> f64 {
- self.max_luminance as f64 / 10_000.0
- }
- /// The 24-byte payload shared by `mdcv` and the SEI message.
- pub fn to_bytes(&self) -> Vec {
- let mut v = Vec::with_capacity(24);
- for (x, y) in self.primaries {
- v.extend_from_slice(&x.to_be_bytes());
- v.extend_from_slice(&y.to_be_bytes());
- }
- v.extend_from_slice(&self.white_point.0.to_be_bytes());
- v.extend_from_slice(&self.white_point.1.to_be_bytes());
- v.extend_from_slice(&self.max_luminance.to_be_bytes());
- v.extend_from_slice(&self.min_luminance.to_be_bytes());
- v
- }
- pub fn parse(p: &[u8]) -> Option {
- if p.len() < 24 {
- return None;
- }
- let u16_at = |i: usize| u16::from_be_bytes([p[i], p[i + 1]]);
- let u32_at = |i: usize| u32::from_be_bytes([p[i], p[i + 1], p[i + 2], p[i + 3]]);
- Some(MasteringDisplay {
- primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))],
- white_point: (u16_at(12), u16_at(14)),
- max_luminance: u32_at(16),
- min_luminance: u32_at(20),
- })
- }
-}
-
-/// `colr` box contents.
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum ColorInfo {
- /// ISO `nclx` (with full-range flag).
- Nclx { primaries: u16, transfer: u16, matrix: u16, full_range: bool },
- /// QuickTime `nclc`.
- Nclc { primaries: u16, transfer: u16, matrix: u16 },
- /// Embedded ICC profile (`prof` restricted/unrestricted `rICC`).
- Icc { kind: FourCc, profile: Vec },
-}
-
-/// `clap` clean aperture (all rationals num/den; offsets are signed).
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct CleanAperture {
- pub width: (u32, u32),
- pub height: (u32, u32),
- pub horiz_offset: (i32, u32),
- pub vert_offset: (i32, u32),
-}
-
-/// QuickTime `fiel`: field count (1 progressive, 2 interlaced) and ordering detail
-/// (0, 1 = top first, 6 = bottom first, 9 / 14 per the QuickTime spec).
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct FieldInfo {
- pub fields: u8,
- pub detail: u8,
-}
-
-/// `btrt` box.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct BitRate {
- pub buffer_size: u32,
- pub max_bitrate: u32,
- pub avg_bitrate: u32,
-}
-
-/// Fields of audio sample entries (ISO v0 plus QuickTime sound description v1/v2 extensions).
-#[derive(Clone, Debug, PartialEq, Default)]
-pub struct AudioParams {
- /// QuickTime sound description version (0, 1, 2). ISO files use 0.
- pub qt_version: u16,
- pub channels: u32,
- /// Bits per sample from the entry (16 for most compressed formats).
- pub sample_size: u16,
- pub sample_rate: f64,
- pub compression_id: i16,
- /// v1 fields.
- pub samples_per_packet: u32,
- pub bytes_per_packet: u32,
- pub bytes_per_frame: u32,
- pub bytes_per_sample: u32,
- /// v2 fields.
- pub const_bits_per_channel: u32,
- pub lpcm_flags: u32,
- pub const_bytes_per_packet: u32,
- pub const_frames_per_packet: u32,
- /// `enda` little-endian flag inside a QuickTime `wave` box.
- pub little_endian: Option,
-}
-
-/// Codec configuration decoded from the sample entry.
-#[derive(Clone, Debug, PartialEq)]
-pub enum CodecConfig {
- Avc(AvcConfig),
- Hevc(HevcConfig),
- Av1(Av1Config),
- Vp9(VpcConfig),
- /// Apple ProRes; fourcc is `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`.
- ProRes {
- fourcc: FourCc,
- },
- /// Motion JPEG (`jpeg`, `mjpa`, `mjpb`).
- Jpeg {
- fourcc: FourCc,
- },
- /// Avid DNxHD/DNxHR (`AVdn`, `AVdh`).
- Dnx {
- fourcc: FourCc,
- },
- Aac(AacConfig),
- /// MPEG-1/2 audio layer III (esds object type 0x69/0x6B, or QuickTime `.mp3`).
- Mp3,
- Pcm(PcmConfig),
- /// ALAC magic cookie (ALACSpecificConfig, 24 bytes, optionally followed by a channel layout).
- Alac {
- cookie: Vec,
- },
- Opus(OpusConfig),
- Flac(FlacConfig),
- /// `dac3` payload.
- Ac3 {
- dac3: Vec,
- },
- /// `dec3` payload.
- Eac3 {
- dec3: Vec,
- },
- Timecode(TimecodeConfig),
- /// Anything else; `raw` is the full sample entry payload (after the 8-byte box header).
- Unknown {
- fourcc: FourCc,
- raw: Vec,
- },
-}
-
-impl CodecConfig {
- /// A short human-readable codec name.
- pub fn name(&self) -> &'static str {
- match self {
- CodecConfig::Avc(_) => "h264",
- CodecConfig::Hevc(_) => "hevc",
- CodecConfig::Av1(_) => "av1",
- CodecConfig::Vp9(_) => "vp9",
- CodecConfig::ProRes { .. } => "prores",
- CodecConfig::Jpeg { .. } => "mjpeg",
- CodecConfig::Dnx { .. } => "dnxhd",
- CodecConfig::Aac(_) => "aac",
- CodecConfig::Mp3 => "mp3",
- CodecConfig::Pcm(_) => "pcm",
- CodecConfig::Alac { .. } => "alac",
- CodecConfig::Opus(_) => "opus",
- CodecConfig::Flac(_) => "flac",
- CodecConfig::Ac3 { .. } => "ac3",
- CodecConfig::Eac3 { .. } => "eac3",
- CodecConfig::Timecode(_) => "timecode",
- CodecConfig::Unknown { .. } => "unknown",
- }
- }
-}
-
-/// `avcC` — AVCDecoderConfigurationRecord (ISO/IEC 14496-15 §5.3.3).
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct AvcConfig {
- pub profile: u8,
- pub compatibility: u8,
- pub level: u8,
- /// NAL length field size in bytes (1, 2 or 4).
- pub length_size: u8,
- pub sps: Vec>,
- pub pps: Vec>,
- /// Bytes after the PPS list (high-profile extension: chroma format, bit depths, SPS-ext).
- pub ext: Vec,
-}
-
-impl AvcConfig {
- /// Build from parameter sets (NAL units without start codes); profile/level taken from the first SPS.
- pub fn new(sps: Vec>, pps: Vec>, length_size: u8) -> Self {
- let (profile, compatibility, level) = sps.first().filter(|s| s.len() >= 4).map(|s| (s[1], s[2], s[3])).unwrap_or((0, 0, 0));
- AvcConfig { profile, compatibility, level, length_size, sps, pps, ext: Vec::new() }
- }
-
- pub fn parse(data: &[u8]) -> Result {
- let mut c = Cur::new(data, "avcC");
- let _version = c.u8()?;
- let profile = c.u8()?;
- let compatibility = c.u8()?;
- let level = c.u8()?;
- let length_size = (c.u8()? & 3) + 1;
- let nsps = (c.u8()? & 0x1F) as usize;
- let mut sps = Vec::with_capacity(nsps);
- for _ in 0..nsps {
- let n = c.u16()? as usize;
- sps.push(c.bytes(n)?.to_vec());
- }
- let npps = c.u8()? as usize;
- let mut pps = Vec::with_capacity(npps);
- for _ in 0..npps {
- let n = c.u16()? as usize;
- pps.push(c.bytes(n)?.to_vec());
- }
- Ok(AvcConfig { profile, compatibility, level, length_size, sps, pps, ext: c.rest().to_vec() })
- }
-
- pub fn to_bytes(&self) -> Vec {
- let mut b = BoxBuf::new();
- b.u8(1);
- b.u8(self.profile);
- b.u8(self.compatibility);
- b.u8(self.level);
- b.u8(0xFC | (self.length_size.clamp(1, 4) - 1));
- b.u8(0xE0 | (self.sps.len().min(31) as u8));
- for s in self.sps.iter().take(31) {
- b.u16(s.len() as u16);
- b.bytes(s);
- }
- b.u8(self.pps.len().min(255) as u8);
- for p in self.pps.iter().take(255) {
- b.u16(p.len() as u16);
- b.bytes(p);
- }
- b.bytes(&self.ext);
- b.buf
- }
-}
-
-/// One NAL array of an `hvcC` record.
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct HevcNalArray {
- pub completeness: bool,
- pub nal_type: u8,
- pub nalus: Vec>,
-}
-
-/// `hvcC` — HEVCDecoderConfigurationRecord (ISO/IEC 14496-15 §8.3.3).
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct HevcConfig {
- pub general_profile_space: u8,
- pub general_tier_flag: bool,
- pub general_profile_idc: u8,
- pub general_profile_compatibility_flags: u32,
- /// 48 bits of general constraint indicator flags.
- pub general_constraint_indicator_flags: u64,
- pub general_level_idc: u8,
- pub min_spatial_segmentation_idc: u16,
- pub parallelism_type: u8,
- pub chroma_format_idc: u8,
- pub bit_depth_luma: u8,
- pub bit_depth_chroma: u8,
- pub avg_frame_rate: u16,
- pub constant_frame_rate: u8,
- pub num_temporal_layers: u8,
- pub temporal_id_nested: bool,
- pub length_size: u8,
- pub arrays: Vec,
-}
-
-impl HevcConfig {
- pub fn parse(data: &[u8]) -> Result {
- let mut c = Cur::new(data, "hvcC");
- let _version = c.u8()?;
- let b = c.u8()?;
- let compat = c.u32()?;
- let hi = c.u16()? as u64;
- let lo = c.u32()? as u64;
- let level = c.u8()?;
- let min_seg = c.u16()? & 0x0FFF;
- let par = c.u8()? & 3;
- let chroma = c.u8()? & 3;
- let bdl = (c.u8()? & 7) + 8;
- let bdc = (c.u8()? & 7) + 8;
- let afr = c.u16()?;
- let x = c.u8()?;
- let narrays = c.u8()? as usize;
- let mut arrays = Vec::new();
- for _ in 0..narrays {
- let t = c.u8()?;
- let n = c.u16()? as usize;
- c.check_count(n, 2)?;
- let mut nalus = Vec::with_capacity(n);
- for _ in 0..n {
- let len = c.u16()? as usize;
- nalus.push(c.bytes(len)?.to_vec());
- }
- arrays.push(HevcNalArray { completeness: t & 0x80 != 0, nal_type: t & 0x3F, nalus });
- }
- Ok(HevcConfig {
- general_profile_space: b >> 6,
- general_tier_flag: b & 0x20 != 0,
- general_profile_idc: b & 0x1F,
- general_profile_compatibility_flags: compat,
- general_constraint_indicator_flags: (hi << 32) | lo,
- general_level_idc: level,
- min_spatial_segmentation_idc: min_seg,
- parallelism_type: par,
- chroma_format_idc: chroma,
- bit_depth_luma: bdl,
- bit_depth_chroma: bdc,
- avg_frame_rate: afr,
- constant_frame_rate: x >> 6,
- num_temporal_layers: (x >> 3) & 7,
- temporal_id_nested: x & 4 != 0,
- length_size: (x & 3) + 1,
- arrays,
- })
- }
-
- pub fn to_bytes(&self) -> Vec {
- let mut b = BoxBuf::new();
- b.u8(1);
- b.u8((self.general_profile_space << 6) | ((self.general_tier_flag as u8) << 5) | (self.general_profile_idc & 0x1F));
- b.u32(self.general_profile_compatibility_flags);
- b.u16((self.general_constraint_indicator_flags >> 32) as u16);
- b.u32(self.general_constraint_indicator_flags as u32);
- b.u8(self.general_level_idc);
- b.u16(0xF000 | (self.min_spatial_segmentation_idc & 0x0FFF));
- b.u8(0xFC | (self.parallelism_type & 3));
- b.u8(0xFC | (self.chroma_format_idc & 3));
- b.u8(0xF8 | (self.bit_depth_luma.saturating_sub(8) & 7));
- b.u8(0xF8 | (self.bit_depth_chroma.saturating_sub(8) & 7));
- b.u16(self.avg_frame_rate);
- b.u8((self.constant_frame_rate << 6)
- | ((self.num_temporal_layers & 7) << 3)
- | ((self.temporal_id_nested as u8) << 2)
- | (self.length_size.clamp(1, 4) - 1));
- b.u8(self.arrays.len() as u8);
- for a in &self.arrays {
- b.u8(((a.completeness as u8) << 7) | (a.nal_type & 0x3F));
- b.u16(a.nalus.len() as u16);
- for n in &a.nalus {
- b.u16(n.len() as u16);
- b.bytes(n);
- }
- }
- b.buf
- }
-
- fn nalus_of(&self, t: u8) -> Vec<&[u8]> {
- self.arrays.iter().filter(|a| a.nal_type == t).flat_map(|a| a.nalus.iter().map(|n| n.as_slice())).collect()
- }
- pub fn vps(&self) -> Vec<&[u8]> {
- self.nalus_of(32)
- }
- pub fn sps(&self) -> Vec<&[u8]> {
- self.nalus_of(33)
- }
- pub fn pps(&self) -> Vec<&[u8]> {
- self.nalus_of(34)
- }
-}
-
-/// `av1C` — AV1CodecConfigurationRecord.
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct Av1Config {
- pub seq_profile: u8,
- pub seq_level_idx_0: u8,
- pub seq_tier_0: bool,
- pub high_bitdepth: bool,
- pub twelve_bit: bool,
- pub monochrome: bool,
- pub chroma_subsampling_x: bool,
- pub chroma_subsampling_y: bool,
- pub chroma_sample_position: u8,
- pub initial_presentation_delay_minus_one: Option,
- pub config_obus: Vec,
-}
-
-impl Av1Config {
- pub fn parse(data: &[u8]) -> Result {
- let mut c = Cur::new(data, "av1C");
- let m = c.u8()?;
- if m & 0x80 == 0 {
- return Err(Error::Invalid("av1C marker bit not set".into()));
- }
- let a = c.u8()?;
- let b = c.u8()?;
- let d = c.u8()?;
- Ok(Av1Config {
- seq_profile: a >> 5,
- seq_level_idx_0: a & 0x1F,
- seq_tier_0: b & 0x80 != 0,
- high_bitdepth: b & 0x40 != 0,
- twelve_bit: b & 0x20 != 0,
- monochrome: b & 0x10 != 0,
- chroma_subsampling_x: b & 0x08 != 0,
- chroma_subsampling_y: b & 0x04 != 0,
- chroma_sample_position: b & 3,
- initial_presentation_delay_minus_one: if d & 0x10 != 0 { Some(d & 0x0F) } else { None },
- config_obus: c.rest().to_vec(),
- })
- }
- pub fn to_bytes(&self) -> Vec {
- let mut b = BoxBuf::new();
- b.u8(0x81);
- b.u8((self.seq_profile << 5) | (self.seq_level_idx_0 & 0x1F));
- b.u8(((self.seq_tier_0 as u8) << 7)
- | ((self.high_bitdepth as u8) << 6)
- | ((self.twelve_bit as u8) << 5)
- | ((self.monochrome as u8) << 4)
- | ((self.chroma_subsampling_x as u8) << 3)
- | ((self.chroma_subsampling_y as u8) << 2)
- | (self.chroma_sample_position & 3));
- b.u8(match self.initial_presentation_delay_minus_one {
- Some(d) => 0x10 | (d & 0x0F),
- None => 0,
- });
- b.bytes(&self.config_obus);
- b.buf
- }
-}
-
-/// `vpcC` — VP codec configuration (version 1).
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct VpcConfig {
- pub profile: u8,
- pub level: u8,
- pub bit_depth: u8,
- pub chroma_subsampling: u8,
- pub full_range: bool,
- pub colour_primaries: u8,
- pub transfer_characteristics: u8,
- pub matrix_coefficients: u8,
- pub codec_init: Vec,
-}
-
-impl VpcConfig {
- pub fn parse(data: &[u8]) -> Result {
- let mut c = Cur::new(data, "vpcC");
- let (version, _) = c.full_header()?;
- if version == 0 {
- // Version 0 draft layout: profile, level, bitDepth(4)|colorSpace(4), chroma(4)|transfer(3)|range(1)
- let profile = c.u8()?;
- let level = c.u8()?;
- let a = c.u8()?;
- let b = c.u8()?;
- return Ok(VpcConfig { profile, level, bit_depth: a >> 4, chroma_subsampling: b >> 4, full_range: b & 1 != 0, ..Default::default() });
- }
- let profile = c.u8()?;
- let level = c.u8()?;
- let a = c.u8()?;
- let colour_primaries = c.u8()?;
- let transfer_characteristics = c.u8()?;
- let matrix_coefficients = c.u8()?;
- let n = c.u16()? as usize;
- let codec_init = c.bytes(n)?.to_vec();
- Ok(VpcConfig {
- profile,
- level,
- bit_depth: a >> 4,
- chroma_subsampling: (a >> 1) & 7,
- full_range: a & 1 != 0,
- colour_primaries,
- transfer_characteristics,
- matrix_coefficients,
- codec_init,
- })
- }
- pub fn to_bytes(&self) -> Vec {
- let mut b = BoxBuf::new();
- b.u32(1 << 24);
- b.u8(self.profile);
- b.u8(self.level);
- b.u8((self.bit_depth << 4) | ((self.chroma_subsampling & 7) << 1) | self.full_range as u8);
- b.u8(self.colour_primaries);
- b.u8(self.transfer_characteristics);
- b.u8(self.matrix_coefficients);
- b.u16(self.codec_init.len() as u16);
- b.bytes(&self.codec_init);
- b.buf
- }
-}
-
-/// AAC (MPEG-4 audio) configuration from `esds`.
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct AacConfig {
- /// AudioSpecificConfig bytes (DecoderSpecificInfo).
- pub asc: Vec,
- /// Audio object type (2 = AAC-LC, 5 = SBR/HE-AAC, 29 = PS …) as signalled first in the ASC.
- pub object_type: u8,
- /// Sampling rate signalled by the ASC (core rate; 0 if the ASC is missing).
- pub sample_rate: u32,
- /// Channel configuration from the ASC (0 = defined elsewhere).
- pub channel_config: u8,
- /// DecoderConfigDescriptor fields.
- pub max_bitrate: u32,
- pub avg_bitrate: u32,
- pub buffer_size: u32,
-}
-
-const AAC_RATES: [u32; 13] = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
-
-impl AacConfig {
- /// Build from an AudioSpecificConfig, decoding its header fields.
- pub fn from_asc(asc: Vec) -> Self {
- let (object_type, sample_rate, channel_config) = parse_asc(&asc).unwrap_or((0, 0, 0));
- AacConfig { asc, object_type, sample_rate, channel_config, max_bitrate: 0, avg_bitrate: 0, buffer_size: 0 }
- }
- /// Channel count implied by the channel configuration (0 if unspecified).
- pub fn channels(&self) -> u32 {
- match self.channel_config {
- 1..=6 => self.channel_config as u32,
- 7 => 8,
- _ => 0,
- }
- }
-}
-
-/// Decode (audio object type, sampling frequency, channel configuration) from an AudioSpecificConfig.
-pub fn parse_asc(asc: &[u8]) -> Option<(u8, u32, u8)> {
- let mut r = BitReader::new(asc);
- let aot = |r: &mut BitReader| -> Option {
- let t = r.read_bits(5).ok()? as u8;
- if t == 31 { Some(32 + r.read_bits(6).ok()? as u8) } else { Some(t) }
- };
- let rate = |r: &mut BitReader| -> Option {
- let i = r.read_bits(4).ok()? as usize;
- if i == 15 { r.read_bits(24).ok() } else { AAC_RATES.get(i).copied() }
- };
- let t = aot(&mut r)?;
- let sr = rate(&mut r)?;
- let ch = r.read_bits(4).ok()? as u8;
- Some((t, sr, ch))
-}
-
-/// Uncompressed PCM description.
-#[derive(Clone, Copy, Debug, PartialEq, Default)]
-pub struct PcmConfig {
- pub bits: u16,
- pub float: bool,
- pub big_endian: bool,
- /// Signed integer samples (8-bit `raw ` is unsigned).
- pub signed: bool,
- pub channels: u32,
- pub sample_rate: f64,
-}
-
-impl PcmConfig {
- /// Bytes per interleaved audio frame (all channels).
- pub fn bytes_per_frame(&self) -> u32 {
- (self.bits as u32).div_ceil(8) * self.channels.max(1)
- }
-}
-
-/// `dOps` — Opus specific box.
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct OpusConfig {
- pub output_channels: u8,
- pub pre_skip: u16,
- pub input_sample_rate: u32,
- pub output_gain: i16,
- pub channel_mapping_family: u8,
- /// StreamCount, CoupledCount and ChannelMapping bytes when family != 0.
- pub channel_mapping: Vec,
-}
-
-impl OpusConfig {
- pub fn parse(data: &[u8]) -> Result {
- let mut c = Cur::new(data, "dOps");
- let _version = c.u8()?;
- Ok(OpusConfig {
- output_channels: c.u8()?,
- pre_skip: c.u16()?,
- input_sample_rate: c.u32()?,
- output_gain: c.i16()?,
- channel_mapping_family: c.u8()?,
- channel_mapping: c.rest().to_vec(),
- })
- }
- pub fn to_bytes(&self) -> Vec {
- let mut b = BoxBuf::new();
- b.u8(0);
- b.u8(self.output_channels);
- b.u16(self.pre_skip);
- b.u32(self.input_sample_rate);
- b.i16(self.output_gain);
- b.u8(self.channel_mapping_family);
- b.bytes(&self.channel_mapping);
- b.buf
- }
-}
-
-/// `dfLa` — FLAC metadata blocks, with STREAMINFO decoded.
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct FlacConfig {
- /// Raw metadata blocks (each with its 4-byte header) as stored in `dfLa`.
- pub metadata_blocks: Vec,
- pub sample_rate: u32,
- pub channels: u8,
- pub bits_per_sample: u8,
- pub total_samples: u64,
-}
-
-impl FlacConfig {
- pub fn parse(data: &[u8]) -> Result {
- let mut c = Cur::new(data, "dfLa");
- c.full_header()?;
- let blocks = c.rest().to_vec();
- let mut cfg = FlacConfig { metadata_blocks: blocks.clone(), ..Default::default() };
- let mut b = Cur::new(&blocks, "dfLa");
- let hdr = b.u32()?;
- if (hdr >> 24) & 0x7F == 0 {
- let si = b.bytes(((hdr & 0xFF_FFFF) as usize).min(34))?;
- if si.len() >= 18 {
- let mut r = BitReader::new(&si[10..18]);
- cfg.sample_rate = r.read_bits(20).unwrap_or(0);
- cfg.channels = r.read_bits(3).unwrap_or(0) as u8 + 1;
- cfg.bits_per_sample = r.read_bits(5).unwrap_or(0) as u8 + 1;
- cfg.total_samples = r.read_bits_u64(36).unwrap_or(0);
- }
- }
- Ok(cfg)
- }
-}
-
-/// QuickTime timecode sample description (`tmcd`) plus the start frame read from its first sample.
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct TimecodeConfig {
- /// Bit 0 drop frame, bit 1 24-hour max, bit 2 negative times OK, bit 3 counter.
- pub flags: u32,
- pub timescale: u32,
- pub frame_duration: u32,
- /// Nominal frames per second (`numberOfFrames`), e.g. 30 for 29.97.
- pub frames_per_second: u8,
- /// Frame number of the first timecode sample (filled in by the demuxer).
- pub start_frame: Option,
- /// Source reel name (`name` box) if present.
- pub name: Option,
-}
-
-impl TimecodeConfig {
- pub fn drop_frame(&self) -> bool {
- self.flags & 1 != 0
- }
- /// Format a frame number as `HH:MM:SS:FF` (`;` before the frames when drop-frame).
- pub fn format_frame(&self, frame: u32) -> String {
- let fps = self.frames_per_second.max(1) as u64;
- let mut f = frame as u64;
- if self.drop_frame() && fps >= 30 {
- let drop = (fps / 30) * 2; // 2 frames per minute at 30, 4 at 60
- let per_min = fps * 60 - drop;
- let per_10 = per_min * 10 + drop;
- let d = f / per_10;
- let m = f % per_10;
- f += drop * 9 * d + if m > drop { drop * ((m - drop) / per_min) } else { 0 };
- }
- let ff = f % fps;
- let s = (f / fps) % 60;
- let mi = (f / fps / 60) % 60;
- let h = (f / fps / 3600) % 24;
- let sep = if self.drop_frame() { ';' } else { ':' };
- format!("{h:02}:{mi:02}:{s:02}{sep}{ff:02}")
- }
-}
-
-// ---------------------------------------------------------------------------------------------
-// Parsing
-
-fn is_video_fourcc(f: &[u8; 4]) -> bool {
- matches!(
- f,
- b"avc1"
- | b"avc3"
- | b"hvc1"
- | b"hev1"
- | b"av01"
- | b"vp09"
- | b"vp08"
- | b"apch"
- | b"apcn"
- | b"apcs"
- | b"apco"
- | b"ap4h"
- | b"ap4x"
- | b"jpeg"
- | b"mjpa"
- | b"mjpb"
- | b"AVdn"
- | b"AVdh"
- | b"mp4v"
- | b"encv"
- | b"raw "
- | b"2vuy"
- | b"v210"
- )
-}
-
-fn is_audio_fourcc(f: &[u8; 4]) -> bool {
- matches!(
- f,
- b"mp4a"
- | b"lpcm"
- | b"sowt"
- | b"twos"
- | b"in24"
- | b"in32"
- | b"fl32"
- | b"fl64"
- | b"ipcm"
- | b"fpcm"
- | b"alac"
- | b"Opus"
- | b"fLaC"
- | b"ac-3"
- | b"ec-3"
- | b".mp3"
- | b"enca"
- | b"ulaw"
- | b"alaw"
- | b"NONE"
- )
-}
-
-/// Parse one `stsd` entry. `handler` is the track handler type; `is_qt` selects QuickTime
-/// interpretation of the sound description version field.
-pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc, is_qt: bool) -> Result {
- let mut c = Cur::new(payload, "sample entry");
- c.skip(6)?;
- let data_reference_index = c.u16()?;
- let f = &format.0;
- let unknown = || CodecConfig::Unknown { fourcc: format, raw: payload.to_vec() };
- if f == b"tmcd" || (handler == b"tmcd" && !is_video_fourcc(f)) {
- let mut tc = TimecodeConfig::default();
- c.skip(4)?;
- tc.flags = c.u32()?;
- tc.timescale = c.u32()?;
- tc.frame_duration = c.u32()?;
- tc.frames_per_second = c.u8()?;
- c.skip(1)?;
- for b in boxes(c.rest()).map_while(|b| b.ok()) {
- if b.kind == b"name" && b.payload.len() >= 4 {
- let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize;
- let s = b.payload.get(4..4 + n).unwrap_or(&b.payload[4..]);
- tc.name = Some(String::from_utf8_lossy(s).into_owned());
- }
- }
- return Ok(SampleEntry { format, data_reference_index, codec: CodecConfig::Timecode(tc), video: None, audio: None, bitrate: None });
- }
- let video_like = is_video_fourcc(f) || (handler == b"vide" && !is_audio_fourcc(f));
- let audio_like = is_audio_fourcc(f) || (handler == b"soun" && !video_like);
- if video_like {
- let mut v = VideoParams::default();
- let _version = c.u16()?;
- let _revision = c.u16()?;
- v.vendor = c.fourcc()?;
- v.temporal_quality = c.u32()?;
- v.spatial_quality = c.u32()?;
- v.width = c.u16()?;
- v.height = c.u16()?;
- v.horiz_resolution = c.u32()?;
- v.vert_resolution = c.u32()?;
- c.skip(4)?;
- v.frame_count = c.u16()?;
- let name = c.bytes(32)?;
- let n = (name[0] as usize).min(31);
- v.compressor_name = String::from_utf8_lossy(&name[1..1 + n]).into_owned();
- v.depth = c.u16()?;
- let _ctab = c.i16()?;
- let mut codec = None;
- let mut bitrate = None;
- for b in boxes(c.rest()).map_while(|b| b.ok()) {
- let p = b.payload;
- match &b.kind.0 {
- b"avcC" if matches!(f, b"avc1" | b"avc3") => codec = Some(CodecConfig::Avc(AvcConfig::parse(p)?)),
- b"hvcC" if matches!(f, b"hvc1" | b"hev1") => codec = Some(CodecConfig::Hevc(HevcConfig::parse(p)?)),
- b"av1C" if f == b"av01" => codec = Some(CodecConfig::Av1(Av1Config::parse(p)?)),
- b"vpcC" if f == b"vp09" => codec = Some(CodecConfig::Vp9(VpcConfig::parse(p)?)),
- b"colr" => v.color = parse_colr(p).ok(),
- b"mdcv" => v.mastering_display = MasteringDisplay::parse(p),
- b"clli" if p.len() >= 4 => v.content_light = Some((u16::from_be_bytes([p[0], p[1]]), u16::from_be_bytes([p[2], p[3]]))),
- b"pasp" => {
- let mut c = Cur::new(p, "pasp");
- v.pixel_aspect = Some((c.u32()?, c.u32()?));
- }
- b"clap" => {
- let mut c = Cur::new(p, "clap");
- v.clean_aperture = Some(CleanAperture {
- width: (c.u32()?, c.u32()?),
- height: (c.u32()?, c.u32()?),
- horiz_offset: (c.i32()?, c.u32()?),
- vert_offset: (c.i32()?, c.u32()?),
- });
- }
- b"fiel" => {
- let mut c = Cur::new(p, "fiel");
- v.field_info = Some(FieldInfo { fields: c.u8()?, detail: c.u8()? });
- }
- b"gama" => v.gamma = Some(Cur::new(p, "gama").u32()?),
- b"btrt" => bitrate = Some(parse_btrt(p)?),
- _ => {}
- }
- }
- let codec = match codec {
- Some(c) => c,
- None => match f {
- b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => CodecConfig::ProRes { fourcc: format },
- b"jpeg" | b"mjpa" | b"mjpb" => CodecConfig::Jpeg { fourcc: format },
- b"AVdn" | b"AVdh" => CodecConfig::Dnx { fourcc: format },
- _ => unknown(),
- },
- };
- return Ok(SampleEntry { format, data_reference_index, codec, video: Some(v), audio: None, bitrate });
- }
- if audio_like {
- let mut a = AudioParams { qt_version: c.u16()?, ..Default::default() };
- let _revision = c.u16()?;
- let _vendor = c.u32()?;
- a.channels = c.u16()? as u32;
- a.sample_size = c.u16()?;
- a.compression_id = c.i16()?;
- let _packet_size = c.u16()?;
- a.sample_rate = c.u32()? as f64 / 65536.0;
- if is_qt && a.qt_version == 1 {
- a.samples_per_packet = c.u32()?;
- a.bytes_per_packet = c.u32()?;
- a.bytes_per_frame = c.u32()?;
- a.bytes_per_sample = c.u32()?;
- } else if is_qt && a.qt_version == 2 {
- let _size_of_struct = c.u32()?;
- a.sample_rate = c.f64()?;
- a.channels = c.u32()?;
- let _always = c.u32()?;
- a.const_bits_per_channel = c.u32()?;
- a.lpcm_flags = c.u32()?;
- a.const_bytes_per_packet = c.u32()?;
- a.const_frames_per_packet = c.u32()?;
- }
- // Flatten QuickTime `wave` children into the list of extension boxes.
- let mut ext: Vec<(FourCc, &[u8])> = Vec::new();
- for b in boxes(c.rest()).map_while(|b| b.ok()) {
- if b.kind == b"wave" {
- for w in boxes(b.payload).map_while(|b| b.ok()) {
- ext.push((w.kind, w.payload));
- }
- } else {
- ext.push((b.kind, b.payload));
- }
- }
- let get = |k: &[u8; 4]| ext.iter().find(|(kk, _)| kk == k).map(|(_, p)| *p);
- if let Some(p) = get(b"enda") {
- let v = if p.len() >= 2 { u16::from_be_bytes([p[0], p[1]]) } else { p.first().copied().unwrap_or(0) as u16 };
- a.little_endian = Some(v != 0);
- }
- let bitrate = get(b"btrt").and_then(|p| parse_btrt(p).ok());
- let codec = match f {
- b"mp4a" => match get(b"esds") {
- Some(p) => parse_esds(p)?,
- None => unknown(),
- },
- b".mp3" => CodecConfig::Mp3,
- b"alac" => match get(b"alac") {
- Some(p) if p.len() >= 4 => CodecConfig::Alac { cookie: p[4..].to_vec() },
- _ => unknown(),
- },
- b"Opus" => match get(b"dOps") {
- Some(p) => CodecConfig::Opus(OpusConfig::parse(p)?),
- None => unknown(),
- },
- b"fLaC" => match get(b"dfLa") {
- Some(p) => CodecConfig::Flac(FlacConfig::parse(p)?),
- None => unknown(),
- },
- b"ac-3" => CodecConfig::Ac3 { dac3: get(b"dac3").unwrap_or(&[]).to_vec() },
- b"ec-3" => CodecConfig::Eac3 { dec3: get(b"dec3").unwrap_or(&[]).to_vec() },
- b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)),
- b"ipcm" | b"fpcm" => {
- let mut pcm = PcmConfig {
- bits: a.sample_size,
- float: f == b"fpcm",
- big_endian: true,
- signed: true,
- channels: a.channels,
- sample_rate: a.sample_rate,
- };
- if let Some(p) = get(b"pcmC") {
- let mut c = Cur::new(p, "pcmC");
- c.full_header()?;
- let flags = c.u8()?;
- pcm.big_endian = flags & 1 == 0;
- pcm.bits = c.u8()? as u16;
- }
- CodecConfig::Pcm(pcm)
- }
- _ => unknown(),
- };
- return Ok(SampleEntry { format, data_reference_index, codec, video: None, audio: Some(a), bitrate });
- }
- Ok(SampleEntry { format, data_reference_index, codec: unknown(), video: None, audio: None, bitrate: None })
-}
-
-fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig {
- let mut p = PcmConfig { channels: a.channels, sample_rate: a.sample_rate, signed: true, ..Default::default() };
- match f {
- b"lpcm" => {
- p.bits = a.const_bits_per_channel as u16;
- p.float = a.lpcm_flags & 1 != 0;
- p.big_endian = a.lpcm_flags & 2 != 0;
- p.signed = a.lpcm_flags & 4 != 0 || p.float;
- if p.bits == 0 {
- p.bits = a.sample_size;
- }
- }
- b"sowt" => {
- p.bits = if a.sample_size == 0 { 16 } else { a.sample_size };
- }
- b"twos" => {
- p.bits = if a.sample_size == 0 { 16 } else { a.sample_size };
- p.big_endian = true;
- }
- b"raw " | b"NONE" => {
- p.bits = if a.sample_size == 0 { 8 } else { a.sample_size };
- p.signed = p.bits != 8;
- p.big_endian = true;
- }
- b"in24" | b"in32" | b"fl32" | b"fl64" => {
- p.bits = match f {
- b"in24" => 24,
- b"in32" | b"fl32" => 32,
- _ => 64,
- };
- p.float = f[0] == b'f';
- p.big_endian = !a.little_endian.unwrap_or(false);
- }
- _ => {}
- }
- p
-}
-
-fn parse_btrt(p: &[u8]) -> Result {
- let mut c = Cur::new(p, "btrt");
- Ok(BitRate { buffer_size: c.u32()?, max_bitrate: c.u32()?, avg_bitrate: c.u32()? })
-}
-
-fn parse_colr(p: &[u8]) -> Result {
- let mut c = Cur::new(p, "colr");
- let kind = c.fourcc()?;
- Ok(match &kind.0 {
- b"nclx" => {
- ColorInfo::Nclx { primaries: c.u16()?, transfer: c.u16()?, matrix: c.u16()?, full_range: c.u8().map(|v| v & 0x80 != 0).unwrap_or(false) }
- }
- b"nclc" => ColorInfo::Nclc { primaries: c.u16()?, transfer: c.u16()?, matrix: c.u16()? },
- _ => ColorInfo::Icc { kind, profile: c.rest().to_vec() },
- })
-}
-
-fn descriptor(c: &mut Cur) -> Result<(u8, usize)> {
- let tag = c.u8()?;
- let mut len = 0usize;
- for _ in 0..4 {
- let b = c.u8()?;
- len = (len << 7) | (b & 0x7F) as usize;
- if b & 0x80 == 0 {
- break;
- }
- }
- Ok((tag, len))
-}
-
-fn parse_esds(p: &[u8]) -> Result {
- let mut c = Cur::new(p, "esds");
- c.full_header()?;
- let (tag, len) = descriptor(&mut c)?;
- if tag != 3 {
- return Err(Error::Invalid(format!("esds: expected ES_Descriptor, got tag {tag}")));
- }
- let es = c.bytes(len.min(c.remaining()))?;
- let mut c = Cur::new(es, "ES_Descriptor");
- c.u16()?;
- let fl = c.u8()?;
- if fl & 0x80 != 0 {
- c.u16()?;
- }
- if fl & 0x40 != 0 {
- let n = c.u8()? as usize;
- c.skip(n)?;
- }
- if fl & 0x20 != 0 {
- c.u16()?;
- }
- while c.remaining() > 0 {
- let (tag, len) = descriptor(&mut c)?;
- let body = c.bytes(len.min(c.remaining()))?;
- if tag != 4 {
- continue;
- }
- let mut d = Cur::new(body, "DecoderConfigDescriptor");
- let oti = d.u8()?;
- let _stream_type = d.u8()?;
- let buffer_size = d.u24()?;
- let max_bitrate = d.u32()?;
- let avg_bitrate = d.u32()?;
- let mut asc = Vec::new();
- while d.remaining() > 0 {
- let (t, l) = descriptor(&mut d)?;
- let b = d.bytes(l.min(d.remaining()))?;
- if t == 5 {
- asc = b.to_vec();
- break;
- }
- }
- return Ok(match oti {
- 0x69 | 0x6B => CodecConfig::Mp3,
- _ => {
- let mut a = AacConfig::from_asc(asc);
- a.max_bitrate = max_bitrate;
- a.avg_bitrate = avg_bitrate;
- a.buffer_size = buffer_size;
- CodecConfig::Aac(a)
- }
- });
- }
- Err(Error::Invalid("esds without DecoderConfigDescriptor".into()))
-}
-
-// ---------------------------------------------------------------------------------------------
-// Serialization (muxer)
-
-fn put_descriptor(b: &mut BoxBuf, tag: u8, body: &[u8]) {
- b.u8(tag);
- let n = body.len() as u32;
- // Always use the 4-byte length form for simplicity (as many encoders do).
- b.u8(0x80 | ((n >> 21) & 0x7F) as u8);
- b.u8(0x80 | ((n >> 14) & 0x7F) as u8);
- b.u8(0x80 | ((n >> 7) & 0x7F) as u8);
- b.u8((n & 0x7F) as u8);
- b.bytes(body);
-}
-
-pub(crate) fn esds_payload(oti: u8, asc: &[u8], max_bitrate: u32, avg_bitrate: u32, buffer_size: u32) -> Vec {
- let mut dcd = BoxBuf::new();
- dcd.u8(oti);
- dcd.u8(0x15); // streamType audio (5) << 2 | upstream 0 | reserved 1
- dcd.u8((buffer_size >> 16) as u8);
- dcd.u16(buffer_size as u16);
- dcd.u32(max_bitrate);
- dcd.u32(avg_bitrate);
- if !asc.is_empty() {
- put_descriptor(&mut dcd, 5, asc);
- }
- let mut es = BoxBuf::new();
- es.u16(1); // ES_ID
- es.u8(0);
- put_descriptor(&mut es, 4, &dcd.buf);
- put_descriptor(&mut es, 6, &[2]); // SLConfigDescriptor, predefined = 2 (MP4)
- let mut b = BoxBuf::new();
- b.u32(0);
- put_descriptor(&mut b, 3, &es.buf);
- b.buf
-}
-
-impl SampleEntry {
- /// Convenience constructor for a video entry.
- pub fn video(format: FourCc, codec: CodecConfig, width: u16, height: u16) -> Self {
- let compressor_name = match &codec {
- CodecConfig::ProRes { fourcc } => match &fourcc.0 {
- b"apch" => "Apple ProRes 422 HQ",
- b"apcn" => "Apple ProRes 422",
- b"apcs" => "Apple ProRes 422 LT",
- b"apco" => "Apple ProRes 422 Proxy",
- b"ap4h" => "Apple ProRes 4444",
- _ => "Apple ProRes 4444 XQ",
- },
- CodecConfig::Jpeg { .. } => "Photo - JPEG",
- _ => "",
- };
- SampleEntry {
- format,
- data_reference_index: 1,
- codec,
- video: Some(VideoParams {
- width,
- height,
- horiz_resolution: 0x0048_0000,
- vert_resolution: 0x0048_0000,
- frame_count: 1,
- compressor_name: compressor_name.into(),
- depth: 24,
- ..Default::default()
- }),
- audio: None,
- bitrate: None,
- }
- }
-
- /// Convenience constructor for an audio entry (ISO v0 layout; the muxer upgrades PCM in MOV to v2).
- pub fn audio(format: FourCc, codec: CodecConfig, channels: u32, sample_rate: f64, sample_size: u16) -> Self {
- SampleEntry {
- format,
- data_reference_index: 1,
- codec,
- video: None,
- audio: Some(AudioParams { channels, sample_rate, sample_size, ..Default::default() }),
- bitrate: None,
- }
- }
-
- /// H.264 entry (`avc1`).
- pub fn avc(cfg: AvcConfig, width: u16, height: u16) -> Self {
- Self::video(FourCc(*b"avc1"), CodecConfig::Avc(cfg), width, height)
- }
- /// HEVC entry (`hvc1`, parameter sets in the config only).
- pub fn hevc(cfg: HevcConfig, width: u16, height: u16) -> Self {
- Self::video(FourCc(*b"hvc1"), CodecConfig::Hevc(cfg), width, height)
- }
- /// ProRes entry; `fourcc` one of `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`.
- pub fn prores(fourcc: FourCc, width: u16, height: u16) -> Self {
- let mut e = Self::video(fourcc, CodecConfig::ProRes { fourcc }, width, height);
- if let Some(v) = e.video.as_mut()
- && matches!(&fourcc.0, b"ap4h" | b"ap4x")
- {
- v.depth = 32;
- }
- e
- }
- /// Avid DNxHD / DNxHR entry (`AVdn`, or `AVdh` for DNxHR).
- pub fn dnx(fourcc: FourCc, width: u16, height: u16) -> Self {
- let mut e = Self::video(fourcc, CodecConfig::Dnx { fourcc }, width, height);
- if let Some(v) = e.video.as_mut() {
- v.depth = 24;
- }
- e
- }
- /// Motion-JPEG entry (`jpeg`).
- pub fn jpeg(width: u16, height: u16) -> Self {
- let f = FourCc(*b"jpeg");
- Self::video(f, CodecConfig::Jpeg { fourcc: f }, width, height)
- }
- /// AAC entry (`mp4a` + `esds`) from an AudioSpecificConfig.
- pub fn aac(asc: Vec, channels: u32, sample_rate: u32) -> Self {
- Self::audio(FourCc(*b"mp4a"), CodecConfig::Aac(AacConfig::from_asc(asc)), channels, sample_rate as f64, 16)
- }
- /// PCM entry. The muxer picks the on-disk format (`sowt`/`twos`/`lpcm` for MOV, `ipcm`/`fpcm` for MP4).
- pub fn pcm(cfg: PcmConfig) -> Self {
- Self::audio(FourCc(*b"lpcm"), CodecConfig::Pcm(cfg), cfg.channels, cfg.sample_rate, cfg.bits)
- }
-
- /// Serialize as a complete sample entry box. For PCM the format is chosen from the brand.
- pub(crate) fn write(&self, b: &mut BoxBuf, qt: bool) -> Result<()> {
- if let CodecConfig::Unknown { fourcc, raw } = &self.codec {
- let m = b.start(&fourcc.0);
- b.bytes(raw);
- b.end(m);
- return Ok(());
- }
- if let CodecConfig::Timecode(tc) = &self.codec {
- let m = b.start(b"tmcd");
- b.zeros(6);
- b.u16(self.data_reference_index);
- b.u32(0);
- b.u32(tc.flags);
- b.u32(tc.timescale);
- b.u32(tc.frame_duration);
- b.u8(tc.frames_per_second);
- b.u8(0);
- if let Some(name) = &tc.name {
- let n = b.start(b"name");
- b.u16(name.len() as u16);
- b.u16(0);
- b.bytes(name.as_bytes());
- b.end(n);
- }
- b.end(m);
- return Ok(());
- }
- if let Some(v) = &self.video {
- let m = b.start(&self.format.0);
- b.zeros(6);
- b.u16(self.data_reference_index);
- b.u16(0);
- b.u16(0);
- b.u32(v.vendor.as_u32());
- b.u32(v.temporal_quality);
- b.u32(v.spatial_quality);
- b.u16(v.width);
- b.u16(v.height);
- b.u32(v.horiz_resolution);
- b.u32(v.vert_resolution);
- b.u32(0);
- b.u16(v.frame_count);
- let name = v.compressor_name.as_bytes();
- let n = name.len().min(31);
- let mut nb = [0u8; 32];
- nb[0] = n as u8;
- nb[1..1 + n].copy_from_slice(&name[..n]);
- b.bytes(&nb);
- b.u16(v.depth);
- b.i16(-1);
- match &self.codec {
- CodecConfig::Avc(c) => b.leaf(b"avcC", &c.to_bytes()),
- CodecConfig::Hevc(c) => b.leaf(b"hvcC", &c.to_bytes()),
- CodecConfig::Av1(c) => b.leaf(b"av1C", &c.to_bytes()),
- CodecConfig::Vp9(c) => b.leaf(b"vpcC", &c.to_bytes()),
- _ => {}
- }
- if let Some(g) = v.gamma {
- b.leaf(b"gama", &g.to_be_bytes());
- }
- if let Some(fi) = v.field_info {
- b.leaf(b"fiel", &[fi.fields, fi.detail]);
- }
- if let Some(ci) = &v.color {
- let m = b.start(b"colr");
- match ci {
- ColorInfo::Nclx { primaries, transfer, matrix, full_range } => {
- b.bytes(b"nclx");
- b.u16(*primaries);
- b.u16(*transfer);
- b.u16(*matrix);
- b.u8(if *full_range { 0x80 } else { 0 });
- }
- ColorInfo::Nclc { primaries, transfer, matrix } => {
- b.bytes(b"nclc");
- b.u16(*primaries);
- b.u16(*transfer);
- b.u16(*matrix);
- }
- ColorInfo::Icc { kind, profile } => {
- b.bytes(&kind.0);
- b.bytes(profile);
- }
- }
- b.end(m);
- }
- if let Some(md) = &v.mastering_display {
- b.leaf(b"mdcv", &md.to_bytes());
- }
- if let Some((cll, fall)) = v.content_light {
- let mut d = cll.to_be_bytes().to_vec();
- d.extend_from_slice(&fall.to_be_bytes());
- b.leaf(b"clli", &d);
- }
- if let Some((h, vv)) = v.pixel_aspect {
- let m = b.start(b"pasp");
- b.u32(h);
- b.u32(vv);
- b.end(m);
- }
- if let Some(c) = v.clean_aperture {
- let m = b.start(b"clap");
- b.u32(c.width.0);
- b.u32(c.width.1);
- b.u32(c.height.0);
- b.u32(c.height.1);
- b.i32(c.horiz_offset.0);
- b.u32(c.horiz_offset.1);
- b.i32(c.vert_offset.0);
- b.u32(c.vert_offset.1);
- b.end(m);
- }
- if let Some(br) = self.bitrate {
- write_btrt(b, br);
- }
- b.end(m);
- return Ok(());
- }
- let a = self.audio.clone().ok_or_else(|| Error::Mux("sample entry has neither video nor audio params".into()))?;
- // Choose the on-disk format and sound description version.
- let (format, version, pcm) = match &self.codec {
- CodecConfig::Pcm(p) => {
- if qt {
- if p.bits == 16 && !p.float {
- (if p.big_endian { *b"twos" } else { *b"sowt" }, 0u16, Some(*p))
- } else if p.bits == 8 && !p.float {
- (*b"raw ", 0, Some(*p))
- } else {
- (*b"lpcm", 2, Some(*p))
- }
- } else {
- (if p.float { *b"fpcm" } else { *b"ipcm" }, 0, Some(*p))
- }
- }
- _ => (self.format.0, if qt { a.qt_version.min(2) } else { 0 }, None),
- };
- let m = b.start(&format);
- b.zeros(6);
- b.u16(self.data_reference_index);
- if version == 2 {
- let p = pcm.unwrap_or_default();
- b.u16(2);
- b.u16(0);
- b.u32(0);
- b.u16(3);
- b.u16(16);
- b.i16(-2);
- b.u16(0);
- b.u32(65536);
- b.u32(72);
- b.f64(p.sample_rate);
- b.u32(p.channels);
- b.u32(0x7F00_0000);
- b.u32(p.bits as u32);
- let flags = (p.float as u32) | ((p.big_endian as u32) << 1) | (((p.signed && !p.float) as u32) << 2) | 8;
- b.u32(flags);
- b.u32(p.bytes_per_frame());
- b.u32(1);
- } else {
- b.u16(version);
- b.u16(0);
- b.u32(0);
- b.u16(a.channels.min(u16::MAX as u32) as u16);
- b.u16(match pcm {
- Some(p) => p.bits,
- None => {
- if a.sample_size == 0 {
- 16
- } else {
- a.sample_size
- }
- }
- });
- b.i16(if version == 1 { a.compression_id } else { 0 });
- b.u16(0);
- let sr = if a.sample_rate < 65536.0 { (a.sample_rate * 65536.0).round() as u32 } else { 0 };
- b.u32(sr);
- if version == 1 {
- b.u32(a.samples_per_packet);
- b.u32(a.bytes_per_packet);
- b.u32(a.bytes_per_frame);
- b.u32(a.bytes_per_sample);
- }
- }
- match &self.codec {
- CodecConfig::Aac(c) => {
- b.leaf(b"esds", &esds_payload(0x40, &c.asc, c.max_bitrate, c.avg_bitrate, c.buffer_size));
- }
- CodecConfig::Mp3 if format == *b"mp4a" => {
- b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0));
- }
- CodecConfig::Alac { cookie } => {
- let m = b.start_full(b"alac", 0, 0);
- b.bytes(cookie);
- b.end(m);
- }
- CodecConfig::Opus(o) => b.leaf(b"dOps", &o.to_bytes()),
- CodecConfig::Flac(fl) => {
- let m = b.start_full(b"dfLa", 0, 0);
- b.bytes(&fl.metadata_blocks);
- b.end(m);
- }
- CodecConfig::Ac3 { dac3 } => b.leaf(b"dac3", dac3),
- CodecConfig::Eac3 { dec3 } => b.leaf(b"dec3", dec3),
- CodecConfig::Pcm(p) if !qt => {
- let m = b.start_full(b"pcmC", 0, 0);
- b.u8(if p.big_endian { 0 } else { 1 });
- b.u8(p.bits as u8);
- b.end(m);
- }
- CodecConfig::Pcm(p) if p.channels == 6 => {
- // QuickTime channel layout: kAudioChannelLayoutTag_MPEG_5_1_A (L R C LFE Ls Rs)
- let m = b.start_full(b"chan", 0, 0);
- b.u32((121 << 16) | 6);
- b.u32(0);
- b.u32(0);
- b.end(m);
- }
- _ => {}
- }
- if let Some(br) = self.bitrate {
- write_btrt(b, br);
- }
- b.end(m);
- Ok(())
- }
-}
-
-fn write_btrt(b: &mut BoxBuf, br: BitRate) {
- let m = b.start(b"btrt");
- b.u32(br.buffer_size);
- b.u32(br.max_bitrate);
- b.u32(br.avg_bitrate);
- b.end(m);
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn roundtrip(e: &SampleEntry, qt: bool, handler: &[u8; 4]) -> SampleEntry {
- let mut b = BoxBuf::new();
- e.write(&mut b, qt).unwrap();
- let bx = crate::isobmff::bytes::boxes(&b.buf).next().unwrap().unwrap();
- parse_sample_entry(bx.kind, bx.payload, FourCc(*handler), qt).unwrap()
- }
-
- #[test]
- fn avcc_roundtrip() {
- let cfg = AvcConfig::new(vec![vec![0x67, 0x64, 0x00, 0x1F, 0xAC]], vec![vec![0x68, 0xEB]], 4);
- assert_eq!(cfg.profile, 0x64);
- assert_eq!(AvcConfig::parse(&cfg.to_bytes()).unwrap(), cfg);
- let mut e = SampleEntry::avc(cfg, 1920, 1080);
- e.video.as_mut().unwrap().color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false });
- e.video.as_mut().unwrap().pixel_aspect = Some((4, 3));
- e.video.as_mut().unwrap().field_info = Some(FieldInfo { fields: 2, detail: 9 });
- e.video.as_mut().unwrap().clean_aperture =
- Some(CleanAperture { width: (1920, 1), height: (1080, 1), horiz_offset: (-2, 1), vert_offset: (0, 1) });
- e.video.as_mut().unwrap().mastering_display = Some(MasteringDisplay::bt2020(1000.0, 0.0001));
- e.video.as_mut().unwrap().content_light = Some((1000, 400));
- e.bitrate = Some(BitRate { buffer_size: 1, max_bitrate: 2, avg_bitrate: 3 });
- assert_eq!(roundtrip(&e, false, b"vide"), e);
- assert_eq!(MasteringDisplay::bt2020(1000.0, 0.0001).max_nits(), 1000.0);
- }
-
- #[test]
- fn hvcc_roundtrip() {
- let cfg = HevcConfig {
- general_profile_idc: 1,
- general_profile_compatibility_flags: 0x6000_0000,
- general_constraint_indicator_flags: 0x9000_0000_0000,
- general_level_idc: 93,
- chroma_format_idc: 1,
- bit_depth_luma: 8,
- bit_depth_chroma: 8,
- length_size: 4,
- num_temporal_layers: 1,
- temporal_id_nested: true,
- arrays: vec![
- HevcNalArray { completeness: true, nal_type: 32, nalus: vec![vec![0x40, 1]] },
- HevcNalArray { completeness: true, nal_type: 33, nalus: vec![vec![0x42, 1, 2]] },
- HevcNalArray { completeness: true, nal_type: 34, nalus: vec![vec![0x44, 1]] },
- ],
- ..Default::default()
- };
- let back = HevcConfig::parse(&cfg.to_bytes()).unwrap();
- assert_eq!(back, cfg);
- assert_eq!(back.sps(), vec![&[0x42u8, 1, 2][..]]);
- }
-
- #[test]
- fn av1_vp9_opus_roundtrip() {
- let a = Av1Config { seq_profile: 1, seq_level_idx_0: 8, high_bitdepth: true, config_obus: vec![1, 2], ..Default::default() };
- assert_eq!(Av1Config::parse(&a.to_bytes()).unwrap(), a);
- let v = VpcConfig {
- profile: 2,
- level: 31,
- bit_depth: 10,
- chroma_subsampling: 1,
- full_range: true,
- colour_primaries: 9,
- transfer_characteristics: 16,
- matrix_coefficients: 9,
- codec_init: vec![],
- };
- assert_eq!(VpcConfig::parse(&v.to_bytes()).unwrap(), v);
- let o = OpusConfig {
- output_channels: 2,
- pre_skip: 312,
- input_sample_rate: 48000,
- output_gain: 0,
- channel_mapping_family: 0,
- channel_mapping: vec![],
- };
- assert_eq!(OpusConfig::parse(&o.to_bytes()).unwrap(), o);
- }
-
- #[test]
- fn aac_esds_roundtrip() {
- let e = SampleEntry::aac(vec![0x12, 0x10], 2, 44100);
- match &e.codec {
- CodecConfig::Aac(a) => {
- assert_eq!((a.object_type, a.sample_rate, a.channel_config), (2, 44100, 2));
- }
- _ => panic!(),
- }
- assert_eq!(roundtrip(&e, false, b"soun"), e);
- assert_eq!(roundtrip(&e, true, b"soun"), e);
- }
-
- #[test]
- fn pcm_entries() {
- for (bits, float, be, qt) in [
- (16, false, false, true),
- (16, false, true, true),
- (24, false, false, true),
- (32, true, false, true),
- (24, false, true, false),
- (32, true, false, false),
- ] {
- let p = PcmConfig { bits, float, big_endian: be, signed: true, channels: 2, sample_rate: 48000.0 };
- let e = SampleEntry::pcm(p);
- let back = roundtrip(&e, qt, b"soun");
- assert_eq!(back.codec, CodecConfig::Pcm(p), "{bits} {float} {be} {qt}");
- }
- }
-
- #[test]
- fn timecode_and_prores() {
- let tc =
- TimecodeConfig { flags: 1, timescale: 30000, frame_duration: 1001, frames_per_second: 30, start_frame: None, name: Some("reel".into()) };
- let e = SampleEntry {
- format: FourCc(*b"tmcd"),
- data_reference_index: 1,
- codec: CodecConfig::Timecode(tc.clone()),
- video: None,
- audio: None,
- bitrate: None,
- };
- assert_eq!(roundtrip(&e, true, b"tmcd"), e);
- assert_eq!(tc.format_frame(107892), "01:00:00;00");
- let nd = TimecodeConfig { flags: 0, frames_per_second: 24, ..Default::default() };
- assert_eq!(nd.format_frame(86400), "01:00:00:00");
- let p = SampleEntry::prores(FourCc(*b"apch"), 1920, 1080);
- assert_eq!(roundtrip(&p, true, b"vide"), p);
- }
-
- #[test]
- fn asc_parse() {
- assert_eq!(parse_asc(&[0x11, 0x90]), Some((2, 48000, 2)));
- assert_eq!(parse_asc(&[]), None);
- }
-}
diff --git a/crates/video/src/isobmff/demux.rs b/crates/video/src/isobmff/demux.rs
deleted file mode 100644
index 0b82e5c0..00000000
--- a/crates/video/src/isobmff/demux.rs
+++ /dev/null
@@ -1,1173 +0,0 @@
-//! Demuxer: parses box structure lazily (headers + `moov`/`moof` only) and builds per-track sample tables.
-
-use crate::isobmff::bytes::{Cur, FourCc, boxes, find, parse_box_header};
-use crate::isobmff::codec::{CodecConfig, SampleEntry, parse_sample_entry};
-use crate::isobmff::error::{Error, Result, invalid};
-use crate::isobmff::source::ByteSource;
-use std::collections::HashMap;
-
-/// Hard cap on per-track sample count (after PCM chunk merging).
-const MAX_SAMPLES: usize = 1 << 26;
-/// Hard cap on the size of an in-memory `moov`/`moof` box.
-const MAX_HEADER_BOX: u64 = 1 << 30;
-
-/// One sample (access unit) of a track, in the track's media timescale.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
-pub struct Sample {
- /// Absolute byte offset in the file.
- pub offset: u64,
- pub size: u32,
- /// Decode timestamp (raw media time, edit lists not applied).
- pub dts: i64,
- /// Presentation timestamp = dts + composition offset (raw media time).
- pub pts: i64,
- pub duration: u32,
- pub is_sync: bool,
- /// 1-based index into [`Track::entries`].
- pub description_index: u32,
-}
-
-/// One edit list entry (`elst`).
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct Edit {
- /// Duration of this edit in the movie timescale.
- pub segment_duration: u64,
- /// Start time within the media (track timescale); `-1` marks an empty edit.
- pub media_time: i64,
- /// Media rate as 16.16 fixed point (0x0001_0000 = normal speed, 0 = dwell).
- pub media_rate: i32,
-}
-
-/// Broad classification of a track by its handler.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum TrackKind {
- Video,
- Audio,
- Timecode,
- Subtitle,
- Other,
-}
-
-/// A sample-to-group mapping (`sbgp`) with the matching descriptions (`sgpd`) kept raw.
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct SampleGroup {
- pub grouping_type: FourCc,
- /// (sample_count, group_description_index) runs.
- pub runs: Vec<(u32, u32)>,
- /// Raw description entries from `sgpd`.
- pub descriptions: Vec>,
-}
-
-/// A demuxed track.
-#[derive(Clone, Debug, PartialEq)]
-pub struct Track {
- pub id: u32,
- pub kind: TrackKind,
- pub handler: FourCc,
- pub handler_name: String,
- /// Media timescale (`mdhd`).
- pub timescale: u32,
- /// Media duration in the media timescale (`mdhd`).
- pub duration: u64,
- /// Track duration in the movie timescale (`tkhd`).
- pub tkhd_duration: u64,
- /// ISO 639-2/T language code (e.g. `und`, `eng`).
- pub language: String,
- /// `tkhd` presentation size (16.16 fixed point converted to integer pixels, rounded down).
- pub width: u32,
- pub height: u32,
- pub enabled: bool,
- pub alternate_group: u16,
- /// `tkhd` volume (8.8 fixed point).
- pub volume: i16,
- /// `tkhd` transformation matrix.
- pub matrix: [i32; 9],
- pub entries: Vec,
- pub samples: Vec,
- pub edits: Vec,
- /// Offset to add to raw pts to get presentation time (track timescale), derived from the
- /// leading empty edits and the first non-empty edit's `media_time`.
- pub edit_offset: i64,
- /// Track references (`tref`): (type, track ids).
- pub references: Vec<(FourCc, Vec)>,
- /// Raw `sdtp` bytes (one per sample) if present.
- pub sdtp: Vec,
- pub sample_groups: Vec,
- /// True if uncompressed audio samples were merged into one [`Sample`] per chunk.
- pub pcm_chunked: bool,
- sync_index: Vec,
- pts_order: Vec,
-}
-
-impl Track {
- /// The first sample entry's codec configuration.
- pub fn codec(&self) -> Option<&CodecConfig> {
- self.entries.first().map(|e| &e.codec)
- }
- pub fn video(&self) -> Option<&crate::isobmff::codec::VideoParams> {
- self.entries.first().and_then(|e| e.video.as_ref())
- }
- /// Clockwise quarter turns (0–3) the `tkhd` matrix applies for display, or None when it is not
- /// a pure 0/90/180/270° rotation (mirrored, scaled or sheared — left to the caller).
- ///
- /// ISO/IEC 14496-12 §8.3.2: a sample point (p, q) is displayed at p′ = a·p + c·q + x,
- /// q′ = b·p + d·q + y for the matrix {a, b, u, c, d, v, x, y, w}, with y pointing down. So
- /// {0, 1, −1, 0} sends the top row to the right-hand column (90° clockwise — iPhone portrait
- /// video), {−1, 0, 0, −1} is 180° and {0, −1, 1, 0} is 270°. Only the signs matter: a·d − b·c
- /// carries the scale, which the presentation size already reflects.
- pub fn display_rotation(&self) -> Option {
- let [a, b, _, c, d, _, _, _, _] = self.matrix;
- match (a.signum(), b.signum(), c.signum(), d.signum()) {
- (1, 0, 0, 1) => Some(0),
- (0, 1, -1, 0) => Some(1),
- (-1, 0, 0, -1) => Some(2),
- (0, -1, 1, 0) => Some(3),
- _ => None,
- }
- }
- pub fn audio(&self) -> Option<&crate::isobmff::codec::AudioParams> {
- self.entries.first().and_then(|e| e.audio.as_ref())
- }
- /// Presentation (edit-adjusted) pts of sample `i`.
- pub fn presentation_pts(&self, i: usize) -> Option {
- self.samples.get(i).map(|s| s.pts.saturating_add(self.edit_offset))
- }
- /// Index of the sample being presented at raw media time `pts` (the last sample, in
- /// presentation order, whose pts is ≤ `pts`; the first sample if `pts` precedes all).
- pub fn sample_at_pts(&self, pts: i64) -> Option {
- if self.pts_order.is_empty() {
- return None;
- }
- let k = self.pts_order.partition_point(|&i| self.samples[i as usize].pts <= pts);
- Some(self.pts_order[k.saturating_sub(1)] as usize)
- }
- /// Like [`sample_at_pts`](Self::sample_at_pts) but for edit-adjusted presentation time.
- pub fn sample_at_presentation_time(&self, t: i64) -> Option {
- self.sample_at_pts(t.saturating_sub(self.edit_offset))
- }
- /// The closest sync sample at or before `index` (decode order); 0 if there is none.
- pub fn sync_sample_before(&self, index: usize) -> usize {
- let k = self.sync_index.partition_point(|&i| i as usize <= index);
- if k == 0 { 0 } else { self.sync_index[k - 1] as usize }
- }
- /// Indices of all sync samples.
- pub fn sync_samples(&self) -> &[u32] {
- &self.sync_index
- }
-
- fn finish_indexes(&mut self) {
- self.sync_index = self.samples.iter().enumerate().filter(|(_, s)| s.is_sync).map(|(i, _)| i as u32).collect();
- let mut order: Vec = (0..self.samples.len() as u32).collect();
- order.sort_by_key(|&i| (self.samples[i as usize].pts, i));
- self.pts_order = order;
- }
-}
-
-/// Text metadata from `udta` (QuickTime `©xxx` atoms and iTunes-style `meta/ilst`, incl. `keys`).
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct Metadata {
- pub entries: Vec<(String, String)>,
-}
-
-impl Metadata {
- pub fn get(&self, key: &str) -> Option<&str> {
- self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
- }
- /// Title from `©nam` or the `com.apple.quicktime.title` key.
- pub fn title(&self) -> Option<&str> {
- self.get("©nam").or_else(|| self.get("com.apple.quicktime.title")).or_else(|| self.get("title"))
- }
-}
-
-/// A parsed MP4/MOV file (headers and sample tables; media data stays in the source).
-#[derive(Clone, Debug, PartialEq)]
-pub struct Mp4File {
- pub major_brand: FourCc,
- pub minor_version: u32,
- pub compatible_brands: Vec,
- /// QuickTime file (major brand `qt ` or no `ftyp`).
- pub is_quicktime: bool,
- /// Movie timescale (`mvhd`).
- pub timescale: u32,
- /// Movie duration in the movie timescale (`mvhd`; for fragmented files, the longest track).
- pub duration: u64,
- pub tracks: Vec