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
new file mode 100644
index 00000000..e78fe1ea
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,21 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ # 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:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/AGENTS.md b/AGENTS.md
index 4fdf3cb7..76338a2b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,6 +32,7 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
- The audio callback never blocks, allocates in steady state, or panics.
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## Architecture (layers enforced by `cargo xtask layers`)
diff --git a/Cargo.lock b/Cargo.lock
index 85b092db..6158b44b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -97,8 +97,8 @@ dependencies = [
"accesskit_consumer 0.35.0",
"hashbrown 0.16.1",
"static_assertions",
- "windows 0.62.2",
- "windows-core 0.62.2",
+ "windows",
+ "windows-core",
]
[[package]]
@@ -151,9 +151,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
-version = "0.9.1"
+version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
+checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
dependencies = [
"alsa-sys",
"bitflags 2.13.2",
@@ -163,9 +163,9 @@ dependencies = [
[[package]]
name = "alsa-sys"
-version = "0.3.1"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
+checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
dependencies = [
"libc",
"pkg-config",
@@ -180,7 +180,7 @@ dependencies = [
"android-properties",
"bitflags 2.13.2",
"cc",
- "jni 0.22.4",
+ "jni",
"libc",
"log",
"ndk",
@@ -249,20 +249,19 @@ version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
- "libloading",
+ "libloading 0.8.9",
]
[[package]]
name = "asio-sys"
-version = "0.2.6"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "826194e1612938c9be09b78b58323fbb2e326de3d491b4230186cf6e832d8ded"
+checksum = "477f1cf5b65679fc2e5422af426f87becba79d1cfa2cd77eff3433e36c8021b9"
dependencies = [
"bindgen",
"cc",
"num-derive",
"num-traits",
- "parse_cfg",
"walkdir",
]
@@ -466,15 +465,6 @@ dependencies = [
"syn 2.0.119",
]
-[[package]]
-name = "bit-set"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
-dependencies = [
- "bit-vec 0.8.0",
-]
-
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -485,10 +475,13 @@ dependencies = [
]
[[package]]
-name = "bit-vec"
-version = "0.8.0"
+name = "bit-set"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
+dependencies = [
+ "bit-vec 0.10.1",
+]
[[package]]
name = "bit-vec"
@@ -496,6 +489,16 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+[[package]]
+name = "bit-vec"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
+dependencies = [
+ "borsh",
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -507,9 +510,6 @@ name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
-dependencies = [
- "serde_core",
-]
[[package]]
name = "block2"
@@ -542,6 +542,30 @@ dependencies = [
"piper",
]
+[[package]]
+name = "borsh"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
+dependencies = [
+ "borsh-derive",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -642,12 +666,6 @@ dependencies = [
"shlex 2.0.1",
]
-[[package]]
-name = "cesu8"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
-
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -678,6 +696,26 @@ dependencies = [
"libc",
]
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "rand_core",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "clang-sys"
version = "1.9.1"
@@ -686,7 +724,7 @@ checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
- "libloading",
+ "libloading 0.8.9",
]
[[package]]
@@ -807,11 +845,11 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "coreaudio-rs"
-version = "0.13.0"
+version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
+checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
dependencies = [
- "bitflags 1.3.2",
+ "bitflags 2.13.2",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
@@ -821,15 +859,18 @@ dependencies = [
[[package]]
name = "cpal"
-version = "0.16.0"
+version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
+checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1"
dependencies = [
"alsa",
"asio-sys",
+ "block2 0.6.2",
"coreaudio-rs",
"dasp_sample",
- "jni 0.21.1",
+ "futures-channel",
+ "futures-util",
+ "jni",
"js-sys",
"libc",
"mach2",
@@ -837,13 +878,27 @@ dependencies = [
"ndk-context",
"num-derive",
"num-traits",
+ "objc2 0.6.5",
"objc2-audio-toolbox",
+ "objc2-avf-audio",
"objc2-core-audio",
"objc2-core-audio-types",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
- "windows 0.54.0",
+ "windows",
+ "windows-core",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
]
[[package]]
@@ -851,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"
@@ -969,7 +1032,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
- "libloading",
+ "libloading 0.8.9",
]
[[package]]
@@ -1150,20 +1213,6 @@ dependencies = [
"bytemuck",
]
-[[package]]
-name = "encoding_rs"
-version = "0.8.42"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
-dependencies = [
- "cfg-if",
- "core_detect",
- "multiversion_no_op",
- "rustversion",
- "scopeguard",
- "simdutf8",
-]
-
[[package]]
name = "endi"
version = "1.1.1"
@@ -1380,6 +1429,15 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+]
+
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -1475,6 +1533,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
+ "rand_core",
]
[[package]]
@@ -1535,7 +1594,7 @@ dependencies = [
"glutin_egl_sys",
"glutin_glx_sys",
"glutin_wgl_sys",
- "libloading",
+ "libloading 0.8.9",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
@@ -1599,7 +1658,7 @@ dependencies = [
"log",
"presser",
"thiserror 2.0.21",
- "windows 0.62.2",
+ "windows",
]
[[package]]
@@ -1840,22 +1899,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
-[[package]]
-name = "jni"
-version = "0.21.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
-dependencies = [
- "cesu8",
- "cfg-if",
- "combine",
- "jni-sys 0.3.1",
- "log",
- "thiserror 1.0.69",
- "walkdir",
- "windows-sys 0.45.0",
-]
-
[[package]]
name = "jni"
version = "0.22.4"
@@ -1937,13 +1980,12 @@ dependencies = [
[[package]]
name = "keyboard-types"
-version = "0.7.0"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
- "unicode-segmentation",
]
[[package]]
@@ -1953,7 +1995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
- "libloading",
+ "libloading 0.8.9",
"pkg-config",
]
@@ -2007,6 +2049,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "libloading"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
[[package]]
name = "libm"
version = "0.2.16"
@@ -2072,12 +2124,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "mach2"
-version = "0.4.3"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
-dependencies = [
- "libc",
-]
+checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
[[package]]
name = "memchr"
@@ -2141,9 +2190,9 @@ dependencies = [
[[package]]
name = "muda"
-version = "0.19.3"
+version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
+checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2152,18 +2201,11 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
- "once_cell",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
-[[package]]
-name = "multiversion_no_op"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
-
[[package]]
name = "naga"
version = "30.0.1"
@@ -2391,6 +2433,17 @@ dependencies = [
"objc2-foundation 0.3.2",
]
+[[package]]
+name = "objc2-avf-audio"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.5",
+ "objc2-foundation 0.3.2",
+]
+
[[package]]
name = "objc2-cloud-kit"
version = "0.2.2"
@@ -2425,6 +2478,7 @@ dependencies = [
"objc2 0.6.5",
"objc2-core-audio-types",
"objc2-core-foundation",
+ "objc2-foundation 0.3.2",
]
[[package]]
@@ -2456,7 +2510,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
+ "block2 0.6.2",
"dispatch2",
+ "libc",
"objc2 0.6.5",
]
@@ -2524,6 +2580,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
+ "libc",
"objc2 0.6.5",
"objc2-core-foundation",
]
@@ -2743,15 +2800,6 @@ dependencies = [
"windows-link",
]
-[[package]]
-name = "parse_cfg"
-version = "4.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "905787a434a2c721408e7c9a252e85f3d93ca0f118a5283022636c0e05a7ea49"
-dependencies = [
- "nom",
-]
-
[[package]]
name = "peniko"
version = "0.6.1"
@@ -2935,15 +2983,6 @@ dependencies = [
"zerovec",
]
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
[[package]]
name = "presser"
version = "0.3.1"
@@ -2995,16 +3034,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
- "bit-set 0.8.0",
- "bit-vec 0.8.0",
+ "bit-set 0.11.1",
+ "bit-vec 0.10.1",
"bitflags 2.13.2",
+ "chacha20",
+ "core_detect",
"num-traits",
"rand",
- "rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -3062,38 +3102,25 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
-version = "0.9.5"
+version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
- "rand_chacha",
- "rand_core",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
+ "getrandom 0.4.3",
"rand_core",
]
[[package]]
name = "rand_core"
-version = "0.9.5"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xorshift"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
"rand_core",
]
@@ -3203,6 +3230,12 @@ dependencies = [
"regex-syntax",
]
+[[package]]
+name = "regex-lite"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
+
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -3386,9 +3419,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.151"
+version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -3572,8 +3605,9 @@ dependencies = [
[[package]]
name = "soundcraft"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
+ "chrono",
"eframe",
"egui",
"log",
@@ -3590,7 +3624,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"log",
"serde",
@@ -3600,7 +3634,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"log",
"proptest",
@@ -3612,7 +3646,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"log",
"serde_json",
@@ -3622,10 +3656,10 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"clap-sys",
- "libloading",
+ "libloading 0.9.0",
"log",
"serde",
"soundcraft-dsp",
@@ -3635,7 +3669,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3646,7 +3680,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"log",
"proptest",
@@ -3657,7 +3691,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"craft-docpath",
"log",
@@ -3680,7 +3714,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"proptest",
"serde",
@@ -3690,7 +3724,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"log",
"rayon",
@@ -3706,7 +3740,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"proptest",
"serde",
@@ -3719,7 +3753,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"cpal",
"log",
@@ -3734,7 +3768,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"proptest",
"serde",
@@ -3744,7 +3778,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"craft-ui",
"egui",
@@ -3770,8 +3804,9 @@ dependencies = [
[[package]]
name = "soundcraft-video"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
+ "craft-media",
"image",
"proptest",
"rayon",
@@ -3781,9 +3816,9 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
- "libloading",
+ "libloading 0.9.0",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -3797,7 +3832,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"eframe",
"egui",
@@ -3843,9 +3878,9 @@ checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "symphonia"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
+checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
@@ -3866,44 +3901,44 @@ dependencies = [
[[package]]
name = "symphonia-bundle-flac"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
+checksum = "6405d5c34ff6f8f7ca08a4101efe66d21e180f7322ef9359900a0e55698a7892"
dependencies = [
"log",
+ "symphonia-common",
"symphonia-core",
"symphonia-metadata",
- "symphonia-utils-xiph",
]
[[package]]
name = "symphonia-bundle-mp3"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
+checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
- "symphonia-metadata",
]
[[package]]
name = "symphonia-codec-aac"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
+checksum = "f5bf8e39552d34a3c4c98333370e62f48c92456d2e814f273b5c3ad7c4a5f45c"
dependencies = [
"lazy_static",
"log",
+ "symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-adpcm"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
+checksum = "445932ecb0c59362fde9c082dd63a75380650c5077fdb8b80b8cac850442a74c"
dependencies = [
"log",
"symphonia-core",
@@ -3911,19 +3946,20 @@ dependencies = [
[[package]]
name = "symphonia-codec-alac"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
+checksum = "920a78f96f3cf62932d0c497959c0cc2100ef36b03c7ca1417b260f432a482d8"
dependencies = [
"log",
+ "symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
+checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea"
dependencies = [
"log",
"symphonia-core",
@@ -3931,82 +3967,92 @@ dependencies = [
[[package]]
name = "symphonia-codec-vorbis"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
+checksum = "73d90b4fcf796137cc683c538282804ff9629f8ad9dbfd881fcbba331ac4e986"
+dependencies = [
+ "log",
+ "symphonia-common",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-common"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4"
dependencies = [
"log",
"symphonia-core",
- "symphonia-utils-xiph",
+ "symphonia-metadata",
]
[[package]]
name = "symphonia-core"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
+checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0"
dependencies = [
- "arrayvec",
- "bitflags 1.3.2",
+ "bitflags 2.13.2",
"bytemuck",
"lazy_static",
"log",
+ "num-complex",
+ "smallvec",
]
[[package]]
name = "symphonia-format-caf"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096"
+checksum = "ab64327ee1920531c5bf86cf7e9cd1a599d57013ddc30691da62683cefc65191"
dependencies = [
"log",
+ "symphonia-common",
"symphonia-core",
- "symphonia-metadata",
]
[[package]]
name = "symphonia-format-isomp4"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
+checksum = "0e681a70e1870d34e02abf1dbc51e4267c3f1827801474e8870be8c689fc4dc3"
dependencies = [
- "encoding_rs",
"log",
+ "symphonia-common",
"symphonia-core",
"symphonia-metadata",
- "symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-mkv"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
+checksum = "d015c5c0558864665894b3f4cbd95e10abb01b9c868e751c72670f326a56360e"
dependencies = [
"lazy_static",
"log",
+ "symphonia-common",
"symphonia-core",
- "symphonia-metadata",
- "symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
+checksum = "0b5495e7f7e3c7035328d82b6d6e377eef289bb0c4105bdeb557fc93a833f994"
dependencies = [
"log",
+ "symphonia-common",
"symphonia-core",
"symphonia-metadata",
- "symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
+checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe"
dependencies = [
"extended",
"log",
@@ -4016,26 +4062,17 @@ dependencies = [
[[package]]
name = "symphonia-metadata"
-version = "0.5.5"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
+checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6"
dependencies = [
- "encoding_rs",
"lazy_static",
"log",
+ "regex-lite",
+ "smallvec",
"symphonia-core",
]
-[[package]]
-name = "symphonia-utils-xiph"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
-dependencies = [
- "symphonia-core",
- "symphonia-metadata",
-]
-
[[package]]
name = "syn"
version = "2.0.119"
@@ -4629,7 +4666,7 @@ version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
dependencies = [
- "jni 0.22.4",
+ "jni",
"log",
"ndk-context",
"objc2 0.6.5",
@@ -4768,7 +4805,7 @@ dependencies = [
"js-sys",
"khronos-egl",
"libc",
- "libloading",
+ "libloading 0.8.9",
"log",
"naga",
"naga-types",
@@ -4797,9 +4834,9 @@ dependencies = [
"web-sys",
"wgpu-naga-bridge",
"wgpu-types",
- "windows 0.62.2",
- "windows-core 0.62.2",
- "windows-result 0.4.1",
+ "windows",
+ "windows-core",
+ "windows-result",
]
[[package]]
@@ -4837,16 +4874,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "windows"
-version = "0.54.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
-dependencies = [
- "windows-core 0.54.0",
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "windows"
version = "0.62.2"
@@ -4854,7 +4881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
- "windows-core 0.62.2",
+ "windows-core",
"windows-future",
"windows-numerics",
]
@@ -4865,17 +4892,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
- "windows-core 0.62.2",
-]
-
-[[package]]
-name = "windows-core"
-version = "0.54.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
-dependencies = [
- "windows-result 0.1.2",
- "windows-targets 0.52.6",
+ "windows-core",
]
[[package]]
@@ -4887,7 +4904,7 @@ dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
- "windows-result 0.4.1",
+ "windows-result",
"windows-strings",
]
@@ -4897,7 +4914,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
- "windows-core 0.62.2",
+ "windows-core",
"windows-link",
"windows-threading",
]
@@ -4936,19 +4953,10 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
- "windows-core 0.62.2",
+ "windows-core",
"windows-link",
]
-[[package]]
-name = "windows-result"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
-dependencies = [
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -4967,15 +4975,6 @@ dependencies = [
"windows-link",
]
-[[package]]
-name = "windows-sys"
-version = "0.45.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
-dependencies = [
- "windows-targets 0.42.2",
-]
-
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -5012,21 +5011,6 @@ dependencies = [
"windows-link",
]
-[[package]]
-name = "windows-targets"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
-dependencies = [
- "windows_aarch64_gnullvm 0.42.2",
- "windows_aarch64_msvc 0.42.2",
- "windows_i686_gnu 0.42.2",
- "windows_i686_msvc 0.42.2",
- "windows_x86_64_gnu 0.42.2",
- "windows_x86_64_gnullvm 0.42.2",
- "windows_x86_64_msvc 0.42.2",
-]
-
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -5069,12 +5053,6 @@ dependencies = [
"windows-link",
]
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
-
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -5087,12 +5065,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -5105,12 +5077,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -5135,12 +5101,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -5153,12 +5113,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -5171,12 +5125,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -5189,12 +5137,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -5269,9 +5211,9 @@ dependencies = [
[[package]]
name = "winresource"
-version = "0.1.31"
+version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
+checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
@@ -5309,7 +5251,7 @@ dependencies = [
"as-raw-xcb-connection",
"gethostname",
"libc",
- "libloading",
+ "libloading 0.8.9",
"once_cell",
"rustix 1.1.5",
"x11rb-protocol",
@@ -5354,7 +5296,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.10.0"
+version = "0.12.1"
dependencies = [
"serde_json",
]
@@ -5564,9 +5506,9 @@ dependencies = [
[[package]]
name = "zip"
-version = "9.0.1"
+version = "9.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
+checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
dependencies = [
"crc32fast",
"indexmap",
diff --git a/Cargo.toml b/Cargo.toml
index 1a82f986..f5637624 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.10.0"
+version = "0.12.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -32,21 +32,27 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
proptest = "1"
# audio
-symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
+symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
rustfft = "6"
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
zip = { version = "9", default-features = false }
-cpal = "0.16"
+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.
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
@@ -64,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/README.md b/README.md
index c683c34b..c31fc87a 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,10 @@
+
+
+
+
SoundCraft
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/Cargo.toml b/apps/soundcraft/Cargo.toml
index 0f30c7a9..5dd042b9 100644
--- a/apps/soundcraft/Cargo.toml
+++ b/apps/soundcraft/Cargo.toml
@@ -22,6 +22,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
egui.workspace = true
serde_json.workspace = true
log.workspace = true
+chrono.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true
@@ -31,6 +32,6 @@ rfd.workspace = true
winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
-muda = { version = "0.19.2", default-features = false }
+muda = { version = "0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
diff --git a/apps/soundcraft/src/logging.rs b/apps/soundcraft/src/logging.rs
index 53be177b..8209f3c2 100644
--- a/apps/soundcraft/src/logging.rs
+++ b/apps/soundcraft/src/logging.rs
@@ -112,28 +112,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
+/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
- let secs = since.as_secs();
- let (days, rem) = (secs / 86_400, secs % 86_400);
- let (y, m, d) = civil_from_days(days);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
+ // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
+ // past `i64::MAX` seconds saturates rather than falling back to the epoch.
+ let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
+ let (y, t) = utc_parts(secs, since.subsec_nanos());
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
-/// clock can't overflow).
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days.saturating_add(719_468);
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
- (y, m, d)
+/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
+/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
+/// so month, day and time of day come from chrono and only the year is carried, which keeps every
+/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
+fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
+ use chrono::Datelike;
+ let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -533,6 +541,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ // Past chrono's year range still converts via the 400-year cycle (the old code did too,
+ // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
+ // SystemTime can't hold this instant, so it is only checked where it exists.
+ // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
+ assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
+ }
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
+ // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
+ // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
+ assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
+ }
}
#[test]
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 5bfe91da..d6563e1a 100644
--- a/crates/audio-io/src/symph.rs
+++ b/crates/audio-io/src/symph.rs
@@ -3,18 +3,18 @@
use std::io::Cursor;
use std::panic::{AssertUnwindSafe, catch_unwind};
-use symphonia::core::audio::SampleBuffer;
-use symphonia::core::codecs::{
- CODEC_TYPE_AAC, CODEC_TYPE_ADPCM_G722, CODEC_TYPE_ADPCM_G726, CODEC_TYPE_ADPCM_G726LE, CODEC_TYPE_ADPCM_IMA_QT, CODEC_TYPE_ADPCM_IMA_WAV,
- CODEC_TYPE_ADPCM_MS, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP1, CODEC_TYPE_MP2, CODEC_TYPE_MP3, CODEC_TYPE_NULL, CODEC_TYPE_OPUS,
- CODEC_TYPE_PCM_ALAW, CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE, CODEC_TYPE_PCM_MULAW,
- CODEC_TYPE_VORBIS, CodecParameters, CodecType, DecoderOptions,
+use symphonia::core::codecs::CodecParameters;
+use symphonia::core::codecs::audio::well_known::{
+ CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS,
+ CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE,
+ CODEC_ID_PCM_F64BE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_MULAW, CODEC_ID_VORBIS,
};
+use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO};
use symphonia::core::errors::Error as SymError;
+use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes};
use symphonia::core::meta::MetadataOptions;
-use symphonia::core::probe::Instantiate;
use crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat};
@@ -23,6 +23,8 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
/// takes its frame length from the file (encoders use 4096).
const MAX_PACKET_FRAMES: u64 = 1 << 20;
+/// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large"
+/// distinct from a malformed file.
fn map_err(e: SymError) -> AudioError {
match e {
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
@@ -42,80 +44,164 @@ enum Opened {
Empty(AudioInfo),
}
-/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it
-/// finds before its reader allocates whatever the file claims.
-fn open(bytes: &[u8]) -> Result {
- let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
- let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
+/// Skip a leading ID3v2 tag before probing.
+///
+/// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims, so the tag is skipped
+/// with [`ReadBytes::ignore_bytes`] (which allocates nothing) instead of being handed to the
+/// metadata reader. Returns the stream position the container starts at.
+fn skip_leading_id3(mss: &mut MediaSourceStream<'_>, bytes: &[u8]) -> Result {
loop {
- let found = symphonia::default::get_probe().next(&mut mss).map_err(map_err)?;
let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX);
let head = bytes.get(at..).unwrap_or_default();
- match (found, head.get(..10)) {
- (Instantiate::Format(reader), _) => {
- match head.get(..4) {
- Some(b"caff") => crate::caf::check(bytes, at)?,
- Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?,
- Some(b"fLaC") => {
- crate::flac::check_metadata(bytes, at)?;
- if let Some(info) = crate::flac::empty_stream_info(bytes, at) {
- return Ok(Opened::Empty(info));
- }
- }
- Some(b"RIFF") => crate::wav::check_info(bytes, at)?,
- _ => {}
- }
- return reader(mss, &fmt_opts).map(Opened::Reader).map_err(map_err);
- }
- // Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it.
- (Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => {
- let size = size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f));
- mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
- }
- (Instantiate::Metadata(tag), _) => {
- tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?;
- }
+ if head.len() < 10 || head.get(..3) != Some(b"ID3") {
+ return Ok(at);
}
+ let size = head.get(6..10).map(|size| size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f))).unwrap_or(0);
+ mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
}
}
-fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat {
+/// How far symphonia's probe scans for a format marker before giving up.
+const MAX_PROBE_DEPTH: usize = 1024 * 1024;
+
+/// The container marker each detected format starts with, if its reader needs size checks first.
+fn container_markers(detected: FileFormat) -> &'static [&'static [u8; 4]] {
+ match detected {
+ // RF64 and BW64 are the >4 GiB RIFF forms.
+ FileFormat::Wav => &[b"RIFF", b"RF64", b"BW64"],
+ FileFormat::Flac => &[b"fLaC"],
+ FileFormat::Caf => &[b"caff"],
+ // MP4/M4A put `ftyp` after the first atom's size field.
+ FileFormat::Aac | FileFormat::Alac => &[b"ftyp"],
+ // Unknown files may carry junk before the container; symphonia's probe scans for markers.
+ FileFormat::Other => &[b"caff", b"ftyp", b"fLaC", b"RIFF"],
+ FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => &[],
+ }
+}
+
+/// The probe short names a container marker stands for.
+fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] {
+ match marker {
+ b"caff" => &["caf"],
+ // The isomp4 reader reads the `ftyp` atom.
+ b"ftyp" => &["isomp4"],
+ b"fLaC" => &["flac"],
+ b"RIFF" | b"RF64" | b"BW64" => &["wave"],
+ _ => &[],
+ }
+}
+
+/// Probe short names whose demuxer allocates from sizes in the file, so the matching container
+/// 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. 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));
+ }
+ }
+ None
+}
+
+/// Probe for a container, checking the container it finds before its reader allocates whatever the
+/// file claims.
+///
+/// The check has to run on the container the probe actually selects, but the probe reports that
+/// container only through the reader it returns. The check therefore runs on the first marker the
+/// detected format allows, and the reader is refused if its format is not the checked one: a
+/// misleading marker must not let a later container slip through unchecked.
+fn open(bytes: &[u8], detected: FileFormat) -> Result {
+ let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
+ let at = skip_leading_id3(&mut mss, bytes)?;
+ let mut checked: Option<&'static [&'static str]> = None;
+ if let Some((found, marker)) = find_marker(bytes, at, container_markers(detected)) {
+ match marker {
+ b"caff" => {
+ crate::caf::check(bytes, found)?;
+ checked = Some(expected_probe_format(marker));
+ }
+ b"ftyp" => {
+ crate::mp4::check(bytes, found.saturating_sub(4))?;
+ checked = Some(expected_probe_format(marker));
+ }
+ b"fLaC" => {
+ crate::flac::check_metadata(bytes, found)?;
+ if let Some(info) = crate::flac::empty_stream_info(bytes, found) {
+ return Ok(Opened::Empty(info));
+ }
+ checked = Some(expected_probe_format(marker));
+ }
+ b"RIFF" | b"RF64" | b"BW64" => {
+ crate::wav::check_info(bytes, found)?;
+ checked = Some(expected_probe_format(marker));
+ }
+ _ => {}
+ }
+ }
+ let reader = symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
+ let probed = reader.format_info().short_name;
+ // Only containers whose readers allocate from sizes in the file need the check. A stream the
+ // probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a marker
+ // found in its audio data must not reject it.
+ if let Some(names) = checked.filter(|_| SIZE_CHECKED_PROBE_FORMATS.contains(&probed)) {
+ if !names.contains(&probed) {
+ return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran")));
+ }
+ } else if checked.is_none() && SIZE_CHECKED_PROBE_FORMATS.contains(&probed) {
+ return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
+ }
+ Ok(Opened::Reader(reader))
+}
+
+/// The container `detected` stands for: the detection wins when it knows the file, otherwise the
+/// decoded codec names the family.
+fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat {
if detected != FileFormat::Other {
return detected;
}
match codec {
- c if c == CODEC_TYPE_FLAC => FileFormat::Flac,
- c if c == CODEC_TYPE_MP3 || c == CODEC_TYPE_MP2 || c == CODEC_TYPE_MP1 => FileFormat::Mp3,
- c if c == CODEC_TYPE_AAC => FileFormat::Aac,
- c if c == CODEC_TYPE_ALAC => FileFormat::Alac,
- c if c == CODEC_TYPE_VORBIS => FileFormat::Ogg,
+ c if c == CODEC_ID_FLAC => FileFormat::Flac,
+ c if c == CODEC_ID_MP3 || c == CODEC_ID_MP2 || c == CODEC_ID_MP1 => FileFormat::Mp3,
+ c if c == CODEC_ID_AAC => FileFormat::Aac,
+ c if c == CODEC_ID_ALAC => FileFormat::Alac,
+ c if c == CODEC_ID_VORBIS => FileFormat::Ogg,
_ => FileFormat::Other,
}
}
-fn sample_format_of(p: &CodecParameters) -> SampleFormat {
+/// The stored sample format, using the codec and its declared bit depth. Lossy and unusual
+/// codecs decode to `Compressed`.
+fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat {
let c = p.codec;
- if c == CODEC_TYPE_PCM_F32LE || c == CODEC_TYPE_PCM_F32BE {
+ if c == CODEC_ID_PCM_F32LE || c == CODEC_ID_PCM_F32BE {
return SampleFormat::Float32;
}
- if c == CODEC_TYPE_PCM_F64LE || c == CODEC_TYPE_PCM_F64BE {
+ if c == CODEC_ID_PCM_F64LE || c == CODEC_ID_PCM_F64BE {
return SampleFormat::Float64;
}
let lossy = [
- CODEC_TYPE_MP1,
- CODEC_TYPE_MP2,
- CODEC_TYPE_MP3,
- CODEC_TYPE_AAC,
- CODEC_TYPE_VORBIS,
- CODEC_TYPE_OPUS,
- CODEC_TYPE_ADPCM_MS,
- CODEC_TYPE_ADPCM_IMA_WAV,
- CODEC_TYPE_ADPCM_IMA_QT,
- CODEC_TYPE_ADPCM_G722,
- CODEC_TYPE_ADPCM_G726,
- CODEC_TYPE_ADPCM_G726LE,
- CODEC_TYPE_PCM_ALAW,
- CODEC_TYPE_PCM_MULAW,
+ CODEC_ID_MP1,
+ CODEC_ID_MP2,
+ CODEC_ID_MP3,
+ CODEC_ID_AAC,
+ CODEC_ID_VORBIS,
+ CODEC_ID_OPUS,
+ CODEC_ID_ADPCM_MS,
+ CODEC_ID_ADPCM_IMA_WAV,
+ CODEC_ID_ADPCM_IMA_QT,
+ CODEC_ID_ADPCM_G722,
+ CODEC_ID_ADPCM_G726,
+ CODEC_ID_ADPCM_G726LE,
+ CODEC_ID_PCM_ALAW,
+ CODEC_ID_PCM_MULAW,
];
let lossless = !lossy.contains(&c);
match (lossless, p.bits_per_sample) {
@@ -127,15 +213,18 @@ fn sample_format_of(p: &CodecParameters) -> SampleFormat {
}
}
+/// The first track with a decodable audio codec, with the stream information the UI shows.
fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> {
- let track = reader
+ let (track, p) = reader
.tracks()
.iter()
- .find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
+ .find_map(|t| match t.codec_params.as_ref().and_then(CodecParameters::audio) {
+ Some(p) if p.codec != CODEC_ID_NULL_AUDIO => Some((t, p)),
+ _ => None,
+ })
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
- let p = &track.codec_params;
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
- let channels = p.channels.map(|c| c.count()).unwrap_or(0);
+ let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0);
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
Ok((
track.id,
@@ -144,7 +233,7 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
sample_format: sample_format_of(p),
sample_rate,
channels,
- frames: p.n_frames.unwrap_or(0),
+ frames: track.num_frames.unwrap_or(0),
bwf: None,
},
))
@@ -152,7 +241,7 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
- let mut reader = match open(bytes)? {
+ let mut reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => {
let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0);
@@ -160,7 +249,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
}
};
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
- if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) {
+ if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.num_frames) {
let total = n.saturating_mul(u64::from(info.channels.max(1)));
if !count_only && total > MAX_DECODE_SAMPLES {
return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
@@ -170,21 +259,24 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
.tracks()
.iter()
.find(|t| t.id == track_id)
- .map(|t| t.codec_params.clone())
+ .and_then(|t| t.codec_params.as_ref())
+ .and_then(CodecParameters::audio)
+ .cloned()
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
- let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_TYPE_ALAC).and_then(|d| be_u32(d, 0));
+ let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
}
- let mut decoder = symphonia::default::get_codecs().make(¶ms, &DecoderOptions::default()).map_err(map_err)?;
+ let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &AudioDecoderOptions::default()).map_err(map_err)?;
let mut planar: Vec> = Vec::new();
+ let mut scratch: Vec> = Vec::new();
let mut frames: u64 = 0;
- let mut sbuf: Option> = None;
let mut errors = 0usize;
loop {
let packet = match reader.next_packet() {
- Ok(p) => p,
+ Ok(Some(packet)) => packet,
+ Ok(None) => break,
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break,
Err(e) => {
@@ -195,7 +287,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
break;
}
};
- if packet.track_id() != track_id {
+ if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -211,8 +303,8 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
Err(SymError::IoError(_)) => break,
Err(e) => return Err(map_err(e)),
};
- let spec = *decoded.spec();
- let ch = spec.channels.count();
+ let spec = decoded.spec();
+ let ch = spec.channels().count();
if ch == 0 {
continue;
}
@@ -229,16 +321,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
if count_only {
continue;
}
- let needed = decoded.capacity() as u64;
- let sb = match &mut sbuf {
- Some(sb) if sb.capacity() >= decoded.capacity() * ch => sb,
- _ => sbuf.insert(SampleBuffer::::new(needed, spec)),
- };
- sb.copy_interleaved_ref(decoded);
- for frame in sb.samples().chunks_exact(ch) {
- for (dst, &s) in planar.iter_mut().zip(frame) {
- dst.push(if s.is_finite() { s } else { 0.0 });
- }
+ decoded.copy_to_vecs_planar::(&mut scratch);
+ for (dst, src) in planar.iter_mut().zip(&scratch) {
+ dst.extend(src.iter().map(|&s| if s.is_finite() { s } else { 0.0 }));
}
}
if info.channels == 0 {
@@ -254,7 +339,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result {
guarded(|| {
- let reader = match open(bytes)? {
+ let reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => return Ok(info),
};
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 93%
rename from crates/audio-io/tests/malformed.rs
rename to crates/audio-io/tests/it/malformed.rs
index ed0d6e31..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();
@@ -451,6 +463,20 @@ fn garbage_in_compressed_containers_errors() {
assert!(decode(&[], None).is_err());
}
+/// A marker that passes its container check must not let the probe select a different container
+/// 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() {
+ // 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"),
+ }
+}
+
proptest! {
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
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/Cargo.toml b/crates/clap-host/Cargo.toml
index e08bbdfa..96c7de7c 100644
--- a/crates/clap-host/Cargo.toml
+++ b/crates/clap-host/Cargo.toml
@@ -23,7 +23,7 @@ harness = false
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
clap-sys = "0.5"
-libloading = "0.8"
+libloading = "0.9"
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
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/src/offline.rs b/crates/dsp/src/offline.rs
index 8bbc80a6..337f3589 100644
--- a/crates/dsp/src/offline.rs
+++ b/crates/dsp/src/offline.rs
@@ -65,19 +65,29 @@ pub fn normalize(ch: &mut [Vec], target_db: f32, per_channel: bool) {
for c in ch.iter_mut() {
let p = peak(std::slice::from_ref(c));
if p > 0.0 {
- let g = target / p;
- c.iter_mut().for_each(|s| *s *= g);
+ scale_to(c.iter_mut(), target, p);
}
}
} else {
let p = peak(ch);
if p > 0.0 {
- let g = target / p;
- ch.iter_mut().flatten().for_each(|s| *s *= g);
+ scale_to(ch.iter_mut().flatten(), target, p);
}
}
}
+/// Scales samples whose peak is `p` to `target`. A subnormal `p` makes the f32 gain overflow to
+/// infinity, so that case scales in f64.
+fn scale_to<'a>(samples: impl Iterator- , target: f32, p: f32) {
+ let g = target / p;
+ if g.is_finite() {
+ samples.for_each(|s| *s *= g);
+ } else {
+ let g = f64::from(target) / f64::from(p);
+ samples.for_each(|s| *s = (f64::from(*s) * g) as f32);
+ }
+}
+
/// Applies a gain change in dB.
pub fn gain(ch: &mut [Vec], db: f32) {
let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) });
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/it/offline_normalize.rs b/crates/dsp/tests/it/offline_normalize.rs
new file mode 100644
index 00000000..6ed47973
--- /dev/null
+++ b/crates/dsp/tests/it/offline_normalize.rs
@@ -0,0 +1,19 @@
+use soundcraft_dsp::offline::{normalize, peak};
+
+#[test]
+fn normalize_subnormal_peak_stays_finite() {
+ for per_channel in [false, true] {
+ let mut c = vec![vec![1e-44f32, -1e-44]];
+ normalize(&mut c, 0.0, per_channel);
+ assert!(c[0].iter().all(|s| s.is_finite()), "{c:?}");
+ assert!((peak(&c) - 1.0).abs() < 1e-3, "{c:?}");
+ }
+}
+
+#[test]
+fn normalize_silent_and_normal_channels() {
+ let mut c = vec![vec![0.0f32; 4], vec![0.25, -0.5]];
+ normalize(&mut c, 0.0, true);
+ assert_eq!(c[0], vec![0.0; 4]);
+ assert!((peak(&c[1..]) - 1.0).abs() < 1e-5);
+}
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 3e5d75cc..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 {
@@ -444,7 +446,7 @@ fn send(e: &mut Engine, p: &Value) -> Result {
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
let explicit = optional_slot(p, "mix.send", SEND_SLOTS)?;
let s = e.session_mut();
- let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
+ let bid = s.bus_by_name(&bus).map(|b| b.id).ok_or_else(|| bad("mix.send", format!("no bus named \"{bus}\"")))?;
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
@@ -930,8 +932,10 @@ mod tests {
fn retargeting_a_send_keeps_its_settings() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
+ e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
+ e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
@@ -985,10 +989,24 @@ mod tests {
assert_eq!(count(&e), 0, "absent param must still clear every lane");
}
+ #[test]
+ fn send_rejects_unknown_bus() {
+ let mut e = Engine::default();
+ e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
+ e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
+ let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
+ assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
+ assert_eq!(e.session().busses.len(), 1);
+ assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
+ e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
+ assert_eq!(e.session().busses.len(), 1);
+ }
+
#[test]
fn insert_and_send_reject_invalid_slot() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
+ e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
for slot in [json!(12), json!("k"), json!("x1")] {
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
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 5a6630d3..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 {
@@ -184,6 +184,7 @@ mod tests {
assert!(e.session().track(t).unwrap().mixer.surround.is_none());
// Sends.
assert!(e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0})).is_err(), "empty slot");
+ e.execute("mix.new_bus", &json!({"name": "FX"})).unwrap();
e.execute("mix.send", &json!({"track": t.0, "slot": 0, "bus": "FX"})).unwrap();
e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0, "y": -1.0})).unwrap();
let snd = e.session().track(t).unwrap().mixer.sends[0].clone().unwrap();
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/edit.rs b/crates/engine/src/edit.rs
index 5531953d..e7a8e977 100644
--- a/crates/engine/src/edit.rs
+++ b/crates/engine/src/edit.rs
@@ -835,6 +835,8 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
}
}
}
+ // Trimming can move a start past a later clip's start when one clip overlapped several.
+ pl.sort();
n
}
@@ -1041,4 +1043,18 @@ mod tests {
s.find_clip_mut(id).unwrap().time_locked = true;
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0);
}
+
+ #[test]
+ fn delete_fades_keeps_playlist_sorted_when_a_clip_overlaps_several() {
+ let (mut s, t) = session_with_clip();
+ let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).expect("track has a playlist");
+ pl.clips.clear();
+ pl.clips.push(Clip::audio(ClipId(901), "a", SourceId(500), 0, 0, 1000));
+ pl.clips.push(Clip::audio(ClipId(902), "b", SourceId(500), 100, 100, 100));
+ pl.clips.push(Clip::audio(ClipId(903), "c", SourceId(500), 150, 150, 750));
+ delete_fades(&mut s, t, Range::new(0, 10_000));
+ let got = clips(&s, t);
+ assert!(got.windows(2).all(|w| w[0].0 <= w[1].0), "playlist not sorted: {got:?}");
+ assert!(got.iter().all(|c| c.1 > 0), "empty clip: {got:?}");
+ }
}
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
index eff105ec..5d85a162 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 68%
rename from crates/engine/tests/random_edits.proptest-regressions
rename to crates/engine/tests/it/random_edits.proptest-regressions
index 3d338463..dc40fac6 100644
--- a/crates/engine/tests/random_edits.proptest-regressions
+++ b/crates/engine/tests/it/random_edits.proptest-regressions
@@ -5,3 +5,4 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 1dc212f0026723d05bfcfbb29fcff476eda7f491e9bea9cd5d26dccf006a81f6 # shrinks to ops = [(9, 0, 2420494, 1), (3, 0, 2396495, 1)]
+cc 87a0afb56f43d6ad377071e28bd6ba94c3d67485fc69dbefa9a2c7de3f698ed6 # shrinks to ops = [(10, 2215386, 0, 0), (0, 844227, 213110, 1), (3, 0, 443078, 0), (0, 443079, 443079, 0), (11, 0, 0, 0)]
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/src/smf.rs b/crates/midi/src/smf.rs
index 7747a061..3b4508bc 100644
--- a/crates/midi/src/smf.rs
+++ b/crates/midi/src/smf.rs
@@ -122,7 +122,7 @@ impl TickConv {
let [hi, lo] = division.to_be_bytes();
let fps = -i128::from(hi as i8);
let tpf = i128::from(lo);
- if fps <= 0 || tpf == 0 {
+ if !matches!(fps, 24 | 25 | 29 | 30) || tpf == 0 {
return Err(MidiError::Invalid { offset, reason: "invalid SMPTE division" });
}
let two_ppq = 2 * i128::from(PPQ);
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 92%
rename from crates/midi/tests/smf.rs
rename to crates/midi/tests/it/smf.rs
index f069be39..aa79ef2b 100644
--- a/crates/midi/tests/smf.rs
+++ b/crates/midi/tests/it/smf.rs
@@ -148,6 +148,29 @@ fn division_conversion_96_and_smpte() {
assert_eq!(smf.tracks[0].sequence.notes[0].length, 480);
}
+#[test]
+fn smpte_division_rejects_undefined_frame_rates() {
+ let body = vec![0, 0x90, 60, 100, 40, 0x80, 60, 64, 0, 0xFF, 0x2F, 0];
+ for high in 0x80..=0xFF {
+ if [(-24i8) as u8, (-25i8) as u8, (-29i8) as u8, (-30i8) as u8].contains(&high) {
+ continue;
+ }
+ let division = u16::from_be_bytes([high, 40]);
+ assert!(matches!(read_smf(&file(0, division, std::slice::from_ref(&body))), Err(MidiError::Invalid { offset: 12, .. })), "{division:#06x}");
+ }
+}
+
+#[test]
+fn smpte_division_keeps_all_four_standard_frame_rates() {
+ for (fps, file_ticks, model_ticks) in [(24i8, 960, 1920), (25, 1000, 1920), (29, 1200, 1922), (30, 1200, 1920)] {
+ let body = [vec![0, 0x90, 60, 100], vlq(file_ticks), vec![0x80, 60, 64, 0, 0xFF, 0x2F, 0]].concat();
+ let division = u16::from_be_bytes([(-fps) as u8, 40]);
+ let smf = read_smf(&file(0, division, std::slice::from_ref(&body))).unwrap();
+ assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, model_ticks)], "{fps} fps");
+ assert!(read_smf(&file(0, u16::from_be_bytes([(-fps) as u8, 0]), std::slice::from_ref(&body))).is_err());
+ }
+}
+
#[test]
fn running_status_and_velocity_zero_note_off() {
// Note-on with running status for both on and off (velocity 0).
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/automation.rs b/crates/model/src/automation.rs
index a6d3963b..26bbcae3 100644
--- a/crates/model/src/automation.rs
+++ b/crates/model/src/automation.rs
@@ -70,7 +70,7 @@ impl AutoParam {
match self {
AutoParam::Volume => "volume".into(),
AutoParam::Pan(0) => "pan".into(),
- AutoParam::Pan(i) => format!("pan {}", i + 1),
+ AutoParam::Pan(i) => format!("pan {}", u16::from(*i) + 1),
AutoParam::Mute => "mute".into(),
AutoParam::SendLevel(i) => format!("send {} level", slot_letter(*i)),
AutoParam::SendPan(i) => format!("send {} pan", slot_letter(*i)),
@@ -199,7 +199,11 @@ impl AutomationLane {
}
self.set_point(start, value);
self.set_point(end, value);
- self.set_point(end.saturating_add(1), after);
+ let anchor = end.saturating_add(1);
+ // A real breakpoint already at `end + 1` keeps its own value.
+ if self.points.binary_search_by(|p| p.at.cmp(&anchor)).is_err() {
+ self.set_point(anchor, after);
+ }
}
/// Thin: remove points whose removal changes the curve by less than `tolerance`.
@@ -251,6 +255,11 @@ impl AutomationLane {
mod tests {
use super::*;
+ #[test]
+ fn pan_label_max_does_not_overflow() {
+ assert_eq!(AutoParam::Pan(255).label(), "pan 256");
+ }
+
#[test]
fn interpolation_and_steps() {
let mut l = AutomationLane::new(AutoParam::Volume);
@@ -266,6 +275,15 @@ mod tests {
assert_eq!(m.value_at(100, 0.0), 1.0);
}
+ #[test]
+ fn write_range_keeps_point_right_after_end() {
+ let mut l = AutomationLane::new(AutoParam::Volume);
+ l.set_point(0, 0.0);
+ l.set_point(11, -100.0);
+ l.write_range(2, 10, -20.0, 0.0);
+ assert_eq!(l.value_at(11, 0.0), -100.0);
+ }
+
#[test]
fn write_range_preserves_outside() {
let mut l = AutomationLane::new(AutoParam::Volume);
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/playback/src/devices.rs b/crates/playback/src/devices.rs
index 19df07b6..a2847097 100644
--- a/crates/playback/src/devices.rs
+++ b/crates/playback/src/devices.rs
@@ -64,9 +64,9 @@ pub struct AudioSystem {
pub inputs: Vec,
}
-#[allow(deprecated)]
-fn device_name(d: &cpal::Device) -> Option {
- d.name().ok()
+/// The name the host gives `d`, if it can produce a description for it.
+pub(crate) fn device_name(d: &cpal::Device) -> Option {
+ d.description().ok().map(|description| description.name().to_owned())
}
fn describe(d: &cpal::Device, default_name: Option<&str>, output: bool) -> Option {
@@ -78,7 +78,7 @@ fn describe(d: &cpal::Device, default_name: Option<&str>, output: bool) -> Optio
};
let max_channels = ranges.iter().map(cpal::SupportedStreamConfigRange::channels).max().unwrap_or(0);
let sample_rates =
- STANDARD_RATES.iter().copied().filter(|r| ranges.iter().any(|c| c.min_sample_rate().0 <= *r && c.max_sample_rate().0 >= *r)).collect();
+ STANDARD_RATES.iter().copied().filter(|r| ranges.iter().any(|c| c.min_sample_rate() <= *r && c.max_sample_rate() >= *r)).collect();
let (mut buffer_min, mut buffer_max) = (None::, None::);
for c in &ranges {
if let cpal::SupportedBufferSize::Range { min, max } = c.buffer_size() {
diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs
index 3821e266..41bb89ed 100644
--- a/crates/playback/src/lib.rs
+++ b/crates/playback/src/lib.rs
@@ -522,14 +522,14 @@ impl Player {
match open_device(want, want_ch, cfg) {
Ok((device, config, format, name)) => {
// The chosen buffer size first; if the device refuses it, its default.
- let mut attempts = vec![config.clone()];
+ let mut attempts = vec![config];
if config.buffer_size != cpal::BufferSize::Default {
attempts.push(cpal::StreamConfig { buffer_size: cpal::BufferSize::Default, ..config });
}
let opened = first_started(
attempts,
- |config: &cpal::StreamConfig| -> Result {
- let rate = config.sample_rate.0;
+ |config: &cpal::StreamConfig| -> Result {
+ let rate = config.sample_rate;
let channels = usize::from(config.channels);
let (tx, rx) = channel();
let (gtx, grx) = channel();
@@ -546,7 +546,7 @@ impl Player {
);
match opened {
Some((config, Opened { stream, tx, grx, fault })) => {
- let rate = config.sample_rate.0;
+ let rate = config.sample_rate;
let channels = usize::from(config.channels);
let buffer = match config.buffer_size {
cpal::BufferSize::Fixed(n) => Some(n),
@@ -926,7 +926,7 @@ fn build_output(
channels: usize,
fault: &Arc,
shared: &Arc,
-) -> Result
+) -> Result
where
T: cpal::SizedSample + cpal::FromSample,
{
@@ -936,7 +936,7 @@ where
let chunk = scratch.len();
let (failed, guard, gone) = (Arc::clone(fault), Arc::clone(shared), Arc::clone(fault));
device.build_output_stream(
- config,
+ *config,
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
mark_audio_thread();
for out in data.chunks_mut(chunk) {
@@ -949,9 +949,9 @@ where
},
// cpal may call this on the audio thread (on ALSA in a loop while the device stays
// broken): marked, so the logger hands the record off instead of writing it.
- move |e: cpal::StreamError| {
+ move |e: cpal::Error| {
mark_audio_thread();
- if matches!(e, cpal::StreamError::DeviceNotAvailable) {
+ if e.kind() == cpal::ErrorKind::DeviceNotAvailable {
gone.gone.store(true, Ordering::Relaxed);
}
log::warn!("audio stream error: {e}");
@@ -968,8 +968,7 @@ fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal:
use cpal::traits::DeviceTrait;
let host = devices::host(cfg);
let device = devices::device(&host, cfg.output.as_deref(), true).ok_or_else(|| "no output device".to_string())?;
- #[allow(deprecated)]
- let name = device.name().unwrap_or_else(|_| "Audio device".into());
+ let name = devices::device_name(&device).unwrap_or_else(|| "Audio device".into());
// Prefer a config at the chosen (else session) rate; else the device default (we resample).
let want_ch = want_ch.max(2);
let rates: Vec = cfg.sample_rate.into_iter().chain(Some(want_rate)).collect();
@@ -984,13 +983,13 @@ fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal:
for rate in rates {
for c in &configs {
if devices::format_rank(c.sample_format()).is_some()
- && c.min_sample_rate().0 <= rate
- && c.max_sample_rate().0 >= rate
+ && c.min_sample_rate() <= rate
+ && c.max_sample_rate() >= rate
&& c.channels() >= 2
&& c.channels() <= want_ch
&& chosen.as_ref().is_none_or(|x| better(c, x))
{
- chosen = Some((c.with_sample_rate(cpal::SampleRate(rate)).config(), *c.buffer_size(), c.sample_format()));
+ chosen = Some((c.with_sample_rate(rate).config(), *c.buffer_size(), c.sample_format()));
}
}
if chosen.is_some() {
@@ -1029,12 +1028,22 @@ mod tests {
}
impl cpal::traits::StreamTrait for Fake {
- fn play(&self) -> Result<(), cpal::PlayStreamError> {
- if self.starts { Ok(()) } else { Err(cpal::PlayStreamError::DeviceNotAvailable) }
+ /// Play reports the device's refusal when `starts` is false.
+ fn play(&self) -> Result<(), cpal::Error> {
+ if self.starts { Ok(()) } else { Err(cpal::Error::new(cpal::ErrorKind::DeviceNotAvailable)) }
}
- fn pause(&self) -> Result<(), cpal::PauseStreamError> {
+ /// Pause always succeeds on the fake stream.
+ fn pause(&self) -> Result<(), cpal::Error> {
Ok(())
}
+ /// The fake stream claims one fixed buffer.
+ fn buffer_size(&self) -> Result {
+ Ok(1024)
+ }
+ /// The fake stream has no clock.
+ fn now(&self) -> cpal::StreamInstant {
+ cpal::StreamInstant::new(0, 0)
+ }
}
#[test]
diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs
index 8072adac..a49b643c 100644
--- a/crates/playback/src/record.rs
+++ b/crates/playback/src/record.rs
@@ -145,7 +145,7 @@ fn build_input(
config: &cpal::StreamConfig,
shared: &Arc,
monitor: &Arc,
-) -> Result
+) -> Result
where
T: cpal::SizedSample,
f32: cpal::FromSample,
@@ -158,7 +158,7 @@ where
let chunk = scratch.len();
let mut owed = 0;
device.build_input_stream(
- config,
+ *config,
move |data: &[T], _: &cpal::InputCallbackInfo| {
crate::mark_audio_thread();
sh.guarded(|| {
@@ -176,10 +176,10 @@ where
}
});
},
- move |e| {
+ move |e: cpal::Error| {
// Possibly the audio thread (see `crate::mark_audio_thread`).
crate::mark_audio_thread();
- if matches!(e, cpal::StreamError::DeviceNotAvailable) {
+ if e.kind() == cpal::ErrorKind::DeviceNotAvailable {
let _ = errors.fault.compare_exchange(0, GONE, Ordering::Relaxed, Ordering::Relaxed);
}
log::warn!("input stream error: {e}");
@@ -202,17 +202,16 @@ impl Recorder {
use cpal::traits::{DeviceTrait, StreamTrait};
let host = crate::devices::host(audio);
let device = crate::devices::device(&host, audio.input.as_deref(), false).ok_or("no audio input device")?;
- #[allow(deprecated)]
- let name = device.name().unwrap_or_else(|_| "Input".into());
+ let name = crate::devices::device_name(&device).unwrap_or_else(|| "Input".into());
// At the chosen rate when the device offers it, so input and output run together: the
// best format first, then the most channels.
let at_rate = audio.sample_rate.and_then(|r| {
device
.supported_input_configs()
.ok()?
- .filter(|c| crate::devices::format_rank(c.sample_format()).is_some() && c.min_sample_rate().0 <= r && c.max_sample_rate().0 >= r)
+ .filter(|c| crate::devices::format_rank(c.sample_format()).is_some() && c.min_sample_rate() <= r && c.max_sample_rate() >= r)
.min_by_key(|c| (crate::devices::format_rank(c.sample_format()), std::cmp::Reverse(c.channels())))
- .map(|c| c.with_sample_rate(cpal::SampleRate(r)))
+ .map(|c| c.with_sample_rate(r))
});
let cfg = match at_rate {
Some(c) => c,
@@ -225,9 +224,9 @@ impl Recorder {
let mut config = cfg.config();
config.buffer_size = crate::devices::buffer_size(audio.buffer_size, cfg.buffer_size());
let channels = usize::from(config.channels).max(1);
- let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25));
+ let ring = CaptureRing::new((config.sample_rate as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25));
let shared = Arc::new(Shared::new(ring));
- let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0);
+ let monitor = InputRing::new(usize::from(config.channels), config.sample_rate);
let build = |config: &cpal::StreamConfig| match format {
cpal::SampleFormat::I32 => build_input::(&device, config, &shared, &monitor),
cpal::SampleFormat::I16 => build_input::(&device, config, &shared, &monitor),
@@ -245,7 +244,7 @@ impl Recorder {
};
stream.play().map_err(|e| e.to_string())?;
let seen = (0, Instant::now());
- Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None, seen })
+ Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate, channels, writer: None, seen })
}
#[cfg(target_os = "freebsd")]
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/src/tempo.rs b/crates/time/src/tempo.rs
index 24a8f49a..6aaa50b8 100644
--- a/crates/time/src/tempo.rs
+++ b/crates/time/src/tempo.rs
@@ -268,9 +268,12 @@ impl TempoMap {
let first = self.meter_at_tick(0);
if tick < 0 {
let tpb = first.ticks_per_bar();
- let bars_back = (-tick + tpb - 1) / tpb;
- let bar_tick = -bars_back * tpb;
- let within = tick - bar_tick;
+ let abs = tick.unsigned_abs();
+ let tpb_u = tpb.unsigned_abs();
+ let bars_back_u = abs / tpb_u + u64::from(!abs.is_multiple_of(tpb_u));
+ let bars_back = i64::try_from(bars_back_u).unwrap_or(i64::MAX);
+ // Offset of `tick` inside its bar, without negating `i64::MIN`.
+ let within = ((tpb_u - abs % tpb_u) % tpb_u) as i64;
let beat_t = first.ticks_per_beat();
return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t };
}
@@ -341,6 +344,15 @@ mod tests {
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
}
+ #[test]
+ fn bar_beat_at_min_tick_does_not_overflow() {
+ let m = TempoMap::default();
+ let bb = m.bar_beat_at_tick(i64::MIN);
+ assert!(bb.bar < 1);
+ assert!(bb.beat >= 1 && bb.tick >= 0);
+ assert_eq!(m.bar_beat_at_tick(-1).bar, 0);
+ }
+
#[test]
fn tempo_change_is_integrated() {
let mut m = TempoMap::default();
diff --git a/crates/time/src/timecode.rs b/crates/time/src/timecode.rs
index f8f6041b..1adf3901 100644
--- a/crates/time/src/timecode.rs
+++ b/crates/time/src/timecode.rs
@@ -133,8 +133,10 @@ impl Timecode {
/// Label for an absolute frame count.
pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode {
let negative = frames < 0;
- let mut f = frames.saturating_abs();
- let base = rate.nominal().max(1);
+ // Drop-frame labels can exceed i64::MAX even when the frame count fits. Widen before
+ // taking the magnitude so i64::MIN is represented exactly too.
+ let mut f = i128::from(frames).abs();
+ let base = i128::from(rate.nominal().max(1));
if rate.is_drop() {
// Drop 2 (or 4) labels each minute except every tenth.
let drop = if base >= 60 { 4 } else { 2 };
@@ -147,7 +149,13 @@ impl Timecode {
}
let frames_l = f % base;
let total_secs = f / base;
- Timecode { negative, hours: total_secs / 3600, minutes: (total_secs / 60) % 60, seconds: total_secs % 60, frames: frames_l }
+ Timecode {
+ negative,
+ hours: i64::try_from(total_secs / 3600).unwrap_or(i64::MAX),
+ minutes: i64::try_from((total_secs / 60) % 60).unwrap_or(0),
+ seconds: i64::try_from(total_secs % 60).unwrap_or(0),
+ frames: i64::try_from(frames_l).unwrap_or(0),
+ }
}
/// Absolute frame count of this label.
@@ -247,6 +255,28 @@ mod tests {
assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400);
}
+ #[test]
+ fn extreme_frame_counts_round_trip_at_every_rate() {
+ for rate in FrameRate::ALL {
+ for frames in [i64::MIN, i64::MIN + 1, -1, 0, 1, i64::MAX - 1, i64::MAX] {
+ let tc = Timecode::from_frames(frames, rate);
+ assert_eq!(tc.to_frames(rate), frames, "{rate:?}: {frames}");
+ assert_eq!(tc.negative, frames < 0);
+ assert!((0..60).contains(&tc.minutes));
+ assert!((0..60).contains(&tc.seconds));
+ assert!((0..rate.nominal()).contains(&tc.frames));
+ assert_eq!(Timecode::parse(&tc.format(rate)).unwrap(), tc);
+ }
+ }
+ }
+
+ #[test]
+ fn extreme_drop_frame_labels_do_not_overflow() {
+ for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
+ assert_eq!(Timecode::from_frames(i64::MAX, rate).to_frames(rate), i64::MAX);
+ }
+ }
+
#[test]
fn parse_variants() {
let tc = Timecode::parse("01:02:03:04").unwrap();
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 da75825f..4876374b 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -782,7 +782,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 5cd048d0..c38ef84d 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -87,7 +87,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",
@@ -634,7 +634,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/mix_window.rs b/crates/ui-egui/src/mix_window.rs
index 4b9a2869..1540bcfb 100644
--- a/crates/ui-egui/src/mix_window.rs
+++ b/crates/ui-egui/src/mix_window.rs
@@ -1256,10 +1256,14 @@ fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, cu
ui.separator();
if ui.button("new bus...").clicked() {
let mut n = busses.len() + 1;
- while busses.iter().any(|bus| bus.name == format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() {
+ while busses.iter().any(|bus| bus.name.eq_ignore_ascii_case(&format!("Bus {n}")))
+ || app.engine.session().track_by_name(&format!("Bus {n}")).is_some()
+ {
n += 1;
}
- let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
+ if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok() {
+ let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
+ }
ui.close();
}
}
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/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
index eae794dc..4aa47a9e 100644
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -162,8 +162,11 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("edit.tool", json!({"tool": tool}));
true
}
- Key::Plus | Key::Minus if !m.command && app.ui.window == MainWindow::Edit => {
- let dir = if key == Key::Plus { 1 } else { -1 };
+ // `=` is what a bare press of the unlabeled +/- key reports (it has no shift held, so it
+ // can never be `Key::Plus`); `Key::Plus` is kept for a keyboard that does send it (e.g. a
+ // numpad +). `!m.alt` leaves Alt+= to `window.event_list`'s registered shortcut.
+ Key::Plus | Key::Equals | Key::Minus if !m.command && !m.alt && app.ui.window == MainWindow::Edit => {
+ let dir = if key == Key::Minus { -1 } else { 1 };
let _ = app.run("edit.nudge", json!({"direction": dir}));
true
}
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 99%
rename from crates/ui-egui/tests/menu_search.rs
rename to crates/ui-egui/tests/it/menu_search.rs
index 973e6f5d..21b99a6c 100644
--- a/crates/ui-egui/tests/menu_search.rs
+++ b/crates/ui-egui/tests/it/menu_search.rs
@@ -143,6 +143,7 @@ fn send_and_output_menus_search_buses_as_soon_as_they_open() {
let mut engine = vox();
let vox = engine.session().tracks[0].id;
for (slot, bus) in [(0, "Verb"), (1, "Delay")] {
+ engine.execute("mix.new_bus", &json!({"name": bus})).unwrap();
engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus})).unwrap();
}
let mut h = harness(engine);
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 98%
rename from crates/ui-egui/tests/send_window.rs
rename to crates/ui-egui/tests/it/send_window.rs
index 908885d3..dcdef662 100644
--- a/crates/ui-egui/tests/send_window.rs
+++ b/crates/ui-egui/tests/it/send_window.rs
@@ -12,6 +12,7 @@ fn harness() -> (Harness<'static, SoundApp>, soundcraft_model::TrackId) {
engine.execute("track.new", &json!({"name": "Vox"})).unwrap();
let vox = engine.session().tracks[0].id;
for (slot, bus) in [(0, "Verb"), (1, "Delay")] {
+ engine.execute("mix.new_bus", &json!({"name": bus})).unwrap();
engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus, "level_db": -10.0})).unwrap();
}
let mut app = SoundApp::new(engine, None, Services::default());
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 63%
rename from crates/ui-egui/tests/shortcuts.rs
rename to crates/ui-egui/tests/it/shortcuts.rs
index 190cd406..7ffe8a56 100644
--- a/crates/ui-egui/tests/shortcuts.rs
+++ b/crates/ui-egui/tests/it/shortcuts.rs
@@ -56,6 +56,44 @@ fn tab_leaves_the_keys_to_the_shortcuts() {
}
}
+#[test]
+fn bare_plus_and_minus_nudge_the_selected_clip_in_edit() {
+ // A laptop keyboard has no dedicated "+": typing it needs Shift, which is already claimed by
+ // the Grid-value shortcut (#125 above). So nudge must fire on the bare, unshifted key the "+"
+ // symbol sits on, same as "-" already does; `Key::Plus` stays bound too for a keyboard (e.g. a
+ // numpad) that does send it on its own.
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
+ app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
+ let ctx = egui::Context::default();
+ let start = |app: &SoundApp| app.engine.session().find_clip(kick).unwrap().1.start;
+ let original = start(&app);
+
+ press(&ctx, &mut app, Key::Minus, Modifiers::NONE);
+ let after_minus = start(&app);
+ assert!(after_minus < original, "bare Minus must nudge left");
+
+ press(&ctx, &mut app, Key::Equals, Modifiers::NONE);
+ assert_eq!(start(&app), original, "bare Equals must nudge back right, same step as Minus");
+
+ press(&ctx, &mut app, Key::Plus, Modifiers::NONE);
+ assert!(start(&app) > original, "Key::Plus (e.g. a numpad +) must still nudge right");
+
+ // Shift+Equals is the pre-existing, separate Grid-value shortcut: it must not also nudge.
+ let before_shift = start(&app);
+ let grid = app.engine.session().edit.grid;
+ press(&ctx, &mut app, Key::Equals, Modifiers::SHIFT);
+ assert_eq!(start(&app), before_shift, "Shift+Equals steps the Grid value, not the clip");
+ assert_ne!(app.engine.session().edit.grid, grid, "Shift+Equals must still step the Grid value");
+
+ // Alt+Equals is the registered `window.event_list` shortcut: it must not also nudge.
+ let before_alt = start(&app);
+ assert!(!app.ui.show_event_list);
+ press(&ctx, &mut app, Key::Equals, Modifiers::ALT);
+ assert_eq!(start(&app), before_alt, "Alt+Equals opens the MIDI Event List, not a nudge");
+ assert!(app.ui.show_event_list, "Alt+Equals must still open the MIDI Event List");
+}
+
#[test]
fn cmd_equals_toggles_mix_and_edit_without_zooming() {
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
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 779970f1..00000000
--- a/crates/video/src/bitstream.rs
+++ /dev/null
@@ -1,431 +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,
-}
-
-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"),
- }
- }
-}
-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/2/4 bytes) into NAL units.
-pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result> {
- let mut out = Vec::new();
- let mut i = 0;
- while i < data.len() {
- if i + length_size > data.len() {
- return Err(BitError::Eof);
- }
- let mut len = 0usize;
- for k in 0..length_size {
- len = (len << 8) | data[i + k] as usize;
- }
- i += length_size;
- if i + len > data.len() {
- return Err(BitError::Eof);
- }
- out.push(&data[i..i + len]);
- i += len;
- }
- 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 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