diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ace994c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,159 @@ +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +env: + CARGO_TERM_COLOR: always + +jobs: + setup: + name: Setup + runs-on: ubuntu-latest + outputs: + ref: ${{ steps.get-ref.outputs.ref }} + version: ${{ steps.get-ref.outputs.version }} + version_number: ${{ steps.get-ref.outputs.version_number }} + steps: + - name: Determine ref and version + id: get-ref + run: | + REF="${{ github.ref }}" + echo "ref=$REF" >> $GITHUB_OUTPUT + # Extract version from ref (remove refs/tags/ prefix if present) + VERSION="${REF#refs/tags/}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + # Also create version without 'v' prefix for cargo + VERSION_NUMBER="${VERSION#v}" + echo "version_number=$VERSION_NUMBER" >> $GITHUB_OUTPUT + echo "Using ref: $REF" + echo "Using version: $VERSION" + echo "Using version number: $VERSION_NUMBER" + + verify-version: + name: Verify version matches tag + needs: setup + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.setup.outputs.ref }} + + - name: Verify Cargo.toml version matches tag + run: | + TAG_VERSION="${{ needs.setup.outputs.version_number }}" + CRATE_VERSION=$(grep "^version" Cargo.toml | head -1 | cut -d'"' -f2) + + echo "Tag version: $TAG_VERSION" + echo "Cargo.toml version: $CRATE_VERSION" + + if [ "$TAG_VERSION" != "$CRATE_VERSION" ]; then + echo "Error: Tag version ($TAG_VERSION) doesn't match Cargo.toml version ($CRATE_VERSION)" + echo "Please ensure the version in Cargo.toml matches the tag you're trying to release." + exit 1 + fi + echo "โœ… Version check passed!" + + test: + name: Run tests + needs: [setup, verify-version] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.setup.outputs.ref }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Run tests + run: cargo test --all-features --verbose + + - name: Run clippy + run: cargo clippy --all-features -- -D warnings + + create-release: + name: Create GitHub Release + needs: [setup, verify-version, test] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.setup.outputs.ref }} + fetch-depth: 0 # Fetch all history for git-cliff + + - name: Generate changelog + id: git-cliff + uses: orhun/git-cliff-action@v4 + with: + config: cliff.toml + args: --current --strip all + env: + OUTPUT: CHANGELOG.md + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.setup.outputs.version }} + name: Release ${{ needs.setup.outputs.version }} + body: | + ## Changes in ${{ needs.setup.outputs.version }} + + ${{ steps.git-cliff.outputs.content }} + + ### Installation + + #### From crates.io + ```bash + cargo add mp4-edit@${{ needs.setup.outputs.version_number }} + ``` + + publish-crate: + name: Publish to crates.io + needs: [setup, create-release] + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write # Required for OIDC token exchange with crates.io + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.setup.outputs.ref }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Get Cargo version for checking + run: | + CRATE_VERSION=$(grep "^version" Cargo.toml | head -1 | cut -d'"' -f2) + echo "CRATE_VERSION=$CRATE_VERSION" >> $GITHUB_ENV + + - name: Authenticate with crates.io + uses: rust-lang/crates-io-auth-action@v1 + id: auth + + - name: Publish to crates.io + run: | + # Try to publish, but don't fail if the version already exists + OUTPUT=$(cargo publish --verbose 2>&1) && echo "$OUTPUT" || { + EXIT_CODE=$? + echo "$OUTPUT" + # Check if it failed because the version already exists + if echo "$OUTPUT" | grep -q "already exists on crates.io"; then + echo "โœ… Version $CRATE_VERSION already published on crates.io, skipping..." + exit 0 + fi + # If it failed for another reason, exit with the original error code + echo "Error: Publishing failed with error code $EXIT_CODE" + exit $EXIT_CODE + } + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..a299397 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,24 @@ +name: Rust + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Clippy + run: cargo clippy --verbose + - name: Build + run: cargo build --verbose --all-features + - name: Run tests + run: cargo test --verbose --all-features diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0592392 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.DS_Store diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0fbf4bc --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,129 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +mp4-edit-abuse@zzh.email. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..606c00d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1188 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bon" +version = "3.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d9ef19ae5263a138da9a86871eca537478ab0332a7770bac7e3f08b801f89f" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577ae008f2ca11ca7641bd44601002ee5ab49ef0af64846ce1ab6057218a5cc1" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "console" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b136475da5ef7b6ac596c0e956e37bad51b85b987ff3d5e230e964936736b2" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b44ad32f92b75fb438b04b68547e521a548be8acc339a6dacc4a7121488f53e6" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b5be8a7a562d315a5b92a630c30cec6bcf663e6673f00fbb69cca66a6f521b9" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "escargot" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11c3aea32bc97b500c9ca6a72b768a26e558264303d101d3409cf6d57a9ed0cf" +dependencies = [ + "log", + "serde", + "serde_json", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indicatif" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.59.0", +] + +[[package]] +name = "mp4-edit" +version = "0.1.0" +dependencies = [ + "aes", + "anyhow", + "bon", + "cbc", + "clap", + "derive_more", + "either", + "escargot", + "futures-io", + "futures-util", + "hex", + "humantime", + "indicatif", + "progress_bar", + "rangemap", + "sha2", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "winnow", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "prettyplease" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "progress_bar" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955f6029e49ac8b5a692b0426ba78d8862edd4b2f98526920d1c7dc9d1edeef1" +dependencies = [ + "log", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rangemap" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbbbbea733ec66275512d0b9694f34102e7d5406fdbe2ad8d21b28dce92887c" + +[[package]] +name = "redox_syscall" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a11c5cf --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "mp4-edit" +version = "0.1.0" +edition = "2021" +keywords = ["mp4", "parser"] +license = "MIT OR Apache-2.0" +description = "mp4 read/write library designed with audiobooks in mind" +exclude = ["test-data", "test-data-review"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +experimental-trim = [] +default = ["experimental-trim"] + +[dependencies] +anyhow = "1.0.98" +bon = "3.6.5" +derive_more = { version = "2.0.1", features = ["full"] } +either = "1.15.0" +futures-io = "0.3" +futures-util = { version = "0.3", features = ["io"] } +rangemap = "1.7.0" +thiserror = "2.0.12" +winnow = "0.7.13" + +[dev-dependencies] +# examples +aes = "0.8.4" +cbc = "0.1.2" +clap = { version = "4.5.53", features = ["derive"] } +humantime = "2.3.0" +progress_bar = { version = "1.2.1", features = ["logger"] } +tokio = { version = "1.45.1", features = ["full"] } +tokio-util = { version = "0.7", features = ["compat"] } +# examples & tests +hex = "0.4.3" +# tests +escargot = "0.5.15" +tempfile = "3.23.0" +sha2 = "0.10.9" +indicatif = "0.18.3" + +[profile.dev.package] diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..1b5ec8b --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..2424467 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jesse Stuart + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8858edd --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +mp4-edit +======== + +This crate provides tools for lossless editing of MP4 files, with a focus on audiobooks. + +## Status + +> [!WARNING] +> Unstable. The API is likely to change, and more testing is needed. + +## Why yet another mp4 parser? + +The short answer is I needed one that could handle non-standard mp4s plus some other features I didn't see in existing crates, and this evolved out of my learning the file format. And why not? It's been fun! (See below for a list of alternatives.) + +## Usage + +See the examples dir for in-depth usage. + +## Highlights + +- Lossless MP4 editing[^lossless]. +- Duration trimming/slicing[^trimming] (WIP, requires `experimental-trim` feature flag). +- Track interleaving[^interleaving]. +- Chapter track builder. +- Easy fast start (it's possible in most cases to insert metadata before `mdat` in a single pass). +- Async API built on `futures` traits[^async] (`tokio` support via `tokio_util::compat`). + +## Examples + +You may run the examples in this repo as usual with `cargo run --example `, but for most examples you'll want to build them first with `cargo build --example --release` before running them to get good performance. + +- **mp4copy**: Makes a copy of the mp4 file, converting it to fast-start if it isn't already. +- **mp4dump**: Useful for seeing what's inside an mp4 file. +- **extract_leaf_atoms**: Generate atom parser test data from an mp4 file. + +## Alternatives + +Here are some other mp4 crates to consider (in alphabetical order): + +- [mp4-atom](https://github.com/kixelated/mp4-atom) +- [mp4-rust](https://github.com/alfg/mp4-rust) +- [mp4ameta](https://github.com/saecki/mp4ameta) +- [mp4parse-rust](https://github.com/mozilla/mp4parse-rust) +- [mtag](https://github.com/insomnimus/mtag) + +## License + +Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +## Contributing + +Please open an issue if you have a feature request or a bug report. I'm happy to accept changes in line with the goals +of this crate (editing mp4 bytes). + +When opening a pull request, please make a best effort to use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. + +[^lossless]: The output will use the most efficient size headers possible, doesn't maintain non-standard reserved field values. Other than that, all data should be the same unless explicitly changed. + +[^trimming]: Limitations apply; Trimming is a work in progress and is currently only supported for a single audio track. The edit list is not taken into consideration. It _should_ be possible to expand this support, with the help of edit lists, to include all track types on multi-track files (see https://github.com/jvatic/mp4-edit/issues/1). + +[^interleaving]: While re-ordering chunks is not yet supported, new tracks may be added that interleave with existing ones, and any original interleaving is maintained. You are responsible for ensuring data is added in the correct locations via the `ChunkParser`. Higher level abstractions may be added later. + +[^async]: The main API works with async IO, but each atom parser is itself sync. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..110b9aa --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +Security updates are applied only to the latest release. + +## Reporting a Vulnerability + +If you have discovered a security vulnerability in this project, please report it privately. **Do not disclose it as a public issue.** This gives me time to work with you to fix the issue before public exposure, reducing the chance that the exploit will be used before a patch is released. + +Please disclose it at [security advisory](https://github.com/jvatic/mp4-edit/security/advisories/new). + +This project is maintained by the owner on a reasonable-effort basis. As such, please give me at least 90 days to work on a fix before public exposure. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..763f68b --- /dev/null +++ b/cliff.toml @@ -0,0 +1,95 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + + +[changelog] +# A Tera template to be rendered for each release in the changelog. +# See https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %} +""" +# Remove leading and trailing whitespaces from the changelog's body. +trim = true +# Render body even when there are no releases to process. +render_always = true +# An array of regex based postprocessors to modify the changelog. +postprocessors = [ + # Replace the placeholder with a URL. + #{ pattern = '', replace = "https://github.com/orhun/git-cliff" }, +] +# render body even when there are no releases to process +# render_always = true +# output file path +# output = "test.md" + +[git] +# Parse commits according to the conventional commits specification. +# See https://www.conventionalcommits.org +conventional_commits = true +# Exclude commits that do not match the conventional commits specification. +filter_unconventional = true +# Require all commits to be conventional. +# Takes precedence over filter_unconventional. +require_conventional = false +# Split commits on newlines, treating each line as an individual commit. +split_commits = false +# An array of regex based parsers to modify commit messages prior to further processing. +commit_preprocessors = [ + # Replace issue numbers with link templates to be updated in `changelog.postprocessors`. + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit message using https://github.com/crate-ci/typos. + # If the spelling is incorrect, it will be fixed automatically. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# Prevent commits that are breaking from being excluded by commit parsers. +protect_breaking_commits = false +# An array of regex based parsers for extracting data from the commit message. +# Assigns commits to groups. +# Optionally sets the commit's scope and can decide to exclude commits from further processing. +commit_parsers = [ + { message = "^feat", group = "๐Ÿš€ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^WIP", skip = true }, + { message = "^fixup", skip = true }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore\\(github\\)", skip = true }, + { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, + { message = ".*", group = "๐Ÿ’ผ Other" }, +] +# Exclude commits that are not matched by any commit parser. +filter_commits = false +# An array of link parsers for extracting external references, and turning them into URLs, using regex. +link_parsers = [] +# Include only the tags that belong to the current branch. +use_branch_tags = false +# Order releases topologically instead of chronologically. +topo_order = false +# Order releases topologically instead of chronologically. +topo_order_commits = true +# Order of commits in each group/release within the changelog. +# Allowed values: newest, oldest +sort_commits = "oldest" +# Process submodules commits +recurse_submodules = false diff --git a/examples/extract_leaf_atoms.rs b/examples/extract_leaf_atoms.rs new file mode 100644 index 0000000..e31e49e --- /dev/null +++ b/examples/extract_leaf_atoms.rs @@ -0,0 +1,252 @@ +/*! + * Slice metadata leaf atoms into bin files for round-trip testing. + */ + +use anyhow::{anyhow, Context, Result}; +use std::{ + env, + path::{Path, PathBuf}, +}; +use tokio::fs; +use tokio_util::compat::TokioAsyncReadCompatExt; + +use mp4_edit::{ + atom::{ + is_container_atom, + stsd::{self, StsdExtension}, + SampleDescriptionTableAtom, + }, + Atom, AtomData, FourCC, Parser, +}; + +/// Extract leaf atoms from an MP4 file and write them to individual files +async fn extract_leaf_atoms(input_path: &str, output_dir: &str) -> Result<()> { + // Open the input file + let file = fs::File::open(input_path) + .await + .with_context(|| format!("Failed to open input file: {}", input_path))?; + + // Create output directory if it doesn't exist + fs::create_dir_all(output_dir) + .await + .with_context(|| format!("Failed to create output directory: {}", output_dir))?; + + // Parse the MP4 file + println!("๐Ÿ“‚ Parsing MP4 file: {}", input_path); + let parser = Parser::new_seekable(file.compat()); + let metadata = parser + .parse_metadata() + .await + .context("Failed to parse MP4 metadata")?; + + // Read the original file again to extract raw atom data + let original_data = fs::read(input_path) + .await + .with_context(|| format!("Failed to read input file: {}", input_path))?; + + let mut leaf_count = 0; + let mut total_size = 0; + + // Process all atoms iteratively using a stack + let mut atoms_to_process: Vec<&Atom> = metadata.atoms_iter().collect(); + + while let Some(atom) = atoms_to_process.pop() { + // Check if this is a leaf atom (no children) + if atom.children.is_empty() { + // Skip container atoms even if they have no children + if !is_container_atom(atom.header.atom_type) { + extract_single_atom(atom, &original_data, output_dir).await?; + leaf_count += 1; + total_size += atom.header.atom_size(); + } + } else { + // Add children to the stack for processing + for child in &atom.children { + atoms_to_process.push(child); + } + } + } + + println!("\nโœ… Extraction complete!"); + println!(" ๐Ÿ“„ Leaf atoms extracted: {}", leaf_count); + println!(" ๐Ÿ“Š Total size: {} bytes", total_size); + + Ok(()) +} + +async fn choose_output_filename( + atom_type_str: String, + output_dir: &str, +) -> Result<(String, PathBuf)> { + let mut output_path = None; + for filename in std::iter::repeat_n(0, 1_00) + .enumerate() + .map(|(i, _)| format!("{atom_type_str}{i:02}.bin")) + { + let output_path_candidate = Path::new(output_dir).join(&filename); + if fs::metadata(&output_path_candidate).await.is_err() { + output_path = Some((filename, output_path_candidate)); + break; + } + } + let (filename, output_path) = output_path.ok_or_else(|| { + anyhow!("failed to find suitable filename for {atom_type_str} in {output_dir}") + })?; + Ok((filename, output_path)) +} + +/// Extract a single leaf atom and write it to a file +async fn extract_single_atom(atom: &Atom, original_data: &[u8], output_dir: &str) -> Result<()> { + let atom_type_str = atom.header.atom_type.to_string(); + let (filename, output_path) = choose_output_filename(atom_type_str.clone(), output_dir).await?; + + // Extract the complete atom data (header + body) from the original file + let atom_start = atom.header.offset; + let atom_end = atom_start + atom.header.atom_size(); + + if atom_end > original_data.len() { + return Err(anyhow::anyhow!( + "Atom {} extends beyond file boundaries (offset: {}, size: {}, file size: {})", + atom_type_str, + atom_start, + atom.header.atom_size(), + original_data.len() + )); + } + + let atom_data = &original_data[atom_start..atom_end]; + + // Write the atom data to file + fs::write(&output_path, atom_data) + .await + .with_context(|| format!("Failed to write atom file: {}", output_path.display()))?; + + println!( + " ๐Ÿ’พ {} โ†’ {} ({} bytes)", + atom_type_str, + filename, + atom_data.len() + ); + + if let Some(data) = atom.data.as_ref() { + match data { + AtomData::SampleDescriptionTable(stsd) => { + extract_stsd_extensions( + stsd, + Path::new(output_dir).join(atom_type_str).to_str().unwrap(), + ) + .await?; + } + _ => {} + } + } + + Ok(()) +} + +async fn extract_stsd_extensions( + stsd: &SampleDescriptionTableAtom, + output_dir: &str, +) -> Result<()> { + fs::create_dir_all(output_dir) + .await + .with_context(|| format!("Failed to create output directory: {}", output_dir))?; + + let empty_list: Vec = Vec::new(); + for entry in stsd.entries.iter() { + let extensions = match &entry.data { + stsd::SampleEntryData::Audio(entry) => entry.extensions.iter(), + _ => empty_list.iter(), + }; + + for extension in extensions { + extract_stsd_extension(&extension, output_dir).await?; + } + } + Ok(()) +} + +async fn extract_stsd_extension(ext: &StsdExtension, output_dir: &str) -> Result<()> { + // We'll only extract the unknown extensions to be sure we're getting the correct bytes + let (typ, data) = match ext { + StsdExtension::Unknown { fourcc, data } => (fourcc, data), + _ => return Ok(()), + }; + + let mut type_str = FourCC::new(typ).to_string(); + + if type_str.contains('\0') { + type_str = "unknown".to_string(); + } + + let (filename, output_path) = choose_output_filename(type_str.clone(), output_dir).await?; + + let data = data.clone(); + + fs::write(&output_path, &data).await.with_context(|| { + format!( + "Failed to write stsd extension file: {} ({type_str:#?})", + output_path.display() + ) + })?; + + println!(" ๐Ÿ’พ {} โ†’ {} ({} bytes)", type_str, filename, data.len()); + + Ok(()) +} + +/// Print usage information +fn print_usage(program_name: &str) { + eprintln!("Usage: {} [output_dir]", program_name); + eprintln!(); + eprintln!("Arguments:"); + eprintln!(" input_mp4 Path to the MP4 file to extract atoms from"); + eprintln!(" output_dir Directory to write extracted atoms to (default: current directory)"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" {} video.mp4", program_name); + eprintln!(" {} video.mp4 ./atoms", program_name); + eprintln!(); + eprintln!("This tool extracts leaf atoms from MP4 metadata and saves each atom"); + eprintln!("as a separate binary file (e.g., ilst.bin, chpl.bin) containing the"); + eprintln!("complete atom data including header and size information."); +} + +#[tokio::main] +async fn main() -> Result<()> { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + print_usage(&args[0]); + std::process::exit(1); + } + + let input_path = &args[1]; + let output_dir = if args.len() > 2 { &args[2] } else { "." }; + + // Validate input file exists + if !Path::new(input_path).exists() { + eprintln!("โŒ Error: Input file does not exist: {}", input_path); + std::process::exit(1); + } + + println!("๐ŸŽฌ MP4 Leaf Atom Extractor"); + println!(" Input: {}", input_path); + println!(" Output: {}", output_dir); + println!(); + + if let Err(e) = extract_leaf_atoms(input_path, output_dir).await { + eprintln!("โŒ Error: {}", e); + + // Print the error chain for more context + let mut source = e.source(); + while let Some(err) = source { + eprintln!(" Caused by: {}", err); + source = err.source(); + } + + std::process::exit(1); + } + + Ok(()) +} diff --git a/examples/mp4copy.rs b/examples/mp4copy.rs new file mode 100644 index 0000000..ce463bc --- /dev/null +++ b/examples/mp4copy.rs @@ -0,0 +1,290 @@ +/*! + * This example demonstrates copying and/or trimming an MP4 file. When using a seekable input, it moves metadata to the start of the file for fast-start (streaming) opimization. Using a non-seekable input requires the file to already be fast-start. + */ + +use anyhow::{anyhow, Context}; +use clap::Parser as ClapParser; +use futures_util::io::{BufReader, BufWriter}; +use indicatif::ProgressBar; +use std::time::Duration; +use tokio::{ + fs, + io::{self}, +}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use mp4_edit::{ + atom::FourCC, + parser::{ReadCapability, MDAT}, + Mp4Writer, +}; + +#[derive(clap::Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + /// Path to the input mp4, use `-` for stdin + input_mp4: String, + + /// Path to the output mp4, use `-` for stdout + output_mp4: String, + + #[command(subcommand)] + command: Option, +} + +#[derive(clap::Subcommand, Debug)] +enum SubCommand { + #[cfg(feature = "experimental-trim")] + Trim(TrimArgs), + #[cfg(feature = "experimental-trim")] + Retain(RetainArgs), +} + +/// Trim the start and/or end of the mp4 +#[derive(clap::Args, Debug)] +#[group(required = true, multiple = true)] +struct TrimArgs { + /// Duration to trim from the start (e.g. 10s) + #[arg(short, long, value_parser = humantime::parse_duration)] + start: Option, + + /// Duration to trim from the end (e.g. 1m20s) + #[arg(short, long, value_parser = humantime::parse_duration)] + end: Option, +} + +/// Retain a clip of the mp4 +#[derive(clap::Args, Debug)] +#[group(required = true, multiple = true)] +struct RetainArgs { + /// Position clip starts at (e.g. 1h10m32s) + #[arg(short = 'o', long, value_parser = humantime::parse_duration)] + from_offset: Option, + + /// Duration of clip to retain (e.g. 30m) + #[arg(short, long, value_parser = humantime::parse_duration)] + duration: Duration, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + let input_name = &args.input_mp4; + let output_name = &args.output_mp4; + let sub_command = args.command; + + eprintln!("Copying {} into {}", input_name, output_name); + + if input_name == "-" { + eprintln!("parsing stdin as readonly"); + + let input = io::stdin().compat(); + let input_reader = BufReader::new(input); + let parser = mp4_edit::parser::Parser::new(input_reader); + let metadata = parser + .parse_metadata() + .await + .context("failed to parse metadata from stdin")?; + + process_mp4_copy(metadata, output_name, sub_command).await?; + } else { + eprintln!("parsing file as seekable"); + + let file = fs::File::open(input_name).await?; + let input_reader = file.compat(); + let parser = mp4_edit::parser::Parser::new_seekable(input_reader); + let metadata = parser + .parse_metadata() + .await + .context("failed to parse metadata from input file")?; + + process_mp4_copy(metadata, output_name, sub_command).await?; + } + + Ok(()) +} + +async fn process_mp4_copy( + metadata: mp4_edit::parser::MdatParser, + output_name: &str, + sub_command: Option, +) -> anyhow::Result<()> +where + C: ReadCapability, + R: futures_util::io::AsyncRead + Unpin + Send, +{ + if output_name == "-" { + process_mp4_copy_to_stdout(metadata, sub_command).await?; + } else { + process_mp4_copy_to_file(metadata, output_name, sub_command).await?; + } + Ok(()) +} + +async fn process_mp4_copy_to_file( + metadata: mp4_edit::parser::MdatParser, + output_name: &str, + sub_command: Option, +) -> anyhow::Result<()> +where + C: ReadCapability, + R: futures_util::io::AsyncRead + Unpin + Send, +{ + eprintln!("writing to file {output_name:#?}"); + let output = fs::File::create(output_name) + .await + .context("failed to create output file")? + .compat_write(); + let output = BufWriter::new(output); + let writer = Mp4Writer::new(output); + process_mp4_copy_inner(metadata, writer, sub_command).await?; + Ok(()) +} + +async fn process_mp4_copy_to_stdout( + metadata: mp4_edit::parser::MdatParser, + sub_command: Option, +) -> anyhow::Result<()> +where + C: ReadCapability, + R: futures_util::io::AsyncRead + Unpin + Send, +{ + eprintln!("writing to stdout"); + let output = io::stdout().compat_write(); + let output = BufWriter::new(output); + let writer = Mp4Writer::new(output); + process_mp4_copy_inner(metadata, writer, sub_command).await?; + Ok(()) +} + +async fn process_mp4_copy_inner( + metadata: mp4_edit::parser::MdatParser, + mut mp4_writer: Mp4Writer, + sub_command: Option, +) -> anyhow::Result<()> +where + C: ReadCapability, + R: futures_util::io::AsyncRead + Unpin + Send, + W: futures_util::io::AsyncWrite + Unpin + Send, +{ + let mut input_metadata = metadata; + + if let Some(sub_command) = sub_command { + match sub_command { + #[cfg(feature = "experimental-trim")] + SubCommand::Trim(args) => { + if let Some(start) = args.start { + eprintln!("trimming {} from start", humantime::format_duration(start)); + } + if let Some(end) = args.end { + eprintln!("trimming {} from end", humantime::format_duration(end)); + } + trim_duration(&mut input_metadata, args)?; + } + #[cfg(feature = "experimental-trim")] + SubCommand::Retain(args) => { + eprintln!( + "retaining {} to {}", + humantime::format_duration(args.from_offset.unwrap_or_default()), + humantime::format_duration( + args.from_offset.unwrap_or_default() + args.duration + ) + ); + retain_duration(&mut input_metadata, args)?; + } + } + } + + let mut metadata = input_metadata.clone(); + + let mdat_size = metadata + .update_chunk_offsets() + .context("error updating chunk offsets")? + .total_size as usize; + + let new_metadata_size = metadata.metadata_size(); + let mdat_content_offset = new_metadata_size + 8; + + let progress_bar = ProgressBar::new((mdat_content_offset + mdat_size) as u64); + + // Write metadata atoms (all neccesary changes have been made already) + for (i, atom) in metadata.atoms_iter().enumerate() { + mp4_writer.write_atom(atom.clone()).await.with_context(|| { + format!("failed to write atom {} ({})", i + 1, atom.header.atom_type) + })?; + progress_bar.set_position(mp4_writer.current_offset() as u64); + } + + mp4_writer.flush().await.context("metadata flush")?; + + // Write MDAT header + mp4_writer + .write_atom_header(FourCC::from(*MDAT), mdat_size as usize) + .await + .context("error writing mdat placeholder header")?; + + if input_metadata.mdat_header().is_none() { + return Err(anyhow!("mdat atom not found")); + } + + assert_eq!( + mp4_writer.current_offset(), + mdat_content_offset, + "incorrect mdat_content_offset" + ); + + // Copy and write sample data + let mut chunk_idx = 0; + let mut chunk_parser = input_metadata.chunks()?; + while let Some(chunk) = chunk_parser.read_next_chunk().await? { + for (i, sample) in chunk.samples().enumerate() { + let data = sample.data.to_vec(); + + mp4_writer.write_raw(&data).await.context(format!( + "error writing sample {i:02} data in chunk {chunk_idx:02}" + ))?; + + progress_bar.set_position(mp4_writer.current_offset() as u64); + } + + chunk_idx += 1; + } + + mp4_writer.flush().await.context("final flush")?; + + progress_bar.finish(); + + assert_eq!( + mp4_writer.current_offset(), + mdat_content_offset + mdat_size as usize, + "mdat header has incorrect size" + ); + + Ok(()) +} + +#[cfg(feature = "experimental-trim")] +fn trim_duration(metadata: &mut mp4_edit::parser::Metadata, args: TrimArgs) -> anyhow::Result<()> { + metadata + .moov_mut() + .trim_duration() + .maybe_from_start(args.start) + .maybe_from_end(args.end) + .trim(); + Ok(()) +} + +#[cfg(feature = "experimental-trim")] +fn retain_duration( + metadata: &mut mp4_edit::parser::Metadata, + args: RetainArgs, +) -> anyhow::Result<()> { + metadata + .moov_mut() + .retain_duration() + .maybe_from_offset(args.from_offset) + .duration(args.duration) + .retain(); + Ok(()) +} diff --git a/examples/mp4dump.rs b/examples/mp4dump.rs new file mode 100644 index 0000000..59f6b86 --- /dev/null +++ b/examples/mp4dump.rs @@ -0,0 +1,203 @@ +/*! + * This example demonstrates inspecting the atom structure of an mp4 file. + */ + +use std::env; +use tokio::{ + fs, + io::{self}, +}; +use tokio_util::compat::TokioAsyncReadCompatExt; + +use mp4_edit::{ + atom::{meta, AtomHeader}, + parser::Metadata, + Atom, AtomData, Parser, +}; + +/// Format file size in human-readable format +fn format_size(size: usize) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; + let mut size = size as f64; + let mut unit_index = 0; + + while size >= 1024.0 && unit_index < UNITS.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + + if unit_index == 0 { + format!("{} {}", size as u64, UNITS[unit_index]) + } else { + format!("{:.1} {}", size, UNITS[unit_index]) + } +} + +/// Get a summary of atom data +fn get_atom_summary(atom: &Atom) -> String { + match &atom.data { + Some(AtomData::RawData(data)) if atom.header.atom_type == b"meta" => { + meta::MetaHeader::from_bytes(data.as_slice()) + .map(|meta| format!("{meta:?}")) + .unwrap_or_else(|_| format!("{data:?}")) + } + Some(data) => format!("{data:?}"), + None => "".to_string(), + } +} + +/// Print atom with proper formatting and indentation +fn print_atom(atom: &Atom, indent: usize) { + let indent_str = if indent > 0 { + format!("{: "\x1b[1;32m", // Green for file type + "moov" | "trak" | "mdia" => "\x1b[1;34m", // Blue for containers + "mvhd" | "tkhd" | "mdhd" => "\x1b[1;35m", // Magenta for headers + "stbl" | "stts" | "stsc" | "stsz" | "stco" | "co64" => "\x1b[1;31m", // Red for sample tables + _ => "\x1b[0m", // Default + }; + + println!("\x1b[1;36mโ”‚\x1b[0m {}{:<20}\x1b[0m โ”‚ \x1b[2m{:<23}\x1b[0m โ”‚ \x1b[1m{:<10}\x1b[0m โ”‚ \x1b[2m{:<30}\x1b[0m", + atom_color, atom_display, offset_display, size_display, summary); +} + +/// Print table header +fn print_table_header() { + println!("\n\x1b[1;36mโ•ญโ”€ MP4 Atom Structure โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\x1b[0m"); + println!("\x1b[1;36mโ”‚\x1b[0m"); + println!( + "\x1b[1;36mโ”‚\x1b[0m \x1b[1;33m{:<20} โ”‚ {:<23} โ”‚ {:<10} โ”‚ {:<30}\x1b[0m", + "Atom Type", "Offset Range", "Size", "Summary" + ); + println!( + "\x1b[1;36mโ”‚\x1b[0m \x1b[2m{:โ”€<20}โ”€โ”ผโ”€{:โ”€<23}โ”€โ”ผโ”€{:โ”€<10}โ”€โ”ผโ”€{:โ”€<30}\x1b[0m", + "", "", "", "" + ); +} + +/// Print table footer +fn print_table_footer() { + println!("\x1b[1;36mโ”‚\x1b[0m"); + println!("\x1b[1;36mโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\x1b[0m\n"); +} + +/// Process atom recursively, handling data extraction and printing +fn process_atom(atom: &Atom, indent: usize, atom_count: &mut usize) { + print_atom(atom, indent); + *atom_count += 1; + + // Process children recursively + for child in &atom.children { + process_atom(child, indent + 1, atom_count); + } +} + +async fn print_atoms(metadata: Metadata, mdat_header: Option) -> anyhow::Result { + let mut atom_count = 0; + let mut first_atom = true; + + let mut track_bitrate = Vec::with_capacity(1); + for trak in metadata.moov().into_tracks_iter() { + let num_bits = trak + .media() + .media_information() + .sample_table() + .sample_size() + .map(|s| s.entry_sizes.iter().sum::()) + .unwrap_or_default() + .saturating_mul(8); + + let duration_secds = trak + .media() + .header() + .map(|mdhd| (mdhd.duration as f64) / (mdhd.timescale as f64)) + .unwrap_or_default(); + + let bitrate = (num_bits as f64) / duration_secds; + println!( + "trak({track_id}) bitrate: {bitrate}", + track_id = trak.header().map(|tkhd| tkhd.track_id).unwrap_or_default() + ); + track_bitrate.push(bitrate.round() as u32); + } + + for atom in metadata.atoms_iter() { + if first_atom { + print_table_header(); + first_atom = false; + } + + process_atom(atom, 0, &mut atom_count); + } + + if let Some(mdat_header) = mdat_header { + let mdat_atom = Atom { + header: mdat_header, + data: None, + children: Vec::new(), + }; + process_atom(&mdat_atom, 0, &mut atom_count); + } + + if !first_atom { + print_table_footer(); + } + + Ok(atom_count) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + std::process::exit(1); + } + + let input_name = args[1].as_str(); + println!("\x1b[1;32m๐ŸŽฌ Analyzing MP4 file: {}\x1b[0m", input_name); + + let atom_count = if input_name == "-" { + eprintln!("parsing as readonly"); + let input = Box::new(io::stdin()); + let parser = Parser::new(input.compat()); + let metadata = parser.parse_metadata().await?; + let mdat_header = metadata.mdat_header().cloned(); + print_atoms(metadata.into_metadata(), mdat_header).await? + } else { + eprintln!("parsing as seekable"); + let file = fs::File::open(input_name).await?; + let parser = Parser::new_seekable(file.compat()); + let metadata = parser.parse_metadata().await?; + let mdat_header = metadata.mdat_header().cloned(); + print_atoms(metadata.into_metadata(), mdat_header).await? + }; + + // Print summary statistics + println!("\x1b[1;33m๐Ÿ“Š Summary:\x1b[0m"); + println!(" Total atoms: \x1b[1m{}\x1b[0m", atom_count); + if args[1].as_str() != "-" { + let file_size = fs::metadata(&args[1]).await?.len(); + println!( + " File size: \x1b[1m{}\x1b[0m", + format_size(file_size as usize) + ); + } + + Ok(()) +} diff --git a/src/atom.rs b/src/atom.rs new file mode 100644 index 0000000..42aef56 --- /dev/null +++ b/src/atom.rs @@ -0,0 +1,241 @@ +/*! + * This mod is concerned with mp4 atoms and how to {de}serialize them. +*/ + +#[cfg(test)] +pub mod test_utils; +pub(crate) mod util; + +pub(crate) mod atom_ref; +pub mod container; +pub mod fourcc; +pub mod iter; +pub mod leaf; + +use bon::bon; + +use crate::{ + atom::util::parser::rest_vec, parser::ParseAtomData, writer::SerializeAtom, ParseError, +}; + +pub use self::{container::*, fourcc::*, leaf::*}; + +/// Represents raw atom bytes. +#[derive(Clone)] +pub struct RawData { + atom_type: FourCC, + data: Vec, +} + +impl RawData { + pub fn new(atom_type: FourCC, data: Vec) -> Self { + Self { atom_type, data } + } + + pub fn as_slice(&self) -> &[u8] { + &self.data + } + + pub fn to_vec(self) -> Vec { + self.data + } +} + +impl ParseAtomData for RawData { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + use crate::atom::util::parser::stream; + use winnow::Parser; + let data = rest_vec.parse(stream(input))?; + Ok(RawData::new(atom_type, data)) + } +} + +impl SerializeAtom for RawData { + fn atom_type(&self) -> FourCC { + self.atom_type + } + + fn into_body_bytes(self) -> Vec { + self.data + } +} + +impl std::fmt::Debug for RawData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[u8; {}]", self.data.len()) + } +} + +#[derive(Debug, Clone)] +pub struct AtomHeader { + pub atom_type: FourCC, + pub offset: usize, + pub header_size: usize, + pub data_size: usize, +} + +impl AtomHeader { + pub fn new(atom_type: impl Into) -> Self { + Self { + atom_type: atom_type.into(), + offset: 0, + header_size: 0, + data_size: 0, + } + } + + pub fn location(&self) -> (usize, usize) { + (self.offset, self.header_size + self.data_size) + } + + pub fn atom_size(&self) -> usize { + self.header_size + self.data_size + } +} + +/// Represents a tree of mp4 atoms. Container atoms usually don't have [`Self::data`] (except for e.g. [meta]), +/// and leaf atoms don't have any [`Self::children`]. +/// +/// This structure allows us to represent any tree of mp4 atoms (boxes), even ones we don't (yet) support (via [RawData]). +#[derive(Debug, Clone)] +pub struct Atom { + pub header: AtomHeader, + pub data: Option, + pub children: Vec, +} + +#[bon] +impl Atom { + #[builder] + pub fn new( + header: AtomHeader, + #[builder(into)] data: Option, + #[builder(default = Vec::new())] children: Vec, + ) -> Self { + Self { + header, + data, + children, + } + } + + /// Recursively retains only the atoms that satisfy the predicate, + /// one level of depth at a time (least to most nested). + pub fn children_flat_retain_mut

(&mut self, mut pred: P) + where + P: FnMut(&mut Atom) -> bool, + { + let mut current_level = vec![self]; + + while !current_level.is_empty() { + let mut next_level = Vec::new(); + + for atom in current_level { + // Apply retain to this atom's children + atom.children.retain_mut(|child| pred(child)); + + // Collect remaining children for next level processing + for child in &mut atom.children { + next_level.push(child); + } + } + + current_level = next_level; + } + } +} + +impl SerializeAtom for Atom { + fn atom_type(&self) -> FourCC { + self.header.atom_type + } + + /// Serialize [Atom]'s body and all children + fn into_body_bytes(self) -> Vec { + // Serialize all children + let mut children_bytes = Vec::new(); + for child in self.children { + let mut child_bytes = child.into_bytes(); + children_bytes.append(&mut child_bytes); + } + + let mut body = self + .data + .map(SerializeAtom::into_body_bytes) + .unwrap_or_default(); + + body.append(&mut children_bytes); + body + } +} + +macro_rules! define_atom_data { + ( $(#[$meta:meta])* $enum:ident { $( $pattern:pat => $variant:ident($struct:ident) ),+ $(,)? } $(,)? ) => { + $(#[$meta])* + pub enum $enum { + $( $variant($struct), )+ + } + + $( + impl From<$struct> for $enum { + fn from(atom: $struct) -> Self { + $enum::$variant(atom) + } + } + )+ + + impl ParseAtomData for $enum { + fn parse_atom_data( + atom_type: FourCC, + input: &[u8], + ) -> Result { + match atom_type { + $($pattern => $struct::parse_atom_data(atom_type, input).map($enum::from), )+ + } + } + } + + impl SerializeAtom for $enum { + fn atom_type(&self) -> FourCC { + match self { + $( $enum::$variant(atom) => atom.atom_type(), )+ + } + } + + fn into_body_bytes(self) -> Vec { + match self { + $( $enum::$variant(atom) => atom.into_body_bytes(), )+ + } + } + } + }; +} + +define_atom_data!( + /// Represents data contained in an atom (other than children). + /// + /// Usually only leaf atoms contain data, but some container types such as [meta] have some extra headers. + #[derive(Debug, Clone)] + AtomData { + ftyp::FTYP => FileType(FileTypeAtom), + mvhd::MVHD => MovieHeader(MovieHeaderAtom), + mdhd::MDHD => MediaHeader(MediaHeaderAtom), + elst::ELST => EditList(EditListAtom), + hdlr::HDLR => HandlerReference(HandlerReferenceAtom), + smhd::SMHD => SoundMediaHeader(SoundMediaHeaderAtom), + gmin::GMIN => BaseMediaInfo(BaseMediaInfoAtom), + text::TEXT => TextMediaInfo(TextMediaInfoAtom), + ilst::ILST => ItemList(ItemListAtom), + tkhd::TKHD => TrackHeader(TrackHeaderAtom), + stsd::STSD => SampleDescriptionTable(SampleDescriptionTableAtom), + tref::TREF => TrackReference(TrackReferenceAtom), + dref::DREF => DataReference(DataReferenceAtom), + stsz::STSZ => SampleSize(SampleSizeAtom), + stco_co64::STCO | stco_co64::CO64 => ChunkOffset(ChunkOffsetAtom), + stts::STTS => TimeToSample(TimeToSampleAtom), + stsc::STSC => SampleToChunk(SampleToChunkAtom), + chpl::CHPL => ChapterList(ChapterListAtom), + free::FREE | free::SKIP => Free(FreeAtom), + _ => RawData(RawData), + }, +); diff --git a/src/atom/atom_ref.rs b/src/atom/atom_ref.rs new file mode 100644 index 0000000..0e5def6 --- /dev/null +++ b/src/atom/atom_ref.rs @@ -0,0 +1,158 @@ +/*! +* [`AtomRef`] and [`AtomRefMut`] provide utilities for working with shared and mutable references to [`Atom`]s that have children. +* +* See [`crate::atom::container`] for more useful types that wrap these. +*/ + +use bon::bon; + +use crate::atom::AtomHeader; + +use crate::{AtomData, FourCC}; + +use crate::atom::Atom; + +/// Unwrap atom data enum given variant type. +/// +/// # Example +/// ```ignore +/// let mut data = Atom::builder() +/// .header(AtomHeader::new(*TKHD)) +/// .data(AtomData::TrackHeader(TrackHeaderAtom::default())) +/// .build(); +/// let _: &mut TrackHeaderAtom = unwrap_atom_data!( +/// AtomRefMut(&mut data), +/// AtomData::TrackHeader, +/// ); +/// ``` +macro_rules! unwrap_atom_data { + ($ref:expr, $variant:path $(,)?) => {{ + let atom = $ref.0; + if let Some($variant(data)) = &mut atom.data { + data + } else { + unreachable!( + "invalid {} atom: data is None or the wrong variant", + atom.header.atom_type, + ) + } + }}; +} +pub(crate) use unwrap_atom_data; + +#[derive(Debug, Clone, Copy)] +/// Wraps a shared [`Atom`] reference in an [`Option`] and provides methods for traversing it. +pub struct AtomRef<'a>(pub Option<&'a Atom>); + +impl<'a> AtomRef<'a> { + pub fn inner(&self) -> Option<&'a Atom> { + self.0 + } + + pub fn find_child(&self, typ: FourCC) -> Option<&'a Atom> { + self.children().find(|atom| atom.header.atom_type == typ) + } + + pub fn children(&self) -> crate::atom::iter::AtomIter<'a> { + crate::atom::iter::AtomIter::from_atom(self.0) + } + + pub fn child_position(&self, typ: FourCC) -> Option { + self.children() + .position(|atom| atom.header.atom_type == typ) + } + + pub fn child_rposition(&self, typ: FourCC) -> Option { + self.children() + .rposition(|atom| atom.header.atom_type == typ) + } +} + +#[derive(Debug)] +/// Wraps a mutable reference to an [`Atom`] and provides methods for manipulating and traversing it. +pub struct AtomRefMut<'a>(pub &'a mut Atom); + +impl<'a> AtomRefMut<'a> { + pub fn as_ref(&self) -> AtomRef<'_> { + AtomRef(Some(self.0)) + } + + pub fn into_ref(self) -> AtomRef<'a> { + AtomRef(Some(self.0)) + } + + pub fn atom_mut(&mut self) -> &'_ mut Atom { + self.0 + } + + pub fn get_child(&mut self, index: usize) -> AtomRefMut<'_> { + AtomRefMut(&mut self.0.children[index]) + } + + pub fn into_child(self, typ: FourCC) -> Option<&'a mut Atom> { + self.into_children() + .find(|atom| atom.header.atom_type == typ) + } + + pub fn children(&mut self) -> impl Iterator { + self.0.children.iter_mut() + } + + pub fn into_children(self) -> impl Iterator { + crate::atom::iter::AtomIterMut::from_atom(self.0) + } + + pub fn insert_child(&mut self, index: usize, child: Atom) -> AtomRefMut<'_> { + self.0.children.insert(index, child); + self.get_child(index) + } +} + +#[bon] +impl<'a> AtomRefMut<'a> { + #[builder] + pub fn find_or_insert_child( + &mut self, + #[builder(start_fn)] atom_type: FourCC, + #[builder(default = Vec::new())] insert_before: Vec, + #[builder(default = Vec::new())] insert_after: Vec, + insert_index: Option, + insert_data: Option, + ) -> AtomRefMut<'_> { + if let Some(index) = self.as_ref().child_position(atom_type) { + self.get_child(index) + } else { + let index = insert_index.unwrap_or_else(|| { + self.get_insert_position() + .before(insert_before) + .after(insert_after) + .call() + }); + self.insert_child( + index, + Atom::builder() + .header(AtomHeader::new(*atom_type)) + .maybe_data(insert_data) + .build(), + ) + } + } + + #[builder] + pub fn get_insert_position( + &self, + #[builder(default = Vec::new())] before: Vec, + #[builder(default = Vec::new())] after: Vec, + ) -> usize { + before + .into_iter() + .find_map(|typ| self.as_ref().child_rposition(typ)) + .or_else(|| { + after + .into_iter() + .find_map(|typ| self.as_ref().child_position(typ)) + .map(|i| i + 1) + }) + .unwrap_or_default() + } +} diff --git a/src/atom/container.rs b/src/atom/container.rs new file mode 100644 index 0000000..c8295bf --- /dev/null +++ b/src/atom/container.rs @@ -0,0 +1,54 @@ +/*! + * Atoms with children. + */ + +pub mod edts; +pub mod gmhd; +pub mod mdia; +pub mod meta; +pub mod minf; +pub mod moov; +pub mod stbl; +pub mod trak; +pub mod udta; + +pub use edts::*; +pub use gmhd::*; +pub use mdia::*; +pub use meta::*; +pub use minf::*; +pub use moov::*; +pub use stbl::*; +pub use trak::*; +pub use udta::*; + +use crate::FourCC; + +pub const MFRA: FourCC = FourCC::new(b"mfra"); +pub const DINF: FourCC = FourCC::new(b"dinf"); +pub const MOOF: FourCC = FourCC::new(b"moof"); +pub const TRAF: FourCC = FourCC::new(b"traf"); +pub const SINF: FourCC = FourCC::new(b"sinf"); +pub const SCHI: FourCC = FourCC::new(b"schi"); + +/// Determines whether a given atom type (fourcc) should be treated as a container for other atoms. +pub fn is_container_atom(atom_type: FourCC) -> bool { + // Common container types in MP4 + matches!( + atom_type, + MOOV | MFRA + | UDTA + | TRAK + | EDTS + | MDIA + | MINF + | GMHD + | DINF + | STBL + | MOOF + | TRAF + | SINF + | SCHI + | META + ) +} diff --git a/src/atom/container/edts.rs b/src/atom/container/edts.rs new file mode 100644 index 0000000..2517b4a --- /dev/null +++ b/src/atom/container/edts.rs @@ -0,0 +1,51 @@ +use std::fmt; + +use crate::{ + atom::{ + atom_ref::{unwrap_atom_data, AtomRef, AtomRefMut}, + elst::ELST, + EditListAtom, + }, + Atom, AtomData, FourCC, +}; + +pub const EDTS: FourCC = FourCC::new(b"edts"); + +#[derive(Clone, Copy)] +pub struct EdtsAtomRef<'a>(pub(crate) AtomRef<'a>); + +impl fmt::Debug for EdtsAtomRef<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EdtsAtomRef").finish() + } +} + +impl<'a> EdtsAtomRef<'a> { + pub fn children(&self) -> impl Iterator { + self.0.children() + } + + pub fn edit_list(&self) -> Option<&'a EditListAtom> { + let atom = self.0.find_child(ELST)?; + match atom.data.as_ref()? { + AtomData::EditList(data) => Some(data), + _ => None, + } + } +} + +#[derive(Debug)] +pub struct EdtsAtomRefMut<'a>(pub(crate) AtomRefMut<'a>); + +impl EdtsAtomRefMut<'_> { + /// Finds or creates the ELST atom + pub fn edit_list(&mut self) -> &mut EditListAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(ELST) + .insert_data(AtomData::EditList(EditListAtom::default())) + .call(), + AtomData::EditList, + ) + } +} diff --git a/src/atom/container/gmhd.rs b/src/atom/container/gmhd.rs new file mode 100644 index 0000000..c0fdfa1 --- /dev/null +++ b/src/atom/container/gmhd.rs @@ -0,0 +1,38 @@ +use crate::{ + atom::atom_ref::{AtomRef, AtomRefMut}, + Atom, FourCC, +}; + +pub const GMHD: FourCC = FourCC::new(b"gmhd"); + +#[derive(Debug, Clone, Copy)] +pub struct GmhdAtomRef<'a>(pub(crate) AtomRef<'a>); + +impl<'a> GmhdAtomRef<'a> { + pub fn children(&self) -> impl Iterator { + self.0.children() + } + + // TODO: gmin + // TODO: text +} + +#[derive(Debug)] +pub struct GmhdAtomRefMut<'a>(pub(crate) AtomRefMut<'a>); + +impl<'a> GmhdAtomRefMut<'a> { + pub fn as_ref(&self) -> GmhdAtomRef<'_> { + GmhdAtomRef(self.0.as_ref()) + } + + pub fn into_ref(self) -> GmhdAtomRef<'a> { + GmhdAtomRef(self.0.into_ref()) + } + + pub fn into_children(self) -> impl Iterator { + self.0.into_children() + } + + // TODO: gmin + // TODO: text +} diff --git a/src/atom/container/mdia.rs b/src/atom/container/mdia.rs new file mode 100644 index 0000000..2ebd0a3 --- /dev/null +++ b/src/atom/container/mdia.rs @@ -0,0 +1,103 @@ +use crate::{ + atom::{ + atom_ref::{unwrap_atom_data, AtomRef, AtomRefMut}, + hdlr::HDLR, + mdhd::MDHD, + HandlerReferenceAtom, MediaHeaderAtom, MinfAtomRef, MinfAtomRefMut, MINF, + }, + Atom, AtomData, FourCC, +}; + +pub const MDIA: FourCC = FourCC::new(b"mdia"); + +#[derive(Debug, Clone, Copy)] +pub struct MdiaAtomRef<'a>(pub(crate) AtomRef<'a>); + +impl<'a> MdiaAtomRef<'a> { + pub fn children(&self) -> impl Iterator { + self.0.children() + } + + /// Finds the MDHD atom + pub fn header(&self) -> Option<&'a MediaHeaderAtom> { + let atom = self.0.find_child(MDHD)?; + match atom.data.as_ref()? { + AtomData::MediaHeader(data) => Some(data), + _ => None, + } + } + + /// Finds the HDLR atom + pub fn handler_reference(&self) -> Option<&'a HandlerReferenceAtom> { + let atom = self.0.find_child(HDLR)?; + match atom.data.as_ref()? { + AtomData::HandlerReference(data) => Some(data), + _ => None, + } + } + + /// Finds the MINF atom + pub fn media_information(&self) -> MinfAtomRef<'a> { + let atom = self.0.find_child(MINF); + MinfAtomRef(AtomRef(atom)) + } +} + +#[derive(Debug)] +pub struct MdiaAtomRefMut<'a>(pub(crate) AtomRefMut<'a>); + +impl<'a> MdiaAtomRefMut<'a> { + pub fn as_ref(&self) -> MdiaAtomRef<'_> { + MdiaAtomRef(self.0.as_ref()) + } + + pub fn into_ref(self) -> MdiaAtomRef<'a> { + MdiaAtomRef(self.0.into_ref()) + } + + pub fn children(&mut self) -> impl Iterator { + self.0.children() + } + + pub fn into_children(self) -> impl Iterator { + self.0.into_children() + } + + /// Finds or inserts the MDHD atom + pub fn header(&mut self) -> &mut MediaHeaderAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(MDHD) + .insert_data(AtomData::MediaHeader(MediaHeaderAtom::default())) + .call(), + AtomData::MediaHeader, + ) + } + + /// Finds or inserts the HDLR atom + pub fn handler_reference(&mut self) -> &mut HandlerReferenceAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(HDLR) + .insert_data(AtomData::HandlerReference(HandlerReferenceAtom::default())) + .call(), + AtomData::HandlerReference, + ) + } + + /// Finds or inserts the MINF atom + pub fn media_information(&mut self) -> MinfAtomRefMut<'_> { + MinfAtomRefMut( + self.0 + .find_or_insert_child(MINF) + .insert_after(vec![HDLR, MDHD]) + .call(), + ) + } + + /// Finds the MINF atom + pub fn into_media_information(self) -> Option> { + let atom = self.0.into_child(MINF)?; + Some(MinfAtomRefMut(AtomRefMut(atom))) + } +} diff --git a/src/atom/container/meta.rs b/src/atom/container/meta.rs new file mode 100644 index 0000000..69307b7 --- /dev/null +++ b/src/atom/container/meta.rs @@ -0,0 +1,144 @@ +use std::io::Read; + +use crate::FourCC; + +pub const META: FourCC = FourCC::new(b"meta"); + +pub const META_VERSION_FLAGS_SIZE: usize = 4; + +#[derive(Debug, Clone, PartialEq)] +pub struct MetaHeader { + pub version: u8, + pub flags: [u8; 3], +} + +impl MetaHeader { + /// Parse the META atom's 4-byte header from a byte slice + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < 4 { + return Err(ParseMetaError::InsufficientData); + } + + Ok(MetaHeader { + version: bytes[0], + flags: [bytes[1], bytes[2], bytes[3]], + }) + } + + /// Parse the META atom's 4-byte header from a reader + pub fn from_reader(reader: &mut R) -> Result { + let mut header_bytes = [0u8; 4]; + reader + .read_exact(&mut header_bytes) + .map_err(|_| ParseMetaError::ReadError)?; + + Ok(MetaHeader { + version: header_bytes[0], + flags: [header_bytes[1], header_bytes[2], header_bytes[3]], + }) + } + + /// Get flags as a single u32 value (big-endian) + pub fn flags_as_u32(&self) -> u32 { + u32::from_be_bytes([0, self.flags[0], self.flags[1], self.flags[2]]) + } + + /// Check if this is a valid META header (version should be 0 for Apple compatibility) + pub fn is_valid_for_apple(&self) -> bool { + self.version == 0 && self.flags == [0, 0, 0] + } + + /// Convert back to 4 bytes + pub fn to_bytes(&self) -> [u8; 4] { + [self.version, self.flags[0], self.flags[1], self.flags[2]] + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ParseMetaError { + InsufficientData, + ReadError, +} + +impl std::fmt::Display for ParseMetaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseMetaError::InsufficientData => { + write!(f, "Insufficient data to parse META header") + } + ParseMetaError::ReadError => write!(f, "Failed to read META header data"), + } + } +} + +impl std::error::Error for ParseMetaError {} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn test_parse_valid_apple_meta_header() { + let bytes = [0x00, 0x00, 0x00, 0x00]; // Version 0, flags all 0 + let header = MetaHeader::from_bytes(&bytes).unwrap(); + + assert_eq!(header.version, 0); + assert_eq!(header.flags, [0, 0, 0]); + assert!(header.is_valid_for_apple()); + assert_eq!(header.flags_as_u32(), 0); + } + + #[test] + fn test_parse_non_apple_meta_header() { + let bytes = [0x01, 0x00, 0x00, 0x01]; // Version 1, some flags set + let header = MetaHeader::from_bytes(&bytes).unwrap(); + + assert_eq!(header.version, 1); + assert_eq!(header.flags, [0, 0, 1]); + assert!(!header.is_valid_for_apple()); + assert_eq!(header.flags_as_u32(), 1); + } + + #[test] + fn test_parse_from_reader() { + let data = [0x00, 0x00, 0x00, 0x00]; + let mut cursor = Cursor::new(&data); + let header = MetaHeader::from_reader(&mut cursor).unwrap(); + + assert_eq!(header.version, 0); + assert_eq!(header.flags, [0, 0, 0]); + } + + #[test] + fn test_insufficient_data() { + let bytes = [0x00, 0x00]; // Only 2 bytes + let result = MetaHeader::from_bytes(&bytes); + + assert!(matches!(result, Err(ParseMetaError::InsufficientData))); + } + + #[test] + fn test_round_trip() { + let original = MetaHeader { + version: 0, + flags: [0x12, 0x34, 0x56], + }; + + let bytes = original.to_bytes(); + let parsed = MetaHeader::from_bytes(&bytes).unwrap(); + + assert_eq!(original, parsed); + } + + #[test] + fn test_flags_as_u32() { + let header = MetaHeader { + version: 0, + flags: [0x01, 0x02, 0x03], + }; + + assert_eq!(header.flags_as_u32(), 0x010203); + } +} diff --git a/src/atom/container/minf.rs b/src/atom/container/minf.rs new file mode 100644 index 0000000..f2b6acb --- /dev/null +++ b/src/atom/container/minf.rs @@ -0,0 +1,68 @@ +use crate::{ + atom::{ + atom_ref::{AtomRef, AtomRefMut}, + smhd::SMHD, + GmhdAtomRef, GmhdAtomRefMut, StblAtomRef, StblAtomRefMut, DINF, GMHD, STBL, + }, + Atom, FourCC, +}; + +pub const MINF: FourCC = FourCC::new(b"minf"); + +#[derive(Debug, Clone, Copy)] +pub struct MinfAtomRef<'a>(pub(crate) AtomRef<'a>); + +impl<'a> MinfAtomRef<'a> { + pub fn children(&self) -> impl Iterator { + self.0.children() + } + + pub fn header(&self) -> GmhdAtomRef<'a> { + let atom = self.0.find_child(GMHD); + GmhdAtomRef(AtomRef(atom)) + } + + /// Finds the STBL atom + pub fn sample_table(&self) -> StblAtomRef<'a> { + let atom = self.0.find_child(STBL); + StblAtomRef(AtomRef(atom)) + } +} + +#[derive(Debug)] +pub struct MinfAtomRefMut<'a>(pub(crate) AtomRefMut<'a>); + +impl<'a> MinfAtomRefMut<'a> { + pub fn as_ref(&self) -> MinfAtomRef<'_> { + MinfAtomRef(self.0.as_ref()) + } + + pub fn into_ref(self) -> MinfAtomRef<'a> { + MinfAtomRef(self.0.into_ref()) + } + + pub fn into_children(self) -> impl Iterator { + self.0.into_children() + } + + /// Finds or inserts the GMHD atom + pub fn header(&mut self) -> GmhdAtomRefMut<'_> { + GmhdAtomRefMut(self.0.find_or_insert_child(GMHD).insert_index(0).call()) + } + + /// Finds or inserts the STBL atom + pub fn sample_table(&mut self) -> StblAtomRefMut<'_> { + StblAtomRefMut( + self.0 + .find_or_insert_child(STBL) + .insert_after(vec![DINF, SMHD]) + .call(), + ) + } + + /// Finds the STBL atom + pub fn into_sample_table(self) -> Option> { + let atom = self.0.into_child(STBL)?; + Some(StblAtomRefMut(AtomRefMut(atom))) + } +} diff --git a/src/atom/container/moov.rs b/src/atom/container/moov.rs new file mode 100644 index 0000000..780ccf3 --- /dev/null +++ b/src/atom/container/moov.rs @@ -0,0 +1,733 @@ +use std::{fmt::Debug, ops::RangeBounds, time::Duration}; + +use bon::bon; + +use crate::{ + atom::{ + atom_ref::{self, unwrap_atom_data}, + hdlr::HandlerType, + mdhd::MDHD, + mvhd::MVHD, + AtomHeader, MovieHeaderAtom, TrakAtomRef, TrakAtomRefMut, UserDataAtomRefMut, TRAK, UDTA, + }, + Atom, AtomData, FourCC, +}; + +pub const MOOV: FourCC = FourCC::new(b"moov"); + +#[derive(Debug, Clone, Copy)] +pub struct MoovAtomRef<'a>(pub(crate) atom_ref::AtomRef<'a>); + +impl<'a> MoovAtomRef<'a> { + pub fn children(&self) -> impl Iterator { + self.0.children() + } + + pub fn header(&self) -> Option<&'a MovieHeaderAtom> { + let atom = self.children().find(|a| a.header.atom_type == MVHD)?; + match atom.data.as_ref()? { + AtomData::MovieHeader(data) => Some(data), + _ => None, + } + } + + /// Iterate through TRAK atoms + pub fn into_tracks_iter(self) -> impl Iterator> { + self.children() + .filter(|a| a.header.atom_type == TRAK) + .map(TrakAtomRef::new) + } + + /// Iterate through TRAK atoms with handler type Audio + pub fn into_audio_track_iter(self) -> impl Iterator> { + self.into_tracks_iter().filter(|trak| { + matches!( + trak.media() + .handler_reference() + .map(|hdlr| &hdlr.handler_type), + Some(HandlerType::Audio) + ) + }) + } + + /// Calculates the next track id based on existing track ids. + /// + /// The returned id will be the greater of `len(tracks)+1` or `max(tracks(id))+1`. + pub fn next_track_id(&self) -> u32 { + self.children() + .filter(|a| a.header.atom_type == TRAK) + .map(TrakAtomRef::new) + .fold(1, |id, trak| { + (trak.track_id().unwrap_or_default() + 1).max(id) + }) + } +} + +#[derive(Debug)] +pub struct MoovAtomRefMut<'a>(pub(crate) atom_ref::AtomRefMut<'a>); + +impl<'a> MoovAtomRefMut<'a> { + pub fn as_ref(&self) -> MoovAtomRef<'_> { + MoovAtomRef(self.0.as_ref()) + } + + pub fn into_ref(self) -> MoovAtomRef<'a> { + MoovAtomRef(self.0.into_ref()) + } + + pub fn children(&mut self) -> impl Iterator { + self.0.children() + } + + /// Finds or inserts MVHD atom + pub fn header(&mut self) -> &'_ mut MovieHeaderAtom { + unwrap_atom_data!( + self.0.find_or_insert_child(MVHD).call(), + AtomData::MovieHeader, + ) + } + + /// Finds or inserts UDTA atom + pub fn user_data(&mut self) -> UserDataAtomRefMut<'_> { + UserDataAtomRefMut( + self.0 + .find_or_insert_child(UDTA) + .insert_after(vec![TRAK, MVHD]) + .call(), + ) + } + + pub fn tracks(&mut self) -> impl Iterator> { + self.0 + .children() + .filter(|a| a.header.atom_type == TRAK) + .map(TrakAtomRefMut::new) + } + + /// Iterate through TRAK atoms with handler type Audio + pub fn audio_tracks(&mut self) -> impl Iterator> { + self.tracks().filter(|trak| { + matches!( + trak.as_ref() + .media() + .handler_reference() + .map(|hdlr| &hdlr.handler_type), + Some(HandlerType::Audio) + ) + }) + } + + /// Retains only the TRAK atoms specified by the predicate + pub fn tracks_retain

(&mut self, mut pred: P) -> &mut Self + where + P: FnMut(TrakAtomRef) -> bool, + { + self.0 + .0 + .children + .retain(|a| a.header.atom_type != TRAK || pred(TrakAtomRef::new(a))); + self + } +} + +#[cfg(feature = "experimental-trim")] +#[bon] +impl<'a> MoovAtomRefMut<'a> { + /// Trim duration from tracks. + /// + /// NOTE: This is meant to be applied to the input metadata. + /// + /// See also [`Self::retain_duration`]. + #[builder(finish_fn(name = "trim"), builder_type = TrimDuration)] + pub fn trim_duration( + &mut self, + from_start: Option, + from_end: Option, + ) -> &mut Self { + use std::ops::Bound; + let start_duration = from_start.map(|d| (Bound::Unbounded, Bound::Included(d))); + let end_duration = from_end.map(|d| { + let d = self.header().duration().saturating_sub(d); + (Bound::Included(d), Bound::Unbounded) + }); + let trim_ranges = vec![start_duration, end_duration] + .into_iter() + .flatten() + .collect::>(); + self.trim_duration_ranges(&trim_ranges) + } + + /// Retains given duration range, trimming everything before and after. + /// + /// NOTE: This is meant to be applied to the input metadata. + /// + /// See also [`Self::trim_duration`]. + #[builder(finish_fn(name = "retain"), builder_type = RetainDuration)] + pub fn retain_duration( + &mut self, + from_offset: Option, + duration: Duration, + ) -> &mut Self { + use std::ops::Bound; + let trim_ranges = vec![ + ( + Bound::Unbounded, + Bound::Excluded(from_offset.unwrap_or_default()), + ), + ( + Bound::Included(from_offset.unwrap_or_default() + duration), + Bound::Unbounded, + ), + ]; + self.trim_duration_ranges(&trim_ranges) + } + + fn trim_duration_ranges(&mut self, trim_ranges: &[R]) -> &mut Self + where + R: RangeBounds + Clone + Debug, + { + let movie_timescale = u64::from(self.header().timescale); + let mut remaining_audio_duration = None; + let remaining_duration = self + .tracks() + .map(|mut trak| { + let handler_type = trak + .as_ref() + .media() + .handler_reference() + .map(|hdlr| hdlr.handler_type.clone()); + let remaining_duration = trak.trim_duration(movie_timescale, trim_ranges); + if let Some(HandlerType::Audio) = handler_type { + if remaining_audio_duration.is_none() { + remaining_audio_duration = Some(remaining_duration); + } + } + remaining_duration + }) + .max(); + // trust the first audio track's reported duration or fall back to the longest reported duration + if let Some(remaining_duration) = remaining_audio_duration.or(remaining_duration) { + self.header().update_duration(|_| remaining_duration); + } + self + } +} + +#[cfg(feature = "experimental-trim")] +#[bon] +impl<'a, 'b, S: trim_duration::State> TrimDuration<'a, 'b, S> { + #[builder(finish_fn(name = "trim"), builder_type = TrimDurationRanges)] + fn ranges( + self, + #[builder(start_fn)] ranges: impl IntoIterator, + ) -> &'b mut MoovAtomRefMut<'a> + where + R: RangeBounds + Clone + Debug, + S::FromEnd: trim_duration::IsUnset, + S::FromStart: trim_duration::IsUnset, + { + self.self_receiver + .trim_duration_ranges(&ranges.into_iter().collect::>()) + } +} + +#[bon] +impl<'a> MoovAtomRefMut<'a> { + /// Adds trak atom to moov + #[builder] + pub fn add_track( + &mut self, + #[builder(default = Vec::new())] children: Vec, + ) -> TrakAtomRefMut<'_> { + let trak = Atom::builder() + .header(AtomHeader::new(*TRAK)) + .children(children) + .build(); + let index = self.0.get_insert_position().after(vec![TRAK, MDHD]).call(); + TrakAtomRefMut(self.0.insert_child(index, trak)) + } +} + +#[cfg(feature = "experimental-trim")] +#[cfg(test)] +mod trim_tests { + use std::ops::Bound; + use std::time::Duration; + + use bon::Builder; + + use crate::{ + atom::{ + container::MOOV, + ftyp::{FileTypeAtom, FTYP}, + hdlr::{HandlerReferenceAtom, HandlerType}, + mvhd::{MovieHeaderAtom, MVHD}, + stsc::SampleToChunkEntry, + trak::trim_tests::{ + create_test_track, create_test_track_builder, CreateTestTrackBuilder, + }, + util::scaled_duration, + Atom, AtomHeader, + }, + parser::Metadata, + FourCC, + }; + + #[bon::builder(finish_fn(name = "build"))] + fn create_test_metadata( + #[builder(field)] tracks: Vec, + #[builder(getter)] movie_timescale: u32, + #[builder(getter)] duration: Duration, + ) -> Metadata { + let atoms = vec![ + Atom::builder() + .header(AtomHeader::new(*FTYP)) + .data( + FileTypeAtom::builder() + .major_brand(*b"isom") + .minor_version(512) + .compatible_brands( + vec![*b"isom", *b"iso2", *b"mp41"] + .into_iter() + .map(FourCC::from) + .collect::>(), + ) + .build(), + ) + .build(), + Atom::builder() + .header(AtomHeader::new(*MOOV)) + .children(Vec::from_iter( + std::iter::once( + // Movie header (mvhd) + Atom::builder() + .header(AtomHeader::new(*MVHD)) + .data( + MovieHeaderAtom::builder() + .timescale(movie_timescale) + .duration(scaled_duration(duration, movie_timescale as u64)) + .next_track_id(2) + .build(), + ) + .build(), + ) + .chain(tracks.into_iter()), + )) + .build(), + ]; + + Metadata::new(atoms.into()) + } + + impl CreateTestMetadataBuilder + where + S: create_test_metadata_builder::State, + S::MovieTimescale: create_test_metadata_builder::IsSet, + S::Duration: create_test_metadata_builder::IsSet, + { + fn track(mut self, track: CreateTestTrackBuilder) -> Self + where + CTBS: create_test_track_builder::State, + CTBS::MovieTimescale: create_test_track_builder::IsUnset, + CTBS::MediaTimescale: create_test_track_builder::IsSet, + CTBS::Duration: create_test_track_builder::IsUnset, + { + self.tracks.push( + track + .movie_timescale(*self.get_movie_timescale()) + .duration(self.get_duration().clone()) + .build(), + ); + self + } + } + + fn test_moov_trim_duration(mut metadata: Metadata, test_case: TrimDurationTestCase) { + let movie_timescale = test_case.movie_timescale; + let media_timescale = test_case.media_timescale; + + // Perform the trim operation with the given trim ranges + metadata + .moov_mut() + .trim_duration() + .ranges( + test_case + .ranges + .into_iter() + .map(|r| (r.start_bound, r.end_bound)) + .collect::>(), + ) + .trim(); + + // Verify movie header duration was updated + let new_movie_duration = metadata.moov().header().map(|h| h.duration).unwrap_or(0); + let expected_movie_duration = scaled_duration( + test_case.expected_remaining_duration, + movie_timescale as u64, + ); + assert_eq!( + new_movie_duration, expected_movie_duration, + "Movie duration should match expected", + ); + + // Verify track header duration was updated + let new_track_duration = metadata + .moov() + .into_tracks_iter() + .next() + .and_then(|t| t.header().map(|h| h.duration)) + .unwrap_or(0); + let expected_track_duration = scaled_duration( + test_case.expected_remaining_duration, + movie_timescale as u64, + ); + assert_eq!( + new_track_duration, expected_track_duration, + "Track duration should match expected", + ); + + // Verify media header duration was updated + let new_media_duration = metadata + .moov() + .into_tracks_iter() + .next() + .map(|t| t.media().header().map(|h| h.duration).unwrap_or(0)) + .unwrap_or(0); + let expected_media_duration = scaled_duration( + test_case.expected_remaining_duration, + media_timescale as u64, + ); + assert_eq!( + new_media_duration, expected_media_duration, + "Media duration should match expected", + ); + + // Verify sample table structure is still valid + let track = metadata.moov().into_tracks_iter().next().unwrap(); + let stbl = track.media().media_information().sample_table(); + + // Validate that all required sample table atoms exist + let stts = stbl + .time_to_sample() + .expect("Time-to-sample atom should exist"); + let stsc = stbl + .sample_to_chunk() + .expect("Sample-to-chunk atom should exist"); + let stsz = stbl.sample_size().expect("Sample-size atom should exist"); + let stco = stbl.chunk_offset().expect("Chunk-offset atom should exist"); + + // Calculate total samples from sample sizes + let total_samples = stsz.sample_count() as u32; + if test_case.expected_remaining_duration != Duration::ZERO { + assert!(total_samples > 0, "Sample table should have samples",); + } + + // Validate time-to-sample consistency + let stts_total_samples: u32 = stts.entries.iter().map(|entry| entry.sample_count).sum(); + assert_eq!( + stts_total_samples, total_samples, + "Time-to-sample total samples should match sample size count", + ); + + // Validate sample-to-chunk references + let chunk_count = stco.chunk_count() as u32; + assert!(chunk_count > 0, "Should have at least one chunk",); + + // Verify all chunk references in stsc are valid + for entry in stsc.entries.iter() { + assert!( + entry.first_chunk >= 1 && entry.first_chunk <= chunk_count, + "Sample-to-chunk first_chunk {} should be between 1 and {}", + entry.first_chunk, + chunk_count, + ); + assert!( + entry.samples_per_chunk > 0, + "Sample-to-chunk samples_per_chunk should be > 0", + ); + } + + // Verify expected duration consistency with time-to-sample + let total_duration: u64 = stts + .entries + .iter() + .map(|entry| entry.sample_count as u64 * entry.sample_duration as u64) + .sum(); + let expected_duration_scaled = scaled_duration( + test_case.expected_remaining_duration, + media_timescale as u64, + ); + + assert_eq!( + total_duration, expected_duration_scaled, + "Sample table total duration should match the expected duration", + ); + } + + #[derive(Debug, Builder)] + struct TrimDurationRange { + start_bound: Bound, + end_bound: Bound, + } + + #[derive(Debug)] + struct TrimDurationTestCase { + movie_timescale: u32, + media_timescale: u32, + original_duration: Duration, + ranges: Vec, + expected_remaining_duration: Duration, + } + + #[bon::bon] + impl TrimDurationTestCase { + #[builder] + pub fn new( + #[builder(field)] ranges: Vec, + #[builder(default = 1_000)] movie_timescale: u32, + #[builder(default = 10_000)] media_timescale: u32, + original_duration: Duration, + expected_remaining_duration: Duration, + ) -> Self { + assert!( + ranges.len() > 0, + "test case must include at least one range" + ); + + Self { + movie_timescale, + media_timescale, + original_duration, + ranges, + expected_remaining_duration, + } + } + } + + impl TrimDurationTestCaseBuilder + where + S: trim_duration_test_case_builder::State, + { + fn range(mut self, range: TrimDurationRange) -> Self { + self.ranges.push(range); + self + } + } + + macro_rules! test_moov_trim_duration { + ($( + $name:ident { + $( + @tracks( $($track:expr),*, ), + )? + $($field:ident: $value:expr),+$(,)? + } $(,)? + )* $(,)?) => { + $( + test_moov_trim_duration!(@single $name { + $( + @tracks( $($track),*, ), + )? + $($field: $value),+ + }); + )* + }; + + (@single $name:ident { + $($field:ident: $value:expr),+$(,)? + } $(,)?) => { + test_moov_trim_duration!(@single $name { + @tracks( + |media_timescale| create_test_track().media_timescale(media_timescale), + ), + $($field: $value),+ + }); + }; + + (@single $name:ident { + @tracks($($track:expr),+,), + $($field:ident: $value:expr),+ + } $(,)?) => { + test_moov_trim_duration!(@fn_def $name { + @tracks($($track),+), + $($field: $value),+ + }); + }; + + (@fn_def $name:ident { + @tracks($($track:expr),+), + $($field:ident: $value:expr),+$(,)? + } $(,)?) => { + #[test] + fn $name() { + let movie_timescale = 1_000; + let media_timescale = 10_000; + + let test_case = TrimDurationTestCase::builder(). + $($field($value)).+. + build(); + + // Create fresh metadata for each test case + let metadata = create_test_metadata() + .movie_timescale(movie_timescale) + .duration(test_case.original_duration). + $( + track( + ($track)(media_timescale) + ) + ).+. + build(); + + test_moov_trim_duration(metadata, test_case); + } + }; + } + + test_moov_trim_duration!( + trim_start_2_seconds { + original_duration: Duration::from_secs(10), + range: TrimDurationRange::builder() + .start_bound(Bound::Included(Duration::ZERO)) + .end_bound(Bound::Included(Duration::from_secs(2))) + .build(), + expected_remaining_duration: Duration::from_secs(8), + } + trim_end_2_seconds { + original_duration: Duration::from_secs(10), + range: TrimDurationRange::builder() + .start_bound(Bound::Included(Duration::from_secs(8))) + .end_bound(Bound::Included(Duration::from_secs(10))) + .build(), + expected_remaining_duration: Duration::from_secs(8), + } + trim_middle_2_seconds { + original_duration: Duration::from_secs(10), + range: TrimDurationRange::builder() + .start_bound(Bound::Included(Duration::from_secs(4))) + .end_bound(Bound::Included(Duration::from_secs(6))) + .build(), + expected_remaining_duration: Duration::from_secs(8), + } + trim_middle_included_start_2_seconds { + original_duration: Duration::from_secs(10), + range: TrimDurationRange::builder() + .start_bound(Bound::Included(Duration::from_secs(2))) + .end_bound(Bound::Included(Duration::from_secs(4))) + .build(), + expected_remaining_duration: Duration::from_secs(8), + } + trim_middle_excluded_start_2_seconds { + original_duration: Duration::from_millis(10_000), + range: TrimDurationRange::builder() + .start_bound(Bound::Excluded(Duration::from_millis(1_999))) + .end_bound(Bound::Included(Duration::from_millis(4_000))) + .build(), + expected_remaining_duration: Duration::from_millis(8_000), + } + trim_middle_excluded_end_2_seconds { + original_duration: Duration::from_secs(10), + range: TrimDurationRange::builder() + .start_bound(Bound::Included(Duration::from_secs(1))) + .end_bound(Bound::Excluded(Duration::from_secs(3))) + .build(), + expected_remaining_duration: Duration::from_secs(8), + } + trim_start_unbounded_5_seconds { + original_duration: Duration::from_secs(10), + range: TrimDurationRange::builder() + .start_bound(Bound::Unbounded) + .end_bound(Bound::Included(Duration::from_secs(5))) + .build(), + expected_remaining_duration: Duration::from_secs(5), + } + trim_end_unbounded_6_seconds { + original_duration: Duration::from_secs(100), + range: TrimDurationRange::builder() + .start_bound(Bound::Included(Duration::from_secs(94))) + .end_bound(Bound::Unbounded) + .build(), + expected_remaining_duration: Duration::from_secs(94), + } + trim_start_and_end_20_seconds { + original_duration: Duration::from_secs(100), + range: TrimDurationRange::builder() + .start_bound(Bound::Unbounded) + .end_bound(Bound::Excluded(Duration::from_secs(20))) + .build(), + range: TrimDurationRange::builder() + .start_bound(Bound::Included(Duration::from_secs(80))) + .end_bound(Bound::Unbounded) + .build(), + expected_remaining_duration: Duration::from_secs(60), + } + trim_first_and_last_chunk { + @tracks( + |media_timescale| create_test_track().stsc_entries(vec![ + // 1 sample per second + SampleToChunkEntry::builder() + .first_chunk(1) + .samples_per_chunk(20) + .sample_description_index(1) + .build(), + SampleToChunkEntry::builder() + .first_chunk(2) + .samples_per_chunk(60) + .sample_description_index(2) + .build(), + SampleToChunkEntry::builder() + .first_chunk(3) + .samples_per_chunk(20) + .sample_description_index(3) + .build(), + ]).media_timescale(media_timescale), + ), + original_duration: Duration::from_secs(100), + range: TrimDurationRange::builder() + .start_bound(Bound::Unbounded) + .end_bound(Bound::Excluded(Duration::from_secs(20))) + .build(), + range: TrimDurationRange::builder() + .start_bound(Bound::Included(Duration::from_secs(80))) + .end_bound(Bound::Unbounded) + .build(), + expected_remaining_duration: Duration::from_secs(60), + } + trim_first_and_20s_multi_track { + @tracks( + |media_timescale| create_test_track().stsc_entries(vec![ + // 1 sample per second + SampleToChunkEntry::builder() + .first_chunk(1) + .samples_per_chunk(20) + .sample_description_index(1) + .build(), + SampleToChunkEntry::builder() + .first_chunk(2) + .samples_per_chunk(60) + .sample_description_index(2) + .build(), + SampleToChunkEntry::builder() + .first_chunk(3) + .samples_per_chunk(20) + .sample_description_index(3) + .build(), + ]).media_timescale(media_timescale), + |_| create_test_track().handler_reference( + HandlerReferenceAtom::builder() + .handler_type(HandlerType::Text).build(), + ).media_timescale(666_666), + ), + original_duration: Duration::from_secs(100), + range: TrimDurationRange::builder() + .start_bound(Bound::Unbounded) + .end_bound(Bound::Excluded(Duration::from_secs(20))) + .build(), + range: TrimDurationRange::builder() + .start_bound(Bound::Included(Duration::from_secs(80))) + .end_bound(Bound::Unbounded) + .build(), + expected_remaining_duration: Duration::from_secs(60), + } + // TODO: unbounded trim should return an error since that would erase all content + ); +} diff --git a/src/atom/container/stbl.rs b/src/atom/container/stbl.rs new file mode 100644 index 0000000..dc0ca0e --- /dev/null +++ b/src/atom/container/stbl.rs @@ -0,0 +1,148 @@ +use std::fmt::Debug; + +use crate::atom::atom_ref::{self, unwrap_atom_data}; +use crate::FourCC; +use crate::{ + atom::{ + stco_co64::{ChunkOffsetAtom, STCO}, + stsc::{SampleToChunkAtom, STSC}, + stsd::{SampleDescriptionTableAtom, STSD}, + stsz::{SampleSizeAtom, STSZ}, + stts::{TimeToSampleAtom, STTS}, + }, + Atom, AtomData, +}; + +pub const STBL: FourCC = FourCC::new(b"stbl"); + +#[derive(Debug)] +pub struct StblAtomRef<'a>(pub(crate) atom_ref::AtomRef<'a>); + +impl<'a> StblAtomRef<'a> { + pub fn children(&self) -> impl Iterator { + self.0.children() + } + + /// Finds the STSD atom + pub fn sample_description(&self) -> Option<&'a SampleDescriptionTableAtom> { + let atom = self.0.find_child(STSD)?; + match atom.data.as_ref()? { + AtomData::SampleDescriptionTable(data) => Some(data), + _ => None, + } + } + + /// Finds the STTS atom + pub fn time_to_sample(&self) -> Option<&'a TimeToSampleAtom> { + let atom = self.0.find_child(STTS)?; + match atom.data.as_ref()? { + AtomData::TimeToSample(data) => Some(data), + _ => None, + } + } + + /// Finds the STSC atom + pub fn sample_to_chunk(&self) -> Option<&'a SampleToChunkAtom> { + let atom = self.0.find_child(STSC)?; + match atom.data.as_ref()? { + AtomData::SampleToChunk(data) => Some(data), + _ => None, + } + } + + /// Finds the STSZ atom + pub fn sample_size(&self) -> Option<&'a SampleSizeAtom> { + let atom = self.0.find_child(STSZ)?; + match atom.data.as_ref()? { + AtomData::SampleSize(data) => Some(data), + _ => None, + } + } + + /// Finds the STCO atom + pub fn chunk_offset(&self) -> Option<&'a ChunkOffsetAtom> { + let atom = self.0.find_child(STCO)?; + match atom.data.as_ref()? { + AtomData::ChunkOffset(data) => Some(data), + _ => None, + } + } +} + +#[derive(Debug)] +pub struct StblAtomRefMut<'a>(pub(crate) atom_ref::AtomRefMut<'a>); + +impl<'a> StblAtomRefMut<'a> { + pub fn as_ref(&self) -> StblAtomRef<'_> { + StblAtomRef(self.0.as_ref()) + } + + pub fn into_ref(self) -> StblAtomRef<'a> { + StblAtomRef(self.0.into_ref()) + } + + pub fn into_children(self) -> impl Iterator { + self.0.into_children() + } + + /// Finds or inserts the STSD atom + pub fn sample_description(&mut self) -> &'_ mut SampleDescriptionTableAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(STSD) + .insert_data(AtomData::SampleDescriptionTable( + SampleDescriptionTableAtom::default(), + )) + .call(), + AtomData::SampleDescriptionTable, + ) + } + + /// Finds or inserts the STTS atom + pub fn time_to_sample(&mut self) -> &mut TimeToSampleAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(STTS) + .insert_after(vec![STTS, STSD]) + .insert_data(AtomData::TimeToSample(TimeToSampleAtom::default())) + .call(), + AtomData::TimeToSample, + ) + } + + /// Finds or inserts the STSC atom + pub fn sample_to_chunk(&mut self) -> &mut SampleToChunkAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(STSC) + .insert_after(vec![STTS, STSD]) + .insert_data(AtomData::SampleToChunk(SampleToChunkAtom::default())) + .call(), + AtomData::SampleToChunk, + ) + } + + /// Finds or inserts the STSZ atom + pub fn sample_size(&mut self) -> &mut SampleSizeAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(STSZ) + .insert_after(vec![STSC, STSD]) + .insert_data(AtomData::SampleSize(SampleSizeAtom::default())) + .call(), + AtomData::SampleSize, + ) + } + + /// Finds or inserts the STCO atom + pub fn chunk_offset(&mut self) -> &mut ChunkOffsetAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(STCO) + .insert_after(vec![STSZ, STSD]) + .insert_data(AtomData::ChunkOffset(ChunkOffsetAtom::default())) + .call(), + AtomData::ChunkOffset, + ) + } +} diff --git a/src/atom/container/trak.rs b/src/atom/container/trak.rs new file mode 100644 index 0000000..70d9fc2 --- /dev/null +++ b/src/atom/container/trak.rs @@ -0,0 +1,726 @@ +use std::{ + fmt::{self, Debug}, + ops::{Range, RangeBounds}, + time::Duration, +}; + +use crate::{ + atom::{ + atom_ref::{unwrap_atom_data, AtomRef, AtomRefMut}, + elst::EditEntry, + stsd::{ + AudioSampleEntry, BtrtExtension, DecoderSpecificInfo, EsdsExtension, SampleEntry, + SampleEntryData, SampleEntryType, StsdExtension, + }, + tkhd::TKHD, + tref::TREF, + util::{scaled_duration_range, unscaled_duration}, + EdtsAtomRef, EdtsAtomRefMut, MdiaAtomRef, MdiaAtomRefMut, TrackHeaderAtom, + TrackReferenceAtom, EDTS, MDIA, + }, + Atom, AtomData, FourCC, +}; + +pub const TRAK: FourCC = FourCC::new(b"trak"); + +#[derive(Clone, Copy)] +pub struct TrakAtomRef<'a>(AtomRef<'a>); + +impl fmt::Debug for TrakAtomRef<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TrakAtomRef") + .field("track_id", &self.header().unwrap().track_id) + .finish() + } +} + +impl<'a> TrakAtomRef<'a> { + pub(crate) fn new(atom: &'a Atom) -> Self { + Self(AtomRef(Some(atom))) + } + + pub fn children(&self) -> impl Iterator { + self.0.children() + } + + /// Finds the TKHD atom + pub fn header(&self) -> Option<&'a TrackHeaderAtom> { + let atom = self.0.find_child(TKHD)?; + match atom.data.as_ref()? { + AtomData::TrackHeader(data) => Some(data), + _ => None, + } + } + + pub fn edit_list_container(&self) -> EdtsAtomRef<'a> { + EdtsAtomRef(AtomRef(self.0.find_child(EDTS))) + } + + /// Finds the MDIA atom + pub fn media(&self) -> MdiaAtomRef<'a> { + MdiaAtomRef(AtomRef(self.0.find_child(MDIA))) + } + + pub fn track_id(&self) -> Option { + let tkhd = self.header()?; + Some(tkhd.track_id) + } + + /// Returns the sum of all sample sizes + pub fn size(&self) -> usize { + self.media() + .media_information() + .sample_table() + .sample_size() + .map_or(0, |s| { + if s.entry_sizes.is_empty() { + s.sample_size * s.sample_count + } else { + s.entry_sizes.iter().sum::() + } + }) as usize + } + + /// Calculates the track's bitrate + /// + /// Returns None if either stsz or mdhd atoms can't be found + pub fn bitrate(&self) -> Option { + let duration_secds = self + .media() + .header() + .map(|mdhd| (mdhd.duration as f64) / f64::from(mdhd.timescale))?; + + self.media() + .media_information() + .sample_table() + .sample_size() + .map(|s| { + let num_bits = s + .entry_sizes + .iter() + .map(|s| *s as usize) + .sum::() + .saturating_mul(8); + + let bitrate = (num_bits as f64) / duration_secds; + bitrate.round() as u32 + }) + } +} + +#[derive(Debug)] +pub struct TrakAtomRefMut<'a>(pub(crate) AtomRefMut<'a>); + +impl<'a> TrakAtomRefMut<'a> { + pub(crate) fn new(atom: &'a mut Atom) -> Self { + Self(AtomRefMut(atom)) + } + + pub fn as_ref(&self) -> TrakAtomRef<'_> { + TrakAtomRef(self.0.as_ref()) + } + + pub fn into_ref(self) -> TrakAtomRef<'a> { + TrakAtomRef(self.0.into_ref()) + } + + pub fn children(&mut self) -> impl Iterator { + self.0.children() + } + + /// Finds or inserts the TKHD atom + pub fn header(&mut self) -> &mut TrackHeaderAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(TKHD) + .insert_data(AtomData::TrackHeader(TrackHeaderAtom::default())) + .call(), + AtomData::TrackHeader, + ) + } + + /// Finds or creates the MDIA atom + pub fn media(&mut self) -> MdiaAtomRefMut<'_> { + MdiaAtomRefMut( + self.0 + .find_or_insert_child(MDIA) + .insert_after(vec![TREF, EDTS, TKHD]) + .call(), + ) + } + + /// Finds the MDIA atom + pub fn into_media(self) -> Option> { + let atom = self.0.into_child(MDIA)?; + Some(MdiaAtomRefMut(AtomRefMut(atom))) + } + + /// Finds or creates the EDTS atom + pub fn edit_list_container(&mut self) -> EdtsAtomRefMut<'_> { + EdtsAtomRefMut( + self.0 + .find_or_insert_child(EDTS) + .insert_after(vec![TREF, TKHD]) + .call(), + ) + } + + /// Finds or inserts the TREF atom + pub fn track_reference(&mut self) -> &mut TrackReferenceAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(TREF) + .insert_after(vec![TKHD]) + .insert_data(AtomData::TrackReference(TrackReferenceAtom::default())) + .call(), + AtomData::TrackReference, + ) + } + + /// Updates track metadata with the new audio bitrate + /// + /// Creates any missing atoms needed to do so + pub fn update_audio_bitrate(&mut self, bitrate: u32) { + let mut mdia = self.media(); + let mut minf = mdia.media_information(); + let mut stbl = minf.sample_table(); + let stsd = stbl.sample_description(); + + let entry = stsd.find_or_create_entry( + |entry| matches!(entry.data, SampleEntryData::Audio(_)), + || SampleEntry { + entry_type: SampleEntryType::Mp4a, + data_reference_index: 0, + data: SampleEntryData::Audio(AudioSampleEntry::default()), + }, + ); + + entry.entry_type = SampleEntryType::Mp4a; + + if let SampleEntryData::Audio(audio) = &mut entry.data { + let mut sample_frequency = None; + audio + .extensions + .retain(|ext| matches!(ext, StsdExtension::Esds(_))); + let esds = audio.find_or_create_extension( + |ext| matches!(ext, StsdExtension::Esds(_)), + || StsdExtension::Esds(EsdsExtension::default()), + ); + if let StsdExtension::Esds(esds) = esds { + let cfg = esds + .es_descriptor + .decoder_config_descriptor + .get_or_insert_default(); + cfg.avg_bitrate = bitrate; + cfg.max_bitrate = bitrate; + if let Some(DecoderSpecificInfo::Audio(a)) = cfg.decoder_specific_info.as_ref() { + sample_frequency = Some(a.sampling_frequency.as_hz()); + } + } + audio.extensions.push(StsdExtension::Btrt(BtrtExtension { + buffer_size_db: 0, + avg_bitrate: bitrate, + max_bitrate: bitrate, + })); + + if let Some(hz) = sample_frequency { + audio.sample_rate = hz as f32; + } + } else { + // this indicates a programming error since we won't get here with parsed data + unreachable!("STSD constructed with invalid data") + } + } +} + +#[cfg(feature = "experimental-trim")] +impl<'a> TrakAtomRefMut<'a> { + /// trims given duration range, excluding partially matched samples, and returns the remaining duration + pub(crate) fn trim_duration(&mut self, movie_timescale: u64, trim_ranges: &[R]) -> Duration + where + R: RangeBounds + Clone + Debug, + { + let mut mdia = self.media(); + let media_timescale = u64::from(mdia.header().timescale); + let media_duration = mdia.header().duration; + let mut minf = mdia.media_information(); + let mut stbl = minf.sample_table(); + + // Step 1: Scale and convert trim ranges + let scaled_ranges = trim_ranges + .iter() + .cloned() + .map(|range| { + convert_range( + media_duration, + scaled_duration_range(range, media_timescale), + ) + }) + .collect::>(); + + // Step 2: Determine which samples to remove based on time + let (remaining_duration, sample_indices_to_remove) = + stbl.time_to_sample().trim_duration(&scaled_ranges); + + let remaining_duration = unscaled_duration(remaining_duration, media_timescale); + + // Step 3: Update sample sizes + let removed_sample_sizes = stbl + .sample_size() + .remove_sample_indices(&sample_indices_to_remove); + + // Step 4: Calculate and remove chunks based on samples + let total_chunks = stbl.chunk_offset().chunk_count(); + let chunk_offset_ops = stbl + .sample_to_chunk() + .remove_sample_indices(&sample_indices_to_remove, total_chunks); + + // Step 5: Resolve chunk offset ops that depend on sample sizes + let chunk_offsets = &stbl.chunk_offset().chunk_offsets; + let chunk_offset_ops = chunk_offset_ops + .into_iter() + .map(|op| op.resolve(chunk_offsets, &removed_sample_sizes)) + .collect::>>() + .expect("chunk offset ops should only involve removed sample indices and valid chunk indices"); + + // Step 6: Remove chunk offsets + stbl.chunk_offset().apply_operations(chunk_offset_ops); + + // Step 7: Update headers + mdia.header().update_duration(|_| remaining_duration); + self.header() + .update_duration(movie_timescale, |_| remaining_duration); + + // Step 8: Replace any edit list entries with a no-op one + if self.as_ref().edit_list_container().edit_list().is_some() { + self.edit_list_container() + .edit_list() + .replace_entries(vec![EditEntry::builder() + .movie_timescale(movie_timescale) + .segment_duration(remaining_duration) + .build()]); + } + + remaining_duration + } +} + +#[cfg(feature = "experimental-trim")] +fn convert_range(media_time: u64, range: impl RangeBounds) -> Range { + use std::ops::Bound; + let start = match range.start_bound() { + Bound::Included(start) => *start, + Bound::Excluded(start) => *start + 1, + Bound::Unbounded => 0, + }; + let end = match range.end_bound() { + Bound::Included(end) => *end + 1, + Bound::Excluded(end) => *end, + Bound::Unbounded => media_time, + }; + start..end +} + +#[cfg(feature = "experimental-trim")] +#[cfg(test)] +pub(crate) mod trim_tests { + use std::{ops::Bound, time::Duration}; + + use bon::Builder; + + use crate::atom::{ + container::{DINF, MDIA, MINF, STBL, TRAK}, + dref::{DataReferenceAtom, DataReferenceEntry, DREF}, + gmin::GMIN, + hdlr::{HandlerReferenceAtom, HandlerType, HDLR}, + mdhd::{MediaHeaderAtom, MDHD}, + smhd::{SoundMediaHeaderAtom, SMHD}, + stco_co64::{ChunkOffsetAtom, STCO}, + stsc::{SampleToChunkAtom, SampleToChunkEntry, STSC}, + stsd::{SampleDescriptionTableAtom, STSD}, + stsz::{SampleSizeAtom, STSZ}, + stts::{TimeToSampleAtom, TimeToSampleEntry, STTS}, + text::TEXT, + tkhd::{TrackHeaderAtom, TKHD}, + util::scaled_duration, + Atom, AtomHeader, BaseMediaInfoAtom, TextMediaInfoAtom, TrakAtomRef, TrakAtomRefMut, GMHD, + }; + + #[bon::builder(finish_fn(name = "build"), state_mod(vis = "pub(crate)"))] + pub fn create_test_track( + #[builder(getter)] movie_timescale: u32, + #[builder(getter)] media_timescale: u32, + #[builder(getter)] duration: Duration, + handler_reference: Option, + minf_header: Option, + stsc_entries: Option>, + sample_sizes: Option>, + ) -> Atom { + Atom::builder() + .header(AtomHeader::new(*TRAK)) + .children(vec![ + // Track header (tkhd) + Atom::builder() + .header(AtomHeader::new(*TKHD)) + .data( + TrackHeaderAtom::builder() + .track_id(1) + .duration(scaled_duration(duration, movie_timescale as u64)) + .build(), + ) + .build(), + // Media (mdia) + create_test_media(media_timescale, duration) + .maybe_handler_reference(handler_reference) + .maybe_minf_header(minf_header) + .maybe_stsc_entries(stsc_entries) + .maybe_sample_sizes(sample_sizes) + .build(), + ]) + .build() + } + + #[bon::builder(finish_fn(name = "build"))] + fn create_test_media( + #[builder(start_fn)] media_timescale: u32, + #[builder(start_fn)] duration: Duration, + handler_reference: Option, + minf_header: Option, + stsc_entries: Option>, + sample_sizes: Option>, + ) -> Atom { + let handler_reference = handler_reference.unwrap_or_else(|| { + HandlerReferenceAtom::builder() + .handler_type(HandlerType::Audio) + .name("SoundHandler".to_string()) + .build() + }); + + let minf_header = minf_header.unwrap_or_else(|| { + match &handler_reference.handler_type { + HandlerType::Audio => { + // Sound media information header (smhd) + Atom::builder() + .header(AtomHeader::new(*SMHD)) + .data(SoundMediaHeaderAtom::default()) + .build() + } + HandlerType::Text => { + // Generic media information header (gmhd) + Atom::builder() + .header(AtomHeader::new(*GMHD)) + .children(vec![ + Atom::builder() + .header(AtomHeader::new(*GMIN)) + .data(BaseMediaInfoAtom::default()) + .build(), + Atom::builder() + .header(AtomHeader::new(*TEXT)) + .data(TextMediaInfoAtom::default()) + .build(), + ]) + .build() + } + _ => { + todo!( + "no default minf header for {:?}", + &handler_reference.handler_type + ) + } + } + }); + + Atom::builder() + .header(AtomHeader::new(*MDIA)) + .children(vec![ + // Media header (mdhd) + Atom::builder() + .header(AtomHeader::new(*MDHD)) + .data( + MediaHeaderAtom::builder() + .timescale(media_timescale) + .duration(scaled_duration(duration, media_timescale as u64)) + .build(), + ) + .build(), + // Handler reference (hdlr) + Atom::builder() + .header(AtomHeader::new(*HDLR)) + .data(handler_reference) + .build(), + // Media information (minf) + create_test_media_info() + .media_timescale(media_timescale) + .duration(duration) + .header(minf_header) + .maybe_stsc_entries(stsc_entries) + .maybe_sample_sizes(sample_sizes) + .build(), + ]) + .build() + } + + #[bon::builder(finish_fn(name = "build"))] + fn create_test_media_info( + media_timescale: u32, + duration: Duration, + header: Atom, + stsc_entries: Option>, + sample_sizes: Option>, + ) -> Atom { + let stsc_entries = stsc_entries.unwrap_or_else(|| { + vec![SampleToChunkEntry::builder() + .first_chunk(1) + .samples_per_chunk(2) + .sample_description_index(1) + .build()] + }); + + let sample_sizes = sample_sizes.unwrap_or_else(|| { + // one sample per second + let total_samples = duration.as_secs() as usize; + let sample_size = 256; + vec![sample_size; total_samples] + }); + + Atom::builder() + .header(AtomHeader::new(*MINF)) + .children(vec![ + header, + // Data information (dinf) + Atom::builder() + .header(AtomHeader::new(*DINF)) + .children(vec![ + // Data reference (dref) + Atom::builder() + .header(AtomHeader::new(*DREF)) + .data( + DataReferenceAtom::builder() + .entry(DataReferenceEntry::builder().url("").build()) + .build(), + ) + .build(), + ]) + .build(), + // Sample table (stbl) + create_test_sample_table() + .media_timescale(media_timescale) + .stsc_entries(stsc_entries) + .sample_sizes(sample_sizes) + .build(), + ]) + .build() + } + + #[bon::builder(finish_fn(name = "build"))] + fn create_test_sample_table( + media_timescale: u32, + stsc_entries: Vec, + sample_sizes: Vec, + #[builder(default = 1000)] mdat_content_offset: u64, + ) -> Atom { + let total_samples = sample_sizes.len() as u32; + + // Calculate chunk offsets + let chunk_offsets = { + let mut chunk_offsets = Vec::new(); + let mut current_offset = mdat_content_offset; + let mut sample_size_index = 0; + let mut remaining_samples = total_samples; + let mut stsc_iter = stsc_entries.iter().peekable(); + while let Some(entry) = stsc_iter.next() { + let n_chunks = match stsc_iter.peek() { + Some(next) => next.first_chunk - entry.first_chunk, + None => remaining_samples / entry.samples_per_chunk, + }; + + let n_samples = entry.samples_per_chunk * n_chunks; + remaining_samples = remaining_samples.saturating_sub(n_samples); + + for _ in 0..n_chunks { + chunk_offsets.push(current_offset); + current_offset += sample_sizes + .iter() + .skip(sample_size_index) + .take(entry.samples_per_chunk as usize) + .map(|s| *s as u64) + .sum::(); + sample_size_index += entry.samples_per_chunk as usize; + } + } + + chunk_offsets + }; + + Atom::builder() + .header(AtomHeader::new(*STBL)) + .children(vec![ + // Sample Description (stsd) + Atom::builder() + .header(AtomHeader::new(*STSD)) + .data(SampleDescriptionTableAtom::default()) + .build(), + // Time to Sample (stts) + Atom::builder() + .header(AtomHeader::new(*STTS)) + .data( + TimeToSampleAtom::builder() + .entry( + TimeToSampleEntry::builder() + .sample_count(total_samples) + .sample_duration(media_timescale) + .build(), + ) + .build(), + ) + .build(), + // Sample to Chunk (stsc) + Atom::builder() + .header(AtomHeader::new(*STSC)) + .data(SampleToChunkAtom::from(stsc_entries)) + .build(), + // Sample Size (stsz) + Atom::builder() + .header(AtomHeader::new(*STSZ)) + .data(SampleSizeAtom::builder().entry_sizes(sample_sizes).build()) + .build(), + // Chunk Offset (stco) + Atom::builder() + .header(AtomHeader::new(*STCO)) + .data( + ChunkOffsetAtom::builder() + .chunk_offsets(chunk_offsets) + .build(), + ) + .build(), + ]) + .build() + } + + #[derive(Debug, Builder)] + struct TrimDurationRange { + start_bound: Bound, + end_bound: Bound, + } + + #[derive(Builder)] + struct TrimDurationTestCase { + #[builder(field)] + ranges: Vec, + #[builder(default = 1_000)] + movie_timescale: u32, + #[builder(default = 10_000)] + media_timescale: u32, + expected_duration: Duration, + expected_chunk_offsets: ECO, + } + + impl TrimDurationTestCaseBuilder + where + S: trim_duration_test_case_builder::State, + { + fn range(mut self, range: TrimDurationRange) -> Self { + self.ranges.push(range); + self + } + } + + fn get_chunk_offsets(track: TrakAtomRef) -> Vec { + track + .media() + .media_information() + .sample_table() + .chunk_offset() + .unwrap() + .chunk_offsets + .clone() + .into_inner() + } + + fn test_trim_duration(mut track: Atom, test_case: TrimDurationTestCase) + where + ECO: FnOnce(Vec) -> Vec, + { + let mut track = TrakAtomRefMut::new(&mut track); + let starting_chunk_offsets = get_chunk_offsets(track.as_ref()); + + let trim_ranges = test_case + .ranges + .into_iter() + .map(|r| (r.start_bound, r.end_bound)) + .collect::>(); + let res = track.trim_duration(test_case.movie_timescale as u64, &trim_ranges); + assert_eq!(res, test_case.expected_duration); + + let trimmed_chunk_offsets = get_chunk_offsets(track.as_ref()); + let expected_chunk_offsets = (test_case.expected_chunk_offsets)(starting_chunk_offsets); + assert_eq!( + trimmed_chunk_offsets, expected_chunk_offsets, + "trimmed chunk offsets don't match what's expected" + ); + } + + macro_rules! test_trim_duration { + ($( + $name:ident { + @track( + $( $track_field:ident: $track_value:expr ),+, + ), + $( $field:ident: $value:expr ),+, + } + )*) => { + $( + #[test] + fn $name() { + test_trim_duration!( + @inner $($field: $value),+, + @track $($track_field: $track_value),+, + ); + } + )* + }; + + ( + @inner $( $field:ident: $value:expr ),+, + @track $( $track_field:ident: $track_value:expr ),+, + ) => { + let test_case = TrimDurationTestCase::builder() + .$( $field($value) ).+ + .build(); + let track = create_test_track() + .movie_timescale(test_case.movie_timescale) + .media_timescale(test_case.media_timescale) + .$( $track_field($track_value) ).+ + .build(); + test_trim_duration(track, test_case); + }; + } + + mod test_trim_duration { + use super::*; + + test_trim_duration!( + // TODO: test that when the middle of a chunk is trimmed, it's split into two chunks + // TODO: test that trimming the start of a chunk adjusts the chunk's offset forward (to the left) to compensate + // (trimming the end of a chunk doesn't require an adjustment) + // TODO: test that the edit list applies the remainder when the trim range doesn't divide cleanly to a sample range (i.e. when trying to trim within a sample's boundaries) + trim_start_11_seconds { + @track( + duration: Duration::from_secs(100), + ), + range: TrimDurationRange::builder() + .start_bound(Bound::Included(Duration::from_secs(0))) + .end_bound(Bound::Excluded(Duration::from_secs(11))).build(), + expected_duration: Duration::from_secs(89), + expected_chunk_offsets: |mut orig_offsets: Vec| { + // 1 second per sample = 11 samples trimmed + // 2 samples per chunk = 5 chunks trimmed + the first sample of the 6th chunk + orig_offsets.drain(..5); + let removed_sample_size = 256; // the default + orig_offsets[0] += removed_sample_size; // the 6th chunk offset should be moved forward + orig_offsets + }, + } + ); + } +} diff --git a/src/atom/container/udta.rs b/src/atom/container/udta.rs new file mode 100644 index 0000000..8a00f45 --- /dev/null +++ b/src/atom/container/udta.rs @@ -0,0 +1,27 @@ +use crate::{ + atom::{ + atom_ref::{unwrap_atom_data, AtomRefMut}, + chpl::CHPL, + container::META, + ChapterListAtom, + }, + AtomData, FourCC, +}; + +pub const UDTA: FourCC = FourCC::new(b"udta"); + +pub struct UserDataAtomRefMut<'a>(pub(crate) AtomRefMut<'a>); + +impl UserDataAtomRefMut<'_> { + /// Finds or inserts CHPL atom + pub fn chapter_list(&mut self) -> &'_ mut ChapterListAtom { + unwrap_atom_data!( + self.0 + .find_or_insert_child(CHPL) + .insert_after(vec![META]) + .insert_data(AtomData::ChapterList(ChapterListAtom::default())) + .call(), + AtomData::ChapterList, + ) + } +} diff --git a/src/atom/fourcc.rs b/src/atom/fourcc.rs new file mode 100644 index 0000000..c9136bd --- /dev/null +++ b/src/atom/fourcc.rs @@ -0,0 +1,78 @@ +use derive_more::Deref; +use std::fmt; + +#[derive(Default, Clone, Copy, Deref, PartialEq, Eq)] +pub struct FourCC(pub(crate) [u8; 4]); + +impl FourCC { + pub const fn new(typ: &[u8; 4]) -> Self { + Self(*typ) + } + + pub fn into_bytes(self) -> [u8; 4] { + self.0 + } + + pub fn as_bytes(&self) -> &[u8; 4] { + &self.0 + } + + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.0).unwrap_or("????") + } +} + +impl From<[u8; 4]> for FourCC { + fn from(value: [u8; 4]) -> Self { + FourCC(value) + } +} + +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 fmt::Display for FourCC { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let fourcc = std::str::from_utf8(&self.0) + .map_or_else(|_| convert_mac_roman_to_utf8(&self.0), ToOwned::to_owned); + if fourcc + .trim_matches(|c| !char::is_ascii_alphanumeric(&c)) + .is_empty() + { + // show bytes when not valid + fmt::Debug::fmt(&self.0, f) + } else { + write!(f, "{fourcc}") + } + } +} + +impl fmt::Debug for FourCC { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "FourCC({self})") + } +} + +fn convert_mac_roman_to_utf8(bytes: &[u8]) -> String { + let mut result = String::new(); + for &byte in bytes { + match byte { + 0xA9 => result.push('ยฉ'), // Copyright symbol + 0xAE => result.push('ยฎ'), // Registered trademark symbol + 0x99 => result.push('โ„ข'), // Trademark symbol + // For other bytes, treat as ASCII if valid, otherwise use replacement char + b if b.is_ascii() => result.push(b as char), + _ => result.push('๏ฟฝ'), + } + } + result +} diff --git a/src/atom/iter.rs b/src/atom/iter.rs new file mode 100644 index 0000000..ada113e --- /dev/null +++ b/src/atom/iter.rs @@ -0,0 +1,70 @@ +use crate::atom::Atom; + +pub struct AtomIter<'a> { + pub(crate) iter: Option>, +} + +impl<'a> AtomIter<'a> { + pub fn from_atom(atom_opt: Option<&'a Atom>) -> Self { + Self { + iter: atom_opt.map(|atom| atom.children.iter()), + } + } +} + +impl<'a> Iterator for AtomIter<'a> { + type Item = &'a Atom; + + fn next(&mut self) -> Option { + self.iter.as_mut().and_then(std::iter::Iterator::next) + } +} + +impl DoubleEndedIterator for AtomIter<'_> { + fn next_back(&mut self) -> Option { + self.iter + .as_mut() + .and_then(std::iter::DoubleEndedIterator::next_back) + } +} + +impl ExactSizeIterator for AtomIter<'_> { + fn len(&self) -> usize { + self.iter + .as_ref() + .map(ExactSizeIterator::len) + .unwrap_or_default() + } +} + +pub struct AtomIterMut<'a> { + pub(crate) children: &'a mut [Atom], + pub(crate) index: usize, +} + +impl<'a> AtomIterMut<'a> { + pub fn from_atom(atom: &'a mut Atom) -> Self { + Self { + children: &mut atom.children, + index: 0, + } + } +} + +impl<'a> Iterator for AtomIterMut<'a> { + type Item = &'a mut Atom; + + fn next(&mut self) -> Option { + if self.index >= self.children.len() { + return None; + } + + let children = std::mem::take(&mut self.children); + let (current, rest) = children.split_at_mut(self.index + 1); + self.children = rest; + let old_index = self.index; + self.index = 0; + + current.get_mut(old_index) + } +} diff --git a/src/atom/leaf.rs b/src/atom/leaf.rs new file mode 100644 index 0000000..4c3b255 --- /dev/null +++ b/src/atom/leaf.rs @@ -0,0 +1,34 @@ +/*! + * Atoms without children. + */ + +pub mod chpl; +pub mod dref; +pub mod elst; +pub mod free; +pub mod ftyp; +pub mod gmin; +pub mod hdlr; +pub mod ilst; +pub mod mdhd; +pub mod mvhd; +pub mod sbgp; +pub mod sgpd; +pub mod smhd; +pub mod stco_co64; +pub mod stsc; +pub mod stsd; +pub mod stsz; +pub mod stts; +pub mod text; +pub mod tkhd; +pub mod tref; + +pub use self::{ + chpl::ChapterListAtom, dref::DataReferenceAtom, elst::EditListAtom, free::FreeAtom, + ftyp::FileTypeAtom, gmin::BaseMediaInfoAtom, hdlr::HandlerReferenceAtom, ilst::ItemListAtom, + mdhd::MediaHeaderAtom, mvhd::MovieHeaderAtom, smhd::SoundMediaHeaderAtom, + stco_co64::ChunkOffsetAtom, stsc::SampleToChunkAtom, stsd::SampleDescriptionTableAtom, + stsz::SampleSizeAtom, stts::TimeToSampleAtom, text::TextMediaInfoAtom, tkhd::TrackHeaderAtom, + tref::TrackReferenceAtom, +}; diff --git a/src/atom/leaf/chpl.rs b/src/atom/leaf/chpl.rs new file mode 100644 index 0000000..bcd76d0 --- /dev/null +++ b/src/atom/leaf/chpl.rs @@ -0,0 +1,248 @@ +use bon::bon; +use derive_more::Deref; +use std::{fmt, time::Duration}; + +use crate::{ + atom::{util::DebugList, FourCC}, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +pub const CHPL: FourCC = FourCC::new(b"chpl"); + +#[derive(Default, Clone, Deref)] +pub struct ChapterEntries(Vec); + +impl ChapterEntries { + pub fn into_vec(self) -> Vec { + self.0 + } +} + +impl FromIterator for ChapterEntries { + fn from_iter>(iter: T) -> Self { + Vec::from_iter(iter).into() + } +} + +impl From> for ChapterEntries { + fn from(entries: Vec) -> Self { + ChapterEntries(entries) + } +} + +impl fmt::Debug for ChapterEntries { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&DebugList::new(self.0.iter(), 10), f) + } +} + +/// Chapter entry containing start time and title +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChapterEntry { + /// Start time of the chapter in 100-nanosecond units + pub start_time: u64, + /// Chapter title as UTF-8 string + pub title: String, +} + +#[bon] +impl ChapterEntry { + #[builder] + pub fn new(#[builder(into, finish_fn)] title: String, start_time: Duration) -> Self { + // convert to 100-nanosecond units + let start_time = (start_time.as_nanos() / 100).min(u128::from(u64::MAX)) as u64; + ChapterEntry { start_time, title } + } +} + +/// Chapter List Atom - contains chapter information for media +#[derive(Debug, Clone)] +pub struct ChapterListAtom { + /// Version of the chpl atom format (1) + pub version: u8, + pub flags: [u8; 3], + pub reserved: [u8; 4], + /// List of chapter entries + pub chapters: ChapterEntries, +} + +impl Default for ChapterListAtom { + fn default() -> Self { + Self { + version: 1, + flags: [0u8; 3], + reserved: [0u8; 4], + chapters: Default::default(), + } + } +} + +impl ChapterListAtom { + pub fn new(chapters: impl Into) -> Self { + Self { + version: 1, + flags: [0u8; 3], + reserved: [0u8; 4], + chapters: chapters.into(), + } + } + + pub fn replace_chapters(&mut self, chapters: impl Into) { + self.chapters = chapters.into(); + } +} + +impl ParseAtomData for ChapterListAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, CHPL); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_chpl_data.parse(stream(input))?) + } +} + +impl SerializeAtom for ChapterListAtom { + fn atom_type(&self) -> FourCC { + CHPL + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_chpl_data(self) + } +} + +mod serializer { + use crate::atom::chpl::{ChapterEntries, ChapterEntry}; + + use super::ChapterListAtom; + + pub fn serialize_chpl_data(atom: ChapterListAtom) -> Vec { + vec![ + version(atom.version), + flags(atom.flags), + reserved(atom.reserved), + chapters(atom.chapters), + ] + .into_iter() + .flatten() + .collect() + } + + fn version(version: u8) -> Vec { + vec![version] + } + + fn flags(flags: [u8; 3]) -> Vec { + flags.to_vec() + } + + fn reserved(reserved: [u8; 4]) -> Vec { + reserved.to_vec() + } + + fn chapters(chapters: ChapterEntries) -> Vec { + vec![ + vec![u8::try_from(chapters.len()) + .expect("there must be no more than {u8::MAX} chapter entries")], + chapters.0.into_iter().flat_map(chapter).collect(), + ] + .into_iter() + .flatten() + .collect() + } + + fn chapter(chapter: ChapterEntry) -> Vec { + vec![start_time(chapter.start_time), title(chapter.title)] + .into_iter() + .flatten() + .collect() + } + + fn start_time(start_time: u64) -> Vec { + start_time.to_be_bytes().to_vec() + } + + fn title(title: String) -> Vec { + let title_bytes = title.into_bytes(); + vec![ + vec![u8::try_from(title_bytes.len()).expect("title length must not exceed {u8::MAX}")], + title_bytes, + ] + .into_iter() + .flatten() + .collect() + } +} + +mod parser { + use winnow::{ + binary::{be_u64, length_and_then, u8}, + combinator::{repeat, seq, trace}, + error::StrContext, + token::rest, + ModalResult, Parser, + }; + + use super::ChapterListAtom; + use crate::atom::{ + chpl::{ChapterEntries, ChapterEntry}, + util::parser::{byte_array, version, Stream}, + }; + + pub fn parse_chpl_data(input: &mut Stream<'_>) -> ModalResult { + trace( + "chpl", + seq!(ChapterListAtom { + version: version.verify(|v| *v == 1), + flags: byte_array.context(StrContext::Label("flags")), + reserved: byte_array.context(StrContext::Label("reserved")), + chapters: chapters.context(StrContext::Label("chapters")), + }) + .context(StrContext::Label("chpl")), + ) + .parse_next(input) + } + + fn chapters(input: &mut Stream<'_>) -> ModalResult { + trace("chapters", move |input: &mut Stream<'_>| { + let chapter_count = u8 + .context(StrContext::Label("chapter_count")) + .parse_next(input)?; + repeat(chapter_count as usize, chapter) + .map(ChapterEntries) + .parse_next(input) + }) + .parse_next(input) + } + + fn chapter(input: &mut Stream<'_>) -> ModalResult { + trace( + "chapter", + seq!(ChapterEntry { + start_time: be_u64.context(StrContext::Label("start_time")), + title: length_and_then( + u8, + rest.try_map(|buf: &[u8]| String::from_utf8(buf.to_vec())) + ) + .context(StrContext::Label("title")), + // _: null_bytes, // discard trailing null bytes + }) + .context(StrContext::Label("chapter")), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available chpl test data files + #[test] + fn test_chpl_roundtrip() { + test_atom_roundtrip::(CHPL); + } +} diff --git a/src/atom/leaf/dref.rs b/src/atom/leaf/dref.rs new file mode 100644 index 0000000..34aae56 --- /dev/null +++ b/src/atom/leaf/dref.rs @@ -0,0 +1,282 @@ +use std::string::FromUtf8Error; + +use bon::Builder; + +use crate::{atom::FourCC, parser::ParseAtomData, writer::SerializeAtom, ParseError}; + +pub const DREF: FourCC = FourCC::new(b"dref"); + +/// Data Reference Entry Types +pub mod entry_types { + /// URL data reference + pub const URL: &[u8; 4] = b"url "; + /// URN data reference + pub const URN: &[u8; 4] = b"urn "; + /// Alias data reference (Mac OS) + pub const ALIS: &[u8; 4] = b"alis"; +} + +/// Data Reference Entry Flags +pub mod flags { + /// Media data is in the same file as the Movie Atom + pub const SELF_CONTAINED: u32 = 0x000001; +} + +#[derive(Debug, Clone)] +pub enum DataReferenceEntryInner { + Url(String), + Urn(String), + Alias(Vec), + Unknown(FourCC, Vec), +} + +impl DataReferenceEntryInner { + fn new(entry_type: FourCC, data: Vec) -> Result { + Ok(match &entry_type.0 { + entry_types::URL => DataReferenceEntryInner::Url(String::from_utf8(data)?), + entry_types::URN => DataReferenceEntryInner::Urn(String::from_utf8(data)?), + entry_types::ALIS => DataReferenceEntryInner::Alias(data), + _ => DataReferenceEntryInner::Unknown(entry_type, data), + }) + } +} + +/// A single data reference entry +#[derive(Debug, Clone, Builder)] +pub struct DataReferenceEntry { + /// Entry data/type (URL string, URN, alias data, etc.) + #[builder(setters(vis = ""))] + pub inner: DataReferenceEntryInner, + /// Version of the entry format + #[builder(default)] + pub version: u8, + /// Entry flags + #[builder(default)] + pub flags: [u8; 3], +} + +impl DataReferenceEntryBuilder { + pub fn url( + self, + url: impl Into, + ) -> DataReferenceEntryBuilder> + where + S::Inner: data_reference_entry_builder::IsUnset, + { + self.inner(DataReferenceEntryInner::Url(url.into())) + } + + pub fn urn( + self, + urn: impl Into, + ) -> DataReferenceEntryBuilder> + where + S::Inner: data_reference_entry_builder::IsUnset, + { + self.inner(DataReferenceEntryInner::Urn(urn.into())) + } +} + +impl DataReferenceEntry { + /// Check if this entry has the self-contained flag set + pub fn is_self_contained(&self) -> bool { + let flags_u32 = u32::from_be_bytes([0, self.flags[0], self.flags[1], self.flags[2]]); + (flags_u32 & flags::SELF_CONTAINED) != 0 + } +} + +/// Data Reference Atom (dref) - ISO/IEC 14496-12 +/// Contains a table of data references that declare the location(s) of the media data +#[derive(Debug, Clone, Builder)] +pub struct DataReferenceAtom { + /// Version of the dref atom format + #[builder(default = 0)] + pub version: u8, + /// Atom flags + #[builder(default = [0u8; 3])] + pub flags: [u8; 3], + /// Data reference entries + #[builder(with = FromIterator::from_iter)] + pub entries: Vec, +} + +impl DataReferenceAtomBuilder { + pub fn entry( + self, + entry: DataReferenceEntry, + ) -> DataReferenceAtomBuilder> + where + S::Entries: data_reference_atom_builder::IsUnset, + { + self.entries(vec![entry]) + } +} + +impl ParseAtomData for DataReferenceAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, DREF); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_dref_data.parse(stream(input))?) + } +} + +impl SerializeAtom for DataReferenceAtom { + fn atom_type(&self) -> FourCC { + DREF + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_dref_data(self) + } +} + +mod serializer { + use crate::atom::{ + dref::{entry_types, DataReferenceEntry, DataReferenceEntryInner}, + DataReferenceAtom, + }; + + pub fn serialize_dref_data(data: DataReferenceAtom) -> Vec { + let entries = data.entries; + vec![ + version(data.version), + flags(data.flags), + entry_count(entries.len()), + entries.into_iter().flat_map(entry).collect(), + ] + .into_iter() + .flatten() + .collect() + } + + fn version(version: u8) -> Vec { + vec![version] + } + + fn flags(flags: [u8; 3]) -> Vec { + flags.to_vec() + } + + fn entry_count(n: usize) -> Vec { + u32::try_from(n) + .expect("entries len must fit in a u32") + .to_be_bytes() + .to_vec() + } + + fn entry(e: DataReferenceEntry) -> Vec { + let e = raw_entry(e); + let data: Vec = vec![version(e.version), flags(e.flags), e.data] + .into_iter() + .flatten() + .collect(); + let header_size = 4 + 4; // size + type + vec![ + entry_size(header_size + data.len()).to_vec(), + e.typ.to_vec(), + data, + ] + .into_iter() + .flatten() + .collect() + } + + fn entry_size(n: usize) -> [u8; 4] { + u32::try_from(n) + .expect("entry size len must fit in a u32") + .to_be_bytes() + } + + struct RawEntry { + version: u8, + flags: [u8; 3], + typ: [u8; 4], + data: Vec, + } + + fn raw_entry(e: DataReferenceEntry) -> RawEntry { + let (typ, data) = match e.inner { + DataReferenceEntryInner::Url(url) => (*entry_types::URL, url.into_bytes()), + DataReferenceEntryInner::Urn(urn) => (*entry_types::URN, urn.into_bytes()), + DataReferenceEntryInner::Alias(alias_data) => (*entry_types::ALIS, alias_data), + DataReferenceEntryInner::Unknown(typ, unknown_data) => (typ.0, unknown_data), + }; + RawEntry { + version: e.version, + flags: e.flags, + typ, + data, + } + } +} + +mod parser { + use winnow::{ + binary::length_repeat, + combinator::{seq, trace}, + error::StrContext, + Parser, + }; + + use super::{DataReferenceAtom, DataReferenceEntry, DataReferenceEntryInner}; + use crate::atom::util::parser::{ + atom_size, be_u32_as_usize, combinators::inclusive_length_and_then, flags3, fourcc, + rest_vec, version, Stream, + }; + + pub fn parse_dref_data(input: &mut Stream<'_>) -> winnow::ModalResult { + trace( + "dref", + (version, flags3, entries) + .map(|(version, flags, entries)| DataReferenceAtom { + version, + flags, + entries, + }) + .context(StrContext::Label("dref")), + ) + .parse_next(input) + } + + fn entries(input: &mut Stream<'_>) -> winnow::ModalResult> { + trace( + "entries", + length_repeat( + be_u32_as_usize.context(StrContext::Label("entry_count")), + entry.context(StrContext::Label("entry")), + ), + ) + .parse_next(input) + } + + fn entry(input: &mut Stream<'_>) -> winnow::ModalResult { + trace( + "entry", + inclusive_length_and_then(atom_size, move |input: &mut Stream<'_>| { + let typ = fourcc.parse_next(input)?; + seq!(DataReferenceEntry { + version: version, + flags: flags3, + inner: rest_vec + .try_map(|data| DataReferenceEntryInner::new(typ, data)) + .context(StrContext::Label("data")), + }) + .parse_next(input) + }), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available dref test data files + #[test] + fn test_dref_roundtrip() { + test_atom_roundtrip::(DREF); + } +} diff --git a/src/atom/leaf/elst.rs b/src/atom/leaf/elst.rs new file mode 100644 index 0000000..153a50f --- /dev/null +++ b/src/atom/leaf/elst.rs @@ -0,0 +1,271 @@ +use std::time::Duration; + +use bon::bon; + +use crate::{ + atom::{util::scaled_duration, FourCC}, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +pub const ELST: FourCC = FourCC::new(b"elst"); + +#[derive(Default, Debug, Clone)] +pub struct EditListAtom { + /// Version of the elst atom format (0 or 1) + pub version: u8, + /// Flags for the elst atom (usually all zeros) + pub flags: [u8; 3], + /// List of edit entries + pub entries: Vec, +} + +impl EditListAtom { + pub fn new(entries: impl Into>) -> Self { + Self { + entries: entries.into(), + ..Default::default() + } + } + + pub fn replace_entries(&mut self, entries: impl Into>) -> &mut Self { + self.entries = entries.into(); + self + } +} + +pub struct MediaTime { + /// None implies -1, or a gap + start_offset: Option, +} + +impl Default for MediaTime { + fn default() -> Self { + Self { + start_offset: Some(Duration::from_secs(0)), + } + } +} + +impl MediaTime { + /// media time starting at a specified offset + pub fn new(start_offset: Duration) -> Self { + Self { + start_offset: Some(start_offset), + } + } + + /// media time representing a gap in playback + pub fn new_empty() -> Self { + Self { start_offset: None } + } + + pub fn scaled(&self, movie_timescale: u64) -> i64 { + match self.start_offset { + Some(start_offset) if start_offset.is_zero() => 0, + Some(start_offset) => i64::try_from(scaled_duration(start_offset, movie_timescale)) + .expect("scaled duration should fit in i64"), + None => -1, + } + } +} + +#[derive(Debug, Clone)] +pub struct EditEntry { + /// Duration of this edit segment (in movie timescale units) + pub segment_duration: u64, + /// Starting time within the media (in media timescale units) + /// -1 indicates an empty edit (no media displayed) + pub media_time: i64, + /// Playback rate for this segment (1.0 = normal speed) + pub media_rate: f32, +} + +#[bon] +impl EditEntry { + #[builder] + pub fn new( + movie_timescale: u64, + segment_duration: Duration, + #[builder(default = Default::default())] media_time: MediaTime, + #[builder(default = 1.0)] media_rate: f32, + ) -> Self { + Self { + segment_duration: scaled_duration(segment_duration, movie_timescale), + media_time: media_time.scaled(movie_timescale), + media_rate, + } + } +} + +impl ParseAtomData for EditListAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, ELST); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_elst_data.parse(stream(input))?) + } +} + +impl SerializeAtom for EditListAtom { + fn atom_type(&self) -> FourCC { + ELST + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_elst_atom(self) + } +} + +mod serializer { + use crate::atom::{elst::EditEntry, util::serializer::fixed_point_16x16}; + + use super::EditListAtom; + + pub fn serialize_elst_atom(atom: EditListAtom) -> Vec { + vec![ + version(atom.version), + flags(atom.flags), + entry_count(atom.entries.len()), + entries(atom.version, atom.entries), + ] + .into_iter() + .flatten() + .collect() + } + + fn version(version: u8) -> Vec { + vec![version] + } + + fn flags(flags: [u8; 3]) -> Vec { + flags.to_vec() + } + + fn entry_count(count: usize) -> Vec { + u32::try_from(count) + .expect("entries len should fit in u32") + .to_be_bytes() + .to_vec() + } + + fn entries(version: u8, entries: Vec) -> Vec { + match version { + 1 => entries.into_iter().flat_map(entry_64).collect(), + _ => entries.into_iter().flat_map(entry_32).collect(), + } + } + + fn entry_32(entry: EditEntry) -> Vec { + vec![ + u32::try_from(entry.segment_duration) + .expect("segument_duration should fit in u32") + .to_be_bytes() + .to_vec(), + i32::try_from(entry.media_time) + .expect("media_time should fit in i32") + .to_be_bytes() + .to_vec(), + media_rate(entry.media_rate), + ] + .into_iter() + .flatten() + .collect() + } + + fn entry_64(entry: EditEntry) -> Vec { + vec![ + entry.segment_duration.to_be_bytes().to_vec(), + entry.media_time.to_be_bytes().to_vec(), + media_rate(entry.media_rate), + ] + .into_iter() + .flatten() + .collect() + } + + fn media_rate(media_rate: f32) -> Vec { + fixed_point_16x16(media_rate).to_vec() + } +} + +mod parser { + use winnow::{ + binary::{be_i64, be_u32, be_u64, length_repeat}, + combinator::{seq, trace}, + error::StrContext, + ModalResult, Parser, + }; + + use super::EditListAtom; + use crate::atom::{ + elst::EditEntry, + util::parser::{be_i32_as, be_u32_as, fixed_point_16x16, flags3, version, Stream}, + }; + + pub fn parse_elst_data(input: &mut Stream<'_>) -> ModalResult { + trace( + "elst", + seq!(EditListAtom { + version: version, + flags: flags3, + entries: length_repeat( + be_u32.context(StrContext::Label("entry_count")), + match version { + 1 => entry_64, + _ => entry_32, + } + ), + }) + .context(StrContext::Label("elst")), + ) + .parse_next(input) + } + + fn entry_32(input: &mut Stream<'_>) -> ModalResult { + trace( + "entry_32", + seq!(EditEntry { + segment_duration: be_u32_as.context(StrContext::Label("segment_duration")), + media_time: be_i32_as.context(StrContext::Label("media_time")), + media_rate: media_rate, + }) + .context(StrContext::Label("entry")), + ) + .parse_next(input) + } + + fn entry_64(input: &mut Stream<'_>) -> ModalResult { + trace( + "entry_64", + seq!(EditEntry { + segment_duration: be_u64.context(StrContext::Label("segment_duration")), + media_time: be_i64.context(StrContext::Label("media_time")), + media_rate: media_rate, + }) + .context(StrContext::Label("entry")), + ) + .parse_next(input) + } + + fn media_rate(input: &mut Stream<'_>) -> ModalResult { + trace( + "media_rate", + fixed_point_16x16.context(StrContext::Label("media_rate")), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available elst test data files + #[test] + fn test_elst_roundtrip() { + test_atom_roundtrip::(ELST); + } +} diff --git a/src/atom/leaf/free.rs b/src/atom/leaf/free.rs new file mode 100644 index 0000000..7139489 --- /dev/null +++ b/src/atom/leaf/free.rs @@ -0,0 +1,79 @@ +use std::fmt; + +use crate::{ + atom::{ + util::{parser::rest_vec, DebugList, DebugUpperHex}, + FourCC, + }, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +pub const FREE: FourCC = FourCC::new(b"free"); +pub const SKIP: FourCC = FourCC::new(b"skip"); + +#[derive(Clone)] +pub struct FreeAtom { + /// The atom type (either 'free' or 'skip') + pub atom_type: FourCC, + /// Size of the free space data + pub data_size: usize, + /// The actual free space data (usually ignored) + pub data: Vec, +} + +impl fmt::Debug for FreeAtom { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FreeAtom") + .field("atom_type", &self.atom_type) + .field("data_size", &self.data_size) + .field( + "data", + &DebugList::new(self.data.iter().map(DebugUpperHex), 10), + ) + .finish() + } +} + +impl ParseAtomData for FreeAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, FREE, SKIP); + + use crate::atom::util::parser::stream; + use winnow::Parser; + + let data = rest_vec.parse(stream(input))?; + Ok(FreeAtom { + atom_type, + data_size: data.len(), + data, + }) + } +} + +impl SerializeAtom for FreeAtom { + fn atom_type(&self) -> FourCC { + self.atom_type + } + + fn into_body_bytes(self) -> Vec { + if self.data.is_empty() { + vec![0u8; self.data_size] + } else { + self.data + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available free test data files + #[test] + fn test_free_roundtrip() { + test_atom_roundtrip::(FREE); + } +} diff --git a/src/atom/leaf/ftyp.rs b/src/atom/leaf/ftyp.rs new file mode 100644 index 0000000..4ee85c7 --- /dev/null +++ b/src/atom/leaf/ftyp.rs @@ -0,0 +1,138 @@ +use bon::Builder; + +use crate::{ + atom::{atom_ref, FourCC}, + parser::ParseAtomData, + writer::SerializeAtom, + AtomData, ParseError, +}; + +pub const FTYP: FourCC = FourCC::new(b"ftyp"); + +#[derive(Debug, Clone, Copy)] +pub struct FtypAtomRef<'a>(pub(crate) atom_ref::AtomRef<'a>); + +impl<'a> FtypAtomRef<'a> { + pub fn data(&self) -> Option<&'a FileTypeAtom> { + self.0 + .inner() + .and_then(|ftyp| ftyp.data.as_ref()) + .and_then(|data| match data { + AtomData::FileType(data) => Some(data), + _ => None, + }) + } +} + +#[derive(Debug)] +pub struct FtypAtomRefMut<'a>(pub(crate) atom_ref::AtomRefMut<'a>); + +impl<'a> FtypAtomRefMut<'a> { + pub fn as_ref(&self) -> FtypAtomRef<'_> { + FtypAtomRef(self.0.as_ref()) + } + + pub fn into_ref(self) -> FtypAtomRef<'a> { + FtypAtomRef(self.0.into_ref()) + } + + pub fn replace(&mut self, data: FileTypeAtom) { + self.0.atom_mut().data = Some(data.into()); + } +} + +/// File Type Atom (ftyp) - ISO/IEC 14496-12 +/// This atom identifies the specifications to which this file complies. +#[derive(Debug, Clone, Builder)] +pub struct FileTypeAtom { + /// Major brand - identifies the 'best use' of the file + #[builder(into)] + pub major_brand: FourCC, + /// Minor version - an informative integer for the minor version of the major brand + #[builder(default = Default::default())] + pub minor_version: u32, + /// Compatible brands - a list of brands compatible with this file + #[builder(default = vec![major_brand], into)] + pub compatible_brands: Vec, +} + +impl Default for FileTypeAtom { + fn default() -> Self { + Self { + major_brand: FourCC(*b"isom"), + minor_version: 512, + compatible_brands: vec![FourCC::from(*b"isom")], + } + } +} + +impl ParseAtomData for FileTypeAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, FTYP); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_ftyp_data.parse(stream(input))?) + } +} + +mod parser { + use winnow::{ + binary::be_u32, + combinator::{repeat, seq, trace}, + error::StrContext, + ModalResult, Parser, + }; + + use super::FileTypeAtom; + use crate::atom::util::parser::{fourcc, Stream}; + + pub fn parse_ftyp_data(input: &mut Stream<'_>) -> ModalResult { + trace( + "ftyp", + seq!(FileTypeAtom { + major_brand: fourcc.context(StrContext::Label("major_brand")), + minor_version: be_u32.context(StrContext::Label("minor_version")), + compatible_brands: repeat( + 0.., + fourcc.context(StrContext::Label("compatible_brand")) + ), + }), + ) + .parse_next(input) + } +} + +impl SerializeAtom for FileTypeAtom { + fn atom_type(&self) -> FourCC { + FTYP + } + + fn into_body_bytes(self) -> Vec { + let mut data = Vec::new(); + + // Major brand (4 bytes) + data.extend_from_slice(&self.major_brand.0); + + // Minor version (4 bytes, big-endian) + data.extend_from_slice(&self.minor_version.to_be_bytes()); + + // Compatible brands (4 bytes each) + for brand in self.compatible_brands { + data.extend_from_slice(&brand.0); + } + + data + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available ftyp test data files + #[test] + fn test_ftyp_roundtrip() { + test_atom_roundtrip::(FTYP); + } +} diff --git a/src/atom/leaf/gmin.rs b/src/atom/leaf/gmin.rs new file mode 100644 index 0000000..14a8ffc --- /dev/null +++ b/src/atom/leaf/gmin.rs @@ -0,0 +1,104 @@ +use crate::{ + atom::{util::ColorRgb, FourCC}, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +pub const GMIN: FourCC = FourCC::new(b"gmin"); + +#[derive(Debug, Clone)] +pub struct BaseMediaInfoAtom { + pub version: u8, + pub flags: [u8; 3], + pub graphics_mode: u16, + pub op_color: ColorRgb, + /// fixed point 8x8 + pub balance: f32, + // reserved: 2 bytes +} + +impl Default for BaseMediaInfoAtom { + fn default() -> Self { + Self { + version: 0, + flags: [0, 0, 0], + graphics_mode: 64, + op_color: ColorRgb { + red: 32768, + green: 32768, + blue: 32768, + }, + balance: 0.0, + } + } +} + +impl ParseAtomData for BaseMediaInfoAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, GMIN); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_gmin_data.parse(stream(input))?) + } +} + +impl SerializeAtom for BaseMediaInfoAtom { + fn atom_type(&self) -> FourCC { + GMIN + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_gmin_data(self) + } +} + +mod serializer { + use crate::atom::{ + gmin::BaseMediaInfoAtom, + util::serializer::{color_rgb, fixed_point_8x8}, + }; + + pub fn serialize_gmin_data(gmin: BaseMediaInfoAtom) -> Vec { + let mut data = Vec::new(); + data.push(gmin.version); + data.extend(gmin.flags); + data.extend(gmin.graphics_mode.to_be_bytes()); + data.extend(color_rgb(gmin.op_color)); + data.extend(fixed_point_8x8(gmin.balance)); + data.extend([0u8; 2]); // reserved + data + } +} + +mod parser { + use crate::atom::{ + gmin::BaseMediaInfoAtom, + util::parser::{byte_array, color_rgb, fixed_point_8x8, flags3, version, Stream}, + }; + use winnow::{binary::be_u16, combinator::seq, error::StrContext, ModalResult, Parser}; + + pub fn parse_gmin_data(input: &mut Stream<'_>) -> ModalResult { + seq!(BaseMediaInfoAtom { + version: version, + flags: flags3, + graphics_mode: be_u16.context(StrContext::Label("graphics_mode")), + op_color: color_rgb.context(StrContext::Label("op_color")), + balance: fixed_point_8x8.context(StrContext::Label("balance")), + _: byte_array::<2>.context(StrContext::Label("reserved")), + }) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available gmin test data files + #[test] + fn test_gmin_roundtrip() { + test_atom_roundtrip::(GMIN); + } +} diff --git a/src/atom/leaf/hdlr.rs b/src/atom/leaf/hdlr.rs new file mode 100644 index 0000000..43ad93b --- /dev/null +++ b/src/atom/leaf/hdlr.rs @@ -0,0 +1,471 @@ +use bon::Builder; +use core::fmt; + +use crate::{atom::FourCC, parser::ParseAtomData, writer::SerializeAtom, ParseError}; + +pub const HDLR: FourCC = FourCC::new(b"hdlr"); + +// Common handler types +pub const HANDLER_VIDEO: FourCC = FourCC::new(b"vide"); +pub const HANDLER_AUDIO: FourCC = FourCC::new(b"soun"); +pub const HANDLER_HINT: FourCC = FourCC::new(b"hint"); +pub const HANDLER_META: FourCC = FourCC::new(b"meta"); +pub const HANDLER_TEXT: FourCC = FourCC::new(b"text"); +pub const HANDLER_MDIR: FourCC = FourCC::new(b"mdir"); +pub const HANDLER_SUBTITLE: FourCC = FourCC::new(b"subt"); +pub const HANDLER_TIMECODE: FourCC = FourCC::new(b"tmcd"); + +#[derive(Debug, Clone, PartialEq)] +pub enum HandlerType { + Video, + Audio, + Hint, + Meta, + Text, + Mdir, + Subtitle, + Timecode, + Unknown(FourCC), +} + +impl Default for HandlerType { + fn default() -> Self { + Self::Unknown(FourCC([0u8; 4])) + } +} + +impl HandlerType { + pub fn from_bytes(bytes: &[u8; 4]) -> Self { + match FourCC::new(bytes) { + HANDLER_VIDEO => HandlerType::Video, + HANDLER_AUDIO => HandlerType::Audio, + HANDLER_HINT => HandlerType::Hint, + HANDLER_META => HandlerType::Meta, + HANDLER_TEXT => HandlerType::Text, + HANDLER_MDIR => HandlerType::Mdir, + HANDLER_SUBTITLE => HandlerType::Subtitle, + HANDLER_TIMECODE => HandlerType::Timecode, + _ => HandlerType::Unknown(FourCC::new(bytes)), + } + } + + pub fn to_bytes(&self) -> [u8; 4] { + match self { + HandlerType::Video => *HANDLER_VIDEO, + HandlerType::Audio => *HANDLER_AUDIO, + HandlerType::Hint => *HANDLER_HINT, + HandlerType::Meta => *HANDLER_META, + HandlerType::Text => *HANDLER_TEXT, + HandlerType::Mdir => *HANDLER_MDIR, + HandlerType::Subtitle => *HANDLER_SUBTITLE, + HandlerType::Timecode => *HANDLER_TIMECODE, + HandlerType::Unknown(fourcc) => fourcc.into_bytes(), + } + } + + pub fn as_str(&self) -> &str { + match self { + HandlerType::Video => "Video", + HandlerType::Audio => "Audio", + HandlerType::Hint => "Hint", + HandlerType::Meta => "Metadata", + HandlerType::Text => "Text", + HandlerType::Mdir => "Mdir", + HandlerType::Subtitle => "Subtitle", + HandlerType::Timecode => "Timecode", + HandlerType::Unknown(_) => "Unknown", + } + } + + pub fn is_media_handler(&self) -> bool { + matches!( + self, + HandlerType::Video | HandlerType::Audio | HandlerType::Text | HandlerType::Subtitle + ) + } +} + +#[derive(Default, Debug, Clone, Builder)] +pub struct HandlerReferenceAtom { + /// Version of the hdlr atom format (0) + #[builder(default = 0)] + pub version: u8, + /// Flags for the hdlr atom (usually all zeros) + #[builder(default = [0u8; 3])] + pub flags: [u8; 3], + /// Component type (pre-defined, usually 0) + #[builder(default = FourCC([0u8; 4]))] + pub component_type: FourCC, + /// Handler type (4CC code indicating the type of media handler) + pub handler_type: HandlerType, + /// Component manufacturer (usually 0) + #[builder(default = FourCC([0u8; 4]))] + pub component_manufacturer: FourCC, + /// Component flags (usually 0) + #[builder(default = 0)] + pub component_flags: u32, + /// Component flags mask (usually 0) + #[builder(default = 0)] + pub component_flags_mask: u32, + /// Human-readable name of the handler + #[builder(into)] + pub name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HandlerName { + /// just string data + Raw(String), + /// length followed by string data + Pascal(String), + /// null terminated string + CString(String), + /// double-null terminated string + CString2(String), +} + +impl Default for HandlerName { + fn default() -> Self { + Self::Raw(String::new()) + } +} + +impl fmt::Display for HandlerName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fmt::Display::fmt(self.as_string(), f) + } +} + +impl From for HandlerName { + fn from(value: String) -> Self { + Self::CString(value) + } +} + +impl From<&String> for HandlerName { + fn from(value: &String) -> Self { + Self::CString(value.to_owned()) + } +} + +impl From<&str> for HandlerName { + fn from(value: &str) -> Self { + Self::CString(value.to_owned()) + } +} + +impl HandlerName { + fn as_string(&self) -> &String { + match self { + Self::Raw(str) => str, + Self::Pascal(str) => str, + Self::CString(str) => str, + Self::CString2(str) => str, + } + } + + pub fn as_str(&self) -> &str { + self.as_string().as_str() + } +} + +impl ParseAtomData for HandlerReferenceAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, HDLR); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_hdlr_data.parse(stream(input))?) + } +} + +impl SerializeAtom for HandlerReferenceAtom { + fn atom_type(&self) -> FourCC { + HDLR + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_hdlr_atom(self) + } +} + +mod serializer { + use crate::FourCC; + + use super::{HandlerName, HandlerReferenceAtom, HandlerType}; + + pub fn serialize_hdlr_atom(atom: HandlerReferenceAtom) -> Vec { + vec![ + version(atom.version), + flags(atom.flags), + component_type(atom.component_type), + handler_type(atom.handler_type), + component_manufacturer(atom.component_manufacturer), + component_flags(atom.component_flags), + component_flags_mask(atom.component_flags_mask), + atom.name.map(name).unwrap_or_default(), + ] + .into_iter() + .flatten() + .collect() + } + + fn version(version: u8) -> Vec { + vec![version] + } + + fn flags(flags: [u8; 3]) -> Vec { + flags.to_vec() + } + + fn component_type(component_type: FourCC) -> Vec { + component_type.to_vec() + } + + fn handler_type(handler_type: HandlerType) -> Vec { + handler_type.to_bytes().to_vec() + } + + fn component_manufacturer(manufacturer: FourCC) -> Vec { + manufacturer.to_vec() + } + + fn component_flags(flags: u32) -> Vec { + flags.to_be_bytes().to_vec() + } + + fn component_flags_mask(flags_mask: u32) -> Vec { + flags_mask.to_be_bytes().to_vec() + } + + fn name(name: HandlerName) -> Vec { + let mut data = Vec::new(); + match name { + HandlerName::Pascal(name) => { + let name_bytes = name.as_bytes(); + let len = u8::try_from(name_bytes.len()) + .expect("HandlerName::Pascal length must not exceed u8::MAX"); + data.push(len); + data.extend_from_slice(name_bytes); + } + HandlerName::CString(name) => { + data.extend_from_slice(name.as_bytes()); + data.push(0); // Null terminator + } + HandlerName::CString2(name) => { + data.extend_from_slice(name.as_bytes()); + data.push(0); // 1st null terminator + data.push(0); // 2nd null terminator + } + HandlerName::Raw(name) => { + data.extend_from_slice(name.as_bytes()); + } + } + data + } +} + +mod parser { + use winnow::{ + binary::{be_u32, length_take, u8}, + combinator::{alt, opt, repeat_till, seq, trace}, + error::StrContext, + token::{literal, rest}, + ModalResult, Parser, + }; + + use super::{HandlerName, HandlerReferenceAtom, HandlerType}; + use crate::{ + atom::util::parser::{byte_array, flags3, fourcc, version, Stream}, + FourCC, + }; + + pub fn parse_hdlr_data(input: &mut Stream<'_>) -> ModalResult { + trace( + "hdlr", + seq!(HandlerReferenceAtom { + version: version, + flags: flags3, + component_type: component_type, + handler_type: handler_type, + component_manufacturer: component_manufacturer, + component_flags: component_flags, + component_flags_mask: component_flags_mask, + name: opt(name), + }) + .context(StrContext::Label("hdlr")), + ) + .parse_next(input) + } + + fn component_type(input: &mut Stream<'_>) -> ModalResult { + trace( + "component_type", + fourcc.context(StrContext::Label("component_type")), + ) + .parse_next(input) + } + + fn handler_type(input: &mut Stream<'_>) -> ModalResult { + trace( + "handler_type", + byte_array + .map(|fourcc| HandlerType::from_bytes(&fourcc)) + .context(StrContext::Label("handler_type")), + ) + .parse_next(input) + } + + fn component_manufacturer(input: &mut Stream<'_>) -> ModalResult { + trace( + "component_manufacturer", + fourcc.context(StrContext::Label("component_manufacturer")), + ) + .parse_next(input) + } + + fn component_flags(input: &mut Stream<'_>) -> ModalResult { + trace( + "component_flags", + be_u32.context(StrContext::Label("component_flags")), + ) + .parse_next(input) + } + + fn component_flags_mask(input: &mut Stream<'_>) -> ModalResult { + trace( + "component_flags_mask", + be_u32.context(StrContext::Label("component_flags_mask")), + ) + .parse_next(input) + } + + fn name(input: &mut Stream<'_>) -> ModalResult { + trace( + "name", + alt((name_cstr, name_pascal, name_raw)).context(StrContext::Label("name")), + ) + .parse_next(input) + } + + fn name_cstr(input: &mut Stream<'_>) -> ModalResult { + trace( + "name_cstr", + repeat_till(1.., u8, null_term).map(|(data, null2): (Vec, Option<()>)| { + let str = String::from_utf8_lossy(&data).to_string(); + match null2 { + Some(_) => HandlerName::CString2(str), + None => HandlerName::CString(str), + } + }), + ) + .parse_next(input) + } + + fn null_term(input: &mut Stream<'_>) -> ModalResult> { + trace( + "null_term", + (literal(0x00), opt(literal(0x00))).map(|(_, null2)| null2.map(|_| ())), + ) + .parse_next(input) + } + + fn name_pascal(input: &mut Stream<'_>) -> ModalResult { + trace( + "name_pascal", + length_take(u8) + .map(|data| HandlerName::Pascal(String::from_utf8_lossy(data).to_string())), + ) + .parse_next(input) + } + + fn name_raw(input: &mut Stream<'_>) -> ModalResult { + trace( + "name_raw", + rest.map(|data| HandlerName::Raw(String::from_utf8_lossy(data).to_string())), + ) + .parse_next(input) + } + + #[cfg(test)] + mod tests { + + use super::*; + use crate::{atom::util::parser::stream, FourCC}; + + #[test] + fn test_handler_type_from_bytes() { + assert_eq!(HandlerType::from_bytes(b"vide"), HandlerType::Video); + assert_eq!(HandlerType::from_bytes(b"soun"), HandlerType::Audio); + assert_eq!(HandlerType::from_bytes(b"text"), HandlerType::Text); + assert_eq!(HandlerType::from_bytes(b"meta"), HandlerType::Meta); + assert_eq!( + HandlerType::from_bytes(b"abcd"), + HandlerType::Unknown(FourCC::new(b"abcd")) + ); + } + + #[test] + fn test_handler_type_methods() { + let video_handler = HandlerType::Video; + assert!(video_handler.is_media_handler()); + assert_eq!(video_handler.as_str(), "Video"); + assert_eq!(video_handler.to_bytes(), *b"vide"); + + let unknown_handler = HandlerType::Unknown(FourCC::new(b"test")); + assert!(!unknown_handler.is_media_handler()); + assert_eq!(unknown_handler.as_str(), "Unknown"); + assert_eq!(unknown_handler.to_bytes(), *b"test"); + } + + #[test] + fn test_parse_handler_name_pascal() { + // Pascal string: length byte followed by string + let pascal_name = b"\x0CHello World!"; + let result = name.parse(stream(pascal_name)).unwrap(); + assert_eq!(result, HandlerName::Pascal("Hello World!".to_owned())); + } + + #[test] + fn test_parse_handler_name_null_terminated() { + // C-style null-terminated string + let c_name = b"Hello World!\0"; + let result = name.parse(stream(c_name)).unwrap(); + assert_eq!(result, HandlerName::CString("Hello World!".to_owned())); + } + + #[test] + fn test_parse_handler_name_double_null_terminated() { + // C-style null-terminated string + let c_name = b"Hello World!\0\0"; + let result = name.parse(stream(c_name)).unwrap(); + assert_eq!(result, HandlerName::CString2("Hello World!".to_owned())); + } + + #[test] + fn test_parse_handler_name_raw() { + // Raw string without null terminator + let raw_name = b"Hello World!"; + let result = name.parse(stream(raw_name)).unwrap(); + assert_eq!(result, HandlerName::Raw("Hello World!".to_owned())); + } + + #[test] + fn test_parse_handler_name_empty() { + let empty_name = b""; + let result = name.parse(stream(empty_name)).unwrap(); + assert_eq!(result, HandlerName::Raw(String::new())); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available hdlr test data files + #[test] + fn test_hdlr_roundtrip() { + test_atom_roundtrip::(HDLR); + } +} diff --git a/src/atom/leaf/ilst.rs b/src/atom/leaf/ilst.rs new file mode 100644 index 0000000..5a0e371 --- /dev/null +++ b/src/atom/leaf/ilst.rs @@ -0,0 +1,260 @@ +use bon::bon; +use core::fmt; +use derive_more::Deref; + +use crate::atom::util::{DebugList, DebugUpperHex}; +use crate::ParseError; +use crate::{atom::FourCC, parser::ParseAtomData, writer::SerializeAtom}; + +pub const ILST: FourCC = FourCC::new(b"ilst"); + +const DATA_TYPE_TEXT: u32 = 1; +const DATA_TYPE_JPEG: u32 = 13; + +#[derive(Clone, Deref)] +pub struct RawData(Vec); + +impl RawData { + pub fn new(data: impl Into>) -> Self { + RawData(data.into()) + } +} + +impl fmt::Debug for RawData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&DebugList::new(self.0.iter().map(DebugUpperHex), 10), f) + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum ListItemData { + Text(String), + Jpeg(RawData), + Raw(RawData), +} + +impl ListItemData { + fn new(data_type: u32, data: Vec) -> Self { + match data_type { + DATA_TYPE_TEXT => String::from_utf8(data) + .map_or_else(|e| Self::Raw(RawData(e.into_bytes())), Self::Text), + DATA_TYPE_JPEG => Self::Jpeg(RawData(data)), + _ => Self::Raw(RawData(data)), + } + } + + fn to_bytes(self: ListItemData) -> Vec { + use ListItemData::{Jpeg, Raw, Text}; + match self { + Text(s) => s.into_bytes(), + Jpeg(data) | Raw(data) => data.0, + } + } +} + +#[derive(Debug, Clone)] +pub struct DataAtom { + pub data_type: u32, + pub reserved: u32, + pub data: ListItemData, +} + +impl DataAtom { + pub fn new(data: ListItemData) -> Self { + Self { + data_type: 0, + reserved: 0, + data, + } + } +} + +#[derive(Debug, Clone)] +pub struct MetadataItem { + pub item_type: FourCC, + pub mean: Option>, + pub name: Option>, + pub data_atoms: Vec, +} + +#[bon] +impl MetadataItem { + #[builder] + pub fn new( + #[builder(into, start_fn)] item_type: FourCC, + #[builder(into)] data_atoms: Vec, + ) -> Self { + Self { + item_type, + mean: None, + name: None, + data_atoms, + } + } +} + +#[derive(Debug, Clone)] +pub struct ItemListAtom { + pub items: Vec, +} + +impl ParseAtomData for ItemListAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, ILST); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_ilst_data.parse(stream(input))?) + } +} + +impl SerializeAtom for ItemListAtom { + fn atom_type(&self) -> FourCC { + ILST + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_ilst_atom(self) + } +} + +mod serializer { + use crate::atom::util::serializer::{prepend_size_inclusive, SizeU32OrU64}; + + use super::{ + DataAtom, ItemListAtom, ListItemData, MetadataItem, DATA_TYPE_JPEG, DATA_TYPE_TEXT, + }; + + pub fn serialize_ilst_atom(atom: ItemListAtom) -> Vec { + atom.items.into_iter().flat_map(serialize_item).collect() + } + + fn serialize_item(item: MetadataItem) -> Vec { + prepend_size_inclusive::(move || { + let mut item_data = Vec::new(); + + item_data.extend(item.item_type.into_bytes()); + + if let Some(mean) = item.mean { + item_data.extend(prepend_size_inclusive::(move || { + let mut mean_data = Vec::new(); + mean_data.extend(b"mean"); + mean_data.extend(mean); + mean_data + })); + } + + if let Some(name) = item.name { + item_data.extend(prepend_size_inclusive::(move || { + let mut name_data = Vec::new(); + name_data.extend(b"name"); + name_data.extend(name); + name_data + })); + } + + for data_atom in item.data_atoms { + item_data.extend(prepend_size_inclusive::(move || { + let mut atom_data = Vec::new(); + atom_data.extend(b"data"); + atom_data.extend(serialize_data_type(&data_atom)); + atom_data.extend(data_atom.reserved.to_be_bytes()); + atom_data.extend(data_atom.data.to_bytes()); + atom_data + })); + } + + item_data + }) + } + + fn serialize_data_type(data_atom: &DataAtom) -> Vec { + match &data_atom.data { + ListItemData::Text(_) => DATA_TYPE_TEXT, + ListItemData::Jpeg(_) => DATA_TYPE_JPEG, + _ => data_atom.data_type, + } + .to_be_bytes() + .to_vec() + } +} + +mod parser { + use winnow::{ + binary::be_u32, + combinator::{opt, preceded, repeat, seq, trace}, + error::StrContext, + token::{literal, rest}, + ModalResult, Parser, + }; + + use super::{DataAtom, ItemListAtom, ListItemData, MetadataItem}; + use crate::atom::util::parser::{ + atom_size, combinators::inclusive_length_and_then, fourcc, rest_vec, Stream, + }; + + pub fn parse_ilst_data(input: &mut Stream<'_>) -> ModalResult { + trace( + "ilst", + seq!(ItemListAtom { + items: repeat(0.., item), + }) + .context(StrContext::Label("ilst")), + ) + .parse_next(input) + } + + fn item(input: &mut Stream<'_>) -> ModalResult { + trace( + "item", + inclusive_length_and_then(atom_size, item_inner).context(StrContext::Label("item")), + ) + .parse_next(input) + } + + fn item_inner(input: &mut Stream<'_>) -> ModalResult { + seq!(MetadataItem { + item_type: fourcc, + mean: opt(inclusive_length_and_then( + atom_size, + preceded(literal(b"mean"), rest_vec) + )) + .context(StrContext::Label("mean")), + name: opt(inclusive_length_and_then( + atom_size, + preceded(literal(b"name"), rest_vec) + )) + .context(StrContext::Label("name")), + data_atoms: repeat( + 0.., + inclusive_length_and_then(atom_size, preceded(literal(b"data"), data_atom)) + ), + }) + .parse_next(input) + } + + fn data_atom(input: &mut Stream<'_>) -> ModalResult { + trace( + "data_atom", + seq!(DataAtom { + data_type: be_u32, + reserved: be_u32, + data: rest.map(|data: &[u8]| ListItemData::new(data_type, data.to_vec())), + }) + .context(StrContext::Label("data_atom")), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available ilst test data files + #[test] + fn test_ilst_roundtrip() { + test_atom_roundtrip::(ILST); + } +} diff --git a/src/atom/leaf/mdhd.rs b/src/atom/leaf/mdhd.rs new file mode 100644 index 0000000..951bd7d --- /dev/null +++ b/src/atom/leaf/mdhd.rs @@ -0,0 +1,245 @@ +use bon::Builder; +use std::{fmt, time::Duration}; + +use crate::{ + atom::{ + util::{mp4_timestamp_now, scaled_duration, unscaled_duration}, + FourCC, + }, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +pub const MDHD: FourCC = FourCC::new(b"mdhd"); + +macro_rules! define_language_code_enum { + ($( #[$meta:meta] )* $name:ident { $( $( #[$tag:meta] )* $variant:ident => $chars:literal ),+ $(,)? }) => { + $(#[$meta])* + pub enum $name { + $( $( #[$tag] )* $variant ),+, + Other([u8; 3]), + } + + impl $name { + fn from_chars(chars: &[u8; 3]) -> Self { + match chars { + $( $chars => Self::$variant ),+, + _ => Self::Other(*chars), + } + } + + fn as_chars(&self) -> &[u8; 3] { + match self { + $( Self::$variant => $chars ),+, + Self::Other(chars) => chars, + } + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let chars = match self { + $( Self::$variant => $chars ),+, + Self::Other(chars) => &[chars[0] as u8, chars[1] as u8, chars[2] as u8], + }; + write!(f, "{}{}{}", chars[0] as char, chars[1] as char, chars[2] as char) + } + } + }; +} + +define_language_code_enum!( + /// Language code (ISO 639-2/T language code) + #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] + #[non_exhaustive] + LanguageCode { + English => b"eng", + Spanish => b"spa", + French => b"fra", + German => b"deu", + Italian => b"ita", + Japanese => b"jpn", + Korean => b"kor", + Chinese => b"chi", + Russian => b"rus", + Arabic => b"ara", + Portuguese => b"por", + #[default] + Undetermined => b"und", + } +); + +impl From for LanguageCode { + fn from(packed: u16) -> Self { + let char1 = (((packed >> 10) & 0x1F) + 0x60) as u8; + let char2 = (((packed >> 5) & 0x1F) + 0x60) as u8; + let char3 = ((packed & 0x1F) + 0x60) as u8; + + let lang: [u8; 3] = [char1, char2, char3]; + + Self::from_chars(&lang) + } +} + +impl From for u16 { + fn from(value: LanguageCode) -> Self { + let chars = value.as_chars(); + + let char1_bits = (chars[0] - 0x60) & 0x1F; + let char2_bits = (chars[1] - 0x60) & 0x1F; + let char3_bits = (chars[2] - 0x60) & 0x1F; + + (u16::from(char1_bits) << 10) | (u16::from(char2_bits) << 5) | u16::from(char3_bits) + } +} + +#[derive(Default, Debug, Clone, Builder)] +pub struct MediaHeaderAtom { + /// Version of the mdhd atom format (0 or 1) + #[builder(default = 0)] + pub version: u8, + /// Flags for the mdhd atom (usually all zeros) + #[builder(default = [0u8; 3])] + pub flags: [u8; 3], + /// Creation time (seconds since midnight, Jan. 1, 1904, UTC) + #[builder(default = mp4_timestamp_now())] + pub creation_time: u64, + /// Modification time (seconds since midnight, Jan. 1, 1904, UTC) + #[builder(default = mp4_timestamp_now())] + pub modification_time: u64, + /// Media timescale (number of time units per second) + pub timescale: u32, + /// Duration of media (in timescale units) + pub duration: u64, + /// Language code (ISO 639-2/T language code) + #[builder(default = LanguageCode::Undetermined)] + pub language: LanguageCode, + /// Pre-defined value (should be 0) + #[builder(default = 0)] + pub pre_defined: u16, +} + +impl MediaHeaderAtom { + pub fn duration(&self) -> Duration { + unscaled_duration(self.duration, u64::from(self.timescale)) + } + + pub fn update_duration(&mut self, mut closure: F) -> &mut Self + where + F: FnMut(Duration) -> Duration, + { + self.duration = scaled_duration(closure(self.duration()), u64::from(self.timescale)); + self + } +} + +impl ParseAtomData for MediaHeaderAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, MDHD); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_mdhd_data.parse(stream(input))?) + } +} + +impl SerializeAtom for MediaHeaderAtom { + fn atom_type(&self) -> FourCC { + MDHD + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_mdhd_atom(self) + } +} + +mod serializer { + use super::MediaHeaderAtom; + + pub fn serialize_mdhd_atom(mdhd: MediaHeaderAtom) -> Vec { + let mut data = Vec::new(); + + let version: u8 = if mdhd.version == 1 + || mdhd.creation_time > u64::from(u32::MAX) + || mdhd.modification_time > u64::from(u32::MAX) + || mdhd.duration > u64::from(u32::MAX) + { + 1 + } else { + 0 + }; + + let be_u32_or_u64 = |v: u64| match version { + 0 => u32::try_from(v).unwrap().to_be_bytes().to_vec(), + 1 => v.to_be_bytes().to_vec(), + _ => unreachable!(), + }; + + data.extend(version.to_be_bytes()); + data.extend(mdhd.flags); + data.extend(be_u32_or_u64(mdhd.creation_time)); + data.extend(be_u32_or_u64(mdhd.modification_time)); + data.extend(mdhd.timescale.to_be_bytes()); + data.extend(be_u32_or_u64(mdhd.duration)); + data.extend(u16::from(mdhd.language).to_be_bytes()); + data.extend(mdhd.pre_defined.to_be_bytes()); + + data + } +} + +mod parser { + use winnow::{ + binary::{be_u16, be_u32, be_u64}, + combinator::{seq, trace}, + error::{StrContext, StrContextValue}, + ModalResult, Parser, + }; + + use super::{LanguageCode, MediaHeaderAtom}; + use crate::atom::util::parser::{be_u32_as_u64, flags3, version, Stream}; + + pub fn parse_mdhd_data(input: &mut Stream<'_>) -> ModalResult { + let be_u32_or_u64 = |version: u8| { + let be_u64_type_fix = + |input: &mut Stream<'_>| -> ModalResult { be_u64.parse_next(input) }; + match version { + 0 => be_u32_as_u64, + 1 => be_u64_type_fix, + _ => unreachable!(), + } + }; + + trace( + "mdhd", + seq!(MediaHeaderAtom { + version: version + .verify(|version| *version <= 1) + .context(StrContext::Expected(StrContextValue::Description( + "expected version 0 or 1" + ))), + flags: flags3, + creation_time: be_u32_or_u64(version), + modification_time: be_u32_or_u64(version), + timescale: be_u32, + duration: be_u32_or_u64(version), + language: be_u16.map(LanguageCode::from), + pre_defined: be_u16, + }) + .context(StrContext::Label("mdhd")), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available mdhd test data files + #[test] + fn test_mdhd_roundtrip() { + test_atom_roundtrip::(MDHD); + } +} diff --git a/src/atom/leaf/mvhd.rs b/src/atom/leaf/mvhd.rs new file mode 100644 index 0000000..81c2c95 --- /dev/null +++ b/src/atom/leaf/mvhd.rs @@ -0,0 +1,212 @@ +use bon::Builder; + +use std::time::Duration; + +use crate::{ + atom::{ + util::{mp4_timestamp_now, scaled_duration, unscaled_duration}, + FourCC, + }, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +pub const MVHD: FourCC = FourCC::new(b"mvhd"); + +#[derive(Debug, Clone, Builder)] +pub struct MovieHeaderAtom { + /// Version of the mvhd atom format (0 or 1) + #[builder(default = 0)] + pub version: u8, + /// Flags for the mvhd atom (usually all zeros) + #[builder(default = [0u8; 3])] + pub flags: [u8; 3], + /// When the movie was created (seconds since Jan 1, 1904 UTC) + #[builder(default = mp4_timestamp_now())] + pub creation_time: u64, + /// When the movie was last modified (seconds since Jan 1, 1904 UTC) + #[builder(default = mp4_timestamp_now())] + pub modification_time: u64, + /// Number of time units per second (e.g., 90000 for 90kHz) + pub timescale: u32, + /// Duration of the movie in timescale units + pub duration: u64, + /// Playback rate (1.0 = normal speed, 2.0 = double speed) + #[builder(default = 1.0)] + pub rate: f32, + /// Audio volume level (1.0 = full volume, 0.0 = muted) + #[builder(default = 1.0)] + pub volume: f32, + #[builder(default)] + pub reserved: [u8; 10], + /// 3x3 transformation matrix for video display positioning/rotation + pub matrix: Option<[i32; 9]>, + /// Time when preview starts (in timescale units) + #[builder(default = 0)] + pub preview_time: u32, + /// Duration of the preview (in timescale units) + #[builder(default = 0)] + pub preview_duration: u32, + /// Time of poster frame to display when movie is not playing + #[builder(default = 0)] + pub poster_time: u32, + /// Start time of current selection (in timescale units) + #[builder(default = 0)] + pub selection_time: u32, + /// Duration of current selection (in timescale units) + #[builder(default = 0)] + pub selection_duration: u32, + /// Current playback time position (in timescale units) + #[builder(default = 0)] + pub current_time: u32, + /// ID to use for the next track added to this movie + pub next_track_id: u32, +} + +impl MovieHeaderAtom { + pub fn update_duration(&mut self, mut closure: F) -> &mut Self + where + F: FnMut(Duration) -> Duration, + { + self.duration = scaled_duration( + closure(unscaled_duration(self.duration, u64::from(self.timescale))), + u64::from(self.timescale), + ); + self + } + + pub fn duration(&self) -> Duration { + unscaled_duration(self.duration, u64::from(self.timescale)) + } +} + +impl ParseAtomData for MovieHeaderAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, MVHD); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_mvhd_data.parse(stream(input))?) + } +} + +impl SerializeAtom for MovieHeaderAtom { + fn atom_type(&self) -> FourCC { + MVHD + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_mvhd_atom(self) + } +} + +mod serializer { + use crate::atom::util::serializer::{fixed_point_16x16, fixed_point_8x8}; + + use super::MovieHeaderAtom; + + pub fn serialize_mvhd_atom(mvhd: MovieHeaderAtom) -> Vec { + let mut data = Vec::new(); + + // Determine version based on whether values fit in 32-bit + let needs_64_bit = mvhd.creation_time > u64::from(u32::MAX) + || mvhd.modification_time > u64::from(u32::MAX) + || mvhd.duration > u64::from(u32::MAX); + + let version: u8 = if needs_64_bit { 1 } else { 0 }; + + let be_u32_or_u64 = |v: u64| match version { + 0 => u32::try_from(v).unwrap().to_be_bytes().to_vec(), + 1 => v.to_be_bytes().to_vec(), + _ => unreachable!(), + }; + + data.extend(version.to_be_bytes()); + data.extend(mvhd.flags); + data.extend(be_u32_or_u64(mvhd.creation_time)); + data.extend(be_u32_or_u64(mvhd.modification_time)); + data.extend(mvhd.timescale.to_be_bytes()); + data.extend(be_u32_or_u64(mvhd.duration)); + data.extend(fixed_point_16x16(mvhd.rate)); + data.extend(fixed_point_8x8(mvhd.volume)); + data.extend(mvhd.reserved); + if let Some(matrix) = mvhd.matrix { + for value in matrix { + data.extend(value.to_be_bytes()); + } + } + data.extend_from_slice(&mvhd.preview_time.to_be_bytes()); + data.extend_from_slice(&mvhd.preview_duration.to_be_bytes()); + data.extend_from_slice(&mvhd.poster_time.to_be_bytes()); + data.extend_from_slice(&mvhd.selection_time.to_be_bytes()); + data.extend_from_slice(&mvhd.selection_duration.to_be_bytes()); + data.extend_from_slice(&mvhd.current_time.to_be_bytes()); + data.extend_from_slice(&mvhd.next_track_id.to_be_bytes()); + + data + } +} + +mod parser { + use winnow::{ + binary::{be_i32, be_u32, be_u64, u8}, + combinator::{opt, seq, trace}, + error::StrContext, + ModalResult, Parser, + }; + + use super::MovieHeaderAtom; + use crate::atom::util::parser::{ + be_u32_as_u64, fixed_array, fixed_point_16x16, fixed_point_8x8, flags3, version_0_or_1, + Stream, + }; + + pub fn parse_mvhd_data(input: &mut Stream<'_>) -> ModalResult { + let be_u32_or_u64 = |version: u8| { + let be_u64_type_fix = + |input: &mut Stream<'_>| -> ModalResult { be_u64.parse_next(input) }; + match version { + 0 => be_u32_as_u64, + 1 => be_u64_type_fix, + _ => unreachable!(), + } + }; + + trace( + "mvhd", + seq!(MovieHeaderAtom { + version: version_0_or_1, + flags: flags3, + creation_time: be_u32_or_u64(version).context(StrContext::Label("creation_time")), + modification_time: be_u32_or_u64(version) + .context(StrContext::Label("modification_time")), + timescale: be_u32.context(StrContext::Label("timescale")), + duration: be_u32_or_u64(version).context(StrContext::Label("duration")), + rate: fixed_point_16x16.context(StrContext::Label("rate")), + volume: fixed_point_8x8.context(StrContext::Label("volume")), + reserved: fixed_array(u8).context(StrContext::Label("reserved")), + matrix: opt(fixed_array(be_i32)).context(StrContext::Label("matrix")), + preview_time: be_u32.context(StrContext::Label("preview_time")), + preview_duration: be_u32.context(StrContext::Label("preview_duration")), + poster_time: be_u32.context(StrContext::Label("poster_time")), + selection_time: be_u32.context(StrContext::Label("selection_time")), + selection_duration: be_u32.context(StrContext::Label("selection_duration")), + current_time: be_u32.context(StrContext::Label("current_time")), + next_track_id: be_u32.context(StrContext::Label("next_track_id")), + }), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available mvhd test data files + #[test] + fn test_mvhd_roundtrip() { + test_atom_roundtrip::(MVHD); + } +} diff --git a/src/atom/leaf/sbgp.rs b/src/atom/leaf/sbgp.rs new file mode 100644 index 0000000..b3e4608 --- /dev/null +++ b/src/atom/leaf/sbgp.rs @@ -0,0 +1,3 @@ +use crate::FourCC; + +pub const SBGP: FourCC = FourCC::new(b"sbgp"); diff --git a/src/atom/leaf/sgpd.rs b/src/atom/leaf/sgpd.rs new file mode 100644 index 0000000..15f29fd --- /dev/null +++ b/src/atom/leaf/sgpd.rs @@ -0,0 +1,3 @@ +use crate::FourCC; + +pub const SGPD: FourCC = FourCC::new(b"sgpd"); diff --git a/src/atom/leaf/smhd.rs b/src/atom/leaf/smhd.rs new file mode 100644 index 0000000..8ea5843 --- /dev/null +++ b/src/atom/leaf/smhd.rs @@ -0,0 +1,89 @@ +use crate::{atom::FourCC, parser::ParseAtomData, writer::SerializeAtom, ParseError}; + +pub const SMHD: FourCC = FourCC::new(b"smhd"); + +#[derive(Debug, Clone, Default)] +pub struct SoundMediaHeaderAtom { + /// Version of the smhd atom format (0) + pub version: u8, + /// Flags for the smhd atom (usually all zeros) + pub flags: [u8; 3], + /// Audio balance (fixed-point 8.8 format, 0.0 = center) + /// Negative values favor left channel, positive favor right + pub balance: f32, + /// Reserved field + pub reserved: [u8; 2], +} + +impl ParseAtomData for SoundMediaHeaderAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, SMHD); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_smhd_data.parse(stream(input))?) + } +} + +impl SerializeAtom for SoundMediaHeaderAtom { + fn atom_type(&self) -> FourCC { + SMHD + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_smhd_data(self) + } +} + +mod serializer { + use crate::atom::util::serializer::fixed_point_8x8; + + use super::SoundMediaHeaderAtom; + + pub fn serialize_smhd_data(smhd: SoundMediaHeaderAtom) -> Vec { + let mut data = Vec::new(); + + data.push(smhd.version); + data.extend(smhd.flags); + data.extend(fixed_point_8x8(smhd.balance)); + data.extend(smhd.reserved); + + data + } +} + +mod parser { + use winnow::{ + combinator::{seq, trace}, + error::StrContext, + ModalResult, Parser, + }; + + use super::SoundMediaHeaderAtom; + use crate::atom::util::parser::{byte_array, fixed_point_8x8, version, Stream}; + + pub fn parse_smhd_data(input: &mut Stream<'_>) -> ModalResult { + trace( + "smhd", + seq!(SoundMediaHeaderAtom { + version: version, + flags: byte_array.context(StrContext::Label("flags")), + balance: fixed_point_8x8.context(StrContext::Label("balance")), + reserved: byte_array.context(StrContext::Label("reserved")), + }) + .context(StrContext::Label("chpl")), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available smhd test data files + #[test] + fn test_smhd_roundtrip() { + test_atom_roundtrip::(SMHD); + } +} diff --git a/src/atom/leaf/stco_co64.rs b/src/atom/leaf/stco_co64.rs new file mode 100644 index 0000000..8b6cca0 --- /dev/null +++ b/src/atom/leaf/stco_co64.rs @@ -0,0 +1,322 @@ +use bon::bon; +use derive_more::{Deref, DerefMut}; +use std::fmt; + +use crate::{ + atom::{ + util::{DebugList, DebugUpperHex}, + FourCC, + }, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +#[cfg(feature = "experimental-trim")] +use {crate::atom::stsz::RemovedSampleSizes, anyhow::anyhow, std::ops::Range}; + +pub const STCO: FourCC = FourCC::new(b"stco"); +pub const CO64: FourCC = FourCC::new(b"co64"); + +#[derive(Default, Clone, Deref, DerefMut, PartialEq, Eq)] +pub struct ChunkOffsets(Vec); + +impl ChunkOffsets { + pub fn into_inner(self) -> Vec { + self.0 + } + + pub fn inner(&self) -> &[u64] { + &self.0 + } +} + +impl From> for ChunkOffsets { + fn from(value: Vec) -> Self { + Self(value) + } +} + +impl FromIterator for ChunkOffsets { + fn from_iter>(iter: T) -> Self { + Self(Vec::from_iter(iter)) + } +} + +impl fmt::Debug for ChunkOffsets { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&DebugList::new(self.0.iter().map(DebugUpperHex), 10), f) + } +} + +/// Chunk Offset Atom - contains file offsets of chunks +#[derive(Default, Debug, Clone)] +pub struct ChunkOffsetAtom { + /// Version of the stco atom format (0) + pub version: u8, + /// Flags for the stco atom (usually all zeros) + pub flags: [u8; 3], + /// List of chunk offsets + pub chunk_offsets: ChunkOffsets, + /// Whether this uses 64-bit offsets (co64) or 32-bit (stco) + pub is_64bit: bool, +} + +#[cfg(feature = "experimental-trim")] +#[derive(Debug)] +pub(crate) enum ChunkOffsetOperationUnresolved { + Remove(Range), + Insert { + /// unadjusted chunk index used to get reference offset + chunk_index_unadjusted: usize, + /// chunk index that takes into account all previous operations + chunk_index: usize, + /// sample indices used to calculate delta from old offset + sample_indices: Range, + }, + ShiftRight { + /// unadjusted chunk index used to get reference offset + chunk_index_unadjusted: usize, + /// chunk index that takes into account all previous operations + chunk_index: usize, + /// sample indices used to calculate delta from old offset + sample_indices: Range, + }, +} + +#[cfg(feature = "experimental-trim")] +impl ChunkOffsetOperationUnresolved { + pub fn resolve( + self, + chunk_offsets: &ChunkOffsets, + removed_sample_sizes: &RemovedSampleSizes, + ) -> anyhow::Result { + let derive_new_offset = |chunk_index: usize, sample_indices: Range| { + let prev_offset = *chunk_offsets + .get(chunk_index) + .ok_or_else(|| anyhow!("chunk index {chunk_index} not found"))?; + + let delta = removed_sample_sizes + .get_sizes(sample_indices.clone()) + .ok_or_else(|| anyhow!("sample indices {sample_indices:?} not found"))? + .iter() + .map(|s| *s as u64) + .sum::(); + + Ok::(prev_offset + delta) + }; + + Ok(match self { + Self::Remove(chunk_offsets) => ChunkOffsetOperation::Remove(chunk_offsets), + Self::Insert { + chunk_index_unadjusted, + chunk_index, + sample_indices, + } => { + let offset = derive_new_offset(chunk_index_unadjusted - 1, sample_indices)?; + ChunkOffsetOperation::Insert(chunk_index, offset) + } + Self::ShiftRight { + chunk_index_unadjusted, + chunk_index, + sample_indices, + } => { + let new_offset = derive_new_offset(chunk_index_unadjusted, sample_indices)?; + ChunkOffsetOperation::Replace(chunk_index, new_offset) + } + }) + } +} + +#[cfg(feature = "experimental-trim")] +#[derive(Debug)] +pub(crate) enum ChunkOffsetOperation { + Remove(Range), + Insert(usize, u64), + Replace(usize, u64), +} + +#[bon] +impl ChunkOffsetAtom { + #[builder] + pub fn new( + #[builder(default = 0)] version: u8, + #[builder(default = [0u8; 3])] flags: [u8; 3], + #[builder(with = FromIterator::from_iter)] chunk_offsets: Vec, + #[builder(default = false)] is_64bit: bool, + ) -> Self { + Self { + version, + flags, + chunk_offsets: chunk_offsets.into(), + is_64bit, + } + } + + /// Returns the total number of chunks + pub fn chunk_count(&self) -> usize { + self.chunk_offsets.len() + } + + /// Applies a list of operations + #[cfg(feature = "experimental-trim")] + pub(crate) fn apply_operations(&mut self, ops: Vec) { + for op in ops { + match op { + ChunkOffsetOperation::Remove(chunk_indices_to_remove) => { + self.chunk_offsets.drain(chunk_indices_to_remove); + } + ChunkOffsetOperation::Insert(chunk_index, offset) => { + self.chunk_offsets.insert(chunk_index, offset); + } + ChunkOffsetOperation::Replace(chunk_index, new_offset) => { + let chunk = self + .chunk_offsets + .get_mut(chunk_index) + .expect("chunk offset must exist"); + *chunk = new_offset; + } + } + } + } +} + +impl ChunkOffsetAtomBuilder { + pub fn chunk_offset( + self, + chunk_offset: impl Into, + ) -> ChunkOffsetAtomBuilder> + where + S::ChunkOffsets: chunk_offset_atom_builder::IsUnset, + { + self.chunk_offsets(vec![chunk_offset.into()]) + } +} + +impl ParseAtomData for ChunkOffsetAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, STCO, CO64); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(match atom_type { + STCO => parser::parse_stco_data.parse(stream(input))?, + CO64 => parser::parse_co64_data.parse(stream(input))?, + _ => unreachable!(), + }) + } +} + +impl SerializeAtom for ChunkOffsetAtom { + fn atom_type(&self) -> FourCC { + // Use the appropriate atom type based on is_64bit + if self.is_64bit { + CO64 + } else { + STCO + } + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_stco_co64_data(self) + } +} + +mod serializer { + use crate::atom::{util::serializer::be_u32, ChunkOffsetAtom}; + + pub fn serialize_stco_co64_data(atom: ChunkOffsetAtom) -> Vec { + let mut data = Vec::new(); + + data.push(atom.version); + data.extend(atom.flags); + data.extend(be_u32( + atom.chunk_offsets + .len() + .try_into() + .expect("chunk offsets length must fit in u32"), + )); + + atom.chunk_offsets.0.into_iter().for_each(|offset| { + if atom.is_64bit { + data.extend(offset.to_be_bytes()); + } else { + data.extend(be_u32( + offset.try_into().expect("chunk offset must fit in u32"), + )) + } + }); + + data + } +} + +mod parser { + use winnow::{ + binary::{be_u32, be_u64}, + combinator::{empty, repeat, seq, trace}, + error::{ContextError, ErrMode, StrContext}, + ModalResult, Parser, + }; + + use super::{ChunkOffsetAtom, ChunkOffsets}; + use crate::atom::util::parser::{byte_array, version, Stream}; + + pub fn parse_stco_data(input: &mut Stream<'_>) -> ModalResult { + parse_stco_co64_data_inner(false).parse_next(input) + } + + pub fn parse_co64_data(input: &mut Stream<'_>) -> ModalResult { + parse_stco_co64_data_inner(true).parse_next(input) + } + + fn parse_stco_co64_data_inner<'i>( + is_64bit: bool, + ) -> impl Parser, ChunkOffsetAtom, ErrMode> { + trace( + if is_64bit { "co64" } else { "stco" }, + move |input: &mut Stream<'_>| { + seq!(ChunkOffsetAtom { + version: version, + flags: byte_array.context(StrContext::Label("flags")), + chunk_offsets: chunk_offsets(is_64bit) + .map(ChunkOffsets) + .context(StrContext::Label("chunk_offsets")), + is_64bit: empty.value(is_64bit), + }) + .parse_next(input) + }, + ) + } + + fn chunk_offsets<'i>( + is_64bit: bool, + ) -> impl Parser, Vec, ErrMode> { + trace("chunk_offsets", move |input: &mut Stream<'_>| { + let entry_count = be_u32.parse_next(input)?; + repeat(entry_count as usize, chunk_offset(is_64bit)).parse_next(input) + }) + } + + fn chunk_offset<'i>(is_64bit: bool) -> impl Parser, u64, ErrMode> { + trace("chunk_offset", move |input: &mut Stream<'_>| { + if is_64bit { + be_u64.parse_next(input) + } else { + be_u32.map(|v| v as u64).parse_next(input) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available stco/co64 test data files + #[test] + fn test_stco_co64_roundtrip() { + test_atom_roundtrip::(STCO); + test_atom_roundtrip::(CO64); + } +} diff --git a/src/atom/leaf/stsc.rs b/src/atom/leaf/stsc.rs new file mode 100644 index 0000000..b5e8b42 --- /dev/null +++ b/src/atom/leaf/stsc.rs @@ -0,0 +1,1030 @@ +use bon::{bon, Builder}; +use derive_more::{Deref, DerefMut}; + +use std::fmt; + +use crate::{ + atom::{util::DebugList, FourCC}, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +#[cfg(feature = "experimental-trim")] +use { + crate::atom::stco_co64::ChunkOffsetOperationUnresolved, + rangemap::RangeSet, + std::{iter::Peekable, ops::Range, slice}, +}; + +pub const STSC: FourCC = FourCC::new(b"stsc"); + +#[derive(Default, Clone, Deref, DerefMut)] +pub struct SampleToChunkEntries(Vec); + +impl SampleToChunkEntries { + pub fn inner(&self) -> &[SampleToChunkEntry] { + &self.0 + } + + #[cfg(feature = "experimental-trim")] + fn expanded_iter(&self, total_chunks: usize) -> ExpandedSampleToChunkEntryIter<'_> { + ExpandedSampleToChunkEntryIter::new(total_chunks, &self.0) + } +} + +impl From> for SampleToChunkEntries { + fn from(inner: Vec) -> Self { + Self(inner) + } +} + +impl fmt::Debug for SampleToChunkEntries { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&DebugList::new(self.0.iter(), 10), f) + } +} + +#[derive(Debug, Clone)] +#[cfg(feature = "experimental-trim")] +struct ExpandedSampleToChunkEntry { + pub chunk_index: usize, + pub sample_indices: Range, + pub samples_per_chunk: u32, + pub sample_description_index: u32, +} + +/// Iterates over [`SampleToChunkEntry`]s expanded to a single entry per chunk. +#[cfg(feature = "experimental-trim")] +struct ExpandedSampleToChunkEntryIter<'a> { + total_chunks: usize, + next_sample_index: usize, + current_entry: Option<(&'a SampleToChunkEntry, usize, usize)>, + iter: Peekable>, +} + +#[cfg(feature = "experimental-trim")] +impl<'a> ExpandedSampleToChunkEntryIter<'a> { + fn new(total_chunks: usize, entries: &'a [SampleToChunkEntry]) -> Self { + let iter = entries.iter().peekable(); + Self { + total_chunks, + next_sample_index: 0, + current_entry: None, + iter, + } + } +} + +#[cfg(feature = "experimental-trim")] +impl<'a> Iterator for ExpandedSampleToChunkEntryIter<'a> { + type Item = ExpandedSampleToChunkEntry; + + fn next(&mut self) -> Option { + let (entry, chunk_index, chunk_count) = self.current_entry.take().or_else(|| { + let entry = self.iter.next()?; + let chunk_index = entry.first_chunk as usize - 1; + let chunk_count = match self.iter.peek() { + Some(next_entry) => (next_entry.first_chunk - entry.first_chunk) as usize, + None => self.total_chunks - chunk_index, + }; + Some((entry, chunk_index, chunk_count)) + })?; + + let first_sample_index = self.next_sample_index; + self.next_sample_index += entry.samples_per_chunk as usize; + + if chunk_count > 1 { + self.current_entry = Some((entry, chunk_index + 1, chunk_count - 1)); + } + + let sample_count = entry.samples_per_chunk as usize; + let last_sample_index = first_sample_index + sample_count.saturating_sub(1); + let sample_indices = first_sample_index..last_sample_index + 1; + + // probably best not to use the builder in a large loop + Some(ExpandedSampleToChunkEntry { + chunk_index, + sample_indices, + samples_per_chunk: entry.samples_per_chunk, + sample_description_index: entry.sample_description_index, + }) + } + + fn size_hint(&self) -> (usize, Option) { + (self.total_chunks, Some(self.total_chunks)) + } +} + +/// Sample-to-Chunk entry - maps samples to chunks +#[derive(Debug, Clone, PartialEq, Eq, Builder)] +pub struct SampleToChunkEntry { + /// First chunk number (1-based) that uses this entry + pub first_chunk: u32, + /// Number of samples in each chunk + pub samples_per_chunk: u32, + /// Sample description index (1-based, references stsd atom) + pub sample_description_index: u32, +} + +/// Sample-to-Chunk Atom - contains sample-to-chunk mapping table +#[derive(Default, Debug, Clone)] +pub struct SampleToChunkAtom { + /// Version of the stsc atom format (0) + pub version: u8, + /// Flags for the stsc atom (usually all zeros) + pub flags: [u8; 3], + /// List of sample-to-chunk entries + pub entries: SampleToChunkEntries, +} + +#[bon] +impl SampleToChunkAtom { + #[builder] + pub fn new( + #[builder(default = 0)] version: u8, + #[builder(default = [0u8; 3])] flags: [u8; 3], + #[builder(with = FromIterator::from_iter)] entries: Vec, + ) -> Self { + Self { + version, + flags, + entries: entries.into(), + } + } + + /// Removes sample indices from the sample-to-chunk mapping table, + /// and returns the indices (starting from zero) of any chunks which are now empty (and should be removed) + /// + /// TODO: return a list of operations to apply to chunk offsets + /// - [x] Remove(RangeSet of chunk indices to remove) + /// - [ ] Insert(chunk index, sample index range) (will need to be cross referenced with sample_sizes) + /// - [ ] ShiftLeft(chunk index, sample index range) (ditto '') + /// + /// `sample_indices_to_remove` must contain contiguous sample indices as a single range, + /// multiple ranges must not overlap. + #[cfg(feature = "experimental-trim")] + pub(crate) fn remove_sample_indices( + &mut self, + sample_indices_to_remove: &RangeSet, + total_chunks: usize, + ) -> Vec { + let mut chunk_ops = Vec::new(); + + let mut num_removed_chunks = 0usize; + let mut num_inserted_chunks = 0usize; + + struct Context<'a> { + sample_indices_to_remove: &'a RangeSet, + + chunk_ops: &'a mut Vec, + + num_removed_chunks: &'a mut usize, + num_inserted_chunks: &'a mut usize, + + next_entries: &'a mut Vec, + } + + impl<'a> Context<'a> { + pub fn process_entry(&mut self, entry: ExpandedSampleToChunkEntry) { + // get only the sample indices that overlap with this entry + if let Some(sample_indices_to_remove) = + entry_samples_to_remove(&entry.sample_indices, self.sample_indices_to_remove) + .first() + { + if sample_indices_to_remove.len() >= entry.sample_indices.len() { + // sample indices to remove fully includes this entry + self.remove_chunk_offset(entry.chunk_index); + *self.num_removed_chunks += 1; + } else { + self.process_entry_partial_match(sample_indices_to_remove, entry); + } + } else { + // no samples/chunks to remove for this entry + match self.next_entries.last() { + Some(prev_entry) + if prev_entry.samples_per_chunk == entry.samples_per_chunk + && prev_entry.sample_description_index + == entry.sample_description_index => + { + // redundand with prev entry + } + _ => { + self.insert_or_update_chunk_entry(SampleToChunkEntry { + first_chunk: (entry.chunk_index + 1) as u32, + samples_per_chunk: entry.samples_per_chunk, + sample_description_index: entry.sample_description_index, + }); + } + } + } + } + + fn process_entry_partial_match( + &mut self, + sample_indices_to_remove: &Range, + entry: ExpandedSampleToChunkEntry, + ) { + if sample_indices_to_remove.start == entry.sample_indices.start { + /* + * process trim start + * + * e.g. + * ------------------------------------------------------------------ + * | 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | + * | ^-----------------^ ^---------------------------------------^ | + * | trim range chunk 0 (remainder) | + * | |- offset --------->| (+= size(trim...)) | + * ------------------------------------------------------------------ + */ + + // chunk offset increases by the size of the removed samples + self.shift_chunk_offset_right( + entry.chunk_index, + sample_indices_to_remove.clone(), + ); + + // chunk sample count decreases by n removed samples + self.insert_or_update_chunk_entry(SampleToChunkEntry { + first_chunk: entry.chunk_index as u32 + 1, + samples_per_chunk: entry.samples_per_chunk + - sample_indices_to_remove.len() as u32, + sample_description_index: entry.sample_description_index, + }); + + // process any additional trim ranges (e.g. middle and/or end) + self.process_entry(ExpandedSampleToChunkEntry { + chunk_index: entry.chunk_index, + sample_indices: sample_indices_to_remove.end..entry.sample_indices.end, + samples_per_chunk: entry.samples_per_chunk + - sample_indices_to_remove.len() as u32, + sample_description_index: entry.sample_description_index, + }); + } else if sample_indices_to_remove.end == entry.sample_indices.end { + /* + * process trim end + * + * e.g. + * ------------------------------------------------------------------ + * | 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | + * | ^------------------------------^ ^---------------------------^ | + * | chunk 0 (remainder) trim range | + * ------------------------------------------------------------------ + */ + + // chunk sample count decreases by n removed samples + self.insert_or_update_chunk_entry(SampleToChunkEntry { + first_chunk: entry.chunk_index as u32 + 1, + samples_per_chunk: entry.samples_per_chunk + - sample_indices_to_remove.len() as u32, + sample_description_index: entry.sample_description_index, + }); + + // since we reached the end of the chunk/entry, and trim ranges are processed in order, + // there are no additional matches to be had + } else { + /* + * process trim middle + * + * e.g. + * ------------------------------------------------------------------ + * | 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | + * | ^------------^ ^------------------^ ^------------------------^ | + * | chunk 0 | trim range | new chunk 1 | + * | |- offsetA + size(trim...) = offsetB (chunk 1) | + * ------------------------------------------------------------------ + */ + + // insert a new chunk after the current one, + // whose offset is the existing offset + size of removed samples + self.insert_chunk_offset( + entry.chunk_index + 1, + sample_indices_to_remove.clone(), + ); + + // insert or update entry for the current chunk + self.insert_or_update_chunk_entry(SampleToChunkEntry { + first_chunk: entry.chunk_index as u32 + 1, + samples_per_chunk: (entry.sample_indices.len() + - (sample_indices_to_remove.start..entry.sample_indices.end).len()) + as u32, + sample_description_index: entry.sample_description_index, + }); + + // insert or update entry for the new chunk + self.insert_or_update_chunk_entry(SampleToChunkEntry { + first_chunk: entry.chunk_index as u32 + 2, + samples_per_chunk: (entry.sample_indices.len() + - (entry.sample_indices.start..sample_indices_to_remove.end).len()) + as u32, + sample_description_index: entry.sample_description_index, + }); + + // increment the counter after we've inserted the entry + *self.num_inserted_chunks += 1; + + // process any additional trim ranges on the new chunk (e.g. middle and/or end) + self.process_entry(ExpandedSampleToChunkEntry { + chunk_index: entry.chunk_index + 1, + sample_indices: sample_indices_to_remove.end..entry.sample_indices.end, + samples_per_chunk: entry.samples_per_chunk, + sample_description_index: entry.sample_description_index, + }); + } + } + + fn adjusted_chunk_index(&self, chunk_index: usize) -> usize { + chunk_index + *self.num_inserted_chunks - *self.num_removed_chunks + } + + fn insert_or_update_chunk_entry(&mut self, mut entry: SampleToChunkEntry) { + entry.first_chunk = self.adjusted_chunk_index(entry.first_chunk as usize) as u32; + match self.next_entries.last_mut() { + Some(prev_entry) if prev_entry.first_chunk == entry.first_chunk => { + *prev_entry = entry + } + _ => { + self.next_entries.push(entry); + } + } + } + + /// increase chunk offset by the size of a removed sample range + fn shift_chunk_offset_right( + &mut self, + chunk_index: usize, + removed_sample_indices: Range, + ) { + self.chunk_ops + .push(ChunkOffsetOperationUnresolved::ShiftRight { + chunk_index_unadjusted: chunk_index, + chunk_index: self.adjusted_chunk_index(chunk_index), + sample_indices: removed_sample_indices, + }); + } + + fn insert_chunk_offset( + &mut self, + chunk_index: usize, + removed_sample_indices: Range, + ) { + self.chunk_ops.push(ChunkOffsetOperationUnresolved::Insert { + chunk_index_unadjusted: chunk_index, + chunk_index: self.adjusted_chunk_index(chunk_index), + sample_indices: removed_sample_indices, + }); + } + + fn remove_chunk_offset(&mut self, chunk_index: usize) { + let chunk_index = self.adjusted_chunk_index(chunk_index); + match self.chunk_ops.last_mut() { + Some(ChunkOffsetOperationUnresolved::Remove(prev_op)) + if prev_op.start == chunk_index => + { + // either merge with the previous remove range + prev_op.end += 1; + } + _ => { + // or insert a new remove range + self.chunk_ops.push(ChunkOffsetOperationUnresolved::Remove( + chunk_index..chunk_index + 1, + )); + } + } + } + } + + let num_sample_ranges_to_remove = sample_indices_to_remove.iter().count(); + let prev_len = self.entries.len(); + self.entries = SampleToChunkEntries(self.entries.expanded_iter(total_chunks).fold( + // TODO: evaluate the actual worst-case additional entries + Vec::with_capacity(prev_len + (num_sample_ranges_to_remove * 4)), + |mut next_entries, entry| { + let mut ctx = Context { + sample_indices_to_remove, + + chunk_ops: &mut chunk_ops, + + num_removed_chunks: &mut num_removed_chunks, + num_inserted_chunks: &mut num_inserted_chunks, + + next_entries: &mut next_entries, + }; + + ctx.process_entry(entry); + + next_entries + }, + )); + self.entries.shrink_to_fit(); + + chunk_ops + } +} + +#[cfg(feature = "experimental-trim")] +fn entry_samples_to_remove( + entry_sample_indices: &Range, + sample_indices_to_remove: &RangeSet, +) -> RangeSet { + let mut entry_samples_to_remove = RangeSet::new(); + + for range in sample_indices_to_remove.overlapping(entry_sample_indices) { + let range = + range.start.max(entry_sample_indices.start)..range.end.min(entry_sample_indices.end); + entry_samples_to_remove.insert(range); + } + + entry_samples_to_remove +} + +impl SampleToChunkAtomBuilder { + pub fn entry( + self, + entry: impl Into, + ) -> SampleToChunkAtomBuilder> + where + S::Entries: sample_to_chunk_atom_builder::IsUnset, + { + self.entries(vec![entry.into()]) + } +} + +impl From> for SampleToChunkAtom { + fn from(entries: Vec) -> Self { + SampleToChunkAtom { + version: 0, + flags: [0u8; 3], + entries: entries.into(), + } + } +} + +impl ParseAtomData for SampleToChunkAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, STSC); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_stsc_data.parse(stream(input))?) + } +} + +impl SerializeAtom for SampleToChunkAtom { + fn atom_type(&self) -> FourCC { + STSC + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_stsc_atom(self) + } +} + +mod serializer { + use crate::atom::util::serializer::be_u32; + + use super::SampleToChunkAtom; + + pub fn serialize_stsc_atom(stsc: SampleToChunkAtom) -> Vec { + let mut data = Vec::new(); + + data.push(stsc.version); + data.extend(stsc.flags); + + data.extend(be_u32( + stsc.entries + .len() + .try_into() + .expect("entries len should fit in u32"), + )); + + for entry in stsc.entries.iter() { + data.extend(entry.first_chunk.to_be_bytes()); + data.extend(entry.samples_per_chunk.to_be_bytes()); + data.extend(entry.sample_description_index.to_be_bytes()); + } + + data + } +} + +mod parser { + use winnow::{ + binary::{be_u32, length_repeat}, + combinator::{seq, trace}, + error::{StrContext, StrContextValue}, + ModalResult, Parser, + }; + + use super::{SampleToChunkAtom, SampleToChunkEntries, SampleToChunkEntry}; + use crate::atom::util::parser::{flags3, version, Stream}; + + pub fn parse_stsc_data(input: &mut Stream<'_>) -> ModalResult { + trace( + "stsc", + seq!(SampleToChunkAtom { + version: version, + flags: flags3, + entries: length_repeat(be_u32, entry) + .map(SampleToChunkEntries) + .context(StrContext::Label("entries")), + }) + .context(StrContext::Label("stsc")), + ) + .parse_next(input) + } + + fn entry(input: &mut Stream<'_>) -> ModalResult { + trace( + "entry", + seq!(SampleToChunkEntry { + first_chunk: be_u32 + .verify(|first_chunk| *first_chunk > 0) + .context(StrContext::Label("first_chunk")) + .context(StrContext::Expected(StrContextValue::Description( + "1-based index" + ))), + samples_per_chunk: be_u32 + .verify(|samples_per_chunk| *samples_per_chunk > 0) + .context(StrContext::Label("samples_per_chunk")) + .context(StrContext::Expected(StrContextValue::Description( + "sample count > 0" + ))), + sample_description_index: be_u32 + .context(StrContext::Label("sample_description_index")) + .context(StrContext::Expected(StrContextValue::Description( + "1-based index" + ))), + }) + .context(StrContext::Label("entry")), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available stco/co64 test data files + #[test] + fn test_stsc_roundtrip() { + test_atom_roundtrip::(STSC); + } +} + +#[cfg(feature = "experimental-trim")] +#[cfg(test)] +mod trim_tests { + use super::*; + + struct EntrySamplesToRemoveTestCase { + entry_start_sample_index: usize, + entry_end_sample_index: usize, + sample_indices_to_remove: Vec>, + expected_entry_samples_to_remove: Vec>, + } + + fn test_entry_samples_to_remove(tc: EntrySamplesToRemoveTestCase) { + let sample_indices_to_remove = RangeSet::from_iter(tc.sample_indices_to_remove.into_iter()); + let entry_sample_indices = tc.entry_start_sample_index..tc.entry_end_sample_index + 1; + let actual_entry_samples_to_remove = + entry_samples_to_remove(&entry_sample_indices, &sample_indices_to_remove); + let expected_entry_samples_to_remove = + RangeSet::from_iter(tc.expected_entry_samples_to_remove.into_iter()); + assert_eq!( + actual_entry_samples_to_remove, + expected_entry_samples_to_remove + ); + } + + mod test_entry_samples_to_remove { + use super::*; + + macro_rules! test_entry_samples_to_remove { + ($( + $name:ident => { + $( $field:ident: $value:expr ),+ $(,)? + }, + )*) => { + $( + #[test] + fn $name() { + test_entry_samples_to_remove!(@inner $( $field: $value ),+); + } + )* + }; + + (@inner $( $field:ident: $value:expr ),+) => { + let tc = EntrySamplesToRemoveTestCase { + $( $field: $value ),+, + }; + + test_entry_samples_to_remove(tc); + }; + } + + test_entry_samples_to_remove!( + entry_contained_in_single_range => { + entry_start_sample_index: 800, + entry_end_sample_index: 1200, + sample_indices_to_remove: vec![300..2000], + expected_entry_samples_to_remove: vec![800..1201], + }, + entry_contained_in_multiple_ranges => { + entry_start_sample_index: 800, + entry_end_sample_index: 1200, + sample_indices_to_remove: vec![300..900, 1000..2000], + expected_entry_samples_to_remove: vec![800..900, 1000..1201], + }, + entry_starts_in_single_range => { + entry_start_sample_index: 800, + entry_end_sample_index: 1200, + sample_indices_to_remove: vec![1000..2000], + expected_entry_samples_to_remove: vec![1000..1201], + }, + entry_ends_in_single_range => { + entry_start_sample_index: 800, + entry_end_sample_index: 1200, + sample_indices_to_remove: vec![100..1000], + expected_entry_samples_to_remove: vec![800..1000], + }, + single_range_contained_in_entry => { + entry_start_sample_index: 800, + entry_end_sample_index: 1200, + sample_indices_to_remove: vec![900..1000], + expected_entry_samples_to_remove: vec![900..1000], + }, + ); + } + + #[derive(Builder)] + struct RemoveSampleIndicesTestCase { + #[builder(default = 20)] + total_chunks: usize, + sample_indices_to_remove: Vec>, + expected_removed_chunk_indices: Vec>, + expected_entries: Vec, + } + + fn test_remove_sample_indices_default_stsc() -> SampleToChunkAtom { + SampleToChunkAtom::builder() + .entries(vec![ + // chunks 0..1 (1) + // samples 0..10 (10) + SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 10, + sample_description_index: 1, + }, + // chunks 1..3 (2) + // samples 10..50 (40) + SampleToChunkEntry { + first_chunk: 2, + samples_per_chunk: 20, + sample_description_index: 1, + }, + // chunks 3..9 (6) + // samples 50..110 (60) + SampleToChunkEntry { + first_chunk: 4, + samples_per_chunk: 10, + sample_description_index: 1, + }, + // total chunks = 20 + // chunks 9..20 (11) + // samples 110..220 (100) + SampleToChunkEntry { + first_chunk: 10, + samples_per_chunk: 10, + // NOTE: this is different to test this entry won't be merged + sample_description_index: 2, + }, + ]) + .build() + } + + fn test_remove_sample_indices(mut stsc: SampleToChunkAtom, test_case: F) + where + F: FnOnce(&SampleToChunkAtom) -> RemoveSampleIndicesTestCase, + { + let test_case = test_case(&stsc); + let total_chunks = test_case.total_chunks; + let sample_indices_to_remove = + RangeSet::from_iter(test_case.sample_indices_to_remove.into_iter()); + let actual_chunk_offset_ops = + stsc.remove_sample_indices(&sample_indices_to_remove, total_chunks); + + // TODO: add assertions for all the ops + let actual_removed_chunk_indices = actual_chunk_offset_ops + .iter() + .filter_map(|op| match op { + ChunkOffsetOperationUnresolved::Remove(chunk_offsets) => Some(chunk_offsets), + _ => None, + }) + .cloned() + // chunk ops take into account previous operations having been applied + // adjust ranges to be in terms of input chunk indices (easier to reason about in the test case) + .scan(0usize, |n_removed, range| { + let range = (range.start + *n_removed)..(range.end + *n_removed); + *n_removed += range.len(); + Some(range) + }) + .collect::>(); + + assert_eq!( + actual_removed_chunk_indices, test_case.expected_removed_chunk_indices, + "removed chunk indices don't match what's expected", + ); + + stsc.entries + .iter() + .zip(test_case.expected_entries.iter()) + .enumerate() + .for_each(|(index, (actual, expected))| { + assert_eq!( + actual, expected, + "sample to chunk entries[{index}] doesn't match what's expected\n{:#?}", + stsc.entries, + ); + }); + + assert_eq!( + stsc.entries.0.len(), + test_case.expected_entries.len(), + "expected {} sample to chunk entries, got {}: {:#?}", + test_case.expected_entries.len(), + stsc.entries.len(), + stsc.entries, + ); + } + + macro_rules! test_remove_sample_indices { + ($($name:ident $($stsc:expr)? => $test_case:expr,)*) => { + $( + #[test] + fn $name() { + test_remove_sample_indices!(@inner $($stsc)? => $test_case); + } + )* + }; + (@inner => $test_case:expr) => { + test_remove_sample_indices!(@inner test_remove_sample_indices_default_stsc() => $test_case); + }; + (@inner $stsc:expr => $test_case:expr) => { + test_remove_sample_indices($stsc, $test_case); + }; + } + + // TODO: test inserted and adjusted chunk offsets in addition to removed offsets + mod test_remove_sample_indices { + use super::*; + + test_remove_sample_indices!( + remove_first_entry => |stsc| RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![0..10]). + expected_removed_chunk_indices(vec![0..1]). + expected_entries(stsc.entries[1..].iter().cloned().map(|mut entry| { + entry.first_chunk -= 1; + entry + }).collect::>()).build(), + remove_first_sample_from_first_entry => |stsc| { + let mut expected_entries = stsc.entries.0.clone(); + expected_entries[0].samples_per_chunk -= 1; + RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![0..1]). + expected_removed_chunk_indices(vec![]). + expected_entries(expected_entries).build() + }, + remove_last_sample_from_first_entry => |stsc| { + let mut expected_entries = stsc.entries.0.clone(); + // there's just a single chunk in the first entry + expected_entries[0].samples_per_chunk -= 1; + RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![9..10]). + expected_removed_chunk_indices(vec![]). + expected_entries(expected_entries).build() + }, + remove_sample_from_second_entry => |stsc| { + let mut expected_entries = stsc.entries.0.clone(); + let mut inserted_entry = expected_entries[1].clone(); + expected_entries[1].first_chunk += 1; + inserted_entry.samples_per_chunk -= 1; + expected_entries.insert(1, inserted_entry); + RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![10..11]). + expected_removed_chunk_indices(vec![]). + expected_entries(expected_entries).build() + }, + remove_first_chunk_from_second_entry => |stsc| { + let mut expected_entries = stsc.entries.0.clone(); + expected_entries.iter_mut().skip(2).for_each(|entry| entry.first_chunk -= 1); + RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![10..30]). + expected_removed_chunk_indices(vec![1..2]). + expected_entries(expected_entries).build() + }, + remove_five_samples_from_last_entry_middle => |stsc| { + RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![151..156]). + expected_removed_chunk_indices(vec![]). + // we're removing 5 samples from chunk index 13 + // so the last entry (starting at chunk index 10) should be split into 4 + expected_entries(vec![ + stsc.entries[0].clone(), + stsc.entries[1].clone(), + stsc.entries[2].clone(), + // 1. unaffected chunks: + // chunks 9..13 (4) + // samples 110..150 (40) + SampleToChunkEntry { + first_chunk: 10, + samples_per_chunk: 10, + sample_description_index: 2, + }, + // 2. chunk with samples removed from middle: + // this is the left side of the trim range (1 sample remaining) + // chunks 13..14 (1) + // samples 150..151 (1) + SampleToChunkEntry { + first_chunk: 14, + samples_per_chunk: 1, + sample_description_index: 2, + }, + // 3. chunk with samples removed from middle: + // this is the right side of the trim range where we've inserted a new chunk + // chunks 13..14 (1) -> 14..15 + // samples 156..160 (4) + SampleToChunkEntry { + first_chunk: 15, + samples_per_chunk: 4, + sample_description_index: 2, + }, + // 4. remaining unaffected chunks: + // total chunks = 20 (0 chunks removed) + // chunks 14..19 (5) -> 15..20 + // samples 155..205 (50) (5 samples removed) + SampleToChunkEntry { + first_chunk: 16, + samples_per_chunk: 10, + sample_description_index: 2, + }, + ]).build() + }, + remove_fifteen_samples_from_last_entry_middle => |stsc| { + RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![150..165]). + expected_removed_chunk_indices(vec![13..14]). + // we're removing 15 samples starting from chunk index 13, + // which will remove chunk index 13, and 5 samples from chunk index 14 + // so the last entry (starting at chunk index 10) should be split into 3 + expected_entries(vec![ + stsc.entries[0].clone(), + stsc.entries[1].clone(), + stsc.entries[2].clone(), + // 1. unaffected chunks: + // chunks 9..13 (4) + // samples 110..150 (40) + SampleToChunkEntry { + first_chunk: 10, + samples_per_chunk: 10, + sample_description_index: 2, + }, + // 2. chunk 13..4 removed + // 3. chunk with samples removed: + // chunks 14..15 (1) + // samples 150..155 (5) + SampleToChunkEntry { + first_chunk: 14, + samples_per_chunk: 5, + sample_description_index: 2, + }, + // 4. remaining unaffected chunks: + // total chunks = 19 (1 chunk removed) + // chunks 14..18 (4) + // samples 160..210 (40) (15 samples removed) + SampleToChunkEntry { + first_chunk: 15, + samples_per_chunk: 10, + sample_description_index: 2, + }, + ]).build() + }, + remove_second_entry_merge_first_and_third => |stsc| RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![10..50]). + expected_removed_chunk_indices(vec![1..3]). + expected_entries(vec![ + stsc.entries.first().cloned().unwrap(), + stsc.entries.last().cloned().map(|mut entry| { + entry.first_chunk -= 2; + entry + }).unwrap(), + ]).build(), + remove_second_and_third_entry_no_merge => |stsc| RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![10..110]). + expected_removed_chunk_indices(vec![1..9]). + expected_entries(vec![ + stsc.entries.first().cloned().unwrap(), + stsc.entries.last().cloned().map(|mut entry| { + entry.first_chunk -= 8; + entry + }).unwrap(), + ]).build(), + remove_first_and_last_entry => |stsc| RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![0..10, 110..220]). + expected_removed_chunk_indices(vec![0..1, 9..20]). + expected_entries(vec![ + stsc.entries.get(1).cloned().map(|mut entry| { + entry.first_chunk = 1; + entry + }).unwrap(), + stsc.entries.get(2).cloned().map(|mut entry| { + entry.first_chunk = 3; + entry + }).unwrap(), + ]).build(), + + remove_last_chunk_single_entry { + SampleToChunkAtom::builder(). + entry(SampleToChunkEntry::builder(). + first_chunk(1). + samples_per_chunk(2). + sample_description_index(1). + build(), + ).build() + } => |stsc| RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![38..40]). + expected_removed_chunk_indices(vec![19..20]). + expected_entries(vec![ + stsc.entries.first().cloned().unwrap(), + ]).build(), + + remove_multiple_ranges_from_single_entry { + SampleToChunkAtom::builder(). + entry(SampleToChunkEntry::builder(). + first_chunk(1). + samples_per_chunk(1). + sample_description_index(1). + build(), + ).build() + } => |stsc| RemoveSampleIndicesTestCase::builder(). + total_chunks(100). + sample_indices_to_remove(vec![20..41, 60..81]). + expected_removed_chunk_indices(vec![20..41, 60..81]). + expected_entries(vec![ + stsc.entries.first().cloned().unwrap(), + ]).build(), + + remove_mid_second_chunk_to_mid_last_chunk => |stsc| { + RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![15..215]). + expected_removed_chunk_indices(vec![2..19]). + expected_entries(vec![ + // first entry is unchanged + stsc.entries[0].clone(), + // samples 10..15 remain intact + SampleToChunkEntry { + first_chunk: 2, + samples_per_chunk: 5, + sample_description_index: 1, + }, + // samples 215..220 remain intact + SampleToChunkEntry { + first_chunk: 3, + samples_per_chunk: 5, + sample_description_index: 2, + }, + ]).build() + }, + + remove_mid_fifth_chunk_to_mid_last_chunk => |stsc| { + RemoveSampleIndicesTestCase::builder(). + sample_indices_to_remove(vec![65..215]). + expected_removed_chunk_indices(vec![5..19]). + expected_entries(vec![ + // first two entries are unchanged + stsc.entries[0].clone(), + stsc.entries[1].clone(), + // 4th chunk remains unchanged + SampleToChunkEntry { + first_chunk: 4, + samples_per_chunk: 10, + sample_description_index: 1, + }, + // samples 60..65 remain intact + SampleToChunkEntry { + first_chunk: 5, + samples_per_chunk: 5, + sample_description_index: 1, + }, + // samples 215..220 remain intact + SampleToChunkEntry { + first_chunk: 6, + samples_per_chunk: 5, + sample_description_index: 2, + }, + ]).build() + }, + ); + } +} diff --git a/src/atom/leaf/stsd.rs b/src/atom/leaf/stsd.rs new file mode 100644 index 0000000..7915afa --- /dev/null +++ b/src/atom/leaf/stsd.rs @@ -0,0 +1,251 @@ +use derive_more::Display; + +pub use crate::atom::stsd::extension::{ + BtrtExtension, DecoderSpecificInfo, EsdsExtension, StsdExtension, +}; +use crate::{atom::FourCC, parser::ParseAtomData, writer::SerializeAtom, ParseError}; + +mod audio; +mod extension; +mod text; + +pub use audio::*; +pub use text::*; + +pub const STSD: FourCC = FourCC::new(b"stsd"); + +pub const SAMPLE_ENTRY_MP4A: FourCC = FourCC::new(b"mp4a"); // AAC audio +pub const SAMPLE_ENTRY_AAVD: FourCC = FourCC::new(b"aavd"); // Audible Audio +pub const SAMPLE_ENTRY_TEXT: FourCC = FourCC::new(b"text"); // Plain text + +#[derive(Debug, Clone, Display, PartialEq)] +#[display("{}", self.as_str())] +pub enum SampleEntryType { + /// AAC audio + Mp4a, + /// Audible Audio (can be treated as Mp4a) + Aavd, + /// QuickTime Text Media + Text, + /// Unknown/unsupported sample entry type + Unknown(FourCC), +} + +impl From for SampleEntryType { + fn from(fourcc: FourCC) -> Self { + match fourcc { + SAMPLE_ENTRY_MP4A => SampleEntryType::Mp4a, + SAMPLE_ENTRY_AAVD => SampleEntryType::Aavd, + SAMPLE_ENTRY_TEXT => SampleEntryType::Text, + _ => SampleEntryType::Unknown(fourcc), + } + } +} + +impl From for FourCC { + fn from(value: SampleEntryType) -> Self { + match value { + SampleEntryType::Mp4a => SAMPLE_ENTRY_MP4A, + SampleEntryType::Aavd => SAMPLE_ENTRY_AAVD, + SampleEntryType::Text => SAMPLE_ENTRY_TEXT, + SampleEntryType::Unknown(bytes) => bytes, + } + } +} + +impl SampleEntryType { + fn into_bytes(self) -> [u8; 4] { + FourCC::from(self).into_bytes() + } + + pub fn as_str(&self) -> &str { + match self { + SampleEntryType::Mp4a => SAMPLE_ENTRY_MP4A.as_str(), + SampleEntryType::Aavd => SAMPLE_ENTRY_AAVD.as_str(), + SampleEntryType::Text => SAMPLE_ENTRY_TEXT.as_str(), + SampleEntryType::Unknown(bytes) => bytes.as_str(), + } + } +} + +#[derive(Debug, Clone)] +pub enum SampleEntryData { + Audio(AudioSampleEntry), + Text(TextSampleEntry), + Other(Vec), +} + +#[derive(Debug, Clone)] +pub struct SampleEntry { + /// Sample entry type (4CC code) + pub entry_type: SampleEntryType, + /// Data reference index + pub data_reference_index: u16, + /// Raw sample entry data (codec-specific) + pub data: SampleEntryData, +} + +#[derive(Default, Debug, Clone)] +pub struct SampleDescriptionTableAtom { + /// Version of the stsd atom format (0) + pub version: u8, + /// Flags for the stsd atom (usually all zeros) + pub flags: [u8; 3], + /// List of sample entries + pub entries: Vec, +} + +impl From> for SampleDescriptionTableAtom { + fn from(entries: Vec) -> Self { + SampleDescriptionTableAtom { + version: 0, + flags: [0u8; 3], + entries, + } + } +} + +impl SampleDescriptionTableAtom { + pub fn find_or_create_entry(&mut self, pred: P, default_fn: D) -> &mut SampleEntry + where + P: Fn(&SampleEntry) -> bool, + D: FnOnce() -> SampleEntry, + { + if let Some(index) = self.entries.iter().position(pred) { + return &mut self.entries[index]; + } + self.entries.push(default_fn()); + self.entries.last_mut().unwrap() + } +} + +impl ParseAtomData for SampleDescriptionTableAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, STSD); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_stsd_data.parse(stream(input))?) + } +} + +impl SerializeAtom for SampleDescriptionTableAtom { + fn atom_type(&self) -> FourCC { + STSD + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_stsd_atom(self) + } +} + +mod serializer { + use super::{ + audio::serializer::serialize_audio_sample_entry, + text::serializer::serialize_text_sample_entry, SampleDescriptionTableAtom, SampleEntryData, + }; + use crate::atom::util::serializer::{be_u32, prepend_size_inclusive, SizeU32}; + + pub fn serialize_stsd_atom(stsd: SampleDescriptionTableAtom) -> Vec { + let mut data = Vec::new(); + + data.push(stsd.version); + data.extend(stsd.flags); + data.extend(be_u32( + stsd.entries + .len() + .try_into() + .expect("stsd entries len must fit in u32"), + )); + + for entry in stsd.entries { + data.extend(prepend_size_inclusive::(move || { + let mut entry_data = Vec::new(); + entry_data.extend(entry.entry_type.into_bytes()); + entry_data.extend([0u8; 6]); // reserved + entry_data.extend(entry.data_reference_index.to_be_bytes()); + match entry.data { + SampleEntryData::Audio(audio) => { + entry_data.extend(serialize_audio_sample_entry(audio)); + } + SampleEntryData::Text(text) => { + entry_data.extend(serialize_text_sample_entry(text)); + } + SampleEntryData::Other(other_data) => { + entry_data.extend_from_slice(&other_data); + } + } + entry_data + })); + } + + data + } +} + +mod parser { + use winnow::{ + binary::{be_u16, be_u32, length_repeat}, + combinator::seq, + error::StrContext, + ModalResult, Parser, + }; + + use super::{ + audio::parser::parse_audio_sample_entry, text::parser::parse_text_sample_entry, + SampleDescriptionTableAtom, SampleEntry, SampleEntryData, SampleEntryType, + }; + use crate::atom::util::parser::{ + byte_array, combinators::inclusive_length_and_then, flags3, fourcc, rest_vec, version, + Stream, + }; + + pub fn parse_stsd_data(input: &mut Stream<'_>) -> ModalResult { + seq!(SampleDescriptionTableAtom { + version: version.verify(|v| *v == 0), + flags: flags3, + entries: length_repeat(be_u32, parse_sample_entry) + .context(StrContext::Label("entries")), + }) + .parse_next(input) + } + + fn parse_sample_entry(input: &mut Stream<'_>) -> ModalResult { + inclusive_length_and_then( + be_u32, + seq!(SampleEntry { + entry_type: fourcc + .map(SampleEntryType::from) + .context(StrContext::Label("entry_type")), + _: byte_array::<6>.context(StrContext::Label("reserved")), // reserved + data_reference_index: be_u16.context(StrContext::Label("data_reference_index")), + data: match entry_type { + SampleEntryType::Mp4a | SampleEntryType::Aavd => { + parse_audio_sample_entry + } + SampleEntryType::Text => { + parse_text_sample_entry + } + _ => parse_unknown_sample_entry, + }.context(StrContext::Label("data")), + }), + ) + .parse_next(input) + } + + pub fn parse_unknown_sample_entry(input: &mut Stream<'_>) -> ModalResult { + rest_vec.map(SampleEntryData::Other).parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use crate::atom::test_utils::test_atom_roundtrip; + + use super::*; + + /// Test round-trip for all available stsd test data files + #[test] + fn test_stsd_roundtrip() { + test_atom_roundtrip::(STSD); + } +} diff --git a/src/atom/leaf/stsd/audio.rs b/src/atom/leaf/stsd/audio.rs new file mode 100644 index 0000000..c4f84fa --- /dev/null +++ b/src/atom/leaf/stsd/audio.rs @@ -0,0 +1,71 @@ +use super::StsdExtension; + +#[derive(Default, Debug, Clone)] +pub struct AudioSampleEntry { + pub version: u16, + pub channel_count: u16, + pub sample_size: u16, + pub predefined: u16, + /// 16.16 fixed point + pub sample_rate: f32, + pub extensions: Vec, +} + +impl AudioSampleEntry { + pub fn find_or_create_extension(&mut self, pred: P, default_fn: D) -> &mut StsdExtension + where + P: Fn(&StsdExtension) -> bool, + D: FnOnce() -> StsdExtension, + { + if let Some(index) = self.extensions.iter().position(pred) { + return &mut self.extensions[index]; + } + self.extensions.push(default_fn()); + self.extensions.last_mut().unwrap() + } +} + +pub(super) mod serializer { + use super::AudioSampleEntry; + use crate::atom::{ + stsd::extension::serializer::serialize_stsd_extensions, util::serializer::fixed_point_16x16, + }; + + pub fn serialize_audio_sample_entry(audio: AudioSampleEntry) -> Vec { + let mut data = Vec::new(); + data.extend(audio.version.to_be_bytes()); + data.extend([0u8; 6]); // reserved + data.extend(audio.channel_count.to_be_bytes()); + data.extend(audio.sample_size.to_be_bytes()); + data.extend(audio.predefined.to_be_bytes()); + data.extend([0u8; 2]); // reserved + data.extend(fixed_point_16x16(audio.sample_rate)); + data.extend(serialize_stsd_extensions(audio.extensions)); + data + } +} + +pub(super) mod parser { + use winnow::{binary::be_u16, combinator::seq, error::StrContext, ModalResult, Parser}; + + use super::AudioSampleEntry; + use crate::atom::{ + stsd::{extension::parser::parse_stsd_extensions, SampleEntryData}, + util::parser::{byte_array, fixed_point_16x16, Stream}, + }; + + pub fn parse_audio_sample_entry(input: &mut Stream<'_>) -> ModalResult { + seq!(AudioSampleEntry { + version: be_u16.verify(|v| *v == 0).context(StrContext::Label("version")), + _: byte_array::<6>.context(StrContext::Label("reserved")), + channel_count: be_u16.context(StrContext::Label("channel_count")), + sample_size: be_u16.context(StrContext::Label("sample_size")), + predefined: be_u16.context(StrContext::Label("predefined")), + _: byte_array::<2>.context(StrContext::Label("reserved")), + sample_rate: fixed_point_16x16.context(StrContext::Label("sample_rate")), + extensions: parse_stsd_extensions.context(StrContext::Label("extensions")), + }) + .map(SampleEntryData::Audio) + .parse_next(input) + } +} diff --git a/src/atom/leaf/stsd/extension.rs b/src/atom/leaf/stsd/extension.rs new file mode 100644 index 0000000..b6c5676 --- /dev/null +++ b/src/atom/leaf/stsd/extension.rs @@ -0,0 +1,468 @@ +use std::fmt; + +pub use audio_specific_config::AudioSpecificConfig; + +use crate::{ + atom::util::{DebugList, DebugUpperHex}, + FourCC, +}; + +pub mod audio_specific_config; + +#[derive(Clone, PartialEq)] +pub enum StsdExtension { + Esds(EsdsExtension), + Btrt(BtrtExtension), + Unknown { fourcc: FourCC, data: Vec }, +} + +impl fmt::Debug for StsdExtension { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StsdExtension::Btrt(btrt) => fmt::Debug::fmt(btrt, f), + StsdExtension::Esds(esds) => fmt::Debug::fmt(esds, f), + StsdExtension::Unknown { fourcc, data } => f + .debug_struct("Unknown") + .field("fourcc", &fourcc) + .field("data", &DebugList::new(data.iter().map(DebugUpperHex), 10)) + .finish(), + } + } +} + +trait Descriptor { + const TAG: u8; +} + +#[derive(Default, Debug, Clone, PartialEq)] +pub struct EsdsExtension { + pub version: u8, + pub flags: [u8; 3], + pub es_descriptor: EsDescriptor, +} + +impl EsdsExtension { + const TYPE: FourCC = FourCC::new(b"esds"); +} + +#[derive(Default, Debug, Clone, PartialEq)] +pub struct EsDescriptor { + pub es_id: u16, + pub depends_on_es_id: Option, + pub url: Option, + pub ocr_es_id: Option, + pub stream_priority: u8, + pub decoder_config_descriptor: Option, + pub sl_config_descriptor: Option, +} + +impl Descriptor for EsDescriptor { + const TAG: u8 = 0x03; +} + +#[derive(Default, Debug, Clone, PartialEq)] +pub struct DecoderConfigDescriptor { + pub object_type_indication: u8, + pub stream_type: u8, + pub upstream: bool, + pub buffer_size_db: u32, + pub max_bitrate: u32, + pub avg_bitrate: u32, + pub decoder_specific_info: Option, +} + +impl Descriptor for DecoderConfigDescriptor { + const TAG: u8 = 0x04; +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DecoderSpecificInfo { + Audio(AudioSpecificConfig), + Unknown(Vec), +} + +impl Descriptor for DecoderSpecificInfo { + const TAG: u8 = 0x05; +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SlConfigDescriptor { + pub predefined: u8, +} + +impl Descriptor for SlConfigDescriptor { + const TAG: u8 = 0x06; +} + +#[derive(Debug, Clone, PartialEq)] +pub struct BtrtExtension { + pub buffer_size_db: u32, + pub max_bitrate: u32, + pub avg_bitrate: u32, +} + +impl BtrtExtension { + const TYPE: FourCC = FourCC::new(b"btrt"); +} + +pub(super) mod serializer { + use crate::{ + atom::{ + stsd::{ + extension::{ + audio_specific_config::serializer::serialize_audio_specific_config, + DecoderConfigDescriptor, Descriptor, EsDescriptor, SlConfigDescriptor, + }, + BtrtExtension, DecoderSpecificInfo, EsdsExtension, StsdExtension, + }, + util::serializer::{ + be_u24, bits::Packer, pascal_string, prepend_size_exclusive, + prepend_size_inclusive, SizeU32, SizeU32OrU64, SizeVLQ, + }, + }, + FourCC, + }; + + pub fn serialize_stsd_extensions(extensions: Vec) -> Vec { + extensions + .into_iter() + .flat_map(serialize_stsd_extension) + .collect::>() + } + + fn serialize_stsd_extension(extension: StsdExtension) -> Vec { + match extension { + StsdExtension::Esds(esds) => { + serialize_box(EsdsExtension::TYPE, serialize_esds_extension(esds)) + } + StsdExtension::Btrt(btrt) => { + serialize_box(BtrtExtension::TYPE, serialize_btrt_extension(btrt)) + } + StsdExtension::Unknown { fourcc, data } => serialize_box(fourcc, data), + } + } + + fn serialize_esds_extension(esds: EsdsExtension) -> Vec { + let mut data = Vec::new(); + data.push(esds.version); + data.extend(esds.flags); + data.extend(serialize_es_descriptor(esds.es_descriptor)); + data + } + + fn serialize_es_descriptor(es_desc: EsDescriptor) -> Vec { + let mut data = Vec::new(); + + data.extend(es_desc.es_id.to_be_bytes()); + + let mut flags = Packer::new(); + flags.push_bool(es_desc.depends_on_es_id.is_some()); + flags.push_bool(es_desc.url.is_some()); + flags.push_bool(es_desc.ocr_es_id.is_some()); + flags.push_n::<5>(es_desc.stream_priority); + data.push(Vec::from(flags)[0]); + + if let Some(depends_on_es_id) = es_desc.depends_on_es_id { + data.extend(depends_on_es_id.to_be_bytes()); + } + + if let Some(url) = es_desc.url { + data.extend(pascal_string(url)); + } + + if let Some(ocr_es_id) = es_desc.ocr_es_id { + data.extend(ocr_es_id.to_be_bytes()); + } + + if let Some(decoder_config) = es_desc.decoder_config_descriptor { + data.extend(serialize_decoder_config(decoder_config)); + } + + if let Some(sl_config) = es_desc.sl_config_descriptor { + data.extend(serialize_sl_config(sl_config)); + } + + serialize_descriptor(EsDescriptor::TAG, data) + } + + fn serialize_decoder_config(decoder_config: DecoderConfigDescriptor) -> Vec { + let mut data = Vec::new(); + + data.push(decoder_config.object_type_indication); + + let mut stream_info = Packer::new(); + stream_info.push_n::<6>(decoder_config.stream_type); + stream_info.push_bool(decoder_config.upstream); + stream_info.push_bool(true); // reserved + data.push(Vec::from(stream_info)[0]); + + data.extend(be_u24(decoder_config.buffer_size_db)); + + data.extend(decoder_config.max_bitrate.to_be_bytes()); + data.extend(decoder_config.avg_bitrate.to_be_bytes()); + + if let Some(decoder_info) = decoder_config.decoder_specific_info { + let decoder_info_bytes = match decoder_info { + DecoderSpecificInfo::Audio(c) => serialize_audio_specific_config(c), + DecoderSpecificInfo::Unknown(c) => c, + }; + data.extend(serialize_descriptor( + DecoderSpecificInfo::TAG, + decoder_info_bytes, + )); + } + + serialize_descriptor(DecoderConfigDescriptor::TAG, data) + } + + fn serialize_sl_config(sl_config: SlConfigDescriptor) -> Vec { + serialize_descriptor(SlConfigDescriptor::TAG, vec![sl_config.predefined]) + } + + fn serialize_btrt_extension(btrt: BtrtExtension) -> Vec { + let mut data = Vec::new(); + data.extend(btrt.buffer_size_db.to_be_bytes()); + data.extend(btrt.max_bitrate.to_be_bytes()); + data.extend(btrt.avg_bitrate.to_be_bytes()); + data + } + + fn serialize_descriptor(tag: u8, descriptor_data: Vec) -> Vec { + let mut data = Vec::new(); + data.push(tag); + data.extend(prepend_size_exclusive::, _>(move || { + descriptor_data + })); + data + } + + fn serialize_box(fourcc: FourCC, box_data: Vec) -> Vec { + prepend_size_inclusive::(move || { + let mut data = Vec::new(); + data.extend(fourcc.into_bytes()); + data.extend(box_data); + data + }) + } +} + +pub(super) mod parser { + use winnow::{ + binary::{be_u16, be_u24, be_u32, bits, length_and_then, u8}, + combinator::{opt, repeat, seq, trace}, + error::{ContextError, ErrMode, StrContext}, + token::literal, + ModalResult, Parser, + }; + + use crate::atom::{ + stsd::extension::audio_specific_config::parser::parse_audio_specific_config, + util::parser::{ + atom_size, combinators::inclusive_length_and_then, flags3, fourcc, pascal_string, + rest_vec, variable_length_be_u32, version, Stream, + }, + }; + + use super::*; + + pub fn parse_stsd_extensions(input: &mut Stream<'_>) -> ModalResult> { + repeat(0.., parse_stsd_extension).parse_next(input) + } + + pub fn parse_stsd_extension(input: &mut Stream<'_>) -> ModalResult { + inclusive_length_and_then( + atom_size, + move |input: &mut Stream<'_>| -> ModalResult { + let fourcc = fourcc.parse_next(input)?; + + Ok(match fourcc { + EsdsExtension::TYPE => { + parse_esds_box.map(StsdExtension::Esds).parse_next(input)? + } + BtrtExtension::TYPE => { + parse_btrt_box.map(StsdExtension::Btrt).parse_next(input)? + } + _ => StsdExtension::Unknown { + fourcc, + data: rest_vec.parse_next(input)?, + }, + }) + }, + ) + .parse_next(input) + } + + fn parse_esds_box(input: &mut Stream<'_>) -> ModalResult { + seq!(EsdsExtension { + version: version, + flags: flags3, + es_descriptor: parse_es_descriptor, + }) + .parse_next(input) + } + + fn parse_es_descriptor(input: &mut Stream<'_>) -> ModalResult { + parse_descriptor(move |input: &mut Stream<'_>| { + let es_id = be_u16.parse_next(input)?; + + struct Flags { + stream_dependence_flag: bool, + url_flag: bool, + ocr_stream_flag: bool, + stream_priority: u8, + } + let Flags { + stream_dependence_flag, + url_flag, + ocr_stream_flag, + stream_priority, + } = bits::bits( + move |input: &mut (Stream<'_>, usize)| -> ModalResult { + seq!(Flags { + stream_dependence_flag: bits::bool + .context(StrContext::Label("stream_dependency_flag")), + url_flag: bits::bool.context(StrContext::Label("url_flag")), + ocr_stream_flag: bits::bool.context(StrContext::Label("ocr_stream_flag")), + stream_priority: bits::take(5usize) + .context(StrContext::Label("stream_priority")), + }) + .parse_next(input) + }, + ) + .parse_next(input)?; + + let depends_on_es_id = if stream_dependence_flag { + Some(be_u16.parse_next(input)?) + } else { + None + }; + + let url = if url_flag { + Some(pascal_string.parse_next(input)?) + } else { + None + }; + + let ocr_es_id = if ocr_stream_flag { + Some(be_u16.parse_next(input)?) + } else { + None + }; + + let decoder_config_descriptor = + opt(parse_decoder_config_descriptor).parse_next(input)?; + + let sl_config_descriptor = opt(parse_sl_config_descriptor).parse_next(input)?; + + Ok(EsDescriptor { + es_id, + depends_on_es_id, + url, + ocr_es_id, + stream_priority, + decoder_config_descriptor, + sl_config_descriptor, + }) + }) + .parse_next(input) + } + + fn parse_decoder_config_descriptor( + input: &mut Stream<'_>, + ) -> ModalResult { + parse_descriptor(move |input: &mut Stream<'_>| { + let object_type_indication = u8.parse_next(input)?; + + struct StreamInfo { + stream_type: u8, + upstream: bool, + } + let StreamInfo { + stream_type, + upstream, + } = bits::bits( + move |input: &mut (Stream<'_>, usize)| -> ModalResult { + seq!(StreamInfo { + stream_type: bits::take(6usize).context(StrContext::Label("stream_type")), + upstream: bits::bool.context(StrContext::Label("upstream")), + _: bits::bool.context(StrContext::Label("reserved")), + }) + .parse_next(input) + }, + ) + .parse_next(input)?; + + let buffer_size_db = be_u24.parse_next(input)?; + let max_bitrate = be_u32.parse_next(input)?; + let avg_bitrate = be_u32.parse_next(input)?; + + // Parse DecoderSpecificInfo if present + let decoder_specific_info = opt(move |input: &mut Stream<'_>| { + parse_descriptor(match stream_type { + 5 => |input: &mut Stream<'_>| { + parse_audio_specific_config + .map(DecoderSpecificInfo::Audio) + .context(StrContext::Label("audio_specific_config")) + .parse_next(input) + }, + _ => |input: &mut Stream<'_>| { + rest_vec + .map(DecoderSpecificInfo::Unknown) + .context(StrContext::Label("unknown")) + .parse_next(input) + }, + }) + .parse_next(input) + }) + .parse_next(input)?; + + Ok(DecoderConfigDescriptor { + object_type_indication, + stream_type, + upstream, + buffer_size_db, + max_bitrate, + avg_bitrate, + decoder_specific_info, + }) + }) + .parse_next(input) + } + + fn parse_sl_config_descriptor(input: &mut Stream<'_>) -> ModalResult { + trace( + "parse_sl_config_descriptor", + parse_descriptor(seq!(SlConfigDescriptor { + predefined: u8.context(StrContext::Label("predefined")), + })), + ) + .parse_next(input) + } + + fn parse_descriptor<'i, Output, ParseDescriptor>( + mut parser: ParseDescriptor, + ) -> impl Parser, Output, ErrMode> + where + ParseDescriptor: Parser, Output, ErrMode>, + Output: Descriptor, + { + trace("parse_descriptor", move |input: &mut Stream<'i>| { + literal(::TAG) + .context(StrContext::Label("tag")) + .parse_next(input)?; + length_and_then(variable_length_be_u32, parser.by_ref()).parse_next(input) + }) + } + + fn parse_btrt_box(input: &mut Stream<'_>) -> ModalResult { + trace( + "parse_btrt_box", + seq!(BtrtExtension { + buffer_size_db: be_u32.context(StrContext::Label("buffer_size_db")), + max_bitrate: be_u32.context(StrContext::Label("max_bitrate")), + avg_bitrate: be_u32.context(StrContext::Label("avg_bitrate")), + }), + ) + .parse_next(input) + } +} diff --git a/src/atom/leaf/stsd/extension/audio_specific_config.rs b/src/atom/leaf/stsd/extension/audio_specific_config.rs new file mode 100644 index 0000000..745b174 --- /dev/null +++ b/src/atom/leaf/stsd/extension/audio_specific_config.rs @@ -0,0 +1,244 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudioObjectType { + AacMain, // 1 + AacLc, // 2 + AacSsr, // 3 + AacLtp, // 4 + Sbr, // 5 + // โ€ฆ up to 31 + Unknown(u8), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SamplingFrequency { + Hz96000, + Hz88200, + Hz64000, + Hz48000, + Hz44100, + Hz32000, + Hz24000, + Hz22050, + Hz16000, + Hz12000, + Hz11025, + Hz8000, + Hz7350, + Explicit(u32), +} + +impl SamplingFrequency { + pub fn as_hz(&self) -> u32 { + match *self { + Self::Hz96000 => 96_000, + Self::Hz88200 => 88_200, + Self::Hz64000 => 64_000, + Self::Hz48000 => 48_000, + Self::Hz44100 => 44_100, + Self::Hz32000 => 32_000, + Self::Hz24000 => 24_000, + Self::Hz22050 => 22_050, + Self::Hz16000 => 16_000, + Self::Hz12000 => 12_000, + Self::Hz11025 => 11_025, + Self::Hz8000 => 8_000, + Self::Hz7350 => 7_350, + Self::Explicit(v) => v, + } + } +} + +/// Number of channels +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelConfiguration { + Mono, + Stereo, + Three, + Four, + Five, + FiveOne, + SevenOne, +} + +/// The parsed AAC AudioSpecificConfig +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AudioSpecificConfig { + pub audio_object_type: AudioObjectType, + pub sampling_frequency: SamplingFrequency, + pub channel_configuration: ChannelConfiguration, + pub extension_bits: u8, + pub extension_bytes: Vec, +} + +pub(crate) mod serializer { + use crate::atom::util::serializer::{be_u24, bits::Packer}; + + use super::{AudioObjectType, AudioSpecificConfig, ChannelConfiguration, SamplingFrequency}; + + pub fn serialize_audio_specific_config(cfg: AudioSpecificConfig) -> Vec { + let mut packer = Packer::new(); + + packer.push_n::<5>(audio_object_type(cfg.audio_object_type)); + + let explicit = match cfg.sampling_frequency { + SamplingFrequency::Explicit(hz) => Some(hz), + _ => None, + }; + packer.push_n::<4>(sampling_frequency_index(cfg.sampling_frequency)); + if let Some(hz) = explicit { + packer.push_bytes(be_u24(hz).to_vec()); + } + + packer.push_n::<4>(channel_configuration(cfg.channel_configuration)); + packer.push_n::<3>(cfg.extension_bits); + packer.push_bytes(cfg.extension_bytes); + + Vec::from(packer) + } + + fn audio_object_type(aot: AudioObjectType) -> u8 { + use AudioObjectType::*; + match aot { + AacMain => 1, + AacLc => 2, + AacSsr => 3, + AacLtp => 4, + Sbr => 5, + Unknown(v) => v.min(31), + } + } + + fn sampling_frequency_index(sf: SamplingFrequency) -> u8 { + use SamplingFrequency::*; + match sf { + Hz96000 => 0, + Hz88200 => 1, + Hz64000 => 2, + Hz48000 => 3, + Hz44100 => 4, + Hz32000 => 5, + Hz24000 => 6, + Hz22050 => 7, + Hz16000 => 8, + Hz12000 => 9, + Hz11025 => 10, + Hz8000 => 11, + Hz7350 => 12, + Explicit(_) => 15, + } + } + + fn channel_configuration(ch: ChannelConfiguration) -> u8 { + use ChannelConfiguration::*; + match ch { + Mono => 1, + Stereo => 2, + Three => 3, + Four => 4, + Five => 5, + FiveOne => 6, + SevenOne => 7, + } + } +} + +pub(crate) mod parser { + use winnow::{ + binary::{be_u24, bits}, + combinator::{alt, backtrack_err, dispatch, empty, fail, seq}, + error::{StrContext, StrContextValue}, + ModalResult, Parser, + }; + + use crate::atom::util::parser::{rest_vec, Stream}; + + use super::{AudioObjectType, AudioSpecificConfig, ChannelConfiguration, SamplingFrequency}; + + pub fn parse_audio_specific_config(input: &mut Stream<'_>) -> ModalResult { + bits::bits( + move |input: &mut (Stream<'_>, usize)| -> ModalResult { + seq!(AudioSpecificConfig { + audio_object_type: audio_object_type // 5 bits + .context(StrContext::Label("audio_object_type")), + sampling_frequency: sampling_frequency // 4 or 28 bits + .context(StrContext::Label("sampling_frequency")), + channel_configuration: channel_configuration // 4 bits + .context(StrContext::Label("channel_configuration")), + extension_bits: bits::take(3usize).context(StrContext::Label("extension_bits")), + extension_bytes: bits::bytes(rest_vec).context(StrContext::Label("extension_bytes")), + }) + .parse_next(input) + }, + ) + .parse_next(input) + } + + fn audio_object_type(input: &mut (Stream<'_>, usize)) -> ModalResult { + use AudioObjectType::*; + alt(( + dispatch! {bits::take(5usize); + 1 => empty.value(AacMain), + 2 => empty.value(AacLc), + 3 => empty.value(AacSsr), + 4 => empty.value(Sbr), + 5 => empty.value(Sbr), + _ => backtrack_err(fail), + }, + bits::take(5usize) + .verify(|v: &u8| (6..=31).contains(v)) + .map(Unknown), + fail.context(StrContext::Expected(StrContextValue::Description( + "0x01..0x1F", + ))), + )) + .parse_next(input) + } + + fn sampling_frequency(input: &mut (Stream<'_>, usize)) -> ModalResult { + use SamplingFrequency::*; + dispatch! {bits::take(4usize); + 0 => empty.value(Hz96000), + 1 => empty.value(Hz88200), + 2 => empty.value(Hz64000), + 3 => empty.value(Hz48000), + 4 => empty.value(Hz44100), + 5 => empty.value(Hz32000), + 6 => empty.value(Hz24000), + 7 => empty.value(Hz22050), + 8 => empty.value(Hz16000), + 9 => empty.value(Hz12000), + 10 => empty.value(Hz11025), + 11 => empty.value(Hz8000), + 12 => empty.value(Hz7350), + 15 => bits::bytes(move |input: &mut Stream<'_>| -> ModalResult { + be_u24.parse_next(input) // 3 bytes + }).map(Explicit) + .context(StrContext::Label("sampling_frequency")) + .context(StrContext::Expected(StrContextValue::Description( + "explicit frequency (be_u24)", + ))), + _ => fail.context(StrContext::Expected(StrContextValue::Description( + "0x00..0x0C, 0x0F", + ))), + } + .parse_next(input) + } + + fn channel_configuration(input: &mut (Stream<'_>, usize)) -> ModalResult { + use ChannelConfiguration::*; + dispatch! {bits::take(4usize); + 1 => empty.value(Mono), + 2 => empty.value(Stereo), + 3 => empty.value(Three), + 4 => empty.value(Four), + 5 => empty.value(Five), + 6 => empty.value(FiveOne), + 7 => empty.value(SevenOne), + _ => + fail.context(StrContext::Expected(StrContextValue::Description( + "0x01..0x07", + ))), + } + .parse_next(input) + } +} diff --git a/src/atom/leaf/stsd/text.rs b/src/atom/leaf/stsd/text.rs new file mode 100644 index 0000000..5e71da3 --- /dev/null +++ b/src/atom/leaf/stsd/text.rs @@ -0,0 +1,257 @@ +use bon::Builder; + +use crate::atom::util::ColorRgb; + +use super::StsdExtension; + +#[derive(Debug, Clone, Default, Builder)] +pub struct TextSampleEntry { + #[builder(default)] + pub display_flags: DisplayFlags, + #[builder(default)] + pub text_justification: TextJustification, + #[builder(default)] + pub background_color: ColorRgb, + #[builder(default)] + pub default_text_box: TextBox, + #[builder(default)] + pub font_number: u16, + /// 0 = normal text + #[builder(default)] + pub font_face: FontFace, + #[builder(default)] + pub foreground_color: ColorRgb, + #[builder(into)] + pub font_name: String, + #[builder(default)] + pub extensions: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct DisplayFlags { + /// Reflow the text instead of scaling when the track is scaled. + pub disable_auto_scale: bool, // 0x0002 + /// Ignore the background color field in the text sample description and use the movieโ€™s background color instead. + pub use_movie_background_color: bool, // 0x0008 + /// Scroll the text until the last of the text is in view. + pub scroll_in: bool, // 0x0020 + /// Scroll the text until the last of the text is gone. + pub scroll_out: bool, // 0x0040 + /// Scroll the text horizontally when set; otherwise, scroll the text vertically. + pub horizontal_scroll: bool, // 0x0080 + /// Scroll down (if scrolling vertically) or backward (if scrolling horizontally) + /// + /// **Note:** Horizontal scrolling also depends upon text justification. + pub reverse_scroll: bool, // 0x0100 + /// Display new samples by scrolling out the old ones. + pub continuous_scroll: bool, // 0x0200 + /// Display the text with a drop shadow. + pub drop_shadow: bool, // 0x1000 + /// Use anti-aliasing when drawing text. + pub anti_alias: bool, // 0x2000 + /// Do not display the background color, so that the text overlay background tracks. + pub key_text: bool, // 0x4000 +} + +#[derive(Debug, Clone, Default)] +pub enum TextJustification { + #[default] + Left, + Centre, + Right, + Other(i32), +} + +#[derive(Debug, Clone, Default)] +pub struct TextBox { + pub top: u16, + pub left: u16, + pub bottom: u16, + pub right: u16, +} + +#[derive(Debug, Clone, Default)] +pub struct FontFace { + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub outline: bool, + pub shadow: bool, + pub condense: bool, + pub extend: bool, +} + +pub(super) mod serializer { + use super::{DisplayFlags, FontFace, TextBox, TextJustification, TextSampleEntry}; + use crate::atom::{ + stsd::extension::serializer::serialize_stsd_extensions, + util::serializer::{bits::Packer, color_rgb, pascal_string}, + }; + + pub fn serialize_text_sample_entry(text: TextSampleEntry) -> Vec { + let mut data = Vec::new(); + + data.extend(display_flags(text.display_flags)); + data.extend(text_justification(text.text_justification)); + data.extend(color_rgb(text.background_color)); + data.extend(text_box(text.default_text_box)); + data.extend([0u8; 8]); // reserved + data.extend(text.font_number.to_be_bytes()); + data.extend(font_face(text.font_face)); + data.extend([0u8; 2]); // reserved + data.extend(color_rgb(text.foreground_color)); + data.extend(pascal_string(text.font_name)); + data.extend(serialize_stsd_extensions(text.extensions)); + + data + } + + fn display_flags(d: DisplayFlags) -> [u8; 4] { + let mut packer = Packer::from(vec![0u8; 2]); // 2 leading empty bytes + packer.push_n::<1>(0); // 1 leading empty bit + packer.push_bool(d.key_text); + packer.push_bool(d.anti_alias); + packer.push_bool(d.drop_shadow); + packer.push_n::<2>(0); // 2 padding bits + packer.push_bool(d.continuous_scroll); + packer.push_bool(d.reverse_scroll); + packer.push_bool(d.horizontal_scroll); + packer.push_bool(d.scroll_out); + packer.push_bool(d.scroll_in); + packer.push_n::<1>(0); // 1 padding bit + packer.push_bool(d.use_movie_background_color); + packer.push_n::<1>(0); // 1 padding bit + packer.push_bool(d.disable_auto_scale); + packer.push_n::<1>(0); // 1 padding bit + Vec::from(packer) + .try_into() + .expect("display_flags is 4 bytes") + } + + fn text_justification(j: TextJustification) -> [u8; 4] { + let value: i32 = match j { + TextJustification::Left => 0, + TextJustification::Centre => 1, + TextJustification::Right => -1, + TextJustification::Other(v) => v, + }; + value.to_be_bytes() + } + + fn text_box(b: TextBox) -> [u8; 8] { + let mut data = Vec::with_capacity(6); + data.extend(b.top.to_be_bytes()); + data.extend(b.left.to_be_bytes()); + data.extend(b.bottom.to_be_bytes()); + data.extend(b.right.to_be_bytes()); + data.try_into().expect("text_box is 8 bytes") + } + + fn font_face(f: FontFace) -> [u8; 2] { + let mut packer = Packer::from(vec![0u8; 1]); // 1 leading byte + packer.push_n::<1>(0); // 1 leading bit + packer.push_bool(f.extend); + packer.push_bool(f.condense); + packer.push_bool(f.shadow); + packer.push_bool(f.outline); + packer.push_bool(f.underline); + packer.push_bool(f.italic); + packer.push_bool(f.bold); + Vec::from(packer).try_into().expect("font_face is 2 bytes") + } +} + +pub(super) mod parser { + use winnow::{ + binary::{be_i32, be_u16, bits}, + combinator::seq, + error::{ContextError, ErrMode, StrContext}, + ModalResult, Parser, + }; + + use crate::atom::{ + stsd::{extension::parser::parse_stsd_extensions, SampleEntryData}, + util::parser::{byte_array, color_rgb, pascal_string, Stream}, + }; + + use super::*; + + pub fn parse_text_sample_entry(input: &mut Stream<'_>) -> ModalResult { + seq!(TextSampleEntry { + display_flags: bits::bits(display_flags).context(StrContext::Label("display_flags")), + text_justification: text_justification.context(StrContext::Label("text_justification")), + background_color: color_rgb.context(StrContext::Label("background_color")), + default_text_box: text_box.context(StrContext::Label("default_text_box")), + _: byte_array::<8>.context(StrContext::Label("reserved")), + font_number: be_u16.context(StrContext::Label("font_number")), + font_face: bits::bits(font_face).context(StrContext::Label("font_face")), + _: byte_array::<2>.context(StrContext::Label("reserved")), + foreground_color: color_rgb.context(StrContext::Label("foreground_color")), + font_name: pascal_string.context(StrContext::Label("text_name")), + extensions: parse_stsd_extensions.context(StrContext::Label("extensions")), + }) + .map(SampleEntryData::Text) + .parse_next(input) + } + + fn display_flags(input: &mut (Stream, usize)) -> ModalResult { + use bits::bool; + seq!(DisplayFlags { + _: bits::take::<_, usize, _, ErrMode>(8usize).context(StrContext::Label("leading byte 1")), + _: bits::take::<_, usize, _, ErrMode>(8usize).context(StrContext::Label("leading byte 2")), + _: bits::take::<_, usize, _, ErrMode>(1usize).context(StrContext::Label("leading bit")), + key_text: bool.context(StrContext::Label("key_text")), + anti_alias: bool.context(StrContext::Label("anti_alias")), + drop_shadow: bool.context(StrContext::Label("drop_shadow")), + _: bits::take::<_, usize, _, ErrMode>(2usize).context(StrContext::Label("padding")), + continuous_scroll: bool.context(StrContext::Label("continuous_scroll")), + reverse_scroll: bool.context(StrContext::Label("reverse_scroll")), + horizontal_scroll: bool.context(StrContext::Label("horizontal_scroll")), + scroll_out: bool.context(StrContext::Label("scroll_out")), + scroll_in: bool.context(StrContext::Label("scroll_in")), + _: bits::take::<_, usize, _, ErrMode>(1usize).context(StrContext::Label("padding")), + use_movie_background_color: bool + .context(StrContext::Label("use_movie_background_color")), + _: bits::take::<_, usize, _, ErrMode>(1usize).context(StrContext::Label("padding")), + disable_auto_scale: bool.context(StrContext::Label("disable_auto_scale")), + _: bits::take::<_, usize, _, ErrMode>(1usize).context(StrContext::Label("padding")), + }) + .parse_next(input) + } + + fn text_justification(input: &mut Stream<'_>) -> ModalResult { + let text_justification = be_i32.parse_next(input)?; + Ok(match text_justification { + 0 => TextJustification::Left, + 1 => TextJustification::Centre, + -1 => TextJustification::Right, + v => TextJustification::Other(v), + }) + } + + fn text_box(input: &mut Stream<'_>) -> ModalResult { + seq!(TextBox { + top: be_u16.context(StrContext::Label("top")), + left: be_u16.context(StrContext::Label("left")), + bottom: be_u16.context(StrContext::Label("bottom")), + right: be_u16.context(StrContext::Label("right")), + }) + .parse_next(input) + } + + fn font_face(input: &mut (Stream, usize)) -> ModalResult { + use bits::bool; + seq!(FontFace { + _: bits::take::<_, usize, _, ErrMode>(8usize).context(StrContext::Label("leading empty byte")), + _: bits::take::<_, usize, _, ErrMode>(1usize).context(StrContext::Label("leading empty bit")), + extend: bool.context(StrContext::Label("extend")), + condense: bool.context(StrContext::Label("condense")), + shadow: bool.context(StrContext::Label("shadow")), + outline: bool.context(StrContext::Label("outline")), + underline: bool.context(StrContext::Label("underline")), + italic: bool.context(StrContext::Label("italic")), + bold: bool.context(StrContext::Label("bold")), + }) + .parse_next(input) + } +} diff --git a/src/atom/leaf/stsz.rs b/src/atom/leaf/stsz.rs new file mode 100644 index 0000000..b5eca35 --- /dev/null +++ b/src/atom/leaf/stsz.rs @@ -0,0 +1,317 @@ +use bon::bon; +use derive_more::{Deref, DerefMut}; +use either::Either; +use rangemap::{RangeMap, RangeSet}; +use std::{ + fmt::{self}, + ops::Range, +}; + +use crate::{ + atom::{util::DebugList, FourCC}, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +pub const STSZ: FourCC = FourCC::new(b"stsz"); + +#[derive(Clone, Default, Deref, DerefMut)] +pub struct SampleEntrySizes(Vec); + +impl SampleEntrySizes { + pub fn inner(&self) -> &[u32] { + &self.0 + } +} + +impl From> for SampleEntrySizes { + fn from(value: Vec) -> Self { + SampleEntrySizes(value) + } +} + +impl fmt::Debug for SampleEntrySizes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&DebugList::new(self.0.iter(), 10), f) + } +} + +impl SampleEntrySizes { + /// Create a new SampleEntrySizes from a vector of sample sizes + pub fn new(sizes: Vec) -> Self { + Self(sizes) + } + + /// Create a new SampleEntrySizes from a vector of sample sizes + pub fn from_vec(sizes: Vec) -> Self { + Self(sizes) + } + + /// Convert to the inner `Vec` + pub fn to_vec(&self) -> Vec { + self.0.clone() + } +} + +/// Sample Size Atom (stsz) - ISO/IEC 14496-12 +/// This atom contains the sample count and a table giving the size in bytes of each sample. +/// Samples within the media may have different sizes, up to the limit of a 32-bit integer. +#[derive(Default, Debug, Clone)] +pub struct SampleSizeAtom { + pub version: u8, + pub flags: [u8; 3], + /// If this field is set to some value other than 0, then it gives the (constant) size + /// of every sample in the track. If this field is set to 0, then the samples have + /// different sizes, and those sizes are stored in the sample size table. + pub sample_size: u32, + /// Number of samples in the track + pub sample_count: u32, + /// If `sample_size` is 0, this contains the size of each sample, indexed by sample number. + /// If `sample_size` is non-zero, this table is empty. + pub entry_sizes: SampleEntrySizes, +} + +pub(crate) struct RemovedSampleSizes { + /// key=removed range start, value=(range.start, removed sizes) + removed_sizes: RangeMap)>, +} + +impl RemovedSampleSizes { + fn new() -> Self { + Self { + removed_sizes: RangeMap::new(), + } + } + + fn insert(&mut self, indices: Range, sizes: impl Iterator) { + let start_index = indices.start; + self.removed_sizes + .insert(indices, (start_index, sizes.collect::>())); + } + + /// Get removed sample sizes for the given sample indices + pub(crate) fn get_sizes(&self, sample_indices: Range) -> Option<&[u32]> { + // get the list of sizes that the range belongs to + let (first_index, sizes) = self.removed_sizes.get(&sample_indices.start)?; + // slice to the requested range (invariant: RangeMap Range.len() == V.len()) + let sizes = &sizes.as_slice() + [(sample_indices.start - first_index)..(sample_indices.end - first_index)]; + Some(sizes) + } +} + +impl SampleSizeAtom { + #[cfg(feature = "experimental-trim")] + pub(crate) fn remove_sample_indices( + &mut self, + indices_to_remove: &RangeSet, + ) -> RemovedSampleSizes { + let num_samples_removed = indices_to_remove + .iter() + .map(|r| r.end - r.start) + .sum::() as u32; + + fn adjust_range(n_removed: usize, range: &Range) -> Range { + let start = range.start - n_removed; + let end = range.end - n_removed; + start..end + } + + let mut removed_sizes = RemovedSampleSizes::new(); + + if !self.entry_sizes.is_empty() && !indices_to_remove.is_empty() { + let mut n_removed = 0; + for range in indices_to_remove.iter() { + let adjusted_range = adjust_range(n_removed, range); + n_removed += adjusted_range.len(); + removed_sizes.insert( + range.clone(), + self.entry_sizes.drain(adjusted_range.clone()), + ); + } + } + + self.sample_count = self.sample_count.saturating_sub(num_samples_removed); + + removed_sizes + } + + /// Returns `sample_count` if it's set, otherwise `entry_sizes.len()` + pub fn sample_count(&self) -> usize { + if self.sample_count > 0 { + self.sample_count as usize + } else { + self.entry_sizes.len() + } + } +} + +#[bon] +impl SampleSizeAtom { + #[builder] + pub fn new( + #[builder(setters(vis = "", name = "sample_size_internal"))] sample_size: u32, + #[builder(default = 0)] sample_count: u32, + /// either set `sample_size` and `sample_count` or `entry_sizes` + #[builder(with = FromIterator::from_iter, setters(vis = "", name = "entry_sizes_internal"))] + entry_sizes: Vec, + ) -> Self { + let entry_sizes: SampleEntrySizes = entry_sizes.into(); + let sample_count = if sample_count == 0 { + u32::try_from(entry_sizes.len()).expect("entry_sizes.len() should fit in a u32") + } else { + sample_count + }; + Self { + version: 0, + flags: [0u8; 3], + sample_size, + sample_count, + entry_sizes, + } + } + + /// Returns an iterator over _all_ sample sizes. + /// + /// If `sample_size != 0` this will repeat that value + /// `sample_count` times; otherwise it will yield + /// the values from `entry_sizes`. + pub fn sample_sizes(&self) -> impl Iterator + '_ { + if self.sample_size != 0 { + Either::Left(std::iter::repeat_n( + &self.sample_size, + self.sample_count as usize, + )) + } else { + Either::Right(self.entry_sizes.iter()) + } + } +} + +#[bon] +impl SampleSizeAtomBuilder { + pub fn sample_size( + self, + sample_size: u32, + ) -> SampleSizeAtomBuilder< + sample_size_atom_builder::SetSampleSize>, + > + where + S::EntrySizes: sample_size_atom_builder::IsUnset, + S::SampleSize: sample_size_atom_builder::IsUnset, + { + self.entry_sizes_internal(vec![]) + .sample_size_internal(sample_size) + } + + #[builder(finish_fn(name = "build"))] + pub fn entry_sizes( + self, + #[builder(start_fn)] entry_sizes: impl IntoIterator, + ) -> SampleSizeAtom + where + S::EntrySizes: sample_size_atom_builder::IsUnset, + S::SampleSize: sample_size_atom_builder::IsUnset, + S::SampleCount: sample_size_atom_builder::IsUnset, + { + self.entry_sizes_internal(entry_sizes) + .sample_size_internal(0) + .sample_count(0) + .build() + } +} + +impl ParseAtomData for SampleSizeAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, STSZ); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_stsz_data.parse(stream(input))?) + } +} + +impl fmt::Display for SampleSizeAtom { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SampleSize(count: {}, ", self.sample_count)?; + + if self.sample_size != 0 { + write!(f, "constant_size: {})", self.sample_size) + } else { + write!(f, "variable_sizes: {} entries)", self.entry_sizes.len()) + } + } +} + +impl SerializeAtom for SampleSizeAtom { + fn atom_type(&self) -> FourCC { + STSZ + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_stsz_data(self) + } +} + +mod serializer { + use super::SampleSizeAtom; + + pub fn serialize_stsz_data(stsz: SampleSizeAtom) -> Vec { + let mut data = Vec::new(); + + data.push(stsz.version); + data.extend(stsz.flags); + data.extend(stsz.sample_size.to_be_bytes()); + data.extend(stsz.sample_count.to_be_bytes()); + + // If sample_size is 0, write the sample size table + if stsz.sample_size == 0 { + for size in stsz.entry_sizes.0.into_iter() { + data.extend(size.to_be_bytes()); + } + } + + data + } +} + +mod parser { + use winnow::{ + binary::be_u32, + combinator::{repeat, seq, trace}, + error::StrContext, + ModalResult, Parser, + }; + + use super::{SampleEntrySizes, SampleSizeAtom}; + use crate::atom::util::parser::{flags3, version, Stream}; + + pub fn parse_stsz_data(input: &mut Stream<'_>) -> ModalResult { + trace( + "stsz", + seq!(SampleSizeAtom { + version: version, + flags: flags3, + sample_size: be_u32.context(StrContext::Label("sample_size")), + sample_count: be_u32.context(StrContext::Label("sample_count")), + entry_sizes: repeat(0.., be_u32.context(StrContext::Label("entry_size"))) + .map(SampleEntrySizes) + .context(StrContext::Label("entry_sizes")), + }) + .context(StrContext::Label("stsz")), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available stsz test data files + #[test] + fn test_stsz_roundtrip() { + test_atom_roundtrip::(STSZ); + } +} diff --git a/src/atom/leaf/stts.rs b/src/atom/leaf/stts.rs new file mode 100644 index 0000000..0329ec9 --- /dev/null +++ b/src/atom/leaf/stts.rs @@ -0,0 +1,721 @@ +use bon::{bon, Builder}; +use derive_more::{Deref, DerefMut}; + +use rangemap::RangeSet; +use std::{ + fmt::{self, Debug}, + ops::{Bound, Range, RangeBounds, Sub}, +}; + +use crate::{ + atom::{util::DebugList, FourCC}, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +pub const STTS: FourCC = FourCC::new(b"stts"); + +#[derive(Default, Clone, Deref, DerefMut)] +pub struct TimeToSampleEntries(Vec); + +impl From> for TimeToSampleEntries { + fn from(entries: Vec) -> Self { + Self::new(entries) + } +} + +impl TimeToSampleEntries { + pub fn new(inner: Vec) -> Self { + Self(inner) + } + + pub fn inner(&self) -> &[TimeToSampleEntry] { + &self.0 + } +} + +impl fmt::Debug for TimeToSampleEntries { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&DebugList::new(self.0.iter(), 10), f) + } +} + +/// Defines duration for a consecutive group of samples +#[derive(Debug, Clone, PartialEq, Eq, Builder)] +pub struct TimeToSampleEntry { + /// Number of consecutive samples with the same duration + pub sample_count: u32, + /// Duration of each sample in timescale units (see MDHD atom) + pub sample_duration: u32, +} + +/// Time-to-Sample (stts) atom +#[derive(Default, Debug, Clone)] +pub struct TimeToSampleAtom { + pub version: u8, + pub flags: [u8; 3], + pub entries: TimeToSampleEntries, +} + +#[bon] +impl TimeToSampleAtom { + #[builder] + pub fn new( + #[builder(default = 0)] version: u8, + #[builder(default = [0u8; 3])] flags: [u8; 3], + #[builder(with = FromIterator::from_iter)] entries: Vec, + ) -> Self { + Self { + version, + flags, + entries: entries.into(), + } + } + + /// Removes samples contained in the given `trim_duration`, excluding partially matched samples. + /// Returns the remaining duration after the trim and indices of removed samples. + /// + /// # Panics + /// + /// This method panics if the trim ranges overlap. + /// + /// WARNING: failing to update other atoms appropriately will cause file corruption. + #[cfg(feature = "experimental-trim")] + pub(crate) fn trim_duration(&mut self, trim_ranges: &[R]) -> (u64, RangeSet) + where + R: RangeBounds + Debug, + { + let mut trim_range_index = 0; + let mut removed_sample_indices = RangeSet::new(); + let mut remove_entry_range = RangeSet::new(); + let mut next_duration_offset = 0u64; + let mut next_sample_index = 0usize; + let mut total_original_duration = 0u64; + let mut total_duration_trimmed = 0u64; + + 'entries: for (entry_index, entry) in self.entries.iter_mut().enumerate() { + let current_duration_offset = next_duration_offset; + let entry_duration = entry.sample_count as u64 * entry.sample_duration as u64; + next_duration_offset = current_duration_offset + entry_duration; + total_original_duration += entry_duration; + + let current_sample_index = next_sample_index; + next_sample_index += entry.sample_count as usize; + + let entry_duration = { + let entry_duration_start = current_duration_offset; + let entry_duration_end = current_duration_offset + + (entry.sample_count as u64 * entry.sample_duration as u64).saturating_sub(1); + entry_duration_start..entry_duration_end + 1 + }; + + 'trim_range: for (i, trim_range) in trim_ranges.iter().enumerate() { + let (trim_duration, entry_trim_duration) = + match entry_trim_duration(&entry_duration, trim_range) { + Some(m) => m, + None => { + // Entire entry is outside trim range, continue to next trim range + continue 'trim_range; + } + }; + + debug_assert!( + i >= trim_range_index, + "invariant: trim ranges must not overlap" + ); + trim_range_index = i; + + // Entire entry is inside trim range + if trim_duration.contains(&entry_duration.start) + && trim_duration.contains(&(entry_duration.end - 1)) + { + remove_entry_range.insert(entry_index..entry_index + 1); + removed_sample_indices.insert( + current_sample_index..current_sample_index + entry.sample_count as usize, + ); + total_duration_trimmed += entry_duration.end - entry_duration.start; + continue 'entries; + } + + // Partial overlap + + if entry.sample_count == 1 { + // we can't trim anything smaller than a sample + // TODO: return range that can be used in an edit list entry + continue 'entries; + } + + let sample_duration = entry.sample_duration as u64; + + let trim_sample_start_index = (current_sample_index as u64 + + (entry_trim_duration.start - entry_duration.start).div_ceil(sample_duration)) + as usize; + let trim_sample_end_index = + match (entry_trim_duration.end - entry_duration.start) / sample_duration { + 0 => trim_sample_start_index, + end => current_sample_index + end as usize - 1, + }; + + // TODO(fix): when trimming a duration of 0, end_index can end up less than start_index + assert!(trim_sample_end_index >= trim_sample_start_index); + + let num_samples_to_remove = trim_sample_end_index + 1 - trim_sample_start_index; + if num_samples_to_remove == entry.sample_count as usize { + if entry.sample_count > 1 { + // remove one less samples + if trim_sample_start_index == 0 { + // anchor to start + removed_sample_indices + .insert(trim_sample_start_index..trim_sample_end_index); + } else { + // anchor to end + removed_sample_indices + .insert((trim_sample_start_index + 1)..trim_sample_end_index); + } + } + entry.sample_count = 1; + let trimmed_duration = entry_trim_duration.end - entry_trim_duration.start; + entry.sample_duration -= trimmed_duration as u32; + total_duration_trimmed += trimmed_duration; + } else { + removed_sample_indices + .insert(trim_sample_start_index..(trim_sample_end_index + 1)); + + entry.sample_count = entry.sample_count.sub(num_samples_to_remove as u32); + + total_duration_trimmed += ((trim_sample_end_index as u64 + 1) + * sample_duration) + - (trim_sample_start_index as u64 * sample_duration); + } + } + } + + let mut n_removed = 0; + for range in remove_entry_range.into_iter() { + let mut range = (range.start - n_removed)..(range.end - n_removed); + n_removed += range.len(); + + // maybe merge entries before and after the removed ones + if range.start > 0 { + if let Ok([prev_entry, next_entry]) = self + .entries + .as_mut_slice() + .get_disjoint_mut([range.start - 1, range.end]) + { + if prev_entry.sample_duration == next_entry.sample_duration { + prev_entry.sample_count += next_entry.sample_count; + range.end += 1; + } + } + } + + self.entries.drain(range); + } + + ( + total_original_duration - total_duration_trimmed, + removed_sample_indices, + ) + } +} + +#[cfg(feature = "experimental-trim")] +fn entry_trim_duration<'a, R>( + entry_range: &Range, + trim_range: &'a R, +) -> Option<(&'a R, Range)> +where + R: RangeBounds, +{ + // entry is contained in range + if trim_range.contains(&entry_range.start) && trim_range.contains(&(entry_range.end - 1)) { + return Some((trim_range, entry_range.clone())); + } + + let finite_trim_range = convert_range(entry_range, trim_range); + + // trim range ends too soon to be useful + if finite_trim_range.end <= entry_range.start { + return None; + } + + // trim range is contained in entry + if entry_range.contains(&finite_trim_range.start) + && finite_trim_range.end > 0 + && entry_range.contains(&(finite_trim_range.end - 1)) + { + return Some((trim_range, finite_trim_range)); + } + + // trim range starts inside of entry + if finite_trim_range.start >= entry_range.start && finite_trim_range.start < entry_range.end { + return Some((trim_range, finite_trim_range.start..entry_range.end)); + } + + // trim range ends inside of entry + if trim_range.contains(&entry_range.start) + && finite_trim_range.start < entry_range.start + && finite_trim_range.end <= entry_range.end + { + return Some((trim_range, entry_range.start..finite_trim_range.end)); + } + + None +} + +#[cfg(feature = "experimental-trim")] +fn convert_range(reference_range: &Range, range: &impl RangeBounds) -> Range { + let start = match range.start_bound() { + Bound::Included(start) => *start, + Bound::Excluded(start) => *start + 1, + Bound::Unbounded => 0, + }; + let end = match range.end_bound() { + Bound::Included(end) => *end + 1, + Bound::Excluded(end) => *end, + Bound::Unbounded => reference_range.end, + }; + start..end +} + +impl TimeToSampleAtomBuilder { + pub fn entry( + self, + entry: impl Into, + ) -> TimeToSampleAtomBuilder> + where + S::Entries: time_to_sample_atom_builder::IsUnset, + { + self.entries(vec![entry.into()]) + } +} + +impl From> for TimeToSampleAtom { + fn from(entries: Vec) -> Self { + TimeToSampleAtom { + version: 0, + flags: [0u8; 3], + entries: entries.into(), + } + } +} + +impl ParseAtomData for TimeToSampleAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, STTS); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_stts_data.parse(stream(input))?) + } +} + +impl SerializeAtom for TimeToSampleAtom { + fn atom_type(&self) -> FourCC { + STTS + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_mdhd_atom(self) + } +} + +mod serializer { + use crate::atom::util::serializer::be_u32; + + use super::TimeToSampleAtom; + + pub fn serialize_mdhd_atom(stts: TimeToSampleAtom) -> Vec { + let mut data = Vec::new(); + + data.push(stts.version); + data.extend(stts.flags); + data.extend(be_u32( + u32::try_from(stts.entries.len()).expect("stts entries len must fit in u32"), + )); + + for entry in stts.entries.0.into_iter() { + data.extend(entry.sample_count.to_be_bytes()); + data.extend(entry.sample_duration.to_be_bytes()); + } + + data + } +} + +mod parser { + use winnow::{ + binary::{be_u32, length_repeat}, + combinator::{seq, trace}, + error::StrContext, + ModalResult, Parser, + }; + + use super::{TimeToSampleAtom, TimeToSampleEntries, TimeToSampleEntry}; + use crate::atom::util::parser::{flags3, version, Stream}; + + pub fn parse_stts_data(input: &mut Stream<'_>) -> ModalResult { + trace( + "stts", + seq!(TimeToSampleAtom { + version: version, + flags: flags3, + entries: length_repeat(be_u32, entry) + .map(TimeToSampleEntries) + .context(StrContext::Label("entries")), + }) + .context(StrContext::Label("stts")), + ) + .parse_next(input) + } + + fn entry(input: &mut Stream<'_>) -> ModalResult { + trace( + "entry", + seq!(TimeToSampleEntry { + sample_count: be_u32.context(StrContext::Label("sample_count")), + sample_duration: be_u32.context(StrContext::Label("sample_duration")), + }) + .context(StrContext::Label("entry")), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available stts test data files + #[test] + fn test_stts_roundtrip() { + test_atom_roundtrip::(STTS); + } +} + +#[cfg(feature = "experimental-trim")] +#[cfg(test)] +mod trim_tests { + use std::ops::Bound; + + use super::*; + + struct TrimDurationTestCase { + trim_duration: Vec<(Bound, Bound)>, + expect_removed_samples: Vec>, + expect_removed_duration: u64, + expect_entries: Vec, + } + + fn test_trim_duration_stts() -> TimeToSampleAtom { + TimeToSampleAtom::builder() + .entries(vec![ + // samples 0..1 + // duration 0..100 + TimeToSampleEntry { + sample_count: 1, + sample_duration: 100, + }, + // samples 1..5 + // duration 100..900 + TimeToSampleEntry { + sample_count: 4, + sample_duration: 200, + }, + // samples 5..9 + // duration 900..1300 + TimeToSampleEntry { + sample_count: 4, + sample_duration: 100, + }, + ]) + .build() + } + + fn test_trim_duration(mut stts: TimeToSampleAtom, test_case: F) + where + F: FnOnce(&TimeToSampleAtom) -> TrimDurationTestCase, + { + let starting_duration = stts.entries.iter().fold(0, |sum, entry| { + sum + (entry.sample_count as u64 * entry.sample_duration as u64) + }); + + let test_case = test_case(&stts); + + let (actual_remaining_duration, actual_removed_samples) = + stts.trim_duration(&test_case.trim_duration); + + assert_eq!( + actual_removed_samples, + RangeSet::from_iter(test_case.expect_removed_samples.into_iter()), + "removed sample indices don't match what's expected" + ); + + let calculated_remaining_duration = stts.entries.iter().fold(0, |sum, entry| { + sum + (entry.sample_count as u64 * entry.sample_duration as u64) + }); + assert_eq!( + actual_remaining_duration, calculated_remaining_duration, + "remaining duration doesn't add up correctly" + ); + + let actual_removed_duration = starting_duration.saturating_sub(actual_remaining_duration); + assert_eq!( + actual_removed_duration, test_case.expect_removed_duration, + "removed duration doesn't match what's expected; started with {starting_duration} and ended up with {actual_remaining_duration}" + ); + + assert_eq!( + stts.entries.0, test_case.expect_entries, + "time to sample entries don't match what's expected" + ) + } + + macro_rules! test_trim_duration { + ($($name:ident $(($stts:expr))? => $test_case:expr,)*) => { + $( + #[test] + fn $name() { + let stts = test_trim_duration!(@get_stts $($stts)?); + test_trim_duration(stts, $test_case); + } + )* + }; + + (@get_stts $stts:expr) => { $stts }; + (@get_stts) => { test_trim_duration_stts() }; + } + + test_trim_duration!( + trim_unbounded_to_0_excluded_from_start => |stts| TrimDurationTestCase { + trim_duration: vec![(Bound::Unbounded, Bound::Excluded(0))], + expect_removed_samples: vec![], + expect_removed_duration: 0, + expect_entries: stts.entries.iter().cloned().collect::>(), + }, + trim_unbounded_to_0_included_from_start_trim_nothing => |stts| { + // 1s is less than 1 sample, so nothing should get trimmed + let expected_entries = stts.entries.0.clone(); + TrimDurationTestCase { + trim_duration: vec![(Bound::Unbounded, Bound::Included(0))], + expect_removed_samples: vec![], + expect_removed_duration: 0, + expect_entries: expected_entries, + } + }, + trim_unbounded_to_0_included_from_start_trim_sample ({ + TimeToSampleAtom::builder().entry( + TimeToSampleEntry { + sample_count: 100, + sample_duration: 1, + }, + ).build() + }) => |stts| { + // 1s is 1 sample, so we should trim 1 sample + let mut expected_entries = stts.entries.0.clone(); + expected_entries[0].sample_count -= 1; + TrimDurationTestCase { + trim_duration: vec![(Bound::Unbounded, Bound::Included(0))], + expect_removed_samples: vec![0..1], + expect_removed_duration: 1, + expect_entries: expected_entries, + } + }, + trim_first_entry_unbounded_start => |stts| TrimDurationTestCase { + trim_duration: vec![(Bound::Unbounded, Bound::Excluded(100))], + expect_removed_samples: vec![0..1], + expect_removed_duration: 100, + expect_entries: stts.entries[1..].to_vec(), + }, + trim_first_entry_included_start => |stts| TrimDurationTestCase { + trim_duration: vec![(Bound::Included(0), Bound::Excluded(100))], + expect_removed_samples: vec![0..1], + expect_removed_duration: 100, + expect_entries: stts.entries[1..].to_vec(), + }, + trim_last_sample_unbounded_end => |stts| { + let mut expect_entries = stts.entries.clone().0; + expect_entries.last_mut().unwrap().sample_count = 3; + TrimDurationTestCase { + trim_duration: vec![(Bound::Included(1_200), Bound::Unbounded)], + expect_removed_duration: 100, + expect_removed_samples: vec![8..9], + expect_entries, + } + }, + trim_last_three_samples_unbounded_end => |stts| { + let mut expect_entries = stts.entries.clone().0; + expect_entries.last_mut().unwrap().sample_count = 1; + TrimDurationTestCase { + trim_duration: vec![(Bound::Included(1_000), Bound::Unbounded)], + expect_removed_duration: 300, + expect_removed_samples: vec![6..9], + expect_entries, + } + }, + trim_last_sample_included_end => |stts| { + let mut expect_entries = stts.entries.clone().0; + expect_entries.last_mut().unwrap().sample_count = 3; + TrimDurationTestCase { + trim_duration: vec![(Bound::Included(1_200), Bound::Included(1_300 - 1))], + expect_removed_duration: 100, + expect_removed_samples: vec![8..9], + expect_entries, + } + }, + trim_middle_entry_excluded_end => |_| TrimDurationTestCase { + trim_duration: vec![(Bound::Included(100), Bound::Excluded(900))], + expect_removed_duration: 800, + expect_removed_samples: vec![1..5], + expect_entries: vec![ + TimeToSampleEntry { + sample_count: 5, + sample_duration: 100, + }, + ], + }, + trim_middle_entry_excluded_start => |_| TrimDurationTestCase { + trim_duration: vec![(Bound::Excluded(99), Bound::Excluded(900))], + expect_removed_duration: 800, + expect_removed_samples: vec![1..5], + expect_entries: vec![ + TimeToSampleEntry { + sample_count: 5, + sample_duration: 100, + }, + ], + }, + trim_middle_entry_excluded_start_included_end => |_| TrimDurationTestCase { + trim_duration: vec![(Bound::Excluded(99), Bound::Included(899))], + expect_removed_duration: 800, + expect_removed_samples: vec![1..5], + expect_entries: vec![ + TimeToSampleEntry { + sample_count: 5, + sample_duration: 100, + }, + ], + }, + trim_middle_samples => |stts| TrimDurationTestCase { + // entry 1 samples: + // sample index 1 starts at 100 (not trimmed) + // sample index 2 starts at 300 (trimmed) + // sample index 3 starts at 500 (trimmed) + // sample index 4 starts at 700 (not trimmed) + trim_duration: vec![(Bound::Included(300), Bound::Excluded(700))], + expect_removed_duration: 400, + expect_removed_samples: vec![2..4], + expect_entries: vec![ + stts.entries[0].clone(), + TimeToSampleEntry { + sample_count: 2, + sample_duration: 200, + }, + stts.entries[2].clone(), + ], + }, + trim_middle_samples_partial => |stts| TrimDurationTestCase { + // partially matching samples should be left intact + // entry 1 samples: + // sample index 1 starts at 100 (partially matched, not trimmed) + // sample index 2 starts at 300 (trimmed) + // sample index 3 starts at 500 (trimmed) + // sample index 4 starts at 700 (partially matched, not trimmed) + trim_duration: vec![(Bound::Included(240), Bound::Excluded(850))], + expect_removed_duration: 400, + expect_removed_samples: vec![2..4], + expect_entries: vec![ + stts.entries[0].clone(), + TimeToSampleEntry { + sample_count: 2, + sample_duration: 200, + }, + stts.entries[2].clone(), + ], + }, + trim_everything => |_| TrimDurationTestCase { + trim_duration: vec![(Bound::Unbounded, Bound::Unbounded)], + expect_removed_duration: 1_300, + expect_removed_samples: vec![0..9], + expect_entries: Vec::new(), + }, + trim_middle_from_large_entry ({ + TimeToSampleAtom::builder().entry( + // samples 0..10 + // duration 0..10_000 + // + // sample 0 => 0 + // sample 1 => 10_000 + // sample 2 => 20_000 (trimmed) + // sample 3 => 30_000 (trimmed) + // sample 4 => 40_000 (trimmed) + // sample 5 => 50_000 + // ... + TimeToSampleEntry { + sample_count: 10, + sample_duration: 10_000, + }, + ).build() + }) => |stts| TrimDurationTestCase { + trim_duration: vec![(Bound::Excluded(19_999), Bound::Included(50_000))], + expect_removed_duration: 30_000, + expect_removed_samples: vec![2..5], + expect_entries: stts.entries.iter().cloned().map(|mut entry| { + entry.sample_count = 7; + entry + }).collect::>(), + }, + trim_start_and_end => |stts| { + let mut expect_entries = stts.entries[1..].to_vec(); + expect_entries.last_mut().unwrap().sample_count = 1; + TrimDurationTestCase { + trim_duration: vec![ + (Bound::Included(0), Bound::Excluded(100)), + (Bound::Included(1_000), Bound::Excluded(1_300)), + ], + expect_removed_duration: 100 + 300, + expect_removed_samples: vec![0..1, 6..9], + expect_entries, + } + }, + trim_start_and_end_single_entry ({ + TimeToSampleAtom::builder().entry( + TimeToSampleEntry { + sample_count: 100, + sample_duration: 1, + }, + ).build() + }) => |stts| { + let mut expect_entry = stts.entries[0].clone(); + expect_entry.sample_count -= 20 + 20; + TrimDurationTestCase { + trim_duration: vec![ + (Bound::Unbounded, Bound::Excluded(20)), + (Bound::Included(80), Bound::Unbounded), + ], + expect_removed_duration: 20 + 20, + expect_removed_samples: vec![0..20, 80..100], + expect_entries: vec![expect_entry], + } + }, + trim_start_end_single_sample ({ + TimeToSampleAtom::builder().entry( + TimeToSampleEntry { + sample_count: 1, + sample_duration: 100, + }, + ).build() + }) => |stts| { + let expect_entry = stts.entries[0].clone(); + TrimDurationTestCase { + trim_duration: vec![ + (Bound::Included(50), Bound::Included(100)), + ], + expect_removed_duration: 0, + expect_removed_samples: vec![], + expect_entries: vec![expect_entry], + } + }, + ); +} diff --git a/src/atom/leaf/text.rs b/src/atom/leaf/text.rs new file mode 100644 index 0000000..2d79957 --- /dev/null +++ b/src/atom/leaf/text.rs @@ -0,0 +1,74 @@ +use crate::{atom::FourCC, parser::ParseAtomData, writer::SerializeAtom, ParseError}; + +pub const TEXT: FourCC = FourCC::new(b"text"); + +#[derive(Debug, Clone)] +pub struct TextMediaInfoAtom { + /// 3x3 transformation matrix for text media + pub matrix: [i32; 9], +} + +impl Default for TextMediaInfoAtom { + fn default() -> Self { + Self { + matrix: [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], + } + } +} + +impl ParseAtomData for TextMediaInfoAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, TEXT); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_text_data.parse(stream(input))?) + } +} + +impl SerializeAtom for TextMediaInfoAtom { + fn atom_type(&self) -> FourCC { + TEXT + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_text_data(self) + } +} + +mod serializer { + use crate::atom::text::TextMediaInfoAtom; + + pub fn serialize_text_data(text: TextMediaInfoAtom) -> Vec { + text.matrix + .into_iter() + .flat_map(|v| v.to_be_bytes()) + .collect() + } +} + +mod parser { + use crate::atom::{ + text::TextMediaInfoAtom, + util::parser::{fixed_array, Stream}, + }; + use winnow::{binary::be_i32, combinator::seq, error::StrContext, ModalResult, Parser}; + + pub fn parse_text_data(input: &mut Stream<'_>) -> ModalResult { + seq!(TextMediaInfoAtom { + matrix: fixed_array(be_i32).context(StrContext::Label("matrix")), + }) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available text test data files + #[test] + fn test_text_roundtrip() { + test_atom_roundtrip::(TEXT); + } +} diff --git a/src/atom/leaf/tkhd.rs b/src/atom/leaf/tkhd.rs new file mode 100644 index 0000000..b931620 --- /dev/null +++ b/src/atom/leaf/tkhd.rs @@ -0,0 +1,229 @@ +use bon::Builder; +use std::time::Duration; + +use crate::{ + atom::{ + util::{mp4_timestamp_now, scaled_duration, unscaled_duration}, + FourCC, + }, + parser::ParseAtomData, + writer::SerializeAtom, + ParseError, +}; + +pub const TKHD: FourCC = FourCC::new(b"tkhd"); + +const IDENTITY_MATRIX: [i32; 9] = [0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000]; + +#[derive(Default, Debug, Clone, Builder)] +pub struct TrackHeaderAtom { + /// Version of the tkhd atom format (0 or 1) + #[builder(default = 0)] + pub version: u8, + /// Flags for the tkhd atom (bit flags for track properties) + #[builder(default = [0, 0, 7])] + pub flags: [u8; 3], + /// When the track was created (seconds since Jan 1, 1904 UTC) + #[builder(default = mp4_timestamp_now())] + pub creation_time: u64, + /// When the track was last modified (seconds since Jan 1, 1904 UTC) + #[builder(default = mp4_timestamp_now())] + pub modification_time: u64, + /// Unique identifier for this track within the movie + pub track_id: u32, + /// Duration of the track in movie timescale units + pub duration: u64, + /// Playback layer (lower numbers are closer to viewer) + #[builder(default = 0)] + pub layer: i16, + /// Audio balance or stereo balance (-1.0 = left, 0.0 = center, 1.0 = right) + #[builder(default = 0)] + pub alternate_group: i16, + /// Audio volume level (1.0 = full volume, 0.0 = muted) + #[builder(default = 1.0)] + pub volume: f32, + /// 3x3 transformation matrix for video display positioning/rotation + /// + /// `None` if matrix is empty or is the identity matrix + pub matrix: Option<[i32; 9]>, + /// Track width in pixels (fixed-point 16.16) + #[builder(default = 0.0)] + pub width: f32, + /// Track height in pixels (fixed-point 16.16) + #[builder(default = 0.0)] + pub height: f32, +} + +impl TrackHeaderAtom { + pub fn duration(&self, movie_timescale: u64) -> Duration { + unscaled_duration(self.duration, movie_timescale) + } + + pub fn update_duration(&mut self, movie_timescale: u64, mut closure: F) -> &mut Self + where + F: FnMut(Duration) -> Duration, + { + self.duration = scaled_duration(closure(self.duration(movie_timescale)), movie_timescale); + self + } +} + +impl ParseAtomData for TrackHeaderAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, TKHD); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_tkhd_data.parse(stream(input))?) + } +} + +impl SerializeAtom for TrackHeaderAtom { + fn atom_type(&self) -> FourCC { + TKHD + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_tkhd_data(self) + } +} + +mod serializer { + use crate::atom::{ + tkhd::IDENTITY_MATRIX, + util::serializer::{fixed_point_16x16, fixed_point_8x8}, + }; + + use super::TrackHeaderAtom; + + pub fn serialize_tkhd_data(tkhd: TrackHeaderAtom) -> Vec { + let mut data = Vec::new(); + + let version: u8 = if tkhd.version == 1 + || tkhd.creation_time > u64::from(u32::MAX) + || tkhd.modification_time > u64::from(u32::MAX) + || tkhd.duration > u64::from(u32::MAX) + { + 1 + } else { + 0 + }; + + let be_u32_or_u64 = |v: u64| match version { + 0 => u32::try_from(v).unwrap().to_be_bytes().to_vec(), + 1 => v.to_be_bytes().to_vec(), + _ => unreachable!(), + }; + + data.push(version); + data.extend(tkhd.flags); + data.extend(be_u32_or_u64(tkhd.creation_time)); + data.extend(be_u32_or_u64(tkhd.modification_time)); + data.extend(tkhd.track_id.to_be_bytes()); + data.extend([0u8; 4]); // reserved + data.extend(be_u32_or_u64(tkhd.duration)); + data.extend([0u8; 8]); // reserved + data.extend(tkhd.layer.to_be_bytes()); + data.extend(tkhd.alternate_group.to_be_bytes()); + data.extend(fixed_point_8x8(tkhd.volume)); + data.extend([0u8; 2]); // reserved + data.extend( + tkhd.matrix + .unwrap_or(IDENTITY_MATRIX) + .into_iter() + .flat_map(|v| v.to_be_bytes()), + ); + data.extend(fixed_point_16x16(tkhd.width)); + data.extend(fixed_point_16x16(tkhd.height)); + + data + } +} + +mod parser { + use winnow::{ + binary::{be_i16, be_i32, be_u32, be_u64}, + combinator::{seq, trace}, + error::{StrContext, StrContextValue}, + ModalResult, Parser, + }; + + use super::TrackHeaderAtom; + use crate::atom::{ + tkhd::IDENTITY_MATRIX, + util::parser::{ + be_u32_as_u64, byte_array, fixed_array, fixed_point_16x16, fixed_point_8x8, flags3, + version, Stream, + }, + }; + + pub fn parse_tkhd_data(input: &mut Stream<'_>) -> ModalResult { + let be_u32_or_u64 = |version: u8| { + let be_u64_type_fix = + |input: &mut Stream<'_>| -> ModalResult { be_u64.parse_next(input) }; + match version { + 0 => be_u32_as_u64, + 1 => be_u64_type_fix, + _ => unreachable!(), + } + }; + + trace( + "tkhd", + seq!(TrackHeaderAtom { + version: version + .verify(|version| *version <= 1) + .context(StrContext::Expected(StrContextValue::Description( + "expected version 0 or 1" + ))), + flags: flags3, + creation_time: be_u32_or_u64(version).context(StrContext::Label("creation_time")), + modification_time: be_u32_or_u64(version).context(StrContext::Label("modification_time")), + track_id: be_u32.context(StrContext::Label("track_id")), + _: byte_array::<4>.context(StrContext::Label("reserved_1")), + duration: be_u32_or_u64(version), + _: byte_array::<8>.context(StrContext::Label("reserved_2")), + layer: be_i16.context(StrContext::Label("layer")), + alternate_group: be_i16.context(StrContext::Label("alternate_group")), + volume: fixed_point_8x8.context(StrContext::Label("volume")), + _: byte_array::<2>.context(StrContext::Label("reserved_3")), + matrix: matrix.context(StrContext::Label("matrix")), + width: fixed_point_16x16.context(StrContext::Label("width")), + height: fixed_point_16x16.context(StrContext::Label("height")), + }) + .context(StrContext::Label("tkhd")), + ) + .parse_next(input) + } + + fn matrix(input: &mut Stream<'_>) -> ModalResult> { + trace( + "matrix", + fixed_array(be_i32).map(|matrix: [i32; 9]| { + if is_empty_matrix(&matrix) { + None + } else { + Some(matrix) + } + }), + ) + .parse_next(input) + } + + fn is_empty_matrix(matrix: &[i32; 9]) -> bool { + let empty = [0; 9]; + let identity = IDENTITY_MATRIX; + matrix == &empty || matrix == &identity + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available tkhd test data files + #[test] + fn test_tkhd_roundtrip() { + test_atom_roundtrip::(TKHD); + } +} diff --git a/src/atom/leaf/tref.rs b/src/atom/leaf/tref.rs new file mode 100644 index 0000000..81e7d49 --- /dev/null +++ b/src/atom/leaf/tref.rs @@ -0,0 +1,166 @@ +use std::fmt; + +use crate::{atom::FourCC, parser::ParseAtomData, writer::SerializeAtom, ParseError}; + +pub const TREF: FourCC = FourCC::new(b"tref"); + +/// A single track reference entry containing the reference type and target track IDs +#[derive(Debug, Clone)] +pub struct TrackReference { + /// The type of reference (e.g., "hint", "chap", "subt") + pub reference_type: FourCC, + /// List of track IDs that this reference points to + pub track_ids: Vec, +} + +impl TrackReference { + /// Check if this reference is of a specific type + pub fn is_type(&self, ref_type: &[u8; 4]) -> bool { + self.reference_type == ref_type + } +} + +impl fmt::Display for TrackReference { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} -> [{}]", + self.reference_type, + self.track_ids + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + ) + } +} + +/// Track Reference Atom (tref) - ISO/IEC 14496-12 +/// Contains references from this track to other tracks +#[derive(Default, Debug, Clone)] +pub struct TrackReferenceAtom { + /// List of track references + pub references: Vec, +} + +impl TrackReferenceAtom { + pub fn new(references: impl Into>) -> Self { + Self { + references: references.into(), + } + } + + pub fn replace_references(&mut self, references: impl Into>) -> &mut Self { + self.references = references.into(); + self + } +} + +impl fmt::Display for TrackReferenceAtom { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.references.is_empty() { + write!(f, "TrackReferenceAtom {{ no references }}") + } else { + write!(f, "TrackReferenceAtom {{")?; + for (i, reference) in self.references.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, " {reference}")?; + } + write!(f, " }}") + } + } +} + +impl ParseAtomData for TrackReferenceAtom { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result { + crate::atom::util::parser::assert_atom_type!(atom_type, TREF); + use crate::atom::util::parser::stream; + use winnow::Parser; + Ok(parser::parse_tref_data.parse(stream(input))?) + } +} + +impl SerializeAtom for TrackReferenceAtom { + fn atom_type(&self) -> FourCC { + TREF + } + + fn into_body_bytes(self) -> Vec { + serializer::serialize_tref_atom(self) + } +} + +mod serializer { + use crate::atom::util::serializer::{prepend_size_inclusive, SizeU32}; + + use super::TrackReferenceAtom; + + pub fn serialize_tref_atom(tref: TrackReferenceAtom) -> Vec { + tref.references + .into_iter() + .flat_map(|reference| { + prepend_size_inclusive::(move || { + let mut data = Vec::new(); + + data.extend(reference.reference_type.into_bytes()); + for track_id in reference.track_ids { + data.extend(track_id.to_be_bytes()); + } + + data + }) + }) + .collect() + } +} + +mod parser { + use winnow::{ + binary::be_u32, + combinator::{repeat, seq, trace}, + error::StrContext, + ModalResult, Parser, + }; + + use super::{TrackReference, TrackReferenceAtom}; + use crate::atom::util::parser::{combinators::inclusive_length_and_then, fourcc, Stream}; + + pub fn parse_tref_data(input: &mut Stream<'_>) -> ModalResult { + trace( + "tref", + seq!(TrackReferenceAtom { + references: repeat(0.., inclusive_length_and_then(be_u32, reference)) + .context(StrContext::Label("references")), + }) + .context(StrContext::Label("tref")), + ) + .parse_next(input) + } + + fn reference(input: &mut Stream<'_>) -> ModalResult { + trace( + "reference", + seq!(TrackReference { + reference_type: fourcc.context(StrContext::Label("reference_type")), + track_ids: repeat(0.., be_u32.context(StrContext::Label("track_id"))) + .context(StrContext::Label("track_ids")), + }) + .context(StrContext::Label("reference")), + ) + .parse_next(input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atom::test_utils::test_atom_roundtrip; + + /// Test round-trip for all available tref test data files + #[test] + fn test_tref_roundtrip() { + test_atom_roundtrip::(TREF); + } +} diff --git a/src/atom/test_utils.rs b/src/atom/test_utils.rs new file mode 100644 index 0000000..1666c3b --- /dev/null +++ b/src/atom/test_utils.rs @@ -0,0 +1,455 @@ +//! Test utilities for atom round-trip testing + +use crate::atom::{util::parser::stream, FourCC}; +use crate::parser::ParseAtomData; +use crate::writer::SerializeAtom; +use anyhow::{Context, Result}; +use std::fs; + +pub mod test_file { + use std::{ + fmt, fs, + path::{Path, PathBuf}, + }; + + use anyhow::{anyhow, Context}; + + pub trait Matcher { + fn dirname(&self) -> Option<&str> { + None + } + + fn match_file(&self, file_name: &str) -> bool; + + /// Given an input file name, return the output file name to look for. + /// + /// If `None`, it's assumed that the input file should be used for the expected output. + /// + /// NOTE: if the returned file name doesn't exist, the input file will be used same as if `None` had been returned. + fn output_file(&self, file_name: &str) -> Option { + let output_file_name = PathBuf::from(file_name); + match output_file_name.extension() { + Some(ext) => { + let output_file_name = + output_file_name.with_extension("out").with_extension(ext); + Some(output_file_name) + } + None => None, + } + } + } + + /// Matches paths following the pattern `{atom_type}{i:02}.bin` + pub struct AtomMatcher<'a> { + atom_type: &'a str, + } + + impl<'a> AtomMatcher<'a> { + pub fn new(atom_type: &'a str) -> Self { + Self { atom_type } + } + } + + impl<'a> Matcher for AtomMatcher<'a> { + fn match_file(&self, file_name: &str) -> bool { + let atom_type = self.atom_type; + if file_name.starts_with(atom_type) && file_name.ends_with(".bin") { + // Check if it matches the pattern {atom_type}{i:02}.bin + let expected_prefix = atom_type.to_string(); + if file_name.len() == expected_prefix.len() + 6 // {i:02}.bin + && file_name[expected_prefix.len()..expected_prefix.len() + 2] + .chars() + .all(|c| c.is_ascii_digit()) + { + return true; + } + } + false + } + } + + #[derive(Clone, PartialEq, Eq)] + pub struct TestCase { + input_file: PathBuf, + expected_output_file: Option, + } + + impl fmt::Display for TestCase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_fmt(format_args!("{}", self.input_file.display())) + } + } + + impl PartialOrd for TestCase { + fn partial_cmp(&self, other: &Self) -> Option { + self.input_file.partial_cmp(&other.input_file) + } + } + + impl Ord for TestCase { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.input_file.cmp(&other.input_file) + } + } + + impl TestCase { + fn new(input_file: PathBuf, expected_output_file: Option) -> Self { + Self { + input_file, + expected_output_file, + } + } + + pub fn input_file(&self) -> &PathBuf { + &self.input_file + } + + pub fn output_file(&self) -> Option<&PathBuf> { + self.expected_output_file.as_ref() + } + } + + fn safe_join>(left: PathBuf, right: P) -> anyhow::Result { + let right = right.as_ref(); + let res = left.join(right).canonicalize().context("invalid path")?; + if !res.starts_with(left) { + return Err(anyhow!("path escapes base dir: {}", right.display())); + } + Ok(res) + } + + /// Discovers all test data files for a given [`Matcher`]. + pub fn discover(matcher: impl Matcher) -> Vec { + let mut test_data_dir = PathBuf::new() + .join("test-data") + .canonicalize() + .expect("invalid path"); + if let Some(path) = matcher.dirname() { + test_data_dir = safe_join(test_data_dir, path).expect("invalid path"); + } + + assert!(test_data_dir.exists(), "test-data dir doesn't exist"); + + let mut files = Vec::new(); + + if let Ok(entries) = fs::read_dir(test_data_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if matcher.match_file(file_name) { + let expected_output_file = match matcher.output_file(file_name) { + Some(p) => match fs::exists(&p) { + Ok(true) => Some(p), + Ok(false) | Err(_) => None, + }, + None => None, + }; + files.push(TestCase::new(path, expected_output_file)); + } + } + } + } + + files.sort(); + files + } + + /// Discovers all test data files for a given atom type (e.g., `ilst`, `ftyp`, `dref`) + pub fn discover_atom(atom_type: &str) -> Vec { + discover(AtomMatcher::new(atom_type)) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_discover_test_files() { + let files = discover_atom("ilst") + .into_iter() + .map(|tc| tc.input_file().to_string_lossy().to_string()) + .collect::>(); + + // Should find at least ilst00.bin and ilst01.bin if they exist + assert!(files.iter().any(|f| f.contains("ilst00.bin")) || files.is_empty()); + + // Test files should be sorted + let mut sorted_files = files.clone(); + sorted_files.sort(); + assert_eq!(files, sorted_files); + } + + #[test] + fn test_discover_nonexistent_files() { + let files = discover_atom("nonexistent"); + assert!(files.is_empty()); + } + } +} + +/// Performs a round-trip test for a specific atom type using all available test data +/// +/// # Arguments +/// * `atom_type_bytes` - The 4-byte atom type identifier +/// +/// # Type Parameters +/// * `T` - The atom type that implements both Parse and SerializeAtom +/// +/// # Example +/// ``` +/// use mp4_edit::atom::test_utils::test_atom_roundtrip; +/// use mp4_edit::atom::ilst::{ItemListAtom, ILST}; +/// +/// test_atom_roundtrip::(ILST).unwrap(); +/// ``` +pub(crate) fn test_atom_roundtrip_inner(atom_type_bytes: &[u8; 4]) -> Result<()> +where + T: ParseAtomData + SerializeAtom + Send + Clone + std::fmt::Debug, +{ + let atom_type_str = std::str::from_utf8(atom_type_bytes) + .unwrap_or_else(|_| panic!("Invalid atom type bytes: {:?}", atom_type_bytes)); + + let test_cases = test_file::discover_atom(atom_type_str); + + if test_cases.is_empty() { + println!("No test files found for atom type '{}'", atom_type_str); + return Ok(()); + } + + println!( + "Testing {} files for atom type '{}'", + test_cases.len(), + atom_type_str + ); + + for tc in test_cases { + println!("Testing: {}", tc); + test_atom_roundtrip_single::(atom_type_bytes, tc)?; + } + + Ok(()) +} + +/// Tests a single file for round-trip consistency +fn test_atom_roundtrip_single(atom_type_bytes: &[u8; 4], tc: test_file::TestCase) -> Result<()> +where + T: ParseAtomData + SerializeAtom + Send + Clone + std::fmt::Debug, +{ + let input_data = + fs::read(tc.input_file()).unwrap_or_else(|_| panic!("Failed to read test file: {}", tc)); + + if input_data.len() < 8 { + panic!("Test file too small (< 8 bytes): {}", tc); + } + + let expected_output_data = match tc.output_file() { + Some(output_path) => { + let data = fs::read(output_path) + .map(|data| data) + .context(format!("failed to read test output file: {}", tc)) + .unwrap(); + if data.len() < 8 { + panic!("test output file too small (< 8 bytes): {}", tc); + } + Some(data) + } + None => None, + }; + + // Skip the atom size (4 bytes) and fourcc (4 bytes) to get just the atom data + let input_data = &input_data[8..]; + // If there's an output file, match against that, otherwise assert that output matches input + let expected_output_data = match expected_output_data.as_ref() { + Some(data) => &data[8..], + None => input_data, + }; + + // Parse the atom data + let fourcc = FourCC::from(*atom_type_bytes); + let mut input = stream(input_data); + let parsed_atom = T::parse_atom_data(fourcc, &mut input) + .unwrap_or_else(|e| panic!("Failed to parse atom from {}: {:#?}", tc, e)); + + // Serialize the atom back to bytes + let re_encoded = parsed_atom.clone().into_body_bytes(); + + let mut input = stream(&re_encoded); + match T::parse_atom_data(fourcc, &mut input) { + Ok(_) => {} + Err(_) => { + println!("{parsed_atom:#?}"); + } + } + + // Compare data in chunks for better error reporting + const CHUNK_SIZE: usize = 200; + for ((i, left), right) in re_encoded + .chunks(CHUNK_SIZE) + .enumerate() + .zip(expected_output_data.chunks(CHUNK_SIZE)) + { + let start_index = i * CHUNK_SIZE; + + if left != right { + let mut local_mismatch_index = 0; + for i in 0..left.len().min(right.len()) { + if left[0..=i] != right[0..=i] { + local_mismatch_index = i; + break; + } + } + + let mut mismatch_len = left.len().max(right.len()); + if left.len() == right.len() { + for i in (0..left.len()).into_iter().rev() { + if left[i..] != right[i..] { + mismatch_len = i + 1; + break; + } + } + } + + let mismatch_range_start = start_index + local_mismatch_index; + let mismatch_range_end = start_index + mismatch_len; + + let re_encoded_len = re_encoded.len(); + let original_len = expected_output_data.len(); + let delta = re_encoded_len.max(original_len) - re_encoded_len.min(original_len); + + let matched_data_start = &right[0..local_mismatch_index]; + let mismatched_data_right = &right[local_mismatch_index..mismatch_len.min(right.len())]; + let mismatched_data_left = &left[local_mismatch_index..mismatch_len.min(left.len())]; + let matched_data_end = if mismatch_len > right.len() { + &[0u8; 0] + } else { + &right[mismatch_len..] + }; + + println!( + "Round-trip failed for {tc} at range [{mismatch_range_start}..{mismatch_range_end}] (left.len()={re_encoded_len}, right.len()={original_len}, delta={delta})\nOriginal: {:02X?}{:02X?}{:02X?}\nRe-encoded: {:02X?}{:02X?}{:02X?}", + matched_data_start, + mismatched_data_right, + matched_data_end, + matched_data_start, + mismatched_data_left, + matched_data_end, + ); + + panic!("left != right"); + } + } + + println!("โœ“ {} passed round-trip test", tc); + Ok(()) +} + +// pub fn test_stsd_extension_roundtrip(typ: &[u8; 4]) { +// use winnow::Parser; + +// use crate::atom::util::parser::stream; + +// let typ = FourCC(*typ); +// let typ_string = typ.to_string(); + +// let files = discover_test_files(&typ_string, Some("stsd")); +// for file in files.iter() { +// eprintln!("Testing {file}..."); + +// let data = fs::read(file).expect(format!("error reading {file}").as_str()); + +// let parsed = parse_stsd_extension(typ) +// .parse(stream(&data)) +// .expect(format!("error parsing {file}").as_str()); + +// let re_encoded = serialize_stsd_extension(parsed); + +// assert_bytes_equal(&re_encoded, &data); +// } +// } + +pub fn assert_bytes_equal(actual: &[u8], expected: &[u8]) { + if actual == expected { + return; + } + + // Format bytes as hex in groups of `size` + fn format_hex_groups(size: usize, data: &[u8]) -> String { + data.chunks(size) + .map(|chunk| { + chunk + .iter() + .map(|b| format!("{:02X}", b)) + .collect::>() + .join("") + }) + .collect::>() + .join(" ") + } + + fn format_hex_groups_right_aligned(data: &[u8]) -> String { + let mut data = data.to_vec(); + data.reverse(); + data.chunks(4) + .map(|chunk| { + chunk + .iter() + .rev() + .map(|b| format!("{:02X}", b)) + .collect::>() + .join("") + }) + .rev() + .collect::>() + .join(" ") + } + + let actual_hex = format_hex_groups(4, actual); + let expected_hex = format_hex_groups(4, expected); + + let actual_hex_1 = format_hex_groups(1, actual); + let expected_hex_1 = format_hex_groups(1, expected); + + let actual_right_aligned = format_hex_groups_right_aligned(actual); + let expected_right_aligned = format_hex_groups_right_aligned(expected); + + panic!( + "Bytes are not equal!\nActual length: {}\nExpected length: {}\nActual: {}\nExpected: {}\n\nActual: {:>w$}\nExpected: {:>w$}\n\nActual: {}\nExpected: {}\n\nActual: {:>w2$}\nExpected: {:>w2$}", + actual.len(), + expected.len(), + actual_hex, + expected_hex, + actual_right_aligned, + expected_right_aligned, + actual_hex_1, + expected_hex_1, + actual_hex_1, + expected_hex_1, + w = actual_right_aligned.len().max(expected_right_aligned.len()), + w2 = actual_hex_1.len().max(expected_hex_1.len()), + ) +} + +/// Round-trip test for use in `#[test]` functions +/// +/// # Arguments +/// * `atom_type_bytes` - The 4-byte atom type identifier +/// +/// # Type Parameters +/// * `T` - The atom type that implements both Parse and SerializeAtom +/// +/// # Example +/// ``` +/// use mp4_edit::atom::test_utils::test_atom_roundtrip; +/// use mp4_edit::atom::ilst::{ItemListAtom, ILST}; +/// +/// fn test_ilst_roundtrip() { +/// test_atom_roundtrip::(ILST); +/// } +/// ``` +pub(crate) fn test_atom_roundtrip(atom_type: impl Into) +where + T: ParseAtomData + SerializeAtom + Send + Clone + std::fmt::Debug, +{ + let atom_type: FourCC = atom_type.into(); + test_atom_roundtrip_inner::(atom_type.as_bytes()).expect("Round-trip test failed"); +} diff --git a/src/atom/util.rs b/src/atom/util.rs new file mode 100644 index 0000000..f7a59f5 --- /dev/null +++ b/src/atom/util.rs @@ -0,0 +1,26 @@ +mod debug; +pub mod parser; +pub mod serializer; +mod time; + +use std::fmt; + +pub use debug::*; +pub use time::*; + +#[derive(Clone, Default)] +pub struct ColorRgb { + pub red: u16, + pub green: u16, + pub blue: u16, +} + +impl fmt::Debug for ColorRgb { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ColorRgb") + .field("red", &DebugUpperHex(self.red)) + .field("green", &DebugUpperHex(self.green)) + .field("blue", &DebugUpperHex(self.blue)) + .finish() + } +} diff --git a/src/atom/util/debug.rs b/src/atom/util/debug.rs new file mode 100644 index 0000000..b4bee68 --- /dev/null +++ b/src/atom/util/debug.rs @@ -0,0 +1,52 @@ +use std::{ + fmt::{self, UpperHex}, + marker::PhantomData, +}; + +pub struct DebugEllipsis(pub Option); + +impl fmt::Debug for DebugEllipsis { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("...")?; + if let Some(size) = self.0 { + write!(f, "({size})")?; + } + Ok(()) + } +} + +pub struct DebugList(List, usize, PhantomData); + +impl DebugList { + pub fn new(list: List, max_len: usize) -> Self { + Self(list, max_len, PhantomData) + } +} + +impl fmt::Debug for DebugList +where + List: Iterator + ExactSizeIterator + Clone, + Item: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (list, max_len) = (self.0.clone(), self.1); + let (lower_bound, upper_bound) = list.size_hint(); + let size = upper_bound.unwrap_or(lower_bound); + if size <= max_len { + f.debug_list().entries(list).finish() + } else { + f.debug_list() + .entries(list.take(max_len)) + .entry(&DebugEllipsis(Some(size - max_len))) + .finish() + } + } +} + +pub struct DebugUpperHex(pub T); + +impl fmt::Debug for DebugUpperHex { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "0x{:X}", self.0) + } +} diff --git a/src/atom/util/parser.rs b/src/atom/util/parser.rs new file mode 100644 index 0000000..bd5e9fd --- /dev/null +++ b/src/atom/util/parser.rs @@ -0,0 +1,291 @@ +use std::fmt::Debug; + +use winnow::{ + binary::{be_i32, be_u16, be_u32, be_u64, length_and_then, u8}, + combinator::{seq, trace}, + error::{ParserError, StrContext, StrContextValue}, + token::rest, + Bytes, LocatingSlice, ModalResult, Parser, +}; + +use crate::{atom::util::ColorRgb, FourCC}; + +macro_rules! assert_atom_type { + ($actual:ident, $( $expected:ident ),+ $(,)?) => { + if $( $actual != $expected )&&+ { + return Err(crate::parser::ParseError::new_unexpected_atom_oneof($actual, vec![$( $expected ),+])); + } + }; +} +pub(crate) use assert_atom_type; + +pub type Stream<'i> = LocatingSlice<&'i Bytes>; + +pub fn stream(b: &[u8]) -> Stream<'_> { + LocatingSlice::new(Bytes::new(b)) +} + +pub fn fourcc(input: &mut Stream<'_>) -> winnow::ModalResult { + trace( + "fourcc", + (byte_array) + .map(FourCC) + .context(StrContext::Label("fourcc")), + ) + .parse_next(input) +} + +pub fn version(input: &mut Stream<'_>) -> winnow::ModalResult { + trace("version", u8) + .context(StrContext::Label("version")) + .parse_next(input) +} + +pub fn version_0_or_1(input: &mut Stream<'_>) -> ModalResult { + trace( + "version_0_or_1", + version + .verify(|version| *version <= 1) + .context(StrContext::Expected(StrContextValue::Description( + "expected version 0 or 1", + ))), + ) + .parse_next(input) +} + +pub fn be_u32_as_usize(input: &mut Stream<'_>) -> winnow::ModalResult { + trace( + "usize_be_u32", + be_u32 + .map(|s| s as usize) + .context(StrContext::Expected(StrContextValue::Description("be u32"))), + ) + .parse_next(input) +} + +pub fn be_u32_as_u64(input: &mut Stream<'_>) -> ModalResult { + trace( + "be_u32_as_u64", + be_u32 + .map(|s| s as u64) + .context(StrContext::Expected(StrContextValue::Description("be u32"))), + ) + .parse_next(input) +} + +pub fn be_u32_as<'i, T, E>(input: &mut Stream<'i>) -> ModalResult +where + T: TryFrom + 'i, + E: std::error::Error + Send + Sync + 'static, +{ + trace( + "be_u32_as", + be_u32 + .try_map(|s| T::try_from(s)) + .context(StrContext::Expected(StrContextValue::Description("be u32"))), + ) + .parse_next(input) +} + +pub fn be_i32_as<'i, T, E>(input: &mut Stream<'i>) -> ModalResult +where + T: TryFrom + 'i, + E: std::error::Error + Send + Sync + 'static, +{ + trace( + "be_i32_as", + be_i32 + .try_map(|s| T::try_from(s)) + .context(StrContext::Expected(StrContextValue::Description("be i32"))), + ) + .parse_next(input) +} + +pub fn atom_size(input: &mut Stream<'_>) -> ModalResult { + trace("atom_size", move |input: &mut Stream| { + let mut size = be_u32_as_u64.parse_next(input)?; + if size == 1 { + size = be_u64.parse_next(input)?; + } + Ok(size as usize) + }) + .parse_next(input) +} + +pub fn flags3(input: &mut Stream<'_>) -> winnow::ModalResult<[u8; 3]> { + trace("flags", byte_array) + .context(StrContext::Label("flags")) + .parse_next(input) +} + +/// Parses a u8 len, and then a UTF8 string with that len +pub fn pascal_string(input: &mut Stream<'_>) -> ModalResult { + trace("pascal_string", length_and_then(u8, utf8_string)).parse_next(input) +} + +/// Parses a UTF8 string from the remainder of the buffer +pub fn utf8_string(input: &mut Stream<'_>) -> ModalResult { + trace( + "utf8_string", + rest.try_map(|data: &[u8]| String::from_utf8(data.to_vec())) + .context(StrContext::Expected(StrContextValue::Description( + "UTF8 string", + ))), + ) + .parse_next(input) +} + +pub fn byte_array(input: &mut Stream<'_>) -> winnow::ModalResult<[u8; N]> { + trace("byte_array", fixed_array(u8)).parse_next(input) +} + +pub fn rest_vec<'i>(input: &mut Stream<'i>) -> ModalResult> { + trace("rest_vec", move |input: &mut Stream<'i>| { + let data = rest.parse_next(input)?; + Ok(data.to_vec()) + }) + .parse_next(input) +} + +pub fn fixed_array<'i, const N: usize, Input, Output, Error, ParseNext>( + mut parser: ParseNext, +) -> impl Parser + 'i +where + Input: winnow::stream::Stream + 'i, + ParseNext: Parser + 'i, + Error: ParserError + 'i, + Output: Debug + 'i, +{ + trace("fixed_array", move |input: &mut Input| { + let mut list: Vec = Vec::with_capacity(N); + for _ in 0..N { + list.push(parser.by_ref().complete_err().parse_next(input)?); + } + let out: [Output; N] = list.try_into().unwrap(); + Ok(out) + }) +} + +pub const FIXED_POINT_16X16_SCALE: f32 = 65536.0; + +pub fn fixed_point_16x16(input: &mut Stream<'_>) -> ModalResult { + trace( + "fixed_point_16_x_16", + be_u32.map(|v| (v as f32) / FIXED_POINT_16X16_SCALE), + ) + .parse_next(input) +} + +pub const FIXED_POINT_8X8_SCALE: f32 = 256.0; + +pub fn fixed_point_8x8(input: &mut Stream<'_>) -> ModalResult { + trace( + "fixed_point_8x8", + be_u16.map(|v| (v as f32) / FIXED_POINT_8X8_SCALE), + ) + .parse_next(input) +} + +/// Read be_u32 from between 1 and 4 bytes using VLQ +pub fn variable_length_be_u32(input: &mut Stream<'_>) -> ModalResult { + variable_length_quantity::<_, 4>(input) +} + +fn variable_length_quantity(input: &mut Stream<'_>) -> ModalResult +where + T: From + std::ops::Shl + std::ops::BitOr, +{ + let mut length = T::from(0); + for _ in 0..N { + let byte = u8.parse_next(input)?; + length = (length << 7) | T::from(byte & 0b0111_1111); + if (byte & 0b1000_0000) == 0 { + break; + } + } + Ok(length) +} + +pub fn color_rgb(input: &mut Stream<'_>) -> ModalResult { + seq!(ColorRgb { + red: be_u16.context(StrContext::Label("red")), + green: be_u16.context(StrContext::Label("green")), + blue: be_u16.context(StrContext::Label("blue")), + }) + .parse_next(input) +} + +pub mod combinators { + use winnow::combinator::trace; + use winnow::error::ParserError; + use winnow::stream::{Location, Stream, StreamIsPartial, ToUsize, UpdateSlice}; + use winnow::token::take; + use winnow::Parser; + + pub fn inclusive_length_and_then( + mut count: CountParser, + mut parser: ParseNext, + ) -> impl Parser + where + Input: StreamIsPartial + Stream + Location + UpdateSlice + Clone, + Count: ToUsize, + CountParser: Parser, + ParseNext: Parser, + Error: ParserError, + { + trace("inclusive_length_and_then", move |i: &mut Input| { + let size = with_len(count.by_ref().map(|c| c.to_usize())) + .map(|(a, b)| a.saturating_sub(b)) + .complete_err() + .parse_next(i)?; + let data = take(size).parse_next(i)?; + let mut data = Input::update_slice(i.clone(), data); + let _ = data.complete(); + let o = parser.by_ref().complete_err().parse_next(&mut data)?; + Ok(o) + }) + } + + fn with_len(mut parser: ParseNext) -> impl Parser + where + I: Stream + Location, + E: ParserError, + ParseNext: Parser, + { + trace("with_len", move |input: &mut I| { + let start = input.current_token_start(); + parser.by_ref().parse_next(input).map(move |output| { + let end = input.previous_token_end(); + (output, end - start) + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! test_variable_length_quantity { + ({ $( $name:ident::<$t:ident, $n:literal>($input:expr) => $expected:expr ),+ $(,)? }) => { + $(#[test] fn $name() { + let input = $input; + let output = variable_length_quantity::<$t, $n> + .parse(stream(&input)) + .unwrap(); + let expected: $t = $expected; + assert_eq!(output, expected); + })+ + }; + } + + test_variable_length_quantity!({ + test_u16_127::(vec![0x7F]) => 127, + test_u16_128::(vec![0x81, 0x00]) => 128, + test_u16_358::(vec![0x82, 0x66]) => 358, + test_u32_358::(vec![0x82, 0x66]) => 358, + test_u64_358::(vec![0x82, 0x66]) => 358, + test_u32_358_padded_x1::(vec![0x80, 0x82, 0x66]) => 358, + test_u32_358_padded_x2::(vec![0x80, 0x80, 0x82, 0x66]) => 358, + }); +} diff --git a/src/atom/util/serializer.rs b/src/atom/util/serializer.rs new file mode 100644 index 0000000..f06d9de --- /dev/null +++ b/src/atom/util/serializer.rs @@ -0,0 +1,621 @@ +use std::{collections::VecDeque, marker::PhantomData}; + +use crate::atom::util::{ + parser::{FIXED_POINT_16X16_SCALE, FIXED_POINT_8X8_SCALE}, + ColorRgb, +}; + +pub fn be_u32(value: u32) -> [u8; 4] { + value.to_be_bytes() +} + +pub fn be_u24(value: u32) -> [u8; 3] { + [(value >> 16) as u8, (value >> 8) as u8, value as u8] +} + +pub fn fixed_point_16x16(val: f32) -> [u8; 4] { + let fixed = (val * FIXED_POINT_16X16_SCALE) as u32; + fixed.to_be_bytes() +} + +pub fn fixed_point_8x8(val: f32) -> [u8; 2] { + let fixed = (val * FIXED_POINT_8X8_SCALE) as u16; + fixed.to_be_bytes() +} + +/// Prepends the size of `f()` + `Size`, according to `Size` +pub fn prepend_size_inclusive(f: F) -> Vec +where + SizeInclusive: SerializeSize, + F: FnOnce() -> Vec, +{ + let inner = f(); + let mut size = as SerializeSize>::serialize_size(inner.len()); + size.extend(inner); + size +} + +/// Prepends the size of `f()`, according to `Size` +pub fn prepend_size_exclusive(f: F) -> Vec +where + SizeExclusive: SerializeSize, + F: FnOnce() -> Vec, +{ + let inner = f(); + let mut size = as SerializeSize>::serialize_size(inner.len()); + size.extend(inner); + size +} + +pub fn pascal_string(s: String) -> Vec { + prepend_size_exclusive::(move || s.into_bytes()) +} + +/// Serialize u8(size) +#[derive(Debug, Clone)] +pub struct SizeU8; + +/// Serialize be_u32(size) +#[derive(Debug, Clone)] +pub struct SizeU32; + +/// Serialize be_u64(size) +#[derive(Debug, Clone)] +pub struct SizeU64; + +/// Serialize be_u32(size), or be_u32(1) + be_u64(size) +#[derive(Debug, Clone)] +pub struct SizeU32OrU64; + +/// Serialize size as a Variable Length Quantity, using at most `S` bytes +#[derive(Debug, Clone)] +pub struct SizeVLQ(PhantomData); + +#[derive(Debug, Clone)] +pub struct SizeInclusive(PhantomData); + +#[derive(Debug, Clone)] +pub struct SizeExclusive(PhantomData); + +pub trait SerializeSize { + fn serialize_size(size: usize) -> Vec; +} + +const U8_BYTE_SIZE: usize = 1; +impl SerializeSize for SizeInclusive { + fn serialize_size(size: usize) -> Vec { + vec![(size + U8_BYTE_SIZE) as u8] + } +} + +const U32_BYTE_SIZE: usize = 4; +impl SerializeSize for SizeInclusive { + fn serialize_size(size: usize) -> Vec { + ((size + U32_BYTE_SIZE) as u32).to_be_bytes().to_vec() + } +} + +const U64_BYTE_SIZE: usize = 8; +impl SerializeSize for SizeInclusive { + fn serialize_size(size: usize) -> Vec { + ((size + U64_BYTE_SIZE) as u64).to_be_bytes().to_vec() + } +} + +impl SerializeSize for SizeInclusive { + fn serialize_size(size: usize) -> Vec { + if size + U32_BYTE_SIZE <= u32::MAX as usize { + SizeInclusive::::serialize_size(size) + } else { + let mut output = vec![1]; + output.extend(SizeInclusive::::serialize_size(size + 1)); + output + } + } +} + +impl SerializeSize for SizeExclusive { + fn serialize_size(size: usize) -> Vec { + vec![size as u8] + } +} + +impl SerializeSize for SizeExclusive> { + fn serialize_size(size: usize) -> Vec { + variable_length_quantity::(size) + } +} + +fn variable_length_quantity(mut length: usize) -> Vec { + let mut data = VecDeque::with_capacity(N); + for _ in 0..N { + let mut byte = (length & 0b0111_1111) as u8; + length >>= 7; + + if !data.is_empty() { + byte |= 0b1000_0000; + } + + data.push_front(byte); + + if length == 0 { + break; + } + } + + data.into() +} + +pub fn color_rgb(color: ColorRgb) -> [u8; 6] { + let mut data = Vec::with_capacity(6); + data.extend(color.red.to_be_bytes()); + data.extend(color.green.to_be_bytes()); + data.extend(color.blue.to_be_bytes()); + data.try_into().expect("color_rgb is 6 bytes") +} + +pub mod bits { + use std::fmt; + + pub struct Packer { + full_bytes: Vec, + partial_byte: u8, + bit_offset: u8, + } + + impl fmt::Debug for Packer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Packer") + .field( + "full_bytes", + &self + .full_bytes + .iter() + .map(|b| format!("{b:08b}")) + .collect::>(), + ) + .field("partial_byte", &format!("{:08b}", self.partial_byte)) + .field("bit_offset", &self.bit_offset) + .finish() + } + } + + const BYTE: u8 = 8; + + impl From> for Packer { + fn from(value: Vec) -> Self { + Packer { + full_bytes: value, + partial_byte: 0, + bit_offset: 0, + } + } + } + + impl From for Vec { + fn from(packer: Packer) -> Self { + let mut res = packer.full_bytes; + if packer.bit_offset > 0 { + res.push(packer.partial_byte); + } + res + } + } + + impl Packer { + pub fn new() -> Self { + Packer { + full_bytes: Vec::new(), + partial_byte: 0u8, + bit_offset: 0u8, + } + } + + pub fn push_bool(&mut self, b: bool) { + self.push_n::<1>(b as u8); + } + + pub fn push_n(&mut self, bits: u8) { + debug_assert!(N <= 8 && N > 0, "N must be <= 8 and > 0"); + + let n = N; + let available = BYTE - self.bit_offset; + + if n <= available { + // All bits fit in current partial byte + let shift_left = available - n; + self.partial_byte |= bits << shift_left; + self.bit_offset += n; + + if n == available { + self.full_bytes.push(self.partial_byte); + self.partial_byte = 0; + self.bit_offset = 0; + } + } else { + // We need to split it across two bytes + let shift_right = n - available; + let first = + self.partial_byte | trim_higher_bits(BYTE - available, bits >> shift_right); + self.full_bytes.push(first); + + let shift_left = BYTE - n + available; + let second = bits << shift_left; + self.partial_byte = second; + self.bit_offset = n - available; + } + } + + pub fn push_n_u32(&mut self, bits: u32) { + let n_bytes = N / BYTE; + let n_bits = N % BYTE; + for bn in 1..=n_bytes { + let shift_right = (n_bytes * BYTE) - (bn * BYTE); + self.push_n::((bits >> shift_right) as u8); + } + if n_bits == 0 { + return; + } + let bits = (bits >> (n_bytes * BYTE)) as u8; + match n_bits { + 1 => self.push_n::<1>(bits), + 2 => self.push_n::<2>(bits), + 3 => self.push_n::<3>(bits), + 4 => self.push_n::<4>(bits), + 5 => self.push_n::<5>(bits), + 6 => self.push_n::<6>(bits), + 7 => self.push_n::<7>(bits), + _ => unreachable!(), + } + } + + pub fn push_bytes(&mut self, bytes: Vec) { + if self.bit_offset == 0 { + self.full_bytes.extend(bytes); + return; + } + + for byte in bytes { + self.push_n::<8>(byte); + } + } + } + + fn trim_higher_bits(n: u8, bits: u8) -> u8 { + let bits = bits << n; + bits >> n + } + + #[cfg(test)] + mod tests { + use crate::atom::{test_utils::assert_bytes_equal, util::serializer::bits::Packer}; + + macro_rules! test_push_n { + ($name:ident($fn_name:ident), { + full_bytes: $full_bytes:expr, + partial_byte: $partial_byte:expr, + bit_offset: $bit_offset:expr, + push_bits: $push_bits:expr, + push_bits_n: $push_bits_n:expr, + expect_partial_byte: $expect_partial_byte:expr, + expect_bit_offset: $expect_bit_offset:expr, + expect_full_bytes: $expect_full_bytes:expr, + }) => { + test_push_n!($name($fn_name), { + rounds: 1, + full_bytes: $full_bytes, + partial_byte: $partial_byte, + bit_offset: $bit_offset, + push_bits: $push_bits, + push_bits_n: $push_bits_n, + expect_partial_byte: $expect_partial_byte, + expect_bit_offset: $expect_bit_offset, + expect_full_bytes: $expect_full_bytes, + }); + }; + + ($name:ident($fn_name:ident), { + rounds: $rounds:literal, + full_bytes: $full_bytes:expr, + partial_byte: $partial_byte:expr, + bit_offset: $bit_offset:expr, + push_bits: $push_bits:expr, + push_bits_n: $push_bits_n:expr, + expect_partial_byte: $expect_partial_byte:expr, + expect_bit_offset: $expect_bit_offset:expr, + expect_full_bytes: $expect_full_bytes:expr, + }) => { + #[test] + fn $name() { + let mut packer = Packer { + full_bytes: $full_bytes, + partial_byte: $partial_byte, + bit_offset: $bit_offset, + }; + let rounds = $rounds; + for n in 1..=rounds { + eprintln!("round {n}"); + packer.$fn_name::<$push_bits_n>($push_bits); + } + assert_bits_eq(packer.partial_byte, $expect_partial_byte); + assert_eq!(packer.bit_offset, $expect_bit_offset); + assert_bytes_equal(&packer.full_bytes, &$expect_full_bytes); + + // ensure converting packer into a Vec handles partial byte + if $expect_bit_offset > 0 { + let mut expected_full_bytes = $expect_full_bytes; + expected_full_bytes.push($expect_partial_byte); + let full_bytes: Vec<_> = packer.into(); + assert_bytes_equal(&full_bytes, &expected_full_bytes); + } else { + let expected_full_bytes = $expect_full_bytes; + let full_bytes: Vec<_> = packer.into(); + assert_bytes_equal(&full_bytes, &expected_full_bytes); + } + } + }; + } + + test_push_n!(test_push_1_first_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b0000_0000, + bit_offset: 0, + push_bits: 0b0000_0001, + push_bits_n: 1, + expect_partial_byte: 0b1000_0000, + expect_bit_offset: 1, + expect_full_bytes: vec![], + }); + + test_push_n!(test_push_1_second_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b1000_0000, + bit_offset: 1, + push_bits: 0b0000_0001, + push_bits_n: 1, + expect_partial_byte: 0b1100_0000, + expect_bit_offset: 2, + expect_full_bytes: vec![], + }); + + test_push_n!(test_push_1_third_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b1100_0000, + bit_offset: 2, + push_bits: 0b0000_0001, + push_bits_n: 1, + expect_partial_byte: 0b1110_0000, + expect_bit_offset: 3, + expect_full_bytes: vec![], + }); + + test_push_n!(test_push_1_fourth_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b1110_0000, + bit_offset: 3, + push_bits: 0b0000_0001, + push_bits_n: 1, + expect_partial_byte: 0b1111_0000, + expect_bit_offset: 4, + expect_full_bytes: vec![], + }); + + test_push_n!(test_push_1_fifth_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b1111_0000, + bit_offset: 4, + push_bits: 0b0000_0001, + push_bits_n: 1, + expect_partial_byte: 0b1111_1000, + expect_bit_offset: 5, + expect_full_bytes: vec![], + }); + + test_push_n!(test_push_1_sixth_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b1111_1000, + bit_offset: 5, + push_bits: 0b0000_0001, + push_bits_n: 1, + expect_partial_byte: 0b1111_1100, + expect_bit_offset: 6, + expect_full_bytes: vec![], + }); + + test_push_n!(test_push_1_seventh_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b1111_1100, + bit_offset: 6, + push_bits: 0b0000_0001, + push_bits_n: 1, + expect_partial_byte: 0b1111_1110, + expect_bit_offset: 7, + expect_full_bytes: vec![], + }); + + test_push_n!(test_push_1_eighth_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b1111_1110, + bit_offset: 7, + push_bits: 0b0000_0001, + push_bits_n: 1, + expect_partial_byte: 0b0000_0000, + expect_bit_offset: 0, + expect_full_bytes: vec![0b1111_1111], + }); + + test_push_n!(test_push_7_eighth_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b1111_1110, + bit_offset: 7, + push_bits: 0b0111_1111, + push_bits_n: 7, + expect_partial_byte: 0b1111_1100, + expect_bit_offset: 6, + expect_full_bytes: vec![0b1111_1111], + }); + + test_push_n!(test_push_6_seventh_bit(push_n), { + full_bytes: vec![0b1111_1111], + partial_byte: 0b1111_1100, + bit_offset: 6, + push_bits: 0b0011_1111, + push_bits_n: 6, + expect_partial_byte: 0b1111_0000, + expect_bit_offset: 4, + expect_full_bytes: vec![0b1111_1111, 0b1111_1111], + }); + + test_push_n!(test_push_6_fifth_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b1111_0000, + bit_offset: 4, + push_bits: 0b0011_1111, + push_bits_n: 6, + expect_partial_byte: 0b1100_0000, + expect_bit_offset: 2, + expect_full_bytes: vec![0b1111_1111], + }); + + test_push_n!(test_push_7_first_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b0000_0000, + bit_offset: 0, + push_bits: 0b0111_1111, + push_bits_n: 7, + expect_partial_byte: 0b1111_1110, + expect_bit_offset: 7, + expect_full_bytes: vec![], + }); + + test_push_n!(test_push_8_second_bit(push_n), { + rounds: 2, + full_bytes: vec![0b1010_1010], + partial_byte: 0b1000_0000, + bit_offset: 1, + push_bits: 0b1111_1111, + push_bits_n: 8, + expect_partial_byte: 0b1000_0000, + expect_bit_offset: 1, + expect_full_bytes: vec![0b1010_1010, 0b1111_1111, 0b1111_1111], + }); + + test_push_n!(test_push_7_incomplete_bits_fifth_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b1111_0000, + bit_offset: 4, + push_bits: 0b0100_1000, + push_bits_n: 7, + expect_partial_byte: 0b0000_0000, + expect_bit_offset: 3, + expect_full_bytes: vec![0b1111_1001], + }); + + test_push_n!(test_push_4_fifth_bit(push_n), { + full_bytes: vec![], + partial_byte: 0b0011_0000, + bit_offset: 5, + push_bits: 0b0000_0011, + push_bits_n: 4, + expect_partial_byte: 0b1000_0000, + expect_bit_offset: 1, + expect_full_bytes: vec![0b0011_0001], + }); + + test_push_n!(test_push_24_first_bit(push_n_u32), { + full_bytes: vec![], + partial_byte: 0b0000_0000, + bit_offset: 0, + push_bits: 0b00000000_11111111_11111111_11111111, + push_bits_n: 24, + expect_partial_byte: 0b0000_0000, + expect_bit_offset: 0, + expect_full_bytes: vec![0b1111_1111, 0b1111_1111, 0b1111_1111], + }); + + test_push_n!(test_push_24_second_bit(push_n_u32), { + full_bytes: vec![], + partial_byte: 0b1000_0000, + bit_offset: 1, + push_bits: 0b00000000_11111111_11111111_11111111, + push_bits_n: 24, + expect_partial_byte: 0b1000_0000, + expect_bit_offset: 1, + expect_full_bytes: vec![0b1111_1111, 0b1111_1111, 0b1111_1111], + }); + + test_push_n!(test_push_26_second_bit(push_n_u32), { + full_bytes: vec![], + partial_byte: 0b1000_0000, + bit_offset: 1, + push_bits: 0b00000011_11111111_11111111_11111111, + push_bits_n: 26, + expect_partial_byte: 0b1110_0000, + expect_bit_offset: 3, + expect_full_bytes: vec![0b1111_1111, 0b1111_1111, 0b1111_1111], + }); + + #[test] + fn test_push_bytes_first_bit() { + let mut packer = Packer { + full_bytes: vec![0b1111_1111], + partial_byte: 0b0000_0000, + bit_offset: 0, + }; + packer.push_bytes(vec![0b0000_0000, 0b0000_0000, 0b0000_0000]); + let expected: Vec = vec![0b1111_1111, 0b0000_0000, 0b0000_0000, 0b0000_0000]; + assert_bytes_equal(&packer.full_bytes, &expected); + assert_bits_eq(packer.partial_byte, 0b0000_0000); + assert_eq!(packer.bit_offset, 0); + } + + #[test] + fn test_push_bytes_second_bit() { + let mut packer = Packer { + full_bytes: vec![0b1111_1111], + partial_byte: 0b1000_0000, + bit_offset: 1, + }; + packer.push_bytes(vec![0b0000_0000, 0b0000_0000, 0b0000_0000]); + let expected: Vec = vec![0b1111_1111, 0b1000_0000, 0b0000_0000, 0b0000_0000]; + assert_bytes_equal(&packer.full_bytes, &expected); + assert_bits_eq(packer.partial_byte, 0b0000_0000); + assert_eq!(packer.bit_offset, 1); + } + + fn assert_bits_eq(actual: u8, expected: u8) { + if actual == expected { + return; + } + + panic!("expected 0b{expected:08b}, got 0b{actual:08b}") + } + } +} + +#[cfg(test)] +mod tests { + use crate::atom::test_utils::assert_bytes_equal; + + use super::*; + + macro_rules! test_variable_length_quantity { + ({ $( $name:ident::<$n:literal>($input:expr) => $expected:expr ),+ $(,)? }) => { + $(#[test] fn $name() { + let input = $input; + let output = variable_length_quantity::<$n>(input); + let expected: Vec = $expected; + assert_bytes_equal(&output, &expected); + })+ + }; + } + + test_variable_length_quantity!({ + test_u16_0::<2>(0) => vec![0x00], + test_u16_127::<2>(127) => vec![0x7F], + test_u16_128::<2>(128) => vec![0x81, 0x00], + test_u16_358::<2>(358) => vec![0x82, 0x66], + test_u32_358::<4>(358) => vec![0x82, 0x66], + test_u64_358::<8>(358) => vec![0x82, 0x66], + }); +} diff --git a/src/atom/util/time.rs b/src/atom/util/time.rs new file mode 100644 index 0000000..32e3eb4 --- /dev/null +++ b/src/atom/util/time.rs @@ -0,0 +1,232 @@ +use std::{ + fmt::Debug, + ops::RangeBounds, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +const NANOS_PER_SECOND: u128 = 1_000_000_000; + +pub fn scaled_duration(duration: Duration, timescale: u64) -> u64 { + (duration.as_nanos() * u128::from(timescale) / NANOS_PER_SECOND).min(u128::from(u64::MAX)) + as u64 +} + +pub fn unscaled_duration(duration: u64, timescale: u64) -> Duration { + let duration_nanos = (u128::from(duration) * NANOS_PER_SECOND / u128::from(timescale)) + .min(u128::from(u64::MAX)) as u64; + Duration::from_nanos(duration_nanos) +} + +pub fn scaled_duration_range( + range: impl RangeBounds, + timescale: u64, +) -> impl RangeBounds + Debug { + use std::ops::Bound; + let start = match range.start_bound() { + Bound::Included(start) => Bound::Included(scaled_duration(*start, timescale)), + Bound::Excluded(start) => Bound::Excluded(scaled_duration(*start, timescale)), + Bound::Unbounded => Bound::Unbounded, + }; + let end = match range.end_bound() { + Bound::Included(end) => Bound::Included(scaled_duration(*end, timescale)), + Bound::Excluded(end) => Bound::Excluded(scaled_duration(*end, timescale)), + Bound::Unbounded => Bound::Unbounded, + }; + (start, end) +} + +pub fn mp4_timestamp(duration: Duration) -> u64 { + duration.as_secs() + 2_082_844_800 +} + +pub fn mp4_timestamp_now() -> u64 { + mp4_timestamp(now()) +} + +fn now() -> Duration { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ops::Bound; + use std::time::Duration; + + fn test_scaled_duration_range(test_case: F) + where + F: FnOnce() -> ScaledDurationRangeTestCase, + { + let test_case = test_case(); + + let range = (test_case.start_bound, test_case.end_bound); + let scaled_range = scaled_duration_range(range, test_case.timescale); + + match (&test_case.expected_start, scaled_range.start_bound()) { + (Bound::Included(expected), Bound::Included(actual)) => { + assert_eq!( + *expected, *actual, + "Start bound mismatch in {}", + test_case.description + ) + } + (Bound::Excluded(expected), Bound::Excluded(actual)) => { + assert_eq!( + *expected, *actual, + "Start bound mismatch in {}", + test_case.description + ) + } + (Bound::Unbounded, Bound::Unbounded) => (), + _ => panic!("Start bound type mismatch in {}", test_case.description), + } + + match (&test_case.expected_end, scaled_range.end_bound()) { + (Bound::Included(expected), Bound::Included(actual)) => { + assert_eq!( + *expected, *actual, + "End bound mismatch in {}", + test_case.description + ) + } + (Bound::Excluded(expected), Bound::Excluded(actual)) => { + assert_eq!( + *expected, *actual, + "End bound mismatch in {}", + test_case.description + ) + } + (Bound::Unbounded, Bound::Unbounded) => (), + _ => panic!("End bound type mismatch in {}", test_case.description), + } + } + + struct ScaledDurationRangeTestCase { + start_bound: Bound, + end_bound: Bound, + timescale: u64, + expected_start: Bound, + expected_end: Bound, + description: &'static str, + } + + macro_rules! test_scaled_duration_range { + ($($name:ident => $test_case:expr,)*) => { + $( + #[test] + fn $name() { + test_scaled_duration_range($test_case); + } + )* + }; + } + + test_scaled_duration_range!( + included_included => || ScaledDurationRangeTestCase { + start_bound: Bound::Included(Duration::from_secs(1)), + end_bound: Bound::Included(Duration::from_secs(3)), + timescale: 1000, + expected_start: Bound::Included(1000), + expected_end: Bound::Included(3000), + description: "included/included", + }, + included_excluded => || ScaledDurationRangeTestCase { + start_bound: Bound::Included(Duration::from_secs(1)), + end_bound: Bound::Excluded(Duration::from_secs(4)), + timescale: 44100, + expected_start: Bound::Included(44100), + expected_end: Bound::Excluded(176400), + description: "included/excluded", + }, + included_unbounded => || ScaledDurationRangeTestCase { + start_bound: Bound::Included(Duration::from_millis(500)), + end_bound: Bound::Unbounded, + timescale: 48000, + expected_start: Bound::Included(24000), + expected_end: Bound::Unbounded, + description: "included/unbounded", + }, + excluded_included => || ScaledDurationRangeTestCase { + start_bound: Bound::Excluded(Duration::from_millis(100)), + end_bound: Bound::Included(Duration::from_secs(2)), + timescale: 48000, + expected_start: Bound::Excluded(4800), + expected_end: Bound::Included(96000), + description: "excluded/included", + }, + excluded_excluded => || ScaledDurationRangeTestCase { + start_bound: Bound::Excluded(Duration::from_secs(1)), + end_bound: Bound::Excluded(Duration::from_secs(3)), + timescale: 1000, + expected_start: Bound::Excluded(1000), + expected_end: Bound::Excluded(3000), + description: "excluded/excluded", + }, + excluded_unbounded => || ScaledDurationRangeTestCase { + start_bound: Bound::Excluded(Duration::from_millis(250)), + end_bound: Bound::Unbounded, + timescale: 8000, + expected_start: Bound::Excluded(2000), + expected_end: Bound::Unbounded, + description: "excluded/unbounded", + }, + unbounded_included => || ScaledDurationRangeTestCase { + start_bound: Bound::Unbounded, + end_bound: Bound::Included(Duration::from_secs(5)), + timescale: 1000, + expected_start: Bound::Unbounded, + expected_end: Bound::Included(5000), + description: "unbounded/included", + }, + unbounded_excluded => || ScaledDurationRangeTestCase { + start_bound: Bound::Unbounded, + end_bound: Bound::Excluded(Duration::from_secs(3)), + timescale: 22050, + expected_start: Bound::Unbounded, + expected_end: Bound::Excluded(66150), + description: "unbounded/excluded", + }, + unbounded_unbounded => || ScaledDurationRangeTestCase { + start_bound: Bound::Unbounded, + end_bound: Bound::Unbounded, + timescale: 1000, + expected_start: Bound::Unbounded, + expected_end: Bound::Unbounded, + description: "unbounded/unbounded", + }, + zero_timescale => || ScaledDurationRangeTestCase { + start_bound: Bound::Included(Duration::from_secs(1)), + end_bound: Bound::Included(Duration::from_secs(2)), + timescale: 0, + expected_start: Bound::Included(0), + expected_end: Bound::Included(0), + description: "zero timescale", + }, + large_timescale => || ScaledDurationRangeTestCase { + start_bound: Bound::Included(Duration::from_millis(1)), + end_bound: Bound::Included(Duration::from_millis(2)), + timescale: 1_000_000, + expected_start: Bound::Included(1000), + expected_end: Bound::Included(2000), + description: "large timescale", + }, + small_timescale => || ScaledDurationRangeTestCase { + start_bound: Bound::Included(Duration::from_secs(1)), + end_bound: Bound::Included(Duration::from_secs(2)), + timescale: 1, + expected_start: Bound::Included(1), + expected_end: Bound::Included(2), + description: "small timescale", + }, + overflow_protection => || ScaledDurationRangeTestCase { + start_bound: Bound::Included(Duration::from_secs(u64::MAX)), + end_bound: Bound::Included(Duration::from_secs(u64::MAX)), + timescale: 1000, + expected_start: Bound::Included(u64::MAX), + expected_end: Bound::Included(u64::MAX), + description: "overflow protection", + }, + ); +} diff --git a/src/chapter_track_builder.rs b/src/chapter_track_builder.rs new file mode 100644 index 0000000..b4f7dc1 --- /dev/null +++ b/src/chapter_track_builder.rs @@ -0,0 +1,498 @@ +use std::time::Duration; + +use bon::bon; + +use crate::atom::{ + container::{DINF, EDTS, MDIA, MINF, STBL, TRAK}, + dref::DataReferenceEntry, + elst::{EditEntry, ELST}, + gmin::GMIN, + hdlr::{HandlerReferenceAtom, HandlerType}, + mdhd::{LanguageCode, MediaHeaderAtom}, + stco_co64::ChunkOffsetAtom, + stsc::{SampleToChunkAtom, SampleToChunkEntry}, + stsd::{ + SampleDescriptionTableAtom, SampleEntry, SampleEntryData, SampleEntryType, TextSampleEntry, + }, + stsz::SampleSizeAtom, + stts::{TimeToSampleAtom, TimeToSampleEntry}, + text::TEXT, + tkhd::TrackHeaderAtom, + util::{mp4_timestamp_now, scaled_duration}, + Atom, AtomHeader, BaseMediaInfoAtom, DataReferenceAtom, EditListAtom, TextMediaInfoAtom, GMHD, +}; +use crate::writer::SerializeAtom; + +/// Represents a chapter from input JSON (milliseconds-based) +#[derive(Debug, Clone)] +pub struct InputChapter { + pub title: String, + pub offset_ms: u64, + pub duration_ms: u64, +} + +#[bon] +impl InputChapter { + #[builder] + pub fn new( + #[builder(into, finish_fn)] title: String, + start_offset: Duration, + duration: Duration, + ) -> Self { + Self { + title, + offset_ms: start_offset.as_millis() as u64, + duration_ms: duration.as_millis() as u64, + } + } +} + +/// Represents a chapter track that can generate TRAK atoms and sample data +pub struct ChapterTrack { + language: LanguageCode, + timescale: u32, + track_id: u32, + creation_time: u64, + modification_time: u64, + sample_data: Vec>, + sample_durations: Vec, + sample_sizes: Vec, // Individual sample sizes + media_duration: u64, + movie_duration: u64, + handler_name: String, +} + +#[bon] +impl ChapterTrack { + #[builder] + pub fn new( + #[builder(finish_fn, into)] chapters: Vec, + #[builder(default = LanguageCode::Undetermined)] language: LanguageCode, + #[builder(default = 44100)] timescale: u32, + #[builder(default = 600)] movie_timescale: u32, + #[builder(default = 2)] track_id: u32, + #[builder(into)] total_duration: Duration, + #[builder(default = mp4_timestamp_now())] creation_time: u64, + #[builder(default = mp4_timestamp_now())] modification_time: u64, + #[builder(default = "Apple Text Media Handler".to_string(), into)] handler_name: String, + ) -> Self { + let (sample_data, sample_durations, sample_sizes) = + Self::create_samples_durations_and_sizes(&chapters, total_duration, timescale); + + Self { + language, + timescale, + track_id, + creation_time, + modification_time, + sample_data, + sample_durations, + sample_sizes, + // Calculate duration in media timescale (for MDHD and STTS) + media_duration: scaled_duration(total_duration, u64::from(timescale)), + // Calculate duration in movie timescale (for TKHD) + movie_duration: scaled_duration(total_duration, u64::from(movie_timescale)), + handler_name, + } + } + + /// Create chapter marker samples with variable sizes based on content + /// This creates a contiguous timeline by extending chapters to fill gaps + fn create_samples_durations_and_sizes( + chapters: &[InputChapter], + total_duration: Duration, + timescale: u32, + ) -> (Vec>, Vec, Vec) { + if chapters.is_empty() { + return (vec![], vec![], vec![]); + } + + let total_duration_ms = total_duration.as_millis() as u64; + let mut samples = Vec::new(); + let mut durations = Vec::new(); + let mut sizes = Vec::new(); + + for (i, chapter) in chapters.iter().enumerate() { + // Create chapter marker data with variable size based on content + let chapter_marker = Self::create_variable_chapter_marker(&chapter.title); + let sample_size = chapter_marker.len() as u32; + + samples.push(chapter_marker); + sizes.push(sample_size); + + // Calculate the end time for this chapter + let chapter_end_ms = if i + 1 < chapters.len() { + // Extend this chapter until the next chapter starts + chapters[i + 1].offset_ms + } else { + // Last chapter extends to the end of the media + total_duration_ms + }; + + // Calculate the actual duration this chapter should span + let actual_duration_ms = chapter_end_ms - chapter.offset_ms; + let chapter_duration_seconds = actual_duration_ms as f64 / 1000.0; + let chapter_duration_scaled = (chapter_duration_seconds * f64::from(timescale)) as u32; + + durations.push(chapter_duration_scaled); + } + + (samples, durations, sizes) + } + + /// Create variable-size chapter marker data based on actual text content + fn create_variable_chapter_marker(title: &str) -> Vec { + // QuickTime text sample format: + // - 2 bytes: text length (big-endian) + // - N bytes: UTF-8 text + // - Optional padding for compatibility + + let title_bytes = title.as_bytes(); + let text_len = title_bytes.len() as u16; + + // Calculate total size: text length field + text content + let total_size = 2 + title_bytes.len(); + let mut data = Vec::with_capacity(total_size); + + // Write text length (big-endian) + data.extend_from_slice(&text_len.to_be_bytes()); + + // Write text data + data.extend_from_slice(title_bytes); + + data + } + + /// Returns all sample data concatenated for writing to mdat + pub fn sample_bytes(&self) -> Vec { + let mut result = Vec::new(); + for sample in &self.sample_data { + result.extend_from_slice(sample); + } + result + } + + /// Returns the individual sample data + pub fn individual_samples(&self) -> &[Vec] { + &self.sample_data + } + + /// Returns the individual sample sizes + pub fn sample_sizes(&self) -> &[u32] { + &self.sample_sizes + } + + /// Returns the total size of all samples combined + pub fn total_sample_size(&self) -> usize { + self.sample_sizes.iter().map(|&s| s as usize).sum() + } + + /// Returns the number of chapter samples + pub fn sample_count(&self) -> u32 { + self.sample_data.len() as u32 + } + + /// Check if all samples have the same size (for STSZ optimization) + pub fn has_uniform_sample_size(&self) -> bool { + if self.sample_sizes.is_empty() { + return true; + } + let first_size = self.sample_sizes[0]; + self.sample_sizes.iter().all(|&size| size == first_size) + } + + /// Get the uniform sample size if all samples are the same size + pub fn uniform_sample_size(&self) -> Option { + if self.has_uniform_sample_size() { + self.sample_sizes.first().copied() + } else { + None + } + } + + /// Creates a TRAK atom for the chapter track at the specified chunk offset + pub fn create_trak_atom(&self, chunk_offset: u64) -> Atom { + let track_header = self.create_track_header(); + let edit_list = self.create_edit_list_atom(); + let media_atom = self.create_media_atom(chunk_offset); + + Atom::builder() + .header(AtomHeader::new(*TRAK)) + .children(vec![track_header, edit_list, media_atom]) + .build() + } + + fn create_track_header(&self) -> Atom { + let tkhd = TrackHeaderAtom { + version: 0, + flags: [0, 0, 2], // Track enabled, not in movie - standard for chapter tracks + creation_time: self.creation_time, + modification_time: self.modification_time, + track_id: self.track_id, + duration: self.movie_duration, + layer: 0, + alternate_group: 0, + volume: 0.0, // Text track has no volume + matrix: None, // Use default identity matrix + width: 0.0, // Text track dimensions + height: 0.0, + }; + + Atom::builder() + .header(AtomHeader::new(tkhd.atom_type())) + .data(tkhd) + .build() + } + + fn create_edit_list_atom(&self) -> Atom { + Atom::builder() + .header(AtomHeader::new(*EDTS)) + .children(vec![Atom::builder() + .header(AtomHeader::new(*ELST)) + .data(EditListAtom::new(vec![EditEntry { + segment_duration: self.movie_duration, + media_time: 0, + media_rate: 1.0, + }])) + .build()]) + .build() + } + + fn create_media_atom(&self, chunk_offset: u64) -> Atom { + let mdhd = self.create_media_header(); + let hdlr = self.create_handler_reference(); + let minf = self.create_media_information(chunk_offset); + + Atom::builder() + .header(AtomHeader::new(*MDIA)) + .children(vec![mdhd, hdlr, minf]) + .build() + } + + fn create_media_header(&self) -> Atom { + let mdhd = MediaHeaderAtom::builder() + .creation_time(self.creation_time) + .modification_time(self.modification_time) + .timescale(self.timescale) + .duration(self.media_duration) + .language(self.language) + .build(); + + Atom::builder() + .header(AtomHeader::new(mdhd.atom_type())) + .data(mdhd) + .build() + } + + fn create_handler_reference(&self) -> Atom { + let hdlr = HandlerReferenceAtom::builder() + .handler_type(HandlerType::Text) + .name(&self.handler_name) + .build(); + + Atom::builder() + .header(AtomHeader::new(hdlr.atom_type())) + .data(hdlr) + .build() + } + + fn create_media_information(&self, chunk_offset: u64) -> Atom { + let stbl = self.create_sample_table(chunk_offset); + + // Create DINF (Data Information) with proper data reference + let dref = DataReferenceAtom::builder() + .entry( + DataReferenceEntry::builder() + .url(String::new()) + .flags( + [0, 0, 1], // Self-contained flag + ) + .build(), + ) + .build(); + + let dinf = Atom::builder() + .header(AtomHeader::new(*DINF)) + .children(vec![Atom::builder() + .header(AtomHeader::new(dref.atom_type())) + .data(dref) + .build()]) + .build(); + + Atom::builder() + .header(AtomHeader::new(*MINF)) + .children(vec![ + Atom::builder() + .header(AtomHeader::new(*GMHD)) + .children(vec![ + Atom::builder() + .header(AtomHeader::new(*GMIN)) + .data(BaseMediaInfoAtom::default()) + .build(), + Atom::builder() + .header(AtomHeader::new(*TEXT)) + .data(TextMediaInfoAtom::default()) + .build(), + ]) + .build(), + dinf, + stbl, + ]) + .build() + } + + fn create_sample_table(&self, chunk_offset: u64) -> Atom { + let stsd = self.create_sample_description(); + let stts = self.create_time_to_sample(); + let stsc = self.create_sample_to_chunk(); + let stsz = self.create_sample_size(); + let stco = self.create_chunk_offset(chunk_offset); + + Atom::builder() + .header(AtomHeader::new(*STBL)) + .children(vec![stsd, stts, stsc, stsz, stco]) + .build() + } + + fn create_sample_description(&self) -> Atom { + // Text sample entry with configurable parameters + let text_sample_entry = SampleEntry { + entry_type: SampleEntryType::Text, + data_reference_index: 1, + data: SampleEntryData::Text(TextSampleEntry::builder().font_name("Sarif").build()), + }; + + let stsd = SampleDescriptionTableAtom::from(vec![text_sample_entry]); + + Atom::builder() + .header(AtomHeader::new(stsd.atom_type())) + .data(stsd) + .build() + } + + fn create_time_to_sample(&self) -> Atom { + // Create individual entries for each sample duration + // This is essential for proper chapter timing recognition in audiobook players + let entries: Vec = self + .sample_durations + .iter() + .map(|&duration| TimeToSampleEntry { + sample_count: 1, // Each entry represents exactly 1 sample + sample_duration: duration, + }) + .collect(); + + let stts = TimeToSampleAtom::from(entries); + + Atom::builder() + .header(AtomHeader::new(stts.atom_type())) + .data(stts) + .build() + } + + fn create_sample_to_chunk(&self) -> Atom { + // All samples in a single chunk + let stsc = SampleToChunkAtom::from(vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: self.sample_count(), + sample_description_index: 1, + }]); + + Atom::builder() + .header(AtomHeader::new(stsc.atom_type())) + .data(stsc) + .build() + } + + fn create_sample_size(&self) -> Atom { + let stsz = if let Some(uniform_size) = self.uniform_sample_size() { + SampleSizeAtom::builder() + .sample_size(uniform_size) + .sample_count(self.sample_count()) + .build() + } else { + SampleSizeAtom::builder() + .entry_sizes(self.sample_sizes.clone()) + .build() + }; + + Atom::builder() + .header(AtomHeader::new(stsz.atom_type())) + .data(stsz) + .build() + } + + fn create_chunk_offset(&self, chunk_offset: u64) -> Atom { + // Single chunk containing all chapter marker samples + let stco = ChunkOffsetAtom::builder() + .chunk_offset(chunk_offset) + .build(); + + Atom::builder() + .header(AtomHeader::new(stco.atom_type())) + .data(stco) + .build() + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_chapter_track_builder() { + let chapters = vec![ + InputChapter { + title: "Opening Credits".to_string(), + offset_ms: 0, + duration_ms: 19758, + }, + InputChapter { + title: "Dedication".to_string(), + offset_ms: 19758, + duration_ms: 4510, + }, + InputChapter { + title: "Epigraph".to_string(), + offset_ms: 24268, + duration_ms: 12364, + }, + ]; + + // Calculate total duration from the last chapter + let last_chapter = chapters.last().unwrap(); + let total_duration = + Duration::from_millis(last_chapter.offset_ms + last_chapter.duration_ms); + + let track = ChapterTrack::builder() + .track_id(2) + .timescale(44100) // Standard audio timescale + .movie_timescale(600) // Standard movie timescale + .total_duration(total_duration) + .language(LanguageCode::Undetermined) + .build(chapters); + + // Verify we have the correct number of samples + assert_eq!(track.sample_count(), 3); + + // Verify sample sizes are based on chapter title length + let samples = track.individual_samples(); + assert_eq!(samples.len(), 3); + + // Expected sizes: 2 bytes (length) + title length + // "Opening Credits" (15 chars) = 2 + 15 = 17 bytes + // "Dedication" (10 chars) = 2 + 10 = 12 bytes + // "Epigraph" (8 chars) = 2 + 8 = 10 bytes + assert_eq!(samples[0].len(), 17); + assert_eq!(samples[1].len(), 12); + assert_eq!(samples[2].len(), 10); + + // Verify we have individual duration entries for each chapter + assert_eq!(track.sample_durations.len(), 3); + + println!( + "Generated {} chapter samples with durations: {:?}", + track.sample_count(), + track.sample_durations + ); + } +} diff --git a/src/chunk_offset_builder.rs b/src/chunk_offset_builder.rs new file mode 100644 index 0000000..e523006 --- /dev/null +++ b/src/chunk_offset_builder.rs @@ -0,0 +1,962 @@ +use std::collections::VecDeque; + +use crate::atom::{SampleSizeAtom, SampleToChunkAtom}; + +#[derive(Debug)] +pub struct ChunkInfo { + pub track_index: usize, + pub chunk_number: u32, + pub chunk_size: u64, + pub sample_indices: Vec, + pub sample_sizes: Vec, +} + +#[derive(Debug)] +pub struct ChunkOffset { + pub track_index: usize, + pub offset: u64, +} + +#[derive(Clone)] +pub struct ChunkOffsetBuilderTrack<'a> { + stsc: &'a SampleToChunkAtom, + stsz: &'a SampleSizeAtom, +} + +impl<'a> ChunkOffsetBuilderTrack<'a> { + /// Build chunk information including sizes and sample mappings + pub fn build_chunk_info(&self, track_index: usize) -> impl Iterator + 'a { + self.stsc + .entries + .iter() + .zip( + self.stsc + .entries + .iter() + .skip(1) + .map(Some) + .chain(std::iter::once(None)), + ) + .scan( + (track_index, 0u32), + |(track_index, sample_index), (entry, next_entry)| { + let next_first_chunk = if let Some(next_entry) = next_entry { + next_entry.first_chunk + } else { + let remaining_samples = + self.stsz.sample_count.saturating_sub(*sample_index); + entry.first_chunk + remaining_samples.div_ceil(entry.samples_per_chunk) + }; + + let start_sample_index = *sample_index; + *sample_index += + (next_first_chunk - entry.first_chunk) * entry.samples_per_chunk; + + // Process all chunks for this entry + let track_index = *track_index; + Some((entry.first_chunk..next_first_chunk).scan( + (track_index, start_sample_index), + |(track_index, sample_index), chunk_num| { + let (sample_indices, sample_sizes, chunk_size) = self + .stsz + .sample_sizes() + .enumerate() + .skip(*sample_index as usize) + .take(entry.samples_per_chunk as usize) + .fold( + ( + Vec::with_capacity(entry.samples_per_chunk as usize), + Vec::with_capacity(entry.samples_per_chunk as usize), + 0u64, + ), + |(mut sample_indices, mut sample_sizes, mut chunk_size), + (i, size)| { + sample_indices.push(i); + sample_sizes.push(*size); + chunk_size += u64::from(*size); + (sample_indices, sample_sizes, chunk_size) + }, + ); + *sample_index += entry.samples_per_chunk; + + Some(ChunkInfo { + track_index: *track_index, + chunk_number: chunk_num, + chunk_size, + sample_indices, + sample_sizes, + }) + }, + )) + }, + ) + .flatten() + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct BuildMetadata { + pub total_size: u64, +} + +pub struct ChunkOffsetBuilder<'a> { + tracks: Vec>, +} + +impl Default for ChunkOffsetBuilder<'_> { + fn default() -> Self { + Self::new() + } +} + +impl<'a> ChunkOffsetBuilder<'a> { + pub fn new() -> Self { + Self { tracks: Vec::new() } + } + + pub fn with_capacity(capacity: usize) -> Self { + Self { + tracks: Vec::with_capacity(capacity), + } + } + + pub fn add_track(&mut self, stsc: &'a SampleToChunkAtom, stsz: &'a SampleSizeAtom) { + self.tracks.push(ChunkOffsetBuilderTrack { stsc, stsz }); + } + + /// Build interleaved chunk information including sizes and sample mappings + pub fn build_chunk_info(&self) -> impl Iterator + 'a { + let mut iters = self + .tracks + .clone() + .into_iter() + .enumerate() + .map(|(track_index, track)| track.build_chunk_info(track_index)) + .collect::>(); + + // round-robin chunks from each track + std::iter::from_fn(move || { + while let Some(mut it) = iters.pop_front() { + if let Some(item) = it.next() { + iters.push_back(it); + return Some(item); + } + } + None + }) + } + + /// Build interleaved chunk offsets for each track given a starting offset + pub fn build_chunk_offsets(&self, start_offset: u64) -> (Vec>, BuildMetadata) { + let tracks: Vec> = (0..self.tracks.len()).map(|_| Vec::new()).collect(); + + let (_, chunk_offsets, total_size) = self.build_chunk_info().fold( + (start_offset, tracks, 0), + |(mut current_offset, mut tracks, size), chunk| { + let chunk_offset = current_offset; + current_offset += chunk.chunk_size; + tracks[chunk.track_index].push(chunk_offset); + (current_offset, tracks, size + chunk.chunk_size) + }, + ); + + (chunk_offsets, BuildMetadata { total_size }) + } + + /// Build interleaved chunk offsets preserving the original order based on input chunk offsets + pub fn build_chunk_offsets_ordered( + &self, + original_chunk_offsets: Vec<&[u64]>, + start_offset: u64, + ) -> (Vec>, BuildMetadata) { + struct ChunkOffsetSortable { + original_offset: u64, + track_index: usize, + chunk_size: u64, + } + + // Allocate intermediate and output `Vec`s + let (total_chunks, mut tracks): (usize, Vec>) = + original_chunk_offsets.iter().fold( + (0, Vec::with_capacity(original_chunk_offsets.len())), + |(mut total, mut tracks), offsets| { + total += offsets.len(); + tracks.push(Vec::with_capacity(offsets.len())); + (total, tracks) + }, + ); + let mut all_chunks = Vec::with_capacity(total_chunks); + + // Collect chunk order information + for (track_index, track) in self.tracks.iter().enumerate() { + for (chunk_idx, chunk) in track.build_chunk_info(track_index).enumerate() { + let original_offset = original_chunk_offsets[track_index][chunk_idx]; + all_chunks.push(ChunkOffsetSortable { + original_offset, + track_index: chunk.track_index, + chunk_size: chunk.chunk_size, + }); + } + } + + // Sort by original offset to maintain interleaving order + all_chunks.sort_unstable_by_key(|chunk| chunk.original_offset); + + // Calculate new offsets maintaining the original interleaving order + let mut total_size = 0; + let mut current_offset = start_offset; + for chunk in all_chunks { + tracks[chunk.track_index].push(current_offset); + current_offset += chunk.chunk_size; + total_size += chunk.chunk_size; + } + + (tracks, BuildMetadata { total_size }) + } +} + +#[cfg(test)] +mod tests { + use crate::atom::stsc::SampleToChunkEntry; + + use super::*; + + #[test] + fn test_chunk_offset_calculation() { + let stsc_entries = vec![ + SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 2, + sample_description_index: 1, + }, + SampleToChunkEntry { + first_chunk: 3, + samples_per_chunk: 3, + sample_description_index: 1, + }, + ]; + + let stsc = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries.into(), + }; + + let stsz = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 7, + entry_sizes: vec![100, 200, 150, 250, 300, 400, 500].into(), + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(1); + builder.add_track(&stsc, &stsz); + let (offsets, meta) = builder.build_chunk_offsets(0); + let offsets = &offsets[0]; + + // Expected: + // Chunk 1: samples 0,1 (100+200=300 bytes) -> offset 0 + // Chunk 2: samples 2,3 (150+250=400 bytes) -> offset 300 + // Chunk 3: samples 4,5,6 (300+400+500=1200 bytes) -> offset 700 + + assert_eq!(offsets.len(), 3); + assert_eq!(offsets[0], 0); // Chunk 1 starts at 0 + assert_eq!(offsets[1], 300); // Chunk 2 starts at 300 + assert_eq!(offsets[2], 700); // Chunk 3 starts at 700 + assert_eq!( + meta.total_size, + stsz.entry_sizes.iter().map(|s| *s as u64).sum::() + ); + } + + #[test] + fn test_chunk_info_generation() { + let stsc_entries = vec![ + SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 2, + sample_description_index: 1, + }, + SampleToChunkEntry { + first_chunk: 3, + samples_per_chunk: 1, + sample_description_index: 1, + }, + ]; + + let stsc = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries.into(), + }; + + let stsz = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 5, + entry_sizes: vec![100, 200, 300, 400, 500].into(), + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(1); + builder.add_track(&stsc, &stsz); + let chunk_info = builder.build_chunk_info().collect::>(); + + assert_eq!(chunk_info.len(), 3); + + // Chunk 1: 2 samples (0, 1) + assert_eq!(chunk_info[0].chunk_number, 1); + assert_eq!(chunk_info[0].chunk_size, 300); // 100 + 200 + assert_eq!(chunk_info[0].sample_indices, vec![0, 1]); + + // Chunk 2: 2 samples (2, 3) + assert_eq!(chunk_info[1].chunk_number, 2); + assert_eq!(chunk_info[1].chunk_size, 700); // 300 + 400 + assert_eq!(chunk_info[1].sample_indices, vec![2, 3]); + + // Chunk 3: 1 sample (4) + assert_eq!(chunk_info[2].chunk_number, 3); + assert_eq!(chunk_info[2].chunk_size, 500); // 500 + assert_eq!(chunk_info[2].sample_indices, vec![4]); + } + + #[test] + fn test_edge_case_empty_samples() { + let stsc_entries = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + + let stsc = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries.into(), + }; + + let stsz = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 0, + entry_sizes: vec![].into(), + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(1); + builder.add_track(&stsc, &stsz); + let chunk_info = builder.build_chunk_info().collect::>(); + + assert_eq!(chunk_info.len(), 0); + } + + #[test] + fn test_track_interleaving() { + // Track 1: 2 chunks with 2 samples each + let stsc_entries_1 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 2, + sample_description_index: 1, + }]; + + let stsc_1 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_1.into(), + }; + + let stsz_1 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 4, + entry_sizes: vec![100, 200, 150, 250].into(), + }; + + // Track 2: 3 chunks with 1 sample each + let stsc_entries_2 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + + let stsc_2 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_2.into(), + }; + + let stsz_2 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 3, + entry_sizes: vec![300, 400, 500].into(), + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(2); + builder.add_track(&stsc_1, &stsz_1); + builder.add_track(&stsc_2, &stsz_2); + + let chunk_info = builder.build_chunk_info().collect::>(); + + // Expected interleaving: T1C1, T2C1, T1C2, T2C2, T2C3 + assert_eq!(chunk_info.len(), 5); + + // Track 1, Chunk 1: samples 0,1 (track_index=0) + assert_eq!(chunk_info[0].track_index, 0); + assert_eq!(chunk_info[0].chunk_number, 1); + assert_eq!(chunk_info[0].chunk_size, 300); // 100 + 200 + assert_eq!(chunk_info[0].sample_indices, vec![0, 1]); + + // Track 2, Chunk 1: sample 0 (track_index=1) + assert_eq!(chunk_info[1].track_index, 1); + assert_eq!(chunk_info[1].chunk_number, 1); + assert_eq!(chunk_info[1].chunk_size, 300); // 300 + assert_eq!(chunk_info[1].sample_indices, vec![0]); + + // Track 1, Chunk 2: samples 2,3 (track_index=0) + assert_eq!(chunk_info[2].track_index, 0); + assert_eq!(chunk_info[2].chunk_number, 2); + assert_eq!(chunk_info[2].chunk_size, 400); // 150 + 250 + assert_eq!(chunk_info[2].sample_indices, vec![2, 3]); + + // Track 2, Chunk 2: sample 1 (track_index=1) + assert_eq!(chunk_info[3].track_index, 1); + assert_eq!(chunk_info[3].chunk_number, 2); + assert_eq!(chunk_info[3].chunk_size, 400); // 400 + assert_eq!(chunk_info[3].sample_indices, vec![1]); + + // Track 2, Chunk 3: sample 2 (track_index=1) + assert_eq!(chunk_info[4].track_index, 1); + assert_eq!(chunk_info[4].chunk_number, 3); + assert_eq!(chunk_info[4].chunk_size, 500); // 500 + assert_eq!(chunk_info[4].sample_indices, vec![2]); + + // Test chunk offsets are calculated correctly with interleaving + let (offsets, meta) = builder.build_chunk_offsets(0); + + // Track 1 offsets: [0, 600] (T1C1 at 0, T1C2 at 0+300+300=600) + assert_eq!(offsets[0], vec![0, 600]); + + // Track 2 offsets: [300, 1000, 1400] (T2C1 at 300, T2C2 at 300+300+400=1000, T2C3 at 1000+400=1400) + assert_eq!(offsets[1], vec![300, 1000, 1400]); + + assert_eq!( + meta.total_size, + stsz_1 + .entry_sizes + .iter() + .cloned() + .chain(stsz_2.entry_sizes.iter().cloned()) + .map(|s| s as u64) + .sum::() + ); + } + + #[test] + fn test_build_chunk_offsets_ordered() { + // Track 1: 2 chunks + let stsc_entries_1 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 2, + sample_description_index: 1, + }]; + + let stsc_1 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_1.into(), + }; + + let stsz_1 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 4, + entry_sizes: vec![100, 200, 150, 250].into(), + }; + + // Track 2: 2 chunks + let stsc_entries_2 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + + let stsc_2 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_2.into(), + }; + + let stsz_2 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 2, + entry_sizes: vec![300, 400].into(), + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(2); + builder.add_track(&stsc_1, &stsz_1); + builder.add_track(&stsc_2, &stsz_2); + + // Original chunk offsets in a specific order: + // Track 1: chunks at offsets [1000, 2000] + // Track 2: chunks at offsets [500, 1500] + // This means the order should be: T2C1 (500), T1C1 (1000), T2C2 (1500), T1C2 (2000) + let original_offsets_track_1 = vec![1000u64, 2000u64]; + let original_offsets_track_2 = vec![500u64, 1500u64]; + let original_offsets = vec![ + original_offsets_track_1.as_slice(), + original_offsets_track_2.as_slice(), + ]; + + let (new_offsets, meta) = builder.build_chunk_offsets_ordered(original_offsets, 0); + + // With the original ordering (T2C1, T1C1, T2C2, T1C2) and chunk sizes: + // T2C1: 300 bytes -> offset 0 + // T1C1: 300 bytes -> offset 300 + // T2C2: 400 bytes -> offset 600 + // T1C2: 400 bytes -> offset 1000 + + // Track 1 chunks should be at offsets [300, 1000] + assert_eq!(new_offsets[0], vec![300, 1000]); + + // Track 2 chunks should be at offsets [0, 600] + assert_eq!(new_offsets[1], vec![0, 600]); + + assert_eq!( + meta.total_size, + stsz_1 + .entry_sizes + .iter() + .cloned() + .chain(stsz_2.entry_sizes.iter().cloned()) + .map(|s| s as u64) + .sum::() + ); + } + + #[test] + fn test_build_chunk_offsets_ordered_single_track() { + let stsc_entries = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 3, + sample_description_index: 1, + }]; + + let stsc = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries.into(), + }; + + let stsz = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 6, + entry_sizes: vec![100, 200, 300, 150, 250, 350].into(), + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(1); + builder.add_track(&stsc, &stsz); + + // Single track with 2 chunks at original offsets [5000, 10000] + let original_offsets_track_1 = vec![5000u64, 10000u64]; + let original_offsets = vec![original_offsets_track_1.as_slice()]; + + let (new_offsets, meta) = builder.build_chunk_offsets_ordered(original_offsets, 0); + + // Chunk 1: samples 0,1,2 -> sizes 100+200+300 = 600 + // Chunk 2: samples 3,4,5 -> sizes 150+250+350 = 750 + // Sequential placement: Chunk 1 at 0, Chunk 2 at 600 + assert_eq!(new_offsets[0], vec![0, 600]); + + assert_eq!( + meta.total_size, + stsz.entry_sizes + .iter() + .cloned() + .map(|s| s as u64) + .sum::() + ); + } + + #[test] + fn test_build_chunk_offsets_ordered_non_zero_start() { + let stsc_entries = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + + let stsc = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries.into(), + }; + + let stsz = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 3, + entry_sizes: vec![100, 200, 300].into(), + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(1); + builder.add_track(&stsc, &stsz); + + let original_offsets_track_1 = vec![1000u64, 2000u64, 3000u64]; + let original_offsets = vec![original_offsets_track_1.as_slice()]; + let start_offset = 50000u64; + + let (new_offsets, meta) = + builder.build_chunk_offsets_ordered(original_offsets, start_offset); + + // Starting at 50000, chunks of sizes 100, 200, 300 + assert_eq!(new_offsets[0], vec![50000, 50100, 50300]); + + assert_eq!( + meta.total_size, + stsz.entry_sizes + .iter() + .cloned() + .map(|s| s as u64) + .sum::() + ); + } + + #[test] + fn test_build_chunk_offsets_ordered_interleaving() { + // Track 1: 3 chunks with different sample counts + let stsc_entries_1 = vec![ + SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }, + SampleToChunkEntry { + first_chunk: 2, + samples_per_chunk: 2, + sample_description_index: 1, + }, + ]; + + let stsc_1 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_1.into(), + }; + + let stsz_1 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 5, + entry_sizes: vec![100, 150, 200, 250, 300].into(), + }; + + // Track 2: 2 chunks + let stsc_entries_2 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 2, + sample_description_index: 1, + }]; + + let stsc_2 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_2.into(), + }; + + let stsz_2 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 4, + entry_sizes: vec![80, 120, 160, 240].into(), + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(2); + builder.add_track(&stsc_1, &stsz_1); + builder.add_track(&stsc_2, &stsz_2); + + // Original offsets creating interleaving: + // Track 1: [100, 300, 700] (chunk 1: sample 0, chunk 2: samples 1-2, chunk 3: samples 3-4) + // Track 2: [200, 600] (chunk 1: samples 0-1, chunk 2: samples 2-3) + // Order should be: T1C1 (100), T2C1 (200), T1C2 (300), T2C2 (600), T1C3 (700) + let original_offsets_track_1 = vec![100u64, 300u64, 700u64]; + let original_offsets_track_2 = vec![200u64, 600u64]; + let original_offsets = vec![ + original_offsets_track_1.as_slice(), + original_offsets_track_2.as_slice(), + ]; + + let (new_offsets, meta) = builder.build_chunk_offsets_ordered(original_offsets, 0); + + // Chunk sizes: + // T1C1: 100 bytes (sample 0) + // T2C1: 200 bytes (samples 0-1: 80+120) + // T1C2: 350 bytes (samples 1-2: 150+200) + // T2C2: 400 bytes (samples 2-3: 160+240) + // T1C3: 550 bytes (samples 3-4: 250+300) + // + // Sequential placement: + // T1C1 at 0, T2C1 at 100, T1C2 at 300, T2C2 at 650, T1C3 at 1050 + + assert_eq!(new_offsets[0], vec![0, 300, 1050]); + assert_eq!(new_offsets[1], vec![100, 650]); + + assert_eq!( + meta.total_size, + stsz_1 + .entry_sizes + .iter() + .cloned() + .chain(stsz_2.entry_sizes.iter().cloned()) + .map(|s| s as u64) + .sum::() + ); + } + + #[test] + fn test_build_chunk_offsets_ordered_different_chunk_sizes() { + // Track 1: Large chunks + let stsc_entries_1 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 4, + sample_description_index: 1, + }]; + + let stsc_1 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_1.into(), + }; + + let stsz_1 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 8, + entry_sizes: vec![1000; 8].into(), // All samples are 1000 bytes + }; + + // Track 2: Small chunks + let stsc_entries_2 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + + let stsc_2 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_2.into(), + }; + + let stsz_2 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 4, + entry_sizes: vec![50; 4].into(), // All samples are 50 bytes + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(2); + builder.add_track(&stsc_1, &stsz_1); + builder.add_track(&stsc_2, &stsz_2); + + // Interleave small chunks between large chunks + // T1: [1000, 10000] (chunks of 4000 bytes each) + // T2: [2000, 3000, 4000, 5000] (chunks of 50 bytes each) + let original_offsets_track_1 = vec![1000u64, 10000u64]; + let original_offsets_track_2 = vec![2000u64, 3000u64, 4000u64, 5000u64]; + let original_offsets = vec![ + original_offsets_track_1.as_slice(), + original_offsets_track_2.as_slice(), + ]; + + let (new_offsets, meta) = builder.build_chunk_offsets_ordered(original_offsets, 0); + + // Order: T1C1 (1000), T2C1 (2000), T2C2 (3000), T2C3 (4000), T2C4 (5000), T1C2 (10000) + // Sizes: 4000, 50, 50, 50, 50, 4000 + // Offsets: 0, 4000, 4050, 4100, 4150, 4200 + assert_eq!(new_offsets[0], vec![0, 4200]); + assert_eq!(new_offsets[1], vec![4000, 4050, 4100, 4150]); + + assert_eq!( + meta.total_size, + stsz_1 + .entry_sizes + .iter() + .cloned() + .chain(stsz_2.entry_sizes.iter().cloned()) + .map(|s| s as u64) + .sum::() + ); + } + + #[test] + fn test_build_chunk_offsets_ordered_empty_track_handling() { + // Track 1: Has chunks + let stsc_entries_1 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 2, + sample_description_index: 1, + }]; + + let stsc_1 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_1.into(), + }; + + let stsz_1 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 4, + entry_sizes: vec![100, 200, 300, 400].into(), + }; + + // Track 2: Empty (no samples) + let stsc_entries_2 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + + let stsc_2 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_2.into(), + }; + + let stsz_2 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 0, + entry_sizes: vec![].into(), + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(2); + builder.add_track(&stsc_1, &stsz_1); + builder.add_track(&stsc_2, &stsz_2); + + let original_offsets_track_1 = vec![1000u64, 2000u64]; + let original_offsets_track_2: Vec = vec![]; + let original_offsets = vec![ + original_offsets_track_1.as_slice(), + original_offsets_track_2.as_slice(), // Empty track has no chunks + ]; + + let (new_offsets, meta) = builder.build_chunk_offsets_ordered(original_offsets, 0); + + // Only track 1 has chunks: chunk 1 (300 bytes), chunk 2 (700 bytes) + assert_eq!(new_offsets[0], vec![0, 300]); + assert_eq!(new_offsets[1], vec![]); // Empty track remains empty + + assert_eq!( + meta.total_size, + stsz_1 + .entry_sizes + .iter() + .cloned() + .chain(stsz_2.entry_sizes.iter().cloned()) + .map(|s| s as u64) + .sum::() + ); + } + + #[test] + fn test_build_chunk_offsets_ordered_non_round_robin_interleaving() { + // Track 1: 5 chunks + let stsc_entries_1 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + + let stsc_1 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_1.into(), + }; + + let stsz_1 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 5, + entry_sizes: vec![100, 150, 200, 250, 300].into(), + }; + + // Track 2: 4 chunks + let stsc_entries_2 = vec![SampleToChunkEntry { + first_chunk: 1, + samples_per_chunk: 2, + sample_description_index: 1, + }]; + + let stsc_2 = SampleToChunkAtom { + version: 0, + flags: [0, 0, 0], + entries: stsc_entries_2.into(), + }; + + let stsz_2 = SampleSizeAtom { + version: 0, + flags: [0u8; 3], + sample_size: 0, + sample_count: 8, + entry_sizes: vec![80, 120, 90, 110, 70, 130, 60, 140].into(), + }; + + let mut builder = ChunkOffsetBuilder::with_capacity(2); + builder.add_track(&stsc_1, &stsz_1); + builder.add_track(&stsc_2, &stsz_2); + + // Create non-round-robin interleaving pattern: + // T1 chunks: [100, 300, 500, 800, 1000] + // T2 chunks: [200, 400, 600, 900] + // Expected order: T1C1(100), T1C2(300), T2C1(200), T1C3(500), T2C2(400), T2C3(600), T1C4(800), T2C4(900), T1C5(1000) + // Pattern: T1, T2, T1, T1, T2, T2, T1, T2, T1 (non-round-robin) + let original_offsets_track_1 = vec![100u64, 300u64, 500u64, 800u64, 1000u64]; + let original_offsets_track_2 = vec![200u64, 400u64, 600u64, 900u64]; + let original_offsets = vec![ + original_offsets_track_1.as_slice(), + original_offsets_track_2.as_slice(), + ]; + + let (new_offsets, meta) = builder.build_chunk_offsets_ordered(original_offsets, 0); + + // Calculate expected chunk sizes: + // T1: [100, 150, 200, 250, 300] (individual samples) + // T2: [(80+120)=200, (90+110)=200, (70+130)=200, (60+140)=200] (2 samples per chunk) + // + // Sequential placement based on original ordering: + // T1C1(100) at 0, T2C1(200) at 100, T1C2(150) at 300, T2C2(200) at 450, + // T1C3(200) at 650, T2C3(200) at 850, T1C4(250) at 1050, T2C4(200) at 1300, T1C5(300) at 1500 + + assert_eq!(new_offsets[0], vec![0, 300, 650, 1050, 1500]); // Track 1 chunks + assert_eq!(new_offsets[1], vec![100, 450, 850, 1300]); // Track 2 chunks + + assert_eq!( + meta.total_size, + stsz_1 + .entry_sizes + .iter() + .cloned() + .chain(stsz_2.entry_sizes.iter().cloned()) + .map(|s| s as u64) + .sum::() + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..473b417 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,15 @@ +/*! + * This crate provides tools for losslessly editing mp4 files. + */ + +pub mod atom; +pub mod chapter_track_builder; +pub mod chunk_offset_builder; +pub mod parser; +pub mod reader; +pub mod writer; + +pub use atom::{Atom, AtomData, FourCC}; +pub use parser::{ParseError, Parser}; +pub use reader::Mp4Reader; +pub use writer::Mp4Writer; diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..494a4d1 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,819 @@ +/*! + * This mod is concerned with parsing mp4 files. + */ + +use anyhow::anyhow; +use derive_more::Display; +use futures_io::{AsyncRead, AsyncSeek}; +use std::collections::VecDeque; +use std::fmt::{self, Debug}; +use std::io::SeekFrom; +use std::ops::{Deref, DerefMut}; +use thiserror::Error; + +use crate::atom::util::parser::stream; +use crate::chunk_offset_builder; +pub use crate::reader::{Mp4Reader, NonSeekable, ReadCapability, Seekable}; +use crate::{ + atom::{ + atom_ref::{AtomRef, AtomRefMut}, + container::{is_container_atom, META, META_VERSION_FLAGS_SIZE, MOOV}, + ftyp::{FileTypeAtom, FtypAtomRef, FtypAtomRefMut, FTYP}, + stco_co64::ChunkOffsets, + stsc::SampleToChunkEntry, + stts::TimeToSampleEntry, + util::DebugEllipsis, + AtomHeader, FourCC, MdiaAtomRefMut, MinfAtomRefMut, MoovAtomRef, MoovAtomRefMut, RawData, + TrakAtomRef, + }, + chunk_offset_builder::{ChunkInfo, ChunkOffsetBuilder}, + writer::SerializeAtom, + Atom, AtomData, +}; + +pub const MDAT: &[u8; 4] = b"mdat"; + +/// This trait is implemented on [`AtomData`] and the inner value of each of it's variants. +/// +/// Note that the [`AtomHeader`] has already been consumed, this trait is concerned with parsing the data. +pub(crate) trait ParseAtomData: Sized { + fn parse_atom_data(atom_type: FourCC, input: &[u8]) -> Result; +} + +#[derive(Debug, Error)] +#[error( + "{kind}{}", + self.location.map(|(offset, length)| + format!(" at offset {offset} with length {length}")).unwrap_or_default() +)] +pub struct ParseError { + /// The kind of error that occurred during parsing. + pub(crate) kind: ParseErrorKind, + /// location is the (offset, length) of the input data related to the error + pub(crate) location: Option<(usize, usize)>, + /// The source error that caused this error. + #[source] + pub(crate) source: Option>, +} + +#[derive(Debug, Display)] +#[non_exhaustive] +pub enum ParseErrorKind { + #[display("I/O error")] + Io, + #[display("EOF error")] + Eof, + #[display("Invalid atom header")] + InvalidHeader, + #[display("Invalid atom size")] + InvalidSize, + #[display("Unsupported atom type")] + UnsupportedAtom, + #[display("Unexpected atom type")] + UnexpectedAtom, + #[display("Atom parsing failed")] + AtomParsing, + #[display("Insufficient data")] + InsufficientData, + #[display("moov atom is missing")] + MissingMoov, +} + +impl ParseError { + pub(crate) fn new_unexpected_atom_oneof(atom_type: FourCC, expected: Vec) -> Self { + if expected.len() == 1 { + return Self::new_unexpected_atom(atom_type, expected[0]); + } + + let expected = expected + .into_iter() + .map(|expected| expected.to_string()) + .collect::>() + .join(", "); + Self { + kind: ParseErrorKind::UnexpectedAtom, + location: Some((0, 4)), + source: Some( + anyhow!("expected one of {expected}, got {atom_type}").into_boxed_dyn_error(), + ), + } + } + + fn new_unexpected_atom(atom_type: FourCC, expected: FourCC) -> Self { + let expected = FourCC::from(*expected); + Self { + kind: ParseErrorKind::UnexpectedAtom, + location: Some((0, 4)), + source: Some(anyhow!("expected {expected}, got {atom_type}").into_boxed_dyn_error()), + } + } + + pub(crate) fn from_winnow( + error: winnow::error::ParseError< + winnow::LocatingSlice<&winnow::Bytes>, + winnow::error::ContextError, + >, + ) -> Self { + use winnow::error::StrContext; + let mut ctx_iter = error.inner().context().peekable(); + let mut ctx_tree = Vec::with_capacity(ctx_iter.size_hint().0); + while let Some(ctx) = ctx_iter.next() { + eprintln!("ctx: {ctx:?}"); + match ctx { + StrContext::Expected(exp) => { + let mut label = None; + if matches!(ctx_iter.peek(), Some(StrContext::Label(_))) { + label = Some(ctx_iter.next().unwrap().to_string()); + } + ctx_tree.push(format!( + "{}({exp})", + label.map(|label| label.to_string()).unwrap_or_default() + )); + } + StrContext::Label(label) => { + ctx_tree.push(label.to_string()); + } + _ => {} + } + } + ctx_tree.reverse(); + + Self { + kind: crate::parser::ParseErrorKind::AtomParsing, + location: Some((error.offset(), 0)), + source: match ctx_tree { + ctx if ctx.is_empty() => None, + ctx => Some(anyhow::format_err!("{}", ctx.join(" -> ")).into_boxed_dyn_error()), + }, + } + } +} + +impl + From< + winnow::error::ParseError< + winnow::LocatingSlice<&winnow::Bytes>, + winnow::error::ContextError, + >, + > for ParseError +{ + fn from( + value: winnow::error::ParseError< + winnow::LocatingSlice<&winnow::Bytes>, + winnow::error::ContextError, + >, + ) -> Self { + ParseError::from_winnow(value) + } +} + +pub struct Parser { + reader: Mp4Reader, + mdat: Option, +} + +impl Parser { + pub fn new_seekable(reader: R) -> Self { + Parser { + reader: Mp4Reader::::new(reader), + mdat: None, + } + } + + /// parses metadata atoms, both before and after mdat if moov isn't found before + pub async fn parse_metadata(mut self) -> Result, ParseError> { + let mut atoms = self.parse_metadata_inner(None).await?; + let mdat = match self.mdat.take() { + Some(mdat) if !atoms.iter().any(|a| a.header.atom_type == MOOV) => { + // moov is likely after mdat, so skip to the end of the mdat atom and parse any atoms there + self.reader + .seek(SeekFrom::Current(mdat.data_size as i64)) + .await?; + let end_atoms = self.parse_metadata_inner(None).await?; + atoms.extend(end_atoms); + // and then return to where we were + self.reader + .seek(SeekFrom::Start((mdat.offset + mdat.header_size) as u64)) + .await?; + Some(mdat) + } + mdat => mdat, + }; + Ok(MdatParser::new(self.reader, Metadata::new(atoms), mdat)) + } +} + +impl Parser { + pub fn new(reader: R) -> Self { + Parser { + reader: Mp4Reader::::new(reader), + mdat: None, + } + } + + /// parses metadata atoms until mdat found + pub async fn parse_metadata(mut self) -> Result, ParseError> { + let atoms = self.parse_metadata_inner(None).await?; + Ok(MdatParser::new( + self.reader, + Metadata::new(atoms), + self.mdat, + )) + } +} + +impl Parser { + async fn parse_metadata_inner( + &mut self, + length_limit: Option, + ) -> Result, ParseError> { + let start_offset = self.reader.current_offset; + + let mut atoms = Vec::new(); + + loop { + // ensure we're respecting container bounds + if length_limit.is_some_and(|limit| self.reader.current_offset - start_offset >= limit) + { + break; + } + + let header = match self.parse_next_atom().await { + Ok(parsed_atom) => Ok(parsed_atom), + Err(err) => { + if matches!( + err.kind, + ParseErrorKind::Eof | ParseErrorKind::InvalidHeader + ) { + // end of stream, this means there's no mdat atom + // TODO: rewrite the tests to always include an mdat atom so we can get rid of this check + break; + } + Err(err) + } + }?; + + // only parse as far as the mdat atom + if header.atom_type == MDAT { + self.mdat = Some(header); + break; + } + + if is_container_atom(header.atom_type) { + // META containers have additional header data + let (size, data) = if header.atom_type == META { + // Handle META version and flags as RawData + let version_flags = self.reader.read_data(META_VERSION_FLAGS_SIZE).await?; + ( + header.data_size - META_VERSION_FLAGS_SIZE, + Some(AtomData::RawData(RawData::new( + FourCC(*META), + version_flags, + ))), + ) + } else { + (header.data_size, None) + }; + + let container_atom = Atom { + header, + data, + children: Box::pin(self.parse_metadata_inner(Some(size))).await?, + }; + + atoms.push(container_atom); + } else { + let atom_data = self.parse_atom_data(&header).await?; + let atom = Atom { + header, + data: Some(atom_data), + children: Vec::new(), + }; + atoms.push(atom); + } + } + + Ok(atoms) + } + + async fn parse_next_atom(&mut self) -> Result { + let atom_offset = self.reader.current_offset as u64; + + // Try to read the atom header (size and type) + let mut header = [0u8; 8]; + self.reader.read_exact(&mut header).await?; + + let size = u64::from(u32::from_be_bytes([ + header[0], header[1], header[2], header[3], + ])); + let atom_type: [u8; 4] = header[4..8].try_into().unwrap(); + + // Handle extended size (64-bit) if needed + let (header_size, data_size) = if size == 1 { + // Extended size format + let mut extended_size = [0u8; 8]; + self.reader.read_exact(&mut extended_size).await?; + let full_size = u64::from_be_bytes(extended_size); + if full_size < 16 { + return Err(ParseError { + kind: ParseErrorKind::InvalidSize, + location: Some((atom_offset as usize, 16)), + source: None, + }); + } + (16u64, full_size - 16) + } else if size == 0 { + // Size extends to end of file - not supported in this context + return Err(ParseError { + kind: ParseErrorKind::InvalidSize, + location: Some((atom_offset as usize, 8)), + source: None, + }); + } else { + if size < 8 { + return Err(ParseError { + kind: ParseErrorKind::InvalidSize, + location: Some((atom_offset as usize, 8)), + source: None, + }); + } + (8u64, size - 8) + }; + + let atom_type = FourCC(atom_type); + + Ok(AtomHeader { + atom_type, + offset: atom_offset as usize, + header_size: header_size as usize, + data_size: data_size as usize, + }) + } + + async fn parse_atom_data(&mut self, header: &AtomHeader) -> Result { + let content_data = self.reader.read_data(header.data_size).await?; + let input = stream(&content_data); + + AtomData::parse_atom_data(header.atom_type, &input).map_err(|err| { + let (header_offset, _) = header.location(); + let content_offset = header_offset + header.header_size; + ParseError { + kind: ParseErrorKind::AtomParsing, + location: Some(err.location.map_or_else( + || (content_offset, 0), + |(offset, size)| (content_offset + offset, size), + )), + source: Some(anyhow::Error::from(err).context(header.atom_type).into()), + } + }) + } +} + +pub struct MdatParser { + meta: Metadata, + reader: Option>, + mdat: Option, +} + +impl Clone for MdatParser { + fn clone(&self) -> Self { + Self { + meta: self.meta.clone(), + reader: None, + mdat: None, + } + } +} + +impl Deref for MdatParser { + type Target = Metadata; + + fn deref(&self) -> &Self::Target { + &self.meta + } +} + +impl DerefMut for MdatParser { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.meta + } +} + +impl MdatParser { + fn new(reader: Mp4Reader, meta: Metadata, mdat: Option) -> Self { + Self { + reader: Some(reader), + meta, + mdat, + } + } + + /// Discards the reader and returns just the metadata + pub fn into_metadata(self) -> Metadata { + self.meta + } + + pub fn mdat_header(&self) -> Option<&AtomHeader> { + self.mdat.as_ref() + } + + /// Parse chunks along with related metadata + pub fn chunks(&mut self) -> Result, ParseError> { + let _ = self.mdat.take().ok_or_else(|| ParseError { + kind: ParseErrorKind::InsufficientData, + location: None, + source: Some( + anyhow!("mdat atom is missing or has already been consumed").into_boxed_dyn_error(), + ), + })?; + + let reader = self.reader.take().ok_or_else(|| ParseError { + kind: ParseErrorKind::Io, + location: None, + source: Some(anyhow!("reader has already been consumed").into_boxed_dyn_error()), + })?; + + let mut parser = ChunkParser { + reader, + tracks: Vec::new(), + chunk_offsets: Vec::new(), + sample_to_chunk: Vec::new(), + sample_sizes: Vec::new(), + time_to_sample: Vec::new(), + chunk_info: Vec::new(), + }; + + for trak in self.meta.moov().into_tracks_iter() { + if let Some((trak, stco, stsc, stsz, stts)) = (|| { + let stbl = trak.media().media_information().sample_table(); + let chunk_offset = stbl.chunk_offset()?; + let sample_entries = stbl.sample_to_chunk()?; + let sample_sizes = stbl.sample_size()?; + let time_to_sample = stbl.time_to_sample()?; + Some(( + trak, + chunk_offset.chunk_offsets.inner(), + sample_entries, + sample_sizes, + time_to_sample, + )) + })() { + let mut builder = ChunkOffsetBuilder::with_capacity(1); + builder.add_track(stsc, stsz); + parser.tracks.push(trak); + parser.chunk_offsets.push(stco); + parser.sample_to_chunk.push(stsc.entries.inner()); + parser.sample_sizes.push(stsz.entry_sizes.inner()); + parser.time_to_sample.push(stts.entries.inner()); + parser + .chunk_info + .push(builder.build_chunk_info().collect::>()); + } + } + + Ok(parser) + } +} + +#[derive(Clone)] +pub struct Metadata { + atoms: Vec, +} + +impl Metadata { + pub(crate) fn new(atoms: Vec) -> Self { + Self { atoms } + } + + /// Transforms into (reader, `current_offset`, atoms) + pub fn into_atoms(self) -> Vec { + self.atoms + } + + /// Iterates over the metadata atoms + pub fn atoms_iter(&self) -> impl Iterator { + self.atoms.iter() + } + + /// Mutably iterates over the metadata atoms + pub fn atoms_iter_mut(&mut self) -> impl Iterator { + self.atoms.iter_mut() + } + + /// Retains only the metadata atoms that satisfy the predicate + /// (applies to top level and nested atoms) + pub fn atoms_flat_retain_mut

(&mut self, mut pred: P) + where + P: FnMut(&mut Atom) -> bool, + { + self.atoms.retain_mut(|a| pred(a)); + for atom in &mut self.atoms { + atom.children_flat_retain_mut(|a| pred(a)); + } + } + + fn atom_position(&self, typ: FourCC) -> Option { + self.atoms.iter().position(|a| a.header.atom_type == typ) + } + + fn find_atom(&self, typ: FourCC) -> AtomRef<'_> { + AtomRef(self.atoms.iter().find(|a| a.header.atom_type == typ)) + } + + pub fn ftyp(&mut self) -> FtypAtomRef<'_> { + FtypAtomRef(self.find_atom(FTYP)) + } + + pub fn ftyp_mut(&mut self) -> FtypAtomRefMut<'_> { + if let Some(index) = self.atom_position(FTYP) { + FtypAtomRefMut(AtomRefMut(&mut self.atoms[index])) + } else { + let index = 0; + self.atoms.insert( + index, + Atom::builder() + .header(AtomHeader::new(*FTYP)) + .data(FileTypeAtom::default()) + .build(), + ); + FtypAtomRefMut(AtomRefMut(&mut self.atoms[index])) + } + } + + pub fn moov(&self) -> MoovAtomRef<'_> { + MoovAtomRef(self.find_atom(MOOV)) + } + + pub fn moov_mut(&mut self) -> MoovAtomRefMut<'_> { + if let Some(index) = self.atom_position(MOOV) { + MoovAtomRefMut(AtomRefMut(&mut self.atoms[index])) + } else { + let index = self.atom_position(FTYP).map(|i| i + 1).unwrap_or_default(); + self.atoms.insert( + index, + Atom::builder().header(AtomHeader::new(*MOOV)).build(), + ); + MoovAtomRefMut(AtomRefMut(&mut self.atoms[index])) + } + } + + /// Returns the sum of all metadata atom sizes in bytes + pub fn metadata_size(&self) -> usize { + self.atoms_iter() + .cloned() + .flat_map(SerializeAtom::into_bytes) + .collect::>() + .len() + } + + /// Returns the sum of all track sizes in bytes + pub fn mdat_size(&self) -> usize { + self.moov() + .into_tracks_iter() + .map(|trak| trak.size()) + .sum::() + } + + /// Returns the sum of `metadata_size` and `mdat_size` + pub fn file_size(&self) -> usize { + self.metadata_size() + self.mdat_size() + } + + /// Updates chunk offsets for each track + /// + /// Call this before writing metadata to disk to avoid corruption + pub fn update_chunk_offsets( + &mut self, + ) -> Result { + // mdat is located directly after metadata atoms, so metadata size + 8 bytes for the mdat header + let mdat_content_offset = self.metadata_size() + 8; + + let (chunk_offsets, original_chunk_offsets) = self.moov().into_tracks_iter().try_fold( + (ChunkOffsetBuilder::new(), Vec::new()), + |(mut builder, mut chunk_offsets), trak| { + let stbl = trak.media().media_information().sample_table(); + let stsz = stbl + .sample_size() + .ok_or(UpdateChunkOffsetError::SampleSizeAtomNotFound)?; + let stsc = stbl + .sample_to_chunk() + .ok_or(UpdateChunkOffsetError::SampleToChunkAtomNotFound)?; + let stco = stbl + .chunk_offset() + .ok_or(UpdateChunkOffsetError::ChunkOffsetAtomNotFound)?; + builder.add_track(stsc, stsz); + chunk_offsets.push(stco.chunk_offsets.inner()); + Ok((builder, chunk_offsets)) + }, + )?; + + let (mut chunk_offsets, build_meta) = chunk_offsets + .build_chunk_offsets_ordered(original_chunk_offsets, mdat_content_offset as u64); + + for (track_idx, trak) in self.moov_mut().tracks().enumerate() { + let mut stbl = trak + .into_media() + .and_then(MdiaAtomRefMut::into_media_information) + .and_then(MinfAtomRefMut::into_sample_table) + .ok_or(UpdateChunkOffsetError::SampleTableNotFound)?; + let stco = stbl.chunk_offset(); + let chunk_offsets = std::mem::take(&mut chunk_offsets[track_idx]); + stco.chunk_offsets = ChunkOffsets::from(chunk_offsets); + } + + Ok(build_meta) + } +} + +#[derive(Debug, Error)] +pub enum UpdateChunkOffsetError { + #[error("sample table atom not found")] + SampleTableNotFound, + #[error("sample size atom not found")] + SampleSizeAtomNotFound, + #[error("sample to chunk atom not found")] + SampleToChunkAtomNotFound, + #[error("chunk offset atom not found")] + ChunkOffsetAtomNotFound, +} + +pub struct ChunkParser<'a, R, C: ReadCapability> { + reader: Mp4Reader, + /// Reference to each track's metadata + tracks: Vec>, + /// Chunk offsets for each track + chunk_offsets: Vec<&'a [u64]>, + /// [`SampleToChunkEntry`]s for each track + sample_to_chunk: Vec<&'a [SampleToChunkEntry]>, + /// Sample sizes for each track + sample_sizes: Vec<&'a [u32]>, + /// [`TimeToSampleEntry`]s for each track + time_to_sample: Vec<&'a [TimeToSampleEntry]>, + /// [`ChunkInfo`]s for each track + chunk_info: Vec>, +} + +impl<'a, R: AsyncRead + Unpin + Send, C: ReadCapability> ChunkParser<'a, R, C> { + pub async fn read_next_chunk(&mut self) -> Result>, ParseError> { + let current_offset = self.reader.current_offset as u64; + + let mut next_offset = None; + let mut next_track_idx = 0; + let mut next_chunk_idx = 0; + + for track_idx in 0..self.tracks.len() { + let chunk_info = self.chunk_info[track_idx].front(); + if let Some(chunk_info) = chunk_info { + let chunk_idx = chunk_info.chunk_number as usize - 1; + let offset = self.chunk_offsets[track_idx][chunk_idx]; + if offset >= current_offset + && next_offset.is_none_or(|next_offset| offset < next_offset) + { + next_offset = Some(offset); + next_track_idx = track_idx; + next_chunk_idx = chunk_idx; + } + } + } + + if let Some(offset) = next_offset { + // Skip to the next chunk + let bytes_to_skip = offset - current_offset; + if bytes_to_skip > 0 { + self.reader.read_data(bytes_to_skip as usize).await?; + } + + let chunk_info = self.chunk_info[next_track_idx].pop_front().unwrap(); + + // Read the chunk + self.read_chunk(next_track_idx, next_chunk_idx, chunk_info) + .await + .map(Some) + } else { + // No more chunks + Ok(None) + } + } + + async fn read_chunk( + &mut self, + track_idx: usize, + chunk_idx: usize, + chunk_info: ChunkInfo, + ) -> Result, ParseError> { + let time_to_sample = self.time_to_sample[track_idx]; + + let sample_start_idx = + chunk_info + .sample_indices + .first() + .copied() + .ok_or_else(|| ParseError { + kind: ParseErrorKind::InsufficientData, + location: None, + source: Some( + anyhow!("no samples indicies in chunk at index {chunk_idx}") + .into_boxed_dyn_error(), + ), + })?; + + // Calculate total chunk size + let chunk_size = chunk_info.chunk_size; + let chunk_sample_sizes = chunk_info.sample_sizes.clone(); + + // Read the chunk data + let data = self.reader.read_data(chunk_size as usize).await?; + + // Get the sample durations slice for this chunk + let sample_durations: Vec = time_to_sample + .iter() + .flat_map(|entry| { + std::iter::repeat_n(entry.sample_duration, entry.sample_count as usize) + }) + .skip(sample_start_idx) + .take(chunk_sample_sizes.len()) + .collect(); + assert_eq!(chunk_sample_sizes.len(), sample_durations.len()); + + // Create the chunk + Ok(Chunk { + trak_idx: track_idx, + trak: self.tracks[track_idx], + sample_sizes: chunk_sample_sizes, + sample_durations, + data, + }) + } +} + +impl fmt::Debug for Chunk<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Chunk") + .field("trak", &self.trak) + .field( + "sample_sizes", + &DebugEllipsis(Some(self.sample_sizes.len())), + ) + .field( + "time_to_sample", + &DebugEllipsis(Some(self.sample_durations.len())), + ) + .field("data", &DebugEllipsis(Some(self.data.len()))) + .finish() + } +} + +pub struct Chunk<'a> { + /// Index of the trak in the file + pub trak_idx: usize, + /// Reference to the track the sample is in + pub trak: TrakAtomRef<'a>, + /// Slice of sample sizes within this chunk + pub sample_sizes: Vec, + /// Timescale duration of each sample indexed reletive to `sample_sizes` + pub sample_durations: Vec, + /// Bytes in the chunk + pub data: Vec, +} + +impl<'a> Chunk<'a> { + pub fn samples(&'a self) -> impl Iterator> { + let timescale = self + .trak + .media() + .header() + .map(|h| h.timescale) + .expect("trak.mdia.mvhd is missing"); + self.sample_sizes + .iter() + .zip(self.sample_durations.iter()) + .scan(0usize, move |offset, (size, duration)| { + let sample_offset = *offset; + *offset += *size as usize; + let data = &self.data[sample_offset..sample_offset + (*size as usize)]; + Some(Sample { + size: *size, + duration: *duration, + timescale, + data, + }) + }) + } +} + +pub struct Sample<'a> { + pub size: u32, + pub duration: u32, + pub timescale: u32, + pub data: &'a [u8], +} + +impl fmt::Debug for Sample<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Sample") + .field("size", &self.size) + .field("duration", &self.duration) + .field("timescale", &self.timescale) + .finish_non_exhaustive() + } +} diff --git a/src/reader.rs b/src/reader.rs new file mode 100644 index 0000000..a583596 --- /dev/null +++ b/src/reader.rs @@ -0,0 +1,106 @@ +use futures_io::{AsyncRead, AsyncSeek}; +use futures_util::io::{AsyncReadExt, AsyncSeekExt}; +use std::io::SeekFrom; +use std::marker::PhantomData; + +use crate::parser::ParseErrorKind; +use crate::ParseError; + +mod sealed { + pub trait Sealed {} +} + +pub struct Seekable; +pub struct NonSeekable; + +impl sealed::Sealed for Seekable {} +impl sealed::Sealed for NonSeekable {} + +pub trait ReadCapability: sealed::Sealed {} + +impl ReadCapability for NonSeekable {} + +impl ReadCapability for Seekable {} + +pub struct Mp4Reader { + reader: R, + pub(crate) current_offset: usize, + peek_buffer: Vec, + _capability: PhantomData, +} + +impl Mp4Reader { + pub fn new(reader: R) -> Self { + Self { + reader, + current_offset: 0, + peek_buffer: Vec::new(), + _capability: PhantomData, + } + } +} + +impl Mp4Reader { + pub(crate) async fn peek_exact(&mut self, buf: &mut [u8]) -> Result<(), ParseError> { + let size = buf.len(); + if self.peek_buffer.len() < size { + let mut temp_buf = vec![0u8; size - self.peek_buffer.len()]; + self.reader.read_exact(&mut temp_buf).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + return ParseError { + kind: ParseErrorKind::Eof, + location: Some((self.current_offset, size)), + source: Some(Box::new(e)), + }; + } + ParseError { + kind: ParseErrorKind::Io, + location: Some((self.current_offset, size)), + source: Some(Box::new(e)), + } + })?; + self.peek_buffer.extend_from_slice(&temp_buf[..]); + } + buf.copy_from_slice(&self.peek_buffer[..size]); + Ok(()) + } + + pub(crate) async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), ParseError> { + self.peek_exact(buf).await?; + self.peek_buffer.drain(..buf.len()); + self.current_offset += buf.len(); + Ok(()) + } + + pub(crate) async fn read_data(&mut self, size: usize) -> Result, ParseError> { + let mut data = vec![0u8; size]; + self.read_exact(&mut data).await?; + Ok(data) + } +} + +impl Mp4Reader { + pub fn new(reader: R) -> Self { + Self { + reader, + current_offset: 0, + peek_buffer: Vec::new(), + _capability: PhantomData, + } + } + + pub(crate) async fn seek(&mut self, pos: SeekFrom) -> Result<(), ParseError> { + match self.reader.seek(pos).await { + Ok(offset) => { + self.current_offset = offset as usize; + self.peek_buffer = Vec::new(); + Ok(()) + } + Err(err) => Err(ParseError { + kind: ParseErrorKind::Io, + location: None, + source: Some(Box::new(err)), + }), + } + } +} diff --git a/src/writer.rs b/src/writer.rs new file mode 100644 index 0000000..885f404 --- /dev/null +++ b/src/writer.rs @@ -0,0 +1,158 @@ +use derive_more::Display; +use futures_io::AsyncWrite; +use futures_util::AsyncWriteExt; +use thiserror::Error; + +use crate::{atom::FourCC, Atom, AtomData}; + +#[derive(Debug, Error)] +#[error("{kind}{}", self.source.as_ref().map(|e| format!(" ({e})")).unwrap_or_default())] +pub struct WriteError { + /// The kind of error that occurred during writing. + kind: WriteErrorKind, + /// The source error that caused this error. + #[source] + source: Option>, +} + +#[derive(Debug, Display)] +pub enum WriteErrorKind { + #[display("I/O error")] + Io, +} + +pub trait SerializeAtom: Sized { + /// [`FourCC`] representing atom type + fn atom_type(&self) -> FourCC; + + /// Serialize an atom's body + fn into_body_bytes(self) -> Vec; + + /// Serialize an atom into bytes + fn into_bytes(self) -> Vec { + let atom_type = self.atom_type(); + let mut body = self.into_body_bytes(); + let mut header = serialize_atom_header(atom_type, body.len() as u64); + header.append(&mut body); + header + } +} + +pub struct Mp4Writer { + writer: W, + offset: usize, +} + +impl Mp4Writer { + pub fn new(writer: W) -> Self { + Self { writer, offset: 0 } + } + + pub fn current_offset(&self) -> usize { + self.offset + } + + pub async fn flush(&mut self) -> Result<(), WriteError> { + self.writer.flush().await.map_err(|e| WriteError { + kind: WriteErrorKind::Io, + source: Some(Box::new(e)), + }) + } + + pub async fn write_atom_header( + &mut self, + atom_type: FourCC, + data_size: usize, + ) -> Result<(), WriteError> { + // Write atom header + let header_bytes = serialize_atom_header(atom_type, data_size as u64); + self.writer + .write_all(&header_bytes) + .await + .map_err(|e| WriteError { + kind: WriteErrorKind::Io, + source: Some(Box::new(e)), + })?; + self.offset += header_bytes.len(); + Ok(()) + } + + pub async fn write_leaf_atom( + &mut self, + atom_type: FourCC, + data: AtomData, + ) -> Result<(), WriteError> { + let data_bytes: Vec = data.into_body_bytes(); + self.write_atom_header(atom_type, data_bytes.len()).await?; + self.writer + .write_all(&data_bytes) + .await + .map_err(|e| WriteError { + kind: WriteErrorKind::Io, + source: Some(Box::new(e)), + })?; + self.offset += data_bytes.len(); + Ok(()) + } + + pub async fn write_atom(&mut self, atom: Atom) -> Result<(), WriteError> { + // Serialize the entire atom tree into bytes + let bytes = atom.into_bytes(); + + // Write all bytes at once + self.writer + .write_all(&bytes) + .await + .map_err(|e| WriteError { + kind: WriteErrorKind::Io, + source: Some(Box::new(e)), + })?; + + self.offset += bytes.len(); + + Ok(()) + } + + pub async fn write_raw(&mut self, data: &[u8]) -> Result<(), WriteError> { + self.writer.write_all(data).await.map_err(|e| WriteError { + kind: WriteErrorKind::Io, + source: Some(Box::new(e)), + })?; + + self.offset += data.len(); + Ok(()) + } +} + +fn serialize_atom_header(atom_type: FourCC, data_size: u64) -> Vec { + let mut result = Vec::new(); + + // Determine if we need 64-bit size + let total_size_with_32bit_header = 8u64 + data_size; + let use_64bit = total_size_with_32bit_header > u64::from(u32::MAX); + + if use_64bit { + // Extended 64-bit size format: size=1 (4 bytes) + type (4 bytes) + extended_size (8 bytes) + content + let total_size = 16u64 + data_size; + + // Write size=1 to indicate extended format + result.extend_from_slice(&1u32.to_be_bytes()); + + // Write atom type + result.extend_from_slice(&atom_type.0); + + // Write extended size + result.extend_from_slice(&total_size.to_be_bytes()); + } else { + // Standard 32-bit size format: size (4 bytes) + type (4 bytes) + content + let total_size = total_size_with_32bit_header as u32; + + // Write size + result.extend_from_slice(&total_size.to_be_bytes()); + + // Write atom type + result.extend_from_slice(&atom_type.0); + } + + result +} diff --git a/test-data-review/stsd00.bin b/test-data-review/stsd00.bin new file mode 100644 index 0000000..f0e8eee Binary files /dev/null and b/test-data-review/stsd00.bin differ diff --git a/test-data-review/stsd07.bin b/test-data-review/stsd07.bin new file mode 100644 index 0000000..cbb3328 Binary files /dev/null and b/test-data-review/stsd07.bin differ diff --git a/test-data-review/stsd09.bin b/test-data-review/stsd09.bin new file mode 100644 index 0000000..61cfef8 Binary files /dev/null and b/test-data-review/stsd09.bin differ diff --git a/test-data-review/stsd13.bin b/test-data-review/stsd13.bin new file mode 100644 index 0000000..0c00404 Binary files /dev/null and b/test-data-review/stsd13.bin differ diff --git a/test-data-review/stsd14.bin b/test-data-review/stsd14.bin new file mode 100644 index 0000000..7f55893 Binary files /dev/null and b/test-data-review/stsd14.bin differ diff --git a/test-data-review/stsd16.bin b/test-data-review/stsd16.bin new file mode 100644 index 0000000..b7b043c Binary files /dev/null and b/test-data-review/stsd16.bin differ diff --git a/test-data/chpl00.bin b/test-data/chpl00.bin new file mode 100644 index 0000000..ab41049 Binary files /dev/null and b/test-data/chpl00.bin differ diff --git a/test-data/co6400.bin b/test-data/co6400.bin new file mode 100644 index 0000000..0936eb0 Binary files /dev/null and b/test-data/co6400.bin differ diff --git a/test-data/co6401.bin b/test-data/co6401.bin new file mode 100644 index 0000000..6b2aff2 Binary files /dev/null and b/test-data/co6401.bin differ diff --git a/test-data/co6402.bin b/test-data/co6402.bin new file mode 100644 index 0000000..5c29a89 Binary files /dev/null and b/test-data/co6402.bin differ diff --git a/test-data/cprt00.bin b/test-data/cprt00.bin new file mode 100644 index 0000000..c809594 Binary files /dev/null and b/test-data/cprt00.bin differ diff --git a/test-data/ctts00.bin b/test-data/ctts00.bin new file mode 100644 index 0000000..1b3d2e9 Binary files /dev/null and b/test-data/ctts00.bin differ diff --git a/test-data/dref00.bin b/test-data/dref00.bin new file mode 100644 index 0000000..a83d472 Binary files /dev/null and b/test-data/dref00.bin differ diff --git a/test-data/dref01.bin b/test-data/dref01.bin new file mode 100644 index 0000000..c74c7cc Binary files /dev/null and b/test-data/dref01.bin differ diff --git a/test-data/dref02.bin b/test-data/dref02.bin new file mode 100644 index 0000000..c74c7cc Binary files /dev/null and b/test-data/dref02.bin differ diff --git a/test-data/elng00.bin b/test-data/elng00.bin new file mode 100644 index 0000000..2c4377e Binary files /dev/null and b/test-data/elng00.bin differ diff --git a/test-data/elst00.bin b/test-data/elst00.bin new file mode 100644 index 0000000..ad67012 Binary files /dev/null and b/test-data/elst00.bin differ diff --git a/test-data/elst01.bin b/test-data/elst01.bin new file mode 100644 index 0000000..0d9ec9d Binary files /dev/null and b/test-data/elst01.bin differ diff --git a/test-data/elst02.bin b/test-data/elst02.bin new file mode 100644 index 0000000..01c03d1 Binary files /dev/null and b/test-data/elst02.bin differ diff --git a/test-data/elst03.bin b/test-data/elst03.bin new file mode 100644 index 0000000..4760741 Binary files /dev/null and b/test-data/elst03.bin differ diff --git a/test-data/elst04.bin b/test-data/elst04.bin new file mode 100644 index 0000000..5bf08fe Binary files /dev/null and b/test-data/elst04.bin differ diff --git a/test-data/free00.bin b/test-data/free00.bin new file mode 100644 index 0000000..6a9da78 Binary files /dev/null and b/test-data/free00.bin differ diff --git a/test-data/free01.bin b/test-data/free01.bin new file mode 100644 index 0000000..8b457ab Binary files /dev/null and b/test-data/free01.bin differ diff --git a/test-data/free02.bin b/test-data/free02.bin new file mode 100644 index 0000000..6f297c4 Binary files /dev/null and b/test-data/free02.bin differ diff --git a/test-data/ftyp00.bin b/test-data/ftyp00.bin new file mode 100644 index 0000000..db8812c Binary files /dev/null and b/test-data/ftyp00.bin differ diff --git a/test-data/ftyp01.bin b/test-data/ftyp01.bin new file mode 100644 index 0000000..3f57071 Binary files /dev/null and b/test-data/ftyp01.bin differ diff --git a/test-data/ftyp02.bin b/test-data/ftyp02.bin new file mode 100644 index 0000000..53c2cb2 Binary files /dev/null and b/test-data/ftyp02.bin differ diff --git a/test-data/ftyp03.bin b/test-data/ftyp03.bin new file mode 100644 index 0000000..66e5401 Binary files /dev/null and b/test-data/ftyp03.bin differ diff --git a/test-data/ftyp04.bin b/test-data/ftyp04.bin new file mode 100644 index 0000000..30b2001 Binary files /dev/null and b/test-data/ftyp04.bin differ diff --git a/test-data/gmhd00.bin b/test-data/gmhd00.bin new file mode 100644 index 0000000..76a01f8 Binary files /dev/null and b/test-data/gmhd00.bin differ diff --git a/test-data/gmin00.bin b/test-data/gmin00.bin new file mode 100644 index 0000000..3d5e305 Binary files /dev/null and b/test-data/gmin00.bin differ diff --git a/test-data/hdlr00.bin b/test-data/hdlr00.bin new file mode 100644 index 0000000..5d96f46 Binary files /dev/null and b/test-data/hdlr00.bin differ diff --git a/test-data/hdlr01.bin b/test-data/hdlr01.bin new file mode 100644 index 0000000..018f026 Binary files /dev/null and b/test-data/hdlr01.bin differ diff --git a/test-data/hdlr02.bin b/test-data/hdlr02.bin new file mode 100644 index 0000000..ef096ce Binary files /dev/null and b/test-data/hdlr02.bin differ diff --git a/test-data/hdlr03.bin b/test-data/hdlr03.bin new file mode 100644 index 0000000..419587b Binary files /dev/null and b/test-data/hdlr03.bin differ diff --git a/test-data/hdlr04.bin b/test-data/hdlr04.bin new file mode 100644 index 0000000..3e6881a Binary files /dev/null and b/test-data/hdlr04.bin differ diff --git a/test-data/hdlr05.bin b/test-data/hdlr05.bin new file mode 100644 index 0000000..ab4ce1a Binary files /dev/null and b/test-data/hdlr05.bin differ diff --git a/test-data/hdlr06.bin b/test-data/hdlr06.bin new file mode 100644 index 0000000..abd6a57 Binary files /dev/null and b/test-data/hdlr06.bin differ diff --git a/test-data/hdlr12.bin b/test-data/hdlr12.bin new file mode 100644 index 0000000..419587b Binary files /dev/null and b/test-data/hdlr12.bin differ diff --git a/test-data/hdlr13.bin b/test-data/hdlr13.bin new file mode 100644 index 0000000..ef7a867 Binary files /dev/null and b/test-data/hdlr13.bin differ diff --git a/test-data/hdlr14.bin b/test-data/hdlr14.bin new file mode 100644 index 0000000..965b456 Binary files /dev/null and b/test-data/hdlr14.bin differ diff --git a/test-data/hdlr15.bin b/test-data/hdlr15.bin new file mode 100644 index 0000000..792223a Binary files /dev/null and b/test-data/hdlr15.bin differ diff --git a/test-data/hdlr16.bin b/test-data/hdlr16.bin new file mode 100644 index 0000000..daa1fd9 Binary files /dev/null and b/test-data/hdlr16.bin differ diff --git a/test-data/hdlr17.bin b/test-data/hdlr17.bin new file mode 100644 index 0000000..8c2222a Binary files /dev/null and b/test-data/hdlr17.bin differ diff --git a/test-data/hdlr18.bin b/test-data/hdlr18.bin new file mode 100644 index 0000000..2231a22 Binary files /dev/null and b/test-data/hdlr18.bin differ diff --git a/test-data/hdlr19.bin b/test-data/hdlr19.bin new file mode 100644 index 0000000..b0ee846 Binary files /dev/null and b/test-data/hdlr19.bin differ diff --git a/test-data/hdlr20.bin b/test-data/hdlr20.bin new file mode 100644 index 0000000..fe3463d Binary files /dev/null and b/test-data/hdlr20.bin differ diff --git a/test-data/hdlr21.bin b/test-data/hdlr21.bin new file mode 100644 index 0000000..9ea0f33 Binary files /dev/null and b/test-data/hdlr21.bin differ diff --git a/test-data/ilst00.bin b/test-data/ilst00.bin new file mode 100644 index 0000000..41e551c Binary files /dev/null and b/test-data/ilst00.bin differ diff --git a/test-data/ilst01.bin b/test-data/ilst01.bin new file mode 100644 index 0000000..b37d83e Binary files /dev/null and b/test-data/ilst01.bin differ diff --git a/test-data/ilst02.bin b/test-data/ilst02.bin new file mode 100644 index 0000000..726d044 Binary files /dev/null and b/test-data/ilst02.bin differ diff --git a/test-data/ilst03.bin b/test-data/ilst03.bin new file mode 100644 index 0000000..73b63fc Binary files /dev/null and b/test-data/ilst03.bin differ diff --git a/test-data/iods00.bin b/test-data/iods00.bin new file mode 100644 index 0000000..bad9887 Binary files /dev/null and b/test-data/iods00.bin differ diff --git a/test-data/kind00.bin b/test-data/kind00.bin new file mode 100644 index 0000000..9062da3 Binary files /dev/null and b/test-data/kind00.bin differ diff --git a/test-data/m4b/AliceInWonderland_librivox_2_chapters.m4b b/test-data/m4b/AliceInWonderland_librivox_2_chapters.m4b new file mode 100644 index 0000000..54e677a Binary files /dev/null and b/test-data/m4b/AliceInWonderland_librivox_2_chapters.m4b differ diff --git a/test-data/mdhd00.bin b/test-data/mdhd00.bin new file mode 100644 index 0000000..d5298c3 Binary files /dev/null and b/test-data/mdhd00.bin differ diff --git a/test-data/mdhd01.bin b/test-data/mdhd01.bin new file mode 100644 index 0000000..10fa7e7 Binary files /dev/null and b/test-data/mdhd01.bin differ diff --git a/test-data/mdhd02.bin b/test-data/mdhd02.bin new file mode 100644 index 0000000..4f45344 Binary files /dev/null and b/test-data/mdhd02.bin differ diff --git a/test-data/mdhd03.bin b/test-data/mdhd03.bin new file mode 100644 index 0000000..14243d3 Binary files /dev/null and b/test-data/mdhd03.bin differ diff --git a/test-data/mdhd04.bin b/test-data/mdhd04.bin new file mode 100644 index 0000000..7e983fa Binary files /dev/null and b/test-data/mdhd04.bin differ diff --git a/test-data/mdhd05.bin b/test-data/mdhd05.bin new file mode 100644 index 0000000..131854c Binary files /dev/null and b/test-data/mdhd05.bin differ diff --git a/test-data/mdhd06.bin b/test-data/mdhd06.bin new file mode 100644 index 0000000..013dd19 Binary files /dev/null and b/test-data/mdhd06.bin differ diff --git a/test-data/mdhd07.bin b/test-data/mdhd07.bin new file mode 100644 index 0000000..c6704aa Binary files /dev/null and b/test-data/mdhd07.bin differ diff --git a/test-data/mdhd08.bin b/test-data/mdhd08.bin new file mode 100644 index 0000000..e5b3ffc Binary files /dev/null and b/test-data/mdhd08.bin differ diff --git a/test-data/mdhd09.bin b/test-data/mdhd09.bin new file mode 100644 index 0000000..1c20ad5 Binary files /dev/null and b/test-data/mdhd09.bin differ diff --git a/test-data/mdhd10.bin b/test-data/mdhd10.bin new file mode 100644 index 0000000..31e61ff Binary files /dev/null and b/test-data/mdhd10.bin differ diff --git a/test-data/mdhd11.bin b/test-data/mdhd11.bin new file mode 100644 index 0000000..6c6db29 Binary files /dev/null and b/test-data/mdhd11.bin differ diff --git a/test-data/mdhd12.bin b/test-data/mdhd12.bin new file mode 100644 index 0000000..a87f6f5 Binary files /dev/null and b/test-data/mdhd12.bin differ diff --git a/test-data/mdhd13.bin b/test-data/mdhd13.bin new file mode 100644 index 0000000..2275f3d Binary files /dev/null and b/test-data/mdhd13.bin differ diff --git a/test-data/mdhd14.bin b/test-data/mdhd14.bin new file mode 100644 index 0000000..55aeac1 Binary files /dev/null and b/test-data/mdhd14.bin differ diff --git a/test-data/mvhd00.bin b/test-data/mvhd00.bin new file mode 100644 index 0000000..e041e52 Binary files /dev/null and b/test-data/mvhd00.bin differ diff --git a/test-data/mvhd01.bin b/test-data/mvhd01.bin new file mode 100644 index 0000000..f4c3643 Binary files /dev/null and b/test-data/mvhd01.bin differ diff --git a/test-data/mvhd02.bin b/test-data/mvhd02.bin new file mode 100644 index 0000000..292f19d Binary files /dev/null and b/test-data/mvhd02.bin differ diff --git a/test-data/mvhd03.bin b/test-data/mvhd03.bin new file mode 100644 index 0000000..cb3bea1 Binary files /dev/null and b/test-data/mvhd03.bin differ diff --git a/test-data/mvhd04.bin b/test-data/mvhd04.bin new file mode 100644 index 0000000..8868466 Binary files /dev/null and b/test-data/mvhd04.bin differ diff --git a/test-data/mvhd05.bin b/test-data/mvhd05.bin new file mode 100644 index 0000000..6668710 Binary files /dev/null and b/test-data/mvhd05.bin differ diff --git a/test-data/mvhd06.bin b/test-data/mvhd06.bin new file mode 100644 index 0000000..0bb80a4 Binary files /dev/null and b/test-data/mvhd06.bin differ diff --git a/test-data/mvhd07.bin b/test-data/mvhd07.bin new file mode 100644 index 0000000..60655c0 Binary files /dev/null and b/test-data/mvhd07.bin differ diff --git a/test-data/nmhd00.bin b/test-data/nmhd00.bin new file mode 100644 index 0000000..213fbaa Binary files /dev/null and b/test-data/nmhd00.bin differ diff --git a/test-data/sbgp00.bin b/test-data/sbgp00.bin new file mode 100644 index 0000000..7df5b6b Binary files /dev/null and b/test-data/sbgp00.bin differ diff --git a/test-data/sgpd00.bin b/test-data/sgpd00.bin new file mode 100644 index 0000000..23af8d7 Binary files /dev/null and b/test-data/sgpd00.bin differ diff --git a/test-data/smhd00.bin b/test-data/smhd00.bin new file mode 100644 index 0000000..c613f28 Binary files /dev/null and b/test-data/smhd00.bin differ diff --git a/test-data/smhd01.bin b/test-data/smhd01.bin new file mode 100644 index 0000000..c613f28 Binary files /dev/null and b/test-data/smhd01.bin differ diff --git a/test-data/stco00.bin b/test-data/stco00.bin new file mode 100644 index 0000000..925f5a4 Binary files /dev/null and b/test-data/stco00.bin differ diff --git a/test-data/stco01.bin b/test-data/stco01.bin new file mode 100644 index 0000000..0951ddc Binary files /dev/null and b/test-data/stco01.bin differ diff --git a/test-data/stco02.bin b/test-data/stco02.bin new file mode 100644 index 0000000..ce4ac86 Binary files /dev/null and b/test-data/stco02.bin differ diff --git a/test-data/stco03.bin b/test-data/stco03.bin new file mode 100644 index 0000000..49e9119 Binary files /dev/null and b/test-data/stco03.bin differ diff --git a/test-data/stco04.bin b/test-data/stco04.bin new file mode 100644 index 0000000..9908e87 Binary files /dev/null and b/test-data/stco04.bin differ diff --git a/test-data/stco05.bin b/test-data/stco05.bin new file mode 100644 index 0000000..aab7b3a Binary files /dev/null and b/test-data/stco05.bin differ diff --git a/test-data/stco06.bin b/test-data/stco06.bin new file mode 100644 index 0000000..64a10f8 Binary files /dev/null and b/test-data/stco06.bin differ diff --git a/test-data/stco07.bin b/test-data/stco07.bin new file mode 100644 index 0000000..c62aa90 Binary files /dev/null and b/test-data/stco07.bin differ diff --git a/test-data/stco08.bin b/test-data/stco08.bin new file mode 100644 index 0000000..e666788 Binary files /dev/null and b/test-data/stco08.bin differ diff --git a/test-data/stco09.bin b/test-data/stco09.bin new file mode 100644 index 0000000..07de48f Binary files /dev/null and b/test-data/stco09.bin differ diff --git a/test-data/stco10.bin b/test-data/stco10.bin new file mode 100644 index 0000000..1a022e9 Binary files /dev/null and b/test-data/stco10.bin differ diff --git a/test-data/stco11.bin b/test-data/stco11.bin new file mode 100644 index 0000000..5ab8930 Binary files /dev/null and b/test-data/stco11.bin differ diff --git a/test-data/stco12.bin b/test-data/stco12.bin new file mode 100644 index 0000000..7660bb9 Binary files /dev/null and b/test-data/stco12.bin differ diff --git a/test-data/stco13.bin b/test-data/stco13.bin new file mode 100644 index 0000000..70d01ea Binary files /dev/null and b/test-data/stco13.bin differ diff --git a/test-data/stco14.bin b/test-data/stco14.bin new file mode 100644 index 0000000..95b2da6 Binary files /dev/null and b/test-data/stco14.bin differ diff --git a/test-data/stco15.bin b/test-data/stco15.bin new file mode 100644 index 0000000..26182d8 Binary files /dev/null and b/test-data/stco15.bin differ diff --git a/test-data/sthd00.bin b/test-data/sthd00.bin new file mode 100644 index 0000000..8160245 Binary files /dev/null and b/test-data/sthd00.bin differ diff --git a/test-data/stsc00.bin b/test-data/stsc00.bin new file mode 100644 index 0000000..c9b8140 Binary files /dev/null and b/test-data/stsc00.bin differ diff --git a/test-data/stsc01.bin b/test-data/stsc01.bin new file mode 100644 index 0000000..638bf7c Binary files /dev/null and b/test-data/stsc01.bin differ diff --git a/test-data/stsc02.bin b/test-data/stsc02.bin new file mode 100644 index 0000000..81f6ca0 Binary files /dev/null and b/test-data/stsc02.bin differ diff --git a/test-data/stsc05.bin b/test-data/stsc05.bin new file mode 100644 index 0000000..03e034a Binary files /dev/null and b/test-data/stsc05.bin differ diff --git a/test-data/stsc07.bin b/test-data/stsc07.bin new file mode 100644 index 0000000..6a4fdbc Binary files /dev/null and b/test-data/stsc07.bin differ diff --git a/test-data/stsc08.bin b/test-data/stsc08.bin new file mode 100644 index 0000000..e62d562 Binary files /dev/null and b/test-data/stsc08.bin differ diff --git a/test-data/stsc09.bin b/test-data/stsc09.bin new file mode 100644 index 0000000..cdfc3f7 Binary files /dev/null and b/test-data/stsc09.bin differ diff --git a/test-data/stsc10.bin b/test-data/stsc10.bin new file mode 100644 index 0000000..488d008 Binary files /dev/null and b/test-data/stsc10.bin differ diff --git a/test-data/stsc13.bin b/test-data/stsc13.bin new file mode 100644 index 0000000..4ae7192 Binary files /dev/null and b/test-data/stsc13.bin differ diff --git a/test-data/stsc14.bin b/test-data/stsc14.bin new file mode 100644 index 0000000..a62cc39 Binary files /dev/null and b/test-data/stsc14.bin differ diff --git a/test-data/stsd/btrt00.bin b/test-data/stsd/btrt00.bin new file mode 100644 index 0000000..ded7f19 Binary files /dev/null and b/test-data/stsd/btrt00.bin differ diff --git a/test-data/stsd/esds00.bin b/test-data/stsd/esds00.bin new file mode 100644 index 0000000..28e3be5 Binary files /dev/null and b/test-data/stsd/esds00.bin differ diff --git a/test-data/stsd/esds03.bin b/test-data/stsd/esds03.bin new file mode 100644 index 0000000..a0ad2fb Binary files /dev/null and b/test-data/stsd/esds03.bin differ diff --git a/test-data/stsd/esds05.bin b/test-data/stsd/esds05.bin new file mode 100644 index 0000000..c85ce4d Binary files /dev/null and b/test-data/stsd/esds05.bin differ diff --git a/test-data/stsd/esds07.bin b/test-data/stsd/esds07.bin new file mode 100644 index 0000000..b60de06 Binary files /dev/null and b/test-data/stsd/esds07.bin differ diff --git a/test-data/stsd/esds10.bin b/test-data/stsd/esds10.bin new file mode 100644 index 0000000..3d77acb Binary files /dev/null and b/test-data/stsd/esds10.bin differ diff --git a/test-data/stsd/esds11.bin b/test-data/stsd/esds11.bin new file mode 100644 index 0000000..ffb7036 Binary files /dev/null and b/test-data/stsd/esds11.bin differ diff --git a/test-data/stsd/esds12.bin b/test-data/stsd/esds12.bin new file mode 100644 index 0000000..111ab35 Binary files /dev/null and b/test-data/stsd/esds12.bin differ diff --git a/test-data/stsd/esds13.bin b/test-data/stsd/esds13.bin new file mode 100644 index 0000000..f3cf2b6 Binary files /dev/null and b/test-data/stsd/esds13.bin differ diff --git a/test-data/stsd/esds14.bin b/test-data/stsd/esds14.bin new file mode 100644 index 0000000..d3ba265 Binary files /dev/null and b/test-data/stsd/esds14.bin differ diff --git a/test-data/stsd/esds15.bin b/test-data/stsd/esds15.bin new file mode 100644 index 0000000..cbec0e8 Binary files /dev/null and b/test-data/stsd/esds15.bin differ diff --git a/test-data/stsd/esds16.bin b/test-data/stsd/esds16.bin new file mode 100644 index 0000000..d5a015c Binary files /dev/null and b/test-data/stsd/esds16.bin differ diff --git a/test-data/stsd/esds17.bin b/test-data/stsd/esds17.bin new file mode 100644 index 0000000..2ed7528 Binary files /dev/null and b/test-data/stsd/esds17.bin differ diff --git a/test-data/stsd/esds18.bin b/test-data/stsd/esds18.bin new file mode 100644 index 0000000..8f3e61f Binary files /dev/null and b/test-data/stsd/esds18.bin differ diff --git a/test-data/stsd00.bin b/test-data/stsd00.bin new file mode 100644 index 0000000..04f04a2 Binary files /dev/null and b/test-data/stsd00.bin differ diff --git a/test-data/stsd01.bin b/test-data/stsd01.bin new file mode 100644 index 0000000..ed8e2c0 Binary files /dev/null and b/test-data/stsd01.bin differ diff --git a/test-data/stsd02.bin b/test-data/stsd02.bin new file mode 100644 index 0000000..f5ac246 Binary files /dev/null and b/test-data/stsd02.bin differ diff --git a/test-data/stsd03.bin b/test-data/stsd03.bin new file mode 100644 index 0000000..2d212d0 Binary files /dev/null and b/test-data/stsd03.bin differ diff --git a/test-data/stsd04.bin b/test-data/stsd04.bin new file mode 100644 index 0000000..04f04a2 Binary files /dev/null and b/test-data/stsd04.bin differ diff --git a/test-data/stsd05.bin b/test-data/stsd05.bin new file mode 100644 index 0000000..c0236c5 Binary files /dev/null and b/test-data/stsd05.bin differ diff --git a/test-data/stsd06.bin b/test-data/stsd06.bin new file mode 100644 index 0000000..728840d Binary files /dev/null and b/test-data/stsd06.bin differ diff --git a/test-data/stsd07.bin b/test-data/stsd07.bin new file mode 100644 index 0000000..6a216d0 Binary files /dev/null and b/test-data/stsd07.bin differ diff --git a/test-data/stsd08.bin b/test-data/stsd08.bin new file mode 100644 index 0000000..4e4b2aa Binary files /dev/null and b/test-data/stsd08.bin differ diff --git a/test-data/stsd09.bin b/test-data/stsd09.bin new file mode 100644 index 0000000..04f04a2 Binary files /dev/null and b/test-data/stsd09.bin differ diff --git a/test-data/stsd10.bin b/test-data/stsd10.bin new file mode 100644 index 0000000..f320fb0 Binary files /dev/null and b/test-data/stsd10.bin differ diff --git a/test-data/stsd11.bin b/test-data/stsd11.bin new file mode 100644 index 0000000..b8fbe20 Binary files /dev/null and b/test-data/stsd11.bin differ diff --git a/test-data/stsd12.bin b/test-data/stsd12.bin new file mode 100644 index 0000000..9ddc8f4 Binary files /dev/null and b/test-data/stsd12.bin differ diff --git a/test-data/stsd13.bin b/test-data/stsd13.bin new file mode 100644 index 0000000..4b17015 Binary files /dev/null and b/test-data/stsd13.bin differ diff --git a/test-data/stsd15.bin b/test-data/stsd15.bin new file mode 100644 index 0000000..0aa595a Binary files /dev/null and b/test-data/stsd15.bin differ diff --git a/test-data/stss00.bin b/test-data/stss00.bin new file mode 100644 index 0000000..7eea75a Binary files /dev/null and b/test-data/stss00.bin differ diff --git a/test-data/stsz00.bin b/test-data/stsz00.bin new file mode 100644 index 0000000..dcf7967 Binary files /dev/null and b/test-data/stsz00.bin differ diff --git a/test-data/stsz01.bin b/test-data/stsz01.bin new file mode 100644 index 0000000..1ab3a68 Binary files /dev/null and b/test-data/stsz01.bin differ diff --git a/test-data/stsz02.bin b/test-data/stsz02.bin new file mode 100644 index 0000000..a0d5a89 Binary files /dev/null and b/test-data/stsz02.bin differ diff --git a/test-data/stsz03.bin b/test-data/stsz03.bin new file mode 100644 index 0000000..16cfb0e Binary files /dev/null and b/test-data/stsz03.bin differ diff --git a/test-data/stsz04.bin b/test-data/stsz04.bin new file mode 100644 index 0000000..422bdfb Binary files /dev/null and b/test-data/stsz04.bin differ diff --git a/test-data/stsz05.bin b/test-data/stsz05.bin new file mode 100644 index 0000000..4599979 Binary files /dev/null and b/test-data/stsz05.bin differ diff --git a/test-data/stsz06.bin b/test-data/stsz06.bin new file mode 100644 index 0000000..270a70a Binary files /dev/null and b/test-data/stsz06.bin differ diff --git a/test-data/stsz07.bin b/test-data/stsz07.bin new file mode 100644 index 0000000..3d858ab Binary files /dev/null and b/test-data/stsz07.bin differ diff --git a/test-data/stsz08.bin b/test-data/stsz08.bin new file mode 100644 index 0000000..1754bde Binary files /dev/null and b/test-data/stsz08.bin differ diff --git a/test-data/stsz09.bin b/test-data/stsz09.bin new file mode 100644 index 0000000..cfc970f Binary files /dev/null and b/test-data/stsz09.bin differ diff --git a/test-data/stsz10.bin b/test-data/stsz10.bin new file mode 100644 index 0000000..c935442 Binary files /dev/null and b/test-data/stsz10.bin differ diff --git a/test-data/stsz11.bin b/test-data/stsz11.bin new file mode 100644 index 0000000..487d553 Binary files /dev/null and b/test-data/stsz11.bin differ diff --git a/test-data/stsz12.bin b/test-data/stsz12.bin new file mode 100644 index 0000000..6f4141e Binary files /dev/null and b/test-data/stsz12.bin differ diff --git a/test-data/stsz13.bin b/test-data/stsz13.bin new file mode 100644 index 0000000..1489482 Binary files /dev/null and b/test-data/stsz13.bin differ diff --git a/test-data/stsz14.bin b/test-data/stsz14.bin new file mode 100644 index 0000000..a581030 Binary files /dev/null and b/test-data/stsz14.bin differ diff --git a/test-data/stsz15.bin b/test-data/stsz15.bin new file mode 100644 index 0000000..4a38013 Binary files /dev/null and b/test-data/stsz15.bin differ diff --git a/test-data/stts00.bin b/test-data/stts00.bin new file mode 100644 index 0000000..26f87bf Binary files /dev/null and b/test-data/stts00.bin differ diff --git a/test-data/stts01.bin b/test-data/stts01.bin new file mode 100644 index 0000000..b1673f2 Binary files /dev/null and b/test-data/stts01.bin differ diff --git a/test-data/stts02.bin b/test-data/stts02.bin new file mode 100644 index 0000000..2863879 Binary files /dev/null and b/test-data/stts02.bin differ diff --git a/test-data/stts03.bin b/test-data/stts03.bin new file mode 100644 index 0000000..dd43964 Binary files /dev/null and b/test-data/stts03.bin differ diff --git a/test-data/stts04.bin b/test-data/stts04.bin new file mode 100644 index 0000000..15ecd2d Binary files /dev/null and b/test-data/stts04.bin differ diff --git a/test-data/stts05.bin b/test-data/stts05.bin new file mode 100644 index 0000000..cc8c22c Binary files /dev/null and b/test-data/stts05.bin differ diff --git a/test-data/stts06.bin b/test-data/stts06.bin new file mode 100644 index 0000000..1a9d9a3 Binary files /dev/null and b/test-data/stts06.bin differ diff --git a/test-data/stts07.bin b/test-data/stts07.bin new file mode 100644 index 0000000..7ef459d Binary files /dev/null and b/test-data/stts07.bin differ diff --git a/test-data/stts08.bin b/test-data/stts08.bin new file mode 100644 index 0000000..bb26b8e Binary files /dev/null and b/test-data/stts08.bin differ diff --git a/test-data/stts09.bin b/test-data/stts09.bin new file mode 100644 index 0000000..71a832c Binary files /dev/null and b/test-data/stts09.bin differ diff --git a/test-data/stts10.bin b/test-data/stts10.bin new file mode 100644 index 0000000..d521566 Binary files /dev/null and b/test-data/stts10.bin differ diff --git a/test-data/stts11.bin b/test-data/stts11.bin new file mode 100644 index 0000000..208e63f Binary files /dev/null and b/test-data/stts11.bin differ diff --git a/test-data/stts12.bin b/test-data/stts12.bin new file mode 100644 index 0000000..27ebd05 Binary files /dev/null and b/test-data/stts12.bin differ diff --git a/test-data/stts13.bin b/test-data/stts13.bin new file mode 100644 index 0000000..dac0fc4 Binary files /dev/null and b/test-data/stts13.bin differ diff --git a/test-data/stts14.bin b/test-data/stts14.bin new file mode 100644 index 0000000..0b96f24 Binary files /dev/null and b/test-data/stts14.bin differ diff --git a/test-data/text00.bin b/test-data/text00.bin new file mode 100644 index 0000000..18a6f49 Binary files /dev/null and b/test-data/text00.bin differ diff --git a/test-data/tkhd00.bin b/test-data/tkhd00.bin new file mode 100644 index 0000000..2c62193 Binary files /dev/null and b/test-data/tkhd00.bin differ diff --git a/test-data/tkhd01.bin b/test-data/tkhd01.bin new file mode 100644 index 0000000..fb6cf86 Binary files /dev/null and b/test-data/tkhd01.bin differ diff --git a/test-data/tkhd02.bin b/test-data/tkhd02.bin new file mode 100644 index 0000000..0d6e155 Binary files /dev/null and b/test-data/tkhd02.bin differ diff --git a/test-data/tkhd03.bin b/test-data/tkhd03.bin new file mode 100644 index 0000000..4a2e9be Binary files /dev/null and b/test-data/tkhd03.bin differ diff --git a/test-data/tkhd04.bin b/test-data/tkhd04.bin new file mode 100644 index 0000000..f0d8642 Binary files /dev/null and b/test-data/tkhd04.bin differ diff --git a/test-data/tkhd05.bin b/test-data/tkhd05.bin new file mode 100644 index 0000000..3023cbf Binary files /dev/null and b/test-data/tkhd05.bin differ diff --git a/test-data/tkhd06.bin b/test-data/tkhd06.bin new file mode 100644 index 0000000..6656ed2 Binary files /dev/null and b/test-data/tkhd06.bin differ diff --git a/test-data/tkhd07.bin b/test-data/tkhd07.bin new file mode 100644 index 0000000..00a49ec Binary files /dev/null and b/test-data/tkhd07.bin differ diff --git a/test-data/tkhd08.bin b/test-data/tkhd08.bin new file mode 100644 index 0000000..ac1331a Binary files /dev/null and b/test-data/tkhd08.bin differ diff --git a/test-data/tkhd09.bin b/test-data/tkhd09.bin new file mode 100644 index 0000000..e9c55fb Binary files /dev/null and b/test-data/tkhd09.bin differ diff --git a/test-data/tkhd10.bin b/test-data/tkhd10.bin new file mode 100644 index 0000000..98d20cd Binary files /dev/null and b/test-data/tkhd10.bin differ diff --git a/test-data/tkhd11.bin b/test-data/tkhd11.bin new file mode 100644 index 0000000..f376e34 Binary files /dev/null and b/test-data/tkhd11.bin differ diff --git a/test-data/tkhd12.bin b/test-data/tkhd12.bin new file mode 100644 index 0000000..4b4fad7 Binary files /dev/null and b/test-data/tkhd12.bin differ diff --git a/test-data/tkhd13.bin b/test-data/tkhd13.bin new file mode 100644 index 0000000..0199494 Binary files /dev/null and b/test-data/tkhd13.bin differ diff --git a/test-data/tkhd14.bin b/test-data/tkhd14.bin new file mode 100644 index 0000000..e3db639 Binary files /dev/null and b/test-data/tkhd14.bin differ diff --git a/test-data/tkhd15.bin b/test-data/tkhd15.bin new file mode 100644 index 0000000..005379e Binary files /dev/null and b/test-data/tkhd15.bin differ diff --git a/test-data/tref00.bin b/test-data/tref00.bin new file mode 100644 index 0000000..ef9d267 Binary files /dev/null and b/test-data/tref00.bin differ diff --git a/test-data/tref02.bin b/test-data/tref02.bin new file mode 100644 index 0000000..2070d5b Binary files /dev/null and b/test-data/tref02.bin differ diff --git a/test-data/tref03.bin b/test-data/tref03.bin new file mode 100644 index 0000000..576ad70 Binary files /dev/null and b/test-data/tref03.bin differ diff --git a/test-data/tref05.bin b/test-data/tref05.bin new file mode 100644 index 0000000..e94110f Binary files /dev/null and b/test-data/tref05.bin differ diff --git a/test-data/vmhd00.bin b/test-data/vmhd00.bin new file mode 100644 index 0000000..a36d2b5 Binary files /dev/null and b/test-data/vmhd00.bin differ diff --git a/test-data/vmhd02.bin b/test-data/vmhd02.bin new file mode 100644 index 0000000..ec7d656 Binary files /dev/null and b/test-data/vmhd02.bin differ diff --git a/test-data/wide00.bin b/test-data/wide00.bin new file mode 100644 index 0000000..d18fe9a Binary files /dev/null and b/test-data/wide00.bin differ diff --git a/tests/examples_test.rs b/tests/examples_test.rs new file mode 100644 index 0000000..1347c90 --- /dev/null +++ b/tests/examples_test.rs @@ -0,0 +1,166 @@ +use std::{fs::File, io, path::PathBuf, process::Stdio}; + +use anyhow::{bail, Context}; +use bon::Builder; +use escargot::CargoBuild; +use tempfile::NamedTempFile; + +#[derive(Builder)] +struct ExampleTestCase { + example: &'static str, + input: &'static str, + #[builder(default = false)] + stdin: bool, + #[builder(default = false)] + stdout: bool, + #[builder(default = Vec::new())] + additional_args: Vec<&'static str>, + expected_hash: &'static str, +} + +macro_rules! test_example { + (@example($example:literal), $( + $name:ident { + $( $field:ident: $value:expr ),+ $(,)? + } + )* => @sha256( $hash:literal ) ) => { + $( + test_example!(@inner $name { + $( $field: $value ),+, + example: $example + } => $hash ); + )* + }; + + (@inner $name:ident { + $( $field:ident: $value:expr ),+ + } => $hash:literal ) => { + #[test] + fn $name() { + let test_case = ExampleTestCase::builder() + .$( $field($value) ).+ + .expected_hash($hash) + .build(); + + test_example(test_case); + } + }; +} + +test_example!( + @example("mp4copy"), + + mp4copy_file_to_file { + input: "./test-data/m4b/AliceInWonderland_librivox_2_chapters.m4b", + } + mp4copy_stdin_to_file { + input: "./test-data/m4b/AliceInWonderland_librivox_2_chapters.m4b", + stdin: true, + } + mp4copy_file_to_stdout { + input: "./test-data/m4b/AliceInWonderland_librivox_2_chapters.m4b", + stdout: true, + } + mp4copy_stdin_to_stdout { + input: "./test-data/m4b/AliceInWonderland_librivox_2_chapters.m4b", + stdin: true, + stdout: true, + } + + => @sha256("db8470b4fbf813056aaf452b8ccb3e8ed4443a2f8cfeeeb795ccaae995a939c6") +); + +fn test_example(test_case: ExampleTestCase) { + let input_path = if test_case.stdin { + "-" + } else { + test_case.input + }; + + let stdin = if test_case.stdin { + // pipe input file through stdin + Stdio::from(File::open(test_case.input).expect("error opening input file")) + } else { + Stdio::null() + }; + + let output_file = NamedTempFile::new().expect("error crating temp file"); + let output_path = if test_case.stdout { + "-" + } else { + output_file.path().to_str().unwrap() + }; + + let stdout = if test_case.stdout { + // pipe stdout into the temp file + Stdio::from( + File::options() + .write(true) + .truncate(true) + .open(output_file.path()) + .expect("error opening output temp file"), + ) + } else { + Stdio::null() + }; + + let status = CargoBuild::new() + .example(test_case.example) + .run() + .expect("error building example") + .command() + .arg(input_path) + .arg(&output_path) + .args(test_case.additional_args) + .stdin(stdin) + .stdout(stdout) + .status() + .expect("failed to run example"); + assert!(status.success(), "failed to run example"); + + let output_path = output_file.path(); + let output_file = File::open(&output_path).expect("error opening output file"); + match assert_file_hash(output_file, test_case.expected_hash) { + Ok(()) => {} + Err(err) => { + let mut inspect_path = PathBuf::from(test_case.input); + let file_name = format!( + "inspect_{}_{}", + test_case.example, + inspect_path + .file_name() + .expect("input file should have a file name") + .to_string_lossy() + .to_string() + ); + inspect_path.set_file_name(file_name); + std::fs::copy(output_path, inspect_path) + .inspect_err(|err| { + eprintln!("error copying output file for inspection: {err}"); + }) + .ok(); + panic!("{err}"); + } + } +} + +fn assert_file_hash(file: File, expected_hash: &str) -> anyhow::Result<()> { + let file_hash = hash_file(file).context("error hashing file")?; + + if file_hash != expected_hash { + bail!("left != right, expected {expected_hash}, got {file_hash}"); + } + + Ok(()) +} + +fn hash_file(mut file: File) -> io::Result { + use sha2::{Digest, Sha256}; + use std::io; + + let mut hasher = Sha256::new(); + + io::copy(&mut file, &mut hasher)?; + + Ok(format!("{:x}", hasher.finalize())) +}