diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..2408084cf --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..63e594472 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,14 @@ +# `cargo nextest run` (used by `cargo xtask ci`) runs every test in a process of its own, in parallel. + +[test-groups] +# NVENC sessions are a limited resource on consumer GPUs. +nvenc = { max-threads = 1 } + +[[profile.default.overrides]] +filter = "package(filmcraft-platform) and (binary(/nvenc/) or test(/nvenc/))" +test-group = "nvenc" + +# A throughput gate: it measures one core's speed, so nothing else runs beside it. +[[profile.default.overrides]] +filter = "package(filmcraft-render) and binary(mixer_perf)" +threads-required = "num-test-threads" diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..9f817afcc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,71 @@ +name: Bug report +description: Report a reproducible problem in FilmCraft. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Please report one problem per issue and search existing issues first. + For security vulnerabilities, follow [SECURITY.md](https://github.com/storytold/filmcraft/blob/main/SECURITY.md) instead of posting sensitive details here. + - type: input + id: version + attributes: + label: FilmCraft version or commit + description: Copy the version from Help > About FilmCraft, or give the commit if built from source. + placeholder: "0.8.0" + validations: + required: true + - type: input + id: environment + attributes: + label: Operating system and architecture + placeholder: "Windows 11 x64, macOS 15 Apple silicon, Ubuntu 24.04 x86_64, or browser and version" + validations: + required: true + - type: dropdown + id: build + attributes: + label: Installation type + options: + - Official installer + - Portable archive or AppImage + - Flatpak + - Web app + - Built from source + - Other or unknown + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Start from opening a project. Include the panel, tool, menu item, or command used. + placeholder: | + 1. Open or create... + 2. Select... + 3. Observe... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: Include any error message and whether it happens every time. + validations: + required: true + - type: textarea + id: document + attributes: + label: Media, sequence, export, and hardware details + description: If relevant, include container/codec, resolution, frame rate, bit depth/HDR, sequence settings, export preset, GPU/driver, and hardware decoding or encoding settings. + - type: textarea + id: evidence + attributes: + label: Screenshots, logs, or a minimal sample + description: Optional. Remove secrets, personal paths, and private project or media content. Attach only files you have permission to share; do not attach malicious files. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..b50e72031 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/storytold/filmcraft/blob/main/SECURITY.md + about: Read the reporting guidance before sharing sensitive details or malicious files. + - name: Community help + url: https://discord.gg/artcraft + about: Ask usage questions and discuss FilmCraft workflows with the community. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..b5e0055f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,33 @@ +name: Feature request +description: Suggest an improvement to a FilmCraft workflow. +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Please search existing issues and describe one feature per request. + For usage questions, visit the [community chat](https://discord.gg/artcraft). + - type: textarea + id: problem + attributes: + label: The problem or workflow + description: What are you trying to accomplish, and what gets in the way? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Requested behavior + description: Describe how the feature would work from the user's point of view. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives or workarounds + description: What have you tried, and where does it fall short? + - type: textarea + id: context + attributes: + label: Additional context + description: Optional examples, related issues, or your platform/version if relevant. Share only material you have permission to publish. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..69f8850e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a PR branch cancels its older run; every push to main runs to the end. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Same craft-fonts commit as release.yml. + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + timeout-minutes: 120 + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + libasound2-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0139df440..dc78b3ae0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -5,17 +5,19 @@ # golden-image tests need tools and a GPU the VM doesn't have.) name: FreeBSD -# The VM build takes an hour or more, so it runs nightly on main rather than on every push or -# dependency change: a drifted dependency still shows up within a day. PRs that change the -# FreeBSD packaging itself still run it. +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: - schedule: - - cron: "37 2 * * *" + push: + branches: [release] pull_request: paths: - .github/workflows/freebsd.yml - packaging/freebsd/** - packaging/env.sh + schedule: + - cron: "37 2 * * *" workflow_dispatch: concurrency: @@ -30,6 +32,6 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 skip-tests: true run-package-check: true diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a..2fe7cd3ac 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,44 @@ concurrency: cancel-in-progress: true jobs: - eval: + git-sources: + name: Git dependencies come from storytold runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build + - name: Git dependencies come from storytold run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 436210aef..0724495e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: filmcraft app-title: FilmCraft - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..e78fe1ea3 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,21 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 7666423a1..7934c954d 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -4,19 +4,18 @@ # out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) name: Windows ARM64 +# The package build takes about 15 minutes, so it runs nightly on main and on pushes to `release` +# rather than on every push to main: a dependency or runner-image change still shows up within a +# day. PRs that change the Windows packaging itself still run it. on: + schedule: + - cron: "41 4 * * *" push: - branches: [main] + branches: [release] pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - "**/build.rs" - .github/workflows/windows-arm64.yml - packaging/windows/** - schedule: - - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up workflow_dispatch: concurrency: diff --git a/AGENTS.md b/AGENTS.md index 0683cb87a..aa6160f08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,9 +124,19 @@ See [§0](#0-never-crash). It outranks every other engineering rule. ### 3.2 The rest +**Invariant — move identity uniqueness:** A move plan lists each clip identity at most once. +The core move operation rejects repeated ids before mutation or id allocation; placing one identity +on multiple tracks makes selection and later identity-based editing ambiguous. Copies receive new ids +and retain each entry's requested destination. Destination track creation and the edit commit together. +In an idle session, a rejected duplicate move retains the project and its undo and redo history. +Selection-derived edit plans keep each identity once in first-occurrence order before expanding +linked partners; explicit move requests with repeated identities are still rejected. + See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). + Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ecf54ad37..20428896a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -29,7 +29,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri | `docs/images/filmcraft-keyframes.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain | | `docs/images/filmcraft-export.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frame from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain | | `docs/images/filmcraft-assembly.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Carnival of Souls (1962), Night of the Living Dead (1968), thumbnails of all demo media | MIT OR Apache-2.0; film frames public domain | -| `docs/images/filmcraft-lumetri.png` | FilmCraft contributors | Original work: FilmCraft UI crop (Lumetri Color panel) | MIT OR Apache-2.0 | +| `docs/images/filmcraft-color-panel.png` | FilmCraft contributors | Original work: FilmCraft UI crop (color grading panel) | MIT OR Apache-2.0 | | `docs/images/filmcraft-scopes.png` | FilmCraft contributors | Original work: FilmCraft UI crop (scopes of a Charade (1963) frame, public domain) | MIT OR Apache-2.0 | | `docs/images/filmcraft-effects.png` | FilmCraft contributors | Original work: FilmCraft screenshot crop; NASA Earth Views (US Government work, public domain) | MIT OR Apache-2.0; footage public domain | | `docs/images/filmcraft-timeline.png` | FilmCraft contributors | Original work: FilmCraft UI crop; thumbnails from Night of the Living Dead (1968) and Carnival of Souls (1962), CC0 Chopin waveform | MIT OR Apache-2.0; film frames public domain | @@ -131,6 +131,10 @@ carry each font's licence as `OFL-.txt`. | BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI text; Japanese fallback for sans titles (the web build embeds only Regular) | OFL-1.1 | | Shippori Mincho Regular (FONTDASU) | Japanese fallback for serif titles (native builds) | OFL-1.1 | | BIZ UDMincho Regular (Morisawa) | Japanese fallback for serif titles (native builds) | OFL-1.1 | +| Noto Sans CJK SC Regular (Google, Adobe) | Chinese UI text and media names; Chinese fallback for titles (native builds) | OFL-1.1 | +| Noto Sans Arabic Regular (Google) | Arabic UI text; Arabic fallback for titles (native builds) | OFL-1.1 | +| Noto Sans Thai Regular (Google) | Thai fallback for titles (native builds) | OFL-1.1 | +| Noto Sans Georgian Regular (Google) | Georgian fallback for titles (native builds) | OFL-1.1 | ## First-party brand marks @@ -169,3 +173,12 @@ SHA-256 (`crates/speech/src/models.rs`, `docs/transcripts.md`). | Asset | Author | Source | Licence | |---|---|---|---| | Whisper tiny / base / small speech-recognition weights (`model.safetensors`, `config.json`, `generation_config.json`, `tokenizer.json`) | OpenAI | `https://huggingface.co/openai/whisper-{tiny,base,small}` (revisions `169d4a43…`, `e37978b9…`, `973afd24…`); weights released with `https://github.com/openai/whisper` | MIT (weights and code, github.com/openai/whisper); the Hugging Face conversion is labelled Apache-2.0 | + +## egui dependency fonts + +| Asset | Author | Source | Licence | Use | +|---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. | +| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | Ubuntu Font Licence 1.0 (Copyright 2011 Canonical Ltd.; full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional fallback, included by `default_fonts`; `system_fonts` is not enabled. Dependency asset unmodified here; no font file copied into this repository. | +| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors (Google) | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | OFL-1.1 (Copyright 2013 Google Inc.; full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is the monochrome emoji fallback, included because the workspace enables `monochrome_emoji_fonts`. Dependency asset unmodified here; no font file copied into this repository. | +| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors; Bitstream Inc.; DejaVu contributors | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT (Copyright (c) 2018 Source Foundry Authors) and the Bitstream Vera License (Copyright (c) 2003 Bitstream, Inc.); the DejaVu work is public domain; full upstream notices in `NOTICE` | Hack-Regular.ttf is egui's default monospace fallback, included by `default_fonts`. Dependency asset unmodified here; no font file copied into this repository. | diff --git a/CLAUDE.md b/CLAUDE.md index 2ecdacc20..d28a9b06b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target - **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback. - **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`. - **Exact time:** all time is `filmcraft_time::Tick` (254 016 000 000/s). Never use f64 seconds for edit math. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. - **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde so the control channel can read/set it. - **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm` (checks L0–L4 crates). `cargo xtask ci` runs them all. diff --git a/Cargo.lock b/Cargo.lock index 43b781b41..7efda0c44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows 0.62.2", "windows-core 0.62.2", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -139,7 +134,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -233,21 +228,22 @@ dependencies = [ [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", + "wl-clipboard-rs", "x11rb", ] @@ -481,12 +477,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "base64" version = "0.23.1" @@ -499,15 +489,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -518,10 +499,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -529,6 +513,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -565,7 +559,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -581,6 +575,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -721,7 +739,7 @@ dependencies = [ "safetensors", "thiserror 2.0.21", "yoke", - "zip", + "zip 7.2.0", ] [[package]] @@ -797,7 +815,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -807,7 +836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -845,6 +874,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1011,18 +1049,100 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] + +[[package]] +name = "craft-media" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +dependencies = [ + "thiserror 2.0.21", +] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", + "skrifa", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip 9.1.0", ] [[package]] @@ -1118,7 +1238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1189,6 +1309,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "digest" version = "0.10.7" @@ -1216,7 +1342,18 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -1249,6 +1386,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1287,9 +1430,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1321,9 +1463,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1337,7 +1478,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1345,6 +1486,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1356,9 +1498,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1376,9 +1517,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1396,30 +1536,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1431,9 +1574,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "eframe", "egui", @@ -1476,9 +1618,8 @@ dependencies = [ [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1538,9 +1679,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1556,16 +1696,17 @@ dependencies = [ "self_cell", "skrifa", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1677,13 +1818,24 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "filmcraft" -version = "0.9.0" +version = "0.13.0" dependencies = [ "arboard", "chrono", "cpal", + "craft-update", "eframe", "egui", "filmcraft-engine", @@ -1697,13 +1849,14 @@ dependencies = [ "glob", "log", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", - "pollster 0.4.0", + "pollster 1.0.1", "rfd", "serde_json", + "subtle", "sys-locale", "winit", "winresource", @@ -1712,7 +1865,7 @@ dependencies = [ [[package]] name = "filmcraft-aac" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1722,7 +1875,7 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -1730,7 +1883,7 @@ dependencies = [ [[package]] name = "filmcraft-apv" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1740,16 +1893,16 @@ dependencies = [ [[package]] name = "filmcraft-audio-dsp" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-automation" -version = "0.9.0" +version = "0.13.0" dependencies = [ - "base64 0.22.1", + "base64", "filmcraft-engine", "filmcraft-time", "image", @@ -1757,13 +1910,14 @@ dependencies = [ "schemars", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "tokio", ] [[package]] name = "filmcraft-av1" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1774,21 +1928,21 @@ dependencies = [ [[package]] name = "filmcraft-avi" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-bitstream" -version = "0.9.0" +version = "0.13.0" dependencies = [ - "proptest", + "craft-media", ] [[package]] name = "filmcraft-captions" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-color", "filmcraft-project", @@ -1801,11 +1955,11 @@ dependencies = [ [[package]] name = "filmcraft-cfb" -version = "0.9.0" +version = "0.13.0" [[package]] name = "filmcraft-cli" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-automation", "filmcraft-codecs", @@ -1827,7 +1981,7 @@ dependencies = [ [[package]] name = "filmcraft-codecs" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-aac", "filmcraft-ac3", @@ -1865,7 +2019,7 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.9.0" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -1873,7 +2027,7 @@ dependencies = [ [[package]] name = "filmcraft-dng" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-dng", "rayon", @@ -1882,7 +2036,7 @@ dependencies = [ [[package]] name = "filmcraft-dnx" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1892,7 +2046,7 @@ dependencies = [ [[package]] name = "filmcraft-edit" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-geom", "filmcraft-media", @@ -1905,8 +2059,9 @@ dependencies = [ [[package]] name = "filmcraft-engine" -version = "0.9.0" +version = "0.13.0" dependencies = [ + "chrono", "craft-docpath", "filmcraft-audio-dsp", "filmcraft-captions", @@ -1940,7 +2095,7 @@ dependencies = [ [[package]] name = "filmcraft-export" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-aac", "filmcraft-apv", @@ -1971,7 +2126,7 @@ dependencies = [ [[package]] name = "filmcraft-flac" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1980,20 +2135,22 @@ dependencies = [ [[package]] name = "filmcraft-format" -version = "0.9.0" +version = "0.13.0" dependencies = [ + "chrono", "filmcraft-media", "filmcraft-project", "filmcraft-time", "log", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", ] [[package]] name = "filmcraft-frame" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2003,7 +2160,7 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.9.0" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -2011,7 +2168,7 @@ dependencies = [ [[package]] name = "filmcraft-golden" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2022,13 +2179,13 @@ dependencies = [ "filmcraft-render", "filmcraft-testkit", "filmcraft-time", - "pollster 0.4.0", + "pollster 1.0.1", "wgpu", ] [[package]] name = "filmcraft-gpu" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-color", "filmcraft-export", @@ -2040,14 +2197,14 @@ dependencies = [ "filmcraft-time", "image", "log", - "pollster 0.4.0", + "pollster 1.0.1", "rayon", "wgpu", ] [[package]] name = "filmcraft-h264" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2057,7 +2214,7 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2067,7 +2224,7 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2077,7 +2234,7 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2094,26 +2251,26 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.9.0" +version = "0.13.0" dependencies = [ - "filmcraft-bitstream", + "craft-media", "filmcraft-testkit", "proptest", "serde_json", - "thiserror 2.0.21", ] [[package]] name = "filmcraft-matroska" -version = "0.9.0" +version = "0.13.0" dependencies = [ + "craft-media", "filmcraft-testkit", "serde_json", ] [[package]] name = "filmcraft-media" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-color", "filmcraft-dng", @@ -2129,7 +2286,7 @@ dependencies = [ [[package]] name = "filmcraft-mpa" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2138,7 +2295,7 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-testkit", "rayon", @@ -2147,7 +2304,7 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -2155,28 +2312,29 @@ dependencies = [ [[package]] name = "filmcraft-mxf" -version = "0.9.0" +version = "0.13.0" dependencies = [ + "chrono", "filmcraft-testkit", ] [[package]] name = "filmcraft-ogg" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-opus" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-platform" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-codecs", @@ -2202,7 +2360,7 @@ dependencies = [ [[package]] name = "filmcraft-project" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2216,7 +2374,7 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2226,7 +2384,7 @@ dependencies = [ [[package]] name = "filmcraft-render" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -2246,7 +2404,7 @@ dependencies = [ [[package]] name = "filmcraft-scopes" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-color", "serde", @@ -2255,7 +2413,7 @@ dependencies = [ [[package]] name = "filmcraft-speech" -version = "0.9.0" +version = "0.13.0" dependencies = [ "candle-core", "candle-nn", @@ -2267,24 +2425,28 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "thiserror 2.0.21", "ureq", ] [[package]] name = "filmcraft-testkit" -version = "0.9.0" +version = "0.13.0" dependencies = [ + "chrono", "png", + "tempfile", ] [[package]] name = "filmcraft-text" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-color", "harfrust", "skrifa", + "tempfile", "unicode-bidi", "unicode-linebreak", "unicode-segmentation", @@ -2292,7 +2454,7 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.9.0" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -2300,7 +2462,7 @@ dependencies = [ [[package]] name = "filmcraft-tts" -version = "0.9.0" +version = "0.13.0" dependencies = [ "candle-core", "candle-nn", @@ -2308,13 +2470,14 @@ dependencies = [ "filmcraft-tts-text", "serde", "serde_json", + "sha2", "thiserror 2.0.21", - "zip", + "zip 7.2.0", ] [[package]] name = "filmcraft-tts-text" -version = "0.9.0" +version = "0.13.0" dependencies = [ "serde", "thiserror 2.0.21", @@ -2322,10 +2485,11 @@ dependencies = [ [[package]] name = "filmcraft-ui-egui" -version = "0.9.0" +version = "0.13.0" dependencies = [ "cpu-time", "craft-ui", + "craft-update", "eframe", "egui", "egui_kittest", @@ -2349,11 +2513,12 @@ dependencies = [ "filmcraft-tts", "image", "log", - "pollster 0.4.0", + "pollster 1.0.1", "rayon", "rustix 1.1.5", "serde", "serde_json", + "tempfile", "toml 1.1.6+spec-1.1.0", "unicode-bidi", "web-time", @@ -2361,23 +2526,25 @@ dependencies = [ [[package]] name = "filmcraft-vp8" -version = "0.9.0" +version = "0.13.0" dependencies = [ + "filmcraft-testkit", "thiserror 2.0.21", ] [[package]] name = "filmcraft-vp9" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-bitstream", + "filmcraft-testkit", "rayon", "thiserror 2.0.21", ] [[package]] name = "filmcraft-web" -version = "0.9.0" +version = "0.13.0" dependencies = [ "eframe", "egui", @@ -2405,6 +2572,23 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2434,6 +2618,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2805,8 +2995,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2921,12 +3114,12 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -2967,7 +3160,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3143,6 +3336,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -3151,7 +3353,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", "serde_core", ] @@ -3162,7 +3364,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3247,6 +3449,183 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -3461,12 +3840,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3542,6 +3921,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] + [[package]] name = "litrs" version = "1.0.0" @@ -3640,20 +4028,19 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", "gtk", "keyboard-types", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "once_cell", "png", "thiserror 2.0.21", "windows-sys 0.61.2", @@ -3733,12 +4120,33 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -3765,6 +4173,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -3855,9 +4269,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3886,7 +4300,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3900,7 +4314,7 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ "bitflags 2.13.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -3938,7 +4352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -3950,7 +4364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3975,7 +4389,7 @@ dependencies = [ "block2 0.6.2", "dispatch2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3986,7 +4400,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -4024,7 +4438,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -4039,7 +4453,7 @@ checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -4073,7 +4487,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4084,7 +4498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4120,7 +4534,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4144,7 +4558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4189,7 +4603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -4226,7 +4640,7 @@ checksum = "05bf9a3c14831a7d9641b0d81d87dd913ee238a012b2fde27db5a84b56f5df3e" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-core-media", @@ -4249,9 +4663,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.4.4" +version = "5.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" dependencies = [ "is-wsl", "libc", @@ -4292,6 +4706,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "p256" version = "0.13.2" @@ -4411,10 +4835,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.13.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -4423,9 +4858,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -4433,9 +4868,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -4446,9 +4881,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -4534,6 +4969,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -4579,7 +5027,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4600,7 +5048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4620,6 +5068,33 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4714,16 +5189,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20 0.10.2", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha 0.9.0", + "rand 0.10.3", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -4781,6 +5257,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -4822,6 +5307,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4860,6 +5355,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -4872,11 +5373,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -4906,7 +5407,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5029,7 +5530,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5061,13 +5562,14 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.5.0" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" +checksum = "ceb42e9e47443cd83a834dea57b5816d35481417bae72ade5dc5615c59122e26" dependencies = [ "chrono", "futures", "indexmap", + "libc", "pastey", "pin-project-lite", "rmcp-macros", @@ -5083,9 +5585,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.5.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e66877136e969d5a2d561ee5841b17717a0619aebd64abb47a2a2f46e34683" +checksum = "967fa7f7935d7d6217d593df0e8e5c9307bcaf256974a5a9ee64476c97735389" dependencies = [ "darling", "proc-macro2", @@ -5173,12 +5675,13 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.15", "subtle", @@ -5334,9 +5837,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "chrono", "dyn-clone", @@ -5348,9 +5851,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", @@ -5418,6 +5921,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "seq-macro" @@ -5468,9 +5981,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5515,7 +6028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5942,7 +6455,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ + "js-sys", "libc", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -5972,6 +6488,16 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5985,7 +6511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", @@ -6055,10 +6581,51 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.53.1" +name = "time" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -6222,6 +6789,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -6237,6 +6815,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -6284,6 +6868,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -6314,11 +6904,12 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ - "base64 0.23.1", + "base64", + "flate2", "log", "percent-encoding", "rustls", @@ -6330,22 +6921,34 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ - "base64 0.23.1", + "base64", "http", "httparse", "log", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.26.1" @@ -6360,9 +6963,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -6375,9 +6978,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -6385,6 +6988,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -6606,7 +7219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -6785,7 +7398,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -6974,6 +7587,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -7028,15 +7652,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -7070,30 +7685,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -7115,12 +7713,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -7133,12 +7725,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -7151,24 +7737,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -7181,12 +7755,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -7199,12 +7767,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -7217,12 +7779,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -7235,17 +7791,10 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -7311,21 +7860,70 @@ dependencies = [ ] [[package]] -name = "winresource" -version = "0.1.31" +name = "winreg" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml 1.1.6+spec-1.1.0", "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -7402,7 +8000,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.9.0" +version = "0.13.0" dependencies = [ "filmcraft-testkit", "serde_json", @@ -7598,6 +8196,43 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "litemap", + "serde_core", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zip" version = "7.2.0" @@ -7607,7 +8242,24 @@ dependencies = [ "crc32fast", "indexmap", "memchr", - "typed-path", + "typed-path 0.12.3", +] + +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path 0.13.0", + "windows-sys 0.61.2", + "zopfli", ] [[package]] @@ -7622,6 +8274,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" @@ -7677,3 +8341,23 @@ dependencies = [ "syn 3.0.6", "winnow 1.0.4", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 625558e91..e3efcac8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.9.0" +version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -67,17 +67,24 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" proptest = "1" roxmltree = "0.21" -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", default-features = false } # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.36" -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["serde", "native-windows"] } +# egui 0.37: bundled fonts are opt-in, and the monochrome symbol/emoji faces moved to their own +# feature. FilmCraft paints ✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑ in panel chrome, so both features are part of the +# production font policy (system_fonts stays off). +egui = { version = "0.37", features = ["default_fonts", "monochrome_emoji_fonts"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] } rfd = "0.17" [workspace.lints.rust] @@ -90,11 +97,15 @@ too_many_arguments = "allow" needless_range_loop = "allow" type_complexity = "allow" +# Line tables keep file and line in panics and backtraces at a fraction of full debug info's size; +# dependencies need none. [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" # Codec hot loops: debug-build tests (RFC/oracle vectors) are minutes slower below opt-level 3. [profile.dev.package.filmcraft-h264] @@ -146,3 +157,21 @@ opt-level = 3 [profile.release] lto = "thin" codegen-units = 1 + +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 221a0acc6..92f248744 100644 --- a/NOTICE +++ b/NOTICE @@ -17,5 +17,270 @@ Noto Serif font (assets/fonts/NotoSerif-Regular.ttf) — OFL-1.1. See assets/fon JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-JetBrainsMono.txt. Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho, -BIZ UDMincho) — OFL-1.1. Their licence texts ship with the packages as OFL-.txt. +BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian) — OFL-1.1. Their licence texts ship with the packages as OFL-.txt. Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md. + + +egui icon font subset (dependency asset) +---------------------------------------- +epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of +emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback +uses the same MIT license. Upstream license text follows unchanged. + +MIT License + +Copyright (c) 2014 John Slegers + +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. + +Noto Emoji font (dependency asset) +---------------------------------- +epaint_default_fonts 0.37, pinned with egui 0.37 at +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts, +embeds NotoEmoji-Regular.ttf, the monochrome emoji fallback used when egui's +`monochrome_emoji_fonts` feature is enabled. The font's copyright notice and the +upstream SIL Open Font License 1.1 text follow unchanged. + +Copyright 2013 Google Inc. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Ubuntu Light font (dependency asset) +------------------------------------ +epaint_default_fonts 0.37, pinned with egui 0.37 at +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts, +embeds Ubuntu-Light.ttf as a default fallback. The font's copyright notice and the +upstream Ubuntu Font Licence 1.0 text follow unchanged. + +Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0 + +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + +Hack font (dependency asset) +---------------------------- +epaint_default_fonts 0.37, pinned with egui 0.37 at +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts, +embeds Hack-Regular.ttf as the default monospace fallback. The upstream notice and +licences follow unchanged. + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +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. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. diff --git a/README.md b/README.md index daff93b38..95fb0a8c2 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,29 @@

+

+ FilmCraft icon: an engraved owl on a purple tile +

+

FilmCraft

- Video editing, color and sound; an open-source, clean-room reimplementation of Adobe Premiere Pro, rebuilt in pure Rust. + Video editing, color and sound; an open-source, professional non-linear video editor, built in pure Rust.

- An open-source, clean-room take on the Adobe Premiere Pro workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.
+ An open-source, modern non-linear video editing workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.
By the ArtCraft team.

- License: MIT OR Apache-2.0 - Written in pure Rust - Runs on macOS, Windows and Linux - Status: young and moving fast + License: MIT OR Apache-2.0 + Written in pure Rust + Runs on macOS, Windows and Linux + Status: young and moving fast +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -39,7 +46,7 @@

- FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with Lumetri and Scale keyframes on the shot, the Lumetri Color panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters + FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with color grading and Scale keyframes on the shot, the color grading panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters

Apollo 11 - Tranquility: a three-minute documentary edit of NASA's 1969 launch, landing and moonwalk film, with subtitles from the mission transcript. Every frame in these screenshots comes from public-domain footage, decoded, composited and graded by FilmCraft's own code.

@@ -66,7 +73,7 @@
-FilmCraft is a non-linear editor for people who know Premiere: the same panels, workspaces, tools and shortcuts, so your hands already know where everything is. Underneath, it is new from the bitstream up. The H.264, HEVC, ProRes and AAC codecs are our own, written in Rust from the public specifications. A GPU compositor works in linear light. Frame math runs on exact integer time, so edits never drift. And every action in the app is a command that an AI agent can drive as precisely as you can. +FilmCraft is a professional non-linear editor: familiar panels, workspaces, tools and shortcuts, so your hands already know where everything is. Underneath, it is new from the bitstream up. The H.264, HEVC, ProRes and AAC codecs are our own, written in Rust from the public specifications. A GPU compositor works in linear light. Frame math runs on exact integer time, so edits never drift. And every action in the app is a command that an AI agent can drive as precisely as you can.
@@ -84,7 +91,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels, - **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step. - **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate. - **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel. -- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts. +- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft and standard NLE key layouts. - **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.

@@ -103,7 +110,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,

The Color workspace on Charade (1963), with live scopes beside the Program monitor.

-**A complete Lumetri-style grading panel**, in the order a colourist works: +**A complete color grading panel**, in the order a colourist works: - **Basic Correction:** temperature and tint, exposure, contrast, highlights, shadows, whites, blacks, saturation. - **Creative:** eight built-in looks with an intensity control, plus faded film, sharpen and vibrance. The looks are colour transforms written in code, not LUT files. @@ -115,11 +122,11 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels, @@ -130,14 +137,14 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels, ## Effects and motion

- Effects workspace: Effect Controls with Motion, Opacity, Time Remapping and Lumetri Color, NASA's Earth Views from the ISS in the Program monitor, and the Effects browser open on the Dissolve transitions + Effects workspace: Effect Controls with Motion, Opacity, Time Remapping and a colour grade, NASA's Earth Views from the ISS in the Program monitor, and the Effects browser open on the Dissolve transitions

NASA's Earth Views from the ISS with a look applied, and the Effects browser open on the dissolves.

-- **Premiere 26's full Video Effects bin (93 effects in 16 folders), the Legacy bin and the obsolete effects old projects use, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more. +- **A comprehensive Video Effects bin (over 90 effects across 16 categories), legacy effects, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more. - **Motion and opacity on every clip:** position, scale, rotation, anchor point and anti-flicker, plus 26 blend modes. -- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one. +- **Advanced keyframing:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one. - **A GPU compositor** built on wgpu (Metal, Vulkan, DirectX 12, WebGPU). It samples YUV straight from the decoder with footprint supersampling and blends in linear light. A CPU path renders the same frames, and the two are tested against each other.

@@ -179,13 +186,13 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **VP9** | ✓ | | Profiles 0–3, 8/10/12-bit, 4:2:0 to 4:4:4, tiles and superframes; bit-exact on 50+ streams; in WebM/Matroska and MP4 | | **Opus** | ✓ | | SILK, CELT, hybrid and multistream surround; range-exact on every RFC 8251 conformance vector; in WebM/Matroska and MP4 | | **AV1** | ✓ | | Main profile, 8/10-bit 4:2:0 and monochrome, every coding tool incl. film grain, superres and spatial layers; bit-exact with libdav1d on the libaom vectors tested; in MP4 and WebM/Matroska | -| **Avid DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV | +| **DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV | | **MJPEG, PCM** | ✓ | ✓ | | | **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own | | **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks | | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | | **AVI** | ✓ | | AVI 1.0 and OpenDML (files past 1 GB); Motion JPEG, H.264, HEVC and uncompressed RGB / YUV with PCM, MP3, MP2 or AC-3. DivX / Xvid and DV not yet | -| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF and APNG as clips, with transparency); export PNG sequences and animated GIF | +| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF, APNG and WebP as clips, with transparency); export PNG sequences and animated GIF | Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)). @@ -200,7 +207,7 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran

Export mode, set to write the trailer as H.264 MP4.

- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes in ffmpeg without a single warning, with strict error detection on. -- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV. +- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and standard OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV. - **Background jobs** with progress and cancel, so you keep editing while it renders. - **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back. @@ -210,11 +217,11 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran Move timelines between FilmCraft and every other editor: -- **Final Cut Pro 7 XML (xmeml):** the format Premiere and DaVinci Resolve exchange. It carries nested sequences, transitions, generators, Motion, Opacity, Time Remap and audio levels, with keyframes. +- **FCP 7 XML (xmeml):** industry-standard timeline interchange format. It carries nested sequences, transitions, generators, Motion, Opacity, Time Remap and audio levels, with keyframes. - **FCPXML 1.9–1.11:** the spine, connected clips as lanes, transitions, retiming and compound clips. - **OpenTimelineIO:** the open interchange format of the film industry, round-tripping every FilmCraft detail through `metadata.filmcraft`. - **CMX 3600 EDL:** the oldest format still in use, with drop-frame timecode, dissolves, wipes, speed changes (`M2`) and one EDL per track. -- **AAF (Edit Protocol):** for Avid Media Composer and Pro Tools. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio. +- **AAF (Edit Protocol):** standard timeline and audio interchange format. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio. - **OMF 2.0:** the audio-post handoff: the audio tracks with crossfades and gain, sample-accurate, with the audio encapsulated or alongside. Import merges the document's bins, media and sequences into your project as one undoable step, and links each media file it finds on disk. @@ -223,7 +230,7 @@ Import merges the document's bins, media and sequences into your project as one ## Built for agents -Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots. +Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with more commands continuously added. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots. ```jsonc // over the control channel (JSON lines on TCP) or as MCP tool calls @@ -277,22 +284,22 @@ The control protocol is documented in [docs/control-protocol.md](docs/control-pr | [docs/project-files.md](docs/project-files.md) | `.fcproj` format, schema migrations, auto-save and crash recovery | | [docs/graphics.md](docs/graphics.md) · [docs/captions.md](docs/captions.md) | Text engine, graphic clips and tools; caption tracks and formats | | [ROADMAP.md](ROADMAP.md) | Stage, parity numbers, languages, what's next and the progress log | -| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment against Premiere Pro, the ranked list of shortfalls, milestones and current focus | +| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment, the ranked list of shortfalls, milestones and current focus | | [docs/codec-parity.md](docs/codec-parity.md) · [docs/file-format-parity.md](docs/file-format-parity.md) · [docs/hardware-parity.md](docs/hardware-parity.md) · [docs/ui-parity.md](docs/ui-parity.md) · [docs/localization-parity.md](docs/localization-parity.md) | Parity checklists by dimension | ## Status FilmCraft is young and moving fast. Editing, trimming, multicam, colour, keyframes, effects, titles, captions, mixing, codecs and export work today. -FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10 against Premiere Pro 26.5.2): +FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10): -- **Feature breadth: ~86%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured). -- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects. +- **Feature breadth: ~86%.** Professional NLE menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured). +- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to handling full production work on real projects. The biggest gaps today: -- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). -- **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO. +- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but color grading, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). +- **Native `.prproj` project import.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO. - **No plugins.** No VST3 / Audio Units or OpenFX hosting. - **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. - **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS. @@ -455,15 +462,13 @@ with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Builds **Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md). -FilmCraft is an independent implementation. It contains no Adobe code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle. +FilmCraft is an independent implementation. It contains no proprietary code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle. The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as part of this repository and FilmCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. FilmCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

diff --git a/ROADMAP.md b/ROADMAP.md index 91fc63a58..4225d8f21 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ **Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (language table: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ProRes alpha final-run conformance and native macOS validation) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in [docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration), @@ -87,7 +87,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings | Italian | partial (whole interface; native review welcome) | ~100% | | Korean | partial (whole interface; needs an installed Hangul font; native review welcome) | ~100% | | Japanese | partial (whole interface) | 99.7% | -| German | menus only (native-reviewed) | 10.6% | +| German | partial (whole interface; menus native-reviewed, the rest welcome) | ~100% | | Vietnamese | none | 0% | Also shipped: Swedish (whole interface; native review welcome, ~100%) and Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md). @@ -107,6 +107,8 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g ## Progress log +- **2026-10-22:** ProRes 4444 / XQ alpha slices include the final singleton run code required by RDD 36:2022. Six HD fixtures rejected by native macOS decoding now pass; syntax regression tests cover final runs and slice widths ([details](crates/prores/README.md#alpha-bitstream-conformance), EffectCraft #293). + - **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088) - **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`). - **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)). @@ -166,6 +168,8 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | ProRes alpha: write the final singleton run code; 4444/XQ HD clips that failed native macOS decoding now decode, with syntax regression tests (EffectCraft #293) | +| 2026-10-13 | minor | Language table: German covers the whole interface (~100%) | | 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) | | 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..706f5f247 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,37 @@ +# Security policy + +FilmCraft processes untrusted media, projects, interchange files, fonts and presets, +and provides CLI, control-channel and MCP automation. Security reports about those +inputs, automation boundaries, dependencies or release artifacts are welcome. + +## Reporting a vulnerability + +GitHub private vulnerability reporting is not currently enabled for this repository, +and no dedicated private security address is documented. Contact the maintainers +through the [project's existing community channels](https://discord.gg/artcraft) +with a minimal, non-exploitable description and ask for a private transfer method. +Do not publish a working exploit, malicious attachment, credential, private project, +or sensitive crash dump in a public issue or chat. Wait for a private channel before +sharing those details; if none is established, withhold sensitive material. + +A useful report includes the FilmCraft version or commit, operating system and +architecture, affected component, impact, reproduction steps, and any workaround. +For media or project parsing problems, initially describe the file format, size, +cryptographic hash and whether the sample is synthetic or privately owned. Remove +personal paths, credentials and unrelated project content from logs. Coordinate +sample transfer with a maintainer instead of uploading a malicious file publicly. + +Please allow maintainers time to investigate and coordinate a fix before disclosing +exploitable details. This policy does not promise a response deadline or a particular +supported-version window. Include the exact affected version and, when safe to do +so, whether the problem also occurs on the latest release. + +## Regression samples + +A regression fixture must be minimized, legally redistributable, free of private +content, and documented with the expected safe failure. Follow [AGENTS.md](AGENTS.md) +for clean-room implementation, asset attribution and the no-crash rules. Large media +fixtures belong in the project's approved corpus workflow rather than ordinary PRs. + +Ordinary feature requests, editing problems and usage questions should use the +normal issue forms or community help channel. diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index b57f1888b..cfc04fc3b 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -69,7 +69,9 @@ USAGE SUBCOMMANDS exec [key=value ...] run one command; prints its result as JSON exec '' same, params as one JSON object - run run commands, one {\"id\",\"params\"} per line (# comments ok) + run run commands, one {\"id\",\"params\"} per line (# comments ok), in + order: file.exportMedia waits for its file before the next line + (and before exit) unless its params say \"wait\": false commands [filter] [--json] list commands (id, label, shortcut, params) describe one command as JSON (menu, shortcut, params, enabled now) inspect [project|sequence] project tree or active sequence as JSON (default: both) @@ -135,6 +137,25 @@ fn usage(msg: impl std::fmt::Display) -> ! { std::process::exit(2) } +/// Read a file off the runtime thread: `cli` is async because MCP, bridge calls and control +/// commands await, so a blocking `std::fs` read would stall every other task on that thread. +async fn read_file(path: impl Into) -> std::io::Result> { + let path = path.into(); + tokio::task::spawn_blocking(move || std::fs::read(path)).await.map_err(std::io::Error::other)? +} + +/// `read_file` for UTF-8 text. +async fn read_text(path: impl Into) -> std::io::Result { + let path = path.into(); + tokio::task::spawn_blocking(move || std::fs::read_to_string(path)).await.map_err(std::io::Error::other)? +} + +/// Write a file off the runtime thread; see `read_file`. +async fn write_file(path: impl Into, data: Vec) -> std::io::Result<()> { + let path = path.into(); + tokio::task::spawn_blocking(move || std::fs::write(path, data)).await.map_err(std::io::Error::other)? +} + fn fail(msg: impl std::fmt::Display) -> ! { diag(format_args!("filmcraft-cli: {msg}")); std::process::exit(1) @@ -169,9 +190,21 @@ impl Backend { Backend::Local(Box::new(s)) } - async fn exec(&mut self, id: &str, params: Value) -> Result { + async fn exec(&mut self, id: &str, mut params: Value) -> Result { match self { - Backend::Local(s) => s.execute(id, params).map_err(|e| e.to_string()), + // this process exits after the command, so a background export would never be written: + // long commands wait unless the caller says otherwise (#2342) + Backend::Local(s) => { + if filmcraft_automation::long_job::LONG_COMMANDS.contains(&id) + && let Some(p) = params.as_object_mut() + { + // anything but an explicit `false` (missing, null, a typo'd value) waits + if p.get("wait").and_then(Value::as_bool) != Some(false) { + p.insert("wait".into(), json!(true)); + } + } + s.execute(id, params).map_err(|e| e.to_string()) + } // a blocking export runs as an app job polled by the client (#91, #92) Backend::Bridge(b) => b.execute(id, params).await.map_err(|e| e.to_string()), } @@ -293,7 +326,7 @@ async fn cli() { "bench-decode" => { let path = a.pos(1).unwrap_or_else(|| usage("bench-decode ")); let n: i64 = a.opt("--frames").and_then(|v| v.parse().ok()).unwrap_or(120); - let src = filmcraft_codecs::open_bytes(path, std::fs::read(path).unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e)); + let src = filmcraft_codecs::open_bytes(path, read_file(path).await.unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e)); let rate = src.info().frame_rate(); let t0 = std::time::Instant::now(); for f in 0..n { @@ -349,7 +382,7 @@ async fn cli() { let text = if script == "-" { std::io::read_to_string(std::io::stdin()).unwrap_or_else(|e| fail(e)) } else { - std::fs::read_to_string(script).unwrap_or_else(|e| fail(format!("{script}: {e}"))) + read_text(script).await.unwrap_or_else(|e| fail(format!("{script}: {e}"))) }; let mut b = Backend::open(&a); let mut failed = 0; @@ -473,7 +506,7 @@ async fn cli() { let (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); let dt = t0.elapsed(); let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e)); - std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}"))); + write_file(out, png).await.unwrap_or_else(|e| fail(format!("{out}: {e}"))); let offline: Vec = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect(); check_offline(&a, &offline, &format!("render: {out}")); diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0)); diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/it/cli.rs similarity index 94% rename from apps/filmcraft-cli/tests/cli.rs rename to apps/filmcraft-cli/tests/it/cli.rs index d5ded27fa..57db7ae47 100644 --- a/apps/filmcraft-cli/tests/cli.rs +++ b/apps/filmcraft-cli/tests/it/cli.rs @@ -3,7 +3,7 @@ use std::io::Write; use std::process::{Command, Output, Stdio}; -use serde_json::Value; +use serde_json::{Value, json}; fn cli(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).output().expect("spawn filmcraft-cli") @@ -150,6 +150,23 @@ fn export_wav_waits_for_job() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn run_waits_for_an_export_on_its_last_line() { + // #2342: `file.exportMedia` as the last line of a script is written before `run` exits + let dir = std::env::temp_dir().join(format!("fc-cli-run-x-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let out = dir.join("last.wav"); + // `"wait": null` waits too: only an explicit `false` doesn't + let script = json!({"id": "file.exportMedia", "params": {"path": out.to_str().unwrap(), "format": "wav", "wait": null}}).to_string(); + let mut child = + Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--demo", "run", "-"]).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn().unwrap(); + child.stdin.take().unwrap().write_all(format!("{script}\n").as_bytes()).unwrap(); + let o = child.wait_with_output().unwrap(); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stdout)); + assert!(std::fs::metadata(&out).map(|m| m.len() > 44).unwrap_or(false), "{}", String::from_utf8_lossy(&o.stdout)); + let _ = std::fs::remove_dir_all(&dir); +} + /// `export --preset`: a built-in preset by name (dash or en dash), a custom range, a user preset /// from `--data-dir`, `--list-presets`, `--queue`, and a clean failure for an unknown preset. #[test] diff --git a/apps/filmcraft-cli/tests/it/main.rs b/apps/filmcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..9bdd14068 --- /dev/null +++ b/apps/filmcraft-cli/tests/it/main.rs @@ -0,0 +1,2 @@ +mod cli; +mod workflows; diff --git a/apps/filmcraft-cli/tests/workflows.rs b/apps/filmcraft-cli/tests/it/workflows.rs similarity index 100% rename from apps/filmcraft-cli/tests/workflows.rs rename to apps/filmcraft-cli/tests/it/workflows.rs diff --git a/apps/filmcraft-web/src/download_pacing.rs b/apps/filmcraft-web/src/download_pacing.rs index 01f9f4212..944e74bd3 100644 --- a/apps/filmcraft-web/src/download_pacing.rs +++ b/apps/filmcraft-web/src/download_pacing.rs @@ -1,4 +1,4 @@ -//! Spacing of browser downloads (pure, so it is tested natively in `tests/download_pacing.rs`; the +//! Spacing of browser downloads (pure, so it is tested natively in `tests/it/download_pacing.rs`; the //! anchor clicks live in [`crate::fs::download`]). //! //! Chrome drops anchor-click downloads that start in one tight burst, so an image-sequence export diff --git a/apps/filmcraft-web/src/fs.rs b/apps/filmcraft-web/src/fs.rs index 416fbf9d7..7f715b4da 100644 --- a/apps/filmcraft-web/src/fs.rs +++ b/apps/filmcraft-web/src/fs.rs @@ -352,6 +352,24 @@ impl Services for WebServices { Ok(()) } + fn write_file_new(&self, path: &str, data: &[u8]) -> io::Result<()> { + if path.starts_with("/opfs/") { + return Err(io::Error::new(io::ErrorKind::Unsupported, "exclusive OPFS writes are unavailable")); + } + let hook = FS.with(|fs| { + let mut fs = fs.borrow_mut(); + if fs.entries.contains_key(path) { + return Err(io::Error::from(io::ErrorKind::AlreadyExists)); + } + fs.entries.insert(path.to_owned(), Entry::Mem(Arc::from(data))); + Ok(fs.on_write.clone()) + })?; + if let Some(h) = hook { + h(path, data); + } + Ok(()) + } + fn file_size(&self, path: &str) -> io::Result { Self::entry_reader(path).map(|r| r.len()) } @@ -406,6 +424,10 @@ impl Services for WebServices { } } +thread_local! { + static DOWNLOAD_CLOCK: web_time::Instant = web_time::Instant::now(); +} + /// Offer bytes as a browser download named `name`. Downloads are clicked at least /// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378). pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { @@ -416,7 +438,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { a.set_href(&url); a.set_download(file_name(name)); a.style().set_property("display", "none")?; - let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now())); + let now_ms = DOWNLOAD_CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0); + let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(now_ms)); let name = name.to_owned(); wasm_bindgen_futures::spawn_local(async move { if delay > 0.0 { @@ -435,3 +458,32 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { }); Ok(()) } + +#[cfg(test)] +mod exclusive_write_tests { + use super::*; + + #[test] + fn virtual_file_creation_is_exclusive_and_opfs_fails_closed() { + let host = WebServices; + let existing = "/files/collision-test.wav"; + let fresh = "/files/new-extraction-test.wav"; + FS.with(|fs| { + let mut fs = fs.borrow_mut(); + fs.on_write = None; + fs.entries.insert(existing.into(), Entry::Mem(Arc::from(&b"original"[..]))); + fs.entries.remove(fresh); + }); + assert_eq!(host.write_file_new(existing, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(host.read_file(existing).unwrap(), b"original"); + host.write_file_new(fresh, b"new").unwrap(); + assert_eq!(host.read_file(fresh).unwrap(), b"new"); + assert_eq!(host.write_file_new(fresh, b"again").unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(host.write_file_new("/opfs/media/new.wav", b"new").unwrap_err().kind(), io::ErrorKind::Unsupported); + FS.with(|fs| { + let mut fs = fs.borrow_mut(); + fs.entries.remove(existing); + fs.entries.remove(fresh); + }); + } +} diff --git a/apps/filmcraft-web/src/import.rs b/apps/filmcraft-web/src/import.rs index a075f9a93..44d293b75 100644 --- a/apps/filmcraft-web/src/import.rs +++ b/apps/filmcraft-web/src/import.rs @@ -264,7 +264,7 @@ pub fn install_hooks(app: &mut FilmcraftApp) { // Saving writes the file in memory and offers it as a download (`fs::WebServices`). app.hooks.pick_save = Some(Box::new(|name: &str| Some(format!("/projects/{name}")))); app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| { - let file = std::path::Path::new(name).file_name().map_or(name.into(), |n| n.to_string_lossy()); + let file = std::path::Path::new(name).file_name().map_or(name.into(), std::ffi::OsStr::to_string_lossy); Some(format!("/exports/{file}")) })); // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: the picker is async, so the diff --git a/apps/filmcraft-web/src/lib.rs b/apps/filmcraft-web/src/lib.rs index 21512ccc5..1110c64e4 100644 --- a/apps/filmcraft-web/src/lib.rs +++ b/apps/filmcraft-web/src/lib.rs @@ -94,12 +94,13 @@ impl eframe::App for WebApp { self.app.raw_input_hook(ctx, raw_input); } - fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + /// Hidden logic pass: host actions, app logic, API replies, jobs, audio, autosave, then IME reset; it does not draw UI. + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut())); for f in actions { f(self, ctx); } - self.app.logic(ctx, frame); + self.app.logic_inner(ctx); api::poll_replies(); // exports advance between frames (no threads) if self.app.session.pump_jobs(std::time::Duration::from_millis(30)) { @@ -109,6 +110,7 @@ impl eframe::App for WebApp { a.pump(); } self.autosave.tick(&self.app.session, ctx); + self.app.finish_ime_reset_logic(ctx); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -159,6 +161,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { canvas, web_options, Box::new(move |cc| { + filmcraft_ui_egui::install_ime_reset_callbacks(&cc.egui_ctx); set_context(cc.egui_ctx.clone()); let ctx = cc.egui_ctx.clone(); fs::set_on_data(move || ctx.request_repaint()); diff --git a/apps/filmcraft-web/src/recovery.rs b/apps/filmcraft-web/src/recovery.rs index 6001531e8..fc556013a 100644 --- a/apps/filmcraft-web/src/recovery.rs +++ b/apps/filmcraft-web/src/recovery.rs @@ -28,10 +28,18 @@ enum Outcome { Clean { ok: bool }, } -#[derive(Default)] pub struct Autosave { policy: Policy, outcomes: Rc>>, + /// Monotonic host clock. Web `logic` may run for hidden tabs; egui's input time is stale there. + clock: web_time::Instant, +} + +impl Default for Autosave { + /// Default policy with a monotonic host clock, so hidden-tab logic never uses a stale egui input time. + fn default() -> Self { + Self { policy: Policy::default(), outcomes: Rc::default(), clock: web_time::Instant::now() } + } } impl Autosave { @@ -40,7 +48,7 @@ impl Autosave { if !opfs::available() { return; } - let now = ctx.input(|i| i.time); + let now = self.clock.elapsed().as_secs_f64(); for o in self.outcomes.borrow_mut().drain(..) { match o { Outcome::Snapshot { revision, ok } => self.policy.written(revision, ok, now), @@ -64,7 +72,9 @@ impl Autosave { } }) }; - let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()}); + // No wall-clock timestamp: recovery policy scheduling uses the monotonic clock + // above, and the snapshot schema needs no display time. + let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision}); let d1 = done(self.outcomes.clone(), failed.clone(), left.clone(), ctx.clone()); let d2 = done(self.outcomes.clone(), failed, left, ctx.clone()); opfs::write_then(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false), Some(d1)); diff --git a/apps/filmcraft-web/src/recovery_policy.rs b/apps/filmcraft-web/src/recovery_policy.rs index 8426d3c39..7a2e0fc26 100644 --- a/apps/filmcraft-web/src/recovery_policy.rs +++ b/apps/filmcraft-web/src/recovery_policy.rs @@ -1,5 +1,5 @@ //! When the web recovery snapshot is written or marked clean (pure, so it is tested natively in -//! `tests/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]). +//! `tests/it/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]). //! //! - A dirty project is snapshotted at most every [`INTERVAL_S`] seconds, once per revision. A write //! that fails is retried after the interval, without waiting for another edit (#197). diff --git a/apps/filmcraft-web/src/webcodecs.rs b/apps/filmcraft-web/src/webcodecs.rs index 48fa7a1c8..48b1d3bad 100644 --- a/apps/filmcraft-web/src/webcodecs.rs +++ b/apps/filmcraft-web/src/webcodecs.rs @@ -68,8 +68,15 @@ thread_local! { static TRANSFERS: RefCell = RefCell::new(Transfers::default()); } +thread_local! { + /// Monotonic browser clock. `Date::now()` is wall time and can move backwards across NTP or + /// suspend/resume; codec idle/staleness accounting must not. + static CLOCK: web_time::Instant = web_time::Instant::now(); +} + +/// Milliseconds from a monotonic browser clock; codec idle and staleness accounting must never use wall time. fn now_ms() -> f64 { - js_sys::Date::now() + CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0) } fn get(o: &JsValue, k: &str) -> JsValue { diff --git a/apps/filmcraft-web/tests/download_pacing.rs b/apps/filmcraft-web/tests/it/download_pacing.rs similarity index 87% rename from apps/filmcraft-web/tests/download_pacing.rs rename to apps/filmcraft-web/tests/it/download_pacing.rs index 893f40d3f..4f2ee8d26 100644 --- a/apps/filmcraft-web/tests/download_pacing.rs +++ b/apps/filmcraft-web/tests/it/download_pacing.rs @@ -1,10 +1,10 @@ //! Download spacing. It is pure, so it runs natively even though the rest of the crate is //! wasm32-only. -#[path = "../src/download_pacing.rs"] -mod download_pacing; +#[path = "../../src/download_pacing.rs"] +mod pacing; -use download_pacing::{GAP_MS, Pacer}; +use pacing::{GAP_MS, Pacer}; /// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst. #[test] diff --git a/apps/filmcraft-web/tests/it/main.rs b/apps/filmcraft-web/tests/it/main.rs new file mode 100644 index 000000000..a223d161d --- /dev/null +++ b/apps/filmcraft-web/tests/it/main.rs @@ -0,0 +1,2 @@ +mod download_pacing; +mod recovery_policy; diff --git a/apps/filmcraft-web/tests/recovery_policy.rs b/apps/filmcraft-web/tests/it/recovery_policy.rs similarity index 96% rename from apps/filmcraft-web/tests/recovery_policy.rs rename to apps/filmcraft-web/tests/it/recovery_policy.rs index c60b34d5b..fe64599d7 100644 --- a/apps/filmcraft-web/tests/recovery_policy.rs +++ b/apps/filmcraft-web/tests/it/recovery_policy.rs @@ -1,10 +1,10 @@ //! The web recovery snapshot policy. It is pure, so it runs natively even though the rest of the //! crate is wasm32-only. -#[path = "../src/recovery_policy.rs"] -mod recovery_policy; +#[path = "../../src/recovery_policy.rs"] +mod policy; -use recovery_policy::{Action, INTERVAL_S, Policy}; +use policy::{Action, INTERVAL_S, Policy}; /// #197: a snapshot write that fails is retried after the interval without another edit. #[test] diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 3ae35e6c6..9e3f1cba8 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -27,14 +27,16 @@ filmcraft-gpu = { workspace = true } filmcraft-ui-egui = { workspace = true } filmcraft-platform = { workspace = true } egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] } serde_json = { workspace = true } +# Constant-time comparison of control-channel credentials. +subtle = "2.6" log = { workspace = true } rfd = { workspace = true } cpal = "0.16" # Files and images on the system clipboard for Paste (#611); egui already uses it for text. arboard = "3" -chrono = { version = "0.4", default-features = false, features = ["clock"] } +chrono = { workspace = true, features = ["clock"] } sys-locale = "0.3" # Help menu / About / Discord links in the system browser (MIT; as PhotoCraft). open = "5" @@ -42,7 +44,7 @@ open = "5" [dev-dependencies] glob = "0.3" # graphics.rs: enumerate the real adapters in a test. -pollster = "0.4" +pollster = "1" [target.'cfg(target_os = "macos")'.dependencies] muda = "0.21" @@ -52,6 +54,9 @@ objc2 = "0.6" # Same winit as eframe: macOS event-loop options (no app activation when driven by an agent). winit = { version = "0.30", default-features = false } +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +winit = { version = "0.30", default-features = false, features = ["x11"] } + [target.'cfg(target_os = "linux")'.dependencies] # Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the # portal file dialogs (rfd) and accesskit already use. @@ -60,3 +65,6 @@ zbus = "5.19" # Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows. [target.'cfg(windows)'.build-dependencies] winresource = "0.1" + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index c64e3509f..130e23ee2 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -9,7 +9,8 @@ Usage: filmcraft [options] [project.fcproj | media files...] Options: --control start the localhost JSON-lines control server - (also: FILMCRAFT_CONTROL_PORT) + (also: FILMCRAFT_CONTROL_PORT; requires FILMCRAFT_CONTROL_TOKEN, + 64 random hex characters; see docs/control-protocol.md) --control-input-only accept widget input only from the control server (requires --control or FILMCRAFT_CONTROL_PORT) --demo open the demo project diff --git a/apps/filmcraft/src/audio.rs b/apps/filmcraft/src/audio.rs index cb56e059e..a29596f81 100644 --- a/apps/filmcraft/src/audio.rs +++ b/apps/filmcraft/src/audio.rs @@ -51,11 +51,12 @@ enum Note { } impl Note { + /// Localized status text for a fallback device (name interpolated through `fmt`) or for no output device. fn text(&self) -> String { use filmcraft_ui_egui::i18n::{fmt, t}; match self { Note::Fallback(device) => fmt(t("Audio hardware: the default output device cannot be opened; using '{device}'"), &[("device", device)]), - Note::NoDevice => t("No audio output device found: playing without sound (check Settings ▸ Audio Hardware)").to_string(), + Note::NoDevice => t("No audio output device found: playing without sound (check Settings > Audio Hardware)").to_string(), } } } diff --git a/apps/filmcraft/src/control_server.rs b/apps/filmcraft/src/control_server.rs index d3b40f320..cba27a6b0 100644 --- a/apps/filmcraft/src/control_server.rs +++ b/apps/filmcraft/src/control_server.rs @@ -4,7 +4,9 @@ //! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets //! one error reply and the connection is closed, so nothing after it is executed. Lines are //! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk -//! never reaches the UI thread. See `docs/control-protocol.md`. +//! never reaches the UI thread. Every request must carry the session credential from +//! `FILMCRAFT_CONTROL_TOKEN`; without a configured credential the listener stays closed. +//! See `docs/control-protocol.md`. use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -15,15 +17,28 @@ use std::time::Duration; use filmcraft_ui_egui::ControlRequest; use serde_json::{Value, json}; +use subtle::ConstantTimeEq; /// Longest accepted request line (bytes, without the newline). Requests are small JSON objects; /// anything longer is refused and the connection closed. pub const MAX_LINE: usize = 4 * 1024 * 1024; /// Connections served at once; further ones get an error line and are closed. pub const MAX_CONNECTIONS: usize = 16; +/// A peer that stops sending or reading must eventually give back its connection slot. +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(120); pub fn start(port: u16, ctx: egui::Context) -> Receiver { + start_with_token(port, ctx, std::env::var("FILMCRAFT_CONTROL_TOKEN").ok()) +} + +fn start_with_token(port: u16, ctx: egui::Context, token: Option) -> Receiver { let (tx, rx) = channel::(); + // An inherited environment variable shares the credential with the owner's bridge process + // without exposing it in command-line arguments, logs, or a shared discovery file. + let Some(token) = token.filter(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())) else { + eprintln!("filmcraft: control server disabled: FILMCRAFT_CONTROL_TOKEN must contain 64 random hex characters (see docs/control-protocol.md)"); + return rx; + }; let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { @@ -32,11 +47,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { } }; eprintln!("filmcraft: control server listening on 127.0.0.1:{port}"); - std::thread::spawn(move || accept_loop(listener, tx, ctx)); + std::thread::spawn(move || accept_loop(listener, tx, ctx, token)); rx } -fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { +fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context, token: String) { let open = Arc::new(AtomicUsize::new(0)); for mut stream in listener.incoming().flatten() { if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { @@ -46,12 +61,13 @@ fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Con } let tx = tx.clone(); let ctx = ctx.clone(); + let token = token.clone(); // The slot is given back when the guard drops: after `serve` returns, if it panics, or if // the thread can't be started (the closure, and with it the guard, is dropped). let slot = Slot(Arc::clone(&open)); let spawned = std::thread::Builder::new().name("control-conn".into()).spawn(move || { let _slot = slot; - serve(stream, &tx, &ctx); + serve(stream, &tx, &ctx, &token); }); if let Err(e) = spawned { eprintln!("filmcraft: control server could not start a connection thread: {e}"); @@ -68,14 +84,23 @@ impl Drop for Slot { } } -fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context) { - let Ok(read) = stream.try_clone() else { return }; - serve_lines(read, stream, |method, params| { +fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context, token: &str) { + if let Err(e) = serve_with_timeout(stream, tx, ctx, token, CONNECTION_TIMEOUT) { + eprintln!("filmcraft: could not configure control connection: {e}"); + } +} + +fn serve_with_timeout(stream: TcpStream, tx: &Sender, ctx: &egui::Context, token: &str, timeout: Duration) -> std::io::Result<()> { + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + let read = stream.try_clone()?; + serve_lines(read, stream, token, |method, params| { let (req, rrx) = ControlRequest::new(method, params); tx.send(req).ok()?; ctx.request_repaint(); Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))) }); + Ok(()) } /// One parsed line. @@ -90,7 +115,7 @@ enum Line { Reject(String), } -fn parse_line(bytes: &[u8]) -> Line { +fn parse_line(bytes: &[u8], token: &str) -> Line { let Ok(text) = std::str::from_utf8(bytes) else { return Line::Reject("not a control request (invalid UTF-8); closing the connection".into()); }; @@ -108,16 +133,31 @@ fn parse_line(bytes: &[u8]) -> Line { let Some(method) = obj.get("method").and_then(Value::as_str) else { return Line::Reject("not a control request (missing string `method`); closing the connection".into()); }; + // Authenticate every request before constructing or enqueueing a ControlRequest. A valid + // earlier request must not authorize a later one with a missing or incorrect credential. + let authenticated = obj.get("token").and_then(Value::as_str).is_some_and(|supplied| bool::from(supplied.as_bytes().ct_eq(token.as_bytes()))); + if !authenticated { + return Line::Reject("control authentication failed; closing the connection".into()); + } Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) } } +#[derive(Debug, PartialEq, Eq)] +enum ReadFailure { + Timeout, + Rejected(String), +} + /// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). /// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. -fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { +fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { buf.clear(); // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); - let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?; + let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() { + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ReadFailure::Timeout, + _ => ReadFailure::Rejected(e.to_string()), + })?; if n == 0 { return Ok(false); } @@ -129,7 +169,7 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) - return Ok(true); } if buf.len() > max { - return Err(format!("request line longer than {max} bytes; closing the connection")); + return Err(ReadFailure::Rejected(format!("request line longer than {max} bytes; closing the connection"))); } // Last line without a newline, then end of stream. Ok(true) @@ -138,14 +178,17 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) - /// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is /// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and /// returns the reply object (the request's `id` is added here). -fn serve_lines(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option) { +fn serve_lines(read: R, mut out: W, token: &str, mut handle: impl FnMut(String, Value) -> Option) { let mut reader = BufReader::new(read); let mut buf = Vec::new(); loop { let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) { Ok(false) => return, - Err(e) => e, - Ok(true) => match parse_line(&buf) { + // Expiration is not a request: a pooled client must see EOF and reconnect, rather + // than read a stale error reply. A partial line must not be parsed on timeout. + Err(ReadFailure::Timeout) => return, + Err(ReadFailure::Rejected(e)) => e, + Ok(true) => match parse_line(&buf, token) { Line::Blank => continue, Line::Reject(e) => e, Line::Request { id, method, params } => { @@ -169,10 +212,12 @@ fn serve_lines(read: R, mut out: W, mut handle: impl FnMut(St mod tests { use super::*; + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + fn run(input: &[u8]) -> (Vec, Vec) { let mut out = Vec::new(); let mut calls = Vec::new(); - serve_lines(input, &mut out, |m, _p| { + serve_lines(input, &mut out, TOKEN, |m, _p| { calls.push(m); Some(json!({"ok": true, "result": null})) }); @@ -182,7 +227,10 @@ mod tests { #[test] fn requests_are_answered_in_order_with_ids() { - let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}"); + let input = format!( + "{{\"token\":\"{TOKEN}\",\"id\":1,\"method\":\"a\"}}\n\n{{\"token\":\"{TOKEN}\",\"id\":2,\"method\":\"b\",\"params\":{{}}}}\r\n{{\"token\":\"{TOKEN}\",\"method\":\"c\"}}" + ); + let (r, calls) = run(input.as_bytes()); assert_eq!(calls, ["a", "b", "c"]); assert_eq!(r.len(), 3); assert_eq!(r[0]["id"], 1); @@ -220,7 +268,8 @@ mod tests { #[test] fn a_request_before_junk_still_runs() { - let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n"); + let input = format!("{{\"token\":\"{TOKEN}\",\"method\":\"a\"}}\nGET / HTTP/1.1\n{{\"token\":\"{TOKEN}\",\"method\":\"b\"}}\n"); + let (r, calls) = run(input.as_bytes()); assert_eq!(calls, ["a"]); assert_eq!(r.len(), 2); assert_eq!(r[1]["ok"], false); @@ -256,11 +305,11 @@ mod tests { let server = std::thread::spawn(move || { let (s, _) = listener.accept().unwrap(); let read = s.try_clone().unwrap(); - serve_lines(read, s, |_, _| Some(json!({"ok": true}))); + serve_lines(read, s, TOKEN, |_, _| Some(json!({"ok": true}))); }); let mut c = TcpStream::connect(addr).unwrap(); c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); + c.write_all(format!("{{\"token\":\"{TOKEN}\",\"id\":7,\"method\":\"x\"}}\nhello\n{{\"token\":\"{TOKEN}\",\"method\":\"y\"}}\n").as_bytes()).unwrap(); let mut text = String::new(); c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection server.join().unwrap(); @@ -275,7 +324,7 @@ mod tests { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let addr = listener.local_addr().unwrap(); let (tx, _rx) = channel::(); - std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default())); + std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default(), TOKEN.into())); // Idle connections are accepted in order and held open. let held: Vec = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect(); let mut extra = TcpStream::connect(addr).unwrap(); @@ -285,4 +334,200 @@ mod tests { assert!(line.contains("too many control connections"), "{line}"); drop(held); } + + /// Idle and unterminated requests must release a counted connection without client EOF. + fn timed_out_connection_releases_slot(partial: &[u8]) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let open = Arc::new(AtomicUsize::new(1)); + let counted = Arc::clone(&open); + let (tx, rx) = channel::(); + let (done, finished) = channel(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + { + let _slot = Slot(counted); + serve_with_timeout(stream, &tx, &egui::Context::default(), TOKEN, Duration::from_millis(100)).unwrap(); + } + done.send(()).unwrap(); + }); + let mut client = TcpStream::connect(addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + client.write_all(partial).unwrap(); + let completion = finished.recv_timeout(Duration::from_secs(5)); + let mut unsolicited = String::new(); + if completion.is_ok() { + client.read_to_string(&mut unsolicited).unwrap(); + } + // Always unblock and join the test worker before asserting, even on the unfixed server. + let unexpected_command = rx.try_recv().is_ok(); + drop(rx); + drop(client); + server.join().unwrap(); + assert!(completion.is_ok(), "an idle peer held its connection slot until the client closed"); + assert!(unsolicited.is_empty(), "expiration must not leave a stale reply for a pooled client: {unsolicited}"); + assert_eq!(open.load(Ordering::SeqCst), 0); + assert!(!unexpected_command, "an incomplete request must not reach the UI queue"); + } + + #[test] + /// A silent unauthenticated peer must not retain control-server capacity. + fn idle_connection_releases_its_slot() { + timed_out_connection_releases_slot(b""); + } + + #[test] + /// Sending a partial JSON line must not bypass idle expiration. + fn unterminated_request_releases_its_slot() { + timed_out_connection_releases_slot(b"{\"method\":"); + } + + #[test] + /// A timeout is not EOF: valid JSON without its newline must not be dispatched. + fn valid_unterminated_request_is_not_dispatched() { + let request = json!({"id": 1, "token": TOKEN, "method": "ui.inspect"}).to_string(); + timed_out_connection_releases_slot(request.as_bytes()); + } + + #[test] + /// Waiting for the UI is separate from socket idleness; buffered requests remain answerable. + fn idle_timeout_does_not_cancel_a_pending_ui_reply() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + serve_with_timeout(stream, &tx, &egui::Context::default(), TOKEN, Duration::from_millis(100)).unwrap(); + }); + let mut client = TcpStream::connect(addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut read = BufReader::new(client.try_clone().unwrap()); + // Buffer complete requests, as the bridge does. Queue both before waiting for replies: + // client scheduling after reply 1 must not create a genuine idle gap in this test. + let requests: String = [1, 2].into_iter().map(|id| format!("{}\n", json!({"token": TOKEN, "id": id, "method": "ui.inspect"}))).collect(); + client.write_all(requests.as_bytes()).unwrap(); + for id in [1, 2] { + let request = rx.recv_timeout(Duration::from_secs(5)).unwrap_or_else(|e| panic!("request {id} did not reach the UI: {e}")); + // Deliberately exceed the read timeout while the server is awaiting a UI reply. + std::thread::sleep(Duration::from_millis(250)); + request.reply.send(json!({"ok": true})).unwrap(); + let mut line = String::new(); + read.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap(), json!({"id": id, "ok": true})); + } + client.shutdown(std::net::Shutdown::Write).unwrap(); + server.join().unwrap(); + } + + #[test] + /// An active client can send another request after consuming its previous reply. + fn active_connection_can_be_reused() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + serve_with_timeout(stream, &tx, &egui::Context::default(), TOKEN, Duration::from_secs(5)).unwrap(); + }); + let mut client = TcpStream::connect(addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut read = BufReader::new(client.try_clone().unwrap()); + for id in [1, 2] { + let line = format!("{}\n", json!({"token": TOKEN, "id": id, "method": "ui.inspect"})); + client.write_all(line.as_bytes()).unwrap(); + rx.recv_timeout(Duration::from_secs(5)).unwrap().reply.send(json!({"ok": true})).unwrap(); + let mut reply = String::new(); + read.read_line(&mut reply).unwrap(); + assert_eq!(serde_json::from_str::(&reply).unwrap(), json!({"id": id, "ok": true})); + } + client.shutdown(std::net::Shutdown::Write).unwrap(); + server.join().unwrap(); + } + + #[test] + fn missing_or_invalid_token_leaves_the_listener_closed() { + for token in [None, Some(String::new()), Some("a".repeat(63)), Some("a".repeat(65)), Some("z".repeat(64))] { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let rx = start_with_token(port, egui::Context::default(), token); + assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected))); + let _still_available = TcpListener::bind(("127.0.0.1", port)).unwrap(); + } + } + + #[test] + fn unauthenticated_save_as_closes_before_any_command_runs() { + for token in + [Value::Null, json!(false), json!(42), json!([]), json!({}), json!(""), json!("a".repeat(64)), json!(&TOKEN[..63]), json!(format!("{TOKEN}0"))] + { + let mut request = json!({"id": 1, "method": "engine.execute", "params": {"command": "file.saveAs", "params": {"path": "/owner/project.fcproj"}}}); + if !token.is_null() { + request["token"] = token; + } + // A valid request after a rejected one must never execute, either. + let input = format!("{request}\n{}\n", json!({"token": TOKEN, "method": "ui.inspect"})); + let (replies, calls) = run(input.as_bytes()); + assert!(calls.is_empty()); + assert_eq!(replies, [json!({"ok": false, "error": "control authentication failed; closing the connection"})]); + } + } + + #[test] + fn authentication_is_required_on_every_request() { + let input = format!( + "{}\n{}\n{}\n", + json!({"token": TOKEN, "method": "ui.inspect"}), + json!({"method": "app.quit"}), + json!({"token": TOKEN, "method": "app.quit"}) + ); + let (replies, calls) = run(input.as_bytes()); + assert_eq!(calls, ["ui.inspect"]); + assert_eq!(replies.len(), 2); + assert_eq!(replies[1]["ok"], false); + } + + #[test] + fn tcp_authentication_happens_before_enqueueing() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + serve(s, &tx, &egui::Context::default(), TOKEN); + }); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(b"{\"method\":\"engine.execute\",\"params\":{\"command\":\"file.saveAs\",\"params\":{\"path\":\"/owner/project.fcproj\"}}}\n").unwrap(); + c.shutdown(std::net::Shutdown::Write).unwrap(); + let mut reply = String::new(); + c.read_to_string(&mut reply).unwrap(); + server.join().unwrap(); + assert!(reply.contains("control authentication failed")); + assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected))); + } + + #[test] + fn authenticated_tcp_request_reaches_the_ui_queue() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + serve(s, &tx, &egui::Context::default(), TOKEN); + }); + let params = json!({"command": "file.saveAs", "params": {"path": "/owner/project.fcproj"}}); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + writeln!(c, "{}", json!({"id": 7, "token": TOKEN, "method": "engine.execute", "params": params})).unwrap(); + c.shutdown(std::net::Shutdown::Write).unwrap(); + let request = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(request.method, "engine.execute"); + assert_eq!(request.params, params); + request.reply.send(json!({"ok": true})).unwrap(); + let mut reply = String::new(); + c.read_to_string(&mut reply).unwrap(); + server.join().unwrap(); + assert_eq!(serde_json::from_str::(&reply).unwrap(), json!({"id": 7, "ok": true})); + } } diff --git a/apps/filmcraft/src/dev_icon.rs b/apps/filmcraft/src/dev_icon.rs index 8c9876794..b14af1505 100644 --- a/apps/filmcraft/src/dev_icon.rs +++ b/apps/filmcraft/src/dev_icon.rs @@ -215,6 +215,8 @@ fn desktop_file_content(app_id: &str, name: &str, exe: &str) -> String { out.push_str("StartupNotify=true\n"); out.push_str(&format!("StartupWMClass={app_id}\n")); out.push_str("Categories=AudioVideo;Video;AudioVideoEditing;\n"); + out.push_str("MimeType=application/x-filmcraft;video/mp4;video/x-m4v;video/quicktime;video/x-matroska;video/webm;"); + out.push_str("video/vnd.avi;video/x-msvideo;application/mxf;video/mp2t;video/mpeg;video/3gpp;\n"); out.push_str("Keywords=video;editor;film;timeline;color;grading;nle;\n"); out } diff --git a/apps/filmcraft/src/logging.rs b/apps/filmcraft/src/logging.rs index 3d7c713fb..006b7f884 100644 --- a/apps/filmcraft/src/logging.rs +++ b/apps/filmcraft/src/logging.rs @@ -97,29 +97,35 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock +/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved) +/// instead of panicking. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd -/// clock can't overflow). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years +/// (146_097 days), so month, day and time of day come from chrono and only the year is carried, +/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is +/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -390,6 +396,17 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than + // collapsing to the epoch (reviewer FredApps). Windows' SystemTime can't hold such an + // instant, so it is built with a checked add and only asserted where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index f850b1638..f296c1c39 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -8,6 +8,7 @@ //! (the demo project), Open Most Recent, or an empty project. //! //! `--control ` (or `FILMCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server; +//! `FILMCRAFT_CONTROL_TOKEN` is required (see `docs/control-protocol.md`). //! see `filmcraft_ui_egui::control` for the methods. A port that is not a number is an error from //! either source. //! @@ -77,6 +78,11 @@ fn tell(text: &str, error: bool) { } fn main() -> eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FILMCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so the panic hook and every start-up record are logged (`logging`). let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored @@ -189,6 +195,7 @@ fn native_options(agent: bool) -> eframe::NativeOptions { fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc) -> eframe::AppCreator<'static> { let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch; Box::new(move |cc| { + filmcraft_ui_egui::install_ime_reset_callbacks(&cc.egui_ctx); created.store(true, Ordering::Relaxed); // A fallback graphics mode says so in the window, with a button back to the default. let notice = graphics.degraded().then(|| { @@ -247,6 +254,7 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc Option { b.with_activate_ignoring_other_apps(false); })); } + // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file + // manager showed a "no" cursor. Run through XWayland when it's there; FILMCRAFT_WAYLAND=1 + // keeps the native Wayland backend. + #[cfg(all(unix, not(target_os = "macos")))] + if std::env::var_os("DISPLAY").is_some() && std::env::var_os("FILMCRAFT_WAYLAND").is_none() { + return Some(Box::new(|b| { + use winit::platform::x11::EventLoopBuilderExtX11; + b.with_x11(); + })); + } let _ = agent; None } diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 07a47648c..c404c59e3 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -19,6 +19,15 @@ fn accel(s: &str) -> Option { mapped.parse().ok() } +/// The app menu's Quit item: the `app.quit` command (which asks to save unsaved changes) on its +/// shortcut, Cmd+Q unless remapped in the Keyboard Shortcuts editor. The key equivalent is read +/// once, when the menu bar is built: `app.quit` has no menu path, so it is not in the menu tree the +/// updater receives, and a remap shows on the Quit item after the next launch. +fn quit_item(app: &FilmcraftApp) -> (&'static str, Option) { + let keys = app.session.shortcuts.primary("app.quit").unwrap_or_else(|| "Cmd+Q".into()); + ("app.quit", accel(&keys)) +} + /// Updates native item labels, key equivalents and enablement when the menu tree changes. pub type MenuUpdater = Box; @@ -53,9 +62,12 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Men let predefined = [ (hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))), (hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))), - (quit_label, PredefinedMenuItem::quit(Some(app.ui.language.tr(quit_label)))), ]; - let [(_, hide), (_, hide_others), (_, quit)] = &predefined; + let [(_, hide), (_, hide_others)] = &predefined; + // Quit is ours, not the predefined item: that one calls `terminate:` and skips the unsaved + // changes prompt (#1943); the `app.quit` command closes the window through it + let (quit_id, quit_accel) = quit_item(app); + let quit = MenuItem::with_id(quit_id, app.ui.language.tr(quit_label), true, quit_accel); let _ = app_menu.append_items(&[ &about, &discord, @@ -65,7 +77,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Men hide, hide_others, &PredefinedMenuItem::separator(), - quit, + &quit, ]); let _ = bar.append(&app_menu); for top in MENUS { @@ -149,6 +161,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Men for (label, item) in &predefined { item.set_text(language.tr(label)); } + quit.set_text(language.tr(quit_label)); for (shown, mi) in &mut native { let Some(it) = items.iter().find(|i| i.id == shown.id) else { continue }; if it.label != shown.label { @@ -166,3 +179,20 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Men }); (rx, update) } + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_engine::Session; + + /// #1943: Cmd+Q must reach the `app.quit` command, which closes the window through the unsaved + /// changes prompt, instead of the predefined Quit item's `terminate:`. + #[test] + fn quit_item_runs_the_app_quit_command_on_cmd_q() { + let app = FilmcraftApp::new(Session::default()); + let (id, accelerator) = quit_item(&app); + assert_eq!(id, "app.quit"); + assert_eq!(accelerator, accel("Cmd+Q")); + assert!(accelerator.is_some(), "Cmd+Q is a native key equivalent"); + } +} diff --git a/crates/aac/tests/common/mod.rs b/crates/aac/tests/it/common/mod.rs similarity index 100% rename from crates/aac/tests/common/mod.rs rename to crates/aac/tests/it/common/mod.rs diff --git a/crates/aac/tests/decoder_oracle.rs b/crates/aac/tests/it/decoder_oracle.rs similarity index 99% rename from crates/aac/tests/decoder_oracle.rs rename to crates/aac/tests/it/decoder_oracle.rs index 721b94486..b5591a56a 100644 --- a/crates/aac/tests/decoder_oracle.rs +++ b/crates/aac/tests/it/decoder_oracle.rs @@ -1,7 +1,7 @@ //! Decoder accuracy against ffmpeg's decoder on streams produced by ffmpeg's AAC encoder (oracle //! only; skipped when ffmpeg is absent). -mod common; +use crate::common; use common::*; diff --git a/crates/aac/tests/encoder_oracle.rs b/crates/aac/tests/it/encoder_oracle.rs similarity index 99% rename from crates/aac/tests/encoder_oracle.rs rename to crates/aac/tests/it/encoder_oracle.rs index 3cc312ace..d73abdbb8 100644 --- a/crates/aac/tests/encoder_oracle.rs +++ b/crates/aac/tests/it/encoder_oracle.rs @@ -1,6 +1,6 @@ //! Encoder quality/bitrate tests using ffmpeg as an external decoding oracle (skipped if absent). -mod common; +use crate::common; use common::*; use filmcraft_aac::{BitrateMode, EncoderConfig, WindowShape}; diff --git a/crates/aac/tests/it/main.rs b/crates/aac/tests/it/main.rs new file mode 100644 index 000000000..6f4d1e3bd --- /dev/null +++ b/crates/aac/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod decoder_oracle; +mod encoder_oracle; +mod robustness; diff --git a/crates/aac/tests/robustness.rs b/crates/aac/tests/it/robustness.rs similarity index 99% rename from crates/aac/tests/robustness.rs rename to crates/aac/tests/it/robustness.rs index 9c1bb9ea0..fd0e83ee6 100644 --- a/crates/aac/tests/robustness.rs +++ b/crates/aac/tests/it/robustness.rs @@ -1,7 +1,7 @@ //! Fuzz-style robustness: mutated access units, configs and ADTS streams must never panic, and the //! encoder must accept hostile input (NaN, ±inf, > full scale, silence, odd chunk sizes). -mod common; +use crate::common; use common::*; use filmcraft_aac::{AudioSpecificConfig, Decoder, Encoder, EncoderConfig, split_adts}; diff --git a/crates/apv/README.md b/crates/apv/README.md index e84c9f5f8..906e4b4ae 100644 --- a/crates/apv/README.md +++ b/crates/apv/README.md @@ -54,7 +54,7 @@ let apvc: Vec = enc.decoder_config_record(); // APVDecoderConfigurationReco - **Metadata (§8)**: Parses `metadata_mdcv` (`payloadType == 5`, Mastering Display Color Volume) and `metadata_cll` (`payloadType == 6`, Content Light-Level Information). - **Robustness (§10)**: All size fields, tile coordinates, VLC codeword lengths, and coefficient - ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/fuzz.rs` + ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/it/fuzz.rs` across thousands of mutated and random inputs. ### Encoder @@ -67,7 +67,7 @@ let apvc: Vec = enc.decoder_config_record(); // APVDecoderConfigurationReco ## Accuracy (vs FFmpeg as an External Oracle) -`tests/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple +`tests/it/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple tiles, custom asymmetric quantization matrices, and HDR metadata PBUs, then decodes the resulting `.apv` streams with `ffmpeg -v error -xerror -f apv` and compares against `filmcraft_apv::decode_frame`. Because APV specifies an exact integer inverse transform, **`filmcraft-apv` is 100% bit-exact diff --git a/crates/apv/src/decode.rs b/crates/apv/src/decode.rs index 997aaa0b4..910ea5207 100644 --- a/crates/apv/src/decode.rs +++ b/crates/apv/src/decode.rs @@ -377,7 +377,20 @@ fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[ let blocks_x = blk_w / 8; let blocks_y = blk_h / 8; - let mut comp_buf = vec![0u16; tw_c * th_c]; + // Every coded 8x8 block needs at least one bit for its DC code. Validate + // that the component can describe every required block before allocating + // its complete reconstructed plane. + let block_count = (num_mb_cols as usize) + .checked_mul(num_mb_rows as usize) + .and_then(|n| n.checked_mul(blocks_x)) + .and_then(|n| n.checked_mul(blocks_y)) + .ok_or(Error::CorruptTile(tile_idx))?; + if comp_bytes.len() < block_count.div_ceil(8) { + return Err(Error::CorruptTile(tile_idx)); + } + let samples = tw_c.checked_mul(th_c).ok_or(Error::CorruptTile(tile_idx))?; + + let mut comp_buf = vec![0u16; samples]; let mut r = BitReader::new(comp_bytes); let mut prev_dc: i32 = 0; diff --git a/crates/apv/tests/fuzz.rs b/crates/apv/tests/it/fuzz.rs similarity index 100% rename from crates/apv/tests/fuzz.rs rename to crates/apv/tests/it/fuzz.rs diff --git a/crates/apv/tests/it/fuzz_hostile.rs b/crates/apv/tests/it/fuzz_hostile.rs new file mode 100644 index 000000000..bb02fdd07 --- /dev/null +++ b/crates/apv/tests/it/fuzz_hostile.rs @@ -0,0 +1,108 @@ +//! Hostile-input robustness: every header byte set to extreme values, every bit flipped, +//! truncation at (nearly) every length must never panic or run away (errors are fine). + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use filmcraft_apv::*; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +fn seeds() -> Vec> { + let mut v = Vec::new(); + for (p, w, h) in [(Profile::P422_10, 64u32, 48u32), (Profile::P444_10, 48, 32), (Profile::P4444_10, 32, 32), (Profile::P400_10, 32, 32)] { + let chroma = p.default_chroma(); + let depth = p.default_bit_depth(); + let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444); + for (i, s) in f.y.iter_mut().enumerate() { + *s = (64 + (i * 37) % 800) as u16; + } + let mut cfg = EncoderConfig::new(p, w, h); + cfg.qp = Some(16); + let Ok(mut enc) = Encoder::with_config(cfg) else { continue }; + if let Ok(d) = enc.encode(&f) { + v.push(d); + } + if let Ok(d) = enc.encode_raw_au(&f) { + v.push(d); + } + } + v +} + +fn run(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + let res = catch_unwind(AssertUnwindSafe(|| { + for depth in [None, Some(8), Some(16)] { + let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() }); + } + let _ = probe(d); + let _ = split_raw_bitstream(d); + })); + assert!(res.is_ok(), "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +#[test] +fn hostile_frames_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let seeds = seeds(); + assert!(!seeds.is_empty()); + for (si, seed) in seeds.iter().enumerate() { + let stride = (seed.len() / 400).max(1); + for n in (0..seed.len()).step_by(stride) { + run(&seed[..n], &format!("seed {si} truncate {n}")); + } + for p in 0..seed.len().min(256) { + for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + let mut d = seed.clone(); + d[p] = v; + run(&d, &format!("seed {si} byte {p}={v:#x}")); + } + for b in 0..8 { + let mut d = seed.clone(); + d[p] ^= 1 << b; + run(&d, &format!("seed {si} flip {p}.{b}")); + } + } + // Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions). + for p in 0..seed.len().min(128) { + for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] { + let mut d = seed.clone(); + if let Some(s) = d.get_mut(p..p + 4) { + s.copy_from_slice(&v.to_be_bytes()); + } + run(&d, &format!("seed {si} u32 {p}={v:#x}")); + } + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64); + let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000); + for i in 0..iters { + let mut d = seed.clone(); + for _ in 0..1 + rng.below(8) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + run(&d, &format!("seed {si} random {i}")); + } + } +} diff --git a/crates/apv/tests/it/main.rs b/crates/apv/tests/it/main.rs new file mode 100644 index 000000000..51c79839a --- /dev/null +++ b/crates/apv/tests/it/main.rs @@ -0,0 +1,3 @@ +mod fuzz; +mod fuzz_hostile; +mod oracle; diff --git a/crates/apv/tests/oracle.rs b/crates/apv/tests/it/oracle.rs similarity index 100% rename from crates/apv/tests/oracle.rs rename to crates/apv/tests/it/oracle.rs diff --git a/crates/audio-dsp/src/effects/mod.rs b/crates/audio-dsp/src/effects/mod.rs index 1a6f02a33..9a6f781cc 100644 --- a/crates/audio-dsp/src/effects/mod.rs +++ b/crates/audio-dsp/src/effects/mod.rs @@ -271,31 +271,37 @@ mod tests { #[test] fn extreme_settings_stay_finite_and_denormal_free() { - for e in effects() { - for setting in [0.0f32, 1.0] { - let mut fx = (e.create)(SR, 2); - for p in e.params { - fx.set_param(p.id, if setting == 0.0 { p.min } else { p.max }); - } - let mut l = noise(3, 24000, 4.0); - let mut r = noise(4, 24000, 4.0); - l[100] = 1e6; - r[200] = -1e6; - fx.process(&mut [&mut l, &mut r]); - assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{} setting {setting}", e.id); - // Long silence afterwards: output decays without denormals. - fx.reset(); - let mut l = noise(3, 4800, 0.5); - let mut r = noise(4, 4800, 0.5); - fx.process(&mut [&mut l, &mut r]); - for _ in 0..40 { - let mut l = vec![0.0f32; 48000]; - let mut r = vec![0.0f32; 48000]; - fx.process(&mut [&mut l, &mut r]); - assert!(l.iter().chain(&r).all(|v| v.is_finite() && !v.is_subnormal()), "{} setting {setting}: denormal/NaN in tail", e.id); - } + // One thread per effect. The tail stays 40 s long: delays, reverbs and graphic EQs still + // produce output in its last second, some of it within 3x of the smallest normal f32. + std::thread::scope(|scope| { + for e in effects() { + scope.spawn(move || { + for setting in [0.0f32, 1.0] { + let mut fx = (e.create)(SR, 2); + for p in e.params { + fx.set_param(p.id, if setting == 0.0 { p.min } else { p.max }); + } + let mut l = noise(3, 24000, 4.0); + let mut r = noise(4, 24000, 4.0); + l[100] = 1e6; + r[200] = -1e6; + fx.process(&mut [&mut l, &mut r]); + assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{} setting {setting}", e.id); + // Long silence afterwards: output decays without denormals. + fx.reset(); + let mut l = noise(3, 4800, 0.5); + let mut r = noise(4, 4800, 0.5); + fx.process(&mut [&mut l, &mut r]); + for _ in 0..40 { + let mut l = vec![0.0f32; 48000]; + let mut r = vec![0.0f32; 48000]; + fx.process(&mut [&mut l, &mut r]); + assert!(l.iter().chain(&r).all(|v| v.is_finite() && !v.is_subnormal()), "{} setting {setting}: denormal/NaN in tail", e.id); + } + } + }); } - } + }); } #[test] diff --git a/crates/audio-dsp/src/remix.rs b/crates/audio-dsp/src/remix.rs index 78d4ea8b1..52b1de990 100644 --- a/crates/audio-dsp/src/remix.rs +++ b/crates/audio-dsp/src/remix.rs @@ -37,6 +37,13 @@ pub const CONTEXT: usize = 4; pub const MIN_PIECE_BEATS: usize = 4; /// Crossfade at each joint (seconds). pub const XFADE_S: f64 = 0.02; +/// Long enough for a complete music cue while bounding the audio decoded and analysed by Remix. +pub const MAX_ANALYSIS_SECONDS: u64 = 10 * 60; +/// A dense similarity matrix is useful for planning, but must remain a bounded allocation. +const MAX_SIMILARITY_CELLS: usize = 4_000_000; +/// Remix analysis supports ordinary production and high-resolution audio rates, not arbitrary +/// integers supplied through a public library API. +pub const MAX_ANALYSIS_SAMPLE_RATE: u32 = 384_000; /// One piece of the output: `len` samples read from the source at `src`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -139,6 +146,12 @@ impl Analysis { /// Why music cannot be remixed. #[derive(Clone, Debug, PartialEq, Eq)] pub enum RemixError { + /// The source sample rate is zero or above the supported analysis limit. + InvalidSampleRate, + /// The source would exceed the bounded Remix analysis duration. + TooLong, + /// The detected beat grid would need too much similarity storage or work. + TooComplex, /// Not enough rhythmic content to find a beat (silence, noise, speech). NoBeat, /// The source has fewer beats than a remix needs. @@ -150,6 +163,9 @@ pub enum RemixError { impl std::fmt::Display for RemixError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { + RemixError::InvalidSampleRate => "the audio sample rate is not supported for remix analysis", + RemixError::TooLong => "the music is too long to remix", + RemixError::TooComplex => "the music has too many beats to remix", RemixError::NoBeat => "no beat found in the audio", RemixError::TooShort => "the music is too short to remix", RemixError::TargetTooShort => "the target duration is too short for this music", @@ -188,7 +204,8 @@ fn stft_mag(x: &[f32], n: usize, hop: usize) -> Vec> { /// STFT frame for the onset envelope: the power of two nearest to 46 ms (1024 at 22.05 kHz, 2048 /// at 44.1/48 kHz). fn onset_frame(sr: u32) -> usize { - let n = (sr as f64 * 0.046).max(256.0); + // `onset_envelope` is public on its own, so it must remain bounded even outside `analyze`. + let n = (sr.min(MAX_ANALYSIS_SAMPLE_RATE) as f64 * 0.046).max(256.0); 1usize << n.log2().round() as u32 } @@ -344,31 +361,34 @@ fn beat_features(x: &[f32], sr: u32, beats: &[i64]) -> (Vec<[f32; 12]>, Vec<[f32 } frame_cep.push(cep); } - let mut chroma = Vec::with_capacity(beats.len()); - let mut cep = Vec::with_capacity(beats.len()); + // Frames and beats are both ordered. Assign every frame once instead of scanning the entire + // feature timeline for every beat interval. + let mut chroma = vec![[0f32; 12]; beats.len()]; + let mut cep = vec![[0f32; 12]; beats.len()]; + let mut counts = vec![0f32; beats.len()]; let end = x.len() as i64; - for (i, &b) in beats.iter().enumerate() { - let e = beats.get(i + 1).copied().unwrap_or(end); - let (mut c, mut q, mut cnt) = ([0f32; 12], [0f32; 12], 0f32); - for (t, (fc, fq)) in frame_chroma.iter().zip(&frame_cep).enumerate() { - let centre = (t * hop + n / 2) as i64; - if centre >= b && centre < e { - for k in 0..12 { - c[k] += fc[k]; - q[k] += fq[k]; - } - cnt += 1.0; - } + let mut beat = 0; + for (t, (fc, fq)) in frame_chroma.iter().zip(&frame_cep).enumerate() { + let centre = (t * hop + n / 2) as i64; + while beat + 1 < beats.len() && centre >= beats[beat + 1] { + beat += 1; } - if cnt > 0.0 { - q.iter_mut().for_each(|v| *v /= cnt); + if centre >= beats[beat] && centre < end { + for k in 0..12 { + chroma[beat][k] += fc[k]; + cep[beat][k] += fq[k]; + } + counts[beat] += 1.0; + } + } + for ((c, q), count) in chroma.iter_mut().zip(&mut cep).zip(counts) { + if count > 0.0 { + q.iter_mut().for_each(|v| *v /= count); } let norm = c.iter().map(|v| v * v).sum::().sqrt(); if norm > 0.0 { c.iter_mut().for_each(|v| *v /= norm); } - chroma.push(c); - cep.push(q); } // z-score the cepstra across beats let m = cep.len().max(1) as f32; @@ -380,6 +400,10 @@ fn beat_features(x: &[f32], sr: u32, beats: &[i64]) -> (Vec<[f32; 12]>, Vec<[f32 (chroma, cep) } +fn similarity_cells(beats: usize) -> Result { + beats.checked_mul(beats).filter(|&n| n <= MAX_SIMILARITY_CELLS).ok_or(RemixError::TooComplex) +} + fn cos(a: &[f32; 12], b: &[f32; 12]) -> f32 { let d: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); let na = a.iter().map(|v| v * v).sum::().sqrt(); @@ -398,7 +422,13 @@ fn local_max(env: &[f32], t: usize, r: usize) -> f32 { /// Analyse planar audio (channels are summed to mono). pub fn analyze(channels: &[&[f32]], sample_rate: u32) -> Result { + if sample_rate == 0 || sample_rate > MAX_ANALYSIS_SAMPLE_RATE { + return Err(RemixError::InvalidSampleRate); + } let len = channels.iter().map(|c| c.len()).max().unwrap_or(0); + if (len as u64) > u64::from(sample_rate).saturating_mul(MAX_ANALYSIS_SECONDS) { + return Err(RemixError::TooLong); + } let k = 1.0 / channels.len().max(1) as f32; let mono: Vec = (0..len).map(|i| channels.iter().map(|c| c.get(i).copied().unwrap_or(0.0)).sum::() * k).collect(); let (env, n, hop) = onset_envelope(&mono, sample_rate); @@ -434,7 +464,7 @@ pub fn analyze(channels: &[&[f32]], sample_rate: u32) -> Result, } +/// Maximum source frames fetched in a single resampler pull. +pub const MAX_RESAMPLE_SOURCE_SPAN: usize = 384_000 * 10; + impl StreamResampler { /// Rates of zero are treated as 1 Hz rather than dividing by zero. pub fn new(from: u32, to: u32) -> Self { @@ -53,7 +56,7 @@ impl StreamResampler { } let held_end = self.held_start + self.held.first().map_or(0, Vec::len) as i64; if last + 1 > held_end { - let want = (last + 1 - held_end) as usize; + let want = ((last + 1 - held_end) as usize).min(MAX_RESAMPLE_SOURCE_SPAN); let got = pull(held_end, want); if self.held.is_empty() { self.held = vec![Vec::new(); got.len().max(1)]; diff --git a/crates/audio-dsp/src/sync.rs b/crates/audio-dsp/src/sync.rs index 73b6e0e82..c4bc007b7 100644 --- a/crates/audio-dsp/src/sync.rs +++ b/crates/audio-dsp/src/sync.rs @@ -77,9 +77,13 @@ pub fn mixdown(channels: &[&[f32]]) -> Vec { /// Low-pass filter (Blackman-windowed sinc, cutoff at 0.42 × the new sample rate) and keep every /// `d`-th sample. pub fn decimate(x: &[f32], d: usize) -> Vec { - if d <= 1 { + if x.is_empty() || d <= 1 { return x.to_vec(); } + // Factors above the input length cannot produce another output sample. Cap the useful factor + // as well as the filter width so a public caller cannot turn a tiny input into a huge plan. + const MAX_FACTOR: usize = 4096; + let d = d.min(x.len()).min(MAX_FACTOR); let half = 8 * d; let fc = 0.42 / d as f64; // cycles per input sample let taps: Vec = { @@ -396,6 +400,13 @@ mod tests { assert!(rh < 0.01, "{rh}"); } + #[test] + fn decimate_does_not_plan_a_filter_for_empty_or_extreme_requests() { + assert!(decimate(&[], usize::MAX).is_empty()); + let tiny = [1.0, -1.0]; + assert_eq!(decimate(&tiny, usize::MAX), decimate(&tiny, tiny.len())); + } + #[test] fn pure_delay_exact_both_signs() { let sr = 48_000; diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 14b090e7d..29121042e 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -16,7 +16,8 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } -base64 = "0.22" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time", "fs"] } +base64 = "0.23" schemars = "1" image = { version = "0.25", default-features = false, features = ["png"] } +tempfile = "3" diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs index f51db9adf..032e4e657 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -20,6 +20,8 @@ type Conn = (BufReader, tokio::net::tcp::OwnedWr pub struct BridgeClient { addr: String, + /// Inherited from the same owner-controlled environment as the desktop app. + token: Option, /// Connections with no request in flight. A request takes one (or opens one) for its whole /// round trip and puts it back only once the reply is read, so a slow or abandoned request /// never holds up the others or leaves its late reply for the next one to read (#1538). @@ -28,14 +30,15 @@ pub struct BridgeClient { } impl BridgeClient { - /// `addr` such as `127.0.0.1:9876` (loopback only). + /// `addr` such as `127.0.0.1:9876` (loopback only). Reads `FILMCRAFT_CONTROL_TOKEN` + /// from the environment; the desktop app requires this credential on every request. pub fn new(addr: impl Into) -> Result { let addr = addr.into(); let host = addr.rsplit_once(':').map(|(h, _)| h).unwrap_or(&addr); if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") { return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`"))); } - Ok(Self { addr, idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) }) + Ok(Self { addr, token: std::env::var("FILMCRAFT_CONTROL_TOKEN").ok(), idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) }) } /// Run engine command `id` in the app. A blocking export (`file.exportMedia` with @@ -85,7 +88,7 @@ impl BridgeClient { None => self.connect().await?, }; let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let line = format!("{}\n", json!({"id": id, "method": method, "params": params})); + let line = format!("{}\n", json!({"id": id, "token": self.token, "method": method, "params": params})); let res: Result = async { conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?; let mut buf = String::new(); @@ -282,4 +285,19 @@ mod tests { assert_eq!(seen.len(), 2); assert_eq!(seen[1]["params"]["command"], "sequence.inspect"); } + + #[tokio::test] + async fn credential_is_sent_on_every_request() { + let (addr, seen) = fake_app(Value::Null).await; + let mut b = BridgeClient::new(addr).unwrap(); + // Inject directly to avoid mutating the process environment in parallel tests. + let token = "0123456789abcdef".repeat(4); + b.token = Some(token.clone()); + b.call("ui.inspect", json!({})).await.unwrap(); + b.execute("file.saveAs", json!({"path": "/owner/project.fcproj"})).await.unwrap(); + let seen = seen.lock().await; + assert_eq!(seen.len(), 2); + assert!(seen.iter().all(|request| request["token"] == token)); + assert_eq!(seen[1]["params"]["params"]["path"], "/owner/project.fcproj"); + } } diff --git a/crates/automation/src/long_job.rs b/crates/automation/src/long_job.rs index bdc756cf5..87fb7e825 100644 --- a/crates/automation/src/long_job.rs +++ b/crates/automation/src/long_job.rs @@ -4,6 +4,7 @@ //! `notifications/cancelled`, deleting the partial output. The session lock is only held for //! short polls, so other requests are answered while the export runs. +use std::ffi::OsStr; use std::path::Path; use std::time::{Duration, SystemTime}; @@ -118,10 +119,10 @@ impl FilmcraftMcp { /// never touched, even when they were saved during the export. pub fn remove_partial(path: &str, since: SystemTime) { let p = Path::new(path); - let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else { + let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(OsStr::to_str), p.file_name().and_then(OsStr::to_str)) else { return; }; - let ext = p.extension().and_then(|s| s.to_str()).unwrap_or_default(); + let ext = p.extension().and_then(OsStr::to_str).unwrap_or_default(); let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; let Ok(entries) = std::fs::read_dir(dir) else { return }; // file times are coarser than the clock diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 0aebbc31d..33f5dc653 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -108,7 +108,7 @@ pub struct ClickParams { /// left | right | middle #[serde(default)] pub button: Option, - /// 2 for double-click. + /// Number of clicks (0–10; defaults to 1). Use 2 for double-click. Larger values are rejected. #[serde(default)] pub count: Option, /// {"shift":bool,"alt":bool,"command":bool,"ctrl":bool} @@ -122,6 +122,7 @@ pub struct DragParams { pub from: Value, /// End point, same forms. pub to: Value, + /// Movement steps (0–1024; defaults to 12; 0 or 1 uses 2). Larger values are rejected. #[serde(default)] pub steps: Option, #[serde(default)] @@ -278,9 +279,22 @@ impl FilmcraftMcp { } /// The project tree plus the active sequence (null when there is none). + /// + /// `project.inspect` and `sequence.inspect` are two separate calls (two lock scopes in + /// headless mode, two round trips to the app in bridge mode), so the active sequence can + /// change between them if another client, or the app's own UI, switches sequence tabs in + /// that window (#1477). Pinning `sequence.inspect` to the `activeSequence` id this + /// `project.inspect` just reported keeps the pair consistent: the returned `sequence`, when + /// not null, always describes the sequence the returned `project.activeSequence` names. async fn document(&self) -> Result { let project = self.run("project.inspect", json!({})).await?; - let sequence = self.run("sequence.inspect", json!({})).await.ok(); + // `ItemId` is a u64 (`item_p` parses the `item` param with `u64_p`): read it as one too, + // not `as_i64`, so an id above `i64::MAX` is still looked up instead of silently going null. + let active = project.get("activeSequence").and_then(Value::as_u64); + let sequence = match active { + Some(id) => self.run("sequence.inspect", json!({"item": id})).await.ok(), + None => None, + }; Ok(json!({"project": project, "sequence": sequence})) } @@ -484,6 +498,7 @@ impl FilmcraftMcp { self.ui("ui.elements", json!({"prefix": p.prefix.unwrap_or_default()})).await } + /// Forward a click to the live editor; an omitted count is sent as null and uses its default. #[tool( title = "Live app: click", description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.", @@ -500,6 +515,7 @@ impl FilmcraftMcp { self.ui("ui.click", v).await } + /// Forward a drag to the live editor, which applies the step default and validates its bounds. #[tool( title = "Live app: drag", description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks).", @@ -551,15 +567,21 @@ impl FilmcraftMcp { impl FilmcraftMcp { async fn screenshot(&self, b: &BridgeClient, panel: Option, max: u32) -> Result { - let path = std::env::temp_dir().join(format!("filmcraft-mcp-{}.png", std::process::id())); + // The desktop bridge needs a pathname, so use a freshly-created, + // owner-only directory rather than exposing image bytes at a + // predictable shared-temp filename. + let dir = match tempfile::tempdir() { + Ok(dir) => dir, + Err(e) => return Ok(fail(format!("screenshot: {e}"))), + }; + let path = dir.path().join("screenshot.png"); if let Err(e) = b.call("ui.screenshot", json!({"path": path.to_string_lossy(), "panel": panel})).await { return Ok(fail(e)); } - let bytes = match std::fs::read(&path) { + let bytes = match tokio::fs::read(&path).await { Ok(b) => b, Err(e) => return Ok(fail(format!("screenshot: {e}"))), }; - let _ = std::fs::remove_file(&path); let bytes = match image::load_from_memory(&bytes) { Ok(img) => { let rgba = img.to_rgba8(); @@ -998,4 +1020,73 @@ mod tests { let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap(); assert_ne!(r.is_error, Some(true)); } + + /// `doc_inspect` composes `project.inspect` and `sequence.inspect` as two separate round + /// trips to the app. If the active sequence changes between them (another client, or the + /// app's own UI, switching tabs in that window), an unscoped `sequence.inspect` answers for + /// whichever sequence is active *now*, not the one `project.inspect` just reported, so the + /// pair comes back inconsistent (#1477, reproduced with a real bridge client and a flipping + /// `sequence.open` in `race_test.py`: ~300/300 calls mismatched). Pinning the second call to + /// the id the first one reported keeps `sequence`, when not null, always describing the + /// sequence `project.activeSequence` names. + #[tokio::test] + async fn doc_inspect_pins_the_sequence_to_the_reported_active_id() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + tokio::spawn(async move { + let (s, _) = listener.accept().await.unwrap(); + let (r, mut w) = s.into_split(); + let mut lines = tokio::io::BufReader::new(r).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let req: Value = serde_json::from_str(&line).unwrap(); + let result = match req["params"]["command"].as_str().unwrap_or_default() { + "project.inspect" => json!({"activeSequence": 21}), + // The app's own active sequence has since moved on to 54; only an explicit + // `item` still gets 21's data (an unscoped call would wrongly answer for 54). + "sequence.inspect" => json!({"id": req["params"]["params"]["item"].as_i64().unwrap_or(54)}), + _ => Value::Null, + }; + let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result})); + w.write_all(line.as_bytes()).await.unwrap(); + } + }); + let m = FilmcraftMcp::bridge(&addr).unwrap(); + let v = m.document().await.unwrap(); + assert_eq!(v["project"]["activeSequence"], json!(21), "{v}"); + assert_eq!(v["sequence"]["id"], json!(21), "sequence must describe the reported active id, not whatever is active now: {v}"); + } + + /// `activeSequence` is a `u64` (`ItemId`'s representation; `item_p` parses the `item` param + /// with `u64_p`), so an id above `i64::MAX` must still round-trip through `document()`: + /// reading it with `as_i64` would silently return `None` and make `sequence` go null instead + /// of inspecting the (valid) id project.inspect reported. + #[tokio::test] + async fn doc_inspect_handles_an_active_sequence_id_above_i64_max() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + const HUGE: u64 = i64::MAX as u64 + 1; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + tokio::spawn(async move { + let (s, _) = listener.accept().await.unwrap(); + let (r, mut w) = s.into_split(); + let mut lines = tokio::io::BufReader::new(r).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let req: Value = serde_json::from_str(&line).unwrap(); + let result = match req["params"]["command"].as_str().unwrap_or_default() { + "project.inspect" => json!({"activeSequence": HUGE}), + // Echoes back whatever `item` it was actually asked for, so the test can + // check that `document()` asked for `HUGE` rather than nothing at all. + "sequence.inspect" => json!({"id": req["params"]["params"]["item"]}), + _ => Value::Null, + }; + let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result})); + w.write_all(line.as_bytes()).await.unwrap(); + } + }); + let m = FilmcraftMcp::bridge(&addr).unwrap(); + let v = m.document().await.unwrap(); + assert_eq!(v["project"]["activeSequence"].as_u64(), Some(HUGE), "{v}"); + assert_eq!(v["sequence"]["id"].as_u64(), Some(HUGE), "an id above i64::MAX must still be inspected, not dropped to a null sequence: {v}"); + } } diff --git a/crates/av1/README.md b/crates/av1/README.md index 354484158..8d29c1eeb 100644 --- a/crates/av1/README.md +++ b/crates/av1/README.md @@ -43,12 +43,12 @@ reference) is used only as an external test oracle. ## Accuracy -`tests/oracle_intra.rs` encodes all-intra streams with ffmpeg's libsvtav1 (with and without the +`tests/it/oracle_intra.rs` encodes all-intra streams with ffmpeg's libsvtav1 (with and without the in-loop filters; 64×64 and 128×128 superblocks; intra edge filter on and off) and compares every decoded frame with libdav1d: **bit-exact** on all 13 fixtures (8- and 10-bit 4:2:0; 128×128 to 1280×720 including odd sizes; testsrc2, mandelbrot, gradients and noise; presets 1–8). -`tests/conformance.rs` downloads libaom's conformance test vectors on first use +`tests/it/conformance.rs` downloads libaom's conformance test vectors on first use (storage.googleapis.com/aom-test-data) and compares with libdav1d: `av1-1-b8-02-allintra` (39 frames, deblocking + CDEF + self-guided restoration), `05-mv`, `06-mfmv`, `24-monochrome` and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored @@ -58,8 +58,8 @@ and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored with scaled inter-layer prediction), `24-monochrome` (8 and 10-bit) and `16-intra_only-intrabc-extreme-dv` (1080p intra block copy), `23-film_grain-50` (8 and 10-bit). -`tests/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with -libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction, +`tests/it/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with +libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/it/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction, OBMC / warped motion, motion-field projection, all loop filters; presets 3–8, 8- and 10-bit, odd sizes), plus super-resolution (denominator 12) and film-grain streams, and compares every frame with libdav1d: **bit-exact** on all 6 fixtures. @@ -76,13 +76,13 @@ independent). Results never depend on scheduling (the oracle tests compare 1 thr and libdav1d). With frame threads, pictures may be returned by a later `decode_pts` call or by `flush`; each carries the `pts` of the temporal unit that showed it. -`tests/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame +`tests/it/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame (`Decoder::stats()`) and all-core fps with its per-stage ms/frame for 1080p fixtures (key frames, GOPs, tiles, low delay) and a single-tile 2160p GOP; `AV1_PERF_ONLY=a,b` picks fixtures. The hot loops (sub-pixel filters, CDEF, inverse transforms, deblocking, entropy decoding) are written for auto-vectorisation. Measured 2026-10-01 on a 14-core Apple Silicon Mac (release build, SVT-AV1 preset 8 1080p -fixtures from `tests/perf.rs`), before (`31f8b3d`: hot loops only, no threading) and after tile / frame / +fixtures from `tests/it/perf.rs`), before (`31f8b3d`: hot loops only, no threading) and after tile / frame / post-filter threading. The machine was heavily loaded by other builds during both runs (load average 200–400), so the all-core wall figures understate what an idle machine reaches and are noisy; single-threaded figures use thread CPU time and are comparable. @@ -125,6 +125,13 @@ motion vectors nor CDFs reach another frame — skip deblocking, CDEF and loop r (super-resolution still runs, it sets the output size) and are flagged `Picture::draft`. In SVT-AV1's hierarchical GOPs that is the top layer, half of the frames. +## Limits + +Frames beyond the AV1 level 6.x limits (Annex A: at most 16384 wide, 8704 high and 35 651 584 luma samples +in total, so 16384 x 2176 or 8192 x 4352 at most) are refused with `Error::Invalid("frame size")`. The sequence +header can code up to 65536 x 65536, which would ask for gigabytes of buffers from a few bytes of hostile input; +no real stream comes close to level 6.3, the highest defined level. + ## API ```rust diff --git a/crates/av1/src/decoder.rs b/crates/av1/src/decoder.rs index 66c966986..c9f487d95 100644 --- a/crates/av1/src/decoder.rs +++ b/crates/av1/src/decoder.rs @@ -23,6 +23,17 @@ use crate::stats::{DecodeStats, Stage, Timer}; use crate::tile::TileDecoder; use crate::{Error, Picture, Result}; +/// Largest accepted frame: the AV1 level 6.x limits (Annex A: MaxHSize 16384, MaxVSize 8704, MaxPicSize +/// 35 651 584 luma samples; level 6.3 is the highest defined). The sequence header allows 65536 x 65536 +/// (gigabytes of buffers), so nothing larger than a level 6.3 frame is decoded. +const MAX_FRAME_WIDTH: u32 = 16384; +const MAX_FRAME_HEIGHT: u32 = 8704; +const MAX_FRAME_AREA: u64 = 35_651_584; + +/// Whether a frame of `width` x `height` luma samples is within the decoder's resource limits. +fn frame_size_allowed(width: u32, height: u32) -> bool { + width <= MAX_FRAME_WIDTH && height <= MAX_FRAME_HEIGHT && u64::from(width) * u64::from(height) <= MAX_FRAME_AREA +} const OBU_SEQUENCE_HEADER_T: u32 = 1; const OBU_TEMPORAL_DELIMITER_T: u32 = 2; const OBU_FRAME_HEADER_T: u32 = 3; @@ -92,6 +103,8 @@ struct FrameJob { ref_info: [RefInfo; NUM_REF_FRAMES], /// Tile payloads (tile number, data). tiles: Vec<(usize, Vec)>, + /// Each tile may be supplied by exactly one tile group. + tile_covered: Vec, slot: Arc, /// The output picture, when the frame is shown. picture: Option>>>, @@ -505,7 +518,7 @@ impl Decoder { } // Encoders exist that code frames slightly larger than the sequence maximum (e.g. a // 270-line stream coded as 272 lines); like other decoders we accept them. - if fh.upscaled_width > 65536 || fh.frame_height > 65536 { + if !frame_size_allowed(fh.upscaled_width, fh.frame_height) { return Err(Error::Invalid("frame size")); } if !fh.frame_is_intra { @@ -520,12 +533,14 @@ impl Decoder { } let picture = fh.show_frame.then(Slot::new); let draft = self.draft && fh.show_frame && fh.refresh_frame_flags == 0; + let tile_count = fh.tile_info.cols.checked_mul(fh.tile_info.rows).ok_or(Error::Invalid("tile count overflows"))?; self.pending = Some(Box::new(FrameJob { seq, fh, refs: self.ref_frames.clone(), ref_info: self.refs.clone(), tiles: Vec::new(), + tile_covered: vec![false; tile_count], slot: Slot::new(), picture, pts: self.pts, @@ -568,13 +583,20 @@ impl Decoder { v + 1 }; let end = pos.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?; + if job.tile_covered.get(tile_num).copied() != Some(false) { + return Err(Error::Invalid("tile is covered by multiple tile groups")); + } job.tiles.push((tile_num, data[pos..end].to_vec())); + job.tile_covered[tile_num] = true; pos = end; } if tg_end != num_tiles - 1 { self.pending = Some(job); return Ok(None); } + if job.tile_covered.iter().any(|&covered| !covered) { + return Err(Error::Invalid("frame is missing tile coverage")); + } self.seen_frame_header = false; Ok(self.submit(job)) } @@ -947,3 +969,26 @@ fn project(fs: &mut FrameState, src: usize, dst_sign: i32) -> bool { } true } + +#[cfg(test)] +mod tests { + use super::frame_size_allowed; + + #[test] + fn frames_up_to_the_level_6_3_limits_are_allowed() { + assert!(frame_size_allowed(16384, 2176)); // exactly MaxPicSize + assert!(frame_size_allowed(4096, 8704)); // exactly MaxVSize + assert!(frame_size_allowed(8192, 4352)); // exactly MaxPicSize + assert!(frame_size_allowed(7680, 4320)); // 8K UHD + assert!(frame_size_allowed(1, 1)); + } + + #[test] + fn frames_beyond_the_limits_are_refused() { + assert!(!frame_size_allowed(16384, 2177)); // one row over MaxPicSize + assert!(!frame_size_allowed(16384, 8704)); // within both sides, four times MaxPicSize + assert!(!frame_size_allowed(4096, 8705)); // one row over MaxVSize + assert!(!frame_size_allowed(16385, 1)); // one column over MaxHSize + assert!(!frame_size_allowed(65536, 65536)); + } +} diff --git a/crates/av1/src/grain.rs b/crates/av1/src/grain.rs index d4baad621..677243644 100644 --- a/crates/av1/src/grain.rs +++ b/crates/av1/src/grain.rs @@ -133,6 +133,10 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader for i in 0..n - 1 { let dy = ys[i + 1] as i32 - ys[i] as i32; let dx = xs[i + 1] as i32 - xs[i] as i32; + // The header parser rejects non-increasing points; never divide by zero regardless. + if dx <= 0 { + continue; + } let delta = dy * ((65536 + (dx >> 1)) / dx); for x in 0..dx { l[xs[i] as usize + x as usize] = ys[i] as i32 + ((x * delta + 32768) >> 16); @@ -274,3 +278,34 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Regression: two equal scaling points divided by zero building the scaling lookup. + #[test] + fn equal_scaling_points_do_not_panic() { + let mut g = FilmGrainParams { apply_grain: true, num_y_points: 2, ..Default::default() }; + g.point_y_value[..2].copy_from_slice(&[64, 64]); + g.point_y_scaling[..2].copy_from_slice(&[20, 40]); + let (w, h) = (16usize, 16usize); + let mut pic = Picture { + width: w as u32, + height: h as u32, + bit_depth: 10, + subsampling_x: 1, + subsampling_y: 1, + mono_chrome: false, + planes: [vec![512; w * h], vec![512; w * h / 4], vec![512; w * h / 4]], + color_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + full_range: false, + pts: 0, + draft: false, + }; + apply(&mut pic, &g, &SequenceHeader::default()); + assert_eq!(pic.planes[0].len(), w * h); + } +} diff --git a/crates/av1/src/header.rs b/crates/av1/src/header.rs index 73d7ec437..c4e5f8a16 100644 --- a/crates/av1/src/header.rs +++ b/crates/av1/src/header.rs @@ -1251,6 +1251,9 @@ impl FrameHeader { g.point_y_value[i] = r.f(8)? as u8; g.point_y_scaling[i] = r.f(8)? as u8; } + if !strictly_increasing(&g.point_y_value[..g.num_y_points]) { + return Err(Error::Invalid("point_y_value")); + } g.chroma_scaling_from_luma = if seq.color.mono_chrome { false } else { r.flag()? }; if seq.color.mono_chrome || g.chroma_scaling_from_luma || (seq.color.subsampling_x == 1 && seq.color.subsampling_y == 1 && g.num_y_points == 0) { g.num_cb_points = 0; @@ -1264,6 +1267,9 @@ impl FrameHeader { g.point_cb_value[i] = r.f(8)? as u8; g.point_cb_scaling[i] = r.f(8)? as u8; } + if !strictly_increasing(&g.point_cb_value[..g.num_cb_points]) { + return Err(Error::Invalid("point_cb_value")); + } g.num_cr_points = r.f(4)? as usize; if g.num_cr_points > 10 { return Err(Error::Invalid("num_cr_points")); @@ -1272,6 +1278,9 @@ impl FrameHeader { g.point_cr_value[i] = r.f(8)? as u8; g.point_cr_scaling[i] = r.f(8)? as u8; } + if !strictly_increasing(&g.point_cr_value[..g.num_cr_points]) { + return Err(Error::Invalid("point_cr_value")); + } } g.grain_scaling_minus_8 = r.f(2)?; g.ar_coeff_lag = r.f(2)?; @@ -1322,6 +1331,12 @@ pub(crate) fn default_gm_params() -> [[i32; 6]; 8] { p } +/// Film grain scaling points must strictly increase (6.8.20); equal points would divide by zero when +/// the scaling lookup is interpolated. +fn strictly_increasing(values: &[u8]) -> bool { + values.windows(2).all(|w| w.first() < w.get(1)) +} + fn inverse_recenter(r: i32, v: i32) -> i32 { if v > 2 * r { v @@ -1359,3 +1374,17 @@ fn decode_subexp(r: &mut BitReader, num_syms: i32) -> Result { } } } + +#[cfg(test)] +mod tests { + use super::strictly_increasing; + + #[test] + fn film_grain_points_must_strictly_increase() { + assert!(strictly_increasing(&[])); + assert!(strictly_increasing(&[7])); + assert!(strictly_increasing(&[0, 64, 255])); + assert!(!strictly_increasing(&[0, 64, 64])); + assert!(!strictly_increasing(&[128, 64])); + } +} diff --git a/crates/av1/src/tile.rs b/crates/av1/src/tile.rs index 9d5344652..6776f201f 100644 --- a/crates/av1/src/tile.rs +++ b/crates/av1/src/tile.rs @@ -394,6 +394,10 @@ impl<'a, 'f> TileDecoder<'a, 'f> { fn decode_block(&mut self, r: usize, c: usize, sub_size: usize) -> Result<()> { let (ssx, ssy) = (self.fs.ssx, self.fs.ssy); let num_planes = self.fs.num_planes; + // Conformance requirement (4:2:2 has no chroma size for 4x16 and similar blocks); indexing the size tables with BLOCK_INVALID would panic. + if num_planes > 1 && self.fs.plane_residual_size(sub_size, 1) == BLOCK_INVALID { + return Err(crate::Error::Invalid("block size has no chroma size at this subsampling")); + } let mut b = Block { mi_row: r, mi_col: c, mi_size: sub_size, ..Default::default() }; b.bw4 = NUM_4X4_BLOCKS_WIDE[sub_size] as usize; b.bh4 = NUM_4X4_BLOCKS_HIGH[sub_size] as usize; diff --git a/crates/av1/tests/fuzz_hostile.rs b/crates/av1/tests/fuzz_hostile.rs new file mode 100644 index 000000000..4421b18c4 --- /dev/null +++ b/crates/av1/tests/fuzz_hostile.rs @@ -0,0 +1,156 @@ +//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small +//! ffmpeg-generated AV1 streams (frame by frame) must never panic or run away. Skips without ffmpeg. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use filmcraft_av1::Decoder; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// (name, encoder args, pix_fmt) +const SEEDS: &[(&str, &str, &str)] = &[("av1_fz_default", "-c:v libsvtav1 -g 4", "yuv420p"), ("av1_fz_10bit", "-c:v libsvtav1 -g 6", "yuv420p10le")]; + +fn ivf_frames(data: &[u8]) -> Vec> { + let mut out = Vec::new(); + let Some(h) = data.get(6..8) else { return out }; + let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]])); + while let Some(hd) = data.get(pos..pos + 12) { + let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize; + pos += 12; + let end = pos.saturating_add(size).min(data.len()); + out.push(data[pos..end].to_vec()); + pos = end; + } + out +} + +fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option>> { + let out = filmcraft_testkit::fixtures_dir("av1").join(format!("{name}.ivf")); + let p = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let a: Vec<&str> = args.split_whitespace().collect(); + matches!( + Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix]) + .args(a) + .args(["-f", "ivf"]) + .arg(tmp) + .stdin(Stdio::null()) + .status(), + Ok(s) if s.success() + ) + })?; + Some(ivf_frames(&std::fs::read(p).ok()?)) +} + +static PANICS: AtomicUsize = AtomicUsize::new(0); + +/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(frames: &[Vec], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + for threads in [1, 3] { + let mut dec = Decoder::with_threads(threads); + for (i, f) in frames.iter().enumerate() { + let _ = dec.decode_pts(f, i as i64); + } + let _ = dec.flush_result(); + } + })); + assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt). +fn iters() -> usize { + std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300) +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("AV1 fuzz") else { return }; + for &(name, args, pix) in SEEDS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libsvtav1") { + filmcraft_testkit::skip("AV1 fuzz", &format!("this ffmpeg has no libsvtav1 encoder (seed {name})")); + continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libsvtav1 but could not encode the {name} seed"); + }; + if seed.is_empty() { + filmcraft_testkit::skip("AV1 fuzz", &format!("the {name} seed has no frames")); + continue; + } + // truncate each of the first frames at every length + for fi in 0..seed.len().min(3) { + let len = seed[fi].len(); + for n in (0..len).step_by((len / 200).max(1)) { + let mut d = seed.clone(); + d[fi].truncate(n); + run_all(&d, &format!("{name} frame {fi} truncate {n}")); + } + } + // header region of every frame: every byte to a few values, every bit + for fi in 0..seed.len() { + for p in 0..seed[fi].len().min(96) { + for v in [0u8, 0xff, 0x80, 0x01, 0x7f] { + let mut d = seed.clone(); + d[fi][p] = v; + run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}")); + } + for b in 0..8 { + let mut d = seed.clone(); + d[fi][p] ^= 1 << b; + run_all(&d, &format!("{name} frame {fi} flip {p}.{b}")); + } + } + } + let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); + for i in 0..iters() { + let mut d = seed.clone(); + for _ in 0..1 + rng.below(6) { + let fi = rng.below(d.len()); + if d[fi].is_empty() { + continue; + } + let p = rng.below(d[fi].len()); + if rng.below(2) == 0 { + d[fi][p] ^= 1 << rng.below(8); + } else { + d[fi][p] = rng.next() as u8; + } + } + run_all(&d, &format!("{name} random {i}")); + } + } +} diff --git a/crates/av1/tests/adhoc.rs b/crates/av1/tests/it/adhoc.rs similarity index 92% rename from crates/av1/tests/adhoc.rs rename to crates/av1/tests/it/adhoc.rs index e140b9768..1595bc257 100644 --- a/crates/av1/tests/adhoc.rs +++ b/crates/av1/tests/it/adhoc.rs @@ -1,7 +1,7 @@ //! Compare an arbitrary IVF file with libdav1d: -//! `AV1_IVF=/path/x.ivf AV1_PIX=yuv420p cargo test -p filmcraft-av1 --test adhoc -- --ignored --nocapture` +//! `AV1_IVF=/path/x.ivf AV1_PIX=yuv420p cargo test -p filmcraft-av1 --test it adhoc:: -- --ignored --nocapture` -mod common; +use crate::common; use common::*; #[test] diff --git a/crates/av1/tests/common/mod.rs b/crates/av1/tests/it/common/mod.rs similarity index 100% rename from crates/av1/tests/common/mod.rs rename to crates/av1/tests/it/common/mod.rs diff --git a/crates/av1/tests/conformance.rs b/crates/av1/tests/it/conformance.rs similarity index 94% rename from crates/av1/tests/conformance.rs rename to crates/av1/tests/it/conformance.rs index 033f590e2..b70270af0 100644 --- a/crates/av1/tests/conformance.rs +++ b/crates/av1/tests/it/conformance.rs @@ -1,6 +1,6 @@ //! libaom conformance test vectors (downloaded on first use), compared with libdav1d. -mod common; +use crate::common; use common::*; /// Vectors the decoder handles today (more are added as stages land). @@ -17,7 +17,7 @@ fn conformance_vectors() { } /// The wider libaom set (slow: many downloads and decodes): -/// `cargo test -p filmcraft-av1 --test conformance -- --ignored`; `AV1_VECTORS=a,b` overrides it. +/// `cargo test -p filmcraft-av1 --test it conformance:: -- --ignored`; `AV1_VECTORS=a,b` overrides it. #[test] #[ignore] fn conformance_vectors_extended() { diff --git a/crates/av1/tests/it/hostile_dims.rs b/crates/av1/tests/it/hostile_dims.rs new file mode 100644 index 000000000..1312e938d --- /dev/null +++ b/crates/av1/tests/it/hostile_dims.rs @@ -0,0 +1,161 @@ +//! A few bytes must not make the decoder allocate gigabytes: oversized sequences are refused. +//! Hand-built (no ffmpeg): a reduced still-picture sequence header plus a lossless key frame whose +//! tiles are one zero byte each. + +use filmcraft_av1::{Decoder, Error}; + +#[derive(Default)] +struct Bits { + bytes: Vec, + n: usize, +} + +impl Bits { + fn put(&mut self, value: u32, bits: usize) { + for i in (0..bits).rev() { + if self.n.is_multiple_of(8) { + self.bytes.push(0); + } + if (value >> i) & 1 == 1 + && let Some(b) = self.bytes.last_mut() + { + *b |= 0x80 >> (self.n % 8); + } + self.n += 1; + } + } +} + +fn obu(kind: u8, payload: &[u8]) -> Vec { + let mut v = vec![kind << 3 | 0b10]; + let mut n = payload.len(); + loop { + let b = (n & 0x7f) as u8; + n >>= 7; + v.push(if n > 0 { b | 0x80 } else { b }); + if n == 0 { + break; + } + } + v.extend_from_slice(payload); + v +} + +fn tile_log2(blk: u32, target: u32) -> u32 { + let mut k = 0; + while (blk << k) < target { + k += 1; + } + k +} + +/// Temporal delimiter, reduced still-picture sequence header of `w` x `h` (64x64 superblocks) and a +/// lossless key frame whose uniformly spaced tiles are one zero byte each. +fn stream(w: u32, h: u32) -> Vec { + let mut s = Bits::default(); + s.put(0, 3); // seq_profile + s.put(1, 1); // still_picture + s.put(1, 1); // reduced_still_picture_header + s.put(0, 5); // seq_level_idx + s.put(15, 4); // frame_width_bits_minus_1 + s.put(15, 4); // frame_height_bits_minus_1 + s.put(w - 1, 16); + s.put(h - 1, 16); + s.put(0, 3 + 3 + 7 + 1); // sb size, filter intra, edge; superres, cdef, restoration; color config; film grain + s.put(1, 1); // trailing bit + + let (sb_cols, sb_rows) = (w.div_ceil(64), h.div_ceil(64)); + let cols_log2 = tile_log2(64, sb_cols); // MAX_TILE_WIDTH / 64 superblocks + let min_log2_tiles = cols_log2.max(tile_log2(2304, sb_rows * sb_cols)); + let rows_log2 = min_log2_tiles.saturating_sub(cols_log2); + let tiles = (sb_cols.div_ceil(sb_cols.div_ceil(1 << cols_log2))) * (sb_rows.div_ceil(sb_rows.div_ceil(1 << rows_log2))); + let mut f = Bits::default(); + f.put(0b0001, 4); // disable_cdf_update, allow_screen_content_tools, render_and_frame_size_different, uniform_tile_spacing_flag + f.put(0, 2); // no increment of the tile column / row counts + if tiles > 1 { + f.put(0, (cols_log2 + rows_log2) as usize + 2); // context_update_tile_id, tile_size_bytes_minus_1 + } + f.put(0, 8 + 4 + 1 + 1); // base_q_idx 0 (lossless), delta flags, using_qmatrix, segmentation_enabled, reduced_tx_set + let mut frame = f.bytes; + frame.push(0); // tile_start_and_end_present_flag + for _ in 1..tiles { + frame.extend([0, 0]); // tile_size_minus_1 = 0, one data byte + } + frame.extend([0, 0]); + + let mut out = obu(2, &[]); + out.extend(obu(1, &s.bytes)); + out.extend(obu(6, &frame)); + out +} + +fn refused(w: u32, h: u32) { + let mut dec = Decoder::new(); + let r = dec.decode(&stream(w, h)).and_then(|_| dec.flush_result()); + assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("frame size")), "{w}x{h} must be refused as too large, got {:?}", r.as_ref().map(|p| p.len())); +} + +#[test] +fn very_wide_frame_is_refused() { + refused(65536, 16); +} + +#[test] +fn very_tall_frame_is_refused() { + refused(16, 65536); +} + +#[test] +fn huge_frames_are_refused_without_allocating() { + refused(65536, 65536); + refused(65535, 65535); + refused(30000, 30000); +} + +/// Frames past the level 6.x limits (width 16384, height 8704, area 35 651 584) are refused. The accepted side of +/// each boundary is unit-tested on `frame_size_allowed` in `decoder.rs`: decoding a maximum-size frame end to end +/// needs gigabytes. +#[test] +fn frames_past_the_level_6_limits_are_refused() { + refused(16384, 2177); + refused(16384, 8704); + refused(4096, 8705); +} + +/// A 64x48 4:2:0 key frame whose sequence profile bit was flipped to 2 (4:2:2): blocks such as 4x16 then have no +/// chroma size (BLOCK_INVALID) and used to index the size tables out of bounds. +const PROFILE_FLIPPED: &[u8] = &[ + 0x12, 0x00, 0x0a, 0x0b, 0x42, 0x00, 0x00, 0x05, 0x55, 0x7f, 0xbc, 0x6a, 0xf9, 0x00, 0x40, 0x32, 0x81, 0x05, 0x14, 0x00, 0xa5, 0xa0, 0x40, 0xd9, 0x52, 0xb9, + 0xe7, 0x3c, 0xa6, 0xc1, 0xe4, 0xe9, 0xff, 0x6d, 0x12, 0xc3, 0xce, 0xac, 0x00, 0x92, 0x6f, 0x29, 0x6a, 0xee, 0x7e, 0x1a, 0x31, 0xfe, 0xe6, 0xb8, 0x2e, 0xe2, + 0xcf, 0x3d, 0xce, 0x24, 0xe6, 0x30, 0x03, 0xcd, 0x89, 0x49, 0x1f, 0xa7, 0x8a, 0x75, 0xb3, 0x93, 0x27, 0x7b, 0x2f, 0x39, 0xc7, 0x60, 0xb7, 0xf8, 0x69, 0xbc, + 0x53, 0x71, 0x30, 0x64, 0x20, 0x7e, 0xc1, 0x10, 0x17, 0xe4, 0xca, 0x34, 0xe9, 0xae, 0x47, 0x39, 0x68, 0x68, 0xef, 0x33, 0x9f, 0x26, 0xe2, 0x3f, 0xf1, 0xef, + 0x02, 0xb7, 0xfd, 0xb1, 0x78, 0x4c, 0x35, 0x31, 0x17, 0x27, 0x11, 0x2f, 0xd7, 0x25, 0x01, 0x30, 0xa5, 0x7a, 0xaf, 0x9b, 0x5b, 0x3b, 0x8d, 0x9e, 0xd5, 0x23, + 0x06, 0x22, 0x3a, 0xec, 0x57, 0xae, 0xdb, 0x4d, 0x00, 0x36, 0xff, 0xbd, 0x3b, 0xef, 0x0a, 0xf0, 0x39, 0x2a, 0xa9, 0x2a, 0x56, 0x9d, 0x7e, 0xc2, 0x05, 0x46, + 0x21, 0x8b, 0xd9, 0x45, 0x33, 0x59, 0x3c, 0x40, 0xe0, 0x2b, 0xec, 0x51, 0x0a, 0xf2, 0xf0, 0xe5, 0x61, 0x99, 0xfd, 0x2e, 0xe5, 0x8c, 0x69, 0x72, 0x44, 0xb8, + 0x92, 0x9e, 0x2e, 0x51, 0x0b, 0x8c, 0x16, 0x0f, 0xd9, 0xf2, 0x47, 0xc4, 0x36, 0x9d, 0x54, 0xc6, 0x9f, 0x26, 0x68, 0xf7, 0xfa, 0x80, 0xab, 0x68, 0xd6, 0x61, + 0xd2, 0x59, 0x4b, 0x07, 0xc4, 0x7a, 0xde, 0x37, 0xfb, 0x02, 0xd5, 0x3d, 0xb0, 0xeb, 0x71, 0x1b, 0x07, 0x45, 0xdc, 0x03, 0x3b, 0x4f, 0xdf, 0x44, 0xa6, 0x28, + 0xd4, 0xb0, 0xbb, 0x26, 0xa4, 0x4f, 0xee, 0xbb, 0xda, 0x59, 0x69, 0xfc, 0xf2, 0x4f, 0xf6, 0x6c, 0x0c, 0xd9, 0xe2, 0xc0, 0xa7, 0x64, 0x96, 0xd5, 0x7b, 0x1a, + 0x71, 0xe0, 0xcc, 0x9e, 0xd7, 0xaf, 0xb9, 0x9a, 0x99, 0xcc, 0xe9, 0xa9, 0x79, 0xa3, 0xa8, 0xbc, 0xfe, 0xdb, 0xed, 0xa3, 0xd6, 0x7d, 0x31, 0xb0, 0x18, 0x3c, + 0x9e, 0x55, 0x0f, 0x1a, 0xdf, 0x5d, 0x8c, 0x33, 0x5f, 0x2d, 0x00, 0xfc, 0x9d, 0x55, 0xc2, 0xc8, 0x10, 0x25, 0xc8, 0x7f, 0xa6, 0x47, 0xbf, 0xab, 0xf3, 0x97, + 0xfb, 0xda, 0xd6, 0x6a, 0x14, 0x87, 0x9d, 0xef, 0x85, 0x12, 0x43, 0x31, 0xc4, 0x4c, 0x4c, 0x7a, 0x50, 0xd1, 0x87, 0xb8, 0x65, 0x2d, 0x3f, 0xf4, 0xa3, 0x54, + 0xea, 0x19, 0xa6, 0xfe, 0x70, 0x30, 0x52, 0x7e, 0xbb, 0x9c, 0x3f, 0x2d, 0x10, 0x4b, 0x43, 0x04, 0x6c, 0x03, 0xc7, 0x15, 0xa8, 0x8e, 0x20, 0xde, 0x46, 0x9a, + 0x12, 0xdf, 0xe5, 0xce, 0x08, 0x91, 0x3e, 0x74, 0xa4, 0x20, 0x78, 0x52, 0xfd, 0x4d, 0x9d, 0x5a, 0xca, 0xa2, 0xdc, 0xc8, 0x95, 0xa6, 0x69, 0x3a, 0x1a, 0x18, + 0x48, 0x76, 0x8c, 0xfa, 0x58, 0xa0, 0x0d, 0xf4, 0xbd, 0xe1, 0x05, 0x37, 0xe6, 0xbb, 0x60, 0xa1, 0x83, 0x40, 0x08, 0xeb, 0x02, 0xa6, 0x6a, 0x7a, 0x16, 0x1f, + 0xb3, 0xbb, 0x58, 0x66, 0x51, 0x79, 0xc7, 0x7a, 0xb2, 0xc2, 0x84, 0x69, 0x17, 0x4d, 0x27, 0xa7, 0xa4, 0xd9, 0xc2, 0xb4, 0x03, 0x57, 0x53, 0x44, 0xb9, 0x8f, + 0x48, 0xd2, 0x1c, 0x74, 0xd9, 0xdd, 0x9b, 0x88, 0xdc, 0xda, 0xef, 0x70, 0xee, 0xf1, 0x04, 0xdf, 0x35, 0x39, 0x22, 0x49, 0xfe, 0x22, 0x9a, 0x0a, 0x33, 0x60, + 0x49, 0xbe, 0x28, 0xfa, 0xdf, 0x71, 0xf6, 0x53, 0x87, 0x49, 0x01, 0x95, 0xfa, 0x22, 0x94, 0x7c, 0x0e, 0xf6, 0x7d, 0x7e, 0x1d, 0x41, 0x8d, 0xc9, 0x1b, 0xf9, + 0x53, 0xc9, 0xea, 0x46, 0xee, 0xb2, 0x80, 0x75, 0xe1, 0xca, 0xa4, 0x0a, 0x9c, 0x2b, 0x7b, 0x18, 0x70, 0x56, 0x22, 0x76, 0xaf, 0x4d, 0x84, 0xdd, 0xad, 0x77, + 0x0e, 0xea, 0x2e, 0xa9, 0xbf, 0x42, 0x95, 0xdf, 0x17, 0x61, 0xae, 0x46, 0x20, 0xb4, 0x57, 0xca, 0xe0, 0x78, 0x86, 0xd0, 0x51, 0xae, 0xfe, 0x51, 0x53, 0x0f, + 0x36, 0x64, 0x5f, 0xb4, 0x5c, 0xac, 0x8b, 0x63, 0xe7, 0x5d, 0x98, 0x8e, 0x65, 0xe9, 0x93, 0xe4, 0x23, 0x10, 0xa3, 0x5b, 0x3b, 0x38, 0x5b, 0x8a, 0x7c, 0xe8, + 0x60, 0x01, 0xc1, 0x0e, 0x53, 0x5e, 0x0e, 0x81, 0x33, 0x63, 0x8d, 0x53, 0x82, 0xa6, 0x7b, 0xbd, 0x02, 0x58, 0x1f, 0xe9, 0x50, 0x75, 0x24, 0x64, 0x80, 0x47, + 0x02, 0x9e, 0x42, 0x28, 0xb0, 0xa2, 0xab, 0x8e, 0xd0, 0x81, 0xcc, 0xbd, 0x22, 0x87, 0x3f, 0xe4, 0x49, 0x4d, 0x5f, 0x6a, 0x5c, 0x5a, 0x5e, 0xe5, 0x30, 0x9d, + 0x7f, 0xc5, 0xaf, 0xce, 0x6c, 0x9c, 0xaf, 0x09, 0x96, 0x1a, 0x62, 0x42, 0x61, 0xa3, 0x1d, 0x1c, 0xe6, 0xcf, 0xa8, 0x90, 0x4d, 0x05, 0x28, 0xef, 0x22, 0x81, + 0x12, 0xa7, 0x70, 0x04, 0x0a, 0x0d, 0xe5, 0xa0, 0xd8, +]; + +#[test] +fn block_without_chroma_size_is_an_error_not_a_panic() { + let mut dec = Decoder::new(); + let r = dec.decode(PROFILE_FLIPPED).and_then(|_| dec.flush_result()); + assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("chroma size")), "got {:?}", r.as_ref().map(|p| p.len())); +} diff --git a/crates/av1/tests/it/main.rs b/crates/av1/tests/it/main.rs new file mode 100644 index 000000000..decf05989 --- /dev/null +++ b/crates/av1/tests/it/main.rs @@ -0,0 +1,7 @@ +mod adhoc; +mod common; +mod conformance; +mod hostile_dims; +mod oracle_inter; +mod oracle_intra; +mod perf; diff --git a/crates/av1/tests/oracle_inter.rs b/crates/av1/tests/it/oracle_inter.rs similarity index 99% rename from crates/av1/tests/oracle_inter.rs rename to crates/av1/tests/it/oracle_inter.rs index 06a4d88fe..5d443194f 100644 --- a/crates/av1/tests/oracle_inter.rs +++ b/crates/av1/tests/it/oracle_inter.rs @@ -2,7 +2,7 @@ //! GOPs with hierarchical references, compound prediction, OBMC / warped motion, global motion, //! motion-field projection and all loop filters. -mod common; +use crate::common; use common::*; fn specs() -> Vec { diff --git a/crates/av1/tests/oracle_intra.rs b/crates/av1/tests/it/oracle_intra.rs similarity index 99% rename from crates/av1/tests/oracle_intra.rs rename to crates/av1/tests/it/oracle_intra.rs index 441220f18..d002af67e 100644 --- a/crates/av1/tests/oracle_intra.rs +++ b/crates/av1/tests/it/oracle_intra.rs @@ -1,6 +1,6 @@ //! Intra-frame bit-exactness against libdav1d (run as an external ffmpeg process). -mod common; +use crate::common; use common::*; const NO_FILTERS: &str = "enable-cdef=0:enable-restoration=0:enable-dlf=0:keyint=1"; diff --git a/crates/av1/tests/perf.rs b/crates/av1/tests/it/perf.rs similarity index 97% rename from crates/av1/tests/perf.rs rename to crates/av1/tests/it/perf.rs index 9047605aa..518a0cac3 100644 --- a/crates/av1/tests/perf.rs +++ b/crates/av1/tests/it/perf.rs @@ -1,11 +1,11 @@ //! Decode throughput and per-stage breakdown (ignored; run with -//! `cargo test --release -p filmcraft-av1 --test perf -- --ignored --nocapture`). +//! `cargo test --release -p filmcraft-av1 --test it perf:: -- --ignored --nocapture`). //! //! The machine may be busy (other builds), so single-threaded work is also measured with the //! thread CPU clock, which is robust to load; wall-clock figures are printed next to the load //! average. -mod common; +use crate::common; use common::*; use filmcraft_av1::{DecodeStats, Decoder, Stage}; use std::time::Instant; diff --git a/crates/av1/tools/extract_tables.py b/crates/av1/tools/extract_tables.py index 83c4e2c9f..b4df9155b 100644 --- a/crates/av1/tools/extract_tables.py +++ b/crates/av1/tools/extract_tables.py @@ -14,11 +14,27 @@ Only the specification text is read: Each table is emitted as a nested Rust array whose shape is checked against the declared dimensions; the element type is the narrowest of u8/i8/u16/i16/i32 that holds every value. """ +import ast +import operator import os import re import sys +from pathlib import Path -root = sys.argv[1] + +def _spec_dir(arg): + """Resolve the CLI argument and require it to be an existing directory.""" + path = Path(arg).resolve() + if not path.is_dir(): + sys.exit(f"not a directory: {arg}") + return path + + +if len(sys.argv) != 2: + sys.exit("usage: extract_tables.py ") +# The argument is the operator's own spec checkout; _spec_dir requires it to be an existing +# directory before anything is read from it. +root = _spec_dir(sys.argv[1]) # NOSONAR files = sorted(f for f in os.listdir(root) if f.endswith(".md")) texts = {f: open(os.path.join(root, f), encoding="utf-8").read() for f in files} @@ -29,13 +45,36 @@ def ev(expr): e = expr.strip().replace("\\", "") e = re.sub(r"\b([A-Z][A-Z0-9_]*)\b", lambda m: str(consts[m.group(1)]) if m.group(1) in consts else m.group(1), e) e = e.replace("/", "//") - return int(eval(e, {"__builtins__": {}}, {})) + binary = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.FloorDiv: operator.floordiv, + ast.Mod: operator.mod, + ast.LShift: operator.lshift, + ast.RShift: operator.rshift, + ast.BitOr: operator.or_, + ast.BitAnd: operator.and_, + ast.BitXor: operator.xor, + } + unary = {ast.UAdd: operator.pos, ast.USub: operator.neg, ast.Invert: operator.invert} + + def calculate(node): + if isinstance(node, ast.Constant) and isinstance(node.value, int): + return node.value + if isinstance(node, ast.BinOp) and type(node.op) in binary: + return binary[type(node.op)](calculate(node.left), calculate(node.right)) + if isinstance(node, ast.UnaryOp) and type(node.op) in unary: + return unary[type(node.op)](calculate(node.operand)) + raise ValueError("unsupported specification expression") + + return int(calculate(ast.parse(e, mode="eval").body)) # --- constants: symbol table (section 3) --- pending = [] for line in texts["03.symbols.md"].split("\n"): - m = re.match(r"^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|\s*([^|]+?)\s*\|", line) + m = re.match(r"^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|\s*([^\s|](?:[^|]*[^|\s])?)\s*\|", line) if m: pending.append((m.group(1), m.group(2))) # --- constants: value/name tables (section 6 semantics, section 8/9) --- @@ -77,7 +116,7 @@ for name, val in pending: def cells(text): """Split one initialiser cell. A missing comma between two operands on separate lines (Split_Tx_Size in the spec text: `TX_32X32` / `TX_4X8`) yields two cells.""" - parts = re.split(r"(?<=[A-Za-z0-9_])\s*\n\s*(?=[A-Za-z0-9_])", text.strip()) + parts = re.split(r"(?<=[A-Za-z0-9_])[^\S\n]*\n[^\S\n]*(?=[A-Za-z0-9_])", text.strip()) if len(parts) > 1: sys.stderr.write("note: missing comma in spec table between %s\n" % parts) return parts @@ -118,10 +157,25 @@ def shape(x): return [] +# `~~~~` opens a block on its own line (with an optional `c` marker) and the next run of +# tildes closes it. Walk the pairs with two linear searches instead of the lazy `(.*?)` +# with re.S, which rescans the whole remaining text at every position (super-linear). +FENCE_OPEN = re.compile(r"~~~~~?\s*c?\n") +FENCE_CLOSE = re.compile(r"~~~~~?") + tables = {} for f in files: t = texts[f] - for block in re.findall(r"~~~~~?\s*c?\n(.*?)~~~~~?", t, re.S): + pos = 0 + while True: + m = FENCE_OPEN.search(t, pos) + if not m: + break + c = FENCE_CLOSE.search(t, m.end()) + if not c: + break + block = t[m.end():c.start()] + pos = c.end() b = re.sub(r"//[^\n]*", "", block) b = re.sub(r"/\*.*?\*/", "", b, flags=re.S) for m in re.finditer(r"(?m)^\s*([A-Z][A-Za-z0-9_]*)\s*((?:\[[^\]=]*\]\s*)+)=\s*", b): diff --git a/crates/avi/src/lib.rs b/crates/avi/src/lib.rs index 0294fdfea..e11bc7b5f 100644 --- a/crates/avi/src/lib.rs +++ b/crates/avi/src/lib.rs @@ -21,6 +21,8 @@ mod tests; pub use source::ByteSource; +use std::collections::HashSet; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Error { /// Not an AVI file (no `RIFF` … `AVI ` header). @@ -175,6 +177,9 @@ pub struct OpenOptions { /// Headers larger than this are refused (a real `hdrl` is a few kilobytes). const MAX_HEADER: u64 = 16 << 20; +/// Total OpenDML standard-index bytes accepted while opening one file. This bounds hostile +/// repeated index references without constraining practical large-file indexes. +const MAX_ODML_INDEX_BYTES: u64 = 64 << 20; /// Streams beyond this are ignored. const MAX_STREAMS: usize = 64; /// `LIST rec ` inside `movi` nests once; deeper is refused. @@ -443,34 +448,52 @@ fn super_index(b: &[u8]) -> Vec { /// Fill the chunk tables from the OpenDML standard indexes; false (tables cleared) when a stream /// that has data lacks one or none could be read. fn read_odml(src: &S, streams: &mut [Stream]) -> bool { - let limit = src.len() / 8; - let mut total = 0u64; + let entry_limit = src.len() / 8; + let mut entries = 0u64; + let mut index_bytes = 0u64; + let mut seen = HashSet::new(); for s in streams.iter_mut() { s.chunks.clear(); s.keyframes_known = true; for &at in &s.super_index { + // A super index can repeat an offset (even across streams). Read and process a + // standard index once, rather than multiplying its work and allocations. + if !seen.insert(at) { + continue; + } let Ok((id, size, data)) = header_at(src, at) else { continue }; if &id[..2] != b"ix" { continue; } - let Ok(b) = read(src, data, size.min(MAX_HEADER * 8)) else { continue }; - let (longs, subtype, itype) = (usize::from(le16(&b, 0)), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0)); + // Validate the fixed header before allocating the variable entry body. + if size < 24 || index_bytes.checked_add(24).is_none_or(|n| n > MAX_ODML_INDEX_BYTES) { + return false; + } + let Ok(header) = read(src, data, 24) else { continue }; + index_bytes += 24; + let (longs, subtype, itype) = (usize::from(le16(&header, 0)), header.get(2).copied().unwrap_or(0), header.get(3).copied().unwrap_or(0)); // AVI_INDEX_OF_CHUNKS; 2 longs per entry, or 3 for the field index (AVI_INDEX_2FIELD) if itype != 1 || !(longs == 2 && subtype == 0 || longs == 3 && subtype == 1) { continue; } - let base = le64(&b, 12); let step = longs * 4; - let n = (le32(&b, 4) as usize).min(b.len().saturating_sub(24) / step); + let n = u64::from(le32(&header, 4)).min(size.saturating_sub(24) / step as u64); + let body = n.saturating_mul(step as u64); + let Some(next_bytes) = index_bytes.checked_add(body) else { return false }; + let Some(next_entries) = entries.checked_add(n) else { return false }; + if next_bytes > MAX_ODML_INDEX_BYTES || next_entries > entry_limit { + return false; + } + let Ok(b) = read(src, data, 24 + body) else { continue }; + index_bytes = next_bytes; + entries = next_entries; + let base = le64(&b, 12); + let n = usize::try_from(n).unwrap_or(0); for k in 0..n { let e = 24 + k * step; let (off, raw) = (u64::from(le32(&b, e)), le32(&b, e + 4)); let size = raw & 0x7FFF_FFFF; let Some(offset) = base.checked_add(off).filter(|o| o.checked_add(u64::from(size)).is_some_and(|end| end <= src.len())) else { continue }; - total += 1; - if total > limit { - return false; - } s.chunks.push(Chunk { offset, size, key: raw & 0x8000_0000 == 0 }); } } diff --git a/crates/avi/src/tests.rs b/crates/avi/src/tests.rs index 248364d6f..42e3dce9f 100644 --- a/crates/avi/src/tests.rs +++ b/crates/avi/src/tests.rs @@ -267,3 +267,18 @@ fn opendml_standard_indexes() { } } } + +#[test] +fn opendml_repeated_index_offsets_are_read_once() { + let (_, at) = avi_odml([0, 0]); + let (file, _) = avi_odml(at); + let mut streams = open(&file).unwrap().streams; + // A malicious super index repeats each valid standard-index address. It must not multiply + // its chunks, allocations, or traversal work. + for (stream, &index) in streams.iter_mut().zip(&at) { + stream.super_index = vec![index; 100]; + } + assert!(read_odml(&file, &mut streams)); + assert_eq!(streams[0].chunks.len(), 3); + assert_eq!(streams[1].chunks.len(), 2); +} diff --git a/crates/bitstream/Cargo.toml b/crates/bitstream/Cargo.toml index 8510e348e..07e798ae4 100644 --- a/crates/bitstream/Cargo.toml +++ b/crates/bitstream/Cargo.toml @@ -9,6 +9,4 @@ rust-version.workspace = true workspace = true [dependencies] - -[dev-dependencies] -proptest = { workspace = true } +craft-media = { workspace = true, features = ["bitstream"] } diff --git a/crates/bitstream/src/lib.rs b/crates/bitstream/src/lib.rs index 1bdb51265..812b0d68c 100644 --- a/crates/bitstream/src/lib.rs +++ b/crates/bitstream/src/lib.rs @@ -1,478 +1,5 @@ -//! Bit-level reading and writing shared by FilmCraft's codec crates. -//! -//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers. -//! - [`BitWriter`]: the matching writer (for encoders and header rewriting). -//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes. -//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units. +//! Bit-level reading and writing: a re-export of [`craft_media::bitstream`] from craft-libs. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] -use std::fmt; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BitError { - /// Tried to read past the end of the buffer. - Eof, - /// An Exp-Golomb code longer than 32 leading zeros. - InvalidExpGolomb, -} - -impl fmt::Display for BitError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - BitError::Eof => f.write_str("unexpected end of bitstream"), - BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"), - } - } -} -impl std::error::Error for BitError {} - -pub type Result = std::result::Result; - -/// MSB-first bit reader over a byte slice. -#[derive(Clone)] -pub struct BitReader<'a> { - data: &'a [u8], - /// Position in bits. - pos: usize, -} - -impl<'a> BitReader<'a> { - pub fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0 } - } - pub fn data(&self) -> &'a [u8] { - self.data - } - /// Bit position from the start. - pub fn position(&self) -> usize { - self.pos - } - pub fn bits_left(&self) -> usize { - (self.data.len() * 8).saturating_sub(self.pos) - } - pub fn is_byte_aligned(&self) -> bool { - self.pos.is_multiple_of(8) - } - pub fn byte_align(&mut self) { - self.pos = self.pos.div_ceil(8) * 8; - } - /// Byte offset of the current position (rounded down). - pub fn byte_pos(&self) -> usize { - self.pos / 8 - } - pub fn seek_bits(&mut self, pos: usize) { - self.pos = pos; - } - - /// Peek up to 32 bits without consuming (zero-padded past the end). - #[inline] - pub fn peek(&self, n: u32) -> u32 { - debug_assert!(n <= 32); - if n == 0 { - return 0; - } - let byte = self.pos / 8; - let shift = self.pos % 8; - let mut v: u64 = 0; - for i in 0..5 { - v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64; - } - // v holds 40 bits starting at `byte` - ((v << (24 + shift)) >> (64 - n)) as u32 - } - - #[inline] - pub fn read_bits(&mut self, n: u32) -> Result { - if n as usize > self.bits_left() { - return Err(BitError::Eof); - } - let v = self.peek(n); - self.pos += n as usize; - Ok(v) - } - - pub fn read_bits_u64(&mut self, n: u32) -> Result { - if n <= 32 { - return self.read_bits(n).map(u64::from); - } - let hi = self.read_bits(n - 32)? as u64; - let lo = self.read_bits(32)? as u64; - Ok((hi << 32) | lo) - } - - #[inline] - pub fn read_bit(&mut self) -> Result { - self.read_bits(1).map(|b| b == 1) - } - pub fn read_flag(&mut self) -> Result { - self.read_bit() - } - - pub fn skip(&mut self, n: usize) -> Result<()> { - if n > self.bits_left() { - return Err(BitError::Eof); - } - self.pos += n; - Ok(()) - } - - /// Raw Exp-Golomb codeNum, up to 32 leading zeros (so below 2^33). - fn read_code_num(&mut self) -> Result { - let mut zeros = 0u32; - while !self.read_bit()? { - zeros += 1; - if zeros > 32 { - return Err(BitError::InvalidExpGolomb); - } - } - if zeros == 0 { - return Ok(0); - } - let v = self.read_bits_u64(zeros)?; - Ok((1u64 << zeros) - 1 + v) - } - - /// Unsigned Exp-Golomb `ue(v)`. - pub fn read_ue(&mut self) -> Result { - u32::try_from(self.read_code_num()?).map_err(|_| BitError::InvalidExpGolomb) - } - - /// Signed Exp-Golomb `se(v)`. - pub fn read_se(&mut self) -> Result { - // codeNum 2^32 is -2^31, so the mapping is done before narrowing to 32 bits. - let k = self.read_code_num()? as i64; - let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) }; - i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb) - } - - /// Truncated Exp-Golomb `te(v)` with range `max`. - pub fn read_te(&mut self, max: u32) -> Result { - if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) } - } - - /// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits. - pub fn more_rbsp_data(&self) -> bool { - let total = self.data.len() * 8; - if self.pos >= total { - return false; - } - // find last set bit (the rbsp_stop_one_bit) - let mut last = None; - for (i, &b) in self.data.iter().enumerate().rev() { - if b != 0 { - last = Some(i * 8 + 7 - b.trailing_zeros() as usize); - break; - } - } - match last { - Some(stop) => self.pos < stop, - None => false, - } - } -} - -/// MSB-first bit writer. -#[derive(Default, Clone)] -pub struct BitWriter { - buf: Vec, - acc: u64, - nbits: u32, -} - -impl BitWriter { - pub fn new() -> Self { - Self::default() - } - pub fn write_bits(&mut self, value: u32, n: u32) { - debug_assert!(n <= 32); - if n == 0 { - return; - } - let v = (value as u64) & ((1u64 << n) - 1); - self.acc = (self.acc << n) | v; - self.nbits += n; - while self.nbits >= 8 { - self.nbits -= 8; - self.buf.push((self.acc >> self.nbits) as u8); - } - self.acc &= (1u64 << self.nbits) - 1; - } - pub fn write_bit(&mut self, b: bool) { - self.write_bits(b as u32, 1); - } - pub fn write_ue(&mut self, v: u32) { - self.write_code_num(u64::from(v)); - } - /// Exp-Golomb codeNum up to 2^32 (the code of `se(v)` -2^31). - fn write_code_num(&mut self, v: u64) { - let x = v + 1; - let len = 64 - x.leading_zeros(); - let zeros = len - 1; - for _ in 0..zeros { - self.write_bit(false); - } - if len > 32 { - self.write_bits((x >> 32) as u32, len - 32); - self.write_bits(x as u32, 32); - } else { - self.write_bits(x as u32, len); - } - } - pub fn write_se(&mut self, v: i32) { - let m = u64::from(v.unsigned_abs()) * 2; - self.write_code_num(if v > 0 { m - 1 } else { m }); - } - pub fn bit_len(&self) -> usize { - self.buf.len() * 8 + self.nbits as usize - } - pub fn is_byte_aligned(&self) -> bool { - self.nbits == 0 - } - /// Pad with zero bits to a byte boundary. - pub fn align_zero(&mut self) { - if self.nbits > 0 { - self.write_bits(0, 8 - self.nbits); - } - } - /// `rbsp_trailing_bits()`: a one bit then zeros to alignment. - pub fn rbsp_trailing(&mut self) { - self.write_bit(true); - self.align_zero(); - } - pub fn write_bytes(&mut self, bytes: &[u8]) { - if self.nbits == 0 { - self.buf.extend_from_slice(bytes); - } else { - for &b in bytes { - self.write_bits(b as u32, 8); - } - } - } - pub fn finish(mut self) -> Vec { - self.align_zero(); - self.buf - } - pub fn bytes(&self) -> &[u8] { - &self.buf - } -} - -/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload. -pub fn unescape_rbsp(data: &[u8]) -> Vec { - let mut out = Vec::with_capacity(data.len()); - let mut zeros = 0; - for &b in data { - if zeros >= 2 && b == 3 { - zeros = 0; - continue; - } - out.push(b); - zeros = if b == 0 { zeros + 1 } else { 0 }; - } - out -} - -/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears. -pub fn escape_rbsp(data: &[u8]) -> Vec { - let mut out = Vec::with_capacity(data.len() + data.len() / 64); - let mut zeros = 0; - for &b in data { - if zeros >= 2 && b <= 3 { - out.push(3); - zeros = 0; - } - out.push(b); - zeros = if b == 0 { zeros + 1 } else { 0 }; - } - out -} - -/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads). -pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> { - let mut starts = Vec::new(); - let mut i = 0; - while i + 3 <= data.len() { - if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 { - starts.push(i + 3); - i += 3; - } else { - i += 1; - } - } - let mut out = Vec::with_capacity(starts.len()); - for (k, &s) in starts.iter().enumerate() { - let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() }; - // trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits) - while e > s && data[e - 1] == 0 { - e -= 1; - } - if e > s { - out.push(&data[s..e]); - } - } - out -} - -/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units. -pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result> { - let mut out = Vec::new(); - let mut i = 0; - while i < data.len() { - if i + length_size > data.len() { - return Err(BitError::Eof); - } - let mut len = 0usize; - for k in 0..length_size { - len = (len << 8) | data[i + k] as usize; - } - i += length_size; - if i + len > data.len() { - return Err(BitError::Eof); - } - out.push(&data[i..i + len]); - i += len; - } - Ok(out) -} - -/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG). -pub fn crc32(data: &[u8]) -> u32 { - let mut crc = 0xFFFF_FFFFu32; - for &b in data { - crc ^= b as u32; - for _ in 0..8 { - crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; - } - } - !crc -} - -#[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - - #[test] - fn reads_bits() { - let d = [0b1010_1100, 0b0101_0011]; - let mut r = BitReader::new(&d); - assert_eq!(r.read_bits(1).unwrap(), 1); - assert_eq!(r.read_bits(3).unwrap(), 0b010); - assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101); - assert_eq!(r.bits_left(), 4); - assert_eq!(r.read_bits(5), Err(BitError::Eof)); - } - - #[test] - fn exp_golomb_known() { - // 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4 - let mut w = BitWriter::new(); - for v in 0..5 { - w.write_ue(v); - } - let b = w.finish(); - let mut r = BitReader::new(&b); - for v in 0..5 { - assert_eq!(r.read_ue().unwrap(), v); - } - let mut w = BitWriter::new(); - for v in [0, 1, -1, 2, -2, 1000, -1000] { - w.write_se(v); - } - let b = w.finish(); - let mut r = BitReader::new(&b); - for v in [0, 1, -1, 2, -2, 1000, -1000] { - assert_eq!(r.read_se().unwrap(), v); - } - } - - #[test] - fn signed_exp_golomb_extremes_roundtrip() { - let vals = [0, 1, -1, i32::MAX, i32::MIN]; - let mut w = BitWriter::new(); - for v in vals { - w.write_se(v); - } - let b = w.finish(); - let mut r = BitReader::new(&b); - for v in vals { - assert_eq!(r.read_se(), Ok(v)); - } - } - - #[test] - fn signed_exp_golomb_reads_minimum_from_spec_bits() { - // 32 zero bits, 1, then the 32-bit suffix 1: codeNum = 2^32 -> -2^31. - let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80]; - assert_eq!(BitReader::new(&bytes).read_se(), Ok(i32::MIN)); - } - - #[test] - fn signed_exp_golomb_rejects_positive_overflow() { - // 32 zero bits, 1, then 32 zero bits: codeNum = 2^32 - 1 -> +2^31, not an i32. - let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0]; - assert_eq!(BitReader::new(&bytes).read_se(), Err(BitError::InvalidExpGolomb)); - } - - #[test] - fn unsigned_exp_golomb_keeps_u32_range() { - // codeNum = 2^32 is a valid se(v) code but does not fit ue(v)'s u32. - let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80]; - assert_eq!(BitReader::new(&bytes).read_ue(), Err(BitError::InvalidExpGolomb)); - } - - #[test] - fn emulation_prevention() { - let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3]; - let esc = escape_rbsp(&raw); - assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]); - assert_eq!(unescape_rbsp(&esc), raw); - } - - #[test] - fn annexb_split() { - let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9]; - let n = annexb_nals(&s); - assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]); - let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68]; - assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]); - } - - #[test] - fn more_rbsp() { - let d = [0b1100_0000]; - let mut r = BitReader::new(&d); - assert!(r.more_rbsp_data()); - r.read_bit().unwrap(); - assert!(!r.more_rbsp_data()); - } - - #[test] - fn crc() { - assert_eq!(crc32(b"123456789"), 0xCBF4_3926); - } - - proptest! { - #[test] - fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) { - let mut w = BitWriter::new(); - for &(v, n) in &vals { w.write_bits(v, n); } - for &u in &ues { w.write_ue(u); } - let b = w.finish(); - let mut r = BitReader::new(&b); - for &(v, n) in &vals { - let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 }; - prop_assert_eq!(r.read_bits(n).unwrap(), v & mask); - } - for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); } - } - - #[test] - fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) { - let e = escape_rbsp(&data); - prop_assert_eq!(unescape_rbsp(&e), data); - for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); } - } - } -} +pub use craft_media::bitstream::*; diff --git a/crates/captions/README.md b/crates/captions/README.md index 04183352c..661d8fb82 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -14,7 +14,7 @@ and writes caption files, converts them to and from caption tracks, and draws ca | SubRip `.srt` | yes | yes | BOM, UTF-16, Windows-1252 fallback, CRLF/CR, missing or wrong indexes, `,` or `.` milliseconds, 1–9 fraction digits, missing hours, SSA coordinates, cues not separated by blank lines. Inline tags are kept as text. | | WebVTT `.vtt` | yes | yes | Cue identifiers, cue settings (verbatim), a leading `` voice span (as the caption's speaker) and STYLE / REGION / NOTE blocks are kept. A missing `WEBVTT` signature is tolerated. | | Scenarist SCC `.scc` | yes | yes | CEA-608 at 29.97 fps, drop-frame or not. Reads pop-on, roll-up and paint-on (channel 1); writes pop-on. | -| MacCaption MCC `.mcc` | yes | yes | One SMPTE ST 334-2 caption distribution packet (CDP) per frame in MacCaption's hex text with letter abbreviations. Writes 29.97 fps (`30DF` / `30`) with CEA-608 field 1 (the SCC schedule) **and** CEA-708 service 1 (two-window pop-on emulation). Reads CEA-608 field 1 when present, else CEA-708 service 1; time code rates 30DF, 30, 60DF, 60, 25, 24, 50. | +| MacCaption MCC `.mcc` | yes | yes | One SMPTE ST 334-2 caption distribution packet (CDP) per frame carrying caption data, omitting empty frames, in MacCaption's hex text with letter abbreviations. Writes 29.97 fps (`30DF` / `30`) with CEA-608 field 1 (the SCC schedule) **and** CEA-708 service 1 (two-window pop-on emulation). Reads CEA-608 field 1 when present, else CEA-708 service 1; time code rates 30DF, 30, 60DF, 60, 25, 24, 50. | | EBU STL `.stl` | yes | yes | EBU Tech 3264-E: GSI block + 128-byte TTI blocks, `STL25.01` (25 fps) or `STL30.01` (read/written as 29.97 fps, non-drop labels), ISO/IEC 6937 Latin text (accents as diacritic + letter), italics / underline codes as `` / ``, extension blocks for long subtitles, start-of-programme (TCP) subtracted. | | TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `

` per cue, `
`, styled ``s for `` / `` / ``, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers (`body` / `div` begins offset and ends clip their contents; `seq` containers are read as `par` with a warning), timed spans, referenced styles. | | DFXP `.dfxp` | yes | yes | TTML1 with the DFXP presentation profile; clock times in milliseconds (frame-exact after `snap_to_frames`). | @@ -64,6 +64,22 @@ Text is wrapped at 32 columns, bottom-aligned (last row 15) and centred. Charact character, as the standard requires); others are dropped. If there is no room to load a caption before its in point (for example a caption at 00:00:00:00), it appears as soon as it is loaded. +### MCC writer + +The writer merges the scheduled CEA-608 and CEA-708 frames in timestamp order. Frames without +caption data are omitted, including during a displayed caption; its explicit erase/hide command +still lands at its out point. CDP sequence counters advance per emitted packet, wrapping at 16 bits. +Export work and output size therefore follow the scheduled caption data, not the time between +cues or their duration (#2811). Empty-frame insertion is an optional MCC export behavior; see +[EZTitles' MCC export documentation, "Write empty frames"](https://eztitles.com/Webhelp/EZTitles/export_subtitles_cc_mcc.htm). +FilmCraft does not offer continuous padding for consumers that require a packet on every video frame. + +Regression tests cover sparse output, both caption streams individually and together, drop-frame +and non-drop timecodes, exact cue round trips, and timestamps near the tick storage limit. +An external FFmpeg 9.0.2 decode of the two-cue regression fixture preserved text and all four +timestamps (2.002–4.004 s and 120.120–122.122 s); the combined-stream export has 29 data lines, +down from 3,614. No media fixtures are committed. + ## Burn-in `burn::render_caption` lays out a caption with the track style (font size relative to a 1080-line @@ -85,7 +101,7 @@ implementation's code was consulted. - Unit tests per module (timestamps, decoding, sloppy SRT, WebVTT blocks and voices, CEA-608 tables, PAC round trips, known SCC pop-on and roll-up streams, layout). -- `tests/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers, +- `tests/it/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers, settings, STYLE blocks), SCC and MCC (drop-frame and non-drop, gaps and back-to-back captions; MCC also through CEA-708 alone), EBU STL (25 and 29.97 fps, accents, extension blocks), TTML (23.976 / 25 / 29.97 / 59.94, ids, speakers) round-trip frame-exactly; DFXP round-trips after diff --git a/crates/captions/src/burn.rs b/crates/captions/src/burn.rs index 463e9737b..e29c3a5c3 100644 --- a/crates/captions/src/burn.rs +++ b/crates/captions/src/burn.rs @@ -12,6 +12,12 @@ use filmcraft_time::Tick; use filmcraft_text::{ParagraphStyle, TextStyle, layout, render}; +/// Caption overlays are transient render resources, not source images. Keep one cue from +/// materialising an unbounded mask or RGBA buffer. +const MAX_OVERLAY_PIXELS: usize = 16 << 20; +const MAX_CAPTION_SIZE_1080: f32 = 256.0; +const MAX_OUTLINE_RADIUS: f32 = 64.0; + /// A rendered caption: premultiplied linear-light RGBA f32 pixels at `(x, y)` in the frame. #[derive(Clone, Debug, PartialEq)] pub struct Overlay { @@ -110,7 +116,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> return None; } let scale = h as f32 / 1080.0; - let px = (style.size * scale).max(4.0); + let px = (style.size.clamp(4.0, MAX_CAPTION_SIZE_1080) * scale).clamp(4.0, h as f32); let max_w = w as f32 * 0.9; let ts = caption_style(&style.font, px); let mut lines: Vec = Vec::new(); @@ -124,7 +130,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> } let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(&ts.family, &ts.style).face).metrics(px); let (asc, desc) = (vm.ascent, vm.descent); - let lh = px * style.line_spacing.max(0.8); + let lh = px * style.line_spacing.clamp(0.8, 4.0); let pad_x = (px * 0.3).round(); let block_h = lh * lines.len() as f32; let (line_override, align_override) = vtt_overrides(&c.settings); @@ -148,15 +154,24 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> CaptionAlign::Center => (w as f32 - lw) / 2.0, }) .collect(); - let outline = (style.outline * scale).max(0.0); + let outline = (style.outline * scale).clamp(0.0, MAX_OUTLINE_RADIUS); let grow = outline.ceil() + 1.0; let bx0 = xs.iter().zip(&widths).map(|(x, _)| x - pad_x - grow).fold(f32::MAX, f32::min).floor(); let bx1 = xs.iter().zip(&widths).map(|(x, lw)| x + lw + pad_x + grow).fold(f32::MIN, f32::max).ceil(); let by0 = (top - grow).floor(); let by1 = (top + block_h + grow).ceil(); - let (ox, oy) = (bx0 as i32, by0 as i32); - let ow = (bx1 - bx0).max(1.0) as usize; - let oh = (by1 - by0).max(1.0) as usize; + // Text can be wider than the frame (an unbroken imported WebVTT word, for example). Raster + // only the visible intersection; `draw` receives the original coordinates and clips into it. + let (left, top) = (bx0.clamp(0.0, w as f32), by0.clamp(0.0, h as f32)); + let (right, bottom) = (bx1.clamp(0.0, w as f32), by1.clamp(0.0, h as f32)); + if right <= left || bottom <= top { + return None; + } + let (ox, oy) = (left as i32, top as i32); + let (ow, oh) = ((right - left) as usize, (bottom - top) as usize); + if ow.checked_mul(oh).is_none_or(|pixels| pixels > MAX_OVERLAY_PIXELS) { + return None; + } let mut cover = vec![0.0f32; ow * oh]; let mut bg = vec![0.0f32; ow * oh]; for (i, line) in lines.iter().enumerate() { @@ -319,6 +334,21 @@ mod tests { assert!(canvas.chunks(4).any(|p| p[0] > 0.9)); } + #[test] + fn overlay_is_clipped_to_the_frame_for_an_unbroken_word() { + let o = render_caption(&cap(&"W".repeat(10_000)), &CaptionStyle::default(), 640, 360).unwrap(); + assert!(o.x >= 0 && o.y >= 0); + assert!(o.w <= 640 && o.h <= 360, "{}x{}", o.w, o.h); + assert!(o.w.checked_mul(o.h).unwrap() <= MAX_OVERLAY_PIXELS); + } + + #[test] + fn hostile_outline_is_bounded() { + let st = CaptionStyle { outline: 1_000_000.0, ..Default::default() }; + let o = render_caption(&cap("Safe"), &st, 640, 360).unwrap(); + assert!(o.w <= 640 && o.h <= 360, "{}x{}", o.w, o.h); + } + #[test] fn outline_draws_around_text() { let st = CaptionStyle { background: false, outline: 4.0, outline_color: [255, 0, 0, 255], ..Default::default() }; diff --git a/crates/captions/src/mcc.rs b/crates/captions/src/mcc.rs index 3004872bb..b5ecb243c 100644 --- a/crates/captions/src/mcc.rs +++ b/crates/captions/src/mcc.rs @@ -1,4 +1,4 @@ -//! MacCaption MCC (`.mcc`): closed-caption data as text, one line per video frame, each line a +//! MacCaption MCC (`.mcc`): closed-caption data as text, one line per frame carrying data, each line a //! SMPTE ST 334-2 caption distribution packet (CDP) carrying CEA-608 byte pairs and CEA-708 //! (DTVCC) packets, written in hexadecimal with MacCaption's letter abbreviations. //! @@ -19,8 +19,8 @@ //! bytes)), optional time code / service info sections, footer `74`, the counter again and a //! checksum that makes the packet's byte sum 0 mod 256. The ancillary packet then ends with one ST 291 checksum byte //! (low 8 bits of DID + SDID + data count + CDP bytes). -//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): every frame from the first caption -//! data to one frame after the last carries a CDP with 20 cc_data triplets: field 1 CEA-608 +//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): each scheduled caption-data frame +//! carries a CDP with 20 cc_data triplets; empty frames are omitted. Each CDP contains field 1 CEA-608 //! pop-on data (the same schedule as [SCC](crate::scc)), a null field-2 pair, CEA-708 //! service 1 data and padding. The CEA-708 captions emulate pop-on with two windows: each //! caption is defined (hidden) and loaded into one window while the other shows, then a @@ -344,6 +344,8 @@ struct Win { defined: bool, visible: bool, rows: Vec, + max_rows: usize, + max_cols: usize, } struct Dec708 { @@ -416,8 +418,9 @@ impl Dec708 { fn put(&mut self, c: char) { let w = self.w(); match w.rows.last_mut() { - Some(row) => row.push(c), - None => w.rows.push(c.to_string()), + Some(row) if row.chars().count() < w.max_cols => row.push(c), + None if w.max_rows > 0 && w.max_cols > 0 => w.rows.push(c.to_string()), + _ => {} } } fn bitmap(&mut self, m: u8, f: impl Fn(&mut Win)) { @@ -441,7 +444,8 @@ impl Dec708 { } } 0x0c => self.w().rows = vec![String::new()], - 0x0d => self.w().rows.push(String::new()), + 0x0d if self.w().rows.len() < self.w().max_rows => self.w().rows.push(String::new()), + 0x0d => {} 0x0e => { if let Some(r) = self.w().rows.last_mut() { r.clear(); @@ -500,11 +504,24 @@ impl Dec708 { 0x98..=0x9f => { let n = (c - 0x98) as usize; let vis = arg(i) & 0x20 != 0; + let max_rows = (arg(i + 3) & 0x0f) as usize + 1; + let max_cols = (arg(i + 4) & 0x3f) as usize + 1; let w = &mut self.win[n]; if !w.defined { - *w = Win { defined: true, visible: vis, rows: vec![String::new()] }; + *w = Win { defined: true, visible: vis, rows: vec![String::new()], max_rows, max_cols }; } else { w.visible = vis; + w.max_rows = max_rows; + w.max_cols = max_cols; + w.rows.truncate(max_rows); + for row in &mut w.rows { + if let Some((at, _)) = row.char_indices().nth(max_cols) { + row.truncate(at); + } + } + if w.rows.is_empty() { + w.rows.push(String::new()); + } } self.cur = n; i += 6; @@ -525,14 +542,19 @@ pub fn write(doc: &Document, drop_frame: bool) -> String { write_with(doc, drop_frame, true, true) } -/// [`write`] choosing which caption streams to include. +/// Write MCC with independently selected CEA-608 field 1 and CEA-708 service 1 streams. +/// +/// Emit only scheduled data frames, preserving their timestamps and explicit erase/hide +/// commands. Empty time spans add no packets; CDP counters advance per emitted packet and +/// wrap at 16 bits. `drop_frame` selects 30DF rather than 30 timecode labels at 29.97 fps. +/// An empty document or two disabled streams produces a header with no data lines. pub fn write_with(doc: &Document, drop_frame: bool, cea608: bool, cea708: bool) -> String { let f1 = if cea608 { scc::schedule(doc) } else { BTreeMap::new() }; let dtv = if cea708 { schedule_708(doc) } else { BTreeMap::new() }; let mut out = String::from("File Format=MacCaption_MCC V1.0\n\n"); out.push_str("///////////////////////////////////////////////////////////////////////////////////\n"); out.push_str("// Closed captions written by FilmCraft: SMPTE ST 334-2 caption distribution packets\n"); - out.push_str("// carrying CEA-608 field 1 and CEA-708 service 1 data, one packet per frame.\n"); + out.push_str("// carrying CEA-608 field 1 and CEA-708 service 1 data; empty frames omitted.\n"); out.push_str("///////////////////////////////////////////////////////////////////////////////////\n\n"); let n = doc.cues.len() as u64; let h = doc.cues.iter().fold(0xcbf2_9ce4_8422_2325u64 ^ n, |h, c| (h ^ c.start.0 as u64 ^ (c.end.0 as u64).rotate_left(17)).wrapping_mul(0x100_0000_01b3)); @@ -546,15 +568,20 @@ pub fn write_with(doc: &Document, drop_frame: bool, cea608: bool, cea708: bool) )); out.push_str("Creation Program=FilmCraft\n"); out.push_str(&format!("Time Code Rate={}\n\n", if drop_frame { "30DF" } else { "30" })); - let first = f1.keys().next().copied().into_iter().chain(dtv.keys().next().copied()).min(); - let last = f1.keys().next_back().copied().into_iter().chain(dtv.keys().next_back().copied()).max(); - if let (Some(a), Some(b)) = (first, last) { - for (seq, f) in (a..=b + 1).enumerate() { - let data = FrameData { f1: f1.get(&f).copied(), dtvcc: dtv.get(&f).cloned().unwrap_or_default() }; - let (_, hh, mm, ss, ff) = frames_to_fields(f, RATE, drop_frame); - let sep = if drop_frame { ';' } else { ':' }; - out.push_str(&format!("{hh:02}:{mm:02}:{ss:02}{sep}{ff:02}\t{}\n", compress(&build_cdp(seq as u16, &data)))); - } + // Merge the sparse schedules without walking (or allocating for) input-derived time gaps. + // Erase/hide commands are scheduled data too, so omitting padding preserves cue end times. + let mut f1 = f1.into_iter().peekable(); + let mut dtv = dtv.into_iter().peekable(); + let mut seq = 0u16; + while let Some(f) = f1.peek().map(|(f, _)| *f).into_iter().chain(dtv.peek().map(|(f, _)| *f)).min() { + let data = FrameData { + f1: f1.next_if(|(frame, _)| *frame == f).map(|(_, pair)| pair), + dtvcc: dtv.next_if(|(frame, _)| *frame == f).map(|(_, pairs)| pairs).unwrap_or_default(), + }; + let (_, hh, mm, ss, ff) = frames_to_fields(f, RATE, drop_frame); + let sep = if drop_frame { ';' } else { ':' }; + out.push_str(&format!("{hh:02}:{mm:02}:{ss:02}{sep}{ff:02}\t{}\n", compress(&build_cdp(seq, &data)))); + seq = seq.wrapping_add(1); } out } @@ -647,10 +674,12 @@ pub fn parse(text: &str) -> Result { mod tests { use super::*; + /// Build a caption fixture from start/end frame indices on the MCC 29.97 fps grid. fn cue(s: i64, e: i64, text: &str) -> Cue { Cue { start: RATE.tick_of(s), end: RATE.tick_of(e), text: text.into(), ..Default::default() } } + /// Check that MCC abbreviations round-trip bytes and reject an incomplete hex pair. #[test] fn abbreviations_round_trip() { let b = [0x61, 0x01, 0x49, 0x96, 0x69, 0xfa, 0, 0, 0xfa, 0, 0, 0xfc, 0x80, 0x80, 0x00, 0x12, 0xe1, 0, 0, 0, 0xfb, 0x80, 0x80, 0xfd, 0x80, 0x80]; @@ -660,6 +689,7 @@ mod tests { assert_eq!(expand("T52S5"), None); } + /// Verify CDP framing, caption triplets, sequence fields, and both packet checksums. #[test] fn cdp_structure_and_checksum() { let d = FrameData { f1: Some((0x14, 0x20)), dtvcc: vec![(true, 0x02, 0x21), (false, 0x89, 0x01)] }; @@ -680,6 +710,7 @@ mod tests { assert_eq!(t[19], [0xfa, 0, 0]); } + /// Preserve caption text and timing through combined-stream and CEA-708-only exports. #[test] fn writes_and_reads_608_and_708() { let doc = Document { @@ -699,4 +730,61 @@ mod tests { assert_eq!(got, vec![(60, 120, "Hello, world!"), (120, 200, "Second caption\nwith two lines"), (260, 300, "Café “q” ♪")]); assert!(parse("garbage").is_err()); } + + /// Keep separated short captions compact while preserving cues and valid CDP packets. + /// Exercise each stream combination with both drop-frame and non-drop timecode labels. + #[test] + fn export_size_depends_on_caption_data_not_empty_frames() { + // A small reproducer: the old writer emits thousands of padding-only lines. + let doc = Document { cues: vec![cue(60, 120, "First"), cue(3_600, 3_660, "Last")], ..Default::default() }; + for drop_frame in [false, true] { + for (cea608, cea708) in [(true, true), (true, false), (false, true)] { + let text = std::panic::catch_unwind(|| write_with(&doc, drop_frame, cea608, cea708)).unwrap(); + let lines: Vec<_> = text.lines().filter_map(|line| line.split_once('\t')).collect(); + assert!(lines.len() < 100, "{} lines for two short captions", lines.len()); + assert!(text.len() < 16_384); + assert_eq!(parse(&text).unwrap().cues, doc.cues); + for (seq, (_, data)) in lines.iter().enumerate() { + let bytes = expand(data).unwrap(); + assert_eq!(u16::from_be_bytes([bytes[8], bytes[9]]), seq as u16); + assert_eq!(bytes[3..76].iter().map(|&b| b as u32).sum::() % 256, 0); + let trips = cc_triplets(&bytes).unwrap(); + assert!(trips.iter().any(|&[m, a, b]| m & 4 != 0 && (m & 3 >= 2 || a & 0x7f != 0 || b & 0x7f != 0))); + } + } + } + } + + /// Bound output for distant or long cues near the tick limit without changing their timing. + #[test] + fn distant_and_long_cues_round_trip_near_tick_limit() { + let last = RATE.frame_at(crate::Tick(i64::MAX)) - 10; + for cues in [vec![cue(60, 120, "First"), cue(last - 60, last, "Last")], vec![cue(60, last, "Long")]] { + let doc = Document { cues, ..Default::default() }; + for drop_frame in [false, true] { + for (cea608, cea708) in [(true, true), (true, false), (false, true)] { + let text = std::panic::catch_unwind(|| write_with(&doc, drop_frame, cea608, cea708)).unwrap(); + assert!(text.len() < 16_384); + assert_eq!(parse(&text).unwrap().cues, doc.cues); + } + } + } + } + + /// Emit only the MCC header when the document is empty or both streams are disabled. + #[test] + fn empty_schedules_have_no_data_lines() { + let doc = Document { cues: vec![cue(60, 120, "Caption")], ..Default::default() }; + assert!(!write_with(&doc, true, false, false).contains('\t')); + assert!(!write(&Document::default(), true).contains('\t')); + } + + /// Prevent CEA-708 text and line breaks from overflowing a window's declared dimensions. + #[test] + fn decoder_keeps_708_text_within_declared_window_dimensions() { + let mut decoder = Dec708::new(); + // Define window 0 with one row and one column, then try to overflow both. + decoder.service_data(&[0x98, 0, 0, 0, 0, 0, 0, b'A', b'B', 0x0d, b'C']); + assert_eq!(decoder.win[0].rows, ["A"]); + } } diff --git a/crates/captions/src/scc.rs b/crates/captions/src/scc.rs index 1d2fafbfd..e894e17cf 100644 --- a/crates/captions/src/scc.rs +++ b/crates/captions/src/scc.rs @@ -34,7 +34,7 @@ pub fn parse_timecode(s: &str) -> Option { let df = s.contains([';', '.', ',']); let parts: Vec<&str> = s.split([':', ';', '.', ',']).collect(); let [h, m, sec, f] = parts.as_slice() else { return None }; - let n = |p: &str| if (1..=3).contains(&p.len()) { p.parse::().ok() } else { None }; + let n = |p: &str| if (1..=3).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_digit()) { p.parse::().ok() } else { None }; Some(fields_to_frames(n(h)?, n(m)?, n(sec)?, n(f)?, RATE, df)) } @@ -304,7 +304,12 @@ pub fn wrap_rows(text: &str) -> Vec { for line in text.lines() { let mut cur = String::new(); for word in line.split_whitespace() { - let mut word = word.to_string(); + if rows.len() == 15 { + return rows; + } + // SCC can emit at most 15 32-column rows. Do not repeatedly copy an unbounded word + // only to discard all but that small prefix below. + let mut word: String = word.chars().take(COLS * (15 - rows.len())).collect(); loop { let need = if cur.is_empty() { word.chars().count() } else { cur.chars().count() + 1 + word.chars().count() }; if need <= COLS { @@ -316,11 +321,17 @@ pub fn wrap_rows(text: &str) -> Vec { } if !cur.is_empty() { rows.push(std::mem::take(&mut cur)); + if rows.len() == 15 { + return rows; + } continue; } let head: String = word.chars().take(COLS).collect(); word = word.chars().skip(COLS).collect(); rows.push(head); + if rows.len() == 15 { + return rows; + } if word.is_empty() { break; } @@ -484,6 +495,14 @@ mod tests { Cue { start: RATE.tick_of(s), end: RATE.tick_of(e), text: text.into(), ..Default::default() } } + #[test] + fn timecode_rejects_signed_fields() { + assert_eq!(parse_timecode("00:00:-5:00"), None); + assert_eq!(parse_timecode("00:+1:00:00"), None); + assert_eq!(parse_timecode("00:00:00:"), None); + assert_eq!(parse_timecode("00:00:01:00"), Some(30)); + } + #[test] fn timecodes() { assert_eq!(parse_timecode("00:01:00;02"), Some(1800)); @@ -533,6 +552,13 @@ mod tests { assert_eq!(rows.join(" "), "the quick brown fox jumps over the lazy dog and keeps running far away"); } + #[test] + fn wrapping_discards_an_unrepresentable_word_without_quadratic_copying() { + let rows = wrap_rows(&"x".repeat(100_000)); + assert_eq!(rows.len(), 15); + assert!(rows.iter().all(|row| row.len() == COLS)); + } + #[test] fn cue_at_zero_is_delayed_not_lost() { let doc = Document { cues: vec![cue(0, 60, "Start")], ..Default::default() }; diff --git a/crates/captions/src/stl.rs b/crates/captions/src/stl.rs index da49f3b10..5b0e9eeff 100644 --- a/crates/captions/src/stl.rs +++ b/crates/captions/src/stl.rs @@ -212,9 +212,13 @@ fn encode_text(text: &str) -> Vec { let mut out = Vec::new(); let mut rest = text; while let Some(ch) = rest.chars().next() { - if ch == '<' - && let Some(end) = rest.find('>') - { + let is_tag_start = ch == '<' + && rest + .get(1..) + .and_then(|tail| tail.strip_prefix('/').or(Some(tail))) + .and_then(|name| name.as_bytes().first()) + .is_some_and(u8::is_ascii_alphabetic); + if is_tag_start && let Some(end) = rest.find('>') { let tag = &rest[1..end]; // Only a syntactically plausible tag is markup. Comparisons such as // "2 < 3 > 1" and "x < y > z" must not lose their middle text. @@ -232,6 +236,18 @@ fn encode_text(text: &str) -> Vec { continue; } } + if is_tag_start && !rest.contains('>') { + // This cannot become markup later. Encode the remaining literal text in one pass + // instead of searching the shrinking suffix after every opening bracket. + for ch in rest.chars() { + if ch == '\n' { + out.push(0x8a); + } else { + out.extend(encode_6937(ch).unwrap_or_else(|| vec![b'?'])); + } + } + break; + } if ch == '\n' { out.push(0x8a); } else { @@ -474,6 +490,14 @@ mod tests { assert_eq!(encode_text("Hi"), vec![0x80, b'H', b'i', 0x81]); } + #[test] + fn unterminated_markup_is_encoded_in_one_pass() { + let text = format!(" Result<()> { + let bytes = text.as_bytes(); + let mut i = 0; + let mut depth = 0usize; + while let Some(relative) = text[i..].find('<') { + let start = i + relative; + let rest = &text[start..]; + if rest.starts_with("") else { return Ok(()) }; + i = start + 4 + end + 3; + continue; + } + if rest.starts_with("") else { return Ok(()) }; + i = start + 9 + end + 3; + continue; + } + if rest.starts_with("") else { return Ok(()) }; + i = start + 2 + end + 2; + continue; + } + + let mut j = start + 1; + let mut quote = None; + while j < bytes.len() { + match (bytes[j], quote) { + (b'\'' | b'\"', None) => quote = Some(bytes[j]), + (b, Some(q)) if b == q => quote = None, + (b'>', None) => break, + _ => {} + } + j += 1; + } + if j == bytes.len() { + return Ok(()); + } + + if rest.starts_with(" MAX_XML_DEPTH { + return Err(Error::Syntax { line: 1, msg: format!("TTML XML nesting exceeds {MAX_XML_DEPTH} elements") }); + } + } + } + i = j + 1; + } + Ok(()) +} + fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len()); for c in s.chars() { @@ -55,8 +116,7 @@ fn esc(s: &str) -> String { } fn clock(t: Tick) -> String { - let ms = (t.0.max(0) + TICKS_PER_MS / 2) / TICKS_PER_MS; - format!("{:02}:{:02}:{:02}.{:03}", ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000) + crate::format_clock(t, '.') } /// Time expression for `t`: frames at `rate` when it is on a frame boundary, else clock time. @@ -70,7 +130,23 @@ fn time_expr(t: Tick, rate: Option) -> String { /// Caption text (`\n` lines, ``/``/`` tags) → TTML inline content. fn inline(text: &str) -> String { let mut out = String::new(); - let mut open: Vec<&str> = Vec::new(); + // Keep the logical style state separate from what has been written. There are only three + // supported styles, so duplicate or crossing input tags cannot grow an unbounded span stack. + let mut active: Vec<&'static str> = Vec::new(); + let mut emitted: Vec<&'static str> = Vec::new(); + let sync = |out: &mut String, active: &[&'static str], emitted: &mut Vec<&'static str>| { + if *emitted != active { + for _ in emitted.drain(..) { + out.push_str(""); + } + for attr in active { + out.push_str("'); + emitted.push(attr); + } + } + }; let mut rest = text; while !rest.is_empty() { if let Some(after) = rest.strip_prefix('<') @@ -89,26 +165,16 @@ fn inline(text: &str) -> String { }; if let Some(a) = attr { if closing { - if let Some(pos) = open.iter().rposition(|x| *x == a) { - // close spans opened after it, then it, then reopen the inner ones - let inner: Vec<&str> = open.drain(pos..).skip(1).collect(); - for _ in 0..=inner.len() { - out.push_str(""); - } - for x in inner { - out.push_str(&format!("")); - open.push(x); - } - } - } else { - out.push_str(&format!("")); - open.push(a); + active.retain(|style| *style != a); + } else if !active.contains(&a) { + active.push(a); } } rest = &after[end + 1..]; continue; } let Some(ch) = rest.chars().next() else { break }; + sync(&mut out, &active, &mut emitted); if ch == '\n' { out.push_str("
"); } else { @@ -116,7 +182,7 @@ fn inline(text: &str) -> String { } rest = &rest[ch.len_utf8()..]; } - for _ in open { + for _ in emitted { out.push_str("
"); } out @@ -277,8 +343,42 @@ fn speaker_names(root: roxmltree::Node) -> HashMap { fn speaker(n: roxmltree::Node, names: &HashMap) -> Option { let refs = n.attributes().find(|a| a.name() == "agent" && metadata_namespace(a.namespace())).map(|a| a.value()).or_else(|| n.attribute("agent")); refs.map(|refs| { - // The project stores one speaker label; multiple agents become an ordered display label. - refs.split_whitespace().map(|id| names.get(id).cloned().unwrap_or_else(|| id.trim_start_matches('_').replace('_', " "))).collect::>().join(", ") + // The project stores one speaker label. Keep document-defined long names and repeated + // references from multiplying into an unbounded per-cue allocation. + const MAX_REFS: usize = 64; + const MAX_LABEL_BYTES: usize = 4096; + let mut out = String::new(); + for id in refs.split_whitespace().take(MAX_REFS) { + if !out.is_empty() { + if out.len() + 2 > MAX_LABEL_BYTES { + break; + } + out.push_str(", "); + } + let name = names.get(id).map(String::as_str).unwrap_or(id); + if names.contains_key(id) { + let room = MAX_LABEL_BYTES.saturating_sub(out.len()); + let mut end = 0; + for (i, ch) in name.char_indices() { + if i + ch.len_utf8() > room { + break; + } + end = i + ch.len_utf8(); + } + out.push_str(&name[..end]); + } else { + for ch in id.trim_start_matches('_').chars() { + if out.len() + ch.len_utf8() > MAX_LABEL_BYTES { + break; + } + out.push(if ch == '_' { ' ' } else { ch }); + } + } + if out.len() == MAX_LABEL_BYTES { + break; + } + } + out }) } @@ -356,7 +456,7 @@ fn parse_time(s: &str, tm: Timing) -> Option { let unit_start = s.find(|c: char| c.is_ascii_alphabetic())?; let (v, unit) = s.split_at(unit_start); let (vi, vf) = v.split_once('.').unwrap_or((v, "")); - if !vi.bytes().all(|b| b.is_ascii_digit()) || vi.len() > 15 { + if vi.is_empty() || !vi.bytes().all(|b| b.is_ascii_digit()) || vi.len() > 15 { return None; } let t = match unit { @@ -482,6 +582,7 @@ fn clean(s: &str) -> String { } pub fn parse(text: &str) -> Result { + check_xml_depth(text)?; let xml = roxmltree::Document::parse(text).map_err(|_| Error::NotFormat("TTML"))?; let root = xml.root_element(); if root.tag_name().name() != "tt" { @@ -649,6 +750,12 @@ mod tests { } } + #[test] + fn deeply_nested_ttml_is_rejected_before_xml_parsing() { + let xml = format!("{}

caption

{}", "
".repeat(MAX_XML_DEPTH), "
".repeat(MAX_XML_DEPTH)); + assert!(matches!(parse(&xml), Err(Error::Syntax { msg, .. }) if msg.contains("nesting exceeds"))); + } + #[test] fn missing_timing_parameters_keep_defaults() { for (params, begin, end) in [ @@ -758,6 +865,17 @@ mod tests { } } + #[test] + fn offset_time_without_integer_digits_is_rejected() { + let t = tm(FrameRate::FPS_25); + for expr in ["s", "ms", "h", "m", "t", "f", ".5s", ".5ms", ".s"] { + assert_eq!(parse_time(expr, t), None, "{expr}"); + } + assert_eq!(parse_time("0s", t), Some(Tick(0))); + assert_eq!(parse_time("0.5s", t), Some(Tick(TICKS_PER_SECOND / 2))); + assert_eq!(parse_time("1ms", t), Some(Tick(TICKS_PER_SECOND / 1000))); + } + #[test] fn reads_styles_spans_and_nesting() { let x = r#" @@ -805,4 +923,11 @@ mod tests { let d = write(&doc, Flavor::Dfxp, Some(r), "en"); assert!(d.contains(DFXP_PROFILE) && d.contains("begin=\"00:00:01.001\""), "{d}"); } + + #[test] + fn duplicate_and_crossing_spans_have_bounded_output() { + let text = format!("{}x{}", "".repeat(1_000), "".repeat(1_000)); + let out = inline(&text); + assert_eq!(out, "x"); + } } diff --git a/crates/captions/src/vtt.rs b/crates/captions/src/vtt.rs index a1afb2445..0ad76615e 100644 --- a/crates/captions/src/vtt.rs +++ b/crates/captions/src/vtt.rs @@ -79,11 +79,11 @@ pub fn parse(text: &str) -> Result { let mut seen_cue = false; for b in blocks { let first = b[0].trim(); - if !first.contains("-->") && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) { + if !b.iter().any(|l| l.contains("-->")) && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) { doc.blocks.push(b.join("\n")); continue; } - if !seen_cue && !first.contains("-->") && (first == "STYLE" || first == "REGION") { + if !seen_cue && !b.iter().any(|l| l.contains("-->")) && (first == "STYLE" || first == "REGION") { doc.blocks.push(b.join("\n")); continue; } @@ -152,6 +152,15 @@ mod tests { const SAMPLE: &str = "WEBVTT - with header\nKind: captions\n\nSTYLE\n::cue { color: yellow }\n\nNOTE a comment\nspanning lines\n\nintro\n00:01.000 --> 00:00:03.500 line:90% align:start\nHello there\nsecond line\n\n00:00:04.000 --> 00:00:05.000\nplain & tags\n"; + #[test] + fn style_region_identifier_cue_is_kept() { + for id in ["STYLE", "REGION"] { + let d = parse(&format!("WEBVTT\n\n{id}\n00:00:01.000 --> 00:00:02.000\nHi\n")).unwrap(); + assert_eq!(d.cues.len(), 1, "{id}"); + assert!(d.blocks.is_empty(), "{id}"); + } + } + #[test] fn parse_sample() { let d = parse(SAMPLE).unwrap(); @@ -190,6 +199,14 @@ mod tests { assert_eq!(split_voice(closed), (None, closed.to_string())); } + #[test] + fn note_prefixed_cue_id_is_a_cue() { + let d = parse("WEBVTT\n\nNOTE 1\n00:00:01.000 --> 00:00:02.000\nhello\n").unwrap(); + assert_eq!(d.cues.len(), 1); + assert_eq!(d.cues[0].id.as_deref(), Some("NOTE 1")); + assert!(d.blocks.is_empty()); + } + #[test] fn without_signature() { let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap(); diff --git a/crates/captions/tests/it/main.rs b/crates/captions/tests/it/main.rs new file mode 100644 index 000000000..538c5cebf --- /dev/null +++ b/crates/captions/tests/it/main.rs @@ -0,0 +1,4 @@ +mod roundtrip; +mod ttml_body_timing; +mod ttml_clock_overflow; +mod ttml_speakers; diff --git a/crates/captions/tests/roundtrip.rs b/crates/captions/tests/it/roundtrip.rs similarity index 100% rename from crates/captions/tests/roundtrip.rs rename to crates/captions/tests/it/roundtrip.rs diff --git a/crates/captions/tests/ttml_body_timing.rs b/crates/captions/tests/it/ttml_body_timing.rs similarity index 100% rename from crates/captions/tests/ttml_body_timing.rs rename to crates/captions/tests/it/ttml_body_timing.rs diff --git a/crates/captions/tests/it/ttml_clock_overflow.rs b/crates/captions/tests/it/ttml_clock_overflow.rs new file mode 100644 index 000000000..23a3870ad --- /dev/null +++ b/crates/captions/tests/it/ttml_clock_overflow.rs @@ -0,0 +1,18 @@ +//! TTML / DFXP export must not overflow for cue times near `i64::MAX` (e.g. from an MCC with a +//! huge timecode). + +use filmcraft_captions::{Cue, Document, Format, WriteOptions, write}; +use filmcraft_time::Tick; + +fn doc(end: Tick) -> Document { + Document { cues: vec![Cue { start: Tick(0), end, text: "x".into(), ..Cue::default() }], ..Document::default() } +} + +#[test] +fn ttml_and_dfxp_write_survive_tick_max() { + for format in [Format::Ttml, Format::Dfxp] { + let out = write(&doc(Tick(i64::MAX)), format, WriteOptions::default()); + let s = String::from_utf8(out).unwrap(); + assert!(s.contains("end=\""), "{format:?}: {s}"); + } +} diff --git a/crates/captions/tests/ttml_speakers.rs b/crates/captions/tests/it/ttml_speakers.rs similarity index 100% rename from crates/captions/tests/ttml_speakers.rs rename to crates/captions/tests/it/ttml_speakers.rs diff --git a/crates/codecs/src/audio_timing_tests.rs b/crates/codecs/src/audio_timing_tests.rs index 62f25cb10..e712f7052 100644 --- a/crates/codecs/src/audio_timing_tests.rs +++ b/crates/codecs/src/audio_timing_tests.rs @@ -50,6 +50,8 @@ fn mkv(asc: &[u8], rate: u32, units: &[Vec], lacing: LacingMode, gap: usize, let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC"); spec.codec_private = asc.to_vec(); spec.audio = Some((rate as f64, 2, None)); + // an access unit's duration, rounded to the nanosecond; without it the writer laces nothing + spec.default_duration_ns = Some((1024 * 1_000_000_000 + u64::from(rate) / 2) / u64::from(rate)); let opts = MuxOptions { lacing, ..MuxOptions::default() }; assert_eq!(opts.timestamp_scale, 1_000_000, "1 ms timestamps"); let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], opts).unwrap(); @@ -116,6 +118,8 @@ fn matroska_millisecond_stamps_play_back_to_back() { let frames = units.len().saturating_sub(1) * 1024; for lacing in [LacingMode::None, LacingMode::Xiph] { let bytes = mkv(&asc, rate, &units, lacing, usize::MAX, 0); + let laced = filmcraft_matroska::open(&bytes[..]).unwrap().tracks.iter().any(|t| t.samples.iter().any(|s| s.lace > 0)); + assert_eq!(laced, !matches!(lacing, LacingMode::None), "{rate} Hz, {lacing:?}: laced blocks"); let src = MkvSource::open("tone.mkv", bytes.clone()).unwrap(); let got = played(&src, rate, frames); let (n, first) = mismatches(&got, &want, 0); diff --git a/crates/codecs/src/avi.rs b/crates/codecs/src/avi.rs index 659524427..8855c3a65 100644 --- a/crates/codecs/src/avi.rs +++ b/crates/codecs/src/avi.rs @@ -154,6 +154,8 @@ fn random_access(data: &[u8], hevc: bool) -> bool { /// The packets of a framed audio stream: every frame header found in the stream of chunks, and /// the samples each frame decodes to. Bytes that start no frame are skipped (resynchronisation). +const MAX_FRAMED_AUDIO_SCAN_BYTES: u64 = 256 << 20; + fn split_frames( file: &AviFile, bytes: &crate::Src, @@ -161,12 +163,18 @@ fn split_frames( ts: &filmcraft_mpegts::Codec, ) -> (Vec, Vec, Vec, Option) { let Some(st) = file.streams.get(s) else { return Default::default() }; + // AVI indexes can reference the same physical chunk repeatedly. Keep header scanning bounded + // by both the source itself and a practical absolute limit before issuing those repeated reads. + let budget = bytes.0.len().saturating_mul(4).min(MAX_FRAMED_AUDIO_SCAN_BYTES); // chunk start positions in the concatenated stream let mut at = Vec::with_capacity(st.chunks.len()); let mut total = 0usize; for c in &st.chunks { at.push(total); total = total.saturating_add(c.size as usize); + if total as u64 > budget { + return Default::default(); + } } let locate = |pos: usize| -> Option<(usize, usize)> { let k = at.partition_point(|&a| a <= pos).checked_sub(1)?; @@ -329,10 +337,12 @@ impl AviSource { let mut audio_streams = Vec::new(); for &s in &astreams { let Some(st) = file.streams.get(s) else { continue }; - let w = st.audio.clone().unwrap_or_default(); + let mut w = st.audio.clone().unwrap_or_default(); + let Ok((sample_rate, channels)) = filmcraft_media::bounded_audio_dimensions(w.sample_rate, u32::from(w.channels)) else { continue }; + w.sample_rate = sample_rate; + w.channels = u16::try_from(channels).unwrap_or(u16::MAX); let (codec, label) = audio_codec(&w); - let mut sample_rate = w.sample_rate.max(1); - let mut channels = u32::from(w.channels.max(1)); + let (mut sample_rate, mut channels) = (sample_rate, channels); let (pieces, first, starts) = match &codec { ACodec::Pcm(cfg) => { let block = usize::from(w.block_align.max(1)).max((usize::from(cfg.bits).div_ceil(8)) * channels as usize); @@ -529,14 +539,18 @@ impl MediaSource for AviSource { return Err(MediaError::Unsupported(format!("{name} audio"))); } let ch = ainfo.channels.max(1) as usize; - let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate.max(1)); + let source_frames = filmcraft_media::resample_source_frames(frames, ainfo.sample_rate, sample_rate)?; + let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?; + filmcraft_media::validate_audio_buffer_shape(ch, need)?; + filmcraft_media::validate_audio_buffer_shape(ch, frames)?; + let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate); let s0 = (start as f64 * ratio).floor() as i64; - let need = (frames as f64 * ratio).ceil() as i64 + 2; - let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let need_i64 = i64::try_from(need).unwrap_or(i64::MAX); + let mut src: Vec> = vec![vec![0.0; need]; ch]; let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner()); let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1); while let Some(&pk_start) = a.starts.get(i) { - if pk_start >= s0 + need { + if pk_start >= s0.saturating_add(need_i64) { break; } let pk = self.audio_packet(a, &mut st, ainfo, i)?; diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index 522b46b43..240aeb5db 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -123,6 +123,20 @@ pub trait VideoSamples: Sync { fn make_decoder(&self) -> crate::Result>; } +/// A container's sync sample for `i` when leading pictures need the GOP before: `sync` is the +/// index's own sync sample, `leads(i, key)` says `i` is shown before `key`. One step per GOP, so a +/// loop: a crafted index can chain tens of thousands of GOPs (#2803). A `sync` past `i` stops the +/// walk instead of looping on it. +pub(crate) fn open_gop_sync(mut i: usize, sync: impl Fn(usize) -> usize, leads: impl Fn(usize, usize) -> bool) -> usize { + loop { + let key = sync(i); + if key == 0 || key > i || !leads(i, key) { + return key; + } + i = key - 1; + } +} + /// The sync sample to start decoding at for sample `i`: its sync sample, or an earlier one when /// `i` is a leading picture of an open GOP (stored after its random-access picture, shown before /// it, like the RASL pictures after every HEVC CRA of iPhone video). Those are predicted from the @@ -432,11 +446,13 @@ pub struct GopCache { budget: usize, } -/// The cache always has room for this many frames, whatever their size: a frame-threaded decoder -/// runs up to ~2x its thread count ahead of the frame it returns, and playback prefetches ahead -/// of the playhead, so a byte budget alone would evict 4K frames before they are shown and force -/// a re-decode from the keyframe. +/// The cache reserves room for this many small frames: a frame-threaded decoder runs up to ~2x +/// its thread count ahead of the frame it returns, and playback prefetches ahead of the playhead. const MIN_FRAMES: usize = 64; +/// A source cache never retains more decoded pixels than this, regardless of frame dimensions. +/// In particular, untrusted VP9 `show_existing_frame` samples must not turn the small-frame +/// reserve above into an unbounded number of full-resolution copies. +const FRAME_CACHE_CEILING: usize = 384 << 20; /// A running decoder up to this many samples before the wanted sample's sync sample keeps going /// rather than restarting at the sync sample. @@ -447,7 +463,7 @@ const CONTINUE_THROUGH: usize = 48; fn decode_pool() -> Option<&'static rayon::ThreadPool> { static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); POOL.get_or_init(|| { - let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + let n = std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(1); rayon::ThreadPoolBuilder::new().num_threads(n).thread_name(|i| format!("decode-{i}")).build().ok() }) .as_ref() @@ -478,7 +494,7 @@ fn isolated(f: impl FnOnce() -> crate::Result + Send) -> crate::Resu let job = slot.lock().unwrap_or_else(PoisonError::into_inner).take(); job.map(|job| pool.install(job)) }); - spawned.map(|h| h.join()) + spawned.map(std::thread::ScopedJoinHandle::join) }); return match waited { Ok(Ok(Some(r))) => r, @@ -519,7 +535,7 @@ impl GopCache { pool: pool.clone(), }); pool.register(&shared); - Self { shared, explicit_color, rotation: 0, crop: None, budget: 384 << 20 } + Self { shared, explicit_color, rotation: 0, crop: None, budget: FRAME_CACHE_CEILING } } /// Turn every decoded frame clockwise by `quarter_turns` × 90° (the container's display @@ -554,6 +570,12 @@ impl GopCache { self.store_finished(st, pts, self.finish(f), draft); } + /// The small-frame reserve may use the configured cache budget, but may never enlarge it. + fn cache_budget(&self, frame_bytes: usize) -> usize { + let reserve = MIN_FRAMES.saturating_mul(frame_bytes).min(FRAME_CACHE_CEILING); + self.budget.min(FRAME_CACHE_CEILING).max(reserve) + } + /// Cache a frame [`Self::finish`] has been applied to. fn store_finished(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { st.last_used = Instant::now(); @@ -563,7 +585,7 @@ impl GopCache { } else { st.drafts.remove(&pts); } - let budget = self.budget.max(MIN_FRAMES * f.byte_size()); + let budget = self.cache_budget(f.byte_size()); let before = st.bytes; st.bytes += f.byte_size(); if let Some(old) = st.frames.insert(pts, Arc::new(f)) { @@ -650,7 +672,7 @@ impl GopCache { // after the guard on every return path: dropping a decoder can block (a hardware session // waits for its frames in flight) and must not hold this cache's lock. let mut released: Vec> = Vec::new(); - let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); + let mut st = self.shared.state.lock().unwrap_or_else(PoisonError::into_inner); let _decoding = DecodingGuard::enter(me); st.last_used = Instant::now(); st.pin = want_pts; @@ -853,7 +875,7 @@ impl GopCache { count_frames(&out); out.into_iter().map(|d| (d.pts, self.finish(d.frame), d.draft)).collect::>() }); - let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); + let mut st = self.shared.state.lock().unwrap_or_else(PoisonError::into_inner); if st.spare.len() < 16 { st.spare.push(dec); } @@ -866,6 +888,19 @@ impl GopCache { #[cfg(test)] mod tests { + #[test] + fn open_gop_sync_walks_a_long_chain_of_leading_pictures_without_recursing() { + // Every GOP of 3 starts at a key whose leading picture is shown before it (#2803). + let gops = 200_000usize; + let sync = |i: usize| i - i % 3; + let leads = |i: usize, key: usize| i == key + 2; + assert_eq!(super::open_gop_sync(gops * 3 - 1, sync, leads), 0); + // A picture that is not a leading one keeps its own key. + assert_eq!(super::open_gop_sync(7, sync, |_, _| false), 6); + // An index whose sync sample lies past `i` ends the walk instead of looping. + assert_eq!(super::open_gop_sync(5, |_| 9, |_, _| true), 9); + } + use super::*; use crate::video::DecodedFrame; use std::collections::VecDeque; @@ -1047,6 +1082,8 @@ mod tests { use rayon::prelude::*; let s = Arc::new(ParSamples(samples(240, 30, 4, false))); let (tx, rx) = std::sync::mpsc::channel(); + let done = Arc::new(AtomicUsize::new(0)); + let counted = done.clone(); std::thread::spawn(move || { for round in 0..40usize { // export-like: many frame workers, frames of several GOPs of a fresh source at once @@ -1054,11 +1091,25 @@ mod tests { (0..96usize).into_par_iter().with_max_len(1).for_each(|k| { let i = (k * 37 + round * 53) % 240; assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); + counted.fetch_add(1, Ordering::Relaxed); }); } let _ = tx.send(()); }); - assert!(rx.recv_timeout(Duration::from_secs(120)).is_ok(), "frame requests deadlocked"); + // A deadlock stops every request of a round for good. On a loaded machine the 3,840 + // requests can take minutes, so the limit applies to the time between two answered ones. + let mut answered = 0; + loop { + match rx.recv_timeout(Duration::from_secs(120)) { + Ok(()) => break, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + let now = done.load(Ordering::Relaxed); + assert!(now > answered, "frame requests deadlocked after {now} frames"); + answered = now; + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("a frame request failed"), + } + } } #[test] @@ -1422,6 +1473,17 @@ mod tests { } } + #[test] + fn large_frames_cannot_enlarge_the_gop_cache_ceiling() { + let mut c = GopCache::new(None); + c.budget = usize::MAX; + assert_eq!(c.cache_budget(1), FRAME_CACHE_CEILING); + assert_eq!(c.cache_budget(FRAME_CACHE_CEILING), FRAME_CACHE_CEILING); + c.budget = 0; + assert_eq!(c.cache_budget(4), MIN_FRAMES * 4, "small frames retain the playback reserve"); + assert_eq!(c.cache_budget(FRAME_CACHE_CEILING), FRAME_CACHE_CEILING, "large frames are capped instead of reserving 64 copies"); + } + #[test] fn evicted_frames_are_decoded_into_again() { let s = Big(200); diff --git a/crates/codecs/src/hw.rs b/crates/codecs/src/hw.rs index d14545bb2..0ad34e5e5 100644 --- a/crates/codecs/src/hw.rs +++ b/crates/codecs/src/hw.rs @@ -8,6 +8,7 @@ //! decode, and a hardware decoder that fails mid-stream switches to //! [`crate::software_video_decoder`], so registering one never makes a file undecodable. +use std::collections::HashSet; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use filmcraft_isobmff::{CodecConfig, SampleEntry}; @@ -175,10 +176,14 @@ impl StreamInfo { /// with (parameter sets, a sequence header, a key frame of another format). pub fn parameters_changed(&self, sample: &[u8]) -> bool { match self { - Self::Nal(i) => i.nals(sample).into_iter().any(|n| { - n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) - && !i.parameter_sets.iter().any(|p| without_trailing_zeros(p) == without_trailing_zeros(n)) - }), + Self::Nal(i) => { + let configured: HashSet<&[u8]> = i.parameter_sets.iter().map(|p| without_trailing_zeros(p)).collect(); + let mut checked = HashSet::new(); + i.nals(sample).into_iter().any(|n| { + let n = without_trailing_zeros(n); + n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) && checked.insert(n) && !configured.contains(n) + }) + } Self::Frame(i) => i.parameters_changed(sample), } } @@ -456,4 +461,16 @@ mod tests { assert!(info.parameters_changed(&sample(&hvcc_pps[..6])), "a missing byte that is not zero is a change"); assert!(info.parameters_changed(&sample(&[0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x91])), "another PPS is a change"); } + + #[test] + fn repeated_parameter_sets_are_checked_once_per_distinct_nal() { + let pps = vec![0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x90]; + let info = StreamInfo::Nal(hevc_info(std::iter::repeat_n(pps.clone(), 256).collect())); + let mut sample = Vec::new(); + for _ in 0..256 { + sample.extend_from_slice(&(pps.len() as u32).to_be_bytes()); + sample.extend_from_slice(&pps); + } + assert!(!info.parameters_changed(&sample)); + } } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 9a8bebdbf..f3ae47b72 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -77,7 +77,7 @@ impl From for filmcraft_media::MediaError { } /// Our own (pure-Rust) decoders, in the order they are tried. -const BUILTIN_FACTORIES: [VideoDecoderFactory; 10] = [ +const BUILTIN_FACTORIES: [VideoDecoderFactory; 11] = [ video::h264_factory, video::hevc_factory, video::vp8_factory, @@ -88,6 +88,7 @@ const BUILTIN_FACTORIES: [VideoDecoderFactory; 10] = [ video::dnx_factory, video::mjpeg_factory, video::mpeg2_factory, + video::raw_factory, ]; fn factories() -> &'static RwLock> { diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 2f51258a8..bdc8dc4fe 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -142,6 +142,20 @@ fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 { i64::try_from((t.codec_delay_ns as i128 + scale_ns / 2) / scale_ns).unwrap_or(i64::MAX) } +/// The stream description of an audio track, `None` when its advertised sample rate or channel +/// count is out of range (see [`filmcraft_media::bounded_audio_dimensions`]). +fn audio_info(t: &filmcraft_matroska::Track) -> Option { + let a = t.audio.clone().unwrap_or_default(); + let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32; + let mut channels = (a.channels as u32).max(1); + if let Some(h) = opus_head(&t.codec) { + rate = crate::audio::OPUS_RATE; + channels = h.channels as u32; + } + let (rate, channels) = filmcraft_media::bounded_audio_dimensions(rate, channels).ok()?; + Some(AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) }) +} + /// The track timestamp that media time zero stands for (#715): the earliest start among the /// playable tracks. Block timestamps count from the start of the Segment, and a remux that keeps /// its source's timestamps (`ffmpeg -copyts`, a cut from a recording) starts its first picture @@ -231,7 +245,7 @@ fn vp9_config(private: &[u8], v: Option<&filmcraft_matroska::VideoInfo>) -> VpcC } p += 2 + len; } - if let Some(col) = v.and_then(|v| v.colour.as_ref()) { + if let Some(col) = v.and_then(|v| v.color.as_ref()) { if let Some(t) = col.transfer_characteristics { c.transfer_characteristics = t as u8; } @@ -265,7 +279,7 @@ fn sample_entry(c: &Codec, private: &[u8], v: Option<&filmcraft_matroska::VideoI } /// HDR static metadata from the Matroska `Colour` element. -fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option { +fn hdr_metadata(c: &filmcraft_matroska::Color) -> Option { if c.mastering.is_none() && c.max_cll.is_none() && c.max_fall.is_none() { return None; } @@ -285,7 +299,7 @@ fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option) -> (ColorInfo, bool) { // H.273 code points; `Range`: 1 broadcast (limited), 2 full, 0 / 3 not signalled here let code = |c: Option| c.unwrap_or(2); - let container = v.colour.as_ref().map(|col| { + let container = v.color.as_ref().map(|col| { let range = match col.range { Some(1) => Some(false), Some(2) => Some(true), @@ -339,7 +353,9 @@ impl MkvSource { .tracks .iter() .enumerate() - .filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty()) + // A track whose advertised dimensions are refused is no stream at all, so `atracks[k]` and + // `info.audio_streams[k]` stay the same track. + .filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty() && audio_info(t).is_some()) .map(|(i, _)| i) .take(filmcraft_media::MAX_AUDIO_STREAMS) .collect(); @@ -404,23 +420,10 @@ impl MkvSource { color, has_alpha: v.alpha_mode != 0, bitrate, - hdr: v.colour.as_ref().and_then(hdr_metadata), + hdr: v.color.as_ref().and_then(hdr_metadata), } }); - let audio_streams: Vec = atracks - .iter() - .filter_map(|&i| file.tracks.get(i)) - .map(|t| { - let a = t.audio.clone().unwrap_or_default(); - let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32; - let mut channels = (a.channels as u32).max(1); - if let Some(h) = opus_head(&t.codec) { - rate = crate::audio::OPUS_RATE; - channels = h.channels as u32; - } - AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) } - }) - .collect(); + let audio_streams: Vec = atracks.iter().filter_map(|&i| file.tracks.get(i)).filter_map(audio_info).collect(); let info = MediaInfo { name: name.to_string(), kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly }, @@ -628,15 +631,19 @@ impl MediaSource for MkvSource { let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?; let src_rate = ainfo.sample_rate; let ch = ainfo.channels.max(1) as usize; + let source_frames = filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)?; + let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?; + filmcraft_media::validate_audio_buffer_shape(ch, need)?; + filmcraft_media::validate_audio_buffer_shape(ch, frames)?; let ratio = src_rate as f64 / sample_rate as f64; let s0 = (start as f64 * ratio).floor() as i64; - let need = (frames as f64 * ratio).ceil() as i64 + 2; - let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let need_i64 = i64::try_from(need).unwrap_or(i64::MAX); + let mut src: Vec> = vec![vec![0.0; need]; ch]; let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner()); let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1); while i < n_packets { let Some(&pk_start) = a.starts.get(i) else { break }; - if pk_start >= s0 + need { + if pk_start >= s0.saturating_add(need_i64) { break; } let pk = self.audio_packet(a, ainfo, &mut st, i)?; diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 6e33c027e..de2beb7d1 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -21,10 +21,15 @@ use crate::{CodecError, make_video_decoder}; /// Most bytes read from one AAC access unit when probing for HE-AAC at open (8 channels × 6144 bits). const MAX_AAC_PROBE_UNIT: usize = 8 * 6144 / 8; +struct DecodedAudioPacket { + samples: Vec>, + failed: bool, +} + struct AudioState { decoder: Option, /// Decoded packets by sample index. - packets: HashMap>>>, + packets: HashMap>, order: Vec, last_decoded: Option, } @@ -92,6 +97,20 @@ fn audio_starts(file: &Mp4File, bytes: &crate::Src, ti: usize, rate: u32) -> Vec contiguous_starts(packets, (frame / 2).max(unit.saturating_mul(2).saturating_add(1))) } +/// Return source-rate buffer indices overlapped by a failed packet's concealment span. +/// Packet positions are audio frames, but duration is raw track-timescale ticks +/// treated as a frame count here; the span is clipped to the request. +fn failed_audio_span(packet_start: i64, request_start: i64, duration: u32, request_len: usize) -> std::ops::Range { + let begin = usize::try_from(packet_start.saturating_sub(request_start).max(0)).unwrap_or(usize::MAX).min(request_len); + let end = if duration == 0 { + begin + } else { + let last = packet_start.saturating_add(i64::from(duration) - 1).saturating_sub(request_start); + usize::try_from((i128::from(last) + 1).max(0)).unwrap_or(usize::MAX).min(request_len) + }; + begin..end +} + /// HDR static metadata from the sample entry's `mdcv` / `clli` boxes (0 = unknown). fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u16, u16)>) -> Option { if md.is_none() && cll.is_none() { @@ -366,18 +385,18 @@ impl Mp4Source { } /// Decoded packet `i` of audio stream `a` (`ainfo` is its `AudioStreamInfo`; `st` its decoder state). - fn audio_packet(&self, a: &Mp4Audio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result>>> { + fn audio_packet(&self, a: &Mp4Audio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result> { if let Some(p) = st.packets.get(&i) { return Ok(p.clone()); } let ti = a.track; let track = self.file.tracks.get(ti).ok_or_else(|| CodecError::Unsupported("no audio track".into()))?; let entry = track.entries.first().ok_or_else(|| CodecError::Container("audio track without a sample description".into()))?; - let sample = track.samples.get(i).ok_or_else(|| CodecError::Container("audio packet out of range".into()))?; + track.samples.get(i).ok_or_else(|| CodecError::Container("audio packet out of range".into()))?; let pk_start = a.starts.get(i).copied().unwrap_or(0); let data = self.read(ti, i)?; - let decoded = match &entry.codec { - CodecConfig::Pcm(p) => decode_pcm(&data, p), + let (samples, failed) = match &entry.codec { + CodecConfig::Pcm(p) => (decode_pcm(&data, p), false), c => { if st.decoder.is_none() { st.decoder = Some(PacketDecoder::for_isobmff(c, ainfo.sample_rate)?); @@ -398,15 +417,15 @@ impl Mp4Source { } } } - let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, pk_start.max(0) as u64); + let result = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, pk_start.max(0) as u64); st.last_decoded = Some(i); - match r { - Ok(v) => v, - Err(_) => vec![vec![0.0; sample.duration as usize]; ainfo.channels.max(1) as usize], + match result { + Ok(samples) => (samples, false), + Err(_) => (Vec::new(), true), } } }; - let p = Arc::new(decoded); + let p = Arc::new(DecodedAudioPacket { samples, failed }); st.packets.insert(i, p.clone()); st.order.push(i); if st.order.len() > 4096 { @@ -465,25 +484,39 @@ impl MediaSource for Mp4Source { let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?; let src_rate = ainfo.sample_rate; let ch = ainfo.channels.max(1) as usize; + let source_frames = filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)?; + let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?; + filmcraft_media::validate_audio_buffer_shape(ch, need)?; + filmcraft_media::validate_audio_buffer_shape(ch, frames)?; // Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset). let ratio = src_rate as f64 / sample_rate as f64; let p0 = (start as f64 * ratio).floor() as i64; // (in i128: a hostile edit list can make the offset large enough to overflow i64) let offset = i128::from(a.offset) * i128::from(src_rate) / i128::from(track.timescale.max(1)); let s0 = i64::try_from(i128::from(p0) - offset).unwrap_or(if offset > 0 { i64::MIN } else { i64::MAX }); - let need = (frames as f64 * ratio).ceil() as i64 + 2; - let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let need_i64 = i64::try_from(need).unwrap_or(i64::MAX); + let mut src: Vec> = vec![vec![0.0; need]; ch]; let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner()); let first = a.starts.partition_point(|&x| x <= s0.max(0)).saturating_sub(1); let mut i = first; - while i < track.samples.len() { + while let Some(sample) = track.samples.get(i) { let Some(&pk_start) = a.starts.get(i) else { break }; - if pk_start >= s0.saturating_add(need) { + if pk_start >= s0.saturating_add(need_i64) { break; } let pk = self.audio_packet(a, ainfo, &mut st, i)?; + if pk.failed { + let range = failed_audio_span(pk_start, s0, sample.duration, src.first().map_or(0, Vec::len)); + for dst in &mut src { + if let Some(span) = dst.get_mut(range.clone()) { + span.fill(0.0); + } + } + i += 1; + continue; + } for (c, dst) in src.iter_mut().enumerate() { - let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue }; + let Some(chan) = pk.samples.get(c.min(pk.samples.len().saturating_sub(1))) else { continue }; for (k, v) in chan.iter().enumerate() { let pos = pk_start.saturating_add(k as i64).saturating_sub(s0); if pos >= 0 && (pos as usize) < dst.len() { @@ -560,7 +593,8 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option } c => codec_label(c), }; - Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec, bits_per_sample: bits }) + let (rate, ch) = filmcraft_media::bounded_audio_dimensions(rate, ch).ok()?; + Some(AudioStreamInfo { sample_rate: rate, channels: ch, codec, bits_per_sample: bits }) } fn codec_label(c: &CodecConfig) -> String { @@ -580,6 +614,9 @@ fn codec_label(c: &CodecConfig) -> String { CodecConfig::Hevc(_) => "HEVC".into(), CodecConfig::Vp9(c) => format!("VP9 (Profile {})", c.profile), CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"vp08" => "VP8".into(), + CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"raw " => "Uncompressed RGB".into(), + CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"2vuy" => "Uncompressed YUV 4:2:2".into(), + CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"v210" => "Uncompressed YUV 4:2:2 10-bit".into(), CodecConfig::Av1(c) => format!("AV1 ({} Profile)", ["Main", "High", "Professional"].get(c.seq_profile as usize).unwrap_or(&"Main")), CodecConfig::Apv(a) => match filmcraft_apv::Profile::from_idc(a.profile_idc) { Some(p) => p.name().into(), @@ -1318,3 +1355,81 @@ mod he_aac_tests { } } } + +#[cfg(test)] +mod corrupt_audio_tests { + use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + use filmcraft_media::MediaSource; + + #[test] + fn failed_packet_span_matches_per_sample_silence_at_extreme_timestamps() { + let min = i64::MIN; + let max = i64::MAX; + for start in [min, min + 1, min + 2, -10, -1, 0, 1, 10, max - 2, max - 1, max] { + for request in [min, min + 1, min + 2, -10, -1, 0, 1, 10, max - 2, max - 1, max] { + for duration in [0, 1, 2, 5, 128] { + let old: Vec<_> = (0..duration) + .map(|k| start.saturating_add(i64::from(k)).saturating_sub(request)) + .filter(|&position| (0..8).contains(&position)) + .map(|position| position as usize) + .collect(); + let range = super::failed_audio_span(start, request, duration, 8); + let actual: Vec<_> = (0..8).filter(|position| range.contains(position)).collect(); + let expected: Vec<_> = old.into_iter().collect::>().into_iter().collect(); + assert_eq!(actual, expected, "start={start}, request={request}, duration={duration}"); + } + } + } + assert_eq!(super::failed_audio_span(0, 0, u32::MAX, 128), 0..128); + } + + #[test] + fn failed_aac_packet_does_not_materialize_its_hostile_duration() { + // AAC-LC, 48 kHz stereo. The empty access unit cannot decode. + let asc = vec![0x11, 0x90]; + let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); + let track = mux.add_track(TrackConfig::new(SampleEntry::aac(asc, 2, 48_000), 48_000)).unwrap(); + mux.write_sample(track, WriteSample { data: &[], duration: 1_000_000, composition_offset: 0, is_sync: true }).unwrap(); + let source = super::Mp4Source::open("damaged.m4a", mux.finish().unwrap().into_inner().into()).unwrap(); + let audio = &source.audios[0]; + let info = &source.info.audio_streams[0]; + let mut state = audio.state.lock().unwrap(); + let packet = source.audio_packet(audio, info, &mut state, 0).unwrap(); + assert!(packet.failed); + assert!(packet.samples.is_empty(), "failed packet must not allocate from its untrusted duration"); + drop(state); + + let window = source.audio(0, 128, 48_000).unwrap(); + assert_eq!(window.channels.len(), 2); + assert!(window.channels.iter().all(|ch| ch.len() == 128 && ch.iter().all(|&v| v == 0.0))); + } + + #[test] + fn failed_aac_packet_silences_overlap_independent_of_request_boundary() { + let rate = 48_000u32; + let tone: Vec = (0..8192).map(|i| 0.3 * (i as f32 * 0.13).sin()).collect(); + let mut encoder = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(rate, 1, 64_000)).unwrap(); + let mut units = encoder.encode(&[&tone]); + units.extend(encoder.flush()); + assert!(units.len() >= 2); + let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); + let track = mux.add_track(TrackConfig::new(SampleEntry::aac(vec![0x11, 0x88], 1, rate), rate)).unwrap(); + mux.write_sample(track, WriteSample { data: &units[0], duration: 1024, composition_offset: 0, is_sync: true }).unwrap(); + mux.write_sample(track, WriteSample { data: &units[1], duration: 400, composition_offset: 0, is_sync: true }).unwrap(); + mux.write_sample(track, WriteSample { data: &[], duration: 1024, composition_offset: 0, is_sync: true }).unwrap(); + let source = super::Mp4Source::open("overlap.m4a", mux.finish().unwrap().into_inner().into()).unwrap(); + // The failed packet starts at 1024 + 400 = 1424, after 124 frames of the crossing read. + let crossing = source.audio(1300, 256, rate).unwrap(); + let isolated = source.audio(1424, 132, rate).unwrap(); + let prior_packet_len = { + let audio = &source.audios[0]; + let info = &source.info.audio_streams[0]; + let mut state = audio.state.lock().unwrap(); + source.audio_packet(audio, info, &mut state, 1).unwrap().samples.first().map_or(0, Vec::len) + }; + assert!(prior_packet_len > 400, "valid packet must overlap the failed packet"); + assert!(crossing.channels[0][..124].iter().any(|v| v.abs() > 0.001)); + assert!(isolated.channels[0].iter().all(|&v| v == 0.0)); + assert_eq!(&crossing.channels[0][124..], &isolated.channels[0]); + } +} diff --git a/crates/codecs/src/mpeg.rs b/crates/codecs/src/mpeg.rs index e2cd09ba1..040ca9092 100644 --- a/crates/codecs/src/mpeg.rs +++ b/crates/codecs/src/mpeg.rs @@ -369,15 +369,7 @@ impl MpegSource { let raw: Vec> = v.order.iter().map(|&i| units[v.units[i]].pts).collect(); let rate = self.info.frame_rate(); let dur = |k: i64| (k as i128 * 90_000 * rate.den as i128 / rate.num.max(1) as i128) as i64; - let known: Vec = (0..raw.len()).filter(|&i| raw[i].is_some()).collect(); - (0..raw.len()) - .map(|i| { - raw[i].or_else(|| { - let j = known.iter().rev().find(|&&j| j < i).or_else(|| known.iter().find(|&&j| j > i))?; - Some(raw[*j]? + dur(i as i64 - *j as i64)) - }) - }) - .collect() + interpolate_pts(&raw, dur) } fn read_unit(&self, v: &VideoTrack, i: usize) -> crate::Result> { @@ -690,6 +682,23 @@ impl MpegSource { } } +/// Fill missing timestamps from their closest preceding timestamp, or the next one when there is +/// no predecessor. The known positions are sorted, so binary search avoids rescanning them for +/// every missing frame. +fn interpolate_pts(raw: &[Option], dur: impl Fn(i64) -> i64) -> Vec> { + let known: Vec = (0..raw.len()).filter(|&i| raw[i].is_some()).collect(); + raw.iter() + .enumerate() + .map(|(i, &pts)| { + pts.or_else(|| { + let next = known.partition_point(|&j| j < i); + let j = next.checked_sub(1).or_else(|| known.get(next).map(|_| next))?; + Some(raw[known[j]]? + dur(i as i64 - known[j] as i64)) + }) + }) + .collect() +} + fn chroma_name(c: filmcraft_frame::Chroma) -> &'static str { match c { filmcraft_frame::Chroma::C420 => "4:2:0", @@ -760,10 +769,15 @@ impl VideoSamples for MpegVideo<'_> { } fn sync_before(&self, i: usize) -> usize { let i = i.min(self.v.keys.len().saturating_sub(1)); - let key = (0..=i).rev().find(|&k| self.v.keys[k]).unwrap_or(0); + let keys = &self.v.keys; + let pts = &self.v.pts; // a leading picture of an open GOP (shown before its random-access picture) needs the // previous GOP's pictures as references - if key > 0 && self.v.pts[i] < self.v.pts[key] { self.sync_before(key - 1) } else { key } + crate::gop::open_gop_sync( + i, + |i| (0..=i).rev().find(|&k| keys.get(k) == Some(&true)).unwrap_or(0), + |i, key| matches!((pts.get(i), pts.get(key)), (Some(a), Some(b)) if a < b), + ) } fn sample_at(&self, t: i64) -> Option { usize::try_from(t).ok().and_then(|t| self.v.order.get(t).copied()) @@ -1015,4 +1029,16 @@ mod tests { assert_eq!(order, vec![0, 4, 5, 3]); assert_eq!(pts, vec![0, -2, -3, 3, 1, 2]); } + + #[test] + fn timestamp_interpolation_handles_long_missing_runs_without_rescanning() { + let mut raw = vec![None; 10_000]; + raw[4_000] = Some(40_000); + raw[9_999] = Some(99_990); + let pts = interpolate_pts(&raw, |frames| frames * 10); + assert_eq!(pts[0], Some(0)); + assert_eq!(pts[3_999], Some(39_990)); + assert_eq!(pts[4_001], Some(40_010)); + assert_eq!(pts[9_998], Some(99_980)); + } } diff --git a/crates/codecs/src/mxf.rs b/crates/codecs/src/mxf.rs index 1b230a8dc..e94aeaa32 100644 --- a/crates/codecs/src/mxf.rs +++ b/crates/codecs/src/mxf.rs @@ -330,7 +330,7 @@ impl MxfSource { if let Some(&a0) = atracks.first() { let si0 = file.tracks[a0].sound.clone().unwrap_or_default(); let rate = si0.sample_rate; - let mut channels = 0; + let mut channels: u32 = 0; for &ai in &atracks { let t = &file.tracks[ai]; let Some(si) = t.sound.as_ref() else { continue }; @@ -342,11 +342,18 @@ impl MxfSource { let stored = t.stored_sample_frames().saturating_sub(first); let presented = t.duration.map_or(stored, |du| ((du as i128 * n as i128 / d.max(1) as i128) as u64).min(stored)); aframes = aframes.min(presented); - channels += si.channels.max(1); + let next = + channels.checked_add(si.channels.max(1)).ok_or_else(|| CodecError::Unsupported("MXF: combined audio channel count overflows".into()))?; + if next > filmcraft_media::MAX_AUDIO_CHANNELS { + return Err(CodecError::Unsupported(format!("MXF: combined audio exceeds {} channels", filmcraft_media::MAX_AUDIO_CHANNELS))); + } + channels = next; combined.push((ai, first)); } let sr = if rate.is_valid() { (rate.num as f64 / rate.den as f64).round() as u32 } else { 48_000 }; - audio = Some(AudioStreamInfo { sample_rate: sr.max(1), channels, codec: file.tracks[a0].codec.name().into(), bits_per_sample: Some(si0.bits) }); + if let Ok((sr, channels)) = filmcraft_media::bounded_audio_dimensions(sr, channels) { + audio = Some(AudioStreamInfo { sample_rate: sr, channels, codec: file.tracks[a0].codec.name().into(), bits_per_sample: Some(si0.bits) }); + } } if aframes == u64::MAX { aframes = 0; @@ -408,10 +415,11 @@ impl VideoSamples for MxfVideo<'_> { self.src.vpts[i] } fn sync_before(&self, i: usize) -> usize { - let key = self.src.file.tracks[self.track].sync_before(i); // a leading picture of an open GOP (stored after its random-access picture, shown // before it) is predicted from the previous GOP too - if key > 0 && i < self.src.vpts.len() && self.src.vpts[i] < self.src.vpts[key] { self.sync_before(key - 1) } else { key } + let track = &self.src.file.tracks[self.track]; + let pts = &self.src.vpts; + crate::gop::open_gop_sync(i, |i| track.sync_before(i), |i, key| matches!((pts.get(i), pts.get(key)), (Some(a), Some(b)) if a < b)) } fn sample_at(&self, t: i64) -> Option { usize::try_from(t).ok().and_then(|t| self.src.vorder.get(t).copied()) @@ -460,15 +468,20 @@ impl MediaSource for MxfSource { let ainfo = self.info.audio().ok_or(MediaError::NoStream("audio"))?; let src_rate = ainfo.sample_rate; let ch = ainfo.channels.max(1) as usize; + let exact = src_rate == sample_rate; + let source_frames = if exact { frames } else { filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)? }; + let need = + if exact { source_frames } else { source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))? }; + filmcraft_media::validate_audio_buffer_shape(ch, need)?; + filmcraft_media::validate_audio_buffer_shape(ch, frames)?; let mut out = AudioBuffer::silence(sample_rate, ch, frames); let ratio = src_rate as f64 / sample_rate as f64; - let exact = src_rate == sample_rate; let s0 = if exact { start } else { (start as f64 * ratio).floor() as i64 }; - let need = if exact { frames as i64 } else { (frames as f64 * ratio).ceil() as i64 + 2 }; + let need_i64 = i64::try_from(need).unwrap_or(i64::MAX); // clip the request to the presented range [0, aframes) let a = s0.max(0); - let b = (s0 + need).min(self.aframes as i64); - let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let b = s0.saturating_add(need_i64).min(self.aframes as i64); + let mut src: Vec> = vec![vec![0.0; need]; ch]; if b > a { let mut c0 = 0; for &(ti, first) in &self.atracks { diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 52af0574c..9c8ea8c19 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -236,6 +236,33 @@ fn mp4_flac_audio() { assert!(peak > 0.05 && peak < 1.0, "audio peak {peak}"); } +/// #2340: uncompressed QuickTime video (`raw ` 24-bit RGB, `2vuy`, `v210`) decodes, with fl32 +/// audio, instead of being reported unreadable. +#[test] +fn quicktime_uncompressed_video() { + let src = ["-f", "lavfi", "-i", "color=c=0x3060C0:s=64x36:r=24:d=0.5", "-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:d=0.5"]; + for (name, vcodec) in [ + ("raw_rgb24.mov", ["-c:v", "rawvideo", "-pix_fmt", "rgb24"]), + ("raw_2vuy.mov", ["-c:v", "rawvideo", "-pix_fmt", "uyvy422"]), + ("raw_v210.mov", ["-c:v", "v210", "-pix_fmt", "yuv422p10le"]), + ] { + let args: Vec<&str> = src.iter().copied().chain(vcodec).chain(["-c:a", "pcm_f32be", "-shortest"]).collect(); + let Some(b) = fixture(name, &args) else { return }; + let src = crate::open_bytes(name, b).unwrap_or_else(|e| panic!("{name}: {e}")); + let f = src.video_frame(FrameRequest { time: Tick(TICKS_PER_SECOND / 4), scale: 1.0 }).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((f.width, f.height), (64, 36), "{name}"); + assert!(src.info().video.as_ref().is_some_and(|v| v.codec.starts_with("Uncompressed")), "{name}: {:?}", src.info().video); + let rgba = f.to_rgba8(); + let px = &rgba[(18 * 64 + 32) * 4..][..3]; + for (got, want) in px.iter().zip([0x30u8, 0x60, 0xc0]) { + assert!((*got as i32 - want as i32).abs() <= 6, "{name}: {px:?}"); + } + let a = src.audio(12_000, 4800, 48_000).expect("fl32 audio"); + let peak = a.channels[0].iter().fold(0f32, |m, s| m.max(s.abs())); + assert!(peak > 0.05 && peak < 1.0, "{name}: audio peak {peak}"); + } +} + #[test] fn matroska_prores_pcm() { check_mkv("blue_prores_pcm.mkv", &["-c:v", "prores_ks", "-profile:v", "2"], &["-c:a", "pcm_s16le"]); diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index 28b3f4c66..7a4a2ef55 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -1094,6 +1094,13 @@ fn esds_object_type(raw: &[u8]) -> Option { raw.get(p).copied() } +/// Uncompressed QuickTime / MP4 video (`raw ` 24-bit RGB, `2vuy`, `v210`, #2340). +pub fn raw_factory(e: &SampleEntry) -> Option>> { + let v = e.video.as_ref()?; + let format = RawFormat::from_mov(e.format.0, v.depth)?; + Some(RawVideoDecoder::new(format, u32::from(v.width), u32::from(v.height), false).map(|d| Box::new(d) as Box)) +} + pub fn mpeg2_factory(e: &SampleEntry) -> Option>> { match &e.codec { CodecConfig::Unknown { fourcc, raw } if is_mpeg2_entry(&fourcc.0, raw) => { @@ -1168,6 +1175,11 @@ pub enum RawFormat { Nv12, /// Luma only (`Y800`, `GREY`, `Y8 `). Gray8, + /// 24-bit R, G, B; rows unpadded (QuickTime `raw ` at depth 24). + Rgb24, + /// 10-bit 4:2:2 Y'CbCr, six pixels in four little-endian words, rows padded to 128 bytes + /// (QuickTime `v210`); shown at 8 bits. + V210, } impl RawFormat { @@ -1193,13 +1205,24 @@ impl RawFormat { }) } + /// The layout of a QuickTime / MP4 video sample entry (`raw ` RGB at depth 24, `2vuy`, `v210`). + pub fn from_mov(fourcc: [u8; 4], depth: u16) -> Option { + match (&fourcc, depth) { + (b"raw ", 24) => Some(RawFormat::Rgb24), + (b"2vuy", _) => Some(RawFormat::Uyvy), + (b"v210", _) => Some(RawFormat::V210), + _ => None, + } + } + pub fn label(self) -> &'static str { match self { - RawFormat::Bgr24 => "Uncompressed RGB 24-bit", + RawFormat::Bgr24 | RawFormat::Rgb24 => "Uncompressed RGB 24-bit", RawFormat::Bgrx32 => "Uncompressed RGB 32-bit", RawFormat::Yuy2 | RawFormat::Uyvy => "Uncompressed YUV 4:2:2", RawFormat::I420 | RawFormat::Yv12 | RawFormat::Nv12 => "Uncompressed YUV 4:2:0", RawFormat::Gray8 => "Uncompressed greyscale", + RawFormat::V210 => "Uncompressed YUV 4:2:2 10-bit", } } } @@ -1254,6 +1277,42 @@ impl RawVideoDecoder { } VideoFrame::rgba8(self.width, self.height, rgba) } + RawFormat::Rgb24 => { + let stride = w * 3; + if s.len() < stride * h { + return Err(short()); + } + let mut rgba = Vec::with_capacity(w * h * 4); + for px in s.get(..stride * h).unwrap_or_default().as_chunks::<3>().0 { + rgba.extend_from_slice(&[px[0], px[1], px[2], 255]); + } + VideoFrame::rgba8(self.width, self.height, rgba) + } + RawFormat::V210 => { + // 6 pixels per 16 bytes; each row padded to a multiple of 128 bytes (48 pixels) + let stride = w.div_ceil(48) * 128; + if s.len() < stride * h { + return Err(short()); + } + let (mut y, mut u, mut v) = (Vec::with_capacity(w * h), Vec::with_capacity(cw * h), Vec::with_capacity(cw * h)); + for row in s.chunks_exact(stride).take(h) { + // the row's 10-bit samples in order: Cb Y Cr Y Cb Y Cr Y …, kept at 8 bits + let mut comps = row.as_chunks::<4>().0.iter().flat_map(|b| { + let word = u32::from_le_bytes(*b); + [word & 0x3ff, (word >> 10) & 0x3ff, (word >> 20) & 0x3ff].map(|c| (c >> 2) as u8) + }); + for x in 0..cw { + let (Some(cb), Some(y0), Some(cr), Some(y1)) = (comps.next(), comps.next(), comps.next(), comps.next()) else { break }; + u.push(cb); + v.push(cr); + y.push(y0); + if 2 * x + 1 < w { + y.push(y1); + } + } + } + self.yuv([y, u, v], filmcraft_frame::Chroma::C422) + } RawFormat::Yuy2 | RawFormat::Uyvy => { let stride = cw * 4; if s.len() < stride * h { diff --git a/crates/codecs/tests/aac_channels_oracle.rs b/crates/codecs/tests/it/aac_channels_oracle.rs similarity index 99% rename from crates/codecs/tests/aac_channels_oracle.rs rename to crates/codecs/tests/it/aac_channels_oracle.rs index 5f850ac3e..43bcf6a65 100644 --- a/crates/codecs/tests/aac_channels_oracle.rs +++ b/crates/codecs/tests/it/aac_channels_oracle.rs @@ -3,7 +3,7 @@ //! both 7.1 (eight channels) and 11 is 6.1 (seven). Each channel carries its own tone, so a //! dropped or swapped channel shows up against ffmpeg's decode. -mod common; +use crate::common; use std::io::Cursor; diff --git a/crates/codecs/tests/adts_file.rs b/crates/codecs/tests/it/adts_file.rs similarity index 100% rename from crates/codecs/tests/adts_file.rs rename to crates/codecs/tests/it/adts_file.rs diff --git a/crates/codecs/tests/common/mod.rs b/crates/codecs/tests/it/common/mod.rs similarity index 100% rename from crates/codecs/tests/common/mod.rs rename to crates/codecs/tests/it/common/mod.rs diff --git a/crates/codecs/tests/draft_decoders.rs b/crates/codecs/tests/it/draft_decoders.rs similarity index 97% rename from crates/codecs/tests/draft_decoders.rs rename to crates/codecs/tests/it/draft_decoders.rs index bec6476f9..2397ad225 100644 --- a/crates/codecs/tests/draft_decoders.rs +++ b/crates/codecs/tests/it/draft_decoders.rs @@ -1,8 +1,8 @@ -//! Draft decoding of VP9, HEVC and AV1 through the media stack (as `h264_draft.rs` for H.264): +//! Draft decoding of H.264, VP9, HEVC and AV1 through the media stack: //! off by default, approximate frames only reach draft requests, and decoding for an export after //! draft playback (same source, same GOP cache) still matches ffmpeg bit for bit. -mod common; +use crate::common; use common::*; use filmcraft_media::FrameRequest; @@ -50,6 +50,7 @@ fn check(name: &str, args: &[&str], w: usize, h: usize) { /// One test (the GOP cache statistics are process-wide, so the codecs run one after another). #[test] fn draft_playback_never_reaches_exports() { + crate::h264_draft::check(); vp9(); hevc(); av1(); diff --git a/crates/codecs/tests/eac3_oracle.rs b/crates/codecs/tests/it/eac3_oracle.rs similarity index 99% rename from crates/codecs/tests/eac3_oracle.rs rename to crates/codecs/tests/it/eac3_oracle.rs index 7d875d49c..e75476db7 100644 --- a/crates/codecs/tests/eac3_oracle.rs +++ b/crates/codecs/tests/it/eac3_oracle.rs @@ -2,7 +2,7 @@ //! and muxes the files and decodes them as the oracle. Zero-bit mantissas carry decoder-specific //! dither (A/52 §7.3.4), so the criterion is SNR against ffmpeg's decode, sample-aligned. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/frame_color_oracle.rs b/crates/codecs/tests/it/frame_color_oracle.rs similarity index 99% rename from crates/codecs/tests/frame_color_oracle.rs rename to crates/codecs/tests/it/frame_color_oracle.rs index 7da3baf09..15740fc3e 100644 --- a/crates/codecs/tests/frame_color_oracle.rs +++ b/crates/codecs/tests/it/frame_color_oracle.rs @@ -2,7 +2,7 @@ //! MP4 without a `colr` box unless asked, so for most files the bitstream is the only place the //! primaries are described: stream info already read them, the decoded frames reported BT.709. -mod common; +use crate::common; use common::*; use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer}; diff --git a/crates/codecs/tests/h264_draft.rs b/crates/codecs/tests/it/h264_draft.rs similarity index 93% rename from crates/codecs/tests/h264_draft.rs rename to crates/codecs/tests/it/h264_draft.rs index 81e8c3bb8..1061f17ab 100644 --- a/crates/codecs/tests/h264_draft.rs +++ b/crates/codecs/tests/it/h264_draft.rs @@ -2,14 +2,15 @@ //! by default, its approximate frames only ever reach draft requests, and decoding for an export //! after draft playback (same source, same GOP cache) still matches ffmpeg bit for bit. -mod common; +use crate::common; use common::*; use filmcraft_media::FrameRequest; use filmcraft_media::cancel::with_draft; -#[test] -fn draft_playback_never_reaches_exports() { +/// Verify H.264 default, draft and export decoding against ffmpeg. +/// Called by the shared draft-codec test because GOP statistics are process-wide. +pub(super) fn check() { let ff = filmcraft_testkit::require_ffmpeg!(); // B-pyramid: references and non-reference B pictures (the ones draft mode leaves unfiltered) let Some(file) = fixture( diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs new file mode 100644 index 000000000..679dcaa6b --- /dev/null +++ b/crates/codecs/tests/it/main.rs @@ -0,0 +1,17 @@ +mod aac_channels_oracle; +mod adts_file; +mod common; +mod draft_decoders; +mod eac3_oracle; +mod frame_color_oracle; +mod h264_draft; +mod matroska_audio_oracle; +mod media_oracle; +mod mkv_mp1_oracle; +mod mp4_oracle; +mod mp4_rtmd; +mod mpeg_oracle; +mod mxf_oracle; +mod ogg_oracle; +mod rf64_metadata; +mod y4m_file; diff --git a/crates/codecs/tests/matroska_audio_oracle.rs b/crates/codecs/tests/it/matroska_audio_oracle.rs similarity index 99% rename from crates/codecs/tests/matroska_audio_oracle.rs rename to crates/codecs/tests/it/matroska_audio_oracle.rs index 3ded7af07..c2cfbd45e 100644 --- a/crates/codecs/tests/matroska_audio_oracle.rs +++ b/crates/codecs/tests/it/matroska_audio_oracle.rs @@ -4,7 +4,7 @@ //! reads it, a tone must stay as smooth as ffmpeg's decode of it: no holes or overlaps between //! packets. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/it/media_oracle.rs similarity index 99% rename from crates/codecs/tests/media_oracle.rs rename to crates/codecs/tests/it/media_oracle.rs index da95c5eb6..5091c1dc3 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/it/media_oracle.rs @@ -4,7 +4,7 @@ //! exactly, JPEG within a few levels, high bit depths within one 8-bit step), and the BWF //! TimeReference must give the start timecode. -mod common; +use crate::common; use std::path::Path; use std::sync::Arc; diff --git a/crates/codecs/tests/mkv_mp1_oracle.rs b/crates/codecs/tests/it/mkv_mp1_oracle.rs similarity index 99% rename from crates/codecs/tests/mkv_mp1_oracle.rs rename to crates/codecs/tests/it/mkv_mp1_oracle.rs index 05d9810fb..aa142457c 100644 --- a/crates/codecs/tests/mkv_mp1_oracle.rs +++ b/crates/codecs/tests/it/mkv_mp1_oracle.rs @@ -2,7 +2,7 @@ //! installed encoder writes Layer I, so the frames are synthetic (valid random Layer I frames, //! as in `crates/mpa/tests/oracle.rs`) and ffmpeg only remuxes them into Matroska and decodes. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/mp4_oracle.rs b/crates/codecs/tests/it/mp4_oracle.rs similarity index 99% rename from crates/codecs/tests/mp4_oracle.rs rename to crates/codecs/tests/it/mp4_oracle.rs index cbb71ba0e..892598daf 100644 --- a/crates/codecs/tests/mp4_oracle.rs +++ b/crates/codecs/tests/it/mp4_oracle.rs @@ -2,7 +2,7 @@ //! priming), and reads at another sample rate must land on the same source samples as the native //! decode, however the requests are cut. -mod common; +use crate::common; use common::*; diff --git a/crates/codecs/tests/mp4_rtmd.rs b/crates/codecs/tests/it/mp4_rtmd.rs similarity index 100% rename from crates/codecs/tests/mp4_rtmd.rs rename to crates/codecs/tests/it/mp4_rtmd.rs diff --git a/crates/codecs/tests/mpeg_oracle.rs b/crates/codecs/tests/it/mpeg_oracle.rs similarity index 99% rename from crates/codecs/tests/mpeg_oracle.rs rename to crates/codecs/tests/it/mpeg_oracle.rs index e6d5a39dd..bf58294e8 100644 --- a/crates/codecs/tests/mpeg_oracle.rs +++ b/crates/codecs/tests/it/mpeg_oracle.rs @@ -4,7 +4,7 @@ //! random seeks give the frames of the sequential decode. Audio: LPCM sample-exact, MPEG audio //! and AAC within a float-decoder tolerance, starting at the same sample. -mod common; +use crate::common; use std::path::{Path, PathBuf}; diff --git a/crates/codecs/tests/mxf_oracle.rs b/crates/codecs/tests/it/mxf_oracle.rs similarity index 99% rename from crates/codecs/tests/mxf_oracle.rs rename to crates/codecs/tests/it/mxf_oracle.rs index 5a965b814..23d882755 100644 --- a/crates/codecs/tests/mxf_oracle.rs +++ b/crates/codecs/tests/it/mxf_oracle.rs @@ -2,7 +2,7 @@ //! oracle. Our decode must match frame for frame (H.264 bit-exact, VC-3 ±2, ProRes ±1 LSB), seek //! to random frames exactly, and give sample-exact PCM. -mod common; +use crate::common; use std::path::{Path, PathBuf}; diff --git a/crates/codecs/tests/ogg_oracle.rs b/crates/codecs/tests/it/ogg_oracle.rs similarity index 99% rename from crates/codecs/tests/ogg_oracle.rs rename to crates/codecs/tests/it/ogg_oracle.rs index 939407756..be02870ed 100644 --- a/crates/codecs/tests/ogg_oracle.rs +++ b/crates/codecs/tests/it/ogg_oracle.rs @@ -4,7 +4,7 @@ //! `filmcraft-opus`'s own oracle; Vorbis: SNR ≥ 60 dB), and any random window must equal the //! continuous decode (granule-position seeking with pre-roll). -mod common; +use crate::common; use std::path::{Path, PathBuf}; diff --git a/crates/codecs/tests/rf64_metadata.rs b/crates/codecs/tests/it/rf64_metadata.rs similarity index 100% rename from crates/codecs/tests/rf64_metadata.rs rename to crates/codecs/tests/it/rf64_metadata.rs diff --git a/crates/codecs/tests/y4m_file.rs b/crates/codecs/tests/it/y4m_file.rs similarity index 100% rename from crates/codecs/tests/y4m_file.rs rename to crates/codecs/tests/it/y4m_file.rs diff --git a/crates/codecs/tests/pool_bitexact.rs b/crates/codecs/tests/pool_bitexact.rs index a90ec658e..1261e9a2b 100644 --- a/crates/codecs/tests/pool_bitexact.rs +++ b/crates/codecs/tests/pool_bitexact.rs @@ -3,6 +3,7 @@ //! primed with buffers full of 0xAA before decoding, and every frame must still match ffmpeg bit //! for bit. (Its own process: the pool is global.) +#[path = "it/common/mod.rs"] mod common; use std::sync::Arc; diff --git a/crates/color/README.md b/crates/color/README.md index 32c3edee8..e78774322 100644 --- a/crates/color/README.md +++ b/crates/color/README.md @@ -38,6 +38,8 @@ for limited- and full-range files. | ARRI LogC4 | ARRI Wide Gamut 4 | ARRI, *ARRI LogC4 Logarithmic Color Space — Specification* (2022) | CV | a = (2¹⁸ − 16)/117.45, b = 928/1023, c = 95/1023, s = 7·ln2·2^(7 − 14c/b)/(a·b), t = (2^(6 − 14c/b) − 64)/a; y ≥ 0: x = (2^(14(y − c)/b + 6) − 64)/a; else x = y·s + t | | Apple Log | BT.2020 | Apple, *Apple Log Profile* white paper (2023) | IRE | R0 = −0.05641088, Rt = 0.01, c = 47.28711236, β = 0.00964052, γ = 0.08550479, δ = 0.69336945; P ≥ c(Rt − R0)²: x = 2^((P − δ)/γ) − β; else x = √(P/c) + R0 | | DJI D-Log | D-Gamut | DJI, *White Paper on D-Log and D-Gamut of DJI Cinema Color System* (2017) | CV | y ≤ 0.14: x = (y − 0.0929)/6.025; else x = (10^(3.89616y − 2.27752) − 0.0108)/0.9892 | +| Fujifilm F-Log | F-Gamut (= BT.2020 primaries) | Fujifilm, *GFX ETERNA Technical Guide – White Paper* v1.01 (2026), F-Log conversion formula (same as the F-Log data sheet) | CV | y ≥ cut2: x = 10^((y − d)/c)/a − b/a; else x = (y − f)/e; a 0.555556, b 0.009468, c 0.344676, d 0.790453, e 8.735631, f 0.092864, cut1 0.00089, cut2 0.100537775223865 | +| Fujifilm F-Log2 / F-Log2 C | F-Gamut / F-Gamut C (R 0.7347, 0.2653; G 0.0263, 0.9737; B 0.1173, −0.0224) | same document, F-Log2 C / F-Log2 conversion formula | CV | same form; a 5.555556, b 0.064829, c 0.245281, d 0.384316, e 8.799461, f 0.092864, cut1 0.000889, cut2 0.100686685370811 | The encoders are the exact inverses given in the same documents. DJI **D-Log M** is not implemented: DJI has not published its formula (only conversion LUTs), and we do not derive curves diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index ce3340162..0c271a3a8 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -296,7 +296,11 @@ pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] { /// Parse `#rrggbb` / `#rrggbbaa` into 0..1 floats. pub fn parse_hex(s: &str) -> Option<[f32; 4]> { - let s = s.trim().trim_start_matches('#'); + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); + if !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|v| v as f32 / 255.0); match s.len() { 6 => Some([b(0)?, b(2)?, b(4)?, 1.0]), @@ -318,6 +322,16 @@ pub fn to_hex(c: [f32; 4]) -> String { mod tests { use super::*; + #[test] + fn parse_hex_rejects_signs() { + assert_eq!(parse_hex("#+f+f+f"), None); + assert_eq!(parse_hex("#-f-f-f"), None); + assert_eq!(parse_hex("##0f0f0f"), None); + assert_eq!(parse_hex("#0f0f0"), None); + assert!(parse_hex("#0f0f0f").is_some()); + assert!(parse_hex("0f0f0f80").is_some()); + } + #[test] fn ycbcr_roundtrip() { for m in [Matrix::Bt601, Matrix::Bt709, Matrix::Bt2020Ncl] { diff --git a/crates/color/src/log.rs b/crates/color/src/log.rs index 7fd23afa8..ec5967518 100644 --- a/crates/color/src/log.rs +++ b/crates/color/src/log.rs @@ -42,11 +42,25 @@ pub enum LogCurve { AppleLog, /// DJI D-Log. DLog, + /// Fujifilm F-Log. + FLog, + /// Fujifilm F-Log2 (also the curve of F-Log2 C). + FLog2, } impl LogCurve { - pub const ALL: [LogCurve; 8] = - [LogCurve::SLog3, LogCurve::VLog, LogCurve::CLog2, LogCurve::CLog3, LogCurve::LogC3, LogCurve::LogC4, LogCurve::AppleLog, LogCurve::DLog]; + pub const ALL: [LogCurve; 10] = [ + LogCurve::SLog3, + LogCurve::VLog, + LogCurve::CLog2, + LogCurve::CLog3, + LogCurve::LogC3, + LogCurve::LogC4, + LogCurve::AppleLog, + LogCurve::DLog, + LogCurve::FLog, + LogCurve::FLog2, + ]; pub fn domain(self) -> Domain { match self { @@ -65,6 +79,8 @@ impl LogCurve { LogCurve::LogC4 => "ARRI LogC4", LogCurve::AppleLog => "Apple Log", LogCurve::DLog => "DJI D-Log", + LogCurve::FLog => "Fujifilm F-Log", + LogCurve::FLog2 => "Fujifilm F-Log2", } } @@ -79,6 +95,8 @@ impl LogCurve { LogCurve::LogC4 => logc4_encode(x), LogCurve::AppleLog => apple_log_encode(x), LogCurve::DLog => dlog_encode(x), + LogCurve::FLog => fujifilm_encode(&FLOG, x), + LogCurve::FLog2 => fujifilm_encode(&FLOG2, x), } } @@ -93,6 +111,8 @@ impl LogCurve { LogCurve::LogC4 => logc4_decode(y), LogCurve::AppleLog => apple_log_decode(y), LogCurve::DLog => dlog_decode(y), + LogCurve::FLog => fujifilm_decode(&FLOG, y), + LogCurve::FLog2 => fujifilm_decode(&FLOG2, y), } } @@ -262,6 +282,35 @@ pub fn dlog_decode(y: f64) -> f64 { if y <= 0.14 { (y - 0.0929) / 6.025 } else { (10f64.powf(3.896_16 * y - 2.277_52) - 0.0108) / 0.9892 } } +// ---------------------------------------------------------------- Fujifilm F-Log / F-Log2 +// Fujifilm, "GFX ETERNA Technical Guide - White Paper" v1.01 (2026), F-Log / F-Log2 C / F-Log2 +// conversion formulas (the same as the F-Log and F-Log2 data sheets). `in` is scene-linear +// reflection, `out` 10-bit code value / 1023: 0 % → 95, 18 % → 470 (F-Log) / 400 (F-Log2). + +/// a, b, c, d, e, f, cut1 (encoding: scene-linear), cut2 (decoding: signal). +struct Fujifilm { + a: f64, + b: f64, + c: f64, + d: f64, + e: f64, + f: f64, + cut1: f64, + cut2: f64, +} + +const FLOG: Fujifilm = + Fujifilm { a: 0.555_556, b: 0.009_468, c: 0.344_676, d: 0.790_453, e: 8.735_631, f: 0.092_864, cut1: 0.000_89, cut2: 0.100_537_775_223_865 }; +const FLOG2: Fujifilm = + Fujifilm { a: 5.555_556, b: 0.064_829, c: 0.245_281, d: 0.384_316, e: 8.799_461, f: 0.092_864, cut1: 0.000_889, cut2: 0.100_686_685_370_811 }; + +fn fujifilm_encode(k: &Fujifilm, x: f64) -> f64 { + if x >= k.cut1 { k.c * (k.a * x + k.b).log10() + k.d } else { k.e * x + k.f } +} +fn fujifilm_decode(k: &Fujifilm, y: f64) -> f64 { + if y >= k.cut2 { 10f64.powf((y - k.d) / k.c) / k.a - k.b / k.a } else { (y - k.f) / k.e } +} + #[cfg(test)] mod tests { use super::*; @@ -317,6 +366,16 @@ mod tests { // DJI D-Log: 0 → 0.0929, 18 % → 0.398 (white paper). close(LogCurve::DLog.encode(0.0), 0.0929, 1e-9, "D-Log 0"); close(LogCurve::DLog.encode(0.18), 0.398, 0.002, "D-Log 18%"); + // Fujifilm (white paper code-value table): 0 % → 95, 18 % → 470 / 400, 90 % → 705 / 570. + close(cv(LogCurve::FLog, 0.0), 95.0, 0.5, "F-Log 0%"); + close(cv(LogCurve::FLog, 0.18), 470.0, 0.5, "F-Log 18%"); + close(cv(LogCurve::FLog, 0.9), 705.0, 0.6, "F-Log 90%"); + close(cv(LogCurve::FLog2, 0.0), 95.0, 0.5, "F-Log2 0%"); + close(cv(LogCurve::FLog2, 0.18), 400.0, 0.5, "F-Log2 18%"); + close(cv(LogCurve::FLog2, 0.9), 570.0, 0.5, "F-Log2 90%"); + // the decoding cut is where the encoding's two pieces meet (within the published rounding) + close(LogCurve::FLog.encode(0.000_89), 0.100_537_775_223_865, 2e-4, "F-Log cut"); + close(LogCurve::FLog2.encode(0.000_889), 0.100_686_685_370_811, 2e-4, "F-Log2 cut"); } #[test] diff --git a/crates/color/src/lut.rs b/crates/color/src/lut.rs index 67590eb12..d2d1d4e75 100644 --- a/crates/color/src/lut.rs +++ b/crates/color/src/lut.rs @@ -226,7 +226,14 @@ impl Lut { return Err(err("keyword after table data")); } match key { - "TITLE" => title = line[5..].trim().trim_matches('"').to_string(), + "TITLE" => { + // a '#' inside a quoted title is part of the title, not a comment + let rest = raw.trim_start()[5..].trim_start(); + title = match rest.strip_prefix('"').and_then(|q| q.split_once('"')) { + Some((t, _)) => t.to_string(), + None => line[5..].trim().trim_matches('"').to_string(), + }; + } "LUT_1D_SIZE" => { size1 = it.next().and_then(|v| v.parse().ok()).ok_or_else(|| err("bad LUT_1D_SIZE"))?; if !(2..=65536).contains(&size1) { @@ -308,7 +315,11 @@ impl Lut { if let Some(sh) = &self.shaper { let _ = writeln!(s, "LUT_1D_SIZE {}", sh.size()); if both { - if sh.domain_min != [0.0; 3] || sh.domain_max != [1.0; 3] { + let uniform = sh.domain_min.iter().all(|v| *v == sh.domain_min[0]) && sh.domain_max.iter().all(|v| *v == sh.domain_max[0]); + if !uniform { + // with both tables, DOMAIN_MIN/DOMAIN_MAX belong to the shaper + write_domain(&mut s, sh.domain_min, sh.domain_max); + } else if sh.domain_min != [0.0; 3] || sh.domain_max != [1.0; 3] { let _ = writeln!(s, "LUT_1D_INPUT_RANGE {} {}", sh.domain_min[0], sh.domain_max[0]); } } else { @@ -555,6 +566,15 @@ mod tests { } } + #[test] + fn cube_title_with_hash_roundtrips() { + let mut c = Lut3d::identity(2); + c.title = "Look #2".into(); + assert_eq!(Lut3d::parse_cube(&c.to_cube()).unwrap().title, "Look #2"); + let text = c.to_cube().replace("TITLE \"Look #2\"", "TITLE \"Look\" # note"); + assert_eq!(Lut::parse_cube(&text).unwrap().title, "Look"); + } + #[test] fn cube_roundtrip_3d_1d_and_shaper() { let mut c = wild(17); @@ -650,4 +670,16 @@ mod tests { let mesh257 = (0..257).map(|i| i.to_string()).collect::>().join(" ") + "\n"; assert!(Lut::parse_3dl(&mesh257).is_err()); } + + #[test] + fn cube_shaper_per_channel_domain_round_trips() { + let mut text = String::from("LUT_1D_SIZE 2\nLUT_3D_SIZE 2\nDOMAIN_MAX 1 2 3\n"); + for _ in 0..10 { + text.push_str("0 0 0\n"); + } + let a = Lut::parse_cube(&text).unwrap(); + assert_eq!(a.shaper.as_ref().unwrap().domain_max, [1.0, 2.0, 3.0]); + let b = Lut::parse_cube(&a.to_cube()).unwrap(); + assert_eq!(b.shaper.unwrap().domain_max, [1.0, 2.0, 3.0]); + } } diff --git a/crates/color/src/spaces.rs b/crates/color/src/spaces.rs index 0dd93f271..e4a994df1 100644 --- a/crates/color/src/spaces.rs +++ b/crates/color/src/spaces.rs @@ -34,6 +34,8 @@ pub enum Gamut { Awg4, /// DJI D-Gamut. DGamut, + /// Fujifilm F-Gamut C (F-Log2 C). F-Gamut itself has the BT.2020 primaries. + FGamutC, } impl Gamut { @@ -51,6 +53,7 @@ impl Gamut { Gamut::Awg3 => [[0.6840, 0.3130], [0.2210, 0.8480], [0.0861, -0.1020]], Gamut::Awg4 => [[0.7347, 0.2653], [0.1424, 0.8576], [0.0991, -0.0308]], Gamut::DGamut => [[0.71, 0.31], [0.21, 0.88], [0.09, -0.08]], + Gamut::FGamutC => [[0.7347, 0.2653], [0.0263, 0.9737], [0.1173, -0.0224]], } } @@ -165,12 +168,18 @@ pub enum ColorSpace { /// Apple Log (BT.2020 primaries). AppleLog, DLogDGamut, + /// Fujifilm F-Log / F-Gamut (BT.2020 primaries). + FLogFGamut, + /// Fujifilm F-Log2 / F-Gamut (BT.2020 primaries). + FLog2FGamut, + /// Fujifilm F-Log2 C / F-Gamut C. + FLog2CFGamutC, /// Linear light with BT.709 primaries (EXR-style float sources). Linear709, } impl ColorSpace { - pub const ALL: [ColorSpace; 16] = [ + pub const ALL: [ColorSpace; 19] = [ ColorSpace::Rec709, ColorSpace::Srgb, ColorSpace::Rec2020, @@ -186,6 +195,9 @@ impl ColorSpace { ColorSpace::LogC4Awg4, ColorSpace::AppleLog, ColorSpace::DLogDGamut, + ColorSpace::FLogFGamut, + ColorSpace::FLog2FGamut, + ColorSpace::FLog2CFGamutC, ColorSpace::Linear709, ]; @@ -207,6 +219,9 @@ impl ColorSpace { ColorSpace::LogC4Awg4 => "logc4-awg4", ColorSpace::AppleLog => "applelog", ColorSpace::DLogDGamut => "dlog-dgamut", + ColorSpace::FLogFGamut => "flog-fgamut", + ColorSpace::FLog2FGamut => "flog2-fgamut", + ColorSpace::FLog2CFGamutC => "flog2c-fgamutc", ColorSpace::Linear709 => "linear709", } } @@ -228,6 +243,9 @@ impl ColorSpace { ColorSpace::LogC4Awg4 => "ARRI LogC4 / ARRI Wide Gamut 4", ColorSpace::AppleLog => "Apple Log / Rec. 2020", ColorSpace::DLogDGamut => "DJI D-Log / D-Gamut", + ColorSpace::FLogFGamut => "Fujifilm F-Log / F-Gamut", + ColorSpace::FLog2FGamut => "Fujifilm F-Log2 / F-Gamut", + ColorSpace::FLog2CFGamutC => "Fujifilm F-Log2 C / F-Gamut C", ColorSpace::Linear709 => "Linear (Rec. 709 primaries)", } } @@ -242,7 +260,9 @@ impl ColorSpace { pub fn gamut(self) -> Gamut { match self { ColorSpace::Rec709 | ColorSpace::Srgb | ColorSpace::Linear709 => Gamut::Bt709, - ColorSpace::Rec2020 | ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg | ColorSpace::AppleLog => Gamut::Bt2020, + ColorSpace::Rec2020 | ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg | ColorSpace::AppleLog | ColorSpace::FLogFGamut | ColorSpace::FLog2FGamut => { + Gamut::Bt2020 + } ColorSpace::P3D65 => Gamut::P3D65, ColorSpace::SLog3SGamut3Cine => Gamut::SGamut3Cine, ColorSpace::SLog3SGamut3 => Gamut::SGamut3, @@ -251,6 +271,7 @@ impl ColorSpace { ColorSpace::LogC3Awg3 => Gamut::Awg3, ColorSpace::LogC4Awg4 => Gamut::Awg4, ColorSpace::DLogDGamut => Gamut::DGamut, + ColorSpace::FLog2CFGamutC => Gamut::FGamutC, } } @@ -269,6 +290,8 @@ impl ColorSpace { ColorSpace::LogC4Awg4 => Curve::Log(LogCurve::LogC4), ColorSpace::AppleLog => Curve::Log(LogCurve::AppleLog), ColorSpace::DLogDGamut => Curve::Log(LogCurve::DLog), + ColorSpace::FLogFGamut => Curve::Log(LogCurve::FLog), + ColorSpace::FLog2FGamut | ColorSpace::FLog2CFGamutC => Curve::Log(LogCurve::FLog2), } } diff --git a/crates/dng/README.md b/crates/dng/README.md index 4c2b1e7e9..c8678c1d3 100644 --- a/crates/dng/README.md +++ b/crates/dng/README.md @@ -73,14 +73,14 @@ restart intervals that split a line, and MXF-wrapped CinemaDNG. ## Tests -- `tests/roundtrip.rs`: container parsing in both byte orders with and without a preview IFD; +- `tests/it/roundtrip.rs`: container parsing in both byte orders with and without a preview IFD; uncompressed samples bit-exact at 8–16 bits, strips and tiles; lossless JPEG bit-exact for every predictor and component count, full-swing 16-bit data (every difference category, SSSS = 16), restarts and point transform; developed values against hand-computed ones (black / white, white balance, ForwardMatrix, BaselineExposure, ActiveArea, DefaultCrop, LinearRaw); CinemaDNG FrameRate / TimeCodes / ReelName; hostile files (every truncation, 12 000 mutations under `catch_unwind`, cyclic IFDs, huge sizes, bad offsets, zero denominators). -- `tests/oracle_libraw.rs`: LibRaw's `unprocessed_raw` reads the same samples from our synthetic +- `tests/it/oracle_libraw.rs`: LibRaw's `unprocessed_raw` reads the same samples from our synthetic files (uncompressed 8–16-bit in both byte orders, lossless JPEG predictors 1–7 with one and two components). Skipped when it is not installed. - Unit tests: matrix maths, the published sRGB matrix, McCamy temperatures, calibration diff --git a/crates/dng/src/ljpeg.rs b/crates/dng/src/ljpeg.rs index e76dbd707..31f357f48 100644 --- a/crates/dng/src/ljpeg.rs +++ b/crates/dng/src/ljpeg.rs @@ -311,7 +311,7 @@ pub fn decode(data: &[u8], max_samples: usize) -> Result { } let mut hufs = Vec::with_capacity(comps.len()); for c in &comps { - hufs.push(tables.get(c.table).and_then(|t| t.as_ref()).ok_or(Error::Malformed("lossless JPEG: missing Huffman table"))?); + hufs.push(tables.get(c.table).and_then(Option::as_ref).ok_or(Error::Malformed("lossless JPEG: missing Huffman table"))?); } let samples = scan(data, pos + len, &hufs, width, height, precision, predictor, pt, restart_interval)?; return Ok(Decoded { width, height, components: comps.len(), precision, predictor, samples }); diff --git a/crates/dng/src/raw.rs b/crates/dng/src/raw.rs index b1c448ffc..4e69e0944 100644 --- a/crates/dng/src/raw.rs +++ b/crates/dng/src/raw.rs @@ -1,14 +1,14 @@ //! The raw image data of a DNG: finding the main raw IFD, and reading its strips or tiles //! (uncompressed or lossless JPEG) into one array of 16-bit samples. -#[cfg(feature = "threads")] -use rayon::prelude::*; - use crate::tiff::{Ifd, Tiff, tags}; use crate::{Error, Result, ljpeg}; /// Largest raw image decoded, in samples (width × height × samples per pixel). pub const MAX_SAMPLES: usize = 1 << 27; +/// Decoded tiles are padded to tile boundaries, so their aggregate can exceed the useful image. +/// Keep that temporary work within the same bound as a complete decoded raw frame. +const MAX_DECODED_TILE_SAMPLES: usize = MAX_SAMPLES; /// Photometric interpretations of DNG raw data. pub const PHOTOMETRIC_CFA: u64 = 32803; @@ -225,23 +225,23 @@ pub fn read(t: &Tiff, ifd: &Ifd) -> Result { if total / 8 > coded { return Err(Error::Truncated); } + let mut decoded_samples = 0usize; for c in &chunks { let n = c.w.checked_mul(c.h).and_then(|v| v.checked_mul(spp)).ok_or(Error::TooLarge)?; - if n > MAX_SAMPLES { + decoded_samples = decoded_samples.checked_add(n).ok_or(Error::TooLarge)?; + if n > MAX_SAMPLES || decoded_samples > MAX_DECODED_TILE_SAMPLES { return Err(Error::TooLarge); } if n / 8 > c.bytes.len() { return Err(Error::Truncated); } } - #[cfg(feature = "threads")] - let decoded: Vec>> = chunks.par_iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); - #[cfg(not(feature = "threads"))] - let decoded: Vec>> = chunks.iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); let mut data = vec![0u16; total]; let row_len = l.width * spp; - for (c, d) in chunks.iter().zip(decoded) { - let d = d?; + for c in &chunks { + // Copy each padded tile immediately. Retaining every decoded tile until assembly lets + // repeated ranges multiply memory beyond the final raw frame's bounded allocation. + let d = decode_chunk(c, &l, t.big_endian)?; let cw = c.w.min(l.width - c.x); let ch = c.h.min(l.height - c.y); for r in 0..ch { diff --git a/crates/dng/src/tiff.rs b/crates/dng/src/tiff.rs index ba479649c..1e8e79c42 100644 --- a/crates/dng/src/tiff.rs +++ b/crates/dng/src/tiff.rs @@ -5,12 +5,16 @@ //! outside the file is an error when it is read, the walk visits at most [`MAX_IFDS`] directories, //! never the same one twice (cyclic chains end), and goes at most [`MAX_DEPTH`] SubIFD levels deep. +use std::collections::HashSet; + use crate::{Error, Result}; /// Most IFDs one file may have (main chain and SubIFDs together). pub const MAX_IFDS: usize = 64; /// Deepest SubIFD nesting followed. pub const MAX_DEPTH: usize = 4; +/// Invalid SubIFD edges count too: otherwise a file can make failed offset probes quadratic. +const MAX_IFD_ATTEMPTS: usize = MAX_IFDS * 16; /// TIFF field types (TIFF 6.0 §2, plus type 13 IFD from TIFF Technical Note 1). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -156,18 +160,22 @@ impl<'a> Tiff<'a> { /// each. Bounded by [`MAX_IFDS`] and [`MAX_DEPTH`]; a directory reached twice ends that branch. pub fn all_ifds(&self, first: u64) -> Result> { let mut out: Vec = Vec::new(); - let mut seen: Vec = Vec::new(); + let mut seen = HashSet::new(); + let mut attempts = 0usize; let mut stack: Vec<(u64, usize)> = vec![(first, 0)]; while let Some((off, depth)) = stack.pop() { let mut next = off; while next != 0 { - if seen.contains(&next) { + if !seen.insert(next) { break; } + attempts += 1; + if attempts > MAX_IFD_ATTEMPTS { + return Err(Error::Malformed("too many IFD offsets")); + } if out.len() >= MAX_IFDS { return Err(Error::Malformed("too many IFDs")); } - seen.push(next); let (ifd, after) = match self.read_ifd(next, depth) { Ok(x) => x, // a broken directory ends the chain; the first one must be readable diff --git a/crates/dng/tests/it/main.rs b/crates/dng/tests/it/main.rs new file mode 100644 index 000000000..fd498d961 --- /dev/null +++ b/crates/dng/tests/it/main.rs @@ -0,0 +1,2 @@ +mod oracle_libraw; +mod roundtrip; diff --git a/crates/dng/tests/oracle_libraw.rs b/crates/dng/tests/it/oracle_libraw.rs similarity index 100% rename from crates/dng/tests/oracle_libraw.rs rename to crates/dng/tests/it/oracle_libraw.rs diff --git a/crates/dng/tests/roundtrip.rs b/crates/dng/tests/it/roundtrip.rs similarity index 100% rename from crates/dng/tests/roundtrip.rs rename to crates/dng/tests/it/roundtrip.rs diff --git a/crates/dnx/README.md b/crates/dnx/README.md index 596ea9fa2..9f4656817 100644 --- a/crates/dnx/README.md +++ b/crates/dnx/README.md @@ -59,7 +59,7 @@ first frame header to label the stream. 12-bit coefficient domain is ¼ of the orthonormal DCT, as Table 14's 13-bit IDCT input range for 12-bit samples implies) and 1 otherwise. - Robustness: every offset and size is validated, run lengths past coefficient 63 are rejected, - and corrupted or truncated streams return `Err` without panicking (`tests/roundtrip.rs`). + and corrupted or truncated streams return `Err` without panicking (`tests/it/roundtrip.rs`). ### Where we follow ffmpeg rather than the letter of the standard @@ -100,7 +100,7 @@ the prose of §7.3.1.2; we follow the prose (normative text takes precedence). ## Accuracy (vs ffmpeg as an external oracle) -`tests/oracle_decode.rs` decodes 22 fixtures made by ffmpeg's VC-3 encoder: CIDs 1235, 1237, +`tests/it/oracle_decode.rs` decodes 22 fixtures made by ffmpeg's VC-3 encoder: CIDs 1235, 1237, 1238, 1241, 1242, 1243, 1244, 1250, 1251, 1252, 1253, 1258, 1259 and DNxHR LB/SQ/HQ/HQX/444 (Y′CbCr and RGB), with testsrc2, mandelbrot and heavy noise, at 1920×1080, 1280×720, 960×720, 1440×1080 (thin raster, interlaced), 1000×562, 640×360, 256×120 and 3840×2160. Compared with @@ -111,7 +111,7 @@ decoder evaluates the exact IDCT of equation 8.3, so decode is **not bit-exact, precision** of ffmpeg. (ffmpeg wraps rather than clips 10-bit reconstructions that overflow 1023, so the noise fixtures keep their samples inside 24…231 before encoding.) -`tests/oracle_encode.rs` encodes 1920×1080 frames with every profile and writes them into a +`tests/it/oracle_encode.rs` encodes 1920×1080 frames with every profile and writes them into a minimal test-only MOV. ffmpeg decodes them with `-xerror`, without errors, and agrees with our decoder within ±2 (90–99% identical). Luma PSNR against the source: HQ 58.8 dB (mandelbrot) and 66.3 dB (testsrc2), SQ 57.5 dB, LB 56.1 dB, HQX 10-bit 67.5 dB, HQX 12-bit 80.2 dB, 444 10-bit @@ -119,7 +119,7 @@ decoder within ±2 (90–99% identical). Luma PSNR against the source: HQ 58.8 d ## Performance -`cargo test --release -p filmcraft-dnx --test perf -- --ignored --nocapture` on an M4 Pro +`cargo test --release -p filmcraft-dnx --test it perf:: -- --ignored --nocapture` on an M4 Pro (14 cores) while the machine was **heavily overloaded** by other jobs (load average ≈ 190). Under the same load `filmcraft-prores`' perf test measured 30 fps threaded / 11.8 fps single for a 1080p HQ frame, against 318 / 53 fps in its README on a less loaded machine, so these are diff --git a/crates/dnx/src/decode.rs b/crates/dnx/src/decode.rs index 22f9f542b..ede98b0b3 100644 --- a/crates/dnx/src/decode.rs +++ b/crates/dnx/src/decode.rs @@ -54,9 +54,11 @@ pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result { } else { (decode_unit(&h1, &data[..off], opts), decode_unit(&h2, &data[off..], opts)) }; + let (top, bottom) = if h1.field == FieldCode::Field2 { (p2?, p1?) } else { (p1?, p2?) }; + // Both field payloads must be valid before committing the full-frame + // allocation; a damaged header alone must not allocate an output picture. let mut f = Frame::new_like(&h1, h1.width, h1.lines * 2); f.interlace = Interlace::TopFieldFirst; - let (top, bottom) = if h1.field == FieldCode::Field2 { (p2?, p1?) } else { (p1?, p2?) }; top.copy_into(&mut f, 0, 2); bottom.copy_into(&mut f, 1, 2); Ok(f) diff --git a/crates/dnx/src/tables.rs b/crates/dnx/src/tables.rs index c1fe24909..905d808f1 100644 --- a/crates/dnx/src/tables.rs +++ b/crates/dnx/src/tables.rs @@ -226,13 +226,13 @@ pub(crate) fn vlc(i: usize) -> &'static VlcSet { /// Orthonormal 1-D DCT-II basis: `BASIS[u][x] = C(u)/2 · cos((2x+1)uπ/16)`, `C(0) = 1/√2`. pub(crate) const BASIS: [[f32; 8]; 8] = [ [0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39], - [0.490_392_64, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.490_392_64], + [0.49039264, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.49039264], [0.461_939_77, 0.191_341_72, -0.191_341_72, -0.461_939_77, -0.461_939_77, -0.191_341_72, 0.191_341_72, 0.461_939_77], - [0.415_734_8, -0.097_545_16, -0.490_392_64, -0.277_785_12, 0.277_785_12, 0.490_392_64, 0.097_545_16, -0.415_734_8], + [0.415_734_8, -0.097_545_16, -0.49039264, -0.277_785_12, 0.277_785_12, 0.49039264, 0.097_545_16, -0.415_734_8], [0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39, 0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39], - [0.277_785_12, -0.490_392_64, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.490_392_64, -0.277_785_12], + [0.277_785_12, -0.49039264, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.49039264, -0.277_785_12], [0.191_341_72, -0.461_939_77, 0.461_939_77, -0.191_341_72, -0.191_341_72, 0.461_939_77, -0.461_939_77, 0.191_341_72], - [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.490_392_64, 0.490_392_64, -0.415_734_8, 0.277_785_12, -0.097_545_16], + [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.49039264, 0.49039264, -0.415_734_8, 0.277_785_12, -0.097_545_16], ]; #[cfg(test)] diff --git a/crates/dnx/tests/common/mod.rs b/crates/dnx/tests/it/common/mod.rs similarity index 100% rename from crates/dnx/tests/common/mod.rs rename to crates/dnx/tests/it/common/mod.rs diff --git a/crates/dnx/tests/it/fuzz_hostile.rs b/crates/dnx/tests/it/fuzz_hostile.rs new file mode 100644 index 000000000..a20e26a2b --- /dev/null +++ b/crates/dnx/tests/it/fuzz_hostile.rs @@ -0,0 +1,183 @@ +//! Hostile-input robustness: every header byte set to extreme values, every bit flipped, +//! truncation at (nearly) every length and random mutation of encoder-made frames must never +//! panic or run away (errors are fine). No ffmpeg needed. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use filmcraft_dnx::{ChromaFormat, DecodeOptions, Encoder, EncoderConfig, Frame, Profile, decode_frame_with, probe}; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +fn seeds() -> Vec> { + let mut v = Vec::new(); + for (profile, depth, w, h) in + [(Profile::Lb, 8, 64u32, 48u32), (Profile::Hq, 8, 48, 32), (Profile::Hqx, 10, 64, 32), (Profile::Hqx, 12, 48, 32), (Profile::R444, 10, 48, 32)] + { + let mut cfg = EncoderConfig::new(profile, w, h); + cfg.bit_depth = depth; + let chroma = cfg.chroma(); + let mut f = Frame::new(w, h, chroma, depth, false); + for (i, s) in f.y.iter_mut().enumerate() { + *s = ((16 + (i * 37) % 200) as u16) << (depth - 8); + } + if chroma != ChromaFormat::Yuv444 { + f.cb.fill(128 << (depth - 8)); + f.cr.fill(100 << (depth - 8)); + } + if let Ok(mut enc) = Encoder::with_config(cfg) + && let Ok(d) = enc.encode(&f) + { + v.push(d); + } + } + v +} + +fn run(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + let res = catch_unwind(AssertUnwindSafe(|| { + let _ = probe(d); + for opts in [DecodeOptions::default(), DecodeOptions { threads: true, spec_dequant: true }] { + let _ = decode_frame_with(d, &opts); + } + })); + assert!(res.is_ok(), "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=1500` runs all of it (see [`Depth`]). +#[test] +fn hostile_frames_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let depth = Depth::from_env(); + let seeds = seeds(); + assert!(!seeds.is_empty()); + for (si, seed) in seeds.iter().enumerate() { + for n in depth.truncations(seed.len()) { + run(&seed[..n], &format!("seed {si} truncate {n}")); + } + header_flips(&depth, seed, si); + big_fields(seed, si); + random_rounds(&depth, seed, si); + } +} + +/// The header (and the start of the scan-line index): every byte to extreme values, every bit. +fn header_flips(depth: &Depth, seed: &[u8], si: usize) { + for p in 0..seed.len().min(0x300) { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } + let mut d = seed.to_vec(); + d[p] = v; + run(&d, &format!("seed {si} byte {p:#x}={v:#x}")); + } + for b in 0..8 { + if !depth.bit(p, b) { + continue; + } + let mut d = seed.to_vec(); + d[p] ^= 1 << b; + run(&d, &format!("seed {si} flip {p:#x}.{b}")); + } + } +} + +/// Big dimension fields with the rest intact (lines 0x18, width 0x1A, macroblock rows 0x16C). +fn big_fields(seed: &[u8], si: usize) { + for (off, v) in [(0x18usize, 0xffffu16), (0x1a, 0xffff), (0x18, 0x4000), (0x1a, 0x4000), (0x16c, 0xffff)] { + let mut d = seed.to_vec(); + if let Some(s) = d.get_mut(off..off + 2) { + s.copy_from_slice(&v.to_be_bytes()); + } + run(&d, &format!("seed {si} u16 {off:#x}={v:#x}")); + } +} + +fn random_rounds(depth: &Depth, seed: &[u8], si: usize) { + let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64); + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { + let mut d = seed.to_vec(); + for _ in 0..1 + rng.below(8) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + if depth.round(i) { + run(&d, &format!("seed {si} random {i}")); + } + } +} + +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 400 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 1500 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 400, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=1500 runs everything)"); + Depth { full: false, truncations: 400, rounds: 1500, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) +} diff --git a/crates/dnx/tests/it/main.rs b/crates/dnx/tests/it/main.rs new file mode 100644 index 000000000..c12ebd099 --- /dev/null +++ b/crates/dnx/tests/it/main.rs @@ -0,0 +1,6 @@ +mod common; +mod fuzz_hostile; +mod oracle_decode; +mod oracle_encode; +mod perf; +mod roundtrip; diff --git a/crates/dnx/tests/oracle_decode.rs b/crates/dnx/tests/it/oracle_decode.rs similarity index 99% rename from crates/dnx/tests/oracle_decode.rs rename to crates/dnx/tests/it/oracle_decode.rs index 476ee68d1..a9da675c8 100644 --- a/crates/dnx/tests/oracle_decode.rs +++ b/crates/dnx/tests/it/oracle_decode.rs @@ -1,6 +1,6 @@ //! Decode ffmpeg-made DNxHD / DNxHR fixtures and compare with ffmpeg's own decode. -mod common; +use crate::common; use common::*; use filmcraft_dnx::{DecodeOptions, decode_frame_with}; diff --git a/crates/dnx/tests/oracle_encode.rs b/crates/dnx/tests/it/oracle_encode.rs similarity index 99% rename from crates/dnx/tests/oracle_encode.rs rename to crates/dnx/tests/it/oracle_encode.rs index ca64941df..4d5702698 100644 --- a/crates/dnx/tests/oracle_encode.rs +++ b/crates/dnx/tests/it/oracle_encode.rs @@ -1,6 +1,6 @@ //! Encode DNxHR with our encoder, decode with ffmpeg (`-xerror`) and with our decoder. -mod common; +use crate::common; use common::*; use filmcraft_dnx::{ChromaFormat, Encoder, EncoderConfig, Profile, decode_frame}; diff --git a/crates/dnx/tests/perf.rs b/crates/dnx/tests/it/perf.rs similarity index 95% rename from crates/dnx/tests/perf.rs rename to crates/dnx/tests/it/perf.rs index 255f1961c..befd20a48 100644 --- a/crates/dnx/tests/perf.rs +++ b/crates/dnx/tests/it/perf.rs @@ -1,4 +1,4 @@ -//! Throughput: `cargo test --release -p filmcraft-dnx --test perf -- --ignored --nocapture`. +//! Throughput: `cargo test --release -p filmcraft-dnx --test it perf:: -- --ignored --nocapture`. use filmcraft_dnx::{ChromaFormat, DecodeOptions, Encoder, EncoderConfig, Frame, Profile, decode_frame_with}; use std::time::Instant; diff --git a/crates/dnx/tests/roundtrip.rs b/crates/dnx/tests/it/roundtrip.rs similarity index 100% rename from crates/dnx/tests/roundtrip.rs rename to crates/dnx/tests/it/roundtrip.rs diff --git a/crates/dnx/tools/extract_tables.py b/crates/dnx/tools/extract_tables.py index 56d73a6e1..2e120cc93 100644 --- a/crates/dnx/tools/extract_tables.py +++ b/crates/dnx/tools/extract_tables.py @@ -14,8 +14,18 @@ prefix code. """ import re import sys +from pathlib import Path -text = open(sys.argv[1], encoding="utf-8").read() + +def _spec_path(arg): + """Resolve a CLI path argument and require it to be an existing regular file.""" + path = Path(arg).resolve() + if not path.is_file(): + sys.exit(f"not a readable file: {arg}") + return path + + +text = open(_spec_path(sys.argv[1]), encoding="utf-8").read() lines = text.split("\n") diff --git a/crates/edit/src/captions.rs b/crates/edit/src/captions.rs index a08d0ad7d..252c7e46e 100644 --- a/crates/edit/src/captions.rs +++ b/crates/edit/src/captions.rs @@ -215,6 +215,67 @@ pub fn move_captions(seq: &mut Sequence, ids: &[ClipId], delta: Tick) -> Result< Ok(d) } +/// Place copies of `captions` on `track`, overwriting what lies under them (captions there are cut +/// back, split or removed), as Paste and Option-drag do. The copies keep their text, speaker and +/// settings at their own `start`/`duration`, and get new ids (returned in time order). +pub fn overwrite_captions(seq: &mut Sequence, track: TrackId, captions: &[Caption], ctx: &mut EditCtx) -> Result> { + let min = ctx.min_duration; + let mut placed: Vec
- The Lumetri Color panel on Charade (1963): Basic Correction sliders with coloured temperature and tint tracks, collapsed Creative and Curves sections, shadow, midtone and highlight colour wheels, and HSL Secondary and Vignette toggles
- The Lumetri Color panel. Basic Correction, Color Wheels and every other section, each with its own on/off switch. + The color grading panel on Charade (1963): Basic Correction sliders with coloured temperature and tint tracks, collapsed Creative and Curves sections, shadow, midtone and highlight colour wheels, and HSL Secondary and Vignette toggles
+ The color grading panel. Basic Correction, Color Wheels and every other section, each with its own on/off switch.
- Lumetri Scopes: a green luma waveform and a vectorscope of a Charade (1963) frame
+ The video scopes: a green luma waveform and a vectorscope of a Charade (1963) frame
Scopes that tell the truth. The waveform and vectorscope are computed from the graded frame. All grading happens in linear light, in 32-bit float.
= captions.iter().filter(|c| c.duration >= min).cloned().collect(); + if placed.is_empty() { + return Err(EditError::Nothing); + } + placed.sort_by_key(|c| c.start); + if placed.first().is_some_and(|c| c.start < Tick::ZERO) { + return Err(EditError::Other("captions cannot start before the sequence".into())); + } + if placed.windows(2).any(|w| w[0].end() > w[1].start) { + return Err(EditError::Other("captions overlap".into())); + } + let tr = track_mut(seq, track)?; + for c in &mut placed { + c.id = ClipId(ctx.alloc()); + c.cue_id = None; + } + for c in &placed { + clear_range(tr, c.start, c.end(), min, ctx); + } + let ids = placed.iter().map(|c| c.id).collect(); + tr.captions.extend(placed); + tr.sort(); + Ok(ids) +} + +/// Clear `a..b` on a caption track without closing the gap: captions inside go, captions across an +/// edge are cut back, a caption across the whole range is split. Pieces shorter than `min` go. +fn clear_range(tr: &mut CaptionTrack, a: Tick, b: Tick, min: Tick, ctx: &mut EditCtx) { + let mut out = Vec::with_capacity(tr.captions.len() + 1); + for c in tr.captions.drain(..) { + let (s, e) = (c.start, c.end()); + if e <= a || s >= b { + out.push(c); + continue; + } + let mut kept_left = false; + if s < a && a - s >= min { + let mut l = c.clone(); + l.duration = a - s; + out.push(l); + kept_left = true; + } + if e > b && e - b >= min { + let mut r = c; + if kept_left { + r.id = ClipId(ctx.alloc()); + r.cue_id = None; + } + r.start = b; + r.duration = e - b; + out.push(r); + } + } + tr.captions = out; +} + /// Delete captions. With `ripple`, later captions on the same track move left to close each gap. pub fn delete_captions(seq: &mut Sequence, ids: &[ClipId], ripple: bool) -> Result { let mut n = 0; @@ -385,6 +446,40 @@ mod tests { assert_eq!(spans(&seq, tid), vec![(0, 40, "a".into()), (40, 80, "c".into())]); } + #[test] + fn overwrite_cuts_splits_and_removes_what_is_under() { + let (mut seq, tid) = seq_with(&[(0, 100, "a"), (100, 50, "b"), (200, 100, "c")]); + let mut n = 1; + let copy = |s: i64, d: i64, t: &str| Caption { + id: ClipId(1), + start: Tick(s), + duration: Tick(d), + text: t.into(), + speaker: None, + cue_id: Some("x".into()), + settings: String::new(), + }; + // across a's tail and all of b; then inside c (c is split around it) + let ids = overwrite_captions(&mut seq, tid, &[copy(60, 95, "x"), copy(230, 40, "y")], &mut ctx(&mut n)).unwrap(); + assert_eq!(ids.len(), 2); + assert_eq!(spans(&seq, tid), vec![(0, 60, "a".into()), (60, 155, "x".into()), (200, 230, "c".into()), (230, 270, "y".into()), (270, 300, "c".into())]); + let tr = seq.caption_track(tid).unwrap(); + assert!(tr.captions.iter().all(|c| c.cue_id.is_none())); + let seen: std::collections::HashSet = tr.captions.iter().map(|c| c.id.0).collect(); + assert_eq!(seen.len(), 5, "every piece has its own id"); + assert!(seq.check().is_ok()); + // a sliver under one frame is dropped, not kept + let (mut seq, tid) = seq_with(&[(0, 100, "a")]); + overwrite_captions(&mut seq, tid, &[copy(5, 90, "x")], &mut ctx(&mut n)).unwrap(); + assert_eq!(spans(&seq, tid), vec![(5, 95, "x".into())]); + // hostile input: before zero, overlapping copies, nothing + assert!(overwrite_captions(&mut seq, tid, &[copy(-5, 50, "x")], &mut ctx(&mut n)).is_err()); + assert!(overwrite_captions(&mut seq, tid, &[copy(0, 50, "x"), copy(40, 50, "y")], &mut ctx(&mut n)).is_err()); + assert_eq!(overwrite_captions(&mut seq, tid, &[], &mut ctx(&mut n)), Err(EditError::Nothing)); + seq.caption_track_mut(tid).unwrap().locked = true; + assert_eq!(overwrite_captions(&mut seq, tid, &[copy(0, 50, "x")], &mut ctx(&mut n)), Err(EditError::Locked)); + } + #[test] fn locked_refuses() { let (mut seq, tid) = seq_with(&[(0, 100, "a")]); diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f2588d6c..42c613ec7 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -26,7 +26,7 @@ pub mod transitions; use std::collections::HashMap; -use filmcraft_project::{ClipId, ItemId, Sequence, Track, TrackId, TrackItem, Transition, TransitionId}; +use filmcraft_project::{ClipId, ItemId, Sequence, Track, TrackId, TrackItem, Transition, TransitionAlign, TransitionId}; use filmcraft_time::{Tick, TimeRange}; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -196,24 +196,59 @@ fn transition_anchor(track: &Track, tr: &Transition) -> Option { } /// Keep transitions on their edit points after an edit moved cuts (ripple trim, roll, slide, ripple -/// speed change): each transition shifts by as much as its anchor clip edge moved. +/// speed change): each transition shifts by as much as its anchor clip edge moved. A transition +/// between two clips stays within them, whether its cut moved or one of the clips got shorter +/// ([`keep_within_clips`]). fn transitions_follow_cuts(before: &Sequence, after: &mut Sequence) { + let frame = after.settings.frame_rate.frame_duration(); for track in after.all_tracks_mut() { let Some(old) = before.track(track.id) else { continue }; - let shifts: Vec = track + let shifts: Vec<(Tick, Option<(Tick, Tick, Tick)>)> = track .transitions .iter() - .map(|tr| match (transition_anchor(old, tr), transition_anchor(track, tr)) { - (Some(a), Some(b)) => b - a, - _ => Tick::ZERO, + .map(|tr| { + let d = match (transition_anchor(old, tr), transition_anchor(track, tr)) { + (Some(a), Some(b)) => b - a, + _ => Tick::ZERO, + }; + (d, clips_span(track, tr)) }) .collect(); - for (tr, d) in track.transitions.iter_mut().zip(shifts) { + for (tr, (d, within)) in track.transitions.iter_mut().zip(shifts) { tr.start += d; + if let Some(span) = within { + keep_within_clips(tr, span, frame); + } } } } +/// The two clips of a transition between clips as (first clip's start, cut, second clip's end), +/// while they still overlap the cut's span. +fn clips_span(track: &Track, tr: &Transition) -> Option<(Tick, Tick, Tick)> { + match (tr.from.and_then(|c| track.item(c)), tr.to.and_then(|c| track.item(c))) { + (Some(f), Some(t)) if f.start < t.end() => Some((f.start, t.start, t.end())), + _ => None, + } +} + +/// Move a transition that reaches past its two clips (`span`, from [`clips_span`]) back inside +/// them, shortened when the clips are now shorter than it. Its stored alignment follows where that +/// leaves it on the cut, as `transitions::set_span` stores it (interchange): Custom Start has no +/// variant. +fn keep_within_clips(tr: &mut Transition, (lo, cut, hi): (Tick, Tick, Tick), frame: Tick) { + if tr.start >= lo && tr.end() <= hi { + return; + } + tr.duration = tr.duration.min(hi - lo); + tr.start = tr.start.clamp(lo, hi - tr.duration); + tr.align = match transitions::alignment(tr, cut, frame) { + transitions::Alignment::Start => TransitionAlign::StartAtCut, + transitions::Alignment::End => TransitionAlign::EndAtCut, + transitions::Alignment::Center | transitions::Alignment::Custom => TransitionAlign::CenterAtCut, + }; +} + /// Keep a clip's one-sided transitions (a fade in from nothing, a fade out to nothing) on its edges /// after a regular trim moved them, no longer than the clip (#374). Transitions between two clips /// are left alone: a regular trim cannot move their cut. @@ -234,6 +269,18 @@ fn fades_follow_edges(track: &mut Track, clip: ClipId) { } } +/// Keep each transition between `clip` and its neighbor within the two clips after a regular trim +/// or a non-ripple speed change made `clip` shorter ([`keep_within_clips`]). +fn transitions_stay_within_clips(track: &mut Track, clip: ClipId, frame: Tick) { + let spans: Vec> = + track.transitions.iter().map(|tr| clips_span(track, tr).filter(|_| tr.from == Some(clip) || tr.to == Some(clip))).collect(); + for (tr, span) in track.transitions.iter_mut().zip(spans) { + if let Some(span) = span { + keep_within_clips(tr, span, frame); + } + } +} + fn place(track: &mut Track, item: TrackItem) { let idx = track.items.partition_point(|i| i.start <= item.start); track.items.insert(idx, item); @@ -509,6 +556,12 @@ pub fn close_gap(seq: &mut Sequence, track: TrackId, t: Tick) -> Result<()> { /// Move items by (track, time) offsets with overwrite (default drag) or insert (Cmd-drag) semantics. /// `moves`: (item, destination track, new start). pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_mode: bool, ctx: &mut EditCtx) -> Result<()> { + let mut seen = std::collections::HashSet::new(); + for (clip, _, _) in moves { + if !seen.insert(*clip) { + return Err(EditError::Other(format!("clip {} is listed more than once in this move", clip.0))); + } + } let mut work = seq.clone(); let mut placed = Vec::new(); for (c, dest, start) in moves { @@ -765,8 +818,12 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: } } transitions_follow_cuts(seq, &mut work); - } else if let Ok(track) = track_mut(&mut work, tid) { - fades_follow_edges(track, clip); + } else { + let frame = work.settings.frame_rate.frame_duration(); + if let Ok(track) = track_mut(&mut work, tid) { + fades_follow_edges(track, clip); + transitions_stay_within_clips(track, clip, frame); + } } work.check().map_err(EditError::Other)?; *seq = work; @@ -895,6 +952,10 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: & let mut d = delta; // left out-point limits d = d.max(-(l.duration - ctx.min_duration)).min(r.duration - ctx.min_duration); + // a transition on this cut moves with it and stays within the two clips + for x in seq.all_tracks().flat_map(|t| &t.transitions).filter(|x| x.from == Some(left) && x.to == Some(right)) { + d = d.max((l.start - x.start).min(Tick::ZERO)).min((r.end() - x.end()).max(Tick::ZERO)); + } // a reversed clip consumes media from the opposite end, so its media limit is the other one if l.reverse { d = d.min(Tick((head(ctx, l).0 as f64 / l.speed.abs()).floor() as i64)); @@ -1130,7 +1191,12 @@ pub fn set_speed_group(seq: &mut Sequence, clips: &[ClipId], speed: f64, reverse it.speed = speed; it.reverse = reverse; it.duration = new_dur; - fades_follow_edges(work.track_mut(tid).ok_or(EditError::NoTrack(tid))?, *clip); + let frame = work.settings.frame_rate.frame_duration(); + let track = work.track_mut(tid).ok_or(EditError::NoTrack(tid))?; + fades_follow_edges(track, *clip); + if !ripple { + transitions_stay_within_clips(track, *clip, frame); + } origins.push((tid, old_end, delta)); } let main_from = origins.first().map(|o| o.1).unwrap_or_default(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 9b42c106a..f12412e83 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -77,6 +77,21 @@ impl Fx { } } +#[test] +fn move_rejects_repeated_clip_ids_without_mutation() { + for insert_mode in [false, true] { + let mut fx = Fx::new(); + let (v1, v2) = (fx.v(0), fx.v(1)); + let c = fx.put(v1, 0, 10, 0); + let before = fx.seq.clone(); + let mut n = fx.next; + let result = move_items(&mut fx.seq, &[(c, v1, f(20)), (c, v2, f(20))], insert_mode, &mut Fx::ctx(&mut n)); + assert!(result.is_err(), "moving one identity to two tracks must be rejected: {result:?}"); + assert_eq!(fx.seq, before); + assert_eq!(n, fx.next, "a rejected move consumes no ids"); + } +} + #[test] fn overwrite_splits_underlying() { let mut fx = Fx::new(); @@ -527,6 +542,69 @@ fn fades_follow_a_non_ripple_speed_change() { fx.seq.check().unwrap(); } +/// A regular trim or a non-ripple speed change that shortens one of the two clips of a transition +/// keeps the transition within them, and its stored alignment follows where it now sits on the cut. +#[test] +fn a_transition_stays_within_its_clips_after_a_regular_trim_or_speed_change() { + for (case, expected) in [("Out trim of the second clip", 7), ("In trim of the first clip", 9), ("speed change of the second clip", 7)] { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 10, 10); + let b = fx.put(v1, 10, 10, 10); + fx.seq.track_mut(v1).unwrap().transitions = vec![Transition { + id: TransitionId(1), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(8), + duration: f(4), + from: Some(a), + to: Some(b), + align: filmcraft_project::TransitionAlign::EndAtCut, + reverse: false, + }]; + let mut n = fx.next; + match case { + "Out trim of the second clip" => { + trim(&mut fx.seq, b, Edge::Out, TrimMode::Regular, -f(9), &mut Fx::ctx(&mut n)).unwrap(); + } + "In trim of the first clip" => { + trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, f(9), &mut Fx::ctx(&mut n)).unwrap(); + } + _ => set_speed(&mut fx.seq, b, 10.0, false, false, &mut Fx::ctx(&mut n)).unwrap(), + } + let tr = &fx.seq.track(v1).unwrap().transitions[0]; + assert_eq!((tr.start, tr.duration), (f(expected), f(4)), "{case}"); + assert_eq!(tr.align, filmcraft_project::TransitionAlign::CenterAtCut, "{case}"); + fx.seq.check().unwrap(); + } +} + +/// A regular trim that leaves the two clips of a transition shorter than it, together, shortens +/// the transition to the clips. +#[test] +fn a_regular_trim_shortens_a_transition_longer_than_its_clips() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 2, 10); + let b = fx.put(v1, 2, 10, 10); + fx.seq.track_mut(v1).unwrap().transitions = vec![Transition { + id: TransitionId(1), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(0), + duration: f(4), + from: Some(a), + to: Some(b), + align: filmcraft_project::TransitionAlign::StartAtCut, + reverse: false, + }]; + let mut n = fx.next; + trim(&mut fx.seq, b, Edge::Out, TrimMode::Regular, -f(9), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(fx.spans(v1), vec![(0, 2), (2, 1)]); + let tr = &fx.seq.track(v1).unwrap().transitions[0]; + assert_eq!((tr.start, tr.duration), (f(0), f(3)), "the transition covers both clips and no more"); + assert_eq!(tr.align, filmcraft_project::TransitionAlign::CenterAtCut); + fx.seq.check().unwrap(); +} + #[test] fn transitions_travel_with_moved_clips() { // V1: a [0,10) fading in, b [10,20), c [20,30); b→c crossfade, c fading out @@ -869,6 +947,102 @@ fn roll_reversed_clips_keeps_media_mapping() { assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50))); } +/// A rolling edit moves the transition on its cut with the cut, and stops where the transition +/// would leave one of its two clips. +#[test] +fn roll_keeps_the_transition_on_its_clips() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 10, 10); + let b = fx.put(v1, 10, 10, 10); + fx.seq.track_mut(v1).unwrap().transitions = vec![Transition { + id: TransitionId(1), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(8), + duration: f(4), + from: Some(a), + to: Some(b), + align: Default::default(), + reverse: false, + }]; + let mut n = fx.next; + // left: the transition reaches the sequence start after 8 frames, not the 9 the clips allow + assert_eq!(roll(&mut fx.seq, a, b, -f(9), &mut Fx::ctx(&mut n)).unwrap(), -f(8)); + assert_eq!(fx.spans(v1), vec![(0, 2), (2, 18)]); + let tr = &fx.seq.track(v1).unwrap().transitions[0]; + assert_eq!((tr.start, tr.duration), (f(0), f(4)), "the transition moved with the cut"); + // right: it reaches the end of the second clip after 16 frames, not 17 + assert_eq!(roll(&mut fx.seq, a, b, f(17), &mut Fx::ctx(&mut n)).unwrap(), f(16)); + assert_eq!(fx.spans(v1), vec![(0, 18), (18, 2)]); + let tr = &fx.seq.track(v1).unwrap().transitions[0]; + assert_eq!((tr.start, tr.duration), (f(16), f(4))); + fx.seq.check().unwrap(); +} + +/// A ripple trim or a slide that moves a cut close to the start of its first clip keeps the +/// transition on that cut within its two clips, and its stored alignment follows: an End at Cut +/// transition that now crosses the cut is stored as centered (Custom Start has no variant). +#[test] +fn edits_that_move_a_cut_keep_its_transition_on_its_clips() { + for slide_it in [false, true] { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 10, 10); + let b = fx.put(v1, 10, 10, 10); + fx.put(v1, 20, 10, 10); + fx.seq.track_mut(v1).unwrap().transitions = vec![Transition { + id: TransitionId(1), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(6), + duration: f(4), + from: Some(a), + to: Some(b), + align: TransitionAlign::EndAtCut, + reverse: false, + }]; + let mut n = fx.next; + if slide_it { + slide(&mut fx.seq, b, -f(9), &mut Fx::ctx(&mut n)).unwrap(); + } else { + trim(&mut fx.seq, a, Edge::Out, TrimMode::Ripple, -f(9), &mut Fx::ctx(&mut n)).unwrap(); + } + let t = fx.seq.track(v1).unwrap(); + let (cut, a_start) = (t.item(b).unwrap().start, t.item(a).unwrap().start); + assert_eq!(cut, f(1), "slide {slide_it}"); + let tr = &t.transitions[0]; + assert_eq!((tr.start, tr.duration), (a_start, f(4)), "slide {slide_it}: the transition starts with its first clip"); + assert_eq!(tr.align, TransitionAlign::CenterAtCut, "slide {slide_it}"); + fx.seq.check().unwrap(); + } +} + +/// A ripple trim that shortens the second clip from its end leaves the cut where it is, and the +/// transition on that cut still stays within its two clips. +#[test] +fn a_ripple_trim_of_the_second_clips_end_keeps_the_transition_within_it() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 10, 10); + let b = fx.put(v1, 10, 10, 10); + fx.seq.track_mut(v1).unwrap().transitions = vec![Transition { + id: TransitionId(1), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(8), + duration: f(4), + from: Some(a), + to: Some(b), + align: TransitionAlign::CenterAtCut, + reverse: false, + }]; + let mut n = fx.next; + trim(&mut fx.seq, b, Edge::Out, TrimMode::Ripple, -f(9), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(fx.spans(v1), vec![(0, 10), (10, 1)]); + let tr = &fx.seq.track(v1).unwrap().transitions[0]; + assert_eq!((tr.start, tr.duration), (f(7), f(4)), "the transition ends with the second clip"); + assert_eq!(tr.align, TransitionAlign::CenterAtCut); + fx.seq.check().unwrap(); +} + /// #374: moving an edge must not change which media sits under any timeline time the clip still /// covers (the timeline's live trim preview draws its waveform from this item), and must leave the /// item as the trim itself does. diff --git a/crates/edit/src/through.rs b/crates/edit/src/through.rs index 9b662abf9..263bbcce0 100644 --- a/crates/edit/src/through.rs +++ b/crates/edit/src/through.rs @@ -22,6 +22,18 @@ pub fn is_through_edit(a: &TrackItem, b: &TrackItem) -> bool { if a.end() != b.start || a.item != b.item || a.speed != b.speed || a.reverse != b.reverse || a.multicam != b.multicam { return false; } + if a.enabled != b.enabled + || a.gain_db != b.gain_db + || a.effects != b.effects + || a.scale_to_frame != b.scale_to_frame + || a.hold_filters != b.hold_filters + || a.time_interpolation != b.time_interpolation + || a.field_options != b.field_options + || a.audio_stream != b.audio_stream + || a.source_channels != b.source_channels + { + return false; + } match (a.frame_hold, b.frame_hold) { (None, None) => {} (Some(x), Some(y)) => return x == y, @@ -214,6 +226,20 @@ mod tests { assert_eq!(q.video_tracks[0].items, orig.video_tracks[0].items); } + #[test] + fn pieces_with_different_settings_are_not_through_edits() { + let tweaks: [fn(&mut TrackItem); 3] = [|t| t.enabled = false, |t| t.gain_db = -20.0, |t| t.scale_to_frame = true]; + for tweak in tweaks { + let mut q = seq_with(vec![item(10, 0, 100, 500)]); + let mut n = 1000; + razor(&mut q, &[], Tick(40), &mut ctx(&mut n)); + tweak(&mut q.video_tracks[0].items[1]); + assert!(through_edits(&q).is_empty()); + assert_eq!(join_through_edits(&mut q, &[]), 0); + assert_eq!(q.video_tracks[0].items.len(), 2); + } + } + #[test] fn reversed_and_speed_changed_pieces_join() { for (speed, reverse) in [(1.0, true), (1.5, false), (0.7, true)] { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 088163f10..d39450794 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -42,6 +42,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } craft-docpath = { workspace = true } web-time = "1" diff --git a/crates/engine/src/aaf_omf.rs b/crates/engine/src/aaf_omf.rs index 823b895dc..6aeafdcd2 100644 --- a/crates/engine/src/aaf_omf.rs +++ b/crates/engine/src/aaf_omf.rs @@ -130,6 +130,9 @@ fn dequantize(pcm: &[u8], channels: usize, bits: u16) -> Vec> { out } +const MAX_EXPORT_AUDIO_CHANNELS: usize = 64; +const MAX_EXPORT_AUDIO_SAMPLES: usize = 64 * 1024 * 1024; + /// Decode a media range at `sample_rate` (planar). fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result>> { let src = s @@ -138,7 +141,12 @@ fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result> = vec![Vec::with_capacity(frames); need.channels.max(1) as usize]; + let ch = (need.channels.max(1) as usize).min(MAX_EXPORT_AUDIO_CHANNELS); + let total_samples = ch.checked_mul(frames).ok_or_else(|| EngineError::Other("audio export buffer size overflow".into()))?; + if total_samples > MAX_EXPORT_AUDIO_SAMPLES { + return Err(EngineError::Other(format!("audio export buffer size {total_samples} samples exceeds maximum allowed ({MAX_EXPORT_AUDIO_SAMPLES})"))); + } + let mut out: Vec> = vec![Vec::with_capacity(frames); ch]; let chunk = sample_rate as usize * 10; let mut done = 0usize; while done < frames { diff --git a/crates/engine/src/aaf_omf_tests.rs b/crates/engine/src/aaf_omf_tests.rs index 83bdd05de..b80245507 100644 --- a/crates/engine/src/aaf_omf_tests.rs +++ b/crates/engine/src/aaf_omf_tests.rs @@ -87,6 +87,50 @@ fn aaf_embedded_trimmed_audio_round_trips_sample_exact() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn embedded_aaf_and_omf_import_leave_existing_media_files_intact() { + for (format, extension) in [(filmcraft_interchange::Format::Aaf, "aaf"), (filmcraft_interchange::Format::Omf, "omf")] { + let (mut s, _items, seq, dir) = setup(&format!("embedded-collision-{extension}")); + let path = dir.join(format!("cut.{extension}")); + let path_text = path.to_string_lossy().into_owned(); + let command = if format == filmcraft_interchange::Format::Aaf { "file.exportAaf" } else { "file.exportOmf" }; + s.execute(command, json!({"path": path_text, "audio": "embedded", "sequence": seq.0})).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let opts = filmcraft_interchange::ImportOptions { base_dir: Some(dir.to_string_lossy().into_owned()), name: Some("cut".into()), ..Default::default() }; + let (_, extracted, _) = if format == filmcraft_interchange::Format::Aaf { + filmcraft_interchange::aaf::import(&bytes, &opts).unwrap() + } else { + filmcraft_interchange::omf::import(&bytes, &opts).unwrap() + }; + let first = extracted.first().expect("embedded audio"); + let original = b"ORIGINAL USER DATA"; + std::fs::create_dir_all(std::path::Path::new(&first.path).parent().unwrap()).unwrap(); + std::fs::write(&first.path, original).unwrap(); + + let response = s.execute("file.import", json!({"paths": [path_text]})).unwrap(); + assert!(std::fs::read(&first.path).unwrap().as_slice() == original, "{extension}: existing file was overwritten"); + let imported = s.project.sequence(imported_seq(&response)).unwrap(); + let new_path = path_of(&s, imported.audio_tracks[0].items[0].item); + assert_ne!(new_path, first.path, "{extension}: imported audio still points at the existing file"); + assert!(new_path.ends_with(" (2).wav"), "{extension}: {new_path}"); + assert!(std::fs::read(&new_path).unwrap() == first.wav, "{extension}: imported audio was not extracted to the new path"); + #[cfg(unix)] + { + let protected = dir.join("protected.wav"); + std::fs::write(&protected, original).unwrap(); + std::fs::remove_file(&first.path).unwrap(); + std::os::unix::fs::symlink(&protected, &first.path).unwrap(); + let again = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert!(std::fs::read(&protected).unwrap().as_slice() == original, "{extension}: symlink target was overwritten"); + let imported_again = s.project.sequence(imported_seq(&again)).unwrap(); + let third_path = path_of(&s, imported_again.audio_tracks[0].items[0].item); + assert!(third_path.ends_with(" (3).wav"), "{extension}: {third_path}"); + assert!(std::fs::read(&third_path).unwrap() == first.wav, "{extension}: the third extraction is not the embedded audio"); + } + let _ = std::fs::remove_dir_all(dir); + } +} + #[test] fn aaf_separate_aiff_breakout_and_linked() { let (mut s, items, seq, dir) = setup("aaf-separate"); diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index 4ac6346a4..aad4adc20 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -35,6 +35,102 @@ fn journaled(s: &mut Session) -> u64 { s.persistence.as_ref().unwrap().status.journaled_revision } +fn discard_fixture(tag: &str) -> (Session, std::path::PathBuf) { + let d = temp_dir(tag); + let mut s = Session::default(); + start(&mut s, &d); + let candidates = ["first", "second", "third"] + .into_iter() + .map(|id| { + let dir = d.join(id); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("snapshot.fcproj"), b"retained snapshot").unwrap(); + autosave::RecoveryCandidate { id: id.into(), dir, meta: Default::default() } + }) + .collect(); + s.persistence.as_mut().unwrap().candidates = candidates; + (s, d) +} + +fn block_discard(dir: &std::path::Path) { + // An ordinary file cannot be removed as a directory, on Windows or Unix. + std::fs::remove_dir_all(dir).unwrap(); + std::fs::write(dir, b"occupied directory path").unwrap(); +} + +fn recovery_ids(s: &mut Session) -> Vec { + s.execute("file.recoveryList", json!({})).unwrap().as_array().unwrap().iter().map(|v| v["id"].as_str().unwrap().to_owned()).collect() +} + +#[test] +fn discard_failure_preserves_candidates_and_allows_retry() { + let (mut s, d) = discard_fixture("discard-first-error"); + block_discard(&d.join("first")); + assert!(s.execute("file.discardRecovery", json!({"all": true})).is_err()); + assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]); + assert!(s.is_enabled("file.discardRecovery")); + assert!(d.join("second/snapshot.fcproj").exists()); + assert!(d.join("third/snapshot.fcproj").exists()); + std::fs::remove_file(d.join("first")).unwrap(); + std::fs::create_dir(d.join("first")).unwrap(); + assert_eq!(s.execute("file.discardRecovery", json!({"all": true})).unwrap()["discarded"], 3); + assert!(recovery_ids(&mut s).is_empty()); + s.shutdown(); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn discard_failure_keeps_only_not_yet_deleted_candidates() { + let (mut s, d) = discard_fixture("discard-second-error"); + block_discard(&d.join("second")); + assert!(s.execute("file.discardRecovery", json!({"all": true})).is_err()); + assert_eq!(recovery_ids(&mut s), ["second", "third"]); + assert!(!d.join("first").exists()); + assert!(d.join("third/snapshot.fcproj").exists()); + s.shutdown(); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn discard_selected_failure_keeps_unselected_candidates() { + let (mut s, d) = discard_fixture("discard-selected-error"); + block_discard(&d.join("second")); + assert!(s.execute("file.discardRecovery", json!({"id": "second"})).is_err()); + assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]); + assert!(d.join("first/snapshot.fcproj").exists()); + assert!(d.join("third/snapshot.fcproj").exists()); + s.shutdown(); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn discard_default_failure_does_not_select_another_candidate() { + let (mut s, d) = discard_fixture("discard-default-error"); + block_discard(&d.join("first")); + let error = s.execute("file.discardRecovery", json!({})).unwrap_err().to_string(); + assert!(error.contains("first"), "{error}"); + assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]); + assert!(d.join("second/snapshot.fcproj").exists()); + s.shutdown(); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn discard_selection_and_unknown_id_preserve_other_candidates() { + let (mut s, d) = discard_fixture("discard-selection"); + assert!(s.execute("file.discardRecovery", json!({"id": "unknown"})).is_err()); + assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]); + assert_eq!(s.execute("file.discardRecovery", json!({"id": "second"})).unwrap()["discarded"], 1); + assert_eq!(recovery_ids(&mut s), ["first", "third"]); + assert!(!d.join("second").exists()); + assert_eq!(s.execute("file.discardRecovery", json!({})).unwrap()["discarded"], 1); + assert_eq!(recovery_ids(&mut s), ["third"]); + assert!(!d.join("first").exists()); + assert!(d.join("third/snapshot.fcproj").exists()); + s.shutdown(); + std::fs::remove_dir_all(d).unwrap(); +} + #[test] fn preferences_get_set_and_persist() { let d = temp_dir("prefs"); diff --git a/crates/engine/src/captions.rs b/crates/engine/src/captions.rs index d048a5677..3a13cbe9f 100644 --- a/crates/engine/src/captions.rs +++ b/crates/engine/src/captions.rs @@ -82,7 +82,11 @@ fn parse_color(v: &Value) -> Option<[u8; 4]> { _ => None, }; } - let h = v.as_str()?.trim_start_matches('#'); + let h = v.as_str()?; + let h = h.strip_prefix('#').unwrap_or(h); + if !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } let b = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); match h.len() { 6 => Some([b(0)?, b(2)?, b(4)?, 255]), @@ -160,6 +164,58 @@ pub fn delete(s: &mut Session, ids: &[ClipId], ripple: bool) -> Result { Ok(json!({"deleted": n})) } +/// Copy the selected captions to the caption clipboard (Edit ▸ Copy / Cut with captions selected). +/// Returns how many were copied. +pub fn copy(s: &mut Session) -> usize { + let Some(q) = s.active_sequence() else { return 0 }; + let sel = &s.state.caption_selection; + let picked: Vec<(usize, filmcraft_project::Caption)> = + q.caption_tracks.iter().enumerate().flat_map(|(ti, t)| t.captions.iter().filter(|c| sel.contains(&c.id)).map(move |c| (ti, c.clone()))).collect(); + let min = picked.iter().map(|(_, c)| c.start).min().unwrap_or_default(); + s.state.caption_clipboard = picked.into_iter().map(|(ti, c)| (ti, filmcraft_project::Caption { start: c.start - min, ..c })).collect(); + s.state.clipboard.clear(); + s.state.clipboard_markers.clear(); + s.state.caption_clipboard.len() +} + +/// Paste the caption clipboard at the playhead, each caption on the caption track it was copied +/// from (the last one when that track is gone), overwriting what is there. Paste Insert first opens +/// a gap the copied span long on those tracks. The pasted captions are selected and the playhead +/// moves to their end. +pub fn paste(s: &mut Session, insert: bool) -> Result { + let at = s.playhead(); + let clip = s.state.caption_clipboard.clone(); + let span = clip.iter().map(|(_, c)| c.end()).max().unwrap_or_default(); + let mut by_track: Vec<(TrackId, Vec)> = Vec::new(); + { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + for (ti, c) in clip { + let Some(t) = q.caption_tracks.get(ti).or(q.caption_tracks.last()) else { + return Err(bad("edit.paste", "the sequence has no caption track to paste into")); + }; + let c = filmcraft_project::Caption { start: c.start + at, ..c }; + match by_track.iter_mut().find(|(id, _)| *id == t.id) { + Some((_, v)) => v.push(c), + None => by_track.push((t.id, vec![c])), + } + } + } + let ids = s.edit_sequence(if insert { "Paste Insert Captions" } else { "Paste Captions" }, |q, ctx, st| { + let mut ids = Vec::new(); + for (tid, caps) in &by_track { + if insert && let Some(tr) = q.caption_track_mut(*tid).filter(|t| !t.locked) { + edit::captions::insert_gap(tr, at, span, ctx); + } + ids.extend(edit::captions::overwrite_captions(q, *tid, caps, ctx)?); + } + st.caption_selection = ids.clone(); + st.selection.clear(); + Ok(ids) + })?; + s.set_playhead(at + span); + Ok(json!({"captions": ids.iter().map(|c| c.0).collect::>()})) +} + fn caption_json(s: &Session, c: &filmcraft_project::Caption) -> Value { let q = s.active_sequence(); let (rate, df) = q.map(|q| (q.settings.frame_rate, q.settings.drop_frame)).unwrap_or_default(); @@ -463,6 +519,44 @@ pub fn commands() -> Vec { let applied = s.edit_sequence("Move Captions", |q, _, _| Ok(edit::captions::move_captions(q, &ids, d)?))?; Ok(json!({"applied": applied.0})) }), + spec("captions.duplicate", "Duplicate Captions", &[], None, r#"{"captions":[id]?,"delta":ticks|"deltaFrames":i64}"#, has_captions, |s, p| { + // Option-drag in the Timeline: copies `delta` later (or earlier) on their own + // tracks, overwriting what is there; the originals stay + let mut ids = caption_ids(s, p); + // each caption is copied once, however often it is named + let mut seen = std::collections::HashSet::new(); + ids.retain(|id| seen.insert(*id)); + if ids.is_empty() { + return Err(EngineError::Edit(edit::EditError::Nothing)); + } + let d = delta_p(s, p).ok_or_else(|| bad("captions.duplicate", "need `delta` or `deltaFrames`"))?; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let mut by_track: Vec<(TrackId, Vec)> = Vec::new(); + for id in &ids { + let (tid, c) = q.find_caption(*id).ok_or(EngineError::Edit(edit::EditError::NoItem(*id)))?; + match by_track.iter_mut().find(|(t, _)| *t == tid) { + Some((_, v)) => v.push(c.clone()), + None => by_track.push((tid, vec![c.clone()])), + } + } + // never before the start of the sequence + let earliest = by_track.iter().flat_map(|(_, v)| v.iter().map(|c| c.start)).min().unwrap_or_default(); + let d = d.max(-earliest); + if d == Tick::ZERO { + return Err(EngineError::Edit(edit::EditError::Nothing)); + } + let new = s.edit_sequence("Duplicate Captions", |q, ctx, st| { + let mut new = Vec::new(); + for (tid, caps) in &by_track { + let moved: Vec<_> = caps.iter().map(|c| filmcraft_project::Caption { start: c.start + d, ..c.clone() }).collect(); + new.extend(edit::captions::overwrite_captions(q, *tid, &moved, ctx)?); + } + st.caption_selection = new.clone(); + st.selection.clear(); + Ok(new) + })?; + Ok(json!({"captions": new.iter().map(|c| c.0).collect::>(), "delta": d.0})) + }), spec("captions.delete", "Delete Captions", &[], None, r#"{"captions":[id]?,"ripple":bool=false}"#, has_captions, |s, p| { let ids = caption_ids(s, p); delete(s, &ids, bool_p(p, "ripple").unwrap_or(false)) diff --git a/crates/engine/src/captions_tests.rs b/crates/engine/src/captions_tests.rs index feb65c175..a8b10c8db 100644 --- a/crates/engine/src/captions_tests.rs +++ b/crates/engine/src/captions_tests.rs @@ -158,3 +158,69 @@ fn caption_list_track_filter_never_falls_back_to_every_track() { assert!(s.execute("captions.list", json!({"track": sel})).is_err(), "{sel} names no caption track"); } } + +fn caption_spans(s: &Session) -> Vec<(f64, f64, String)> { + let secs = |t: Tick| (t.seconds() * 10.0).round() / 10.0; // the demo sequence snaps to 23.976 fps frames + s.active_sequence().unwrap().caption_tracks[0].captions.iter().map(|c| (secs(c.start), secs(c.end()), c.text.clone())).collect() +} + +#[test] +fn captions_duplicate_copies_and_overwrites() { + let mut s = demo(); + for (sec, text) in [(1.0, "one"), (3.0, "two")] { + s.execute("playhead.set", json!({"seconds": sec})).unwrap(); + s.execute("captions.add", json!({"text": text, "durationSeconds": 1.0})).unwrap(); + } + let one = s.active_sequence().unwrap().caption_tracks[0].captions[0].id; + // Option-drag: a copy 5 s later, the original stays; the copy is selected + let r = s.execute("captions.duplicate", json!({"captions": [one.0], "delta": 5 * TICKS_PER_SECOND})).unwrap(); + assert_eq!(caption_spans(&s), vec![(1.0, 2.0, "one".into()), (3.0, 4.0, "two".into()), (6.0, 7.0, "one".into())]); + assert_eq!(s.state.caption_selection.len(), 1); + assert_eq!(r["captions"][0].as_u64(), Some(s.state.caption_selection[0].0)); + // a copy dropped half over `two` overwrites that half + s.execute("captions.duplicate", json!({"captions": [one.0], "delta": TICKS_PER_SECOND * 3 / 2})).unwrap(); + assert_eq!(caption_spans(&s)[1..3], [(2.5, 3.5, "one".into()), (3.5, 4.0, "two".into())]); + // never before zero; zero delta is nothing + s.execute("captions.duplicate", json!({"captions": [one.0], "delta": -100 * TICKS_PER_SECOND})).unwrap(); + assert_eq!(caption_spans(&s)[0], (0.0, 1.0, "one".into())); + assert!(s.execute("captions.duplicate", json!({"captions": [one.0], "delta": 0})).is_err()); + assert!(s.execute("captions.duplicate", json!({"captions": [one.0]})).is_err(), "needs a delta"); + assert!(s.execute("captions.duplicate", json!({"captions": [], "delta": TICKS_PER_SECOND})).is_err(), "needs a caption"); + let before = s.active_sequence().unwrap().caption_tracks[0].captions.len(); + s.execute("captions.duplicate", json!({"captions": [one.0, one.0], "delta": 20 * TICKS_PER_SECOND})).unwrap(); + assert_eq!(s.active_sequence().unwrap().caption_tracks[0].captions.len(), before + 1, "a caption named twice is copied once"); + s.active_sequence().unwrap().check().unwrap(); +} + +#[test] +fn captions_copy_cut_and_paste() { + let mut s = demo(); + // Copy / Paste with only captions selected copies captions, pasted at the playhead + s.execute("playhead.set", json!({"seconds": 1.0})).unwrap(); + s.execute("captions.add", json!({"text": "hi", "durationSeconds": 1.0})).unwrap(); + assert!(s.state.selection.is_empty()); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["copied"], 1); + s.execute("playhead.set", json!({"seconds": 10.0})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(caption_spans(&s), vec![(1.0, 2.0, "hi".into()), (10.0, 11.0, "hi".into())]); + assert!((s.playhead().seconds() - 11.0).abs() < 0.05, "the playhead moves past the paste"); + // Cut takes it away and Paste puts it back + s.execute("edit.cut", json!({})).unwrap(); + assert_eq!(caption_spans(&s), vec![(1.0, 2.0, "hi".into())]); + s.execute("playhead.set", json!({"seconds": 4.0})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(caption_spans(&s)[1], (4.0, 5.0, "hi".into())); + // copying clips afterwards makes Paste place clips again, not captions + let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id; + s.execute("timeline.select", json!({"clips": [clip.0]})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.state.caption_clipboard.is_empty() && !s.state.clipboard.is_empty()); + // undo takes back a caption paste as one step + let mut s = demo(); + s.execute("captions.add", json!({"text": "x", "durationSeconds": 1.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("playhead.set", json!({"seconds": 5.0})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(caption_spans(&s).len(), 1); +} diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index 8a85b9eee..efc7fb26c 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -826,6 +826,7 @@ fn close_project(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("the project has unsaved changes: save it first, or pass {\"force\": true} to discard them".into())); } s.project = std::sync::Arc::new(Project::new("Untitled")); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = Session::default().state; @@ -929,8 +930,12 @@ fn consolidate_duplicates(s: &mut Session, _: &Value) -> Result { let mut groups: BTreeMap> = BTreeMap::new(); for it in s.project.items.values() { if let ItemKind::Media(m) = &it.kind { - // same file (or generator), interpretation and streams - let shape = (m.info.video.is_some(), m.info.has_audio(), m.info.duration); + let generator_rate = if matches!(&m.media, MediaRef::Generator(filmcraft_media::Generator::CountingLeader)) { + m.info.video.as_ref().map(|v| v.frame_rate) + } else { + None + }; + let shape = (m.info.video.is_some(), m.info.has_audio(), m.info.duration, generator_rate); let key = serde_json::to_string(&(&m.media, &m.interpret, m.offline, shape)).unwrap_or_default(); groups.entry(key).or_default().push(it.id); } @@ -1234,7 +1239,15 @@ fn parse_format(f: &str) -> Option { fn audio_channels(s: &mut Session, p: &Value) -> Result { let items: Vec = items_p(s, p).into_iter().filter(|i| s.project.item(*i).and_then(|it| it.as_media()).is_some_and(|m| m.info.has_audio())).collect(); - if items.is_empty() || p.get("channels").is_some() { + // `channels` is the only thing the timeline-clip branch below reads, so it alone decides + // which branch runs: no project item selected used to fall into it too (on the theory that + // there was nothing else to do), but a `channels`-less call with no item selected (e.g. a + // stray `format` with an empty project selection, #1845) then reset the selected clips' + // `source_channels` to `[]` via `unwrap_or_default()` instead of being rejected. + if items.is_empty() && p.get("channels").is_none() { + return Err(bad("clip.audioChannels", "select a clip with audio, or a project item to set its format")); + } + if p.get("channels").is_some() { // timeline audio clips: which source channels each plays let clips = match p.get("clips").and_then(Value::as_array) { Some(a) if a.iter().all(Value::is_u64) => a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect(), diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 262b1ec80..30b6103b5 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -228,6 +228,33 @@ fn remove_unused_and_consolidate_duplicates() { assert!(s.project.item(dup).is_some()); } +#[test] +fn consolidate_keeps_counting_leaders_with_distinct_source_rates() { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Leader rates"})).unwrap(); + s.execute("file.newSequence", json!({"name": "24 fps", "fps": 24, "video": 1, "audio": 1})).unwrap(); + let leader_24 = ItemId(s.execute("file.newCountingLeader", json!({"seconds": 3})).unwrap()["item"].as_u64().unwrap()); + s.execute("file.newSequence", json!({"name": "60 fps", "fps": 60, "video": 1, "audio": 1})).unwrap(); + let leader_60 = ItemId(s.execute("file.newCountingLeader", json!({"seconds": 3})).unwrap()["item"].as_u64().unwrap()); + assert_ne!(leader_24, leader_60); + s.execute("timeline.place", json!({"item": leader_60.0, "track": "V1", "audioTrack": "A1", "seconds": 0.0})).unwrap(); + let before = s.media.full_res_source(&s.project, leader_60, &*s.services).unwrap().audio(0, 2_010, 48_000).unwrap(); + assert!(before.channels.iter().all(|channel| channel[800..].iter().all(|sample| *sample == 0.0))); + + let result = s.execute("edit.consolidateDuplicates", json!({})).unwrap(); + assert_eq!(result["removed"], json!([]), "leaders with different source rates are not duplicates"); + assert!(s.project.item(leader_60).is_some()); + let sequence = s.active_sequence().unwrap(); + assert_eq!(sequence.audio_tracks[0].items[0].item, leader_60); + let after = s.media.full_res_source(&s.project, leader_60, &*s.services).unwrap().audio(0, 2_010, 48_000).unwrap(); + assert_eq!(after.channels, before.channels); + + s.execute("project.select", json!({"items": [leader_60.0]})).unwrap(); + let duplicate = ItemId(s.execute("edit.duplicate", json!({})).unwrap()["items"][0].as_u64().unwrap()); + let result = s.execute("edit.consolidateDuplicates", json!({})).unwrap(); + assert_eq!(result["removed"], json!([duplicate.0]), "equal-rate copies should still consolidate"); +} + #[test] fn new_sequence_from_clip_bin_from_selection_and_offline_file() { let mut s = demo(); @@ -379,6 +406,24 @@ fn modify_audio_channels_and_breakout_to_mono() { assert!(s.execute("clip.breakoutToMono", json!({})).is_err(), "no audio"); } +/// #1845: with no project item selected, a `channels`-less call (a stray `format` included) +/// used to fall into the timeline-clip branch anyway and reset `source_channels` to `[]` via +/// `unwrap_or_default()`, instead of being rejected. The map set by the previous `channels` call +/// must survive a later `format`-only call under the same (empty) project selection. +#[test] +fn audio_channels_rejects_a_channel_less_call_with_no_item_selected() { + let mut s = demo(); + s.state.project_selection.clear(); + let a1 = s.active_sequence().unwrap().audio_tracks[0].items[0].id; + s.state.selection = vec![a1]; + s.execute("clip.audioChannels", json!({"channels": [1]})).unwrap(); + assert_eq!(clip(&s, a1.0).source_channels, vec![1]); + assert!(s.execute("clip.audioChannels", json!({"format": null})).is_err(), "nothing to do: no item selected and no `channels`"); + assert_eq!(clip(&s, a1.0).source_channels, vec![1], "the existing map must survive a rejected call"); + assert!(s.execute("clip.audioChannels", json!({})).is_err(), "same with no params at all"); + assert_eq!(clip(&s, a1.0).source_channels, vec![1]); +} + #[test] fn modify_timecode_and_reset() { let mut s = demo(); @@ -1059,3 +1104,60 @@ fn linked_rate_stretch_keeps_one_speed_when_a_neighbour_blocks_the_sound() { assert!((pic.speed - sound.speed).abs() < 1e-12); assert!((r["speed"].as_f64().unwrap() - 60.0 / 70.0).abs() < 1e-9); } + +/// #2358: Toggle Animation on a parameter an older effect instance lacks (it was added to the +/// effect later) creates it from its default and animates it, instead of "no such param". +#[test] +fn toggle_animation_creates_a_parameter_missing_from_an_older_instance() { + let mut s = crate::Session::default(); + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + let seq = s.state.active_sequence.unwrap(); + let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id; + // an older project: Motion without its Rotation entry + { + let p = std::sync::Arc::make_mut(&mut s.project); + let q = p.sequence_mut(seq).unwrap(); + let (_, it) = q.find_item_mut(clip).unwrap(); + let m = it.effects.iter_mut().find(|e| e.effect == "motion").unwrap(); + m.params.remove("rotation"); + } + // `"mask": null` is no mask, as in effects.setParam + s.execute("effects.toggleAnimation", serde_json::json!({"clip": clip.0, "effect": "motion", "param": "rotation", "mask": null})).unwrap(); + let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone(); + let rot = it.effect("motion").unwrap().param("rotation").unwrap(); + assert!(rot.is_animated(), "the stopwatch is on"); +} + +/// Transform's Anchor Point and Position are centred in the clip's own picture (on apply and on reset), so Scale grows it +/// from its centre (they used to be centred in the sequence frame, off to the side of a smaller picture). +#[test] +fn applied_transform_centres_its_points_in_the_clip_picture() { + let mut s = demo(); + let r = s.execute("file.newColorMatte", json!({"width": 960, "height": 1080, "color": "#ff0000"})).unwrap(); + let matte = r["item"].as_u64().unwrap(); + let r = s.execute("timeline.place", json!({"item": matte, "track": "V3", "seconds": 0.0})).unwrap(); + let c = r["clips"][0].as_u64().unwrap(); + s.execute("effects.apply", json!({"clips": [c], "effect": "transform"})).unwrap(); + let centre = filmcraft_geom::Vec2::new(480.0, 540.0); + let point = |s: &Session, k: &str| clip(s, c).effect("transform").unwrap().param(k).unwrap().value.as_vec2().unwrap(); + assert_eq!(point(&s, "anchor"), centre); + assert_eq!(point(&s, "position"), centre); + // Reset Effect and Reset Parameter centre them the same way + for k in ["anchor", "position"] { + s.execute("effects.setParam", json!({"clip": c, "effect": "transform", "param": k, "value": [10.0, 20.0]})).unwrap(); + } + let idx = clip(&s, c).effects.iter().position(|e| e.effect == "transform").unwrap(); + s.execute("effects.reset", json!({"clip": c, "index": idx})).unwrap(); + assert_eq!(point(&s, "anchor"), centre); + assert_eq!(point(&s, "position"), centre); + s.execute("effects.setParam", json!({"clip": c, "effect": "transform", "param": "anchor", "value": [10.0, 20.0]})).unwrap(); + s.execute("effects.resetParam", json!({"clip": c, "effect": "transform", "param": "anchor"})).unwrap(); + assert_eq!(point(&s, "anchor"), centre); + // intrinsic effects stay centred in the sequence frame when applied to a smaller clip + let q = s.active_sequence().unwrap(); + let frame_centre = filmcraft_geom::Vec2::new(f64::from(q.settings.width) / 2.0, f64::from(q.settings.height) / 2.0); + s.execute("effects.apply", json!({"clips": [c], "effect": "motion"})).unwrap(); + for m in clip(&s, c).effects.iter().filter(|e| e.effect == "motion") { + assert_eq!(m.param("position").unwrap().value.as_vec2().unwrap(), frame_centre); + } +} diff --git a/crates/engine/src/color.rs b/crates/engine/src/color.rs index 71261bca5..d25270ad5 100644 --- a/crates/engine/src/color.rs +++ b/crates/engine/src/color.rs @@ -85,6 +85,9 @@ pub(crate) fn commands() -> Vec { fn import_lut(s: &mut Session, path: &str, name: Option<&str>) -> Result<(String, Value)> { let text = std::fs::read_to_string(path).map_err(|e| bad("lut.import", format!("{path}: {e}")))?; + if text.len() > filmcraft_project::MAX_LUT_TEXT_BYTES { + return Err(bad("lut.import", format!("{path}: LUT source exceeds the {} byte limit", filmcraft_project::MAX_LUT_TEXT_BYTES))); + } let fmt = LutFormat::from_path(path).ok_or_else(|| bad("lut.import", "expected a .cube or .3dl file"))?; let lut = Lut::parse(&text, Some(fmt)).map_err(|e| bad("lut.import", format!("{path}: {e}")))?; let stem = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "LUT".into()); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index f7737f571..5784fa823 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -79,6 +79,11 @@ pub(crate) fn has_selection(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; if s.state.selection.is_empty() { Err("no clips selected".into()) } else { Ok(()) } } +/// Clips or captions selected (Copy / Cut work on either). +fn has_clips_or_captions(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + if s.state.selection.is_empty() && s.state.caption_selection.is_empty() { Err("no clips or captions selected".into()) } else { Ok(()) } +} /// Clips or captions selected (Clear / Ripple Delete work on either). fn has_any_selection(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; @@ -107,6 +112,9 @@ fn can_undo(s: &Session) -> std::result::Result<(), String> { fn can_redo(s: &Session) -> std::result::Result<(), String> { if s.history.can_redo() { Ok(()) } else { Err("nothing to redo".into()) } } +fn has_history(s: &Session) -> std::result::Result<(), String> { + if s.history.can_undo() || s.history.can_redo() { Ok(()) } else { Err("the history is empty".into()) } +} fn has_in_out(s: &Session) -> std::result::Result<(), String> { let seq = s.active_sequence().ok_or("no sequence is open")?; if seq.mark_in.is_some() || seq.mark_out.is_some() { Ok(()) } else { Err("mark an In or Out point first".into()) } @@ -120,7 +128,7 @@ pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), Stri } fn has_clipboard(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; - if s.state.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) } + if s.state.clipboard.is_empty() && s.state.caption_clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) } } /// The ids a caller named under `many` (a list) or `one` (a single id), when the command documents @@ -344,10 +352,10 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option { /// as `copied`. fn move_clips(s: &mut Session, p: &Value) -> Result { const CMD: &str = "timeline.move"; - let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new(); - let mut new_moves = Vec::new(); + let mut listed: Vec<(ClipId, TrackId, Tick, bool)> = Vec::new(); for m in p.get("moves").and_then(Value::as_array).into_iter().flatten() { - let track = if str_p(m, "track").is_some_and(|t| t.eq_ignore_ascii_case("new")) { + let new_track = str_p(m, "track").is_some_and(|t| t.eq_ignore_ascii_case("new")); + let track = if new_track { let clip = clip_p(m, "clip").ok_or_else(|| bad(CMD, "new track needs a clip"))?; let q = s.active_sequence().ok_or(EngineError::NoSequence)?; let (source, _) = q.find_item(clip).ok_or(edit::EditError::NoItem(clip))?; @@ -357,13 +365,12 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { if m.get("time").and_then(Value::as_i64).is_none() { return Err(bad(CMD, "new track needs a time")); } - new_moves.push((clip, source)); Some(source) } else { track_p(s, m, "track", CMD)? }; if let (Some(clip), Some(track), Some(time)) = (clip_p(m, "clip"), track, m.get("time").and_then(Value::as_i64)) { - listed.push((clip, track, Tick(time))); + listed.push((clip, track, Tick(time), new_track)); } } if listed.is_empty() { @@ -371,32 +378,32 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { } let follow = bool_p(p, "linked").unwrap_or(s.state.linked_selection); let q = s.active_sequence().ok_or(EngineError::NoSequence)?; - // (clip, destination track, new start, where a partner starts now). `time` is any i64: the - // sums are done in i128, where a few i64 values cannot overflow, and checked on the way back. - let mut plan: Vec<(ClipId, TrackId, i128, Option)> = Vec::new(); - for &(clip, track, time) in &listed { - plan.push((clip, track, i128::from(time.0), None)); + // (clip, destination track, new start, where a partner starts now, new track requested). + // `time` is any i64: sums use i128 and are checked on the way back. + let mut plan: Vec<(ClipId, TrackId, i128, Option, bool)> = Vec::new(); + for &(clip, track, time, new_track) in &listed { + plan.push((clip, track, i128::from(time.0), None, new_track)); let Some((_, it)) = q.find_item(clip) else { continue }; // `edit::move_items` reports it let Some(link) = it.link.filter(|_| follow) else { continue }; let offset = i128::from(time.0) - i128::from(it.start.0); for t in q.all_tracks().filter(|t| !t.locked) { for partner in t.items.iter().filter(|i| i.link == Some(link)) { if !listed.iter().any(|m| m.0 == partner.id) && !plan.iter().any(|m| m.0 == partner.id) { - plan.push((partner.id, t.id, i128::from(partner.start.0) + offset, Some(i128::from(partner.start.0)))); + plan.push((partner.id, t.id, i128::from(partner.start.0) + offset, Some(i128::from(partner.start.0)), false)); } } } } let late = plan.iter().map(|m| -m.2).max().unwrap_or(0).max(0); - let mut moves: Vec<(ClipId, TrackId, Tick)> = Vec::with_capacity(plan.len()); - for (clip, track, start, was) in plan { + let mut moves: Vec<(ClipId, TrackId, Tick, bool)> = Vec::with_capacity(plan.len()); + for (clip, track, start, was, new_track) in plan { // a partner that ends up where it is does not move (its listed clip only changed track): // re-placing it would drop the transitions at its edges if was == Some(start + late) { continue; } let start = i64::try_from(start + late).ok().map(Tick).filter(|t| *t <= Tick::MAX).ok_or_else(|| bad(CMD, "`time` is out of range"))?; - moves.push((clip, track, start)); + moves.push((clip, track, start, new_track)); } let ins = bool_p(p, "insert").unwrap_or(false); let copy = bool_p(p, "copy").unwrap_or(false); @@ -409,7 +416,7 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { // Allocate destinations in the same transaction as the move: an invalid edit leaves // neither empty tracks nor history entries. Each source track keeps its clips together. let prepare = |q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>| -> Result> { - let sources: Vec<_> = q.all_tracks().filter(|t| new_moves.iter().any(|m| m.1 == t.id)).map(|t| (t.id, t.kind, t.channels)).collect(); + let sources: Vec<_> = q.all_tracks().filter(|t| moves.iter().any(|m| m.3 && m.1 == t.id)).map(|t| (t.id, t.kind, t.channels)).collect(); for kind in [TrackKind::Video, TrackKind::Audio] { let added = sources.iter().filter(|s| s.1 == kind).count(); if added > 0 && q.tracks(kind).len().saturating_add(added) > crate::sequence_tools::MAX_TRACKS { @@ -428,8 +435,9 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { } Ok(moves .iter() - .map(|&(clip, track, time)| { - let dest = new_moves.iter().find(|m| m.0 == clip).and_then(|m| destinations.iter().find(|d| d.0 == m.1)).map_or(track, |d| d.1); + .map(|&(clip, track, time, new_track)| { + // A copy can list the same source again with a different destination. + let dest = destinations.iter().find(|d| new_track && d.0 == track).map_or(track, |d| d.1); (clip, dest, time) }) .collect()) @@ -575,12 +583,17 @@ pub fn linked_roll_pairs(seq: &filmcraft_project::Sequence, left: ClipId, right: pairs } -/// Expand a clip selection with linked partners (when linked selection is on). +/// Keep each selected identity once, then expand linked partners when linked selection is on. pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { - let Some(seq) = s.active_sequence() else { return clips.to_vec() }; - let mut out = clips.to_vec(); + let mut out = Vec::new(); + for c in clips { + if !out.contains(c) { + out.push(*c); + } + } + let Some(seq) = s.active_sequence() else { return out }; if s.state.linked_selection { - let links: Vec = clips.iter().filter_map(|c| seq.find_item(*c).and_then(|(_, i)| i.link)).collect(); + let links: Vec = out.iter().filter_map(|c| seq.find_item(*c).and_then(|(_, i)| i.link)).collect(); for t in seq.all_tracks() { for i in &t.items { if i.link.is_some_and(|l| links.contains(&l)) && !out.contains(&i.id) { @@ -619,12 +632,59 @@ fn clamp_trim_linked(q: &filmcraft_project::Sequence, clips: &[ClipId], edge: Ed /// trim drag shows this, so the edge stops at the end of the media or the neighbouring clip /// instead of following the pointer past it (#653). pub fn trim_delta(s: &Session, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick) -> Result { + trim_delta_linked(s, clip, edge, mode, delta, true) +} + +/// [`trim_delta`] for a trim that, with `linked` false (Alt held), leaves the clip's linked +/// partners alone (#3300). +pub fn trim_delta_linked(s: &Session, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, linked: bool) -> Result { let q = s.active_sequence().ok_or(EngineError::NoSequence)?; let durations = |id: ItemId| crate::media_duration(&s.project, &s.media, id); let starts = |id: ItemId| crate::media_start(&s.project, id); let mut next = s.project.next_id; let ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: q.settings.frame_rate.frame_duration() }; - clamp_trim_linked(q, &with_links(s, &[clip]), edge, mode, delta, &ctx) + let clips = if linked { with_links(s, &[clip]) } else { vec![clip] }; + clamp_trim_linked(q, &clips, edge, mode, delta, &ctx) +} + +/// `clip` and, unless `p` has `"linked": false` (Alt held), its linked partners. +fn with_links_p(s: &Session, p: &Value, clip: ClipId) -> Vec { + if bool_p(p, "linked").unwrap_or(true) { with_links(s, &[clip]) } else { vec![clip] } +} + +/// The delta a roll of every cut in `pairs` can apply together: `delta` clamped by each pair's +/// media and the frame left in each clip, each tried on a copy of `q`. 0 when a pair would roll +/// by another amount (a clip whose Out point is past the end of its media rolls the other way). +fn clamp_roll_linked(q: &filmcraft_project::Sequence, pairs: &[(ClipId, ClipId)], delta: Tick, ctx: &mut edit::EditCtx) -> Result { + let mut d = delta; + for &(pl, pr) in pairs { + let mut probe = q.clone(); + let x = edit::roll(&mut probe, pl, pr, d, ctx)?; + if x.abs() < d.abs() { + d = x; + } + } + if d != Tick::ZERO { + for &(pl, pr) in pairs { + let mut probe = q.clone(); + if edit::roll(&mut probe, pl, pr, d, ctx)? != d { + return Ok(Tick::ZERO); + } + } + } + Ok(d) +} + +/// The delta `timeline.roll` would apply for `delta`, without editing anything: the Timeline's +/// Rolling Edit drag shows this, so the cut stops where the roll on release stops instead of +/// following the pointer past the media. +pub fn roll_delta(s: &Session, left: ClipId, right: ClipId, delta: Tick) -> Result { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let durations = |id: ItemId| crate::media_duration(&s.project, &s.media, id); + let starts = |id: ItemId| crate::media_start(&s.project, id); + let mut next = s.project.next_id; + let mut ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: q.settings.frame_rate.frame_duration() }; + clamp_roll_linked(q, &linked_roll_pairs(q, left, right, s.state.linked_selection), delta, &mut ctx) } /// Sequence settings matching a media clip (New Sequence From Clip, New Sequence from an item): @@ -1134,12 +1194,14 @@ fn marker_list_mut(s: &mut Session) -> Result<(ItemId, Tick)> { Ok((seq, s.playhead())) } +/// Builds the command registry shared by the UI, CLI and control interfaces. fn build() -> Vec { let mut v = vec![ // ================= File ================= cmd!("file.newProject", "Project…", ["File", "New"], Some("Cmd+Alt+N"), r#"{"name":str}"#, always, |s, p| { let name = str_p(p, "name").unwrap_or("Untitled").to_string(); s.project = std::sync::Arc::new(filmcraft_project::Project::new(&name)); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = crate::Session::default().state; @@ -1149,11 +1211,13 @@ fn build() -> Vec { s.revision += 1; s.saved_revision = s.revision; s.events.push(crate::Event::ProjectChanged { revision: s.revision }); - Ok(Value::Null) + // the project as `project_inspect` names it (#1545: an agent needs no extra round trip) + Ok(json!({"name": name, "revision": s.revision})) }), cmd!("file.openDemoProject", "Demo Project", ["File", "New"], None, "{}", always, |s, _| { let (p, seq) = crate::demo::demo_project(&s.media); s.project = std::sync::Arc::new(p); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = crate::Session::default().state; @@ -1521,18 +1585,27 @@ fn build() -> Vec { let all = bool_p(p, "all").unwrap_or(false); let id = str_p(p, "id").map(str::to_string); let Some(per) = s.persistence.as_mut() else { return Ok(json!({"discarded": 0})) }; - let mut n = 0; + let mut n = 0usize; let mut keep = Vec::new(); + let mut failure = None; for (i, c) in std::mem::take(&mut per.candidates).into_iter().enumerate() { let hit = all || id.as_deref().map_or(i == 0, |want| want == c.id); - if hit { - crate::autosave::discard_candidate(&c).map_err(|e| EngineError::Other(e.to_string()))?; - n += 1; + if hit && failure.is_none() { + match crate::autosave::discard_candidate(&c) { + Ok(()) => n = n.saturating_add(1), + Err(e) => { + failure = Some(EngineError::Other(format!("could not discard recovery session {}: {e}", c.id))); + keep.push(c); + } + } } else { keep.push(c); } } per.candidates = keep; + if let Some(e) = failure { + return Err(e); + } if n == 0 { return Err(bad("file.discardRecovery", "no such recovery session")); } @@ -1615,7 +1688,16 @@ fn build() -> Vec { // ================= Edit ================= cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| Ok(json!({"undone": s.undo()}))), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!({"redone": s.redo()}))), - cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, |s, _| { + // the History panel menu; neither one changes the project or its unsaved state + cmd!("history.deleteRedoable", "Delete Redoable Actions", [], None, "{}", can_redo, |s, _| Ok(json!({"deleted": s.history.delete_redoable()}))), + cmd!("history.clear", "Clear History", [], None, "{}", has_history, |s, _| Ok(json!({"deleted": s.history.clear()}))), + cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_clips_or_captions, |s, _| { + if s.state.selection.is_empty() { + crate::captions::copy(s); + let caps = s.state.caption_selection.clone(); + crate::captions::delete(s, &caps, false)?; + return Ok(Value::Null); + } copy_selection(s); let sel = with_links(s, &s.state.selection.clone()); s.edit_sequence("Cut", |q, _, st| { @@ -1625,7 +1707,10 @@ fn build() -> Vec { })?; Ok(Value::Null) }), - cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, |s, _| { + cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_clips_or_captions, |s, _| { + if s.state.selection.is_empty() { + return Ok(json!({"copied": crate::captions::copy(s)})); + } copy_selection(s); Ok(json!({"copied": s.state.clipboard.len()})) }), @@ -1850,7 +1935,8 @@ fn build() -> Vec { // Each clip flips on its own, as in Premiere (#484): with one enabled and one disabled clip // selected, Enable swaps them rather than first making both the same. cmd!("clip.enable", "Enable", ["Clip"], Some("Shift+E"), r#"{"clips":[id]?}"#, has_selection, |s, p| { - let mut sel = with_links(s, &clips_p(s, p)); + // An Alt selection is already exact (#3031); explicit targets keep the linked behavior. + let mut sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() }; // a clip named twice must still flip once sel.sort(); sel.dedup(); @@ -1866,11 +1952,24 @@ fn build() -> Vec { }), cmd!("clip.link", "Link", ["Clip"], Some("Cmd+L"), r#"{"clips":[id]?}"#, has_selection, |s, p| { let sel = clips_p(s, p); - let linked = sel.iter().all(|c| s.active_sequence().and_then(|q| q.find_item(*c)).is_some_and(|(_, i)| i.link.is_some())); + // Unlink when any selected clip is linked, as the Timeline's context menu says (#1103): + // a title or a music clip among the selection must not turn Unlink into a re-link + let linked = sel.iter().any(|c| s.active_sequence().and_then(|q| q.find_item(*c)).is_some_and(|(_, i)| i.link.is_some())); s.edit_sequence(if linked { "Unlink" } else { "Link" }, |q, ctx, _| { if linked { + let mut links = Vec::new(); for c in &sel { if let Some((_, i)) = q.find_item_mut(*c) { + links.extend(i.link.take()); + } + } + // a partner left alone in its link (one half unlinked on its own) is unlinked + // too: a one-member link would read as "linked" with nothing to follow + for l in links { + let members: Vec = q.all_tracks().flat_map(|t| t.items.iter()).filter(|i| i.link == Some(l)).map(|i| i.id).collect(); + if let [lonely] = members[..] + && let Some((_, i)) = q.find_item_mut(lonely) + { i.link = None; } } @@ -2080,13 +2179,19 @@ fn build() -> Vec { cmd!("sequence.lift", "Lift", ["Sequence"], Some(";"), "{}", has_in_out, |s, _| { let r = in_out_range(s)?; let tg = s.targeting().targeted; - s.edit_sequence("Lift", |q, ctx, _| Ok(edit::lift(q, &tg, r, ctx)))?; - Ok(Value::Null) + let cut = s.edit_sequence("Lift", |q, ctx, st| { + let cut = cut_range(q, &tg, r, ctx, st.copy_paste_sequence_markers); + edit::lift(q, &tg, r, ctx); + Ok(cut) + })?; + let copied = store_cut(s, cut); + Ok(json!({"copied": copied})) }), cmd!("sequence.extract", "Extract", ["Sequence"], Some("'"), "{}", has_in_out, |s, _| { let r = in_out_range(s)?; let tg = s.targeting().targeted; - s.edit_sequence("Extract", |q, ctx, st| { + let cut = s.edit_sequence("Extract", |q, ctx, st| { + let cut = cut_range(q, &tg, r, ctx, st.copy_paste_sequence_markers); edit::extract(q, &tg, r, ctx); if st.ripple_sequence_markers { crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration); @@ -2094,10 +2199,11 @@ fn build() -> Vec { q.mark_in = None; q.mark_out = None; q.split = Default::default(); - Ok(()) + Ok(cut) })?; + let copied = store_cut(s, cut); s.set_playhead(r.start); - Ok(Value::Null) + Ok(json!({"copied": copied})) }), cmd!( "sequence.applyVideoTransition", @@ -2137,12 +2243,14 @@ fn build() -> Vec { remove_transitions(s, &ids, "Clear Transition") }), cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id?,"time":ticks?}"#, has_seq, |s, p| { - // Shortcuts and menus have no parameters. Resolve that form from the selected gap - // (#1135); explicit requests keep their existing track/time validation. + // Shortcuts and menus have no parameters. That form closes the selected gap (#1135), + // or with none selected every gap between In and Out, as Premiere's Sequence ▸ Close + // Gap does (#1986); explicit requests keep their existing track/time validation. if p.as_object().is_some_and(|p| p.is_empty()) { - let gap = s.state.gap_selection.ok_or_else(|| bad("sequence.closeGap", "select a gap or provide `track`"))?; - ripple_delete_gap(s, gap)?; - return Ok(Value::Null); + return match s.state.gap_selection { + Some(gap) => ripple_delete_gap(s, gap).map(|_| Value::Null), + None => close_sequence_gaps(s), + }; } let tr = track_p(s, p, "track", "sequence.closeGap")?.ok_or_else(|| bad("sequence.closeGap", "need `track`"))?; let t = time_p(s, p, "").unwrap_or(s.playhead()); @@ -2266,6 +2374,55 @@ fn build() -> Vec { crate::previews::delete(s, true) }), query!("sequence.renderBar", "Render Bar", "{}", |s, _| crate::previews::bar_json(s)), + // ================= Work area bar ================= + cmd!("sequence.workAreaBar", "Work Area Bar", [], None, r#"{"show":bool?}"#, has_seq, |s, p| { + let shown = s.active_sequence().ok_or(EngineError::NoSequence)?.work_area.is_some(); + let show = bool_p(p, "show").unwrap_or(!shown); + if show == shown { + return Ok(json!({"show": show})); + } + let fd = s.sequence_rate().frame_duration(); + s.edit_sequence("Work Area Bar", |q, _, _| { + // shown again, the bar spans the whole sequence + q.work_area = show.then(|| TimeRange::from_bounds(Tick::ZERO, q.duration().max(fd))); + Ok(()) + })?; + Ok(json!({"show": show})) + }), + cmd!( + "sequence.setWorkAreaIn", + "Set Work Area Bar In Point", + [], + None, + r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?}"#, + has_seq, + |s, p| set_work_area_point(s, p, true) + ), + cmd!( + "sequence.setWorkAreaOut", + "Set Work Area Bar Out Point", + [], + None, + r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?}"#, + has_seq, + |s, p| set_work_area_point(s, p, false) + ), + cmd!("sequence.setWorkArea", "Set Work Area", [], None, r#"{"start":ticks,"end":ticks}"#, has_seq, |s, p| { + const CMD: &str = "sequence.setWorkArea"; + let tick = |key: &str| p.get(key).and_then(Value::as_i64).map(Tick).ok_or_else(|| bad(CMD, format!("need `{key}` (ticks)"))); + let (start, end) = (tick("start")?, tick("end")?); + if start < Tick::ZERO { + return Err(bad(CMD, "`start` must not be negative")); + } + if end <= start { + return Err(bad(CMD, "`end` must be after `start`")); + } + s.edit_sequence("Set Work Area", |q, _, _| { + q.work_area = Some(TimeRange::from_bounds(start, end)); + Ok(()) + })?; + Ok(json!({"start": start.0, "end": end.0})) + }), cmd!("sequence.matchFrame", "Match Frame", ["Sequence"], Some("F"), "{}", has_seq, |s, _| { let t = s.playhead(); let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; @@ -2636,7 +2793,7 @@ fn build() -> Vec { "Trim Edit", [], None, - r#"{"clip":id,"edge":"in|out","mode":"regular|ripple","delta":ticks|"deltaFrames":i64}"#, + r#"{"clip":id,"edge":"in|out","mode":"regular|ripple","delta":ticks|"deltaFrames":i64,"linked":bool?}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.trim", "need `clip`"))?; @@ -2648,7 +2805,7 @@ fn build() -> Vec { .map(Tick) .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); - let clips = with_links(s, &[c]); + let clips = with_links_p(s, p, c); let applied = s.edit_sequence(if mode == TrimMode::Ripple { "Ripple Trim" } else { "Trim" }, |q, ctx, st| { if q.find_item(c).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| tr.locked)) { return Err(edit::EditError::Locked.into()); @@ -2771,19 +2928,12 @@ fn build() -> Vec { .map(Tick) .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); - // `followLinks: false` rolls only this pair: Trim mode already lists linked partners as edit points of their own + // `followLinks: false` rolls only this pair; the default clamps linked cuts together. let follow = s.state.linked_selection && p.get("followLinks").and_then(Value::as_bool).unwrap_or(true); let (x, n, linked) = s.edit_sequence("Rolling Edit", |q, ctx, _| { let pairs = linked_roll_pairs(q, l, r, follow); let linked = pairs.len() > 1; - let mut d = d; - for &(pl, pr) in &pairs { - let mut probe = q.clone(); - let x = edit::roll(&mut probe, pl, pr, d, ctx)?; - if x.abs() < d.abs() { - d = x; - } - } + let d = clamp_roll_linked(q, &pairs, d, ctx)?; if d != Tick::ZERO { for &(pl, pr) in &pairs { edit::roll(q, pl, pr, d, ctx)?; @@ -2829,7 +2979,7 @@ fn build() -> Vec { })?; Ok(Value::Null) }), - cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| { + cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks,"linked":bool?}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.rateStretch", "need `clip`"))?; let edge = if str_p(p, "edge") == Some("in") { Edge::In } else { Edge::Out }; let d = p @@ -2838,7 +2988,7 @@ fn build() -> Vec { .map(Tick) .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); - let clips = with_links(s, &[c]); + let clips = with_links_p(s, p, c); let sp = s.edit_sequence("Rate Stretch", |q, ctx, _| Ok(edit::rate_stretch_items(q, &clips, edge, d, ctx)?))?; Ok(json!({"speed": sp})) }), @@ -2860,10 +3010,17 @@ fn build() -> Vec { "Track Settings", [], None, - r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?}"#, + r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?,"label":"Violet|Iris|…"|""|null?}"#, has_seq, |s, p| { let tr = track_p(s, p, "track", "timeline.setTrack")?.ok_or_else(|| bad("timeline.setTrack", "need `track`"))?; + // colour label: a label name, or "" / "None" / null for no colour + let label = match p.get("label") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(n)) if n.is_empty() || n.eq_ignore_ascii_case("none") => Some(None), + Some(v) => Some(Some(v.as_str().and_then(Label::from_name).ok_or_else(|| bad("timeline.setTrack", format!("unknown label {v}")))?)), + }; let p = p.clone(); s.edit_sequence("Track Settings", |q, _, _| { let t = q.track_mut(tr).ok_or(filmcraft_edit::EditError::NoTrack(tr))?; @@ -2891,6 +3048,9 @@ fn build() -> Vec { if let Some(v) = f64_p(&p, "pan") { t.pan = v.clamp(-100.0, 100.0); } + if let Some(l) = label { + t.label = l; + } Ok(()) })?; Ok(Value::Null) @@ -2945,25 +3105,37 @@ fn build() -> Vec { } let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); let label = format!("Apply {}", def.name); - s.edit_sequence(&label, |q, _, _| { - let mut n = 0; + // standard effects run on the clip's source picture, so "auto" points (the centre) are + // measured in it, not in the sequence frame + let source_sizes: std::collections::BTreeMap = s + .active_sequence() + .map(|q| clips.iter().filter_map(|c| q.find_item(*c).and_then(|(_, i)| s.project.source_size(i.item)).map(|sz| (*c, sz))).collect()) + .unwrap_or_default(); + // Answer `{"applied":[{"clip","index","effect"}]}`: where each instance landed, so an + // agent can address it (`effects.setParam`, `effects.remove`) without a `sequence_inspect` + // round trip, as `timeline.place` answers with its clips (#1545). + let applied = s.edit_sequence(&label, |q, _, _| { + let mut applied = Vec::new(); for t in q.all_tracks_mut() { if (t.kind == TrackKind::Audio) != is_audio { continue; } for i in t.items.iter_mut().filter(|i| clips.contains(&i.id)) { let mut inst = def.instance(); - resolve_auto_points(&mut inst, (fw, fh), (fw, fh)); + let size = source_sizes.get(&i.id).copied().unwrap_or((fw, fh)); + // intrinsic effects (Motion) are laid out in the sequence frame, as `reset_params` does + let frame = if def.intrinsic { (fw, fh) } else { size }; + resolve_auto_points(&mut inst, frame, size); // standard effects go before the intrinsic ones (render order) let pos = i.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap_or(i.effects.len()); let pos = if is_audio { i.effects.len() } else { pos }; i.effects.insert(pos, inst); - n += 1; + applied.push(json!({"clip": i.id.0, "index": pos, "effect": def.id})); } } - Ok(n) + Ok(applied) })?; - Ok(Value::Null) + Ok(json!({"applied": applied})) }), cmd!("effects.remove", "Remove Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.remove", "need `clip`"))?; @@ -3030,7 +3202,7 @@ fn build() -> Vec { if bool_p(p, "begin").unwrap_or(false) { s.history.merge_key = None; } - let keyframes = s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| { + let (keyframes, value) = s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; let mt = it.source_time_at(at.unwrap_or_else(|| ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)))); let e = match &eff { @@ -3067,6 +3239,13 @@ fn build() -> Vec { let (keyframes, v) = { let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?; let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?; + // the parameter's hard range, which `effects.list` publishes: a value outside it + // (a factor where a percentage is meant, #2679) is refused, not saved + if let (Some(filmcraft_project::ParamKind::Float { min, max, .. }), ParamValue::Float(x)) = (def.map(|d| &d.kind), &v) + && !(*min..=*max).contains(x) + { + return Err(bad("effects.setParam", format!("`{pid}` must be between {min} and {max}, got {x}"))); + } let keyframes = if keyframe { // the stopwatch and the value in one step prm.put_keyframe(mt, v.clone()); @@ -3077,6 +3256,8 @@ fn build() -> Vec { }; (keyframes, v) }; + let accepted_value = param_value_to_json(&v); + if e.effect == "ultra_key" && pid == "setting" && let ParamValue::Choice(setting) = v @@ -3091,10 +3272,10 @@ fn build() -> Vec { } } } - Ok(keyframes) + Ok((keyframes, accepted_value)) })?; // 0 keyframes: the value is static (`time` was not used) - Ok(json!({"keyframes": keyframes})) + Ok(json!({"keyframes": keyframes, "param": pid, "value": value})) } ), cmd!("effects.toggleAnimation", "Toggle Animation", [], None, r#"{"clip":id,"effect":str|index,"param":str,"mask":n?}"#, has_seq, |s, p| { @@ -3111,6 +3292,14 @@ fn build() -> Vec { _ => None, } .ok_or_else(|| bad("effects.toggleAnimation", "no such effect"))?; + // a parameter an older instance lacks (added to the effect later) starts from its + // default, as the UI shows it, without touching an existing one + if p.get("mask").is_none_or(Value::is_null) + && !e.params.contains_key(&pid) + && let Some(d) = e.def().and_then(|d| d.param(&pid)) + { + e.params.insert(pid.clone(), filmcraft_project::Param::new(d.default.clone())); + } crate::masks::target_param(e, p, &pid).ok_or_else(|| bad("effects.toggleAnimation", "no such param"))?.toggle_animation(mt); Ok(()) })?; @@ -3132,12 +3321,13 @@ fn build() -> Vec { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.reset", "need `clip`"))?; let idx = u64_p(p, "index").unwrap_or(0) as usize; let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let source = s.active_sequence().and_then(|q| q.find_item(c)).and_then(|(_, i)| s.project.source_size(i.item)).unwrap_or(frame); let ph = s.playhead(); s.edit_sequence("Reset Effect", |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); if let Some(e) = it.effects.get_mut(idx) { - reset_params(e, None, frame, mt); + reset_params(e, None, frame, source, mt); } Ok(()) })?; @@ -3148,6 +3338,7 @@ fn build() -> Vec { let pid = str_p(p, "param").ok_or_else(|| bad("effects.resetParam", "need `param`"))?.to_string(); let eff = p.get("effect").cloned().unwrap_or(json!("motion")); let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let source = s.active_sequence().and_then(|q| q.find_item(c)).and_then(|(_, i)| s.project.source_size(i.item)).unwrap_or(frame); let ph = s.playhead(); s.edit_sequence("Reset Parameter", |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; @@ -3158,7 +3349,7 @@ fn build() -> Vec { _ => None, } .ok_or_else(|| bad("effects.resetParam", "no such effect on clip"))?; - if !reset_params(e, Some(&pid), frame, mt) { + if !reset_params(e, Some(&pid), frame, source, mt) { return Err(bad("effects.resetParam", format!("no param `{pid}`"))); } Ok(()) @@ -3437,11 +3628,12 @@ fn build() -> Vec { /// Put `e`'s parameters (all of them, or only `only`) back to their defaults, as Premiere's reset /// buttons do: a parameter that is not animated gets its default as its static value, and an /// animated one keeps its keyframes and gets the default as a keyframe at media time `mt`. -/// Masks are left alone. Returns whether any parameter was reset. -fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, frame: (u32, u32), mt: Tick) -> bool { +/// Masks are left alone. Returns whether any parameter was reset. "Auto" points are centred as +/// when the effect was added: a standard effect's in the clip's `source` picture it runs on. +fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, frame: (u32, u32), source: (u32, u32), mt: Tick) -> bool { let Some(d) = e.def() else { return false }; let mut fresh = d.instance(); - resolve_auto_points(&mut fresh, frame, frame); + resolve_auto_points(&mut fresh, if d.intrinsic { frame } else { source }, source); let mut any = false; for (id, prm) in fresh.params { if only.is_some_and(|o| o != id) { @@ -3586,6 +3778,33 @@ fn mark(s: &mut Session, p: &Value, is_in: bool) -> Result { Ok(Value::Null) } +/// Set Work Area Bar In / Out Point: the work area starts (`is_in`) or ends at `time`, else at the +/// playhead. An In point at or after the end moves the end to the end of the sequence, or to one +/// frame after the In point when the In point is at or past the end of the sequence; an Out point +/// at or before the start moves the start to 0. With no work area yet, the other end is the end +/// or the start of the sequence. +fn set_work_area_point(s: &mut Session, p: &Value, is_in: bool) -> Result { + let cmd = if is_in { "sequence.setWorkAreaIn" } else { "sequence.setWorkAreaOut" }; + let t = sequence_time_p(s, p, "", cmd)?.unwrap_or(s.playhead()); + let fd = s.sequence_rate().frame_duration(); + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (start, end) = seq.work_area.map(|w| (w.start, w.end())).unwrap_or((Tick::ZERO, seq.duration())); + let (start, end) = if is_in { + (t, if end > t { end } else { seq.duration().max(t + fd) }) + } else { + if t <= Tick::ZERO { + return Err(bad(cmd, "the Out point must be after the start of the sequence")); + } + (if start < t { start } else { Tick::ZERO }, t) + }; + s.edit_sequence(if is_in { "Set Work Area Bar In Point" } else { "Set Work Area Bar Out Point" }, |q, _, _| { + q.work_area = Some(TimeRange::from_bounds(start, end)); + Ok(()) + })?; + Ok(json!({"start": start.0, "end": end.0})) +} + +/// Decodes caller JSON using the stored parameter as a type template. pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option { Some(match template { ParamValue::Float(_) => ParamValue::Float(v.as_f64()?), @@ -3604,7 +3823,7 @@ pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option return None, }, ParamValue::Bool(_) => ParamValue::Bool(v.as_bool()?), - ParamValue::Choice(_) => ParamValue::Choice(v.as_u64()? as u32), + ParamValue::Choice(_) => ParamValue::Choice(u32::try_from(v.as_u64()?).ok()?), ParamValue::Text(_) => ParamValue::Text(v.as_str()?.to_string()), ParamValue::Path(_) => ParamValue::Path(crate::masks::path_from_json(v)?), ParamValue::Curve(_) => { @@ -3613,6 +3832,20 @@ pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option Value { + match value { + ParamValue::Float(value) => json!(value), + ParamValue::Vec2(value) => json!([value.x, value.y]), + ParamValue::Color(value) => json!(value), + ParamValue::Bool(value) => json!(value), + ParamValue::Choice(value) => json!(value), + ParamValue::Text(value) => json!(value), + ParamValue::Curve(value) => json!(value), + ParamValue::Path(value) => crate::masks::path_to_json(value), + } +} + fn copy_selection(s: &mut Session) { let Some(q) = s.active_sequence() else { return }; let sel = with_links(s, &s.state.selection); @@ -3634,11 +3867,59 @@ fn copy_selection(s: &mut Session) { } } } - s.state.clipboard = clip; - s.state.clipboard_markers = markers; + set_clipboard(&mut s.state, (clip, markers)); +} + +/// Timeline clips (track kind, track index, item) and the sequence markers that go with them. +type Clipboard = (Vec<(TrackKind, usize, filmcraft_project::TrackItem)>, Vec); + +fn set_clipboard(st: &mut crate::EditorState, (clips, markers): Clipboard) { + st.clipboard = clips; + st.clipboard_markers = markers; + st.caption_clipboard.clear(); +} + +/// Put a Lift or Extract cut on the clipboard; returns how many clips it holds (0 leaves the +/// clipboard as it was). +fn store_cut(s: &mut Session, cut: Option) -> usize { + let Some(cut) = cut else { return 0 }; + let copied = cut.0.len(); + set_clipboard(&mut s.state, cut); + copied +} + +/// What Lift and Extract put on the clipboard, as in Premiere (#3019): the clips of the unlocked +/// `tracks` (the targeted ones) in `range`, cut at its edges and held the way Copy holds them. +/// Extract also cuts sync-locked tracks only to keep them in sync; that material is not copied. +/// Reads the sequence before the edit; None when the range has no clips on those tracks. +fn cut_range(q: &filmcraft_project::Sequence, tracks: &[TrackId], range: TimeRange, ctx: &mut edit::EditCtx, with_markers: bool) -> Option { + let mut links = std::collections::HashMap::new(); + let mut clips = Vec::new(); + for (kind, list) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] { + for (ti, t) in list.iter().enumerate().filter(|(_, t)| !t.locked && tracks.contains(&t.id)) { + let mut t = t.clone(); + edit::split_track_at(&mut t, range.start, ctx, &mut links); + edit::split_track_at(&mut t, range.end(), ctx, &mut links); + clips.extend(t.items.into_iter().filter(|i| i.start >= range.start && i.end() <= range.end()).map(|i| (kind, ti, i))); + } + } + let min = clips.iter().map(|c| c.2.start).min()?; + let max = clips.iter().map(|c| c.2.end()).max()?; + for c in &mut clips { + c.2.start -= min; + } + let markers = if with_markers { + q.markers.iter().filter(|m| m.start >= min && m.start < max).map(|m| Marker { start: m.start - min, ..m.clone() }).collect() + } else { + Vec::new() + }; + Some((clips, markers)) } fn paste(s: &mut Session, insert: bool) -> Result { + if !s.state.caption_clipboard.is_empty() { + return crate::captions::paste(s, insert); + } let at = s.playhead(); let clip = s.state.clipboard.clone(); let markers = if s.state.copy_paste_sequence_markers { s.state.clipboard_markers.clone() } else { Vec::new() }; @@ -4038,6 +4319,28 @@ fn ripple_delete_gap(s: &mut Session, g: crate::GapSelection) -> Result { Ok(json!({"closed": g.range.duration.0})) } +/// `sequence.closeGap` with no gap selected: every span where no track has anything, between In +/// and Out (the whole sequence without them), closes in one undo step (#1986). +fn close_sequence_gaps(s: &mut Session) -> Result { + let within = in_out_range(s)?; + let gaps = s.active_sequence().map(|q| crate::sequence_extras::sequence_gaps(q, Some(within))).unwrap_or_default(); + let Some(first) = gaps.first().copied() else { + return Err(bad("sequence.closeGap", "no gaps to close")); + }; + s.edit_sequence("Close Gap", |q, _, st| { + // a locked track would stay put while everything else moved past it + if q.all_tracks().any(|t| t.locked && t.items.iter().any(|i| i.start >= first.end())) { + return Err(edit::EditError::Locked.into()); + } + for g in gaps.iter().rev() { + crate::sequence_extras::close_sequence_gap(q, *g, st.ripple_sequence_markers); + } + Ok(()) + })?; + let closed = gaps.iter().fold(Tick::ZERO, |sum, g| sum + g.duration); + Ok(json!({"gaps": gaps.len(), "closed": closed.0})) +} + /// `sequence.closeGap`: ripple the gap on `track` at `t` closed (one undo step, "Ripple Delete"). fn close_gap(s: &mut Session, tr: TrackId, t: Tick) -> Result<()> { s.edit_sequence("Ripple Delete", |q, _, st| { @@ -4259,6 +4562,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence let tr = |t: &filmcraft_project::Track| { json!({ "id": t.id.0, "name": t.name, "locked": t.locked, "syncLock": t.sync_lock, "enabled": t.enabled, "muted": t.muted, "solo": t.solo, + "label": t.label.map(|l| l.name()), "items": t.items.iter().map(|i| json!({ "clip": i.id.0, "item": i.item.0, "name": i.name, "start": i.start.0, "duration": i.duration.0, "startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed, "reverse": i.reverse, @@ -4359,6 +4663,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti let first = proj.sequences().next().map(|i| i.id); s.media.add_stored(&proj, path.as_deref()); s.project = std::sync::Arc::new(proj); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = crate::Session::default().state; diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index c60fc0f53..18fdf3cca 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -220,5 +220,7 @@ fn build_demo(pool: &MediaPool) -> Option<(Project, ItemId)> { for tr in s.all_tracks_mut() { tr.sort(); } + // the transitions and markers above take fixed ids: new ones start past them + p.next_id = p.next_id.max(p.max_id().saturating_add(1)); Some((p, seq)) } diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs index 7168b181a..942d8f43c 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/doc_path.rs @@ -39,15 +39,27 @@ impl std::error::Error for NotFollowed {} /// where it was opened from. pub fn resolve(stored: &str, document: Option<&Path>) -> Result { let document = document.filter(|d| !d.as_os_str().is_empty()).map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf())); - let path: PathBuf = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; - // A path that is still relative would be taken from the working directory, which the - // document does not choose. - if path.is_relative() { - return Err(NotFollowed::NoDocumentFolder); - } + let path: PathBuf = match craft_docpath::resolve_absolute(stored, document.as_deref()) { + Ok(path) => path, + // Without a document, a relative path resolves but stays relative. + Err(Refused::Invalid) if document.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| !foreign_root(&p)) => { + return Err(NotFollowed::NoDocumentFolder); + } + Err(r) => return Err(NotFollowed::Refused(r)), + }; path.into_os_string().into_string().map_err(|_| NotFollowed::Refused(Refused::Invalid)) } +/// Whether an unresolved `craft_docpath::resolve` result starts with a drive (`C:`) or a share +/// root (`\\`) outside Windows. Such a path names no file here, and no document folder makes it one. +fn foreign_root(path: &Path) -> bool { + if cfg!(windows) { + return false; + } + let b = path.as_os_str().as_encoded_bytes(); + b.starts_with(br"\\") || matches!((b.first(), b.get(1)), (Some(l), Some(b':')) if l.is_ascii_alphabetic()) +} + #[cfg(test)] mod tests { use super::*; @@ -60,5 +72,27 @@ mod tests { assert_eq!(resolve("clip.mp4", None), Err(NotFollowed::NoDocumentFolder)); assert_eq!(resolve(r"\\192.0.2.1\share\x.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); assert_eq!(resolve("file://192.0.2.1/s/x.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("jar:take.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Url))); + // Colons in names: an alternate data stream or a URL on Windows, a plain character elsewhere. + if cfg!(windows) { + assert_eq!(resolve("media/take:2.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid))); + assert_eq!(resolve("take:2.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Url))); + } else { + assert_eq!(resolve("media/take:2.mp4", Some(&doc)), Ok(dir.join("media").join("take:2.mp4").to_string_lossy().into_owned())); + assert_eq!(resolve("take:2.mp4", Some(&doc)), Ok(dir.join("take:2.mp4").to_string_lossy().into_owned())); + } + } + + /// Without a document a relative path waits for the document's folder; outside Windows a + /// drive path names no file and is refused. + #[test] + fn foreign_drive_paths_without_a_document_are_refused() { + for stored in ["clip.mp4", "../clip.mp4"] { + assert_eq!(resolve(stored, None), Err(NotFollowed::NoDocumentFolder), "{stored}"); + } + #[cfg(unix)] + for stored in [r"C:\x", r"C:x", "file:///C:/x/clip.mp4"] { + assert_eq!(resolve(stored, None), Err(NotFollowed::Refused(Refused::Invalid)), "{stored}"); + } } } diff --git a/crates/engine/src/explicit_targets_tests.rs b/crates/engine/src/explicit_targets_tests.rs index 2abbcee12..d18dc4e93 100644 --- a/crates/engine/src/explicit_targets_tests.rs +++ b/crates/engine/src/explicit_targets_tests.rs @@ -130,7 +130,7 @@ fn naming_nothing_usable_does_not_enable_a_command() { assert!(why.starts_with("-1 is not a clip id"), "{why}"); assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({"clips": [v1(&s)[0].id.0], "item": 987_654_321u64}))), "select a clip in the Project panel"); // `edit.cut` takes no `clips`: it works on the selection only - assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips selected"); + assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips or captions selected"); assert!(std::sync::Arc::ptr_eq(&before, &s.project), "nothing was edited"); } @@ -307,3 +307,26 @@ fn link_leaves_clips_without_a_partner_unlinked() { assert_eq!(clip(&s, v1).unwrap().link, None, "v1 has no overlapping audio in the selection"); assert_eq!(clip(&s, a2).unwrap().link, None, "a2 has no overlapping video in the selection"); } + +/// #1103: Unlink with a selection that also holds an unlinked clip (a title, a music clip) unlinks +/// the linked ones, as the Timeline's context menu offers, instead of re-linking them. +#[test] +fn unlink_with_an_unlinked_clip_in_the_selection_still_unlinks() { + let mut s = demo(); + let (v, a) = (v1(&s), a1(&s)); + let (v0, a0, v1, a1) = (v[0].id, a[0].id, v[1].id, a[1].id); + assert!(clip(&s, v0).unwrap().link.is_some() && clip(&s, v1).unwrap().link.is_some(), "the demo's clips start linked"); + // make one clip unlinked on its own: its partner goes with it (no one-member link stays) + s.execute("clip.link", json!({"clips": [a1.0]})).unwrap(); + assert_eq!(clip(&s, a1).unwrap().link, None); + assert_eq!(clip(&s, v1).unwrap().link, None, "the partner left alone is unlinked too"); + s.execute("clip.link", json!({"clips": [v0.0, a0.0]})).unwrap(); + s.execute("clip.link", json!({"clips": [v0.0, a0.0]})).unwrap(); + assert!(clip(&s, v0).unwrap().link.is_some(), "v0 and a0 linked again"); + // v0 + a0 linked, v1 not: Unlink + let r = s.execute("clip.link", json!({"clips": [v0.0, a0.0, v1.0]})).unwrap(); + assert_eq!(r["linked"], false); + for c in [v0, a0, v1] { + assert_eq!(clip(&s, c).unwrap().link, None, "{c:?}"); + } +} diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 79ddcb810..cd245649b 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -9,6 +9,16 @@ use serde_json::{Value, json}; use crate::Session; +#[test] +fn project_names_become_one_export_filename_component() { + for name in ["../../outside", "/absolute/path", r"C:\\outside", ".."] { + let safe = crate::export_tools::file_safe(name); + assert!(!safe.contains(['/', '\\']), "{name:?} became {safe:?}"); + assert_ne!(safe, "."); + assert_ne!(safe, ".."); + } +} + #[test] fn hostile_export_ranges_are_rejected_without_panicking() { let mut session = demo(); @@ -145,11 +155,13 @@ fn files_in(dir: &Path) -> Vec { v } -/// Every built-in preset: a quarter second of the demo sequence (seven frames at 23.976) exported +/// Every built-in preset: 1.0 to 1.08 s of the demo sequence (three frames at 23.976) exported /// with `file.exportMedia {preset}`; ffprobe checks container, codec, profile, frame size, frame -/// rate and audio format, and the data rate where the codec fixes it. +/// rate and audio format, and the data rate where the codec fixes it. Three frames are enough for +/// all of these, and each extra frame costs a 4K H.264 encode in five presets. #[test] fn every_builtin_preset_exports_and_ffprobe_confirms() { + let (start, end) = (1.0, 1.08); let mut s = demo(); let presets = s.execute("export.presets.list", json!({})).unwrap()["presets"].as_array().unwrap().clone(); assert!(presets.len() >= 24); @@ -159,7 +171,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { let sub = dir.0.join(format!("p{}", filmcraft_export::presets::preset_key(name))); std::fs::create_dir_all(&sub).unwrap(); let path = sub.join("Demo").to_string_lossy().to_string(); - let params = json!({"preset": name, "path": path, "range": "custom", "startSeconds": 1.0, "endSeconds": 1.25, "wait": true}); + let params = json!({"preset": name, "path": path, "range": "custom", "startSeconds": start, "endSeconds": end, "wait": true}); let frames = s.execute("export.resolve", params.clone()).unwrap()["frames"].as_u64().unwrap(); let r = s.execute("file.exportMedia", params).unwrap_or_else(|e| panic!("{name}: {e}")); let out = r["path"].as_str().unwrap().to_string(); @@ -172,7 +184,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx" | "exr") { let files = files_in(&sub); let ext = Path::new(&out).extension().unwrap().to_string_lossy().to_string(); - assert_eq!(frames, 7, "frames 23..=29 at 23.976 fps"); + assert_eq!(frames, 3, "frames 23..=25 at 23.976 fps"); let want: Vec = (0..frames).map(|i| format!("Demo{i:03}.{ext}")).collect(); assert_eq!(files, want, "{name}"); if let Some(j) = probe(&sub.join(&want[0]).to_string_lossy()) { @@ -208,7 +220,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert_eq!(a["codec_name"], codec, "{name}"); assert_eq!(a["sample_rate"], "48000", "{name}"); assert_eq!(a["channels"], 2, "{name}"); - assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.01, "{name}: {}", j["format"]["duration"]); + assert!((num(&j["format"]["duration"]) - (end - start)).abs() < 0.01, "{name}: {}", j["format"]["duration"]); continue; } "aac" => { @@ -218,7 +230,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}"); let a = stream(&j, "audio").unwrap(); assert_eq!((a["codec_name"].as_str(), a["sample_rate"].as_str(), a["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)), "{name}"); - assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.03, "{name}: {}", j["format"]["duration"]); + assert!((num(&j["format"]["duration"]) - (end - start)).abs() < 0.03, "{name}: {}", j["format"]["duration"]); continue; } "gif" => { @@ -322,9 +334,6 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { } } -/// H.264 bitrate control over three seconds of the demo: the adaptive Match Source preset -/// targets width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's -/// measured video bitrate stays within the VBR maximum and close to the target. #[test] fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() { let mut s = demo(); @@ -344,6 +353,12 @@ fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() { assert_eq!(&std::fs::read(&m4a).unwrap()[4..8], b"ftyp"); } +/// H.264 bitrate control over one second of the demo: the adaptive Match Source preset targets +/// width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's measured +/// video bitrate stays within the VBR maximum and close to the target. 0.5 to 1.5 s holds the +/// second half of the one-second fade in from black, where every frame changes and each preset +/// spends its whole budget; the shot after it is simple enough that both 1080p presets land at the +/// same bitrate, far under their targets. #[test] fn h264_presets_hit_their_bitrates() { let mut s = demo(); @@ -356,7 +371,7 @@ fn h264_presets_hit_their_bitrates() { let path = dir.path(&format!("{}.mp4", filmcraft_export::presets::preset_key(preset))); s.execute( "file.exportMedia", - json!({"preset": preset, "path": path, "range": "custom", "startSeconds": 0.0, "endSeconds": 3.0, "audio": false, "wait": true}), + json!({"preset": preset, "path": path, "range": "custom", "startSeconds": 0.5, "endSeconds": 1.5, "audio": false, "wait": true}), ) .unwrap(); let Some(j) = probe(&path) else { return }; @@ -847,3 +862,16 @@ fn caption_sidecars_keep_only_the_exported_interval() { assert!(doc.cues.is_empty(), "half-open export excludes cues touching either boundary: {doc:?}"); } } + +/// #1216: `maxThreads` reaches the export settings from the command parameters (CLI, MCP, control +/// channel), as a flat parameter or inside `settings`. +#[test] +fn max_threads_reaches_the_export_settings() { + let session = Session::default(); + let settings_of = |params: Value| crate::export_tools::settings_from_params(&session, ¶ms, "file.exportMedia").map(|(_, s)| s); + assert_eq!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "maxThreads": 2})).unwrap().max_threads, 2); + assert_eq!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "settings": {"maxThreads": 3}})).unwrap().max_threads, 3); + assert_eq!(settings_of(json!({"format": "mjpeg", "path": "out.mov"})).unwrap().max_threads, 0, "all cores unless asked"); + assert!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "maxThreads": -1})).is_err(), "a negative count is refused"); + assert!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "settings": {"maxThread": 2}})).is_err(), "a misspelt setting is refused, not ignored"); +} diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 98c7ad2fc..d8f367013 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -235,6 +235,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option settings.bitrate_kbps = v; settings.adaptive_bitrate = None; } + if let Some(v) = crate::commands::checked_u32_p(p, "maxThreads", cmd)? { + settings.max_threads = v; + } if let Some(v) = crate::commands::checked_u32_p(p, "maxBitrateKbps", cmd)? { settings.max_bitrate_kbps = Some(v); } @@ -385,10 +388,11 @@ pub fn default_export_dir(s: &Session) -> PathBuf { crate::temp_dir() } -fn file_safe(n: &str) -> String { +/// Convert project-derived display text into exactly one safe output filename component. +pub fn file_safe(n: &str) -> String { let s: String = n.chars().map(|c| if c.is_alphanumeric() || " -_().".contains(c) { c } else { '_' }).collect(); let s = s.trim().to_string(); - if s.is_empty() { "Export".into() } else { s } + if s.is_empty() || matches!(s.as_str(), "." | "..") { "Export".into() } else { s } } /// `path` with the settings' extension (added when missing; image sequences keep theirs). diff --git a/crates/engine/src/file_tests.rs b/crates/engine/src/file_tests.rs index f474b268c..f6c6ad218 100644 --- a/crates/engine/src/file_tests.rs +++ b/crates/engine/src/file_tests.rs @@ -94,6 +94,19 @@ fn project_is_named_after_its_file() { let _ = std::fs::remove_dir_all(&d); } +/// #1545: `file.newProject` answers with the project it made (name and revision, as +/// `project_inspect` reports them) instead of `null`. +#[test] +fn new_project_answers_with_its_name_and_revision() { + let mut s = Session::default(); + let r = s.execute("file.newProject", json!({"name": "Trailer"})).unwrap(); + assert_eq!(r["name"], "Trailer"); + assert_eq!(r["revision"].as_u64(), Some(s.revision)); + assert_eq!(s.project.name, "Trailer"); + let r = s.execute("file.newProject", json!({})).unwrap(); + assert_eq!(r["name"], "Untitled", "the default name"); +} + /// Opening the demo after a saved project must not keep that path: Save would overwrite it. #[test] fn open_demo_project_clears_saved_path_so_save_does_not_overwrite() { diff --git a/crates/engine/src/gap_selection_tests.rs b/crates/engine/src/gap_selection_tests.rs index 19abd7c4c..74d62d329 100644 --- a/crates/engine/src/gap_selection_tests.rs +++ b/crates/engine/src/gap_selection_tests.rs @@ -211,7 +211,6 @@ fn close_gap_rejects_invalid_context_and_explicit_parameters_without_editing() { two_clips(&mut s, [0, 96]); let before = s.project.clone(); let undo = s.history.undo.len(); - assert!(s.execute("sequence.closeGap", json!({})).is_err(), "a shortcut needs a selected gap"); s.execute("timeline.selectGap", json!({"track":"V1","frame":60})).unwrap(); for params in [json!({"track":null}), json!({"track":false}), json!({"track":"V99","frame":60}), json!({"time":1})] { assert!(s.execute("sequence.closeGap", params).is_err(), "invalid explicit requests must not fall back to the selected gap"); @@ -232,3 +231,76 @@ fn close_gap_rejects_invalid_context_and_explicit_parameters_without_editing() { assert!(std::sync::Arc::ptr_eq(&before, &s.project)); assert_eq!(s.history.undo.len(), undo); } + +/// 48-frame clips (pictures on V1, linked sounds on A1) placed at `frames`, in that order. +fn clips_at(s: &mut Session, frames: &[i64]) -> Vec<(ClipId, ClipId)> { + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Gaps", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for f in frames { + s.execute("timeline.place", json!({"item": item.0, "frame": f, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + s.execute("edit.deselectAll", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + (0..frames.len()).map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id)).collect() +} + +/// #1986: Sequence ▸ Close Gap with no gap selected closes every gap in the sequence, on picture and +/// sound alike, in one undo step (Premiere's Close Gap). +#[test] +fn close_gap_without_a_selected_gap_closes_every_gap_in_one_undo_step() { + let mut s = Session::default(); + let clips = clips_at(&mut s, &[24, 96, 200]); + let rate = s.sequence_rate(); + let at = |f: i64| rate.tick_of(f); + let (zero, f48, f96) = (Tick::ZERO, at(48), at(96)); + let before = serde_json::to_value(&s.project).unwrap(); + let undo = s.history.undo.len(); + let r = s.execute("sequence.closeGap", json!({})).unwrap(); + assert_eq!(r["gaps"], json!(3), "the leading gap and the two between the clips: {r}"); + for ((picture, sound), start) in clips.iter().zip([zero, f48, f96]) { + assert_eq!(range(&s, *picture).0, start); + assert_eq!(range(&s, *sound).0, start, "sound stays with its picture"); + } + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.undo(); + assert_eq!(serde_json::to_value(&s.project).unwrap(), before); +} + +/// With In and Out set, only the gaps that lie between them close. +#[test] +fn close_gap_without_a_selected_gap_only_closes_gaps_between_in_and_out() { + let mut s = Session::default(); + let clips = clips_at(&mut s, &[0, 96, 200]); + let rate = s.sequence_rate(); + let at = |f: i64| rate.tick_of(f); + s.execute("markers.markIn", json!({"time": at(140).0})).unwrap(); + s.execute("markers.markOut", json!({"time": at(260).0})).unwrap(); + let r = s.execute("sequence.closeGap", json!({})).unwrap(); + assert_eq!(r["gaps"], json!(1), "{r}"); + assert_eq!(range(&s, clips[0].0).0, Tick::ZERO); + assert_eq!(range(&s, clips[1].0).0, at(96), "the gap before In stays"); + assert_eq!(range(&s, clips[2].0).0, at(144), "the gap between In and Out closed"); +} + +/// Nothing to close, or a locked track that would be left behind: an error and no edit. +#[test] +fn close_gap_without_a_selected_gap_refuses_without_editing() { + let mut s = Session::default(); + clips_at(&mut s, &[0, 48]); + let before = s.project.clone(); + let undo = s.history.undo.len(); + assert!(s.execute("sequence.closeGap", json!({})).is_err(), "no gaps"); + assert!(std::sync::Arc::ptr_eq(&before, &s.project)); + assert_eq!(s.history.undo.len(), undo); + + let mut s = Session::default(); + clips_at(&mut s, &[0, 96]); + s.execute("timeline.setTrack", json!({"track":"A1","locked":true})).unwrap(); + let before = s.project.clone(); + let undo = s.history.undo.len(); + assert!(s.execute("sequence.closeGap", json!({})).is_err(), "A1 is locked and has a clip after the gap"); + assert!(std::sync::Arc::ptr_eq(&before, &s.project)); + assert_eq!(s.history.undo.len(), undo); +} diff --git a/crates/engine/src/graphic_templates.rs b/crates/engine/src/graphic_templates.rs index f5053756b..01fa146a4 100644 --- a/crates/engine/src/graphic_templates.rs +++ b/crates/engine/src/graphic_templates.rs @@ -44,6 +44,7 @@ fn query(id: &'static str, label: &'static str, params: &'static str, run: Run) } const G: &[&str] = &["Graphics and Titles"]; +const MAX_THUMBNAIL_DIMENSION: u32 = 1920; fn has_graphic(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; @@ -149,8 +150,7 @@ fn entry_json(e: &LibraryEntry) -> Value { /// Render a template's thumbnail (static: rolls are shown at rest) as straight sRGB RGBA8, /// `w` pixels wide with the template's aspect ratio. Returns (width, height, pixels). pub fn thumbnail(t: &GraphicsTemplate, w: u32) -> (u32, u32, Vec) { - let w = w.clamp(16, 1920); - let h = ((w as f64 * t.canvas[1] as f64 / t.canvas[0].max(1) as f64).round() as u32).max(1); + let (w, h) = thumbnail_dimensions(t.canvas, w); register_fonts(t); let mut meta = t.graphic.clone(); meta.roll.mode = RollMode::Off; @@ -171,11 +171,25 @@ pub fn thumbnail(t: &GraphicsTemplate, w: u32) -> (u32, u32, Vec) { // a moment into the clip, so intro animations have settled a little let at = Tick((t.duration.0 / 2).min(filmcraft_time::TICKS_PER_SECOND)); let mut img = filmcraft_render::image::Image::new(w as usize, h as usize); - let k = w as f64 / t.canvas[0].max(1) as f64; + // fit both sides: a capped height leaves `w / canvas width` too large for a tall canvas + let k = (w as f64 / t.canvas[0].max(1) as f64).min(h as f64 / t.canvas[1].max(1) as f64); filmcraft_render::graphic_clip::render_graphic(&it, at, (t.canvas[0], t.canvas[1]), &Affine::scale(k, k), &mut img); (w, h, img.to_rgba8()) } +/// Fit a requested thumbnail width within a bounded rectangle without trusting the template's +/// aspect ratio to derive an unbounded second dimension. +fn thumbnail_dimensions(canvas: [u32; 2], requested_width: u32) -> (u32, u32) { + let mut w = requested_width.clamp(16, MAX_THUMBNAIL_DIMENSION); + let aspect = canvas[0].max(1) as f64 / canvas[1].max(1) as f64; + let mut h = (w as f64 / aspect).round().clamp(1.0, u32::MAX as f64) as u32; + if h > MAX_THUMBNAIL_DIMENSION { + h = MAX_THUMBNAIL_DIMENSION; + w = (h as f64 * aspect).round().clamp(1.0, MAX_THUMBNAIL_DIMENSION as f64) as u32; + } + (w, h) +} + /// Cached thumbnails by template id + content + width (the UI asks every frame). pub fn thumbnail_cached(t: &GraphicsTemplate, w: u32) -> Arc<(u32, u32, Vec)> { static CACHE: OnceLock)>>>> = OnceLock::new(); @@ -775,7 +789,12 @@ fn set_char_style(s: &mut Session, p: &Value) -> Result { }; let tl = s.playhead(); let (start, end) = (u64_p(p, "start").map(|v| v as usize), u64_p(p, "end").map(|v| v as usize)); - let runs = s.edit_sequence("Character Style", move |q, _, _| { + // A pointer gesture shares one undo step only within the same clip, layer and range. + let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("graphics.setCharStyle:{}:{ei}:{start:?}:{end:?}", clip.0)); + if bool_p(p, "begin").unwrap_or(false) { + s.history.merge_key = None; + } + let runs = s.edit_sequence_as("Character Style", merge.as_deref(), move |q, _, _| { let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?; let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1))); let e = &mut it.effects[ei]; @@ -1217,7 +1236,7 @@ pub fn commands() -> Vec { "graphics.setCharStyle", "Character Style", &[], - r##"{"clip":id?,"layer":n|name?,"start":char=0,"end":char=len,"style":{"font":str,"fontStyle":str,"size":px,"color":"#rrggbb","bold":bool,"italic":bool,"underline":bool,"tracking":n,"baselineShift":px,"caps":"normal|all caps|small caps"},"clear":bool? (remove styles from the range)}"##, + r##"{"clip":id?,"layer":n|name?,"start":char=0,"end":char=len,"style":{"font":str,"fontStyle":str,"size":px,"color":"#rrggbb","bold":bool,"italic":bool,"underline":bool,"tracking":n,"baselineShift":px,"caps":"normal|all caps|small caps"},"clear":bool? (remove styles from the range),"merge":bool?,"begin":bool? (start a new undo gesture)}"##, has_graphic, set_char_style, ), diff --git a/crates/engine/src/graphic_templates_tests.rs b/crates/engine/src/graphic_templates_tests.rs index 98a55a588..47a7c1528 100644 --- a/crates/engine/src/graphic_templates_tests.rs +++ b/crates/engine/src/graphic_templates_tests.rs @@ -143,6 +143,13 @@ fn builtin_templates_apply_and_thumbnail() { } } +#[test] +fn template_thumbnail_dimensions_fit_a_bounded_rectangle() { + assert_eq!(super::thumbnail_dimensions([1920, 1080], 160), (160, 90)); + assert_eq!(super::thumbnail_dimensions([1, 16_384], 1920), (1, 1920)); + assert_eq!(super::thumbnail_dimensions([16_384, 1], 1920), (1920, 1)); +} + #[test] fn responsive_time_protects_intro_and_outro_when_trimmed() { let mut s = demo("gt-time"); @@ -380,3 +387,52 @@ fn copy_paste_duplicates_a_graphic_clip() { assert!((pasted[0].start.seconds() - 620.0).abs() < 0.05, "at the playhead: {:?}", pasted[0].start); assert_eq!(texts(&s, pasted[0].id), vec!["Lyric".to_string()]); } + +#[test] +fn character_style_drag_merges_only_its_target_and_range() { + let mut s = demo("gt-char-drag"); + let c = clip_of(&s.execute("graphics.newText", json!({"text":"Alpha Bravo"})).unwrap()); + let before = s.project.clone(); + let n = s.history.undo.len(); + for i in 0..8 { + s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":105+i*5},"merge":true,"begin":i==0})).unwrap(); + } + assert_eq!(s.history.undo.len(), n + 1, "one drag, one undo step"); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(*s.project, *after); + s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":150},"merge":true,"begin":true})).unwrap(); + assert_eq!(s.history.undo.len(), n + 2, "a new gesture on the same range starts a new step"); + s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":6,"end":11,"style":{"size":160},"merge":true})).unwrap(); + assert_eq!(s.history.undo.len(), n + 3, "another character range does not merge"); + let before = s.project.clone(); + assert!(s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":3,"end":3,"style":{"size":170},"merge":true})).is_err()); + assert_eq!(*s.project, *before); + assert_eq!(s.history.undo.len(), n + 3, "an invalid edit adds no undo step"); +} + +#[test] +fn character_style_merge_preserves_default_calls_and_separate_clips() { + let mut s = demo("gt-char-merge-targets"); + let a = clip_of(&s.execute("graphics.newText", json!({"text":"First", "newClip":true})).unwrap()); + let b = clip_of(&s.execute("graphics.newText", json!({"text":"Other", "newClip":true})).unwrap()); + assert_ne!(a, b); + let n = s.history.undo.len(); + for c in [a, b] { + s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":120},"merge":true})).unwrap(); + } + assert_eq!(s.history.undo.len(), n + 2, "distinct clips do not merge"); + for size in [130, 140] { + s.execute("graphics.setCharStyle", json!({"clip":b.0,"style":{"size":size}})).unwrap(); + } + assert_eq!(s.history.undo.len(), n + 4, "default calls retain separate undo steps"); + let added = s.execute("graphics.newText", json!({"clip":b.0,"text":"Layer"})).unwrap(); + assert_eq!(clip_of(&added), b); + let n = s.history.undo.len(); + for layer in [0, 1] { + s.execute("graphics.setCharStyle", json!({"clip":b.0,"layer":layer,"start":0,"end":5,"style":{"size":150},"merge":true})).unwrap(); + } + assert_eq!(s.history.undo.len(), n + 2, "distinct layers do not merge"); +} diff --git a/crates/engine/src/graphics.rs b/crates/engine/src/graphics.rs index 998b1e2f7..58b3eaada 100644 --- a/crates/engine/src/graphics.rs +++ b/crates/engine/src/graphics.rs @@ -8,7 +8,9 @@ //! at the playhead. use filmcraft_geom::Vec2; -use filmcraft_project::graphic::{self, LayerContent, SHAPE_OPTS, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer}; +use filmcraft_project::graphic::{ + self, LayerContent, SHAPE_OPTS, ensure_layer_uids, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer, +}; use filmcraft_project::{ClipId, EffectInstance, ItemId, ItemKind, Label, Param, ParamValue, Sequence, TrackKind}; use filmcraft_render::graphic_clip::{item_layer_specs, layer_local_bounds, layer_quad, text_layout}; use filmcraft_time::{Tick, TimeRange}; @@ -138,6 +140,9 @@ pub(crate) fn place_video_clip( let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?; ti.name = name; ti.effects.extend(extra); + // Every new graphic layer gets a stable uid; a new clip holds layers from `extra` plus + // any source-graphic layers, so do it after both are in place (duplicates are repaired). + ensure_layer_uids(&mut ti.effects); for e in &mut ti.effects { filmcraft_project::resolve_auto_points(e, (w, h), src_size); } @@ -179,6 +184,9 @@ fn add_layer(s: &mut Session, clip: ClipId, mut layer: filmcraft_project::Effect // new layers go in front: after the last graphic layer let pos = layer_indices(&it.effects).last().map_or(it.effects.len(), |l| l + 1); it.effects.insert(pos, layer); + // Assign a uid to the new layer (and lazily upgrade any uid-less legacy layers) inside + // this undo step, so undo/redo keeps the same identities. + ensure_layer_uids(&mut it.effects); let n = layer_indices(&it.effects).len() - 1; st.selection = vec![clip]; st.graphic_layers = vec![n]; diff --git a/crates/engine/src/graphics_tests.rs b/crates/engine/src/graphics_tests.rs index e8893104e..8069fd17b 100644 --- a/crates/engine/src/graphics_tests.rs +++ b/crates/engine/src/graphics_tests.rs @@ -95,6 +95,31 @@ fn graphic_placement_takes_a_track_and_a_time() { } } +/// Every graphic layer gets a stable uid when it is created, both in a new clip and when added to +/// an existing one. Two byte-identical layers with different text must not share an identity. +#[test] +fn new_graphic_layers_get_unique_stable_uids() { + let mut s = demo(); + let clip = ClipId(s.execute("graphics.newText", json!({"text": "one", "position": [960, 540], "size": 100})).unwrap()["clip"].as_u64().unwrap()); + s.execute("graphics.newText", json!({"text": "two", "position": [960, 540], "size": 100, "clip": clip.0})).unwrap(); + s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip.0})).unwrap(); + let uids = |s: &mut Session| -> Vec { + s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"].as_array().unwrap().iter().map(|l| l["uid"].as_u64().unwrap()).collect() + }; + let first = uids(&mut s); + assert_eq!(first.len(), 3); + assert!(first.iter().all(|uid| *uid != 0), "every created layer is assigned a uid: {first:?}"); + assert_eq!(first.iter().copied().collect::>().len(), 3, "uids are unique: {first:?}"); + // A real reorder keeps the same per-layer identity, and undo/redo restores it unchanged. + s.execute("graphics.arrangeLayer", json!({"clip": clip.0, "layer": 0, "to": "front"})).unwrap(); + let reordered = uids(&mut s); + assert_eq!(reordered, vec![first[1], first[2], first[0]]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(uids(&mut s), first, "undo restores the same identities, not fresh ones"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(uids(&mut s), reordered); +} + #[test] fn new_text_makes_a_graphic_clip_above_the_footage() { let mut s = demo(); diff --git a/crates/engine/src/history_tests.rs b/crates/engine/src/history_tests.rs new file mode 100644 index 000000000..1f5b26191 --- /dev/null +++ b/crates/engine/src/history_tests.rs @@ -0,0 +1,77 @@ +//! The History panel menu: `history.deleteRedoable` (Delete Redoable Actions) forgets the steps +//! Redo would bring back, and `history.clear` (Clear History) forgets every step. Neither one +//! changes the project or its unsaved state. + +use serde_json::json; + +use crate::Session; + +/// The demo project with three labeled edits, then two of them undone. +fn three_edits_two_undone() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + for name in ["A", "B", "C"] { + s.execute("file.newBin", json!({"name": name})).unwrap(); + } + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s +} + +#[test] +fn delete_redoable_actions_forgets_the_undone_steps_only() { + let mut s = three_edits_two_undone(); + let before = s.execute("history.list", json!({})).unwrap(); + assert_eq!(before["redo"].as_array().unwrap().len(), 2, "{before}"); + let project = s.project.clone(); + let dirty = s.is_dirty(); + + let r = s.execute("history.deleteRedoable", json!({})).unwrap(); + assert_eq!(r, json!({"deleted": 2})); + let after = s.execute("history.list", json!({})).unwrap(); + assert_eq!(after["undo"], before["undo"], "the steps Undo takes back stay"); + assert_eq!(after["redo"], json!([])); + assert_eq!(*s.project, *project, "the project does not change"); + assert_eq!(s.is_dirty(), dirty); + assert!(s.execute("edit.redo", json!({})).is_err(), "nothing is left to redo"); + // Undo still takes back the step before + s.execute("edit.undo", json!({})).unwrap(); + // with nothing to redo the command is disabled + s.history.redo.clear(); + assert!(s.execute("history.deleteRedoable", json!({})).is_err()); +} + +#[test] +fn clear_history_forgets_every_step_and_keeps_the_project() { + let mut s = three_edits_two_undone(); + let undo = s.history.undo.len(); + assert!(undo > 0); + let project = s.project.clone(); + let dirty = s.is_dirty(); + + let r = s.execute("history.clear", json!({})).unwrap(); + assert_eq!(r, json!({"deleted": undo + 2})); + assert_eq!(s.execute("history.list", json!({})).unwrap(), json!({"undo": [], "redo": []})); + assert_eq!(*s.project, *project, "the project does not change"); + assert_eq!(s.is_dirty(), dirty); + assert!(s.execute("edit.undo", json!({})).is_err()); + assert!(s.execute("history.clear", json!({})).is_err(), "an empty history has nothing to clear"); + + // a new edit starts a new history and can be undone + s.execute("file.newBin", json!({"name": "D"})).unwrap(); + assert_eq!(s.history.undo.len(), 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *project); +} + +#[test] +fn history_commands_ignore_unexpected_parameters() { + for p in [json!(null), json!([1, 2]), json!({"deleted": -1}), json!("x")] { + let mut s = three_edits_two_undone(); + let undo = s.history.undo.len(); + assert_eq!(s.execute("history.deleteRedoable", p.clone()).unwrap(), json!({"deleted": 2}), "{p}"); + assert_eq!(s.history.undo.len(), undo, "{p}"); + assert_eq!(s.execute("history.clear", p.clone()).unwrap(), json!({"deleted": undo}), "{p}"); + assert_eq!(s.execute("history.list", json!({})).unwrap(), json!({"undo": [], "redo": []}), "{p}"); + } +} diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs index 5102308c8..a0ce36778 100644 --- a/crates/engine/src/interchange.rs +++ b/crates/engine/src/interchange.rs @@ -24,6 +24,28 @@ fn is_file_media(item: &filmcraft_project::ProjectItem) -> bool { matches!(&item.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::File { .. })) } +/// Write embedded audio without replacing an occupied path; return the path used. A numbered +/// name changes only the file name: the folder keeps its separators as written (`/` or `\`). +fn write_embedded_audio(s: &Session, path: &str, wav: &[u8]) -> Result { + let (folder, name) = path.split_at(path.rfind(['/', '\\']).map_or(0, |i| i + 1)); + if matches!(name, "" | "." | "..") { + return Err(EngineError::Other(format!("{path}: missing audio file name"))); + } + let (stem, extension) = match name.rfind('.') { + Some(i) if i > 0 => name.split_at(i), + _ => (name, ""), + }; + for number in 1..=1_000 { + let candidate = if number == 1 { path.to_owned() } else { format!("{folder}{stem} ({number}){extension}") }; + match s.services.write_file_new(&candidate, wav) { + Ok(()) => return Ok(candidate), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(EngineError::Other(format!("{candidate}: {e}"))), + } + } + Err(EngineError::Other(format!("{path}: no unused embedded-audio filename after 1000 attempts"))) +} + /// Import a document: merge its bins, media and sequences into the project (one undo step), then /// link each media file that exists on disk. Returns the new sequences. pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Result { @@ -37,17 +59,27 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu // AAF / OMF may embed audio: write it next to the document (the media items point there) Format::Aaf | Format::Omf => { let r = if format == Format::Aaf { filmcraft_interchange::aaf::import(bytes, &opts) } else { filmcraft_interchange::omf::import(bytes, &opts) }; - let (fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let (mut fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?; for m in extracted { - if let Some(dir) = std::path::Path::new(&m.path).parent().filter(|_| !s.services.export_in_memory()) { - let _ = std::fs::create_dir_all(dir); + let media = fragment + .project + .item_mut(m.item) + .and_then(|item| item.as_media_mut()) + .ok_or_else(|| EngineError::Other(format!("{}: embedded audio has no media item", m.path)))?; + let written_path = write_embedded_audio(s, &m.path, &m.wav)?; + if written_path != m.path { + media.media = MediaRef::File { path: written_path }; } - s.services.write_file(&m.path, &m.wav).map_err(|e| EngineError::Other(format!("{}: {e}", m.path)))?; } (fragment, report) } _ => filmcraft_interchange::import_with(bytes, format, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?, }; + for item in fragment.project.items.values() { + if let ItemKind::Sequence(sequence) = &item.kind { + sequence.settings.validate().map_err(|e| EngineError::Other(format!("{path}: invalid imported sequence settings: {e}")))?; + } + } let before: std::collections::HashSet = s.project.items.keys().copied().collect(); let name = opts.name.clone().unwrap_or_else(|| format.name().to_string()); let seqs = s.edit(&format!("Import {name}"), |proj, _| { @@ -152,3 +184,63 @@ pub fn export(s: &mut Session, p: &Value) -> Result { s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(e.to_string()))?; Ok(json!({"path": path, "bytes": bytes.len(), "report": report_json(&report)})) } + +#[cfg(test)] +mod embedded_audio_tests { + use std::collections::HashMap; + use std::io; + use std::sync::{Arc, Mutex}; + + use crate::{Services, Session}; + + use super::write_embedded_audio; + + #[derive(Default)] + struct VirtualFiles(Mutex>>); + + impl Services for VirtualFiles { + fn read_file(&self, path: &str) -> io::Result> { + self.0.lock().unwrap().get(path).cloned().ok_or_else(|| io::ErrorKind::NotFound.into()) + } + + fn write_file(&self, path: &str, data: &[u8]) -> io::Result<()> { + self.0.lock().unwrap().insert(path.to_owned(), data.to_vec()); + Ok(()) + } + + fn write_file_new(&self, path: &str, data: &[u8]) -> io::Result<()> { + let mut files = self.0.lock().unwrap(); + if files.contains_key(path) { + return Err(io::ErrorKind::AlreadyExists.into()); + } + files.insert(path.to_owned(), data.to_vec()); + Ok(()) + } + } + + #[test] + fn virtual_extraction_keeps_existing_media() { + let files = Arc::new(VirtualFiles::default()); + files.0.lock().unwrap().insert("/files/cut Audio.wav".into(), b"first".to_vec()); + files.0.lock().unwrap().insert("/files/cut Audio (2).wav".into(), b"second".to_vec()); + let session = Session::new(files.clone()); + let path = write_embedded_audio(&session, "/files/cut Audio.wav", b"new audio").unwrap(); + assert_eq!(path, "/files/cut Audio (3).wav"); + let stored = files.0.lock().unwrap(); + assert_eq!(stored["/files/cut Audio.wav"], b"first"); + assert_eq!(stored["/files/cut Audio (2).wav"], b"second"); + assert_eq!(stored["/files/cut Audio (3).wav"], b"new audio"); + } + + #[test] + fn numbered_names_keep_the_path_separators() { + for path in ["C:/files/cut Audio.wav", "C:\\files\\cut Audio.wav"] { + let files = Arc::new(VirtualFiles::default()); + files.0.lock().unwrap().insert(path.into(), b"first".to_vec()); + let session = Session::new(files.clone()); + let numbered = write_embedded_audio(&session, path, b"new audio").unwrap(); + assert_eq!(numbered, path.replace("cut Audio.wav", "cut Audio (2).wav")); + assert_eq!(files.0.lock().unwrap()[path], b"first"); + } + } +} diff --git a/crates/engine/src/keyboard.rs b/crates/engine/src/keyboard.rs index 5d344943f..622bd5300 100644 --- a/crates/engine/src/keyboard.rs +++ b/crates/engine/src/keyboard.rs @@ -226,7 +226,11 @@ fn nudge_track(s: &mut Session, up: bool) -> Result { let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; let mut moves = Vec::new(); let mut blocked = 0usize; + let mut seen = std::collections::HashSet::new(); for c in &s.state.selection { + if !seen.insert(*c) { + continue; + } let Some((tid, it)) = seq.find_item(*c) else { continue }; let Some(tr) = seq.track(tid) else { continue }; let list = seq.tracks(tr.kind); diff --git a/crates/engine/src/keyboard_tests.rs b/crates/engine/src/keyboard_tests.rs index 4e6ff2fdf..47a749b83 100644 --- a/crates/engine/src/keyboard_tests.rs +++ b/crates/engine/src/keyboard_tests.rs @@ -67,7 +67,7 @@ fn premiere_engine_entries_exist() { // UI-owned ids (tools, transport, panels…) are checked by the ui-egui keyboard test let ui_prefixes = ["tool.", "playback.", "window.", "view.", "app.", "help.", "mode.", "multicam.", "projectPanel.", "textPanel.", "panel.", "mediaBrowser."]; - let ui_ids = ["timeline.expandAllTracks", "timeline.minimizeAllTracks", "graphics.beginTextEditing"]; + let ui_ids = ["timeline.expandAllTracks", "timeline.minimizeAllTracks", "graphics.beginTextEditing", "mixer.showHideTracks", "mixer.meterInputOnly"]; for (id, _, _) in presets::premiere() { if id.starts_with("timeline.") && (id.contains("Height") || id.contains("Screen")) || ui_ids.contains(&id) { continue; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5d0a044d0..5b70393d8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -102,6 +102,14 @@ pub type Result = std::result::Result; pub trait Services: Send + Sync { fn read_file(&self, path: &str) -> std::io::Result>; fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>; + /// Publish a complete new file only when the final path is unoccupied. Publication must + /// reject a concurrent collision; a prior existence check alone is insufficient. + /// Return `ErrorKind::AlreadyExists` for an occupied final path so the importer can retry + /// with a numbered name; return other errors without publishing a partial file. + /// Hosts without this operation fail closed. + fn write_file_new(&self, _path: &str, _data: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "exclusive file creation is unavailable on this host")) + } /// Size of a file in bytes (an error when it is missing). Hosts should override this: the /// default reads the whole file. fn file_size(&self, path: &str) -> std::io::Result { @@ -159,6 +167,23 @@ impl Services for FsServices { // Atomic + durable: temp file in the same directory, fsync, rename, fsync the directory. filmcraft_format::atomic_write(std::path::Path::new(path), data) } + fn write_file_new(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + let path = std::path::Path::new(path); + let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| std::path::Path::new(".")); + std::fs::create_dir_all(dir).map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + std::io::Error::new(std::io::ErrorKind::NotADirectory, format!("{} is not a directory: {error}", dir.display())) + } else { + error + } + })?; + match std::fs::symlink_metadata(path) { + Ok(_) => return Err(std::io::ErrorKind::AlreadyExists.into()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + filmcraft_format::atomic_write_new(path, data) + } fn file_size(&self, path: &str) -> std::io::Result { let m = std::fs::metadata(path)?; if m.is_file() { Ok(m.len()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("{path} is not a file"))) } @@ -217,6 +242,22 @@ impl History { pub fn can_redo(&self) -> bool { !self.redo.is_empty() } + /// The History panel's Delete Redoable Actions: removes every step that Redo would bring + /// back. The project and the undo steps stay as they are. Returns how many steps were removed. + pub fn delete_redoable(&mut self) -> usize { + let n = self.redo.len(); + self.redo.clear(); + n + } + /// The History panel's Clear History: removes every undo and redo step. The project stays as + /// it is. Returns how many steps were removed. + pub fn clear(&mut self) -> usize { + let n = self.undo.len() + self.redo.len(); + self.undo.clear(); + self.redo.clear(); + self.merge_key = None; + n + } } /// Source patching / track targeting (the track header "V1/A1" source buttons + target toggles). @@ -344,6 +385,10 @@ pub struct EditorState { /// Sequence ▸ Show Through Edits: the timeline marks cuts between continuous pieces of a clip. #[serde(default)] pub show_through_edits: bool, + /// Timeline Display Settings ▸ Show Duplicate Frame Markers: a stripe along the bottom of a + /// clip under the media frames another clip also shows. + #[serde(default)] + pub show_duplicate_frame_markers: bool, /// Markers ▸ Ripple Sequence Markers: ripple edits move (and delete) sequence markers too. #[serde(default)] pub ripple_sequence_markers: bool, @@ -356,6 +401,11 @@ pub struct EditorState { /// Sequence markers copied with the clipboard clips (start relative to the copied range). #[serde(skip)] pub clipboard_markers: Vec, + /// Captions copied from the Timeline, with their caption track's index (starts relative to the + /// copied range). Copying clips empties it and copying captions empties `clipboard`, so Paste + /// places whichever was copied last. + #[serde(skip)] + pub caption_clipboard: Vec<(usize, filmcraft_project::Caption)>, /// The last Edit ▸ Find… (Find Next continues it). #[serde(default)] pub find: Option, @@ -381,6 +431,10 @@ pub struct Session { pub project: Arc, pub history: History, pub revision: u64, + /// Bumped whenever the session installs a different project document (`file.newProject`, + /// open/import/recovery/template). Frontends use it to discard UI state (text edits, IME + /// owners) that belongs to the replaced document even if item ids are reused. + pub document_epoch: u64, pub saved_revision: u64, pub path: Option, pub state: EditorState, @@ -499,6 +553,7 @@ impl Session { project: Arc::new(Project::new("Untitled")), history: History { limit: 200, ..Default::default() }, revision: 1, + document_epoch: 0, saved_revision: 1, path: None, state: EditorState { @@ -1235,6 +1290,8 @@ mod file_tests; #[cfg(test)] mod gap_selection_tests; #[cfg(test)] +mod history_tests; +#[cfg(test)] mod image_sequence_tests; #[cfg(test)] mod individual_selection_tests; @@ -1245,6 +1302,8 @@ mod keyboard_tests; #[cfg(test)] mod linked_speed_tests; #[cfg(test)] +mod linked_trim_tests; +#[cfg(test)] mod locked_clip_edits_tests; #[cfg(test)] mod masks_tests; @@ -1324,6 +1383,8 @@ mod trim_tests; mod vfx_tests; #[cfg(test)] mod voiceover_tests; +#[cfg(test)] +mod work_area_tests; #[cfg(test)] mod source_placement_tests; diff --git a/crates/engine/src/linked_trim_tests.rs b/crates/engine/src/linked_trim_tests.rs new file mode 100644 index 000000000..e1e722a31 --- /dev/null +++ b/crates/engine/src/linked_trim_tests.rs @@ -0,0 +1,59 @@ +//! Trimming and rate-stretching one side of a linked clip (#3300): `"linked": false` (Alt held +//! in the Timeline) edits only the given clip, while the default still edits its partners too. + +use crate::Session; +use filmcraft_project::ClipId; +use filmcraft_time::Tick; +use serde_json::json; + +fn linked_pair() -> (Session, ClipId, ClipId) { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Linked trim", "video": 1, "audio": 1})).unwrap(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).unwrap().id; + s.execute("timeline.place", json!({"item": item.0, "frame": 0, "duration": s.sequence_rate().tick_of(48).0})).unwrap(); + let q = s.active_sequence().unwrap(); + let (video, sound) = (q.video_tracks[0].items[0].id, q.audio_tracks[0].items[0].id); + assert!(q.find_item(video).unwrap().1.link.is_some(), "the pair is linked"); + (s, video, sound) +} + +fn state(s: &Session, id: ClipId) -> (Tick, Tick, Tick) { + let (_, it) = s.active_sequence().unwrap().find_item(id).unwrap(); + (it.start, it.duration, it.source_in) +} + +#[test] +fn unlinked_trim_leaves_the_partner_alone() { + for mode in ["regular", "ripple"] { + for edge in ["in", "out"] { + let (mut s, video, sound) = linked_pair(); + let (before_v, before_a) = (state(&s, video), state(&s, sound)); + let delta = if edge == "in" { 10 } else { -10 }; + s.execute("timeline.trim", json!({"clip": video.0, "edge": edge, "mode": mode, "deltaFrames": delta, "linked": false})).unwrap(); + assert_eq!(state(&s, video).1, before_v.1 - s.sequence_rate().tick_of(10), "{mode} {edge}: the video is trimmed"); + assert_eq!(state(&s, sound), before_a, "{mode} {edge}: the linked audio keeps its range"); + assert!(s.state.linked_selection, "the global toggle stays on"); + + // without the flag, the partner follows as before + let (mut s, video, sound) = linked_pair(); + s.execute("timeline.trim", json!({"clip": video.0, "edge": edge, "mode": mode, "deltaFrames": delta})).unwrap(); + assert_eq!(state(&s, video).1, before_v.1 - s.sequence_rate().tick_of(10)); + assert_eq!(state(&s, sound).1, before_a.1 - s.sequence_rate().tick_of(10), "{mode} {edge}: the linked audio follows"); + } + } +} + +#[test] +fn unlinked_rate_stretch_leaves_the_partner_alone() { + let (mut s, video, sound) = linked_pair(); + let (before_v, before_a) = (state(&s, video), state(&s, sound)); + s.execute("timeline.rateStretch", json!({"clip": video.0, "edge": "out", "deltaFrames": 12, "linked": false})).unwrap(); + assert_eq!(state(&s, video).1, before_v.1 + s.sequence_rate().tick_of(12)); + assert_eq!(state(&s, sound), before_a, "the linked audio keeps its range"); + + let (mut s, video, sound) = linked_pair(); + s.execute("timeline.rateStretch", json!({"clip": video.0, "edge": "out", "deltaFrames": 12})).unwrap(); + assert_eq!(state(&s, video).1, before_v.1 + s.sequence_rate().tick_of(12)); + assert_eq!(state(&s, sound).1, before_a.1 + s.sequence_rate().tick_of(12), "the linked audio follows"); +} diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index 7dedd9c35..2fd751cda 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -51,7 +51,7 @@ pub(crate) fn commands() -> Vec { spec( "masks.moveVertex", "Move Mask Vertex", - r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n,"handle":"point"|"in"|"out"?,"to":[x,y]?|"delta":[dx,dy]?,"breakHandles":bool?,"merge":key?}"#, + r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n|"vertices":[n],"handle":"point"|"in"|"out"?,"to":[x,y]?|"delta":[dx,dy]?,"breakHandles":bool?,"merge":key?}"#, move_vertex, ), spec("masks.translate", "Move Mask", r#"{"clip":id?,"effect":index|id?,"mask":n?,"delta":[dx,dy],"merge":key?}"#, translate), @@ -324,6 +324,9 @@ fn set(s: &mut Session, p: &Value) -> Result { } fn move_vertex(s: &mut Session, p: &Value) -> Result { + if p.get("vertices").is_some_and(|v| !v.is_null()) { + return move_vertices(s, p); + } let vi = u64_p(p, "vertex").ok_or_else(|| bad("masks.moveVertex", "need `vertex`"))? as usize; let handle = str_p(p, "handle").unwrap_or("point").to_string(); let to = p.get("to").and_then(pt); @@ -362,6 +365,33 @@ fn move_vertex(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// `masks.moveVertex` with `vertices`: the listed points move by `delta` together, in one edit +/// (the multi-vertex drag on the Program monitor). Any index past the path moves nothing. +fn move_vertices(s: &mut Session, p: &Value) -> Result { + const ID: &str = "masks.moveVertex"; + let list = p.get("vertices").and_then(Value::as_array).ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))?; + let mut only = list + .iter() + .map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))) + .collect::>>()?; + only.sort_unstable(); + only.dedup(); + if str_p(p, "handle").is_some_and(|h| h != "point") || p.get("to").is_some() { + return Err(bad(ID, "`vertices` moves points by `delta`")); + } + let d = p.get("delta").and_then(pt).ok_or_else(|| bad(ID, "need `delta`"))?; + edit_mask(s, p, "Move Mask Vertex", move |m, t| { + let mut path = m.path_at(t); + for &vi in &only { + let v = path.vertices.get_mut(vi).ok_or_else(|| bad(ID, format!("no vertex {vi}")))?; + v.p = v.p + d; + } + set_path_at(m, t, path); + Ok(()) + })?; + Ok(Value::Null) +} + fn translate(s: &mut Session, p: &Value) -> Result { let d = p.get("delta").and_then(pt).ok_or_else(|| bad("masks.translate", "need `delta`"))?; edit_mask(s, p, "Move Mask", move |m, t| { diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index c25cb4680..eab7a3a2e 100644 --- a/crates/engine/src/masks_tests.rs +++ b/crates/engine/src/masks_tests.rs @@ -399,3 +399,39 @@ fn path_json_round_trip_keeps_tangents() { // the serialized project form still parses assert_eq!(crate::masks::path_from_json(&serde_json::to_value(&path).unwrap()), Some(path)); } + +/// `masks.moveVertex` with `vertices` moves several points by the same delta in one edit (the +/// Program monitor's multi-vertex drag, #3150); a bad index leaves every vertex where it was. +#[test] +fn move_vertex_moves_a_list_of_vertices_together() { + let (mut s, _) = demo(); + s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap(); + let pts = |s: &mut Session| -> Vec<(f64, f64)> { + let l = s.execute("masks.list", json!({})).unwrap(); + l["masks"][0]["path"]["vertices"].as_array().unwrap().iter().map(|v| (v["p"][0].as_f64().unwrap(), v["p"][1].as_f64().unwrap())).collect() + }; + let before = pts(&mut s); + let n = s.history.undo.len(); + // two frames of one drag, a vertex listed twice moves once + for _ in 0..2 { + s.execute("masks.moveVertex", json!({"vertices": [0, 2, 2], "delta": [10, -5], "merge": "vertex-1"})).unwrap(); + } + assert_eq!(s.history.undo.len(), n + 1, "one undo step for the drag"); + let after = pts(&mut s); + for i in [0, 2] { + assert_eq!(after[i], (before[i].0 + 20.0, before[i].1 - 10.0), "vertex {i}"); + } + for i in [1, 3] { + assert_eq!(after[i], before[i], "vertex {i} stays"); + } + // an index past the last vertex moves nothing + let e = s.execute("masks.moveVertex", json!({"vertices": [1, 9], "delta": [10, 0]})).unwrap_err().to_string(); + assert!(e.contains("no vertex 9"), "{e}"); + assert_eq!(pts(&mut s), after); + // a list moves points, by a delta + assert!(s.execute("masks.moveVertex", json!({"vertices": [1], "to": [0, 0]})).is_err()); + assert!(s.execute("masks.moveVertex", json!({"vertices": [1], "handle": "in", "delta": [1, 0]})).is_err()); + assert!(s.execute("masks.moveVertex", json!({"vertices": "1", "delta": [1, 0]})).is_err()); + s.undo(); + assert_eq!(pts(&mut s), before); +} diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index d1e65f815..f2ea3013f 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -636,22 +636,33 @@ fn format_size(b: u64) -> String { } } -/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds. +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days` +/// (≤ `u64::MAX / 86_400`) always fits an `i64`. +fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + +/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds. Years far outside chrono's own range are preserved +/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn format_date(secs: u64) -> String { - let days = (secs / 86_400) as i64; - let rem = secs % 86_400; - // civil-from-days (Howard Hinnant's algorithm) - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem / 60) % 60) + let (y, t) = utc_parts(secs); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%d %H:%M")) } fn probe_cmd(s: &mut Session, p: &Value) -> Result { @@ -773,3 +784,23 @@ pub fn std_home_dir() -> Option { let vars = if cfg!(windows) { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] }; vars.iter().find_map(|v| std::env::var(v).ok().filter(|h| !h.is_empty())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_date_is_utc_minutes() { + // Pinned against the previous hand-rolled civil-from-days output. + assert_eq!(format_date(0), "1970-01-01 00:00"); + assert_eq!(format_date(951_782_400), "2000-02-29 00:00"); + assert_eq!(format_date(1_709_164_800), "2024-02-29 00:00"); + assert_eq!(format_date(1_790_775_907), "2026-09-30 13:45"); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(format_date(253_402_300_800), "10000-01-01 00:00"); + // Values far beyond chrono's own range are preserved (the old hand-rolled civil-from-days + // kept them); they must not collapse to the epoch (reviewer FredApps). + assert_eq!(format_date(10_000_000_000_000), "318857-05-20 17:46"); + assert_eq!(format_date(u64::MAX), "584554051223-11-09 07:00"); + } +} diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index 23278317f..d3bc86e87 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -17,7 +17,7 @@ use std::collections::{BTreeSet, HashMap}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, PoisonError, RwLock}; use filmcraft_frame::{AudioBuffer, VideoFrame}; use filmcraft_media::generators::GeneratorSource; @@ -147,24 +147,24 @@ fn file_name(path: &str) -> String { impl MediaPool { pub fn register_opener(&self, o: Opener) { - self.openers.write().unwrap_or_else(|e| e.into_inner()).push(o); + self.openers.write().unwrap_or_else(PoisonError::into_inner).push(o); } /// Cache a source for an item, valid whatever the item's reference (generators). pub fn insert(&self, item: ItemId, src: SharedSource) { - self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (String::new(), src)); + self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (String::new(), src)); } /// Cache a source for an item under its [`media_key`]. pub fn insert_keyed(&self, item: ItemId, key: String, src: SharedSource) { - self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src)); + self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (key, src)); } /// Cache a source opened from `path` for an item. pub fn insert_file(&self, item: ItemId, path: &str, src: SharedSource) { - self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item); + self.offline.write().unwrap_or_else(PoisonError::into_inner).remove(&item); let src = self.unsupported_as_slate(item, path, src); - self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (format!("file:{path}"), src)); + self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (format!("file:{path}"), src)); } /// `src` (opened from `path` for `item`) showing the unreadable slate for the frames it @@ -175,9 +175,9 @@ impl MediaPool { } pub fn remove(&self, item: ItemId) { - self.sources.write().unwrap_or_else(|e| e.into_inner()).remove(&item); - self.proxies.write().unwrap_or_else(|e| e.into_inner()).remove(&item); - self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item); + self.sources.write().unwrap_or_else(PoisonError::into_inner).remove(&item); + self.proxies.write().unwrap_or_else(PoisonError::into_inner).remove(&item); + self.offline.write().unwrap_or_else(PoisonError::into_inner).remove(&item); } /// Record the media and proxy paths of `p`, loaded from the document at `document` (None: one @@ -194,7 +194,7 @@ impl MediaPool { /// [`Self::add_stored`] for single paths. pub fn add_stored_paths(&self, paths: impl IntoIterator, document: Option<&str>) { let document = document.filter(|d| !d.is_empty()).map(PathBuf::from); - let mut stored = self.stored.write().unwrap_or_else(|e| e.into_inner()); + let mut stored = self.stored.write().unwrap_or_else(PoisonError::into_inner); for p in paths { stored.entry(p).or_insert_with(|| document.clone()); } @@ -202,12 +202,12 @@ impl MediaPool { /// Forget the stored paths (a new project). pub fn clear_stored(&self) { - self.stored.write().unwrap_or_else(|e| e.into_inner()).clear(); + self.stored.write().unwrap_or_else(PoisonError::into_inner).clear(); } /// The user picked `path` in this session (import, Link Media): open it as given. pub fn allow(&self, path: &str) { - self.stored.write().unwrap_or_else(|e| e.into_inner()).remove(path); + self.stored.write().unwrap_or_else(PoisonError::into_inner).remove(path); } /// The file to open for `path`, a media or proxy path of the project: a stored path as @@ -217,7 +217,7 @@ impl MediaPool { if cfg!(target_arch = "wasm32") { return Ok(path.to_string()); } - match self.stored.read().unwrap_or_else(|e| e.into_inner()).get(path) { + match self.stored.read().unwrap_or_else(PoisonError::into_inner).get(path) { Some(document) => crate::doc_path::resolve(path, document.as_deref()), None => Ok(path.to_string()), } @@ -230,18 +230,18 @@ impl MediaPool { /// Forget every opened source (files are re-read on next use). pub fn clear(&self) { - self.sources.write().unwrap_or_else(|e| e.into_inner()).clear(); - self.proxies.write().unwrap_or_else(|e| e.into_inner()).clear(); - self.offline.write().unwrap_or_else(|e| e.into_inner()).clear(); + self.sources.write().unwrap_or_else(PoisonError::into_inner).clear(); + self.proxies.write().unwrap_or_else(PoisonError::into_inner).clear(); + self.offline.write().unwrap_or_else(PoisonError::into_inner).clear(); } pub fn cached(&self, item: ItemId) -> Option { - self.sources.read().unwrap_or_else(|e| e.into_inner()).get(&item).map(|(_, s)| s.clone()) + self.sources.read().unwrap_or_else(PoisonError::into_inner).get(&item).map(|(_, s)| s.clone()) } /// Sources opened so far (originals and proxies; `perf.stats`). pub fn open_sources(&self) -> usize { - self.sources.read().unwrap_or_else(|e| e.into_inner()).len() + self.proxies.read().unwrap_or_else(|e| e.into_inner()).len() + self.sources.read().unwrap_or_else(PoisonError::into_inner).len() + self.proxies.read().unwrap_or_else(PoisonError::into_inner).len() } pub fn use_proxies(&self) -> bool { @@ -267,12 +267,12 @@ impl MediaPool { /// Why an item last rendered offline (None = online or not opened yet). pub fn offline_status(&self, item: ItemId) -> Option { - self.offline.read().unwrap_or_else(|e| e.into_inner()).get(&item).cloned() + self.offline.read().unwrap_or_else(PoisonError::into_inner).get(&item).cloned() } /// Open a file through the registered openers. pub fn open_bytes(&self, name: &str, bytes: Arc<[u8]>) -> Result { - let openers = self.openers.read().unwrap_or_else(|e| e.into_inner()).clone(); + let openers = self.openers.read().unwrap_or_else(PoisonError::into_inner).clone(); filmcraft_media::open_bytes(name, bytes, &openers) } @@ -281,7 +281,7 @@ impl MediaPool { pub fn open_file(&self, path: &str, services: &dyn Services) -> Result { let io = |e: std::io::Error| if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(e.to_string()) } else { MediaError::Io(e.to_string()) }; if let Some(r) = services.reader(path) { - let openers = self.openers.read().unwrap_or_else(|e| e.into_inner()).clone(); + let openers = self.openers.read().unwrap_or_else(PoisonError::into_inner).clone(); return filmcraft_media::reader::open_reader(&file_name(path), r.map_err(io)?, &filmcraft_codecs::reader_openers(), &openers); } let bytes = services.read_file(path).map_err(io)?; @@ -294,7 +294,7 @@ impl MediaPool { pub fn probe_file(&self, path: &str, services: &dyn Services) -> Result { let Some(r) = services.reader(path) else { return self.open_file(path, services) }; let r = r.map_err(|e| if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(e.to_string()) } else { MediaError::Io(e.to_string()) })?; - let openers = self.openers.read().unwrap_or_else(|e| e.into_inner()).clone(); + let openers = self.openers.read().unwrap_or_else(PoisonError::into_inner).clone(); filmcraft_media::reader::open_reader_within(&file_name(path), r, &filmcraft_codecs::reader_openers(), &openers, PROBE_WHOLE_FILE_MAX) } @@ -319,7 +319,7 @@ impl MediaPool { return Some(s); } let key = media_key(m); - if let Some((k, s)) = self.sources.read().unwrap_or_else(|e| e.into_inner()).get(&item) + if let Some((k, s)) = self.sources.read().unwrap_or_else(PoisonError::into_inner).get(&item) && (k.is_empty() || *k == key) { return Some(s.clone()); @@ -342,7 +342,7 @@ impl MediaPool { } }) { Ok(s) => { - self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item); + self.offline.write().unwrap_or_else(PoisonError::into_inner).remove(&item); self.unsupported_as_slate(item, path, s) } Err(_) if filmcraft_media::pending::is_set() => { @@ -357,17 +357,17 @@ impl MediaPool { } }, }; - self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src.clone())); + self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (key, src.clone())); Some(src) } fn set_offline(&self, item: ItemId, reason: OfflineReason, path: &str, error: &str) { - self.offline.write().unwrap_or_else(|e| e.into_inner()).insert(item, OfflineStatus { reason, path: path.to_string(), error: error.to_string() }); + self.offline.write().unwrap_or_else(PoisonError::into_inner).insert(item, OfflineStatus { reason, path: path.to_string(), error: error.to_string() }); } fn proxy_source(&self, item: ItemId, path: &str, m: &MediaClip, services: &dyn Services) -> Option { let key = format!("proxy:{path}"); - if let Some((k, s)) = self.proxies.read().unwrap_or_else(|e| e.into_inner()).get(&item) + if let Some((k, s)) = self.proxies.read().unwrap_or_else(PoisonError::into_inner).get(&item) && *k == key { return Some(s.clone()); @@ -387,7 +387,7 @@ impl MediaPool { None }; let src: SharedSource = Arc::new(ProxySource { proxy: s, info: m.info.clone(), audio_from }); - self.proxies.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src.clone())); + self.proxies.write().unwrap_or_else(PoisonError::into_inner).insert(item, (key, src.clone())); Some(src) } Err(e) => { @@ -432,7 +432,7 @@ impl PoolProvider { /// item order (subclips count as their media). Empty when not recording. pub fn offline_used(&self) -> Vec { let Some(used) = &self.used else { return Vec::new() }; - let used = used.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let used = used.lock().unwrap_or_else(PoisonError::into_inner).clone(); let media: BTreeSet = used.into_iter().filter_map(|i| self.project.resolve_media(i).map(|(id, _, _)| id)).collect(); media .into_iter() @@ -453,7 +453,7 @@ impl PoolProvider { impl filmcraft_render::SourceProvider for PoolProvider { fn source(&self, item: ItemId) -> Option { if let Some(used) = &self.used { - used.lock().unwrap_or_else(|e| e.into_inner()).insert(item); + used.lock().unwrap_or_else(PoisonError::into_inner).insert(item); } if self.full_res { self.pool.full_res_source(&self.project, item, &*self.services) } else { self.pool.source_for(&self.project, item, &*self.services) } } @@ -485,7 +485,7 @@ impl MediaSource for SlateSource { let v = self.info.video.as_ref().ok_or(MediaError::NoStream("video"))?; let s = req.scale.clamp(1.0 / 64.0, 1.0); let (w, h) = (((v.width as f32 * s).round() as u32).max(2), ((v.height as f32 * s).round() as u32).max(2)); - let mut last = self.last.lock().unwrap_or_else(|e| e.into_inner()); + let mut last = self.last.lock().unwrap_or_else(PoisonError::into_inner); if let Some(f) = last.as_ref() && f.width == w && f.height == h @@ -521,7 +521,7 @@ impl MediaSource for UnsupportedAsSlate { fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { match self.inner.video_frame(req) { Err(MediaError::Unsupported(why)) => { - self.offline.write().unwrap_or_else(|e| e.into_inner()).entry(self.item).or_insert_with(|| { + self.offline.write().unwrap_or_else(PoisonError::into_inner).entry(self.item).or_insert_with(|| { log::warn!("media unreadable: {}: {why}", self.path); OfflineStatus { reason: OfflineReason::Unreadable, path: self.path.clone(), error: why } }); @@ -560,7 +560,7 @@ impl MediaSource for DamagedAsSlate { } fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { let r = self.inner.video_frame(req); - let mut failures = self.failures.lock().unwrap_or_else(|e| e.into_inner()); + let mut failures = self.failures.lock().unwrap_or_else(PoisonError::into_inner); match r { Ok(f) => { failures.remove(&self.item); diff --git a/crates/engine/src/multicam.rs b/crates/engine/src/multicam.rs index 66e36308f..6145bbbd3 100644 --- a/crates/engine/src/multicam.rs +++ b/crates/engine/src/multicam.rs @@ -243,17 +243,19 @@ fn can_synchronize(s: &Session) -> std::result::Result<(), String> { if sel.len() < 2 { Err("select at least two clips to synchronize".into()) } else { Ok(()) } } fn has_items(s: &Session) -> std::result::Result<(), String> { - if s.state.project_selection.iter().any(|i| sync::project_clip(&s.project, *i).is_some()) { + if selected_items(s).iter().any(|i| sync::project_clip(&s.project, *i).is_some()) { Ok(()) } else { - Err("select clips in the Project panel".into()) + Err("select clips in the Project panel or the Timeline".into()) } } +/// Two clips or more: Merge Clips then opens its dialog, which says what is wrong with a +/// selection it can't merge (two video clips, no audio clip) instead of a greyed-out menu item. fn has_two_items(s: &Session) -> std::result::Result<(), String> { - if s.state.project_selection.iter().filter(|i| sync::project_clip(&s.project, **i).is_some()).count() >= 2 { + if selected_items(s).iter().filter(|i| sync::project_clip(&s.project, **i).is_some()).count() >= 2 { Ok(()) } else { - Err("select a video clip and audio clips in the Project panel".into()) + Err("select a video clip and audio clips in the Project panel or the Timeline".into()) } } @@ -262,10 +264,30 @@ fn has_two_items(s: &Session) -> std::result::Result<(), String> { fn items_p(s: &Session, p: &Value) -> Vec { match p.get("items").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(), - None => s.state.project_selection.clone(), + None => selected_items(s), } } +/// The items Merge Clips and Create Multi-Camera Source Sequence work on without `items`: the +/// clips selected in the Project panel, or, with none there, the media of the clips selected in +/// the Timeline (with their linked partners), once each in selection order (#1362). +pub fn selected_items(s: &Session) -> Vec { + if s.state.project_selection.iter().any(|i| sync::project_clip(&s.project, *i).is_some()) { + return s.state.project_selection.clone(); + } + let Some(q) = s.active_sequence() else { return Vec::new() }; + let mut out = Vec::new(); + for c in with_links(s, &s.state.selection) { + if let Some((_, it)) = q.find_item(c) + && sync::project_clip(&s.project, it.item).is_some() + && !out.contains(&it.item) + { + out.push(it.item); + } + } + out +} + fn method_p(p: &Value, cmd: &str) -> Result { Method::from_params(p).map_err(|e| bad(cmd, e)) } diff --git a/crates/engine/src/multicam_tests.rs b/crates/engine/src/multicam_tests.rs index 1a8c3528b..c9221726f 100644 --- a/crates/engine/src/multicam_tests.rs +++ b/crates/engine/src/multicam_tests.rs @@ -328,6 +328,59 @@ const H: u32 = 90; /// Three 4-second cameras (different scenes, own audio), a multi-camera source sequence by In /// points, and a sequence holding one multi-camera clip on V1/A1. Returns (session, camera items, /// source sequence, edit sequence). +/// #1362: with nothing selected in the Project panel, Create Multi-Camera Source Sequence and Merge +/// Clips take the media of the clips selected in the Timeline. +#[test] +fn multicam_from_clips_selected_in_the_timeline() { + let dir = tmp_dir("mc-timeline"); + let files: Vec = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect(); + for (f, sc) in files.iter().zip([DemoScene::OceanSunset, DemoScene::CityNight, DemoScene::Aurora]) { + make_movie(f, sc, W, H, 48); + } + let mut s = Session::default(); + let refs: Vec<&Path> = files.iter().map(|p| p.as_path()).collect(); + let items = import(&mut s, &refs); + let mut p = (*s.project).clone(); + let edit = + p.new_sequence("Edit", filmcraft_project::SequenceSettings { width: W, height: H, frame_rate: FrameRate::FPS_24, ..Default::default() }, 1, 1, None); + s.project = std::sync::Arc::new(p); + s.state.active_sequence = Some(edit); + for (k, item) in items.iter().enumerate() { + s.set_playhead(FrameRate::FPS_24.tick_of(k as i64 * 48)); + s.execute("source.open", json!({"item": item.0})).unwrap(); + s.execute("source.overwrite", json!({})).unwrap(); + } + s.execute("project.select", json!({"items": []})).unwrap(); + s.execute("timeline.select", json!({"clips": []})).unwrap(); + assert!(!s.is_enabled("clip.createMulticam"), "nothing selected anywhere"); + let clips: Vec = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.id.0).collect(); + assert_eq!(clips.len(), 3); + s.execute("timeline.select", json!({"clips": clips})).unwrap(); + assert!(s.is_enabled("clip.createMulticam") && s.is_enabled("clip.mergeClips")); + // Merge Clips opens on them, and says why it can't merge three video clips + assert!(s.execute("clip.mergeClips", json!({"method": "in"})).unwrap_err().to_string().contains("one video clip")); + let r = s.execute("clip.createMulticam", json!({"method": "in"})).unwrap(); + let src = ItemId(r["sequence"].as_u64().unwrap()); + let cameras: Vec = s.project.sequence(src).unwrap().video_tracks.iter().filter_map(|t| t.items.first().map(|c| c.item)).collect(); + assert_eq!(cameras, items, "one camera per selected clip's media, in selection order"); + + // one video clip and an audio-only one, selected in the Timeline: Merge Clips takes them + s.state.active_sequence = Some(edit); + let wav = dir.join("room.wav"); + std::fs::write(&wav, crate::voiceover::write_wav_f32(&vec![0.0; 48_000 * 2], 48_000)).unwrap(); + let audio = import(&mut s, &[wav.as_path()])[0]; + s.set_playhead(FrameRate::FPS_24.tick_of(200)); + s.execute("source.open", json!({"item": audio.0})).unwrap(); + s.execute("source.overwrite", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + let a = q.audio_tracks.iter().flat_map(|t| &t.items).find(|c| c.item == audio).unwrap().id.0; + s.execute("project.select", json!({"items": []})).unwrap(); + s.execute("timeline.select", json!({"clips": [clips[0], a], "linked": false})).unwrap(); + assert!(s.is_enabled("clip.mergeClips")); + let r = s.execute("clip.mergeClips", json!({"method": "in"})).unwrap(); + assert!(r["item"].as_u64().is_some(), "{r}"); +} + fn mc_session(audio: &str) -> (Session, Vec, ItemId, ItemId) { let dir = tmp_dir("mc-edit"); let files: Vec = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect(); diff --git a/crates/engine/src/narration.rs b/crates/engine/src/narration.rs index cc9f1fdf8..562271dd1 100644 --- a/crates/engine/src/narration.rs +++ b/crates/engine/src/narration.rs @@ -98,7 +98,7 @@ fn cache_key(n: &Narration) -> String { fn tts_err(cmd: &str, e: tts::TtsError) -> EngineError { match e { - tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech ▸ Download natural voices (tts.downloadVoices)"), + tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech > Download natural voices (tts.downloadVoices)"), e => bad(cmd, e.to_string()), } } diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index 5b75c02f4..c726953db 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -219,17 +219,20 @@ impl PreviewStore { self.bump(); } - /// Move to `dir`, taking this store's preview files along (first save of a project). + /// Move to `dir`, taking this store's preview files along (first save of a project). The folder is + /// created only when there are files to take along: a render creates it when it needs it (#2343). pub fn move_to(&self, dir: PathBuf) { let old = self.dir(); if old.as_deref() == Some(dir.as_path()) { return; } + let files = self.files.read().unwrap_or_else(|e| e.into_inner()).clone(); if let Some(old) = &old + && !files.is_empty() && (old.starts_with(crate::temp_dir()) || self.temp_root().is_some_and(|r| old.starts_with(r))) { let _ = std::fs::create_dir_all(&dir); - for name in self.files.read().unwrap_or_else(|e| e.into_inner()).iter() { + for name in &files { let (a, b) = (old.join(name), dir.join(name)); if !b.exists() && std::fs::rename(&a, &b).is_err() { let _ = std::fs::copy(&a, &b).map(|_| std::fs::remove_file(&a)); @@ -535,8 +538,7 @@ impl PreviewStore { } } let path = self.dir()?.join(name); - let bytes = std::fs::read(&path).ok()?; - let src = pool.open_bytes(name, bytes.into()).ok()?; + let src = open_preview_file(pool, name, &path)?; let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner()); g.push((name.to_string(), src.clone())); if g.len() > 6 { @@ -546,6 +548,21 @@ impl PreviewStore { } } +/// Open a rendered preview file. Native builds read it in place (the container's index now, +/// frames on demand): previews are full-resolution ProRes, gigabytes a minute, and reading each +/// one whole filled memory during render and playback (#2185). +#[cfg(any(unix, windows))] +pub(crate) fn open_preview_file(_pool: &MediaPool, name: &str, path: &std::path::Path) -> Option { + let reader = filmcraft_media::reader::FileReader::open(path).ok()?; + filmcraft_media::reader::open_reader_within(name, std::sync::Arc::new(reader), &filmcraft_codecs::reader_openers(), &[], 0).ok() +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn open_preview_file(pool: &MediaPool, name: &str, path: &std::path::Path) -> Option { + let bytes = std::fs::read(path).ok()?; + pool.open_bytes(name, bytes.into()).ok() +} + fn need_state(seg: &Segment) -> BarState { match seg.need { Need::None => BarState::None, @@ -639,7 +656,6 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { let provider = s.media.full_res_provider(project.clone(), s.services.clone()); let (prog, res) = (job.progress.clone(), job.result.clone()); let nseg = todo.len(); - let pool = s.media.clone(); let run = move || { let t0 = web_time::Instant::now(); let mut bytes = 0u64; @@ -679,8 +695,6 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { break; } store.add_video(&name); - // open it now so the first playback doesn't wait for the file read - let _ = store.open(&pool, &name); } Err(e) => { let _ = std::fs::remove_file(&part); diff --git a/crates/engine/src/previews_tests.rs b/crates/engine/src/previews_tests.rs index dfc6b7a48..837d1b62b 100644 --- a/crates/engine/src/previews_tests.rs +++ b/crates/engine/src/previews_tests.rs @@ -224,6 +224,27 @@ fn previews_survive_save_and_open() { let _ = std::fs::remove_dir_all(&folder); } +#[test] +fn saving_without_previews_creates_no_previews_folder() { + // #2343: a project with nothing rendered leaves only the file it was asked to write + let mut s = Session::default(); + let folder = tmp("save-empty"); + std::fs::create_dir_all(&folder).unwrap(); + let path = folder.join("Film.fcproj").to_string_lossy().to_string(); + s.execute("file.save", json!({"path": path})).unwrap(); + assert_eq!(s.previews.dir().unwrap(), folder.join("FilmCraft Previews").join("Film")); + assert!(!folder.join("FilmCraft Previews").exists()); + // the first render creates it + let (mut r, _) = session("save-empty-render"); + r.execute("file.save", json!({"path": path})).unwrap(); + assert!(!folder.join("FilmCraft Previews").exists()); + let v = r.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap(); + job_ok(&r, &v); + assert!(folder.join("FilmCraft Previews").join("Film").is_dir()); + assert_eq!(states(&r), vec!["green", "none"]); + let _ = std::fs::remove_dir_all(&folder); +} + #[test] fn render_selection_and_audio_previews() { let (mut s, a) = session("sel"); @@ -282,3 +303,29 @@ fn float_wav_round_trip() { fn bar_state_serializes_lowercase() { assert_eq!(serde_json::to_value(BarState::Green).unwrap(), json!("green")); } + +/// #2185: preview files are read in place, never whole. Rendered previews still play from their +/// files, and a file only a whole-file reader could open is refused rather than loaded. +#[test] +fn preview_files_are_streamed_not_read_whole() { + let (mut s, _) = session("stream"); + let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap(); + job_ok(&s, &v); + let seq = s.state.active_sequence.unwrap(); + assert!(s.previews.frame(&s.media, &s.project, seq, 5, 1.0).is_some(), "a rendered frame plays from its file"); + // a still image opens only from its whole bytes: as a preview file it is not opened + let dir = s.previews.dir().unwrap(); + let png = dir.join("still.png"); + // a 1×1 PNG + const PNG: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, + 120, 218, 99, 100, 96, 248, 95, 15, 0, 2, 135, 1, 128, 235, 71, 186, 146, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]; + std::fs::write(&png, PNG).unwrap(); + assert!(s.media.open_bytes("still.png", PNG.into()).is_ok(), "the image opens from its bytes"); + // (the web, without a file reader, still reads preview files whole) + if cfg!(any(unix, windows)) { + assert!(crate::previews::open_preview_file(&s.media, "still.png", &png).is_none()); + } + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/src/project_manager.rs b/crates/engine/src/project_manager.rs index 3a78fff18..53c543e80 100644 --- a/crates/engine/src/project_manager.rs +++ b/crates/engine/src/project_manager.rs @@ -137,7 +137,10 @@ pub fn run(s: &mut Session, p: &Value) -> Result { for id in ids { let Some(m) = proj.item(id).and_then(|i| i.as_media()).cloned() else { continue }; let MediaRef::File { path } = &m.media else { continue }; - let size = s.services.file_size(path).unwrap_or(0); + // Project files carry stored paths; use only the media pool's resolved, policy-approved + // pathname for filesystem I/O. Keep the stored path in the project itself. + let source_path = s.media.follow(path).map_err(|why| EngineError::Other(format!("{path} is not opened: {why}")))?; + let size = s.services.file_size(&source_path).unwrap_or(0); original_bytes += size; let file = Path::new(path); let stem = file.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| format!("media-{}", id.0)); @@ -158,20 +161,23 @@ pub fn run(s: &mut Session, p: &Value) -> Result { }; taken.push(out.clone()); let proxy = match (&m.proxy, include_proxies) { - (Some(MediaRef::File { path: pp }), true) if s.services.file_size(pp).is_ok() => { - let pf = Path::new(pp); - let ps = pf.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let pe = pf.extension().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let o = unique_path(&dest_dir.join("Proxies"), &ps, "", &pe, &taken).to_string_lossy().into_owned(); - taken.push(o.clone()); - let b = s.services.file_size(pp).unwrap_or(0); - original_bytes += b; - result_bytes += b; - Some((pp.clone(), o)) - } + (Some(MediaRef::File { path: pp }), true) => match s.media.follow(pp) { + Ok(resolved_proxy) if s.services.file_size(&resolved_proxy).is_ok() => { + let pf = Path::new(&resolved_proxy); + let ps = pf.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let pe = pf.extension().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let o = unique_path(&dest_dir.join("Proxies"), &ps, "", &pe, &taken).to_string_lossy().into_owned(); + taken.push(o.clone()); + let b = s.services.file_size(&resolved_proxy).unwrap_or(0); + original_bytes += b; + result_bytes += b; + Some((resolved_proxy, o)) + } + _ => None, + }, _ => None, }; - work.push(Work { item: id, src_path: path.clone(), out, range, proxy }); + work.push(Work { item: id, src_path: source_path, out, range, proxy }); } let previews = s.path.as_deref().map(crate::previews::dir_for_project).filter(|d| include_previews && d.is_dir()); if let Some(d) = &previews { diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index c5f967a75..a7959bac0 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -678,6 +678,7 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result { proj.name = name.clone(); proj.root.name = name; s.project = std::sync::Arc::new(proj); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = Session::default().state; @@ -1027,7 +1028,7 @@ fn inside(dir: &std::path::Path, root: &std::path::Path) -> bool { /// Remember the Scratch Disks folders chosen in this session, so projects naming them may use /// them later (see [`trusted_scratch`]). -fn remember_scratch_folders(s: &mut Session, dirs: &[String]) { +pub(crate) fn remember_scratch_folders(s: &mut Session, dirs: &[String]) { const KEEP: usize = 64; let mut prefs = s.prefs.clone(); let list = &mut prefs.auto_save.scratch_folders; @@ -1062,7 +1063,7 @@ pub(crate) fn warn_untrusted_scratch(s: &mut Session) { let list = ignored.join(", "); log::warn!("project scratch disks outside the project folder not used: {list}"); s.toast(format!( - "Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings ▸ Scratch Disks to use it." + "Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings > Scratch Disks to use it." )); } @@ -1076,12 +1077,10 @@ fn project_settings_scratch(s: &mut Session, p: &Value) -> Result { None => {} Some(Value::Null) => *slot = None, Some(Value::String(x)) if x.is_empty() || x.eq_ignore_ascii_case("same as project") => *slot = None, - Some(Value::String(x)) - if matches!(crate::doc_path::resolve(x, s.path.as_deref().map(std::path::Path::new)), Err(crate::doc_path::NotFollowed::Refused(_))) => - { - return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` must be a folder on this computer, not a network share or device"))); - } Some(Value::String(x)) => { + if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(x, s.path.as_deref().map(std::path::Path::new)) { + return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` must be a folder on this computer: {why}"))); + } chosen.push(x.clone()); *slot = Some(x.clone()); } diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index 075525654..64965166c 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -307,6 +307,11 @@ fn media_properties_settings_and_scratch_disks() { assert!(s.execute("file.projectSettings.general", json!({"renderer": "quantum"})).is_err()); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.settings.renderer, crate::project_tools::RENDERER_GPU); + // Scratch Disks: a drive another system names is refused before the project has a folder + if cfg!(unix) { + assert!(s.execute("file.projectSettings.scratchDisks", json!({"captured": r"D:\Captured"})).is_err()); + assert!(s.project.settings.scratch.captured.is_none()); + } // Scratch Disks: previews follow the Video Previews folder once the project is saved let proj = dir.join("Show.fcproj").to_string_lossy().to_string(); s.execute("file.saveAs", json!({"path": proj})).unwrap(); diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs index d6c720fa6..e6e7f203c 100644 --- a/crates/engine/src/proxies.rs +++ b/crates/engine/src/proxies.rs @@ -97,6 +97,25 @@ pub fn transcode( preset: &Preset, progress: &Progress, ) -> std::result::Result { + transcode_with_file(clip, name, src, range, Dest { path: out, file: None }, preset, progress) +} + +/// Output path, plus the already-open file to write into when the caller holds one. +struct Dest<'a> { + path: &'a str, + file: Option>, +} + +fn transcode_with_file( + clip: &MediaClip, + name: &str, + src: SharedSource, + range: Option, + dest: Dest<'_>, + preset: &Preset, + progress: &Progress, +) -> std::result::Result { + let Dest { path: out, file: output_file } = dest; let info = &clip.info; let rate = clip.frame_rate(); let mut st = SequenceSettings::default(); @@ -140,6 +159,7 @@ pub fn transcode( as u32, part_of_batch: true, prores_profile: preset.prores.to_string(), + output_file, ..Default::default() }; let project = Arc::new(p); @@ -147,6 +167,59 @@ pub fn transcode( filmcraft_export::export(&project, seq, &settings, &provider, progress).map_err(|e| e.to_string()) } +/// An output created exclusively while choosing its name. Unless kept after a successful +/// transcode, dropping it removes only that file (including on a cancelled or rejected job). +struct ReservedOutput { + path: PathBuf, + file: Option>, + keep: bool, +} + +impl ReservedOutput { + fn path(&self) -> &Path { + &self.path + } + + fn file(&self) -> Option> { + self.file.clone() + } + + fn preserve(&mut self) { + self.keep = true; + } +} + +impl Drop for ReservedOutput { + fn drop(&mut self) { + if !self.keep { + // Release our handle before removal, which is required on Windows. + self.file.take(); + let _ = std::fs::remove_file(&self.path); + } + } +} + +/// Reserve a numbered output name without following a later replacement of its pathname. +fn reserve_unique_path(dir: &Path, stem: &str, suffix: &str, ext: &str, taken: &[String]) -> Result { + std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let mut n = 1; + loop { + let name = if n == 1 { format!("{stem}{suffix}.{ext}") } else { format!("{stem}{suffix} {n}.{ext}") }; + let path = dir.join(name); + if taken.contains(&path.to_string_lossy().into_owned()) { + n += 1; + continue; + } + match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => { + return Ok(ReservedOutput { path, file: Some(Arc::new(file)), keep: false }); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1, + Err(e) => return Err(EngineError::Other(format!("{}: {e}", path.display()))), + } + } +} + /// Frames a transcode of `clip` will write (for progress totals). pub fn frame_count(clip: &MediaClip, range: Option) -> u64 { let rate = clip.frame_rate(); @@ -184,17 +257,17 @@ fn items_param(s: &Session, p: &Value) -> Vec { } /// Start a background transcode job over `work` (item, output path, preset). Returns the job id. -pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'static Preset)>, on_done: OnDone, wait: bool) -> Result { +fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, ReservedOutput, &'static Preset)>, on_done: OnDone, wait: bool) -> Result { let mut tasks = Vec::new(); let mut total = 0; - for (item, out, pr) in work { + for (item, output, pr) in work { let (clip, _, name) = media_path(&s.project, item).ok_or_else(|| bad("media.createProxies", format!("item {} is not file media", item.0)))?; let src = s.media.full_res_source(&s.project, item, &*s.services).ok_or_else(|| EngineError::Other(format!("{name}: no source")))?; if s.media.offline_status(item).is_some() { return Err(EngineError::Other(format!("{name} is offline; link it first"))); } total += frame_count(&clip, None); - tasks.push((item, clip, name, src, out, pr)); + tasks.push((item, clip, name, src, output, pr)); } let id = s.jobs.iter().map(|j| j.id).max().unwrap_or(0) + 1; let job = crate::Job { id, label, progress: Default::default(), result: Default::default() }; @@ -206,22 +279,20 @@ pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'st let mut bytes = 0; let mut err = None; let n = tasks.len(); - for (k, (item, clip, name, src, out, pr)) in tasks.into_iter().enumerate() { + for (k, (item, clip, name, src, mut output, pr)) in tasks.into_iter().enumerate() { if prog.cancel.load(std::sync::atomic::Ordering::Relaxed) { err = Some("cancelled".to_string()); break; } *prog.status.lock().unwrap_or_else(|e| e.into_inner()) = format!("{name} ({} of {n})", k + 1); - if let Some(d) = Path::new(&out).parent() { - let _ = std::fs::create_dir_all(d); - } - match transcode(&clip, &name, src, None, &out, pr, &prog) { + let out = output.path().to_string_lossy().into_owned(); + match transcode_with_file(&clip, &name, src, None, Dest { path: &out, file: output.file() }, pr, &prog) { Ok(r) => { bytes += r.bytes; + output.preserve(); outs.lock().unwrap_or_else(|e| e.into_inner()).push((item, out)); } Err(e) => { - let _ = std::fs::remove_file(&out); err = Some(format!("{name}: {e}")); break; } @@ -371,14 +442,15 @@ pub fn create(s: &mut Session, p: &Value) -> Result { } }; let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name); - let out = unique_path(&proxy_dir(&path, dest.as_deref()), &stem, "_Proxy", pr.extension, &taken).to_string_lossy().into_owned(); + let output = reserve_unique_path(&proxy_dir(&path, dest.as_deref()), &stem, "_Proxy", pr.extension, &taken)?; + let out = output.path().to_string_lossy().into_owned(); taken.push(out.clone()); - work.push((i, out, pr)); + work.push((i, output, pr)); } if work.is_empty() { return Err(EngineError::Other(format!("nothing to proxy: {}", skipped.iter().filter_map(|v| v["reason"].as_str()).collect::>().join("; ")))); } - let outputs: Vec = work.iter().map(|(i, o, _)| json!({"item": i.0, "path": o})).collect(); + let outputs: Vec = work.iter().map(|(i, o, _)| json!({"item": i.0, "path": o.path()})).collect(); let n = work.len(); let label = format!("Create Proxies ({n} clip{})", if n == 1 { "" } else { "s" }); let attach = bool_p(p, "attach").unwrap_or(true); @@ -442,13 +514,24 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { if !ing.enabled || items.is_empty() { return Ok(Value::Null); } + // Ingest settings travel in a project file. A destination only becomes active after this + // machine has chosen it, or when it is safely inside the project folder. + let destination = ing + .destination + .as_deref() + .filter(|d| !d.is_empty()) + .and_then(|d| crate::project_tools::trusted_scratch(Some(d), s.path.as_deref(), &s.prefs.auto_save.scratch_folders)); + if ing.destination.as_deref().is_some_and(|d| !d.is_empty()) && destination.is_none() { + s.toast("Ingest: ignoring an untrusted project destination; choose it locally in Ingest Settings to enable copying."); + return Ok(json!({"skipped": "untrusted ingest destination"})); + } let mut copied = Vec::new(); let copy = matches!(ing.action, IngestAction::Copy | IngestAction::CopyAndCreateProxies); if copy { for &i in items { let Some((_, path, _)) = media_path(&s.project, i) else { continue }; - let dir = match &ing.destination { - Some(d) if !d.is_empty() => PathBuf::from(d), + let dir = match &destination { + Some(d) => d.clone(), _ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"), }; if Path::new(&path).file_name().is_some_and(|name| Path::new(&path) == dir.join(name)) { @@ -461,7 +544,7 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { let mut job = Value::Null; match ing.action { IngestAction::CreateProxies | IngestAction::CopyAndCreateProxies => { - let p = json!({"items": items.iter().map(|i| i.0).collect::>(), "preset": if ing.preset.is_empty() { DEFAULT_PROXY_PRESET } else { ing.preset.as_str() }, "destination": ing.destination}); + let p = json!({"items": items.iter().map(|i| i.0).collect::>(), "preset": if ing.preset.is_empty() { DEFAULT_PROXY_PRESET } else { ing.preset.as_str() }, "destination": destination.as_ref().map(|d| d.to_string_lossy().into_owned())}); if items.iter().any(|i| s.project.item(*i).and_then(|x| x.as_media()).is_some_and(|m| m.info.video.is_some() && m.info.kind == MediaKind::Movie)) { job = create(s, &p)?; } @@ -476,14 +559,15 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { if clip.info.video.is_none() { continue; } - let dir = match &ing.destination { - Some(d) if !d.is_empty() => PathBuf::from(d), + let dir = match &destination { + Some(d) => d.clone(), _ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"), }; let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name); - let out = unique_path(&dir, &stem, "", pr.extension, &taken).to_string_lossy().into_owned(); + let output = reserve_unique_path(&dir, &stem, "", pr.extension, &taken)?; + let out = output.path().to_string_lossy().into_owned(); taken.push(out.clone()); - work.push((i, out, pr)); + work.push((i, output, pr)); } if !work.is_empty() { let id = start_job(s, format!("Ingest: transcode {} clip(s)", work.len()), work, OnDone::ReplaceMedia, false)?; @@ -613,6 +697,9 @@ pub fn commands() -> Vec { } if let Some(d) = p.get("destination") { ing.destination = d.as_str().filter(|d| !d.is_empty()).map(str::to_string); + if let Some(d) = ing.destination.as_ref() { + crate::project_tools::remember_scratch_folders(s, std::slice::from_ref(d)); + } } if let Some(pr) = str_p(p, "preset") { if !pr.is_empty() && preset(pr).is_none() { diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index c3b6b3879..9649e5815 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -489,9 +489,14 @@ pub fn index_folder(root: &Path, limit: usize) -> BTreeMap> if name.starts_with('.') { continue; } - if p.is_dir() { + let Ok(file_type) = e.file_type() else { continue }; + // A relink search must stay within the folder explicitly chosen by the user. In + // particular, following a directory symlink can make a small loop defeat the file + // budget and walk an unrelated tree. Symlinked files are still candidates. + let is_file = if file_type.is_symlink() { std::fs::metadata(&p).is_ok_and(|m| m.is_file()) } else { file_type.is_file() }; + if file_type.is_dir() { queue.push_back(p); - } else { + } else if is_file { out.entry(name.to_ascii_lowercase()).or_default().push(p); n += 1; if n >= limit { @@ -564,7 +569,11 @@ pub fn make_offline(s: &mut Session, p: &Value) -> Result { let mut deleted = Vec::new(); if delete { for (_, path) in &files { - if std::fs::remove_file(path).is_ok() { + // Stored paths in an opened document are not authority to delete arbitrary files. + // Delete only the resolved path that the media pool permits opening. + if let Ok(path) = s.media.follow(path) + && std::fs::remove_file(&path).is_ok() + { deleted.push(path.clone()); } } @@ -583,14 +592,14 @@ fn status_json(s: &Session, id: ItemId) -> Option { let status = match &path { None => "generated", Some(_) if m.offline => "offline", - Some(p) if s.services.file_size(p).is_err() => "missing", + Some(p) if s.media.follow(p).ok().is_none_or(|file| s.services.file_size(&file).is_err()) => "missing", Some(_) => match s.media.offline_status(id) { Some(o) if o.reason == filmcraft_render::offline::OfflineReason::Unreadable => "unreadable", _ => "online", }, }; let proxy = match &m.proxy { - Some(MediaRef::File { path }) => json!({"path": path, "online": s.services.file_size(path).is_ok()}), + Some(MediaRef::File { path }) => json!({"path": path, "online": s.media.follow(path).is_ok_and(|file| s.services.file_size(&file).is_ok())}), _ => Value::Null, }; Some(json!({ @@ -743,11 +752,11 @@ pub fn on_open(s: &mut Session) -> usize { list.iter().filter(|m| m.status == "missing").filter_map(|m| s.media.follow(&m.path).err().map(|why| format!("{} ({why})", m.path))).collect(); if !refused.is_empty() { log::warn!("media paths stored in the project not opened: {}", refused.join(", ")); - s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File ▸ Link Media… reconnects them", refused.len())); + s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File > Link Media… reconnects them", refused.len())); } s.offline.prompt = !s.offline.missing.is_empty(); if !s.offline.missing.is_empty() { - s.toast(format!("{} media file(s) are missing; File ▸ Link Media… reconnects them", s.offline.missing.len())); + s.toast(format!("{} media file(s) are missing; File > Link Media… reconnects them", s.offline.missing.len())); } list.len() } diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index 33208c544..7660f85ba 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -163,6 +163,29 @@ fn make_offline_shows_the_slate_until_relinked() { let _ = std::fs::remove_dir_all(&root); } +#[cfg(unix)] +#[test] +fn relink_search_does_not_follow_directory_symlinks() { + use std::os::unix::fs::symlink; + + let root = tmp_dir("relink-no-symlink-walk"); + let inside = root.join("inside"); + let outside = root.join("outside"); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(inside.join("kept.mov"), b"kept").unwrap(); + std::fs::write(outside.join("outside.mov"), b"outside").unwrap(); + symlink(&outside, inside.join("loop")).unwrap(); + symlink(outside.join("outside.mov"), inside.join("linked.mov")).unwrap(); + + let index = crate::relink::index_folder(&inside, 20_000); + assert!(index.contains_key("kept.mov")); + assert!(!index.contains_key("outside.mov")); + // a symlinked file is still media the user can relink to + assert!(index.contains_key("linked.mov")); + let _ = std::fs::remove_dir_all(&root); +} + // Regression test for issue #110: after `file.import` re-imports a file at a path the project // already listed as offline, `s.offline.missing` must drop that item so the slate and the // "Media missing" badge clear without reopening the project. Before the fix (the patch in diff --git a/crates/engine/src/remix.rs b/crates/engine/src/remix.rs index 6f46e75e8..87d2a3c88 100644 --- a/crates/engine/src/remix.rs +++ b/crates/engine/src/remix.rs @@ -102,13 +102,17 @@ fn analysis(s: &Session, item: ItemId, source_in: Tick, dur: Tick) -> Result = buf.channels.iter().map(Vec::as_slice).collect(); let a = Arc::new(dsp::analyze(&refs, sr).map_err(|e| EngineError::Other(format!("cannot remix: {e}")))?); let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/engine/src/roll_linked_tests.rs b/crates/engine/src/roll_linked_tests.rs index dd3f2e4b2..ee89a8938 100644 --- a/crates/engine/src/roll_linked_tests.rs +++ b/crates/engine/src/roll_linked_tests.rs @@ -45,6 +45,59 @@ fn roll_moves_linked_audio_cut() { assert_eq!(range(&s, a1), (f(60), f(96))); } +/// The delta the Timeline previews while a cut is rolled is the one `timeline.roll` applies on +/// release: rolled far to the left, the cut stops where the right clip's media starts, 12 frames +/// before its In point. +#[test] +fn roll_delta_stops_at_the_start_of_the_media() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Roll", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for (frame, source_in) in [(0, 48), (48, 12)] { + s.execute("timeline.place", json!({"item": item.0, "frame": frame, "sourceIn": f(source_in).0, "duration": f(48).0})).unwrap(); + } + let q = s.active_sequence().unwrap(); + let (left, right) = (q.video_tracks[0].items[0].id, q.video_tracks[0].items[1].id); + let far = Tick(-3600 * filmcraft_time::TICKS_PER_SECOND); + let shown = crate::commands::roll_delta(&s, left, right, far).unwrap(); + assert_eq!(shown, -f(12), "clamped to the media before the right clip's In point"); + assert_eq!(crate::commands::roll_delta(&s, left, right, -f(5)).unwrap(), -f(5), "a roll within the media is not clamped"); + let r = s.execute("timeline.roll", json!({"left": left.0, "right": right.0, "delta": far.0})).unwrap(); + assert_eq!(r["delta"], json!(shown.0), "the preview and the roll agree"); + assert_eq!(range(&s, right), (f(36), f(96))); +} + +/// A clip whose Out point is past the end of its media (the file became shorter) cannot roll by +/// a small amount: `edit::roll` would move its cut the other way. The roll moves nothing, and the +/// preview and the roll both report 0. +#[test] +fn roll_delta_is_zero_when_a_clip_cannot_roll_by_the_asked_amount() { + let mut s = Session::default(); + let [(v0, _), (v1, a1)] = two_clips(&mut s); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let item = s.active_sequence().unwrap().find_item(v0).unwrap().1.item; + match std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).map(|i| &mut i.kind) { + Some(filmcraft_project::ItemKind::Media(m)) => m.info.duration = f(94), + other => panic!("not media: {other:?}"), + } + for asked in [f(1), -f(1)] { + assert_eq!(crate::commands::roll_delta(&s, v0, v1, asked).unwrap(), Tick::ZERO, "{asked:?}"); + let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "delta": asked.0})).unwrap(); + assert_eq!(r["delta"], json!(0), "{r}"); + assert_eq!(range(&s, v1), (f(48), f(96)), "the cut stays where it was"); + assert_eq!(range(&s, a1), (f(48), f(96))); + } + assert_eq!(crate::commands::roll_delta(&s, v0, v1, -f(5)).unwrap(), -f(5), "a roll back into the media applies"); + let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "delta": (-f(5)).0})).unwrap(); + assert_eq!(r["delta"], json!((-f(5)).0), "{r}"); + assert_eq!(range(&s, v1), (f(43), f(96))); + assert_eq!(range(&s, a1), (f(43), f(96))); +} + /// Linked selection off (or unlinked clips) rolls only the given pair and says so. #[test] fn roll_without_linked_selection_leaves_audio() { diff --git a/crates/engine/src/sequence_extras.rs b/crates/engine/src/sequence_extras.rs index 093ce0f29..54449fa0c 100644 --- a/crates/engine/src/sequence_extras.rs +++ b/crates/engine/src/sequence_extras.rs @@ -413,27 +413,8 @@ pub fn simplify_sequence(proj: &filmcraft_project::Project, q: &mut Sequence, o: // close gaps: spans where no track has anything let mut closed = Tick::ZERO; if o.close_gaps { - let mut spans: Vec = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.range())).collect(); - spans.extend(q.caption_tracks.iter().flat_map(|t| t.captions.iter().map(|c| c.range()))); - spans.sort_by_key(|r| r.start); - let mut gaps: Vec = Vec::new(); - let mut cursor = Tick::ZERO; - for r in &spans { - if r.start > cursor { - gaps.push(TimeRange::from_bounds(cursor, r.start)); - } - cursor = cursor.max(r.end()); - } - for g in gaps.iter().rev() { - for t in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) { - edit::shift_track_from(t, g.end(), Tick::ZERO - g.duration); - } - for t in &mut q.caption_tracks { - for c in t.captions.iter_mut().filter(|c| c.start >= g.end()) { - c.start -= g.duration; - } - } - crate::sequence_tools::ripple_markers(&mut q.markers, g.end(), Tick::ZERO - g.duration); + for g in sequence_gaps(q, None).iter().rev() { + close_sequence_gap(q, *g, true); closed += g.duration; } } @@ -456,6 +437,43 @@ pub fn simplify_sequence(proj: &filmcraft_project::Project, q: &mut Sequence, o: json!({"removedClips": removed_clips, "removedEffects": removed_effects, "movedClips": moved, "closedGaps": closed.0, "removedTracks": removed_tracks}) } +/// Spans where no track has anything (clips or captions), in time order: the gaps before, between +/// and inside the material, never the open time after the last clip. With `within`, only the gaps +/// that lie entirely inside it (Close Gap between In and Out). +pub(crate) fn sequence_gaps(q: &Sequence, within: Option) -> Vec { + let mut spans: Vec = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.range())).collect(); + spans.extend(q.caption_tracks.iter().flat_map(|t| t.captions.iter().map(|c| c.range()))); + spans.sort_by_key(|r| r.start); + let mut gaps: Vec = Vec::new(); + let mut cursor = Tick::ZERO; + for r in &spans { + if r.start > cursor { + gaps.push(TimeRange::from_bounds(cursor, r.start)); + } + cursor = cursor.max(r.end()); + } + if let Some(w) = within { + gaps.retain(|g| g.start >= w.start && g.end() <= w.end()); + } + gaps +} + +/// Ripple one of [`sequence_gaps`] closed: everything after it, on every track, moves left by its +/// length (it is empty on all of them, so nothing can collide). Close later gaps first. +pub(crate) fn close_sequence_gap(q: &mut Sequence, g: TimeRange, markers: bool) { + for t in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) { + edit::shift_track_from(t, g.end(), Tick::ZERO - g.duration); + } + for t in &mut q.caption_tracks { + for c in t.captions.iter_mut().filter(|c| c.start >= g.end()) { + c.start -= g.duration; + } + } + if markers { + crate::sequence_tools::ripple_markers(&mut q.markers, g.end(), Tick::ZERO - g.duration); + } +} + fn simplify(s: &mut Session, p: &Value) -> Result { let o = SimplifyOptions::from_params(p)?; let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index 90716d2fc..980e571c1 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -10,6 +10,8 @@ //! | `sequence.showThroughEdits` | Sequence ▸ Show Through Edits | | //! | `sequence.joinThroughEdits` | (clip and edit point context menus) Join Through Edits | | //! | `sequence.throughEdits` | query: the through edits of the active sequence | | +//! | `sequence.showDuplicateFrameMarkers` | (Timeline Display Settings) Show Duplicate Frame Markers | | +//! | `sequence.duplicateFrames` | query: the duplicate frame markers of the active sequence | | //! | `sequence.makeSubsequence` | Sequence ▸ Make Subsequence | Shift+U | //! | `sequence.deleteTracks` | Sequence ▸ Delete Tracks… | | //! | `markers.markSplitVideoIn` … `markers.markSplitAudioOut` | Markers ▸ Mark Split ▸ Video In … Audio Out | | @@ -26,9 +28,12 @@ //! that side. Insert / Overwrite from the Source monitor honour source split points: each channel //! takes its own range and lands offset by the difference of the In points (J- and L-cuts). +use std::collections::{BTreeMap, HashMap, HashSet}; + use filmcraft_edit as edit; -use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, MarkerKind, SplitMarks, TrackId, TrackItem, TrackKind}; -use filmcraft_time::{Tick, TimeRange}; +use filmcraft_media::MediaKind; +use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, MarkerKind, Project, Sequence, SplitMarks, TrackId, TrackItem, TrackKind}; +use filmcraft_time::{FrameRate, Tick, TimeRange}; use serde_json::{Value, json}; use crate::commands::{ @@ -105,6 +110,22 @@ fn groups() -> Vec<(&'static str, Vec)> { s.state.show_through_edits = bool_p(p, "on").unwrap_or(!s.state.show_through_edits); Ok(json!({"showThroughEdits": s.state.show_through_edits})) }), + spec("sequence.showDuplicateFrameMarkers", "Show Duplicate Frame Markers", &[], None, r#"{"on":bool?}"#, always, |s, p| { + s.state.show_duplicate_frame_markers = bool_p(p, "on").unwrap_or(!s.state.show_duplicate_frame_markers); + Ok(json!({"showDuplicateFrameMarkers": s.state.show_duplicate_frame_markers})) + }), + query("sequence.duplicateFrames", "List Duplicate Frames", "{}", |s, _| { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + Ok(Value::Array( + duplicate_frames(&s.project, q) + .iter() + .map(|d| { + let ranges: Vec = d.ranges.iter().map(|r| json!({"start": r.start.0, "end": r.end().0})).collect(); + json!({"clip": d.clip.0, "color": d.color, "ranges": ranges}) + }) + .collect(), + )) + }), spec( "sequence.joinThroughEdits", "Join Through Edits", @@ -376,6 +397,110 @@ fn join_through_edit_at(s: &mut Session, cut: &Value) -> Result { Ok(json!({"joined": n})) } +// ---------- duplicate frames ---------- + +/// How many colors duplicate frame markers use; master clips past the last one share it. +pub const DUPLICATE_FRAME_COLORS: usize = 10; + +/// One clip's duplicate frame markers: where on the timeline it shows media frames that another +/// video clip of the same sequence also shows. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DuplicateFrames { + pub clip: ClipId, + /// The color of the clip's master clip, 0 to [`DUPLICATE_FRAME_COLORS`] - 1: master clips with + /// duplicate frames are numbered in the order they first appear in the sequence. + pub color: usize, + /// Timeline ranges inside the clip, in order and apart from each other. + pub ranges: Vec, +} + +/// The most timeline frames [`duplicate_frames`] compares in one sequence. A clip whose frames +/// would pass that count, in timeline order, is not compared and gets no markers, and later clips +/// that still fit are compared. +pub const MAX_DUPLICATE_FRAME_CHECKS: i64 = 1_000_000; + +/// Timeline Display Settings ▸ Show Duplicate Frame Markers: the video clips of `q` that show a +/// media frame another video clip of `q` also shows. Each frame of the sequence is compared by the +/// media frame the clip shows there ([`TrackItem::source_time_at`] at the frame's start, counted in +/// the media's frame rate). A subclip counts as its master clip. Clips of still images, +/// generators, nested sequences, graphics and adjustment layers, frame holds and clips whose Time +/// Remapping speed has keyframes get no markers and are not compared. +pub fn duplicate_frames(project: &Project, q: &Sequence) -> Vec { + let rate = q.settings.frame_rate; + // every clip that can be compared, with its master clip and the media frame rate, in timeline + // order (track order for clips that start together) + let mut clips: Vec<(u64, FrameRate, &TrackItem)> = + q.video_tracks.iter().flat_map(|tr| &tr.items).filter_map(|it| comparable_master(project, it).map(|(m, r)| (m.0, r, it))).collect(); + clips.sort_by_key(|(_, _, it)| it.start); + // the sequence frames each clip covers, while the limit allows + let mut budget = MAX_DUPLICATE_FRAME_CHECKS; + let spans: Vec> = clips + .iter() + .map(|(_, _, it)| { + let first = rate.frame_at(it.start); + let first = if rate.tick_of(first) < it.start { first.saturating_add(1) } else { first }; + let last = rate.frame_at(it.end() - Tick(1)); + let n = last.saturating_sub(first).saturating_add(1); + (n > 0 && n <= budget).then(|| { + budget -= n; + (first, last) + }) + }) + .collect(); + let shows = |media_rate: FrameRate, it: &TrackItem, f: i64| media_rate.frame_at(it.source_time_at(rate.tick_of(f))); + // (master clip, media frame) → the first clip that shows it, and whether another clip does too + let mut seen: HashMap<(u64, i64), (ClipId, bool)> = HashMap::new(); + for ((m, media_rate, it), span) in clips.iter().zip(&spans) { + let Some((first, last)) = *span else { continue }; + for f in first..=last { + let e = seen.entry((*m, shows(*media_rate, it, f))).or_insert((it.id, false)); + e.1 |= e.0 != it.id; + } + } + // colors go to the master clips with a repeated frame, in the order they first appear + let repeated: HashSet = seen.iter().filter(|(_, e)| e.1).map(|((m, _), _)| *m).collect(); + let mut colors: BTreeMap = BTreeMap::new(); + for (m, ..) in clips.iter().filter(|(m, ..)| repeated.contains(m)) { + let next = colors.len().min(DUPLICATE_FRAME_COLORS - 1); + colors.entry(*m).or_insert(next); + } + let mut out = Vec::new(); + for ((m, media_rate, it), span) in clips.iter().zip(&spans) { + let Some((first, last)) = *span else { continue }; + let mut ranges: Vec = Vec::new(); + let mut run: Option = None; + for f in first..=last.saturating_add(1) { + let repeated = f <= last && seen.get(&(*m, shows(*media_rate, it, f))).is_some_and(|e| e.1); + match (repeated, run) { + (true, None) => run = Some(f), + (false, Some(from)) => { + let (a, b) = (rate.tick_of(from).max(it.start), rate.tick_of(f).min(it.end())); + ranges.push(TimeRange::new(a, b - a)); + run = None; + } + _ => {} + } + } + if !ranges.is_empty() { + out.push(DuplicateFrames { clip: it.id, color: colors.get(m).copied().unwrap_or(0), ranges }); + } + } + out +} + +/// The master clip `it` shows frames of and the master clip's frame rate, when its frames can be +/// compared. +fn comparable_master(project: &Project, it: &TrackItem) -> Option<(ItemId, FrameRate)> { + if it.frame_hold.is_some() || !(it.speed.is_finite() && it.speed.abs() > 0.0) || it.duration <= Tick::ZERO { + return None; + } + if it.effect("time_remap").and_then(|e| e.param("speed")).is_some_and(|p| p.is_animated()) { + return None; + } + let (master, media, _) = project.resolve_media(it.item)?; + (matches!(media.info.kind, MediaKind::Movie | MediaKind::ImageSequence) && media.info.video.is_some()).then(|| (master, media.frame_rate())) +} + // ---------- subsequence ---------- /// Sequence ▸ Make Subsequence: a new sequence (same settings and track layout) holding copies of the diff --git a/crates/engine/src/sequence_tools_tests.rs b/crates/engine/src/sequence_tools_tests.rs index 8f1ae2d4d..2d0eb2dd7 100644 --- a/crates/engine/src/sequence_tools_tests.rs +++ b/crates/engine/src/sequence_tools_tests.rs @@ -142,6 +142,171 @@ fn through_edits_show_and_join() { assert_eq!(s.execute("sequence.throughEdits", json!({})).unwrap().as_array().unwrap().len(), n - 2); } +/// The demo project with a new empty sequence active, and its first two movie items. +fn demo_with_empty_sequence() -> (Session, u64, u64) { + let mut s = demo(); + let mut movies: Vec = s + .project + .items + .values() + .filter(|i| i.as_media().is_some_and(|m| m.info.kind == filmcraft_media::MediaKind::Movie && m.info.video.is_some())) + .map(|i| i.id.0) + .collect(); + movies.sort(); + s.execute("file.newSequence", json!({"name": "Duplicates"})).unwrap(); + (s, movies[0], movies[1]) +} + +/// The video clip on video track `track` (0 = V1) that starts at `frame`. +fn video_clip_at(s: &Session, track: usize, frame: i64) -> u64 { + let start = f(s, frame); + s.active_sequence().unwrap().video_tracks[track].items.iter().find(|c| c.start == start).unwrap().id.0 +} + +fn clip_duration(s: &Session, clip: u64) -> Tick { + s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.duration +} + +/// `sequence.duplicateFrames` as (clip, color, [(start frame, end frame)]). +fn duplicates(s: &mut Session) -> Vec<(u64, u64, Vec<(i64, i64)>)> { + let frame = f(s, 1).0; + let r = s.execute("sequence.duplicateFrames", json!({})).unwrap(); + r.as_array() + .unwrap() + .iter() + .map(|d| { + let ranges = d["ranges"].as_array().unwrap().iter().map(|r| (r["start"].as_i64().unwrap() / frame, r["end"].as_i64().unwrap() / frame)).collect(); + (d["clip"].as_u64().unwrap(), d["color"].as_u64().unwrap(), ranges) + }) + .collect() +} + +#[test] +fn duplicate_frame_markers_cover_the_repeated_frames() { + let (mut s, forest, other) = demo_with_empty_sequence(); + assert_eq!(s.execute("sequence.showDuplicateFrameMarkers", json!({})).unwrap()["showDuplicateFrameMarkers"], json!(true)); + assert_eq!(s.execute("sequence.showDuplicateFrameMarkers", json!({"on": false})).unwrap()["showDuplicateFrameMarkers"], json!(false)); + // media frames 0–48 at 0, media frames 24–72 at 100: frames 24–48 are shown twice + place(&mut s, forest, "V1", 0, 0, 48); + place(&mut s, forest, "V1", 100, 24, 48); + // another master clip, used once + place(&mut s, other, "V2", 0, 0, 48); + // a generator used twice has no markers + let matte = s.execute("file.newColorMatte", json!({"width": 64, "height": 64, "color": "#ff0000"})).unwrap()["item"].as_u64().unwrap(); + s.execute("timeline.place", json!({"item": matte, "track": "V2", "frame": 200, "duration": f(&s, 24).0})).unwrap(); + s.execute("timeline.place", json!({"item": matte, "track": "V2", "frame": 300, "duration": f(&s, 24).0})).unwrap(); + let (a, b) = (video_clip_at(&s, 0, 0), video_clip_at(&s, 0, 100)); + assert_eq!(duplicates(&mut s), [(a, 0, vec![(24, 48)]), (b, 0, vec![(100, 124)])]); + // the second master clip used again (frames 40–60 twice) takes the next color + place(&mut s, other, "V2", 400, 40, 20); + place(&mut s, other, "V3", 400, 40, 20); + let (c, d, e) = (video_clip_at(&s, 1, 0), video_clip_at(&s, 1, 400), video_clip_at(&s, 2, 400)); + assert_eq!( + duplicates(&mut s), + [(a, 0, vec![(24, 48)]), (c, 1, vec![(40, 48)]), (b, 0, vec![(100, 124)]), (d, 1, vec![(400, 420)]), (e, 1, vec![(400, 420)])] + ); + // clips that only touch in the media share no frame + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + place(&mut s, forest, "V3", 500, 72, 24); + assert_eq!(duplicates(&mut s), [(a, 0, vec![(24, 48)]), (b, 0, vec![(100, 124)])]); +} + +/// Only master clips with duplicate frames take a color: a master clip used once ahead of a +/// duplicated one leaves the first color to the duplicated one. +#[test] +fn duplicate_frame_markers_color_only_master_clips_with_duplicates() { + let (mut s, forest, other) = demo_with_empty_sequence(); + place(&mut s, other, "V1", 0, 0, 48); + place(&mut s, forest, "V1", 100, 0, 24); + place(&mut s, forest, "V1", 200, 0, 24); + let (a, b) = (video_clip_at(&s, 0, 100), video_clip_at(&s, 0, 200)); + assert_eq!(duplicates(&mut s), [(a, 0, vec![(100, 124)]), (b, 0, vec![(200, 224)])]); +} + +#[test] +fn duplicate_frame_markers_follow_speed_reverse_and_holds() { + let (mut s, forest, _) = demo_with_empty_sequence(); + place(&mut s, forest, "V1", 0, 0, 48); + // at 200 % from media frame 24, timeline frames 100–112 show media frames 24, 26 … 46 + place(&mut s, forest, "V2", 100, 24, 24); + let (a, b) = (video_clip_at(&s, 0, 0), video_clip_at(&s, 1, 100)); + let set = |s: &mut Session, clip: u64, change: fn(&mut filmcraft_project::TrackItem)| { + s.edit_sequence("test", |q, _, _| { + change(q.find_item_mut(ClipId(clip)).unwrap().1); + Ok(()) + }) + .unwrap(); + }; + set(&mut s, b, |it| it.speed = 2.0); + let every_other: Vec<(i64, i64)> = (24..48).step_by(2).map(|f| (f, f + 1)).collect(); + assert_eq!(duplicates(&mut s), [(a, 0, every_other), (b, 0, vec![(100, 112)])]); + // reversed, 100 %: timeline frames 0–48 show media frames 47 down to 0, so 0–24 shows 24–48 + set(&mut s, b, |it| it.speed = 1.0); + set(&mut s, a, |it| it.reverse = true); + assert_eq!(duplicates(&mut s), [(a, 0, vec![(0, 24)]), (b, 0, vec![(100, 124)])]); + // a frame hold and Time Remapping keyframes take a clip out of the comparison + set(&mut s, a, |it| it.frame_hold = Some(it.source_in)); + assert!(duplicates(&mut s).is_empty()); + set(&mut s, a, |it| it.frame_hold = None); + assert_eq!(duplicates(&mut s).len(), 2); + // a clip longer than the comparison limit is skipped instead of walked frame by frame + let dur = clip_duration(&s, a); + set(&mut s, a, |it| it.duration = Tick(i64::MAX / 4)); + assert!(duplicates(&mut s).is_empty()); + s.edit_sequence("test", |q, _, _| { + q.find_item_mut(ClipId(a)).unwrap().1.duration = dur; + Ok(()) + }) + .unwrap(); + assert_eq!(duplicates(&mut s).len(), 2); + set(&mut s, b, |it| { + let p = it + .effect_mut("time_remap") + .unwrap() + .params + .entry("speed".into()) + .or_insert_with(|| filmcraft_project::Param::new(filmcraft_project::ParamValue::Float(100.0))); + p.keyframes.push(filmcraft_project::Keyframe::new(Tick::ZERO, filmcraft_project::ParamValue::Float(100.0))); + p.keyframes.push(filmcraft_project::Keyframe::new(Tick(1), filmcraft_project::ParamValue::Float(50.0))); + }); + assert!(duplicates(&mut s).is_empty()); + // parameters of the wrong type are ignored: `on` that is not a boolean toggles + s.state.show_duplicate_frame_markers = false; + for p in [json!({"on": "yes"}), json!({"on": [1]}), json!(null), json!(7)] { + let on = s.state.show_duplicate_frame_markers; + assert_eq!(s.execute("sequence.showDuplicateFrameMarkers", p).unwrap()["showDuplicateFrameMarkers"], json!(!on)); + } + assert!(s.execute("sequence.duplicateFrames", json!({"clips": "x", "ranges": -1})).is_ok()); + // without a sequence there is nothing to list + s.state.active_sequence = None; + assert!(s.execute("sequence.duplicateFrames", json!({})).is_err()); +} + +/// A clip at 200 % skips every other media frame: clips are compared by the frames they show, +/// not by the media between their In and Out. +#[test] +fn duplicate_frame_markers_compare_the_frames_a_fast_clip_shows() { + let (mut s, forest, _) = demo_with_empty_sequence(); + assert_eq!(s.active_sequence().unwrap().settings.frame_rate, s.project.item(ItemId(forest)).unwrap().as_media().unwrap().frame_rate()); + // media frames 0, 2, 4 … 46 and 1, 3, 5 … 47: no frame in common + place(&mut s, forest, "V1", 0, 0, 24); + place(&mut s, forest, "V2", 0, 1, 24); + let (even, odd) = (video_clip_at(&s, 0, 0), video_clip_at(&s, 1, 0)); + for c in [even, odd] { + s.edit_sequence("test", |q, _, _| { + q.find_item_mut(ClipId(c)).unwrap().1.speed = 2.0; + Ok(()) + }) + .unwrap(); + } + assert!(duplicates(&mut s).is_empty()); + // media frames 0 and 1 at 100 %: the even clip shows frame 0 at timeline frame 0 + place(&mut s, forest, "V3", 100, 0, 2); + let slow = video_clip_at(&s, 2, 100); + assert_eq!(duplicates(&mut s), [(even, 0, vec![(0, 1)]), (odd, 0, vec![(0, 1)]), (slow, 0, vec![(100, 102)])]); +} + /// `cut: [left, right]` (the timeline's edit point menu, #219) joins exactly that cut and its /// linked partner, even when one of its pieces is also part of another through edit. #[test] @@ -671,3 +836,64 @@ fn move_to_new_track_adds_it_in_one_undo_step() { assert_eq!(*s.active_sequence().unwrap(), before); assert_eq!(s.history.undo.len(), undo); } + +/// Lift and Extract put what they take off the timeline on the clipboard, cut at the In and Out +/// points, from the targeted tracks only, with the sequence markers in the range when Copy Paste +/// Includes Sequence Markers is on (#3019). +#[test] +fn lift_and_extract_put_the_range_on_the_clipboard() { + for cmd in ["sequence.lift", "sequence.extract"] { + let (mut s, item) = fresh(); + place(&mut s, item, "V1", 0, 0, 48); + place(&mut s, item, "V2", 0, 0, 48); + for t in ["V2", "A2"] { + s.execute("timeline.setTargeting", json!({"track": t, "targeted": false})).unwrap(); + } + s.execute("markers.copyPasteIncludesSequenceMarkers", json!({"on": true})).unwrap(); + s.execute("markers.add", json!({"frame": 15, "name": "inside"})).unwrap(); + s.execute("markers.add", json!({"frame": 30, "name": "after"})).unwrap(); + s.execute("markers.markIn", json!({"frame": 12})).unwrap(); + s.execute("markers.markOut", json!({"frame": 23})).unwrap(); + assert_eq!(s.execute(cmd, json!({})).unwrap()["copied"], json!(2), "{cmd}: the V1 and A1 pieces"); + s.execute("playhead.set", json!({"frame": 100})).unwrap(); + s.execute("edit.paste", json!({})).unwrap_or_else(|e| panic!("{cmd} then edit.paste: {e:?}")); + let q = s.active_sequence().unwrap(); + let at = f(&s, 100); + let pasted: Vec<_> = [&q.video_tracks[0], &q.audio_tracks[0]] + .iter() + .map(|t| t.items.iter().find(|i| i.start == at).unwrap_or_else(|| panic!("{cmd}: nothing pasted on {}", t.name))) + .collect(); + for p in &pasted { + assert_eq!((p.duration, p.source_in), (f(&s, 12), f(&s, 12)), "{cmd}: the pasted piece is frames 12-23 of the media"); + } + assert!(pasted[0].link.is_some() && pasted[0].link == pasted[1].link, "{cmd}: the pasted video and audio stay linked"); + for t in [&q.video_tracks[1], &q.audio_tracks[1]] { + assert!(t.items.iter().all(|i| i.start != at), "{cmd}: nothing from the untargeted {} is pasted", t.name); + } + let pasted_markers: Vec<_> = q.markers.iter().filter(|m| m.start >= at).map(|m| (m.start, m.name.as_str())).collect(); + assert_eq!(pasted_markers, vec![(f(&s, 103), "inside")], "{cmd}: only the marker inside the range comes along"); + } +} + +/// Over a range with no clips on the targeted tracks, Lift and Extract copy nothing and Paste still +/// brings back what was copied before (#3019). +#[test] +fn lift_and_extract_over_no_clips_keep_the_clipboard() { + for cmd in ["sequence.lift", "sequence.extract"] { + let (mut s, item) = fresh(); + let clips = place(&mut s, item, "V1", 0, 0, 48); + s.execute("timeline.select", json!({"clips": clips})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + for t in ["V1", "A1"] { + s.execute("timeline.setTargeting", json!({"track": t, "targeted": false})).unwrap(); + } + s.execute("markers.markIn", json!({"frame": 12})).unwrap(); + s.execute("markers.markOut", json!({"frame": 23})).unwrap(); + assert_eq!(s.execute(cmd, json!({})).unwrap()["copied"], json!(0), "{cmd}"); + s.execute("playhead.set", json!({"frame": 100})).unwrap(); + s.execute("edit.paste", json!({})).unwrap_or_else(|e| panic!("{cmd} then edit.paste: {e:?}")); + let q = s.active_sequence().unwrap(); + let pasted = q.video_tracks[0].items.iter().find(|i| i.start == f(&s, 100)).map(|i| i.duration); + assert_eq!(pasted, Some(f(&s, 48)), "{cmd}: the earlier copy is pasted"); + } +} diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 0c7ee9156..3d34b1049 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -109,6 +109,9 @@ pub struct AppearancePrefs { /// "UI Scale": `auto` (default: the scale the operating system reports) or a fixed /// percentage of [`UI_SCALES`] (`100` = one point per physical pixel). pub ui_scale: String, + /// "Animate zoom, scrolling and menus" (#2325): off, the Timeline zooms and scrolls in one + /// step and menus, collapsing sections and tooltips appear without a fade. + pub animations: bool, } impl Default for AppearancePrefs { @@ -122,6 +125,7 @@ impl Default for AppearancePrefs { accessible_contrast: false, highlight_color: DEFAULT_HIGHLIGHT.into(), ui_scale: "auto".into(), + animations: true, } } } @@ -1054,6 +1058,7 @@ static CATEGORIES: &[Category] = &[ b("appearance.accessibleContrast", "Accessible color contrast", true), f("appearance.highlightColor", "Highlight Color", Kind::Color, true), f("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true), + b("appearance.animations", "Animate zoom, scrolling and menus", true), ], }, Category { diff --git a/crates/engine/src/shortcut_presets.rs b/crates/engine/src/shortcut_presets.rs index 7e105e509..19e514d68 100644 --- a/crates/engine/src/shortcut_presets.rs +++ b/crates/engine/src/shortcut_presets.rs @@ -264,6 +264,7 @@ pub const PREMIERE: &[Entry] = &[ ("source.open", "Shift+O", "Project"), ("edit.undo", "Left", "History"), ("edit.redo", "Right", "History"), + ("history.deleteRedoable", "Backspace", "History"), ]; /// Premiere's panel-scoped default keys for the keyboard-only commands (M3.12). FilmCraft Default @@ -289,6 +290,8 @@ pub const PREMIERE_PANEL: &[Entry] = &[ ("timeline.decreaseAudioHeight", "Alt+-", "Timeline"), ("timeline.nextScreen", "PageDown", "Timeline"), ("timeline.prevScreen", "PageUp", "Timeline"), + ("sequence.setWorkAreaIn", "Alt+[", "Timeline"), + ("sequence.setWorkAreaOut", "Alt+]", "Timeline"), ("graphics.nudgeLeft", "Cmd+Left", "Program"), ("graphics.nudgeRight", "Cmd+Right", "Program"), ("graphics.nudgeUp", "Cmd+Up", "Program"), @@ -350,6 +353,8 @@ pub const PREMIERE_PANEL: &[Entry] = &[ ("captions.merge", "Alt+M", "Text"), ("captions.split", "Alt+S", "Text"), ("playback.loop", "Cmd+L", "Audio Track Mixer"), + ("mixer.showHideTracks", "Cmd+Alt+T", "Audio Track Mixer"), + ("mixer.meterInputOnly", "Ctrl+Shift+I", "Audio Track Mixer"), ("playback.toggle", "Space", "Metadata"), ("playback.loop", "Cmd+L", "Metadata"), ("mediaBrowser.openInSource", "Shift+O", "Media Browser"), diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index c6beec578..4aeb6e824 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -288,7 +288,7 @@ fn list_searches_labels_and_keys() { let v = s.execute("shortcuts.list", json!({"query": "cmd+k", "platform": "windows"})).unwrap(); assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "sequence.addEdit"), "{v}"); let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap(); - assert_eq!(v.as_array().unwrap().len(), 2, "{v}"); + assert_eq!(v.as_array().unwrap().len(), 3, "{v}"); } #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fe3e35133..8034a4020 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -349,11 +349,11 @@ fn set_param_writes_a_keyframe_when_asked_or_animated() { let mut first = set(60.0, 0.5); first["keyframe"] = json!(true); let r = s.execute("effects.setParam", first).unwrap(); - assert_eq!(r["keyframes"], 1, "{r}"); + assert_eq!(r, json!({"keyframes": 1, "param": "scale", "value": 60.0})); assert_eq!(scale(&s).0, 1); // animated now, so a plain set at another time is a second keyframe let r = s.execute("effects.setParam", set(120.0, 2.5)).unwrap(); - assert_eq!(r["keyframes"], 2, "{r}"); + assert_eq!(r, json!({"keyframes": 2, "param": "scale", "value": 120.0})); let (n, _, a, b) = scale(&s); assert_eq!((n, a, b), (2, 60.0, 120.0)); // a keyframe that is already there is replaced, not doubled @@ -381,6 +381,38 @@ fn set_param_writes_a_keyframe_when_asked_or_animated() { assert_eq!(s.execute("effects.setParam", odd).unwrap()["keyframes"], 2); } +/// The response reflects the stored point when extra input coordinates are ignored. +#[test] +fn set_param_reports_the_stored_vector_value() { + let mut s = demo(); + let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0; + let result = s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "position", "value": [11.0, 22.0, 33.0]})).unwrap(); + assert_eq!(result, json!({"keyframes": 0, "param": "position", "value": [11.0, 22.0]})); + + let sequence = s.execute("sequence.inspect", json!({})).unwrap(); + let effects = sequence["video"][0]["items"][0]["effects"].as_array().unwrap(); + let motion = effects.iter().find(|e| e["effect"] == "motion").unwrap(); + assert_eq!(motion["params"]["position"]["value"], "Vec2(Vec2 { x: 11.0, y: 22.0 })"); +} + +/// A static edit succeeds without creating keyframes; its response identifies the accepted value. +#[test] +fn set_param_reports_static_value_and_parameter() { + let mut s = demo(); + let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0; + for (effect, param, value, stored, inspected) in + [("opacity", "blend", json!("Screen"), json!(8), "Choice(8)"), ("motion", "scale", json!(80.0), json!(80.0), "Float(80.0)")] + { + let result = s.execute("effects.setParam", json!({"clip": c, "effect": effect, "param": param, "value": value})).unwrap(); + assert_eq!(result, json!({"keyframes": 0, "param": param, "value": stored})); + let sequence = s.execute("sequence.inspect", json!({})).unwrap(); + let effects = sequence["video"][0]["items"][0]["effects"].as_array().unwrap(); + let effect = effects.iter().find(|e| e["effect"] == effect).unwrap(); + assert_eq!(effect["params"][param]["value"], inspected); + assert_eq!(effect["params"][param]["keyframes"], 0); + } +} + /// A keyframe set with a `time` outside the clip stays at that time (#1055), before the start as /// after the end: a ramp that spans a cut shows its true slice, and a clip-end keyframe is not /// overwritten by a later one. Only the playhead fallback is pinned to the clip. @@ -457,7 +489,7 @@ fn set_param_takes_a_choice_by_name() { // a name that is not an option is an error that lists the options and changes nothing let e = s.execute("effects.setParam", set(json!("Glow"))).unwrap_err().to_string(); assert!(e.contains("Screen") && e.contains("Overlay"), "{e}"); - for bad in [json!(""), json!(null), json!([8]), json!({"name": "Screen"}), json!(-1), json!(1.5)] { + for bad in [json!(""), json!(null), json!([8]), json!({"name": "Screen"}), json!(-1), json!(1.5), json!(u64::from(u32::MAX) + 1)] { assert!(s.execute("effects.setParam", set(bad.clone())).is_err(), "{bad}"); } assert_eq!(blend(&s), ParamValue::Choice(12)); @@ -1087,6 +1119,31 @@ fn enable_flips_each_selected_clip() { assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true)); } +/// #3031: with linked selection on, Enable on an Alt-selected audio clip flips only that clip, +/// while a linked selection still flips both sides. +#[test] +fn enable_on_an_individual_selection_leaves_the_linked_partner() { + let mut s = demo(); + assert!(s.state.linked_selection); + let enabled = |s: &Session, c: ClipId| s.active_sequence().unwrap().find_item(c).unwrap().1.enabled; + let video = ClipId(v1(&s)[0].0); + let audio = { + let q = s.active_sequence().unwrap(); + let link = q.find_item(video).unwrap().1.link.unwrap(); + q.all_tracks().flat_map(|t| t.items.iter()).find(|i| i.link == Some(link) && i.id != video).unwrap().id + }; + s.execute("timeline.select", json!({"clips": [audio.0], "linked": false})).unwrap(); + assert_eq!(s.state.selection, vec![audio]); + s.execute("clip.enable", json!({})).unwrap(); + assert_eq!((enabled(&s, video), enabled(&s, audio)), (true, false), "only the selected audio is disabled"); + s.execute("clip.enable", json!({})).unwrap(); + assert_eq!((enabled(&s, video), enabled(&s, audio)), (true, true)); + // a linked selection still flips both sides + s.execute("timeline.select", json!({"clips": [audio.0], "linked": true})).unwrap(); + s.execute("clip.enable", json!({})).unwrap(); + assert_eq!((enabled(&s, video), enabled(&s, audio)), (false, false)); +} + /// #580: Escape takes back what a gesture changed, merged or not: the project returns to the mark, /// neither undo nor redo keeps the changes, and older steps stay. #[test] diff --git a/crates/engine/src/timeline_move_tests.rs b/crates/engine/src/timeline_move_tests.rs index f6aa640ff..846c5a19a 100644 --- a/crates/engine/src/timeline_move_tests.rs +++ b/crates/engine/src/timeline_move_tests.rs @@ -36,6 +36,320 @@ fn pair_in_an_empty_sequence(s: &mut Session, seconds: f64) -> (TrackId, TrackIt (q.video_tracks[0].id, v, q.audio_tracks[0].id, a) } +fn assert_selection_edit_matches_control(s: &mut Session, selection: Vec, command: &str, params: serde_json::Value) { + let before = s.project.clone(); + let undo = s.history.undo.len(); + let mut control = Session { project: s.project.clone(), state: s.state.clone(), history: s.history.clone(), media: s.media.clone(), ..Session::default() }; + control.state.selection = selection; + let expected = control.execute(command, params.clone()).unwrap(); + let actual = s.execute(command, params).unwrap(); + assert_eq!(actual, expected, "{command} returns the unique-selection result"); + assert_eq!(s.project, control.project, "{command} produces the complete unique-selection project"); + assert_eq!(s.history.undo.len(), undo + 1, "the edit is one undo step"); + assert_eq!(s.history.undo, control.history.undo); + assert_eq!(s.history.redo, control.history.redo); + assert_eq!(s.history.merge_key, control.history.merge_key); + let q = s.active_sequence().unwrap(); + let ids: Vec = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect(); + assert_eq!(ids.iter().copied().collect::>().len(), ids.len(), "no identity was duplicated"); + q.check().unwrap(); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + control.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before, "undo restores the entire pre-edit project"); + assert_eq!(s.project, control.project); + assert_eq!(s.history.undo, control.history.undo); + assert_eq!(s.history.redo, control.history.redo); + s.active_sequence().unwrap().check().unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + control.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, after, "redo restores the entire edited project"); + assert_eq!(s.project, control.project); + assert_eq!(s.history.undo, control.history.undo); + assert_eq!(s.history.redo, control.history.redo); + s.active_sequence().unwrap().check().unwrap(); +} + +#[test] +fn duplicate_selection_inputs_keep_first_occurrence_and_toggle_once() { + for linked in [false, true] { + let mut s = demo(); + let (_, v, _, a) = pair_in_an_empty_sequence(&mut s, 0.0); + s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap(); + let before = s.project.clone(); + let history = s.history.clone(); + let ids = json!([a.id.0, a.id.0, v.id.0, a.id.0, v.id.0]); + let result = s.execute("timeline.select", json!({"clips": ids})).unwrap(); + assert_eq!(s.state.selection, vec![a.id, v.id], "explicit order precedes linked expansion"); + assert_eq!(result["selection"], json!([a.id.0, v.id.0])); + s.execute("timeline.select", json!({"clips": [v.id.0, v.id.0], "add": true})).unwrap(); + assert_eq!(s.state.selection, vec![a.id, v.id], "adding an identity twice does not reorder the selection"); + s.execute("timeline.select", json!({"clips": ids, "toggle": true})).unwrap(); + assert!(s.state.selection.is_empty(), "each selected identity toggles off once"); + s.execute("timeline.select", json!({"clips": ids, "toggle": true})).unwrap(); + assert_eq!(s.state.selection, vec![a.id, v.id], "each unselected identity toggles on once"); + s.execute("timeline.select", json!({"clips": []})).unwrap(); + s.execute("timeline.select", json!({"clips": [v.id.0, v.id.0], "add": true})).unwrap(); + assert_eq!(s.state.selection, if linked { vec![v.id, a.id] } else { vec![v.id] }); + assert_eq!(s.project, before, "selection admission does not edit the project"); + assert_eq!(s.history.undo, history.undo); + assert_eq!(s.history.redo, history.redo); + s.active_sequence().unwrap().check().unwrap(); + } +} + +#[test] +fn duplicate_selection_expansion_is_unique_without_an_active_sequence() { + let clips = [ClipId(9), ClipId(3), ClipId(9), ClipId(3), ClipId(5)]; + for linked in [false, true] { + let mut s = Session::default(); + s.state.linked_selection = linked; + for active in [None, Some(filmcraft_project::ItemId(u64::MAX))] { + s.state.active_sequence = active; + let before = serde_json::to_value(&s.state).unwrap(); + assert_eq!(crate::commands::with_links(&s, &clips), vec![ClipId(9), ClipId(3), ClipId(5)]); + assert_eq!(serde_json::to_value(&s.state).unwrap(), before); + } + } +} + +#[test] +fn duplicate_selection_public_admission_keeps_nudges_equivalent_to_unique_selection() { + for (command, linked) in [("timeline.nudgeRight", false), ("timeline.nudgeRight", true), ("timeline.nudgeUp", false)] { + let mut s = demo(); + let (_, v, _, a) = pair_in_an_empty_sequence(&mut s, 60.0); + s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap(); + s.execute("timeline.select", json!({"clips": [v.id.0, v.id.0]})).unwrap(); + let selection = if linked { vec![v.id, a.id] } else { vec![v.id] }; + assert_eq!(s.state.selection, selection); + assert_selection_edit_matches_control(&mut s, selection.clone(), command, json!({})); + assert_eq!(s.state.selection, selection); + } +} + +#[test] +fn duplicate_selection_already_in_session_keeps_nudges_equivalent_to_unique_selection() { + for linked in [false, true] { + for command in ["timeline.nudgeRight", "timeline.nudgeUp"] { + let mut s = demo(); + let (_, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 60.0); + s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap(); + s.state.selection = vec![v.id, v.id, v.id]; + assert_selection_edit_matches_control(&mut s, vec![v.id], command, json!({})); + let q = s.active_sequence().unwrap(); + if command == "timeline.nudgeUp" { + assert_eq!(state(&s, v.id), (q.video_tracks[1].id, v.start)); + assert_eq!(state(&s, a.id), (a_track, a.start), "vertical nudges remain selected-only even with linked selection on"); + } else { + assert_eq!(state(&s, v.id).1, v.start + s.sequence_rate().tick_of(1)); + assert_eq!(state(&s, a.id).1, a.start + if linked { s.sequence_rate().tick_of(1) } else { Tick::ZERO }); + } + } + } +} + +#[test] +fn duplicate_selection_delete_matches_unique_selection_with_and_without_links() { + for linked in [false, true] { + for command in ["edit.clear", "edit.rippleDelete"] { + let mut s = demo(); + let (_, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 60.0); + s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap(); + if !linked { + s.execute("timeline.setTrack", json!({"track": a_track.0, "syncLock": false})).unwrap(); + } + // Clear and Ripple Delete act on the selection as it is: selecting the clip takes its + // partner along only with Linked Selection on + s.execute("timeline.select", json!({"clips": [v.id.0]})).unwrap(); + let unique = s.state.selection.clone(); + s.state.selection = unique.iter().chain(&unique).copied().collect(); + assert_selection_edit_matches_control(&mut s, unique, command, json!({})); + assert!(s.active_sequence().unwrap().find_item(v.id).is_none()); + assert_eq!(s.active_sequence().unwrap().find_item(a.id).is_none(), linked); + assert!(s.state.selection.is_empty()); + } + } +} + +#[test] +fn duplicate_move_targets_are_rejected_and_retry_is_undoable() { + for insert in [false, true] { + let mut s = demo(); + let (_, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0); + let before = s.project.clone(); + let undo = s.history.undo.len(); + let at = s.sequence_rate().tick_of(48).0; + let result = s.execute( + "timeline.move", + json!({"moves": [{"clip": v.id.0, "track": "V1", "time": at}, {"clip": v.id.0, "track": "V2", "time": at}], "insert": insert, "linked": false}), + ); + assert!(result.is_err(), "duplicate move entries were accepted: {result:?}"); + assert_eq!(s.project, before); + assert_eq!(s.history.undo.len(), undo); + s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": at}], "insert": insert, "linked": false})).unwrap(); + let after = s.project.clone(); + assert_eq!(state(&s, v.id).1.0, at); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, after); + } +} + +fn reject_duplicate_new_track_move(s: &mut Session, clip: ClipId, insert: bool, linked: bool, new_first: bool) { + s.state.selection = vec![clip]; + let before = s.project.clone(); + let selection = s.state.selection.clone(); + let editor_state = serde_json::to_value(&s.state).unwrap(); + let history = s.history.clone(); + let journal = s.journal.clone(); + let at = s.sequence_rate().tick_of(48).0; + let tracks = if new_first { ["new", "V2"] } else { ["V2", "new"] }; + let result = s.execute( + "timeline.move", + json!({"moves": [{"clip": clip.0, "track": tracks[0], "time": at}, {"clip": clip.0, "track": tracks[1], "time": at}], "insert": insert, "linked": linked}), + ); + assert!(matches!(result, Err(EngineError::Edit(filmcraft_edit::EditError::Other(_)))), "duplicate move entries must be rejected: {result:?}"); + assert_eq!(s.project, before, "the project and its id allocator stay unchanged"); + assert_eq!(s.state.selection, selection); + assert_eq!(serde_json::to_value(&s.state).unwrap(), editor_state); + assert_eq!(s.history.undo.len(), history.undo.len(), "no oldest undo entry is evicted"); + assert_eq!(s.history.redo.len(), history.redo.len(), "no existing redo entry is lost"); + assert_eq!(s.history.undo, history.undo); + assert_eq!(s.history.redo, history.redo); + assert_eq!(s.history.merge_key, history.merge_key); + assert_eq!(s.history.limit, history.limit); + assert_eq!(s.journal, journal, "the failed command is not journaled"); +} + +#[test] +fn duplicate_new_track_move_preserves_redo_and_retry_is_undoable() { + for insert in [false, true] { + for linked in [false, true] { + for new_first in [false, true] { + let mut s = demo(); + let (_, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0); + s.execute("timeline.setTrack", json!({"track": "V2", "locked": true})).unwrap(); + let redo_project = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!s.history.redo.is_empty()); + reject_duplicate_new_track_move(&mut s, v.id, insert, linked, new_first); + let before = s.project.clone(); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, redo_project, "the previously available redo still works"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before); + let at = s.sequence_rate().tick_of(48).0; + s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "new", "time": at}], "insert": insert, "linked": linked})).unwrap(); + let after = s.project.clone(); + assert_eq!(state(&s, v.id).1.0, at); + assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 3); + s.active_sequence().unwrap().check().unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before, "one undo removes the new track and restores the move"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, after); + } + } + } +} + +#[test] +fn duplicate_new_track_move_preserves_full_undo_history() { + for insert in [false, true] { + for linked in [false, true] { + for new_first in [false, true] { + let mut s = demo(); + let (_, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0); + assert_eq!(s.history.limit, 200); + while s.history.undo.len() < s.history.limit { + let step = s.history.undo.len(); + s.edit_merged("History step", &format!("history-{step}"), |project, _| { + project.root.name = format!("History {step}"); + Ok(()) + }) + .unwrap(); + } + assert_eq!(s.history.undo.len(), s.history.limit); + assert!(s.history.merge_key.is_some()); + reject_duplicate_new_track_move(&mut s, v.id, insert, linked, new_first); + let oldest = s.history.undo.first().unwrap().1.clone(); + for _ in 0..s.history.limit { + s.execute("edit.undo", json!({})).unwrap(); + } + assert_eq!(s.project, oldest, "every retained undo step remains usable"); + } + } + } +} + +#[test] +fn unique_new_track_moves_keep_linked_partner_behavior() { + for insert in [false, true] { + for linked in [false, true] { + let mut s = demo(); + let (_, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 0.0); + let before = s.project.clone(); + let undo = s.history.undo.len(); + let to = s.sequence_rate().tick_of(48); + let result = + s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "new", "time": to.0}], "insert": insert, "linked": linked})).unwrap(); + let sequence = s.active_sequence().unwrap(); + assert_eq!(sequence.video_tracks.len(), 3); + assert_eq!(state(&s, v.id), (sequence.video_tracks[2].id, to)); + assert_eq!(state(&s, a.id), (a_track, if linked { to } else { a.start })); + assert_eq!(result["moved"], if linked { json!([v.id.0, a.id.0]) } else { json!([v.id.0]) }); + assert_eq!(s.history.undo.len(), undo + 1); + sequence.check().unwrap(); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, after); + } + } +} + +#[test] +fn repeated_new_track_copy_destinations_get_distinct_ids() { + for insert in [false, true] { + for linked in [false, true] { + for new_first in [false, true] { + let mut s = demo(); + let (v_track, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 0.0); + let before = s.project.clone(); + let undo = s.history.undo.len(); + let to = s.active_sequence().unwrap().duration() + s.sequence_rate().tick_of(48); + let tracks = if new_first { ["new", "V2"] } else { ["V2", "new"] }; + let result = s + .execute( + "timeline.move", + json!({"moves": [{"clip": v.id.0, "track": tracks[0], "time": to.0}, {"clip": v.id.0, "track": tracks[1], "time": to.0}], "insert": insert, "linked": linked, "copy": true}), + ) + .unwrap(); + let copies: Vec = result["copied"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), if linked { 3 } else { 2 }); + assert_eq!(copies.iter().copied().collect::>().len(), copies.len()); + assert!(!copies.contains(&v.id) && !copies.contains(&a.id)); + assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, v.start), (a_track, a.start))); + assert_eq!(s.state.selection, copies); + let sequence = s.active_sequence().unwrap(); + assert_eq!(sequence.video_tracks.len(), 3); + assert!(copies.iter().all(|c| sequence.find_item(*c).is_some()), "every reported copy exists"); + assert!(copies.iter().any(|c| state(&s, *c) == (sequence.video_tracks[2].id, to))); + assert!(copies.iter().any(|c| state(&s, *c) == (sequence.video_tracks[1].id, to))); + assert_eq!(s.history.undo.len(), undo + 1); + sequence.check().unwrap(); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, after); + } + } + } +} + /// `timeline.move` of one clip left its linked sound behind although Linked Selection was on. #[test] fn move_takes_linked_partners_along() { diff --git a/crates/engine/src/track_params_tests.rs b/crates/engine/src/track_params_tests.rs index 6c243dfa5..707ad38cc 100644 --- a/crates/engine/src/track_params_tests.rs +++ b/crates/engine/src/track_params_tests.rs @@ -114,3 +114,38 @@ fn place_on_an_audio_track_places_the_sound_there_and_nothing_else() { s.execute("timeline.place", json!({"item": with_picture.0, "seconds": 600.0})).unwrap(); assert_eq!(counts(&s), [2, 1, 2, 2]); } + +/// Track colour labels (#1186): set, shown by `sequence.inspect`, saved with the project, +/// cleared with "" / null, one undo step each; unknown names are refused. +#[test] +fn a_track_carries_a_colour_label() { + let mut s = demo(); + let label = |s: &mut Session, kind: &str| s.execute("sequence.inspect", json!({})).unwrap()[kind][0]["label"].clone(); + assert_eq!(label(&mut s, "video"), json!(null)); + s.execute("timeline.setTrack", json!({"track": "V1", "label": "Forest"})).unwrap(); + s.execute("timeline.setTrack", json!({"track": "A1", "label": "Mango"})).unwrap(); + assert_eq!(label(&mut s, "video"), json!("Forest")); + assert_eq!(label(&mut s, "audio"), json!("Mango")); + // saved with the project, and a project from before the field still loads + let text = serde_json::to_string(&*s.project).unwrap(); + let back: filmcraft_project::Project = serde_json::from_str(&text).unwrap(); + let q = back.sequence(s.state.active_sequence.unwrap()).unwrap(); + assert_eq!(q.video_tracks[0].label, Some(filmcraft_project::Label::Forest)); + let mut old = serde_json::to_value(&q.video_tracks[0]).unwrap(); + old.as_object_mut().unwrap().remove("label"); + let old: filmcraft_project::Track = serde_json::from_value(old).unwrap(); + assert_eq!(old.label, None); + // a plain track does not write the key at all + assert!(serde_json::to_value(&q.video_tracks[1]).unwrap().get("label").is_none()); + // clear, undo, refuse + s.execute("timeline.setTrack", json!({"track": "V1", "label": ""})).unwrap(); + assert_eq!(label(&mut s, "video"), json!(null)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(label(&mut s, "video"), json!("Forest")); + s.execute("timeline.setTrack", json!({"track": "V1", "label": null})).unwrap(); + assert_eq!(label(&mut s, "video"), json!(null)); + assert!(matches!(s.execute("timeline.setTrack", json!({"track": "V1", "label": "Chartreuse"})), Err(EngineError::BadParams { .. }))); + // other settings leave the label alone + s.execute("timeline.setTrack", json!({"track": "A1", "muted": true})).unwrap(); + assert_eq!(label(&mut s, "audio"), json!("Mango")); +} diff --git a/crates/engine/src/trim.rs b/crates/engine/src/trim.rs index f9bb63ec3..86ef185f6 100644 --- a/crates/engine/src/trim.rs +++ b/crates/engine/src/trim.rs @@ -3,7 +3,10 @@ //! An edit point is one edge of a clip plus a trim kind. Trim and Ripple act on that edge; Roll acts //! on the edge shared with the adjacent clip on the same track. Trimming delegates to the //! `timeline.trim` / `timeline.roll` commands so linked partners and sync locks behave the same as -//! with the mouse. +//! with the mouse. Linked Selection adds unlocked partner edges to both explicit and nearest-edge +//! selections. Shift-click toggles the linked group. A roll follows only linked cuts at the same +//! time, preserving split edits; regular/ripple trims follow the corresponding In or Out edges. +//! Each linked group is clamped and applied once, including when both partners were selected. //! //! **Trim Monitor** ([`monitor_info`]): while edit points are selected the Program monitor shows the //! outgoing clip's last frame and the incoming clip's first frame, with the Out/In shift counters @@ -87,6 +90,29 @@ fn kind_p(p: &Value) -> Option { serde_json::from_value(p.get("kind")?.clone()).ok() } +/// The edges that the timeline command will edit together. Rolls follow only matching linked +/// cuts; regular/ripple trims follow the same edge of linked clips, including split edits. +fn linked_points(s: &Session, ep: EditPoint) -> Vec { + let Some(seq) = s.active_sequence() else { return vec![ep] }; + let clips = if ep.kind == TrimKind::Roll { + let Some((left, right)) = roll_pair(seq, &ep) else { return vec![ep] }; + crate::commands::linked_roll_pairs(seq, left, right, s.state.linked_selection).into_iter().map(|(l, r)| if ep.out { l } else { r }).collect() + } else { + crate::commands::with_links(s, &[ep.clip]) + }; + clips + .into_iter() + .filter(|c| *c == ep.clip || seq.find_item(*c).is_some_and(|(t, _)| seq.track(t).is_some_and(|tr| !tr.locked))) + .map(|clip| EditPoint { clip, ..ep }) + .collect() +} + +/// Either side of a rolling cut denotes the same edit, even when selected separately. +fn same_edge(seq: &Sequence, a: &EditPoint, b: &EditPoint) -> bool { + (a.clip == b.clip && a.out == b.out) + || (a.kind == TrimKind::Roll && b.kind == TrimKind::Roll && roll_pair(seq, a).is_some_and(|p| Some(p) == roll_pair(seq, b))) +} + /// `trim.selectEditPoint {clip, edge: "in"|"out", kind, add?}` pub fn select(s: &mut Session, p: &Value) -> Result { let clip = p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| EngineError::Other("need `clip`".into()))?; @@ -94,14 +120,17 @@ pub fn select(s: &mut Session, p: &Value) -> Result { let kind = kind_p(p).unwrap_or(TrimKind::Trim); s.active_sequence().and_then(|q| q.find_item(clip)).ok_or_else(|| EngineError::Other(format!("no clip {}", clip.0)))?; let ep = EditPoint { clip, out, kind }; + let points = linked_points(s, ep); if p.get("add").and_then(Value::as_bool).unwrap_or(false) { - if let Some(i) = s.state.edit_points.iter().position(|e| e.clip == clip && e.out == out) { - s.state.edit_points.remove(i); - } else { - s.state.edit_points.push(ep); + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let remove = s.state.edit_points.iter().any(|e| same_edge(seq, e, &ep)); + let mut selected: Vec<_> = s.state.edit_points.iter().copied().filter(|e| !points.iter().any(|p| same_edge(seq, e, p))).collect(); + if !remove { + selected.extend(points); } + s.state.edit_points = selected; } else { - s.state.edit_points = vec![ep]; + s.state.edit_points = points; } s.state.selection.clear(); s.state.gap_selection = None; @@ -125,7 +154,7 @@ pub fn select_nearest(s: &mut Session, p: &Value) -> Result { let targeted = s.state.active_sequence.and_then(|id| s.state.targeting.get(&id)).map(|t| t.targeted.clone()).unwrap_or_default(); let mut points = Vec::new(); for tr in seq.video_tracks.iter().chain(seq.audio_tracks.iter()) { - if !targeted.is_empty() && !targeted.contains(&tr.id) { + if tr.locked || (!targeted.is_empty() && !targeted.contains(&tr.id)) { continue; } // nearest edge on this track @@ -147,11 +176,11 @@ pub fn select_nearest(s: &mut Session, p: &Value) -> Result { } } if let Some((_, ep)) = best { - // one roll point per edge: skip the partner's mirror of an edge already chosen - if kind == TrimKind::Roll && points.iter().any(|e: &EditPoint| roll_pair(seq, e) == roll_pair(seq, &ep)) { - continue; + for point in linked_points(s, ep) { + if !points.iter().any(|e| same_edge(seq, e, &point)) { + points.push(point); + } } - points.push(ep); } } s.state.edit_points = points; @@ -204,19 +233,27 @@ fn trim_all_raw(s: &mut Session, delta_for: &dyn Fn(&Sequence, &EditPoint) -> Op } let pts = s.state.edit_points.clone(); let mut applied = Vec::new(); + let mut edited = Vec::new(); for ep in &pts { let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + if edited.iter().any(|e| same_edge(seq, e, ep)) { + continue; + } let Some(delta) = delta_for(seq, ep) else { continue }; + // Each timeline command clamps and edits its linked group together. Do not dispatch it + // again for the group's other selected edges (which would also ripple later clips twice). + let group = linked_points(s, *ep); let r = match ep.kind { TrimKind::Roll => { let Some((l, r)) = roll_pair(seq, ep) else { continue }; - s.execute("timeline.roll", json!({"left": l.0, "right": r.0, "delta": delta.0, "followLinks": false}))? + s.execute("timeline.roll", json!({"left": l.0, "right": r.0, "delta": delta.0}))? } k => s.execute( "timeline.trim", json!({"clip": ep.clip.0, "edge": if ep.out { "out" } else { "in" }, "mode": if k == TrimKind::Ripple { "ripple" } else { "regular" }, "delta": delta.0}), )?, }; + edited.extend(group); applied.push(r.get("delta").cloned().unwrap_or(Value::Null)); } Ok(applied) diff --git a/crates/engine/src/trim_tests.rs b/crates/engine/src/trim_tests.rs index cb3d32ccd..1ae2dc005 100644 --- a/crates/engine/src/trim_tests.rs +++ b/crates/engine/src/trim_tests.rs @@ -3,6 +3,185 @@ use super::*; use serde_json::json; +/// Two adjacent linked A/V clips with handles on both sides of their cut. +fn linked_cut() -> (Session, [(ClipId, ClipId); 2]) { + let mut s = demo(); + s.execute("file.newSequence", json!({"name": "Linked trim", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).unwrap().id; + for n in 0..2 { + s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + let q = s.active_sequence().unwrap(); + let clips = [0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id)); + (s, clips) +} + +/// #3085: selecting either side of a picture cut also selects its linked sound edge. +#[test] +fn linked_edit_point_selection_and_shift_toggle() { + for kind in ["trim", "ripple", "roll"] { + for edge in ["in", "out"] { + let (mut s, clips) = linked_cut(); + let (video, audio) = clips[usize::from(edge == "in")]; + let p = json!({"clip": video.0, "edge": edge, "kind": kind}); + s.execute("trim.selectEditPoint", p.clone()).unwrap(); + assert_eq!(s.state.edit_points.iter().map(|e| e.clip).collect::>(), vec![video, audio], "{kind} {edge}"); + s.execute("trim.selectEditPoint", json!({"clip": audio.0, "edge": edge, "kind": kind, "add": true})).unwrap(); + assert!(s.state.edit_points.is_empty(), "Shift-click removes the linked group"); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + s.execute("trim.selectEditPoint", p).unwrap(); + assert_eq!(s.state.edit_points.len(), 1); + } + } +} + +/// #3085: Trim Monitor rolls picture and sound once, with one undo/redo step. +#[test] +fn trim_mode_roll_moves_linked_audio_once() { + for edge in ["in", "out"] { + let (mut s, clips) = linked_cut(); + let selected = clips[usize::from(edge == "in")].0; + s.execute("trim.selectEditPoint", json!({"clip": selected.0, "edge": edge, "kind": "roll"})).unwrap(); + let before = s.project.clone(); + let undo = s.history.undo.len(); + s.execute("trim.nudge", json!({"frames": 3})).unwrap(); + let q = s.active_sequence().unwrap(); + for clip in [clips[0].0, clips[0].1] { + assert_eq!(q.find_item(clip).unwrap().1.end(), s.sequence_rate().tick_of(51)); + } + for clip in [clips[1].0, clips[1].1] { + assert_eq!(q.find_item(clip).unwrap().1.start, s.sequence_rate().tick_of(51)); + } + assert_eq!(s.history.undo.len(), undo + 1); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(*s.project, *after); + } +} + +/// Explicitly selected partners must not run the same linked trim twice, including ripple shifts. +#[test] +fn selected_linked_regular_and_ripple_edges_move_once() { + for kind in ["trim", "ripple"] { + for out in [false, true] { + let (mut s, clips) = linked_cut(); + let pair = clips[usize::from(!out)]; + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + for (n, clip) in [pair.0, pair.1].into_iter().enumerate() { + s.execute("trim.selectEditPoint", json!({"clip": clip.0, "edge": if out { "out" } else { "in" }, "kind": kind, "add": n != 0})).unwrap(); + } + s.execute("sequence.linkedSelection", json!({"on": true})).unwrap(); + let before = s.project.clone(); + s.execute("trim.nudge", json!({"frames": if out { -3 } else { 3 }})).unwrap(); + let q = s.active_sequence().unwrap(); + let f = |n| s.sequence_rate().tick_of(n); + for clip in [pair.0, pair.1] { + let it = q.find_item(clip).unwrap().1; + let expected = if out { + (0, 45) + } else if kind == "ripple" { + (48, 93) + } else { + (51, 96) + }; + assert_eq!((it.start, it.end()), (f(expected.0), f(expected.1)), "{kind} out={out}"); + } + if out { + for clip in [clips[1].0, clips[1].1] { + assert_eq!(q.find_item(clip).unwrap().1.start, f(if kind == "ripple" { 45 } else { 48 }), "{kind}"); + } + } + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(*s.project, *after); + } + } +} + +/// Nearest-edge selection follows links even when only the video track is targeted, without +/// duplicating the sound when it is targeted too. Dynamic trimming uses the same grouped edit. +#[test] +fn nearest_linked_roll_and_dynamic_trim() { + let (mut s, clips) = linked_cut(); + let rate = s.sequence_rate(); + s.execute("timeline.setTargeting", json!({"track": "A1", "targeted": false})).unwrap(); + s.execute("playhead.set", json!({"frame": 48})).unwrap(); + s.execute("trim.selectNearest", json!({"kind": "roll"})).unwrap(); + assert_eq!(s.state.edit_points.len(), 2); + assert!(s.state.edit_points.iter().any(|e| e.clip == clips[0].1)); + s.execute("timeline.setTargeting", json!({"track": "A1", "targeted": true})).unwrap(); + s.execute("trim.selectNearest", json!({"kind": "roll"})).unwrap(); + assert_eq!(s.state.edit_points.len(), 2); + let before = s.project.clone(); + s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.0})).unwrap(); + s.execute("trim.shuttleStop", json!({"clock": 0.25})).unwrap(); + let expected = rate.tick_of(48 + frames(&s, 0.25)); + for clip in [clips[0].0, clips[0].1] { + assert_eq!(s.active_sequence().unwrap().find_item(clip).unwrap().1.end(), expected); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); +} + +/// The shorter audio handle limits the picture too; separately applying the two cuts desyncs them. +#[test] +fn linked_trim_roll_uses_the_shortest_handle() { + let (mut s, clips) = linked_cut(); + let rate = s.sequence_rate(); + s.edit_sequence("Short audio handle", |q, _, _| { + q.find_item_mut(clips[1].1).unwrap().1.source_in = rate.tick_of(2); + Ok(()) + }) + .unwrap(); + s.execute("trim.selectEditPoint", json!({"clip": clips[0].0.0, "edge": "out", "kind": "roll"})).unwrap(); + s.execute("trim.nudge", json!({"frames": -10})).unwrap(); + for clip in [clips[0].0, clips[0].1] { + assert_eq!(s.active_sequence().unwrap().find_item(clip).unwrap().1.end(), rate.tick_of(46)); + } +} + +/// Locked partners are neither selected nor changed; directly trimming a locked primary fails. +#[test] +fn linked_trim_respects_locked_tracks() { + for kind in ["trim", "ripple", "roll"] { + let (mut s, clips) = linked_cut(); + s.execute("timeline.setTrack", json!({"track": "A1", "locked": true})).unwrap(); + s.execute("trim.selectEditPoint", json!({"clip": clips[0].0.0, "edge": "out", "kind": kind})).unwrap(); + assert_eq!(s.state.edit_points.len(), 1); + let audio = s.active_sequence().unwrap().audio_tracks[0].clone(); + s.execute("trim.nudge", json!({"frames": -3})).unwrap(); + assert_eq!(s.active_sequence().unwrap().audio_tracks[0], audio); + s.execute("trim.selectEditPoint", json!({"clip": clips[0].1.0, "edge": "out", "kind": kind})).unwrap(); + let before = s.project.clone(); + assert!(s.execute("trim.nudge", json!({"frames": -3})).is_err()); + assert_eq!(*s.project, *before); + } +} + +/// A split audio cut stays separate from a picture roll; linked selection off also edits one cut. +#[test] +fn split_or_disabled_linked_roll_leaves_audio_alone() { + for split in [false, true] { + let (mut s, clips) = linked_cut(); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + if split { + s.execute("timeline.roll", json!({"left": clips[0].1.0, "right": clips[1].1.0, "deltaFrames": -4})).unwrap(); + s.execute("sequence.linkedSelection", json!({"on": true})).unwrap(); + } + let audio = s.active_sequence().unwrap().audio_tracks[0].clone(); + s.execute("trim.selectEditPoint", json!({"clip": clips[0].0.0, "edge": "out", "kind": "roll"})).unwrap(); + assert_eq!(s.state.edit_points.len(), 1); + s.execute("trim.nudge", json!({"frames": 3})).unwrap(); + assert_eq!(s.active_sequence().unwrap().audio_tracks[0], audio); + assert_eq!(s.active_sequence().unwrap().find_item(clips[0].0).unwrap().1.end(), s.sequence_rate().tick_of(51)); + } +} + fn demo() -> Session { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); @@ -206,9 +385,10 @@ fn trim_monitor_sides_and_shift_counters() { assert_eq!(s.execute("trim.monitor", json!({})).unwrap()["inShift"], json!(0)); // Apply Default Transitions to Selection at the edit point let r = s.execute("trim.applyDefaultTransition", json!({})).unwrap(); - assert_eq!(r["transitions"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!(r["transitions"].as_array().unwrap().len(), 2, "picture and linked sound: {r}"); let seq = s.execute("sequence.inspect", json!({})).unwrap(); assert!(!seq["video"][0]["transitions"].as_array().unwrap().is_empty()); + assert!(!seq["audio"][0]["transitions"].as_array().unwrap().is_empty()); } #[test] diff --git a/crates/engine/src/vfx_tests.rs b/crates/engine/src/vfx_tests.rs index f1aeb5987..aa489e24c 100644 --- a/crates/engine/src/vfx_tests.rs +++ b/crates/engine/src/vfx_tests.rs @@ -96,6 +96,41 @@ fn every_video_effect_applies_by_id_and_name_and_renders() { } } +/// #1545: `effects.apply` reports each instance it added (clip, index, effect id), like +/// `timeline.place` reports its clips, so agents need no `sequence_inspect` round trip. +#[test] +fn effects_apply_reports_where_each_instance_landed() { + let mut s = demo(); + let clip = first_v1_clip(&s); + let r = s.execute("effects.apply", json!({"clips": [clip], "effect": "gaussian_blur"})).unwrap(); + let applied = r["applied"].as_array().unwrap(); + assert_eq!(applied.len(), 1); + assert_eq!(applied[0]["clip"].as_u64(), Some(clip)); + assert_eq!(applied[0]["effect"], "gaussian_blur"); + let idx = applied[0]["index"].as_u64().unwrap() as usize; + let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.clone(); + assert_eq!(it.effects.get(idx).map(|e| e.effect.as_str()), Some("gaussian_blur"), "index addresses the new instance"); + // the index is usable straight away + s.execute("effects.remove", json!({"clip": clip, "index": idx})).unwrap(); + assert!(s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.effects.iter().all(|e| e.effect != "gaussian_blur")); + + // several clips: one entry per instance, each with its own clip id + let clips: Vec = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.id.0).collect(); + assert!(clips.len() >= 2, "demo project has several V1 clips"); + let r = s.execute("effects.apply", json!({"clips": clips, "effect": "invert"})).unwrap(); + let got: Vec = r["applied"].as_array().unwrap().iter().map(|a| a["clip"].as_u64().unwrap()).collect(); + assert_eq!(got, clips); + + // audio effects report the (retargeted) audio clip, not the video clip that was passed + let video = first_v1_clip(&s); + let r = s.execute("effects.apply", json!({"clips": [video], "effect": "parametric_eq"})).unwrap(); + let a = &r["applied"].as_array().unwrap()[0]; + let target = a["clip"].as_u64().unwrap(); + let (track, item) = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(target)).unwrap(); + assert_eq!(s.active_sequence().unwrap().track(track).unwrap().kind, filmcraft_project::TrackKind::Audio); + assert_eq!(item.effects.get(a["index"].as_u64().unwrap() as usize).map(|e| e.effect.as_str()), Some("parametric_eq")); +} + #[test] fn new_effect_params_are_settable_and_keyframable() { let mut s = demo(); @@ -128,3 +163,27 @@ fn ultra_key_setting_writes_aggressive_parameters() { assert_eq!(e.param("choke").unwrap().value.as_f64(), Some(10.0)); assert_eq!(e.param("contrast").unwrap().value.as_f64(), Some(10.0)); } + +/// #2679: a value outside a parameter's range (the `min`/`max` `effects.list` publishes) is +/// refused with the range, not saved: gamma 1.1 meant as a factor turned the frame black. +#[test] +fn set_param_refuses_values_outside_the_range() { + let mut s = demo(); + let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0; + s.execute("effects.apply", json!({"clips": [clip], "effect": "levels"})).unwrap(); + s.execute("effects.apply", json!({"clips": [clip], "effect": "lumetri"})).unwrap(); + let before = s.project.to_json(); + let undo = s.history.undo.len(); + for (effect, param, value) in + [("levels", "gamma", 1.1), ("lumetri", "saturation", 500.0), ("lumetri", "vibrance", -300.0), ("opacity", "opacity", 250.0), ("motion", "scale", -50.0)] + { + let e = s.execute("effects.setParam", json!({"clip": clip, "effect": effect, "param": param, "value": value})).unwrap_err().to_string(); + assert!(e.contains("must be between"), "{effect}.{param} = {value}: {e}"); + } + assert_eq!(s.project.to_json(), before, "nothing was saved"); + assert_eq!(s.history.undo.len(), undo); + // the ends of the range and values inside it still go in + s.execute("effects.setParam", json!({"clip": clip, "effect": "levels", "param": "gamma", "value": 110})).unwrap(); + s.execute("effects.setParam", json!({"clip": clip, "effect": "opacity", "param": "opacity", "value": 100})).unwrap(); + s.execute("effects.setParam", json!({"clip": clip, "effect": "opacity", "param": "opacity", "value": 0})).unwrap(); +} diff --git a/crates/engine/src/work_area_tests.rs b/crates/engine/src/work_area_tests.rs new file mode 100644 index 000000000..122cb9f7d --- /dev/null +++ b/crates/engine/src/work_area_tests.rs @@ -0,0 +1,116 @@ +//! The work area bar: the Timeline panel menu's Work Area Bar shows and hides it, Set Work Area Bar +//! In / Out Point (Alt+[ / Alt+] in the Timeline) move its ends to the playhead, `sequence.setWorkArea` +//! sets both ends (a drag of the bar), and Export ▸ Range ▸ Work Area exports it. + +use filmcraft_time::{Tick, TimeRange}; +use serde_json::json; + +use crate::Session; +use crate::shortcuts::{Chord, Platform}; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn area(s: &Session) -> Option<(Tick, Tick)> { + s.active_sequence().unwrap().work_area.map(|w| (w.start, w.end())) +} + +#[test] +fn the_work_area_bar_spans_the_sequence_when_shown_and_undoes() { + let mut s = demo(); + let end = s.active_sequence().unwrap().duration(); + assert!(end > Tick::ZERO); + assert_eq!(area(&s), None, "the bar starts hidden"); + assert!(s.execute("export.resolve", json!({"range": "workArea"})).is_err(), "no work area to export"); + + assert_eq!(s.execute("sequence.workAreaBar", json!({})).unwrap(), json!({"show": true})); + assert_eq!(area(&s), Some((Tick::ZERO, end))); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Work Area Bar")); + let frames = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "workArea"})).unwrap()["frames"].as_i64().unwrap(); + let whole = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "entire"})).unwrap()["frames"].as_i64().unwrap(); + assert_eq!(frames, whole, "the work area covers the whole sequence"); + + // asking for the state it is already in changes nothing + let steps = s.history.undo.len(); + assert_eq!(s.execute("sequence.workAreaBar", json!({"show": true})).unwrap(), json!({"show": true})); + assert_eq!(s.history.undo.len(), steps); + + assert_eq!(s.execute("sequence.workAreaBar", json!({})).unwrap(), json!({"show": false})); + assert_eq!(area(&s), None); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(area(&s), Some((Tick::ZERO, end))); +} + +#[test] +fn set_work_area_in_and_out_points_follow_the_playhead() { + let mut s = demo(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let end = s.active_sequence().unwrap().duration(); + + // with no bar yet, the In point runs to the end of the sequence + s.set_playhead(f(24)); + assert_eq!(s.execute("sequence.setWorkAreaIn", json!({})).unwrap(), json!({"start": f(24).0, "end": end.0})); + assert_eq!(area(&s), Some((f(24), end))); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Set Work Area Bar In Point")); + + s.set_playhead(f(72)); + s.execute("sequence.setWorkAreaOut", json!({})).unwrap(); + assert_eq!(area(&s), Some((f(24), f(72)))); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Set Work Area Bar Out Point")); + let frames = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "workArea"})).unwrap()["frames"].as_i64().unwrap(); + assert!((47..=49).contains(&frames), "{frames}"); + + // an In point past the Out point moves the Out point to the end of the sequence + s.execute("sequence.setWorkAreaIn", json!({"frame": 96})).unwrap(); + assert_eq!(area(&s), Some((f(96), end))); + // an Out point before the In point moves the In point to the start + s.execute("sequence.setWorkAreaOut", json!({"frame": 48})).unwrap(); + assert_eq!(area(&s), Some((Tick::ZERO, f(48)))); + // an In point at or past the end of the sequence keeps one frame + s.execute("sequence.setWorkAreaIn", json!({"time": end.0})).unwrap(); + assert_eq!(area(&s), Some((end, end + rate.frame_duration()))); + + let before = area(&s); + assert!(s.execute("sequence.setWorkAreaOut", json!({"time": 0})).is_err(), "an Out point at 0 leaves no work area"); + assert!(s.execute("sequence.setWorkAreaIn", json!({"time": -1})).is_err(), "negative times are refused"); + assert_eq!(area(&s), before); +} + +#[test] +fn set_work_area_takes_both_ends_and_refuses_bad_ones() { + let mut s = demo(); + let rate = s.sequence_rate(); + let (a, b) = (rate.tick_of(10), rate.tick_of(20)); + assert_eq!(s.execute("sequence.setWorkArea", json!({"start": a.0, "end": b.0})).unwrap(), json!({"start": a.0, "end": b.0})); + assert_eq!(s.active_sequence().unwrap().work_area, Some(TimeRange::from_bounds(a, b))); + for p in [ + json!({"start": b.0, "end": a.0}), + json!({"start": a.0, "end": a.0}), + json!({"start": -1, "end": b.0}), + json!({"start": a.0}), + json!({"end": b.0}), + json!({"start": 0, "end": i64::MAX}), + ] { + assert!(s.execute("sequence.setWorkArea", p.clone()).is_err(), "{p}"); + assert_eq!(s.active_sequence().unwrap().work_area, Some(TimeRange::from_bounds(a, b)), "{p}"); + } + let mut empty = Session::default(); + for id in ["sequence.workAreaBar", "sequence.setWorkAreaIn", "sequence.setWorkAreaOut", "sequence.setWorkArea"] { + assert!(empty.execute(id, json!({"start": 0, "end": 1})).is_err(), "{id} needs a sequence"); + } +} + +#[test] +fn alt_brackets_set_the_work_area_in_the_timeline() { + let s = Session::default(); + for p in [Platform::Mac, Platform::Windows] { + for (keys, cmd) in [("Alt+[", "sequence.setWorkAreaIn"), ("Alt+]", "sequence.setWorkAreaOut")] { + let chord = Chord::parse(keys).unwrap(); + assert_eq!(s.shortcuts.resolve(&chord, Some("Timeline"), p).map(|b| b.command.as_str()), Some(cmd), "{keys} {p:?}"); + } + } +} diff --git a/crates/export/src/determinism_tests.rs b/crates/export/src/determinism_tests.rs index 2866cb9d4..44ff76daa 100644 --- a/crates/export/src/determinism_tests.rs +++ b/crates/export/src/determinism_tests.rs @@ -308,3 +308,77 @@ fn a_panic_while_rendering_ahead_is_an_error() { assert!(matches!(r, Err(ExportError::Encode(_))), "{:?}", r.map(|_| ())); }); } + +// ------------------------------------------------------------------ max_threads + +/// A provider that notes how many threads the pool it is asked on has, and which threads ask. +struct Noting<'a> { + inner: &'a SourceMap, + most: AtomicUsize, + threads: std::sync::Mutex>, +} + +impl<'a> Noting<'a> { + fn new(inner: &'a SourceMap) -> Self { + Self { inner, most: AtomicUsize::new(0), threads: Default::default() } + } +} + +impl SourceProvider for Noting<'_> { + fn source(&self, item: ItemId) -> Option { + self.most.fetch_max(rayon::current_num_threads(), Ordering::SeqCst); + self.threads.lock().unwrap().insert(std::thread::current().id()); + self.inner.source(item) + } +} + +/// Export the test project from a pool of `outer` threads with `max_threads` set, and report the +/// biggest pool a frame was asked on and how many different threads asked. +fn threads_used(outer: usize, max_threads: u32, name: &str) -> (usize, usize) { + let (p, seq, m) = project(); + let noting = Noting::new(&m); + let settings = ExportSettings { format: Format::Mjpeg, path: tmp(name), include_audio: false, max_threads, ..Default::default() }; + pool(outer).install(|| export(&p, seq, &settings, ¬ing, &Progress::default())).unwrap(); + (noting.most.load(Ordering::SeqCst), noting.threads.lock().unwrap().len()) +} + +#[test] +fn max_threads_caps_the_threads_an_export_runs_on() { + // uncapped, it renders on the pool it is called in + let (most, threads) = threads_used(8, 0, "cap-0.mov"); + assert_eq!(most, 8); + assert!(threads > 3, "{threads} threads asked for frames: the uncapped export should have used several"); + // capped, on a pool of that size + for cap in [1u32, 3] { + let (most, threads) = threads_used(8, cap, &format!("cap-{cap}.mov")); + assert_eq!(most, cap as usize, "cap {cap}"); + assert!(threads <= cap as usize, "cap {cap}: {threads} threads asked for frames"); + } +} + +#[test] +fn max_threads_never_adds_threads() { + // a cap at or above the pool the export is called in changes nothing + assert_eq!(threads_used(4, 4, "cap-same.mov").0, 4); + assert_eq!(threads_used(4, 100, "cap-large.mov").0, 4); +} + +#[test] +fn max_threads_does_not_change_the_file() { + for (format, ext) in [(Format::ProRes, "mov"), (Format::H264, "mp4"), (Format::Mjpeg, "mov")] { + let settings = |name: &str, max_threads: u32| ExportSettings { format, path: tmp(&format!("{name}.{ext}")), max_threads, ..Default::default() }; + let reference = export_with_cores(5, &settings(&format!("{}-uncapped", format.id()), 0)); + for cap in [1u32, 3] { + let capped = export_with_cores(5, &settings(&format!("{}-cap{cap}", format.id()), cap)); + assert!(reference == capped, "{}: a cap of {cap} thread(s) gives a different file", format.label()); + } + } +} + +#[test] +fn max_threads_is_a_setting_of_the_json_interface() { + let s: ExportSettings = serde_json::from_str(r#"{"format":"mjpeg","maxThreads":2}"#).unwrap(); + assert_eq!(s.max_threads, 2); + assert_eq!(ExportSettings::default().max_threads, 0, "all cores unless asked"); + assert_eq!(serde_json::to_value(&s).unwrap()["maxThreads"], serde_json::json!(2)); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 46956912f..cd8845908 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -35,7 +35,7 @@ pub use pipeline::limit_rgba8; pub use presets::{ExportPreset, builtin_presets}; pub use settings::*; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock}; use filmcraft_isobmff::SampleEntry; use filmcraft_project::{ItemId, Project}; @@ -325,6 +325,13 @@ pub struct ExportSettings { /// `progress.total`, `finished` and the final status; this call only adds to `done`. #[serde(default)] pub part_of_batch: bool, + /// Most CPU threads the export may use (0 = all of them, as before). Rendering, the software + /// encoders and the rest of the export's parallel work run on a pool of this size; a number + /// above what the machine has (or above the pool the export already runs in) changes nothing. + /// The output does not depend on it. Hardware encoders and the decoders' own threads are not + /// counted. + #[serde(default)] + pub max_threads: u32, /// ProRes flavour: `proxy`, `lt`, `standard`, `hq`, `4444` or `4444xq` (empty = HQ). #[serde(default)] pub prores_profile: String, @@ -406,6 +413,10 @@ pub struct ExportSettings { /// writing `path` (hosts without a filesystem: the web app offers the file as a download). #[serde(skip)] pub sink: Option, + /// An already-created primary output file. Internal callers use this to retain an exclusive + /// reservation made while choosing an output name, rather than reopening that path later. + #[serde(skip)] + pub output_file: Option>, } /// Receives in-memory export output: `(path, bytes)` per finished file. @@ -432,6 +443,10 @@ enum Out { impl Out { fn create(settings: &ExportSettings) -> Result { + if let Some(file) = &settings.output_file { + let file = file.try_clone().map_err(|e| ExportError::Io(e.to_string()))?; + return Ok(Out::File(std::io::BufWriter::new(file))); + } Out::create_path(settings, &settings.path) } @@ -440,6 +455,12 @@ impl Out { if settings.sink.is_some() { return Ok(Out::Mem(std::io::Cursor::new(Vec::new()))); } + if path == settings.path + && let Some(file) = &settings.output_file + { + let file = file.try_clone().map_err(|e| ExportError::Io(e.to_string()))?; + return Ok(Out::File(std::io::BufWriter::new(file))); + } let f = std::fs::File::create(path).map_err(|e| ExportError::Io(format!("{path}: {e}")))?; Ok(Out::File(std::io::BufWriter::new(f))) } @@ -557,6 +578,7 @@ impl Default for ExportSettings { bitrate_kbps: 20_000, burn_captions: false, part_of_batch: false, + max_threads: 0, prores_profile: String::new(), dnx_profile: String::new(), apv_profile: String::new(), @@ -588,6 +610,7 @@ impl Default for ExportSettings { h264_pass: H264Pass::Single, signal: ColorSignal::default(), sink: None, + output_file: None, } } } @@ -600,6 +623,42 @@ fn default_crf() -> f32 { } impl ExportSettings { + fn validate_audio_settings(&self) -> Result<()> { + if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) { + return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into())); + } + if self.audio.float && self.audio.bits != 32 { + return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into())); + } + let sample_format = self.audio.sample_format(); + if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) { + let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() }; + return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label()))); + } + Ok(()) + } + + fn validate_bitrate_mode(&self) -> Result<()> { + if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass { + return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); + } + if self.format == Format::Av1 && self.bitrate_mode == BitrateMode::Vbr2Pass { + return Err(ExportError::Unsupported("AV1 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); + } + if self.bitrate_mode == BitrateMode::Crf { + if self.format == Format::Hevc { + return Err(ExportError::Unsupported("H.265 export has no CRF mode: choose CBR or VBR, 1 pass".into())); + } + if self.format == Format::Av1 { + return Err(ExportError::Unsupported("AV1 export has no CRF mode: choose CBR or VBR, 1 pass".into())); + } + if !self.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) { + return Err(ExportError::Unsupported("CRF must be between 0 and 51".into())); + } + } + Ok(()) + } + /// Reject settings the encoders cannot honour. pub fn validate(&self) -> Result<()> { if let Some(range) = self.range { @@ -621,40 +680,14 @@ impl ExportSettings { { return Err(ExportError::Unsupported("output frame rate must be positive and at most 1000 fps".into())); } - if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) { - return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into())); - } - if self.audio.float && self.audio.bits != 32 { - return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into())); - } - let sample_format = self.audio.sample_format(); - if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) { - let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() }; - return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label()))); - } + self.validate_audio_settings()?; if self.field_order != FieldOrder::Progressive && self.has_video() { return Err(ExportError::Unsupported(format!("{} field order: FilmCraft's encoders write progressive frames", self.field_order.label()))); } if self.effects.image_overlay.enabled && self.effects.image_overlay.path.trim().is_empty() { return Err(ExportError::Unsupported("image overlay: no image file chosen".into())); } - if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass { - return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); - } - if self.format == Format::Av1 && self.bitrate_mode == BitrateMode::Vbr2Pass { - return Err(ExportError::Unsupported("AV1 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); - } - if self.bitrate_mode == BitrateMode::Crf { - if self.format == Format::Hevc { - return Err(ExportError::Unsupported("H.265 export has no CRF mode: choose CBR or VBR, 1 pass".into())); - } - if self.format == Format::Av1 { - return Err(ExportError::Unsupported("AV1 export has no CRF mode: choose CBR or VBR, 1 pass".into())); - } - if !self.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) { - return Err(ExportError::Unsupported("CRF must be between 0 and 51".into())); - } - } + self.validate_bitrate_mode()?; Ok(()) } } @@ -690,7 +723,7 @@ impl Progress { /// [`Progress::eta`] at a given time (tests, and hosts with a clock of their own). pub fn eta_at(&self, now: web_time::Instant) -> Option { let (done, total) = (self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed)); - let mut pace = self.pace.lock().unwrap_or_else(|e| e.into_inner()); + let mut pace = self.pace.lock().unwrap_or_else(PoisonError::into_inner); pace.observe(now, done); if self.finished.load(Ordering::Relaxed) || done >= total { return None; @@ -698,7 +731,7 @@ impl Progress { pace.eta(total - done) } fn set_status(&self, s: impl Into) { - *self.status.lock().unwrap_or_else(|e| e.into_inner()) = s.into(); + *self.status.lock().unwrap_or_else(PoisonError::into_inner) = s.into(); } } @@ -895,7 +928,7 @@ pub fn timed(stage: Stage, f: impl FnOnce() -> T) -> T { /// Register a video encoder factory (tried before the built-in ones and those registered earlier). /// Registering the same factory twice is harmless. pub fn register_encoder(f: EncoderFactory) { - let mut g = video_factories().write().unwrap_or_else(|e| e.into_inner()); + let mut g = video_factories().write().unwrap_or_else(PoisonError::into_inner); if !g.iter().any(|x| std::ptr::fn_addr_eq(*x, f)) { g.insert(0, f); } @@ -903,7 +936,7 @@ pub fn register_encoder(f: EncoderFactory) { /// Whether `f` is among the registered video encoder factories (startup diagnostics, tests). pub fn encoder_registered(f: EncoderFactory) -> bool { - video_factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f)) + video_factories().read().unwrap_or_else(PoisonError::into_inner).iter().any(|x| std::ptr::fn_addr_eq(*x, f)) } /// Renders one export frame on something other than the CPU reference renderer — today the GPU @@ -933,7 +966,7 @@ fn frame_renderer_factories() -> &'static RwLock> { /// Register a frame-renderer factory (tried before those registered earlier). Registering the /// same factory twice is harmless. pub fn register_frame_renderer(f: FrameRendererFactory) { - let mut g = frame_renderer_factories().write().unwrap_or_else(|e| e.into_inner()); + let mut g = frame_renderer_factories().write().unwrap_or_else(PoisonError::into_inner); if !g.iter().any(|x| std::ptr::fn_addr_eq(*x, f)) { g.insert(0, f); } @@ -941,21 +974,21 @@ pub fn register_frame_renderer(f: FrameRendererFactory) { /// Whether `f` is among the registered frame-renderer factories (startup diagnostics, tests). pub fn frame_renderer_registered(f: FrameRendererFactory) -> bool { - frame_renderer_factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f)) + frame_renderer_factories().read().unwrap_or_else(PoisonError::into_inner).iter().any(|x| std::ptr::fn_addr_eq(*x, f)) } /// Clear the registered frame-renderer factories. Test-only: registration is a process-wide /// startup step and tests that register their own must not see each other's. #[cfg(test)] pub(crate) fn reset_frame_renderers_for_tests() { - frame_renderer_factories().write().unwrap_or_else(|e| e.into_inner()).clear(); + frame_renderer_factories().write().unwrap_or_else(PoisonError::into_inner).clear(); GPU_FRAMES.store(0, Ordering::Relaxed); GPU_FALLBACKS.store(0, Ordering::Relaxed); } /// Pick the first registered factory that can build a renderer for this host. pub(crate) fn build_frame_renderer() -> Option> { - let g = frame_renderer_factories().read().unwrap_or_else(|e| e.into_inner()); + let g = frame_renderer_factories().read().unwrap_or_else(PoisonError::into_inner); g.iter().filter_map(|f| f()).next() } @@ -999,7 +1032,7 @@ pub fn gpu_render_stats() -> GpuRenderStats { GpuRenderStats { frames: GPU_FRAMES.load(Ordering::Relaxed), fallbacks: GPU_FALLBACKS.load(Ordering::Relaxed) } } pub fn register_audio_encoder(f: AudioEncoderFactory) { - audio_factories().write().unwrap_or_else(|e| e.into_inner()).insert(0, f); + audio_factories().write().unwrap_or_else(PoisonError::into_inner).insert(0, f); } type FormatProbe = (Format, fn() -> bool); @@ -1013,7 +1046,7 @@ fn format_probes() -> &'static RwLock> { /// works on this machine: `probe` runs when [`available`] asks (it should cache its answer). /// Registering the same probe twice is harmless. pub fn register_format_probe(format: Format, probe: fn() -> bool) { - let mut g = format_probes().write().unwrap_or_else(|e| e.into_inner()); + let mut g = format_probes().write().unwrap_or_else(PoisonError::into_inner); if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) { g.push((format, probe)); } @@ -1029,7 +1062,7 @@ fn hdr_probes() -> &'static RwLock> { /// one: its encoders are hardware-only and not all of them take 10-bit pictures, so an HDR sequence /// exports as tone-mapped SDR unless a probe says otherwise. Registering the same probe twice is harmless. pub fn register_hdr_probe(format: Format, probe: fn() -> bool) { - let mut g = hdr_probes().write().unwrap_or_else(|e| e.into_inner()); + let mut g = hdr_probes().write().unwrap_or_else(PoisonError::into_inner); if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) { g.push((format, probe)); } @@ -1038,7 +1071,7 @@ pub fn register_hdr_probe(format: Format, probe: fn() -> bool) { /// Whether a registered probe says the encoder of `format` writes HDR here (false without one). pub fn hdr_available(format: Format) -> bool { // the probes run outside the lock: one may take a while - let probes: Vec bool> = hdr_probes().read().unwrap_or_else(|e| e.into_inner()).iter().filter(|(f, _)| *f == format).map(|(_, p)| *p).collect(); + let probes: Vec bool> = hdr_probes().read().unwrap_or_else(PoisonError::into_inner).iter().filter(|(f, _)| *f == format).map(|(_, p)| *p).collect(); probes.into_iter().any(|p| p()) } @@ -1048,7 +1081,7 @@ pub fn available(format: Format) -> bool { if !Format::ALL.contains(&format) { return false; } - format.has_builtin_encoder() || format_probes().read().unwrap_or_else(|e| e.into_inner()).iter().any(|(f, probe)| *f == format && probe()) + format.has_builtin_encoder() || format_probes().read().unwrap_or_else(PoisonError::into_inner).iter().any(|(f, probe)| *f == format && probe()) } struct MjpegEncoder { @@ -1774,7 +1807,7 @@ struct SharedBuf(Arc>>); impl Write for SharedBuf { fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.lock().unwrap_or_else(|e| e.into_inner()).extend_from_slice(buf); + self.0.lock().unwrap_or_else(PoisonError::into_inner).extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { @@ -1784,12 +1817,11 @@ impl Write for SharedBuf { /// Encode straight sRGB RGBA8 pixels as a PNG file (thumbnails, previews). pub fn encode_png(rgba: Vec, w: u32, h: u32) -> Result> { - encode_still(Format::PngSequence, rgba, w, h, false) + encode_still(Format::PngSequence, rgba, w, h, true) } -/// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha -/// channel it is given; `alpha` keeps it in TIFF, Targa and OpenEXR too, which are otherwise written -/// as RGB. +/// Encode one still of an image sequence (also Export Frame). `alpha` keeps the alpha channel in +/// PNG, TIFF, Targa and OpenEXR, which are otherwise written as RGB. /// JPEG uses the default export quality ([`encode_still_with_quality`] picks another). pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool) -> Result> { encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality) @@ -1819,9 +1851,13 @@ pub fn encode_still_with(format: Format, rgba: Vec, w: u32, h: u32, alpha: b Format::TgaSequence => return raster::tga(&rgba, w, h, alpha), Format::DpxSequence => return raster::dpx(&rgba, w, h), Format::ExrSequence => return raster::exr(&rgba, w, h, alpha, exr), - Format::PngSequence => { + Format::PngSequence if alpha => { image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)? } + Format::PngSequence => { + let rgb = raster::rgb(&rgba, w, h)?; + image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgb, w, h, image::ExtendedColorType::Rgb8).map_err(enc)? + } Format::TiffSequence | Format::BmpSequence => { let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame size".into()))?; let f = if format == Format::TiffSequence { image::ImageFormat::Tiff } else { image::ImageFormat::Bmp }; @@ -1836,8 +1872,28 @@ pub fn encode_still_with(format: Format, rgba: Vec, w: u32, h: u32, alpha: b Ok(out.into_inner()) } -/// Run an export (blocking; call from a worker thread). +/// A pool of `max_threads` threads to run an export on, or `None` when it should run where it is: +/// no cap (0), a cap that is not below the pool the caller is in already, or no threads to spare +/// (the browser). +fn capped_pool(max_threads: u32) -> Option { + let cap = max_threads as usize; + if cap == 0 || cap >= rayon::current_num_threads() || cfg!(target_arch = "wasm32") { + return None; + } + rayon::ThreadPoolBuilder::new().num_threads(cap).thread_name(|i| format!("filmcraft-export-{i}")).build().ok() +} + +/// Run an export (blocking; call from a worker thread). With `settings.max_threads` set it runs on +/// a pool of that many threads, so its rendering and software encoding use at most that many cores. pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, sources: &dyn SourceProvider, progress: &Progress) -> Result { + match capped_pool(settings.max_threads) { + Some(pool) => pool.install(|| export_here(project, seq, settings, sources, progress)), + None => export_here(project, seq, settings, sources, progress), + } +} + +/// [`export`] on the threads it is called from. +fn export_here(project: &Arc, seq: ItemId, settings: &ExportSettings, sources: &dyn SourceProvider, progress: &Progress) -> Result { settings.validate()?; if stepped(settings.format) { let mut ex = Exporter::new(project.clone(), seq, settings, progress)?; @@ -1881,7 +1937,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so progress.set_status(format!("Exporting audio ({})", settings.format.label())); } a.measure(settings, sources, &cancelled)?; - *progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness; + *progress.loudness.lock().unwrap_or_else(PoisonError::into_inner) = a.loudness; let mut out = Out::create(settings)?; let io = |e: std::io::Error| ExportError::Io(e.to_string()); out.write_all(&head).map_err(io)?; @@ -1979,7 +2035,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so if let Some(enc) = gif { let buf = gif_buf.clone(); drop(enc); - let data = std::mem::take(&mut *buf.0.lock().unwrap_or_else(|e| e.into_inner())); + let data = std::mem::take(&mut *buf.0.lock().unwrap_or_else(PoisonError::into_inner)); total = write_output(settings, &settings.path, data)?; } (total, count) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index c47e5f7b8..c4c6f73ea 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -3,6 +3,7 @@ //! apply the Export effects (image, name and timecode overlays, video limiter) and convert to the //! encoder's input (straight sRGB RGBA8, or encoded R'G'B' floats for HDR). +use std::io::Cursor; use std::sync::Arc; use filmcraft_geom::Vec2; @@ -14,6 +15,17 @@ use rayon::prelude::*; use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay}; use crate::{ExportError, ExportSettings, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame}; +/// Image overlays originate outside the project and are expanded to f32 working images. This +/// admits ordinary 4K artwork while keeping both source decode and resampling bounded. +const MAX_OVERLAY_PIXELS: usize = 16 << 20; + +fn bounded_overlay_dimensions(w: usize, h: usize) -> Result<()> { + if w == 0 || h == 0 || w.checked_mul(h).is_none_or(|pixels| pixels > MAX_OVERLAY_PIXELS) { + return Err(ExportError::Unsupported("image overlay exceeds the pixel budget".into())); + } + Ok(()) +} + /// Where the rendered picture lands in the output frame. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct Geometry { @@ -392,8 +404,16 @@ fn composite(dst: &mut Image, src: &Image, x: i64, y: i64, opacity: f32) { /// Load a PNG / JPEG overlay as premultiplied linear light, `width` pixels wide. fn load_overlay(path: &str, width: f32) -> Result { let bytes = std::fs::read(path).map_err(|e| ExportError::Io(format!("image overlay {path}: {e}")))?; - let rgba = image::load_from_memory(&bytes).map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?.to_rgba8(); + let mut reader = image::ImageReader::new(Cursor::new(bytes)); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16_384); + limits.max_image_height = Some(16_384); + limits.max_alloc = Some((MAX_OVERLAY_PIXELS * 4) as u64); + reader.limits(limits); + let reader = reader.with_guessed_format().map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?; + let rgba = reader.decode().map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?.to_rgba8(); let (iw, ih) = (rgba.width() as usize, rgba.height() as usize); + bounded_overlay_dimensions(iw, ih)?; let mut img = Image::new(iw, ih); for (o, p) in img.px.as_chunks_mut::<4>().0.iter_mut().zip(rgba.as_raw().as_chunks::<4>().0) { let a = p[3] as f32 / 255.0; @@ -402,8 +422,12 @@ fn load_overlay(path: &str, width: f32) -> Result { } o[3] = a; } + if !width.is_finite() || width <= 0.0 { + return Err(ExportError::Unsupported("image overlay width is invalid".into())); + } let nw = width.round().max(1.0) as usize; - let nh = ((ih as f32 * nw as f32 / iw.max(1) as f32).round() as usize).max(1); + let nh = ((ih as f64 * nw as f64 / iw.max(1) as f64).round() as usize).max(1); + bounded_overlay_dimensions(nw, nh)?; Ok(if (nw, nh) == (iw, ih) { img } else { resample(&img, nw, nh) }) } @@ -498,3 +522,15 @@ fn limit_px(c: &mut [f32; 3], lo: f32, hi: f32) { } c.iter_mut().for_each(|v| *v = v.clamp(0.0, hi.max(lo))); } + +#[cfg(test)] +mod overlay_budget_tests { + use super::*; + + #[test] + fn overlay_pixel_budget_is_checked_without_overflow() { + assert!(bounded_overlay_dimensions(4096, 4096).is_ok()); + assert!(bounded_overlay_dimensions(4097, 4096).is_err()); + assert!(bounded_overlay_dimensions(usize::MAX, 2).is_err()); + } +} diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index ab954adc9..84ca5bacd 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -624,7 +624,7 @@ impl ExportSettings { px * fps * bpp } Format::Mjpeg | Format::JpegSequence => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0, - Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps, + Format::PngSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 0.45 * 8.0 * fps, Format::BmpSequence => px * 3.0 * 8.0 * fps, Format::TiffSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 22be2d317..cfd028bf8 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -238,7 +238,8 @@ fn openexr_sequences_compress() { } } -/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160). +/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160) +/// and writes RGB files without an alpha channel (#1863). #[test] fn image_sequences_keep_alpha_when_asked() { let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None); @@ -248,16 +249,36 @@ fn image_sequences_keep_alpha_when_asked() { let mut s = ExportSettings { format: fmt, path: dir.path(&format!("a.{ext}")), alpha, ..Default::default() }; s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(2))); export(&p, seq, &s, &m, &Progress::default()).unwrap(); - let img = image::open(dir.0.join(format!("a000.{ext}"))).unwrap(); + let file = dir.0.join(format!("a000.{ext}")); + if fmt == Format::PngSequence { + let colour_type = std::fs::read(&file).unwrap()[25]; + assert_eq!(colour_type, if alpha { 6 } else { 2 }, "PNG colour type, alpha={alpha}"); + } + let img = image::open(&file).unwrap(); let px = img.to_rgba8().get_pixel(32, 18).0; + assert_eq!(img.color().has_alpha(), alpha, "{fmt:?} alpha channel written, alpha={alpha}"); if alpha { - assert!(img.color().has_alpha(), "{fmt:?} must be written with an alpha channel"); assert!((100..=160).contains(&px[3]) && px[2] > 200 && px[0] < 15, "{fmt:?} straight alpha: {px:?}"); } else { assert!(px[3] == 255 && px[2] > 100 && px[2] < 240 && px[0] < 15, "{fmt:?} flattened over black: {px:?}"); } } } + // the PNG size estimate counts the channels actually written, as TIFF's does + let rate = FrameRate::FPS_24; + let png = |alpha| ExportSettings { format: Format::PngSequence, alpha, ..Default::default() }.estimate_bytes(320, 180, rate, 48_000, rate.tick_of(1)); + assert_eq!(png(false) * 4, png(true) * 3, "PNG estimate: {} RGB vs {} RGBA", png(false), png(true)); +} + +/// Thumbnails (`encode_png`) and an 8-bit PNG Export Frame keep the alpha they are given: only the +/// sequence's `alpha` switch drops it (#1863). +#[test] +fn png_stills_keep_their_alpha_channel() { + let half = filmcraft_render::Image::filled(4, 2, [0.0, 0.0, 0.5, 0.5]); + let thumb = encode_png(half.to_rgba8(), 4, 2).unwrap(); + assert_eq!(thumb[25], 6, "encode_png colour type"); + let frame = still::encode(&half, still::StillFormat::Png, 8).unwrap(); + assert_eq!(frame[25], 6, "Export Frame PNG colour type"); } #[test] diff --git a/crates/export/src/still.rs b/crates/export/src/still.rs index c60c2159f..2af712461 100644 --- a/crates/export/src/still.rs +++ b/crates/export/src/still.rs @@ -77,8 +77,8 @@ pub fn encode(image: &Image, format: StillFormat, depth: u8) -> Result> StillFormat::Tiff => Format::TiffSequence, _ => Format::BmpSequence, }; - // 8-bit TIFF stays RGB, as Export Frame wrote it before; 16-bit output keeps alpha. - return crate::encode_still(f, rgba, w, h, false); + // 8-bit TIFF and BMP stay RGB, as Export Frame wrote them before; PNG and 16-bit output keep alpha. + return crate::encode_still(f, rgba, w, h, format == StillFormat::Png); } let capacity = samples.checked_mul(2).ok_or_else(bad)?; let mut bytes = Vec::with_capacity(capacity); diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs new file mode 100644 index 000000000..3452f185c --- /dev/null +++ b/crates/export/tests/it/main.rs @@ -0,0 +1,2 @@ +mod pool_trim; +mod renderer_reentrancy; diff --git a/crates/export/tests/pool_trim.rs b/crates/export/tests/it/pool_trim.rs similarity index 100% rename from crates/export/tests/pool_trim.rs rename to crates/export/tests/it/pool_trim.rs diff --git a/crates/export/tests/renderer_reentrancy.rs b/crates/export/tests/it/renderer_reentrancy.rs similarity index 98% rename from crates/export/tests/renderer_reentrancy.rs rename to crates/export/tests/it/renderer_reentrancy.rs index fc6c235bc..2c3a8bd8a 100644 --- a/crates/export/tests/renderer_reentrancy.rs +++ b/crates/export/tests/it/renderer_reentrancy.rs @@ -143,7 +143,7 @@ fn nested_rayon_renderers_finish_every_export_path() { } for case in ["png", "gif", "stepped", "overlap-audio"] { let mut child = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "nested_rayon_renderers_finish_every_export_path", "--nocapture"]) + .args(["--exact", "renderer_reentrancy::nested_rayon_renderers_finish_every_export_path", "--nocapture"]) .env(CHILD, case) .spawn() .unwrap(); diff --git a/crates/flac/src/lib.rs b/crates/flac/src/lib.rs index 0844b7c3c..10d8d28fa 100644 --- a/crates/flac/src/lib.rs +++ b/crates/flac/src/lib.rs @@ -111,7 +111,9 @@ impl Encoder { total_samples: 0, min_frame: 0, max_frame: 0, - md5: md5::Md5::default(), + // RFC 9639 requires an MD5 signature of the unencoded audio in STREAMINFO; this is an + // integrity checksum, not a password hash or a security boundary. + md5: md5::Md5::default(), // NOSONAR }) } diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index f2c1e982e..87c6be25b 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -16,6 +16,10 @@ log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tempfile = "3.27" [dev-dependencies] filmcraft-time = { workspace = true } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 0f1044ca6..fc6d29dcd 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -28,11 +28,21 @@ fn parent_dir(path: &Path) -> PathBuf { /// `fsync` the directory so the rename itself survives a power cut. If anything fails the temp file /// is removed and `path` is untouched. pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> { + // `rename` replaces the inode, so a new staging file must inherit an existing project's + // access mode instead of silently broadening a private file to the process default. + let permissions = match fs::metadata(path) { + Ok(meta) => Some(meta.permissions()), + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; let tmp = temp_path(path); let r = (|| { { // closed before the rename let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?; + if let Some(permissions) = permissions.as_ref() { + f.set_permissions(permissions.clone())?; + } f.write_all(data)?; f.sync_all()?; } @@ -46,8 +56,38 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> { Ok(()) } -/// Best-effort directory fsync (persists the rename on POSIX; not possible on Windows, where -/// `MoveFileEx` with write-through semantics is already durable enough). +/// Publish a complete new file without replacing an occupied final path, including a symlink. +/// Bytes are staged and synced in the destination directory before no-clobber publication. +/// A write failure leaves no partial final file; temporary-file cleanup is best effort. +#[cfg(not(target_arch = "wasm32"))] +pub fn atomic_write_new(path: &Path, data: &[u8]) -> io::Result<()> { + atomic_write_new_with(path, |file| file.write_all(data).and_then(|_| file.sync_all())) +} + +#[cfg(target_arch = "wasm32")] +pub fn atomic_write_new(_path: &Path, _data: &[u8]) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "native exclusive file creation is unavailable in the browser")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn atomic_write_new_with(path: &Path, write: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + let dir = parent_dir(path); + #[cfg(unix)] + let mut staged = { + use std::os::unix::fs::PermissionsExt; + // Like an ordinary file creation: request 0666 and let the process umask decide the + // effective mode (0644 with the usual 022, 0664 with 002). + tempfile::Builder::new().permissions(fs::Permissions::from_mode(0o666)).tempfile_in(&dir)? // NOSONAR + }; + #[cfg(not(unix))] + let mut staged = tempfile::NamedTempFile::new_in(&dir)?; + write(staged.as_file_mut())?; + let _published = staged.persist_noclobber(path).map_err(|e| e.error)?; + sync_dir(&dir); + Ok(()) +} + +/// Best-effort directory sync; directory handles are not uniformly readable or syncable. fn sync_dir(dir: &Path) { if let Ok(d) = File::open(dir) { let _ = d.sync_all(); @@ -92,6 +132,81 @@ mod tests { fs::remove_dir_all(&d).unwrap(); } + #[cfg(unix)] + #[test] + fn replacement_preserves_existing_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let d = tmpdir("permissions"); + let p = d.join("private.fcproj"); + fs::write(&p, b"before").unwrap(); + fs::set_permissions(&p, fs::Permissions::from_mode(0o600)).unwrap(); + atomic_write(&p, b"after").unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"after"); + assert_eq!(fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600); + fs::remove_dir_all(&d).unwrap(); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn new_file_is_published_complete_without_clobbering_or_temp_leaks() { + let d = tmpdir("new"); + let p = d.join("audio.wav"); + atomic_write_new(&p, b"complete").unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let reference = d.join("normal-create"); + OpenOptions::new().write(true).create_new(true).open(&reference).unwrap(); + assert_eq!(fs::metadata(&p).unwrap().permissions().mode() & 0o777, fs::metadata(&reference).unwrap().permissions().mode() & 0o777); + fs::remove_file(reference).unwrap(); + } + assert_eq!(atomic_write_new(&p, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + #[cfg(unix)] + { + let link = d.join("link.wav"); + std::os::unix::fs::symlink(&p, &link).unwrap(); + assert_eq!(atomic_write_new(&link, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + } + let expected = if cfg!(unix) { 2 } else { 1 }; + assert_eq!(fs::read_dir(&d).unwrap().count(), expected, "staging file remained after publication"); + fs::remove_dir_all(&d).unwrap(); + } + + #[cfg(unix)] + #[test] + fn new_file_can_publish_in_a_directory_without_read_permission() { + use std::os::unix::fs::PermissionsExt; + let d = tmpdir("write-only-directory"); + let original_permissions = fs::metadata(&d).unwrap().permissions(); + fs::set_permissions(&d, fs::Permissions::from_mode(0o300)).unwrap(); + let p = d.join("audio.wav"); + let result = atomic_write_new(&p, b"complete"); + fs::set_permissions(&d, original_permissions).unwrap(); + assert!(result.is_ok(), "{result:?}"); + assert_eq!(fs::read(&p).unwrap(), b"complete"); + fs::remove_dir_all(&d).unwrap(); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn failed_staging_leaves_no_partial_destination() { + let d = tmpdir("failed-new"); + let p = d.join("audio.wav"); + let error = atomic_write_new_with(&p, |file| { + file.write_all(b"partial")?; + Err(io::Error::other("simulated full disk")) + }) + .unwrap_err(); + assert_eq!(error.to_string(), "simulated full disk"); + assert!(!p.exists()); + assert_eq!(fs::read_dir(&d).unwrap().count(), 0); + fs::remove_dir_all(&d).unwrap(); + } + #[test] fn missing_directory_is_an_error() { let d = tmpdir("missing"); diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 4608c1061..54543749b 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -1,9 +1,10 @@ //! Auto-save file naming and rotation (Premiere behaviour). //! //! Auto-saves go to an `Auto-Save` folder next to the project, named -//! `-YYYY-MM-DD_HH-MM-SS.fcproj` (local wall-clock time). After each write the oldest -//! files of that project beyond the "Maximum Project Versions" limit are deleted. Only files whose -//! name is exactly `-.fcproj` are ever touched. +//! `-YYYY-MM-DD_HH-MM-SS-.fcproj` (local wall-clock time, then the real +//! time that orders the files). After each write the oldest files of that project beyond the "Maximum +//! Project Versions" limit are deleted. Only files whose name is exactly `-.fcproj` +//! or `--.fcproj` are ever touched. use std::fs; use std::io; @@ -24,23 +25,37 @@ pub fn file_stem(name: &str) -> String { if s.is_empty() { "Untitled".into() } else { s } } +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact instead of collapsing out-of-range years to the epoch. +fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + /// Civil date-time (proleptic Gregorian) of a unix timestamp shifted by `offset_secs`. +/// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept them); +/// only an overflowing `unix + offset_secs` shift (which the old code wrapped to garbage) reads as +/// the epoch. pub fn civil(unix: i64, offset_secs: i32) -> (i64, u32, u32, u32, u32, u32) { - let t = unix + offset_secs as i64; - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - // Howard Hinnant's days_from_civil inverse. - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d, (secs / 3600) as u32, (secs % 3600 / 60) as u32, (secs % 60) as u32) + use chrono::{Datelike, Timelike}; + let Some(s) = unix.checked_add(offset_secs as i64) else { + return (1970, 1, 1, 0, 0, 0); + }; + let (y, t) = utc_parts(s); + (y, t.month(), t.day(), t.hour(), t.minute(), t.second()) } /// `YYYY-MM-DD_HH-MM-SS` (the auto-save file-name stamp). @@ -55,9 +70,10 @@ pub fn display_time(unix: i64, offset_secs: i32) -> String { format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}") } -/// `-YYYY-MM-DD_HH-MM-SS.fcproj`. +/// `-YYYY-MM-DD_HH-MM-SS-.fcproj`. The local stamp is for people; the unix time +/// orders the files (the local clock can go back, or repeat an hour, when daylight saving time ends). pub fn auto_save_name(name: &str, unix: i64, offset_secs: i32) -> String { - format!("{}-{}.fcproj", file_stem(name), timestamp(unix, offset_secs)) + format!("{}-{}-{unix}.fcproj", file_stem(name), timestamp(unix, offset_secs)) } fn is_stamp(s: &str) -> bool { @@ -70,20 +86,33 @@ fn is_stamp(s: &str) -> bool { }) } -/// Auto-saves of project `name` in `dir`, oldest first. +/// Parse `YYYY-MM-DD_HH-MM-SS` or `YYYY-MM-DD_HH-MM-SS-` into the stamp and the unix time +/// (`None` for files written before the unix time was part of the name). +fn parse_stamp(s: &str) -> Option<(&str, Option)> { + if is_stamp(s) { + return Some((s, None)); + } + let stamp = s.get(..19).filter(|st| is_stamp(st))?; + let digits = s.get(19..)?.strip_prefix('-')?; + let unix: i64 = digits.parse().ok()?; + (unix.to_string() == digits).then_some((stamp, Some(unix))) +} + +/// Auto-saves of project `name` in `dir`, oldest first (by real time, not by the local clock; files +/// without a unix time in their name are older than those with one). pub fn list_auto_saves(dir: &Path, name: &str) -> Vec { let prefix = format!("{}-", file_stem(name)); let Ok(rd) = fs::read_dir(dir) else { return Vec::new() }; - let mut v: Vec<(String, PathBuf)> = rd + let mut v: Vec<(Option, String, PathBuf)> = rd .filter_map(|e| e.ok()) .filter_map(|e| { let f = e.file_name().to_string_lossy().into_owned(); - let stamp = f.strip_prefix(&prefix)?.strip_suffix(".fcproj")?; - is_stamp(stamp).then(|| (stamp.to_string(), e.path())) + let (stamp, unix) = parse_stamp(f.strip_prefix(&prefix)?.strip_suffix(".fcproj")?)?; + Some((unix, stamp.to_string(), e.path())) }) .collect(); v.sort(); - v.into_iter().map(|(_, p)| p).collect() + v.into_iter().map(|(_, _, p)| p).collect() } /// Delete the oldest auto-saves of `name` so at most `max` remain. Returns the deleted paths. @@ -122,14 +151,60 @@ mod tests { assert_eq!(timestamp(-1, 0), "1969-12-31_23-59-59"); } + #[test] + fn out_of_range_years_are_preserved_not_collapsed_to_the_epoch() { + // The old hand-rolled conversion kept years far outside chrono's own range; the refactor + // must too (reviewer FredApps). `10_000_000_000_000` s → 318857, not 1970. + assert_eq!(timestamp(10_000_000_000_000, 0), "318857-05-20_17-46-40"); + assert_eq!(display_time(10_000_000_000_000, 0), "318857-05-20 17:46:40"); + // chrono's own upper edge (year 10000) and a negative value far before it. + assert_eq!(timestamp(253_402_300_800, 0), "10000-01-01_00-00-00"); + assert_eq!(display_time(-10_000_000_000_000, 0), "-314918-08-13 06:13:20"); + // The input extremes the old code accepted (offset 0, so no shift overflow). + assert_eq!(display_time(i64::MAX, 0), "292277026596-12-04 15:30:07"); + assert_eq!(display_time(i64::MIN, 0), "-292277022657-01-27 08:29:52"); + // Only an overflowing `unix + offset_secs` shift (old code wrapped it to garbage) reads as + // the epoch; the separate overflow guard is kept. + assert_eq!(civil(i64::MAX, 3600), (1970, 1, 1, 0, 0, 0)); + assert_eq!(civil(i64::MIN, -3600), (1970, 1, 1, 0, 0, 0)); + } + #[test] fn names_and_stems() { - assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00.fcproj"); + assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00-0.fcproj"); assert_eq!(file_stem("a/b:c"), "a_b_c"); assert_eq!(file_stem(" "), "Untitled"); assert_eq!(auto_save_dir(Path::new("/x/y/p.fcproj")), Path::new("/x/y/Auto-Save")); } + #[test] + fn rotation_survives_the_clock_going_back_at_the_end_of_dst() { + let d = std::env::temp_dir().join(format!("filmcraft-rotate-dst-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + let t0 = 1_792_900_000; + // 02:30 at +02:00, then 40 minutes later the clock reads 02:10 at +01:00. + let old = write_auto_save(&d, "Cut", t0, 7200, b"old", 1).unwrap(); + let new = write_auto_save(&d, "Cut", t0 + 2400, 3600, b"new", 1).unwrap(); + assert!(new.exists(), "the newest auto-save was deleted by rotation"); + assert!(!old.exists(), "the oldest auto-save should be rotated out"); + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn repeated_wall_clock_hour_does_not_overwrite_earlier_saves() { + let d = std::env::temp_dir().join(format!("filmcraft-rotate-dst2-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + let t0 = 1_792_900_000; + // The same wall-clock second, one hour apart (+02:00 then +01:00). + let a = write_auto_save(&d, "Cut", t0, 7200, b"first", 5).unwrap(); + let b = write_auto_save(&d, "Cut", t0 + 3600, 3600, b"second", 5).unwrap(); + assert_ne!(a, b); + assert_eq!(fs::read(&a).unwrap(), b"first"); + assert_eq!(fs::read(&b).unwrap(), b"second"); + assert_eq!(list_auto_saves(&d, "Cut"), vec![a, b]); + fs::remove_dir_all(&d).unwrap(); + } + #[test] fn rotation_keeps_newest_and_ignores_other_files() { let d = std::env::temp_dir().join(format!("filmcraft-rotate-{}", std::process::id())); @@ -142,8 +217,8 @@ mod tests { fs::write(d.join("notes.txt"), b"keep").unwrap(); let left = list_auto_saves(&d, "Cut"); assert_eq!(left.len(), 5); - assert!(left[0].to_string_lossy().ends_with(&format!("Cut-{}.fcproj", timestamp(1_790_000_120, 0)))); - assert!(left[4].to_string_lossy().ends_with(&format!("Cut-{}.fcproj", timestamp(1_790_000_360, 0)))); + assert!(left[0].to_string_lossy().ends_with(&format!("Cut-{}-1790000120.fcproj", timestamp(1_790_000_120, 0)))); + assert!(left[4].to_string_lossy().ends_with(&format!("Cut-{}-1790000360.fcproj", timestamp(1_790_000_360, 0)))); assert_eq!(list_auto_saves(&d, "Cut-Two").len(), 1); assert!(d.join("notes.txt").exists()); rotate(&d, "Cut", 2).unwrap(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 70af3c747..b77968dd1 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -49,7 +49,7 @@ use filmcraft_project::{Project, ProjectView}; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub use atomic::atomic_write; +pub use atomic::{atomic_write, atomic_write_new}; /// The `format` tag written in every envelope. pub const FORMAT_ID: &str = "filmcraft.project"; @@ -216,6 +216,20 @@ pub fn decode(bytes: &[u8]) -> Result { /// Refuse only values that would overflow or exhaust memory later (`Sequence::check_bounds`); /// structural rules such as overlaps are left to the editor, so projects that open today still open. fn validate_loaded(mut loaded: Loaded) -> Result { + // Graphic layers written before they carried a uid get a stable identity now, before any + // undo history exists. The field is optional in the schema, so this is not a schema upgrade. + loaded.project.ensure_graphic_layer_uids(); + // Embedded LUT text is rendered on the hot path. Drop oversize entries at load time so a + // hostile project cannot retain an unrenderable multi-megabyte source or repeatedly offer it + // to renderers. Clips that refer to one simply have no LUT, like a deleted library entry. + let max_lut = filmcraft_project::MAX_LUT_TEXT_BYTES; + loaded.project.luts.retain(|lut| { + let keep = lut.text.len() <= max_lut; + if !keep { + log::warn!("LUT `{}`: {} bytes, dropping entry above {max_lut} byte limit", lut.name, lut.text.len()); + } + keep + }); // Out-of-range audio stream data is repaired, not refused: failing the load would lose the // whole project over one bad index. A media item keeps its first `MAX_AUDIO_STREAMS` streams; a // clip pointing past that plays stream 0. @@ -223,9 +237,14 @@ fn validate_loaded(mut loaded: Loaded) -> Result { for item in loaded.project.items.values_mut() { let name = item.name.clone(); match &mut item.kind { - filmcraft_project::ItemKind::Media(m) if m.info.audio_streams.len() > max => { - log::warn!("media `{name}`: {} audio streams, keeping the first {max}", m.info.audio_streams.len()); - m.info.audio_streams.truncate(max); + filmcraft_project::ItemKind::Media(m) => { + if let Some(video) = &m.info.video { + filmcraft_project::validate_frame_size(video.width, video.height).map_err(|e| FormatError::Corrupt(format!("media `{name}`: {e}")))?; + } + if m.info.audio_streams.len() > max { + log::warn!("media `{name}`: {} audio streams, keeping the first {max}", m.info.audio_streams.len()); + m.info.audio_streams.truncate(max); + } } filmcraft_project::ItemKind::Sequence(sequence) => { let sequence = std::sync::Arc::make_mut(sequence); @@ -244,6 +263,18 @@ fn validate_loaded(mut loaded: Loaded) -> Result { sequence.check_bounds().map_err(|e| FormatError::Corrupt(format!("sequence `{}`: {e}", item.name)))?; } } + // Every new id is `next_id`, read from the file as is. One that lags behind the ids in it (an + // older build, a hand edit) would hand out an id in use, and the new item replace the old (#2809). + let project = &mut loaded.project; + let max = project.max_id(); + let past = max.checked_add(1).ok_or_else(|| FormatError::Corrupt(format!("project ID space exhausted (id {max} in use)")))?; + if project.next_id < past { + log::warn!("next id {} is not past the largest id in the project, raised to {past}", project.next_id); + project.next_id = past; + } + if project.next_id > Project::MAX_NEXT_ID { + return Err(FormatError::Corrupt(format!("project ID space exhausted (next id {})", project.next_id))); + } Ok(loaded) } @@ -409,6 +440,32 @@ mod tests { assert_eq!(loaded.project.sequence(seq).unwrap().video_tracks[0].items.len(), 3); } + /// Opening a project written before graphic layers had uids assigns stable, unique identities + /// without a schema upgrade. The old file still opens, and its two byte-identical layers (same + /// geometry, different text) can no longer be confused after a reorder. + #[test] + fn opening_a_legacy_project_assigns_unique_graphic_layer_uids() { + use filmcraft_project::graphic::{layer_indices, new_text_layer}; + use filmcraft_project::{ItemKind, SequenceSettings, TrackKind}; + use filmcraft_time::{FrameRate, Tick, TimeRange}; + let mut p = Project::new("Legacy graphics"); + let rate = FrameRate::FPS_25; + let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 1, 1, None); + let graphic = p.add_item("Graphic", filmcraft_project::Label::Rose, ItemKind::Graphic { width: 1920, height: 1080, rate }, None); + let mut item = p.make_track_item(graphic, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(100)), rate).unwrap(); + item.effects = vec![new_text_layer("one", Default::default(), 100.0), new_text_layer("two", Default::default(), 100.0)]; + assert!(item.effects.iter().all(|e| filmcraft_project::gtemplate::layer_uid(e) == 0)); + p.sequence_mut(seq).unwrap().video_tracks[0].items = vec![item]; + let loaded = decode(&encode(&p, false)).expect("an old project still opens"); + let item = &loaded.project.sequence(seq).unwrap().video_tracks[0].items[0]; + let uids: Vec = layer_indices(&item.effects).into_iter().map(|i| filmcraft_project::gtemplate::layer_uid(&item.effects[i])).collect(); + assert_eq!(uids.len(), 2); + assert!(uids.iter().all(|uid| *uid != 0), "opening assigned uids: {uids:?}"); + assert_ne!(uids[0], uids[1], "identical geometry and different text still get distinct identities"); + let again = decode(&encode(&loaded.project, false)).expect("the upgraded project round-trips"); + assert_eq!(again.project, loaded.project, "assigned uids are stable across saves"); + } + #[test] fn roundtrip_current() { let p = Project::new("Round Trip"); @@ -422,6 +479,20 @@ mod tests { } } + #[test] + fn oversized_embedded_luts_are_dropped_on_load() { + let mut p = Project::new("LUT limit"); + p.luts.push(filmcraft_project::ProjectLut { + id: "huge".into(), + name: "huge".into(), + source_path: None, + format: "cube".into(), + text: "#".repeat(filmcraft_project::MAX_LUT_TEXT_BYTES + 1).into(), + }); + let loaded = decode(&encode(&p, false)).unwrap(); + assert!(loaded.project.luts.is_empty()); + } + /// What is open is stored beside the project. A file without it, or with one that cannot be /// read, opens all the same. #[test] diff --git a/crates/format/tests/fixtures.rs b/crates/format/tests/fixtures.rs index e99a7853f..fa1b7027d 100644 --- a/crates/format/tests/fixtures.rs +++ b/crates/format/tests/fixtures.rs @@ -35,7 +35,8 @@ fn v1_edit_loads_with_defaults_for_later_fields() { assert_eq!(l.schema_version, 1); let p = &l.project; assert_eq!(p.name, "Legacy Edit"); - assert_eq!(p.next_id, 48); + // the file says 48, but its sequence marker is 800000: new ids start past it (#2809) + assert_eq!(p.next_id, 800_001); let media = p.item(ItemId(5)).unwrap(); assert_eq!(media.name, "Ocean_Sunset.mp4"); assert!(media.metadata.is_empty()); @@ -84,6 +85,20 @@ fn hostile_audio_stream_counts_and_indices_are_repaired_not_rejected() { assert_eq!(loaded.item(ItemId(5)).unwrap().as_media().unwrap().info.audio_streams.len(), filmcraft_media::MAX_AUDIO_STREAMS); } +/// Persisted media metadata is used to allocate synthetic and offline source frames, so it must +/// have the same bounded dimensions as a sequence before the project is opened. +#[test] +fn hostile_media_video_dimensions_are_rejected() { + let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project; + let media = p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap(); + let video = media.info.video.as_mut().unwrap(); + video.width = u32::MAX; + video.height = 1; + + let err = decode(&encode(&p, false)).unwrap_err(); + assert!(matches!(err, FormatError::Corrupt(ref message) if message.contains("media `Ocean_Sunset.mp4`") && message.contains("frame size")), "{err}"); +} + /// Schema 8 (before M3.10): no time interpolation / Hold Filters / Field Options / source channels /// on clips, no audio channel map, subclips without Restrict Trims. #[test] @@ -219,6 +234,8 @@ fn v8_loads_without_transcripts_and_v9_roundtrips_them() { t.normalize(); p.transcripts.insert(ItemId(7), std::sync::Arc::new(t)); let again = decode(&encode(&p, true)).unwrap(); + // the transcript's key is an id in use, so new ids start past it (#2809) + p.next_id = 8; assert!(!again.migrated()); assert_eq!(again.project, p); assert_eq!(again.project.transcripts[&ItemId(7)].speakers[0].name, "Speaker 1"); @@ -259,3 +276,200 @@ fn v10_minimal_loads_with_m3_11_defaults() { assert_eq!(again.schema_version, SCHEMA_VERSION); assert_eq!(&again.project, p); } + +/// #2809: `next_id` is read from the file, and nothing raised it past the ids already in it. A file +/// whose `next_id` lagged behind (an older build, a hand edit, a merge) handed out an id in use, and +/// the new item silently replaced the old one. Each kind of id is raised past on its own, so a kind +/// the loader forgets fails here. +#[test] +fn next_id_is_raised_past_every_id_in_the_file() { + use filmcraft_project::{ + BinId, Camera, Caption, CaptionFormat, CaptionTrack, Label, Marker, MarkerId, MarkerKind, MergedClip, MulticamSource, Narration, ProjectLut, SearchBin, + Track, TrackId, TrackKind, TrackSend, Transition, TransitionId, + }; + const BIG: u64 = 5_000_000; + let base = decode(&fixture("v1-edit.fcproj")).unwrap().project; + let seq = ItemId(21); + let marker = |id| Marker { + id: MarkerId(id), + start: Tick::ZERO, + duration: Tick::ZERO, + name: String::new(), + comment: String::new(), + kind: MarkerKind::Comment, + color: Label::Green, + }; + let kinds: Vec<(&str, Box)> = vec![ + ( + "item", + Box::new(|p| { + p.next_id = BIG; + p.add_item("Layer", Label::Iris, p.item(ItemId(5)).unwrap().kind.clone(), None); + }), + ), + ( + "bin", + Box::new(|p| { + p.next_id = BIG; + p.add_bin("Bin", None); + }), + ), + ("search bin", Box::new(|p| p.search_bins.push(SearchBin { id: BinId(BIG), name: "Find".into(), query: Default::default() }))), + ("media marker", Box::new(move |p| p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap().markers.push(marker(BIG)))), + ("sequence marker", Box::new(move |p| p.sequence_mut(seq).unwrap().markers[0].id = MarkerId(BIG))), + ("track", Box::new(move |p| p.sequence_mut(seq).unwrap().audio_tracks[0].id = TrackId(BIG))), + ("submix track", Box::new(move |p| p.sequence_mut(seq).unwrap().submix_tracks.push(Track::new(TrackId(BIG), TrackKind::Audio, "Submix".into())))), + ("clip", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].id = ClipId(BIG))), + ("link", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].link = Some(BIG))), + ("group", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].group = Some(BIG))), + ("clip marker", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].markers.push(marker(BIG)))), + ( + "transition", + Box::new(move |p| { + let s = p.sequence_mut(seq).unwrap(); + let clip = s.video_tracks[0].items[0].clone(); + let effect = clip.effects[0].clone(); + s.video_tracks[0].transitions.push(Transition { + id: TransitionId(BIG), + effect, + start: clip.start, + duration: Tick::ZERO, + from: None, + to: Some(clip.id), + align: Default::default(), + reverse: false, + }); + }), + ), + ( + "caption track", + Box::new(move |p| p.sequence_mut(seq).unwrap().caption_tracks.push(CaptionTrack::new(TrackId(BIG), "Subtitle".into(), CaptionFormat::Subtitle))), + ), + ( + "caption", + Box::new(move |p| { + let mut track = CaptionTrack::new(TrackId(7), "Subtitle".into(), CaptionFormat::Subtitle); + track.captions.push(Caption { + id: ClipId(BIG), + start: Tick::ZERO, + duration: Tick(1), + text: "Hi".into(), + speaker: None, + cue_id: None, + settings: String::new(), + }); + p.sequence_mut(seq).unwrap().caption_tracks.push(track); + }), + ), + ( + "LUT", + Box::new(|p| p.luts.push(ProjectLut { id: format!("lut{BIG}"), name: "Look".into(), source_path: None, format: "cube".into(), text: "".into() })), + ), + // left behind by a deleted item: a new item must not take its id and inherit them + ("transcript", Box::new(|p| _ = p.transcripts.insert(ItemId(BIG), Default::default()))), + ("source graphic", Box::new(|p| _ = p.source_graphics.insert(ItemId(BIG), Default::default()))), + ( + "narration", + Box::new(|p| { + let narration = Narration { + text: "Hello".into(), + language: "en-US".into(), + voice: "basic-female".into(), + pitch: Default::default(), + pace: 1.0, + speech_duration: Tick(1), + }; + p.narrations.insert(ItemId(BIG), narration); + }), + ), + // only referenced, the target gone: a new item taking that id would be picked up by the reference + ("clip's item", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].item = ItemId(BIG))), + ( + "subclip's parent", + Box::new(|p| { + let range = filmcraft_time::TimeRange::new(Tick::ZERO, Tick(1)); + p.add_item("Subclip", Label::Iris, ItemKind::Subclip { parent: ItemId(BIG), range, restrict_trims: false }, None); + }), + ), + ( + "transition's clip", + Box::new(move |p| { + let s = p.sequence_mut(seq).unwrap(); + let clip = s.video_tracks[0].items[0].clone(); + s.video_tracks[0].transitions.push(Transition { + id: TransitionId(9), + effect: clip.effects[0].clone(), + start: clip.start, + duration: Tick::ZERO, + from: None, + to: Some(ClipId(BIG)), + align: Default::default(), + reverse: false, + }); + }), + ), + ("send target", Box::new(move |p| p.sequence_mut(seq).unwrap().audio_tracks[0].mixer.sends.push(TrackSend::new(TrackId(BIG))))), + ("track output", Box::new(move |p| p.sequence_mut(seq).unwrap().master_mixer.output = Some(TrackId(BIG)))), + ( + "camera's source", + Box::new(move |p| { + let camera = Camera { name: "Cam 1".into(), video_track: None, audio_tracks: Vec::new(), enabled: true, source: Some(ItemId(BIG)) }; + p.sequence_mut(seq).unwrap().multicam = Some(MulticamSource { cameras: vec![camera], ..Default::default() }); + }), + ), + ( + "camera's track", + Box::new(move |p| { + let camera = Camera { name: "Cam 1".into(), video_track: Some(TrackId(BIG)), audio_tracks: Vec::new(), enabled: true, source: None }; + p.sequence_mut(seq).unwrap().multicam = Some(MulticamSource { cameras: vec![camera], ..Default::default() }); + }), + ), + ("merged clip", Box::new(move |p| p.sequence_mut(seq).unwrap().merged = Some(MergedClip { audio: vec![ItemId(BIG)], ..Default::default() }))), + ]; + for (kind, add) in &kinds { + let mut p = base.clone(); + add(&mut p); + p.next_id = 1; + let loaded = decode(&encode(&p, false)).unwrap_or_else(|e| panic!("{kind}: {e}")); + assert_eq!(loaded.project.next_id, BIG + 1, "{kind}"); + } +} + +/// #2809: the case the issue describes. A file whose `next_id` names an item already in it: the next +/// new item used to take that id and replace the item. It gets a fresh id, and both are still there +/// after a save and a reload. +#[test] +fn a_new_item_never_replaces_one_from_a_file_with_a_stale_next_id() { + let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project; + p.next_id = 5; + let mut p = decode(&encode(&p, false)).unwrap().project; + let before = p.items.len(); + let kind = p.item(ItemId(5)).unwrap().kind.clone(); + let new = p.add_item("Copy", filmcraft_project::Label::Iris, kind, None); + assert_ne!(new, ItemId(5)); + assert_eq!(p.items.len(), before + 1, "a new item replaced an old one"); + assert_eq!(p.item(ItemId(5)).unwrap().name, "Ocean_Sunset.mp4"); + let again = decode(&encode(&p, false)).unwrap().project; + assert_eq!(again, p, "the round trip keeps both items and the raised next_id"); + assert!(again.next_id > new.0); +} + +/// #2809: ids past what can ever be allocated. The largest possible id has no successor, and a +/// `next_id` that high overflows `alloc_id`; such a file is refused instead of panicking (debug) or +/// wrapping to 0 (release) on the first new item. +#[test] +fn an_exhausted_id_space_is_refused_on_load() { + use filmcraft_project::MarkerId; + let base = decode(&fixture("v1-edit.fcproj")).unwrap().project; + let mut p = base.clone(); + p.sequence_mut(ItemId(21)).unwrap().markers[0].id = MarkerId(u64::MAX); + let e = decode(&encode(&p, false)).unwrap_err(); + assert!(matches!(&e, FormatError::Corrupt(m) if m.contains("ID space exhausted")), "{e}"); + let mut p = base.clone(); + p.next_id = u64::MAX; + let e = decode(&encode(&p, false)).unwrap_err(); + assert!(matches!(&e, FormatError::Corrupt(m) if m.contains("ID space exhausted")), "{e}"); + let mut p = base; + p.next_id = Project::MAX_NEXT_ID; + assert_eq!(decode(&encode(&p, false)).unwrap().project.next_id, Project::MAX_NEXT_ID, "the last usable next_id still opens"); +} diff --git a/crates/frame/src/lib.rs b/crates/frame/src/lib.rs index 4be8cfb1f..9c8e643a2 100644 --- a/crates/frame/src/lib.rs +++ b/crates/frame/src/lib.rs @@ -139,6 +139,7 @@ impl VideoFrame { /// and bit depth unchanged. Reduced-resolution playback hands the GPU this instead of the full /// picture (a quarter / sixteenth of the upload). None for RGBA frames or `n` < 2. pub fn box_decimated(&self, n: usize) -> Option { + let n = self.bounded_decimation(n); if n < 2 { return None; } @@ -285,7 +286,7 @@ impl VideoFrame { /// `decode` (a colour-managed curve: log, PQ, HLG scene light…) instead of the frame's /// transfer. Float frames are already linear and ignore it. pub fn to_linear_f32_decimated_with(&self, n: usize, decode: Option<&DecodeTable>) -> (usize, usize, Vec) { - let n = n.max(1); + let n = self.bounded_decimation(n); let (ow, oh) = self.decimated_size(n); // every element is written below, so a recycled buffer needs no zero-fill let mut out = pool::take_f32_overwritten(ow * oh * 4); @@ -295,7 +296,7 @@ impl VideoFrame { /// The size of the picture after `n`×`n` box decimation. pub fn decimated_size(&self, n: usize) -> (usize, usize) { - let n = n.max(1); + let n = self.bounded_decimation(n); ((self.width as usize / n).max(1), (self.height as usize / n).max(1)) } @@ -304,7 +305,7 @@ impl VideoFrame { /// by row. Each pixel is exactly what the full conversion gives at that position. The buffer /// may come from the pool ([`pool::recycle_f32`] hands it back). pub fn to_linear_f32_region(&self, n: usize, decode: Option<&DecodeTable>, region: Region) -> (Region, Vec) { - let n = n.max(1); + let n = self.bounded_decimation(n); let (ow, oh) = self.decimated_size(n); let r = region.clip(ow, oh); let mut out = pool::take_f32_overwritten(r.w * r.h * 4); @@ -323,6 +324,7 @@ impl VideoFrame { out.fill(0.0); return; } + let n = n.min(w.max(h)).max(1); if let PixelData::RgbaF32(d) = &self.data && n == 1 { @@ -343,7 +345,7 @@ impl VideoFrame { Some(t) => t.lookup(v), None => lin[(v.clamp(0.0, 1.0) * 4095.0 + 0.5) as usize], }; - let inv = 1.0 / (n * n) as f32; + let inv = 1.0 / (n as f32 * n as f32); match &self.data { PixelData::RgbaF32(d) => { out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| { @@ -440,6 +442,13 @@ impl VideoFrame { let cw = w.div_ceil(1 << sx); let bits = *bits; let amax = ((1u32 << bits) - 1) as f32; + // Per-code tables (exactly the values `normalize_*` returns) instead of two + // divisions per sample; a code past the table (a corrupt plane) is computed. + let codes = if bits <= 12 { 1usize << bits } else { 0 }; + let ytab: Vec = (0..codes as u32).map(|v| normalize_y(v, bits, info.range)).collect(); + let ctab: Vec = (0..codes as u32).map(|v| normalize_c(v, bits, info.range)).collect(); + let ny = |v: u16| ytab.get(usize::from(v)).copied().unwrap_or_else(|| normalize_y(u32::from(v), bits, info.range)); + let nc = |v: u16| ctab.get(usize::from(v)).copied().unwrap_or_else(|| normalize_c(u32::from(v), bits, info.range)); out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| { let oy = r.y + ry; for rx in 0..r.w { @@ -451,9 +460,9 @@ impl VideoFrame { for dx in 0..n { let x = (ox * n + dx).min(w - 1); let cx = x >> sx; - let yy = normalize_y(planes[0][y * w + x] as u32, bits, info.range); - let u = normalize_c(planes[1][cy * cw + cx] as u32, bits, info.range); - let v = normalize_c(planes[2][cy * cw + cx] as u32, bits, info.range); + let yy = ny(planes[0][y * w + x]); + let u = nc(planes[1][cy * cw + cx]); + let v = nc(planes[2][cy * cw + cx]); let rgb = ycbcr_to_rgb(yy, u, v, info.matrix); let a = alpha.as_ref().map_or(1.0, |al| al[y * w + x] as f32 / amax); acc[0] += q(rgb[0]) * a; @@ -477,7 +486,7 @@ impl VideoFrame { /// or its alpha is not stored as integers), so nothing can be skipped. Pixels outside the /// rectangle have alpha exactly 0 and, in the premultiplied conversion, colour 0 as well. pub fn alpha_region(&self, n: usize) -> Option { - let n = n.max(1); + let n = self.bounded_decimation(n); let (w, h) = (self.width as usize, self.height as usize); let px = w.checked_mul(h)?; // the first and last x of non-transparent pixels in source row `y` @@ -502,6 +511,12 @@ impl VideoFrame { Some(Region { x: rx0, y: ry0, w: rx1.saturating_sub(rx0), h: ry1.saturating_sub(ry0) }.clip(ow, oh)) } + /// Keep hostile decimation requests from doing work beyond the source picture. Larger boxes + /// only resample the edge pixels, so clamping them has no useful effect on the reduced output. + fn bounded_decimation(&self, n: usize) -> usize { + n.clamp(1, (self.width as usize).max(self.height as usize).max(1)) + } + /// Convert to straight-alpha sRGB RGBA8 for display (fast paths for 8-bit sources). pub fn to_rgba8(&self) -> Vec { let (w, h) = (self.width as usize, self.height as usize); @@ -830,6 +845,24 @@ mod tests { assert!(f.box_decimated(1).is_none()); assert!(VideoFrame::rgba8(2, 2, vec![0; 16]).box_decimated(2).is_none()); } + + #[test] + fn decimation_caps_hostile_factors_to_the_frame_size() { + let rgba = VideoFrame::rgba8(1, 1, vec![23, 42, 99, 255]); + let (w, h, pixels) = rgba.to_linear_f32_decimated(usize::MAX); + assert_eq!((w, h), (1, 1)); + assert_eq!(pixels, rgba.to_linear_f32()); + + let yuv = VideoFrame { + width: 2, + height: 2, + data: PixelData::Yuv8 { planes: [Arc::new(vec![1, 2, 3, 4]), Arc::new(vec![128]), Arc::new(vec![128])], chroma: Chroma::C420, alpha: None }, + color: ColorInfo::REC709, + par: (1, 1), + pts: Tick::ZERO, + }; + assert_eq!(yuv.box_decimated(usize::MAX).expect("yuv").luma8(), vec![3]); + } use super::*; #[test] diff --git a/crates/golden/Cargo.toml b/crates/golden/Cargo.toml index fc14d15de..b5d01991f 100644 --- a/crates/golden/Cargo.toml +++ b/crates/golden/Cargo.toml @@ -24,5 +24,5 @@ filmcraft-media = { workspace = true } filmcraft-project = { workspace = true } filmcraft-render = { workspace = true } filmcraft-gpu = { workspace = true } -pollster = "0.4" +pollster = "1" wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] } diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index 3f6137f5b..e8425b8bf 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -18,11 +18,11 @@ filmcraft-render = { workspace = true } filmcraft-time = { workspace = true } wgpu = { version = "30", default-features = false, features = ["wgsl"] } log = { workspace = true } -pollster = "0.4" +pollster = "1" rayon = { workspace = true } [dev-dependencies] -pollster = "0.4" +pollster = "1" filmcraft-export = { workspace = true } image = { version = "0.25", default-features = false, features = ["png"] } wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] } diff --git a/crates/gpu/src/blend_tests.rs b/crates/gpu/src/blend_tests.rs index a21c5a2b7..028ceb5ec 100644 --- a/crates/gpu/src/blend_tests.rs +++ b/crates/gpu/src/blend_tests.rs @@ -151,7 +151,8 @@ fn gpu_blend_modes_match_cpu() { 1.0, 24, filmcraft_time::FrameRate::FPS_24, - ); + ) + .unwrap(); let demo = Arc::new(VideoFrame::rgba8(320, 180, pixels)); let base = ramp_layer(200, 180, 3); let top = ramp_layer(160, 120, 0); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 034333ee3..ff7dc97c3 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -116,7 +116,8 @@ fn gpu_matches_cpu_plan() { 1.0, 24, filmcraft_time::FrameRate::FPS_24, - ); + ) + .unwrap(); let rgba = Arc::new(VideoFrame::rgba8(320, 180, pixels)); let (w, h) = (320usize, 180usize); let plan = FramePlan::Layers { @@ -464,7 +465,8 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() { 1.0, 24, filmcraft_time::FrameRate::FPS_24, - ); + ) + .unwrap(); let background = Arc::new(VideoFrame::rgba8(320, 180, pixels)); for bits in [8, 10, 12] { let mut c = GpuCompositor::new(&dev, &q); diff --git a/crates/h264/README.md b/crates/h264/README.md index 21e7a10f1..634a2977f 100644 --- a/crates/h264/README.md +++ b/crates/h264/README.md @@ -108,7 +108,7 @@ with ffmpeg + libx264; tests print a message and skip when `/opt/homebrew/bin/ff explicit bi-prediction. The expected output is known exactly and is also checked against ffmpeg. - Robustness: avcC input with pts round trip; randomly corrupted, truncated and garbage input never panics or deadlocks (single- and multi-threaded). -- Conformance (`tests/conformance.rs`): every fixture is decoded single-threaded, with 3 threads +- Conformance (`tests/it/conformance.rs`): every fixture is decoded single-threaded, with 3 threads and with every core, and compared byte-for-byte with `ffmpeg -f rawvideo -pix_fmt yuv420p`; on mismatch the first differing frame, plane, sample position and macroblock are reported. Output pts/POC order is checked too, and that no picture is flagged draft without draft mode. Draft @@ -164,12 +164,12 @@ encoder is unavailable. | vt_high | testsrc2+noise 640x360 | VideoToolbox, High | | vt_high_1080p | testsrc2 1920x1080 | VideoToolbox, High 8 Mbit/s | -`cargo test --release -p filmcraft-h264 --test conformance coverage_report -- --ignored --nocapture` +`cargo test --release -p filmcraft-h264 --test it conformance::coverage_report -- --ignored --nocapture` prints which coding tools each fixture exercises. ## Performance -`cargo test --release -p filmcraft-h264 --test perf -- --ignored --nocapture` (1080p High profile, +`cargo test --release -p filmcraft-h264 --test it perf:: -- --ignored --nocapture` (1080p High profile, CABAC, B-pyramid, 120 frames, 8.8 Mbit/s; bit-exactness is verified first). Measured on an Apple M4 Pro (14 cores) while the machine was heavily shared (load average 40-180), so these are lower bounds: diff --git a/crates/h264/src/deblock.rs b/crates/h264/src/deblock.rs index ba50be414..063e83e8f 100644 --- a/crates/h264/src/deblock.rs +++ b/crates/h264/src/deblock.rs @@ -232,7 +232,7 @@ fn filter_vertical(pix: &mut [u8], stride: usize, x: usize, y: u let mut w = [0u64; N]; for (i, w) in w.iter_mut().enumerate() { let o = (y + i) * stride + x - 4; - let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else { + let Some(&b) = pix.get(o..).and_then(<[u8]>::first_chunk::<8>) else { return; }; *w = u64::from_le_bytes(b); @@ -259,7 +259,7 @@ fn filter_horizontal(pix: &mut [u8], stride: usize, x: usize, y: let base = (y - 4) * stride + x; let mut s = [[0i16; N]; 8]; for (k, row) in s.iter_mut().enumerate() { - let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::()) else { + let Some(src) = pix.get(base + k * stride..).and_then(<[u8]>::first_chunk::) else { return; }; for i in 0..N { @@ -269,7 +269,7 @@ fn filter_horizontal(pix: &mut [u8], stride: usize, x: usize, y: filter_lanes(&mut s, ep, chroma); let (k0, k1) = if chroma { (3, 5) } else { (1, 7) }; for (k, row) in s.iter().enumerate().take(k1).skip(k0) { - let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::()) else { + let Some(dst) = pix.get_mut(base + k * stride..).and_then(<[u8]>::first_chunk_mut::) else { return; }; for i in 0..N { diff --git a/crates/h264/src/decoder.rs b/crates/h264/src/decoder.rs index 59070d359..135ebfa59 100644 --- a/crates/h264/src/decoder.rs +++ b/crates/h264/src/decoder.rs @@ -18,6 +18,10 @@ use filmcraft_bitstream::{BitReader, annexb_nals, length_prefixed_nals, unescape use std::collections::VecDeque; use std::sync::{Arc, Mutex}; +/// The decoder supports at most 16 reference pictures, so constructing more +/// inferred gap pictures cannot improve a usable reference list. +const MAX_INFERRED_GAP_FRAMES: u32 = 16; + /// One slice of a picture job. struct SliceJob { sh: SliceHeader, @@ -234,7 +238,7 @@ impl Decoder { } } // The access unit is complete: hand its picture to a decoding job. - self.submit_pending(); + self.submit_pending()?; if let Some(e) = self.take_error() { result = result.and(Err(e)); } @@ -254,7 +258,7 @@ impl Decoder { /// Output all remaining pictures (end of stream). pub fn flush(&mut self) -> Vec { - self.submit_pending(); + let _ = self.submit_pending(); let mut outs = Vec::new(); self.dpb.flush(&mut outs); self.emit(outs); @@ -304,7 +308,7 @@ impl Decoder { nal_type::SLICE_DPA | nal_type::SLICE_DPB | nal_type::SLICE_DPC => { return unsupported("data partitioning (Extended profile)"); } - nal_type::END_SEQ | nal_type::END_STREAM => self.submit_pending(), + nal_type::END_SEQ | nal_type::END_STREAM => self.submit_pending()?, // SEI, AUD, filler, SPS extension, prefix NAL, subset SPS, auxiliary and extension slices: skipped. _ => {} } @@ -353,7 +357,7 @@ impl Decoder { Some(p) => is_new_picture(&p.first, &sh, &sps) || sh.first_mb_in_slice == 0 || !Arc::ptr_eq(&p.sps, &sps), }; if new_pic { - self.submit_pending(); + self.submit_pending()?; if sh.first_mb_in_slice != 0 && self.active_sps.is_none() { return invalid("stream does not start with the first slice of a picture"); } @@ -425,6 +429,10 @@ impl Decoder { } else { let max = sps.max_frame_num(); if sh.frame_num != self.prev_ref_frame_num && sh.frame_num != (self.prev_ref_frame_num + 1) % max { + let gap = (sh.frame_num + max - self.prev_ref_frame_num - 1) % max; + if gap > MAX_INFERRED_GAP_FRAMES { + return Err(Error::Invalid("frame number gap exceeds the supported reference limit".into())); + } // frame_num gap (8.2.5.2): insert "non-existing" frames. Their samples are never used by // conforming streams; copy the latest decoded frame for concealment. let meta = Arc::new(output_meta(sps, pts, false)); @@ -464,8 +472,8 @@ impl Decoder { } /// Start decoding the pending picture and do its reference marking / DPB insertion. - fn submit_pending(&mut self) { - let Some(p) = self.pending.take() else { return }; + fn submit_pending(&mut self) -> Result<()> { + let Some(p) = self.pending.take() else { return Ok(()) }; let PendingPic { frame, sps, first, poc, pts, key, slices } = p; let draft = self.draft && first.nal_ref_idc == 0; self.dispatch(frame.clone(), slices, draft); @@ -476,8 +484,9 @@ impl Decoder { let meta = Arc::new(OutputMeta { draft, ..output_meta(&sps, pts, key) }); let mut outs = Vec::new(); let fpoc = frame.poc; - self.dpb.store_picture(&first, frame, fpoc, sps.max_frame_num(), sps.max_num_ref_frames as usize, meta, &mut outs); + self.dpb.store_picture(&first, frame, fpoc, sps.max_frame_num(), sps.max_num_ref_frames as usize, meta, &mut outs)?; self.emit(outs); + Ok(()) } fn dispatch(&mut self, frame: FrameRef, slices: Vec, draft: bool) { diff --git a/crates/h264/src/dpb.rs b/crates/h264/src/dpb.rs index 657aa411a..c712b0424 100644 --- a/crates/h264/src/dpb.rs +++ b/crates/h264/src/dpb.rs @@ -138,8 +138,19 @@ impl Dpb { poc_state: &mut dyn FnMut(u32), ) { let mut unused = (prev_ref_frame_num + 1) % max_frame_num; + // Missing frames before the last `max_num_ref_frames` would be dropped by the sliding window + // (oldest FrameNumWrap first) before the gap ends, so only their POC / frame_num bookkeeping + // runs; building pictures for them would cost up to max_frame_num frames per access unit. + let gap = if frame_num >= unused { frame_num - unused } else { (frame_num % max_frame_num) + max_frame_num - unused }; + let skip = gap.saturating_sub(max_num_ref_frames.max(1).try_into().unwrap_or(u32::MAX)); let mut guard = 0; while unused != frame_num && guard < max_frame_num { + if guard < skip { + poc_state(unused); + unused = (unused + 1) % max_frame_num; + guard += 1; + continue; + } self.sliding_window(unused, max_frame_num, max_num_ref_frames); let (frame, poc, meta) = make_frame(unused); poc_state(unused); @@ -260,7 +271,7 @@ impl Dpb { } /// Adaptive memory control (8.2.5.4). Returns true if MMCO 5 was executed. - fn apply_mmcos(&mut self, mmcos: &[Mmco], cur_frame_num: u32, max_frame_num: u32, cur_long: &mut Option) -> bool { + fn apply_mmcos(&mut self, mmcos: &[Mmco], cur_frame_num: u32, max_frame_num: u32, cur_long: &mut Option) -> Result { let mut had5 = false; let curr_pic_num = cur_frame_num as i32; for m in mmcos { @@ -281,6 +292,7 @@ impl Dpb { } } 3 => { + ensure!(self.max_long_term_frame_idx.is_some_and(|max| m.long_term_frame_idx <= max), "MMCO long-term index exceeds the configured limit"); let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1); for e in &mut self.entries { if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx { @@ -311,6 +323,7 @@ impl Dpb { had5 = true; } 6 => { + ensure!(self.max_long_term_frame_idx.is_some_and(|max| m.long_term_frame_idx <= max), "MMCO long-term index exceeds the configured limit"); for e in &mut self.entries { if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx { e.mark = RefMark::Unused; @@ -322,7 +335,7 @@ impl Dpb { } } self.prune(); - had5 + Ok(had5) } /// Reference marking for the just-decoded picture and insertion into the DPB with output bumping. @@ -337,7 +350,7 @@ impl Dpb { max_num_ref_frames: usize, meta: Arc, out: &mut Vec>, - ) { + ) -> Result<()> { let mut mark = RefMark::Unused; let mut long_idx = 0; let mut had5 = false; @@ -353,7 +366,7 @@ impl Dpb { } else { let mut cur_long = None; if sh.adaptive_ref_pic_marking { - had5 = self.apply_mmcos(&sh.mmcos, sh.frame_num, max_frame_num, &mut cur_long); + had5 = self.apply_mmcos(&sh.mmcos, sh.frame_num, max_frame_num, &mut cur_long)?; } else { self.sliding_window(sh.frame_num, max_frame_num, max_num_ref_frames); } @@ -369,6 +382,9 @@ impl Dpb { } } } + if mark == RefMark::Long { + ensure!(self.num_refs() < max_num_ref_frames.max(1), "long-term reference exceeds max_num_ref_frames"); + } if had5 { // All earlier pictures are output before the current one (C.4.4). self.flush(out); @@ -385,7 +401,7 @@ impl Dpb { let min_poc = self.entries.iter().filter(|e| e.needed_for_output).map(|e| e.poc).min(); if min_poc.is_none_or(|m| poc < m) { out.push(Output { frame, poc, meta }); - return; + return Ok(()); } if !self.bump(out) { break; @@ -405,6 +421,7 @@ impl Dpb { break; } } + Ok(()) } } @@ -413,3 +430,68 @@ impl Default for Dpb { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn meta() -> Arc { + Arc::new(OutputMeta { + pts: 0, + key: false, + crop: (0, 0, 0, 0), + full_range: false, + colour_primaries: 2, + transfer_characteristics: 2, + matrix_coefficients: 2, + sar: (1, 1), + draft: false, + }) + } + + /// A huge frame_num jump must only materialise the frames the sliding window keeps (#2779). + #[test] + fn frame_num_gap_materialises_at_most_max_num_ref_frames() { + let mut dpb: Dpb = Dpb::new(); + let m = meta(); + let mut made = 0u32; + let mut make = |f: u32| { + made += 1; + (f, 0, m.clone()) + }; + let mut updated = Vec::new(); + let mut upd = |f: u32| updated.push(f); + dpb.fill_frame_num_gap(0, 32768, 1 << 16, 4, &mut make, &mut upd); + assert!(made <= 4, "gap fill materialised {made} frames, expected <= 4"); + assert_eq!(updated, (1..32768).collect::>()); + let mut refs: Vec = dpb.entries.iter().filter(|e| e.mark == RefMark::Short).map(|e| e.frame_num).collect(); + refs.sort_unstable(); + assert_eq!(refs, vec![32764, 32765, 32766, 32767]); + assert!(dpb.entries.iter().all(|e| e.frame == e.frame_num)); + } + + /// Existing short-term references are still evicted by a gap no longer than the window. + #[test] + fn short_frame_num_gap_keeps_sliding_window_semantics() { + let mut dpb: Dpb = Dpb::new(); + let m = meta(); + for f in 0..2 { + dpb.entries.push(DpbEntry { + frame: f, + poc: 0, + frame_num: f, + long_term_frame_idx: 0, + mark: RefMark::Short, + needed_for_output: false, + non_existing: false, + meta: m.clone(), + }); + } + let mut make = |f: u32| (f, 0, m.clone()); + let mut upd = |_f: u32| {}; + dpb.fill_frame_num_gap(1, 5, 16, 4, &mut make, &mut upd); + let mut refs: Vec = dpb.entries.iter().filter(|e| e.mark == RefMark::Short).map(|e| e.frame_num).collect(); + refs.sort_unstable(); + assert_eq!(refs, vec![1, 2, 3, 4]); + } +} diff --git a/crates/h264/src/params.rs b/crates/h264/src/params.rs index 5b92a6055..236b7b928 100644 --- a/crates/h264/src/params.rs +++ b/crates/h264/src/params.rs @@ -4,6 +4,10 @@ use crate::error::{Result, ensure, unsupported}; use crate::tables::*; use filmcraft_bitstream::BitReader; +/// Maximum supported reconstructed luma area. 8K UHD fits comfortably while +/// hostile SPS dimensions cannot allocate full pictures and decoder work state. +const MAX_LUMA_SAMPLES: u64 = 1 << 25; + /// Scaling matrices after applying fall-back rules, stored in raster order (weightScale). #[derive(Clone, Debug, PartialEq, Eq)] pub struct ScalingMatrices { @@ -516,6 +520,9 @@ impl Sps { } pub fn check_supported(&self) -> Result<()> { + if u64::from(self.width()).saturating_mul(u64::from(self.height())) > MAX_LUMA_SAMPLES { + return unsupported("picture dimensions exceed the decoder memory limit"); + } if !self.frame_mbs_only { return unsupported("interlaced (field / MBAFF) coding"); } diff --git a/crates/h264/src/slicedec.rs b/crates/h264/src/slicedec.rs index da0af3316..2d6e3c6da 100644 --- a/crates/h264/src/slicedec.rs +++ b/crates/h264/src/slicedec.rs @@ -888,7 +888,7 @@ impl<'a> SliceDecoder<'a> { let stride = self.pic.planes.width; let px = self.mb_x * 16 + (raster & 3) * 4; let py = self.mb_y * 16 + (raster >> 2) * 4; - let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(|c| c.first_chunk_mut::<16>()) else { + let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(<[i32]>::first_chunk_mut::<16>) else { return; }; transform::idct4_add(blk, &mut self.pic.planes.y[py * stride + px..], stride); @@ -902,7 +902,7 @@ impl<'a> SliceDecoder<'a> { let stride = self.pic.planes.width; let px = self.mb_x * 16 + (b8 & 1) * 8; let py = self.mb_y * 16 + (b8 >> 1) * 8; - let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(|c| c.first_chunk_mut::<64>()) else { + let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(<[i32]>::first_chunk_mut::<64>) else { return; }; transform::idct8_add(blk, &mut self.pic.planes.y[py * stride + px..], stride); @@ -921,7 +921,7 @@ impl<'a> SliceDecoder<'a> { } let px = self.mb_x * 8 + (b & 1) * 4; let py = self.mb_y * 8 + (b >> 1) * 4; - let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(|c| c.first_chunk_mut::<16>()) else { + let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(<[i32]>::first_chunk_mut::<16>) else { continue; }; let plane = if c == 0 { &mut self.pic.planes.cb } else { &mut self.pic.planes.cr }; diff --git a/crates/h264/tests/fuzz_hostile.rs b/crates/h264/tests/fuzz_hostile.rs new file mode 100644 index 000000000..476683a39 --- /dev/null +++ b/crates/h264/tests/fuzz_hostile.rs @@ -0,0 +1,202 @@ +//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and +//! random mutation of small libx264 streams must never panic or run away. Skips without ffmpeg. + +#[path = "it/common/mod.rs"] +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{Command, Stdio}; + +use filmcraft_h264::Decoder; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// (name, extra x264 args, pix_fmt) +const SEEDS: &[(&str, &str, &str)] = &[ + ("h264_fz_cabac_b", "-profile:v high -bf 2 -g 8 -x264-params cabac=1:weightp=2:8x8dct=1", "yuv420p"), + ("h264_fz_cavlc", "-profile:v baseline -g 6 -x264-params cabac=0", "yuv420p"), + ("h264_fz_interlaced", "-profile:v high -flags +ildct+ilme -bf 1 -g 6 -x264-params tff=1:interlaced=1", "yuv420p"), + ("h264_fz_10bit_422", "-profile:v high422 -bf 1 -g 6", "yuv422p10le"), + ("h264_fz_multislice", "-profile:v main -g 6 -x264-params slices=3:cabac=1", "yuv420p"), +]; + +fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option> { + let out = filmcraft_testkit::fixtures_dir("h264").join(format!("{name}.h264")); + let p = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let a: Vec<&str> = args.split_whitespace().collect(); + matches!( + Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", "libx264"]) + .args(a) + .args(["-f", "h264"]) + .arg(tmp) + .stdin(Stdio::null()) + .status(), + Ok(s) if s.success() + ) + })?; + std::fs::read(p).ok() +} + +static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + for threads in [1, 3] { + let mut dec = Decoder::with_threads(threads); + for au in common::split_access_units(d) { + let _ = dec.decode(au, 0); + } + let _ = dec.decode(d, 1); + let _ = dec.flush(); + let _ = dec.take_error(); + } + })); + assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=400` runs all of it (see [`Depth`]). +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("h264 fuzz") else { return }; + let depth = Depth::from_env(); + for &(name, args, pix) in SEEDS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libx264") { + filmcraft_testkit::skip("h264 fuzz", &format!("this ffmpeg has no libx264 encoder (seed {name})")); + continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libx264 but could not encode the {name} seed"); + }; + for n in depth.truncations(seed.len()) { + run_all(&seed[..n], &format!("{name} truncate {n}")); + } + header_flips(&depth, &seed, name); + random_rounds(&depth, &seed, name); + } +} + +/// SPS/PPS/first slice header region: every byte to a few values, every bit. +fn header_flips(depth: &Depth, seed: &[u8], name: &str) { + for p in 0..seed.len().min(120) { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } + let mut d = seed.to_vec(); + d[p] = v; + run_all(&d, &format!("{name} byte {p}={v:#x}")); + } + for b in 0..8 { + if !depth.bit(p, b) { + continue; + } + let mut d = seed.to_vec(); + d[p] ^= 1 << b; + run_all(&d, &format!("{name} flip {p}.{b}")); + } + } +} + +fn random_rounds(depth: &Depth, seed: &[u8], name: &str) { + let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { + let mut d = seed.to_vec(); + for _ in 0..1 + rng.below(6) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + if depth.round(i) { + run_all(&d, &format!("{name} random {i}")); + } + } +} + +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 300 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 400 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 300, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=400 runs everything)"); + Depth { full: false, truncations: 300, rounds: 400, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) +} diff --git a/crates/h264/tests/common/mod.rs b/crates/h264/tests/it/common/mod.rs similarity index 99% rename from crates/h264/tests/common/mod.rs rename to crates/h264/tests/it/common/mod.rs index e100c3289..2de4d62d2 100644 --- a/crates/h264/tests/common/mod.rs +++ b/crates/h264/tests/it/common/mod.rs @@ -64,7 +64,7 @@ pub const FIXTURES: &[Fixture] = &[ filter: "", args: &["-c:v", "h264_videotoolbox", "-profile:v", "high", "-b:v", "8M"], }, - // performance reference stream (see tests/perf.rs) + // performance reference stream (see tests/it/perf.rs) Fixture { name: "bench_1080p", source: "testsrc2", diff --git a/crates/h264/tests/conformance.rs b/crates/h264/tests/it/conformance.rs similarity index 99% rename from crates/h264/tests/conformance.rs rename to crates/h264/tests/it/conformance.rs index c06694b79..a55d9fe40 100644 --- a/crates/h264/tests/conformance.rs +++ b/crates/h264/tests/it/conformance.rs @@ -1,6 +1,6 @@ //! Bit-exactness against ffmpeg's decoder on libx264-generated fixtures (skipped when ffmpeg is absent). -mod common; +use crate::common; macro_rules! fixture_tests { ($($name:ident),* $(,)?) => { diff --git a/crates/h264/tests/headers.rs b/crates/h264/tests/it/headers.rs similarity index 99% rename from crates/h264/tests/headers.rs rename to crates/h264/tests/it/headers.rs index f0f7a59e3..f27a7ed01 100644 --- a/crates/h264/tests/headers.rs +++ b/crates/h264/tests/it/headers.rs @@ -1,6 +1,6 @@ //! Parameter set / slice header / POC parsing over the fixture streams. -mod common; +use crate::common; use filmcraft_bitstream::{annexb_nals, unescape_rbsp}; use filmcraft_h264::params::{Pps, Sps}; diff --git a/crates/h264/tests/it/main.rs b/crates/h264/tests/it/main.rs new file mode 100644 index 000000000..ceefd95be --- /dev/null +++ b/crates/h264/tests/it/main.rs @@ -0,0 +1,6 @@ +mod common; +mod conformance; +mod headers; +mod perf; +mod pps_validation; +mod robustness; diff --git a/crates/h264/tests/perf.rs b/crates/h264/tests/it/perf.rs similarity index 91% rename from crates/h264/tests/perf.rs rename to crates/h264/tests/it/perf.rs index c8ed4beb8..da744c24b 100644 --- a/crates/h264/tests/perf.rs +++ b/crates/h264/tests/it/perf.rs @@ -1,7 +1,7 @@ //! Decode speed on a 1080p High-profile stream (ignored by default; run with -//! `cargo test --release -p filmcraft-h264 --test perf -- --ignored --nocapture`). +//! `cargo test --release -p filmcraft-h264 --test it perf:: -- --ignored --nocapture`). -mod common; +use crate::common; use filmcraft_h264::Decoder; use std::time::Instant; diff --git a/crates/h264/tests/pps_validation.rs b/crates/h264/tests/it/pps_validation.rs similarity index 100% rename from crates/h264/tests/pps_validation.rs rename to crates/h264/tests/it/pps_validation.rs diff --git a/crates/h264/tests/robustness.rs b/crates/h264/tests/it/robustness.rs similarity index 99% rename from crates/h264/tests/robustness.rs rename to crates/h264/tests/it/robustness.rs index 902ab191d..898d50bef 100644 --- a/crates/h264/tests/robustness.rs +++ b/crates/h264/tests/it/robustness.rs @@ -1,6 +1,6 @@ //! avcC / length-prefixed input and resilience against damaged streams. -mod common; +use crate::common; use filmcraft_bitstream::annexb_nals; use filmcraft_h264::Decoder; diff --git a/crates/h264enc/examples/h264enc_synth.rs b/crates/h264enc/examples/h264enc_synth.rs index 15ab55fd9..3d1b25f4c 100644 --- a/crates/h264enc/examples/h264enc_synth.rs +++ b/crates/h264enc/examples/h264enc_synth.rs @@ -2,7 +2,7 @@ //! //! `cargo run --release -p filmcraft-h264enc --example h264enc_synth -- W H FRAMES [profile] [preset] [bframes] [rate] [slices] [src]` //! profile: baseline|main|high, preset: speed|balanced|quality, rate: qpN | crfN | cbrN | vbrN -#[path = "../tests/common/mod.rs"] +#[path = "../tests/it/common/mod.rs"] mod common; use filmcraft_h264enc::*; diff --git a/crates/h264enc/src/ratecontrol.rs b/crates/h264enc/src/ratecontrol.rs index 9f77a6813..0fb27216c 100644 --- a/crates/h264enc/src/ratecontrol.rs +++ b/crates/h264enc/src/ratecontrol.rs @@ -81,7 +81,7 @@ pub enum RcKind { Qp(u8), Crf(f32), Abr { kbps: u32 }, - TwoPass { kbps: u32, plan: Vec, planned_bits: Vec }, + TwoPass { kbps: u32, plan: Vec, planned_bits: Vec, planned_total: f64, remaining_bits: Vec }, } pub struct RateControl { @@ -153,8 +153,15 @@ impl RateControl { } } let plan = qs((0.5 * (lo + hi)).exp()); - let planned_bits = stats.frames.iter().zip(&plan).map(|(f, &q)| f.bits as f64 * f.qscale / q).collect(); - RcKind::TwoPass { kbps, plan, planned_bits } + let planned_bits: Vec = stats.frames.iter().zip(&plan).map(|(f, &q)| f.bits as f64 * f.qscale / q).collect(); + let planned_total = planned_bits.iter().sum(); + // `remaining_bits[i]` is the inclusive suffix for frame `i`; retain an + // empty suffix so callers can index it without a special case. + let mut remaining_bits = vec![0.0; planned_bits.len() + 1]; + for i in (0..planned_bits.len()).rev() { + remaining_bits[i] = remaining_bits[i + 1] + planned_bits[i]; + } + RcKind::TwoPass { kbps, plan, planned_bits, planned_total, remaining_bits } } /// Choose the frame QP (fractional) for frame `index` (display order) of `t` with lookahead complexity `cplx`. @@ -198,17 +205,17 @@ impl RateControl { let q = rf * cplx.powf(1.0 - QCOMP) * type_factor(t); qscale2qp(q) } - RcKind::TwoPass { kbps, plan, planned_bits } => { + RcKind::TwoPass { kbps, plan, planned_bits, planned_total, remaining_bits } => { // Closed-loop second pass: `m` measures how far real frame sizes deviate from the first-pass model // (bits ∝ 1/qscale) at the qscales actually used; the remaining plan is rescaled so its predicted // size fits the remaining budget. let i = index_in_coding_order.min(plan.len().saturating_sub(1)); let bpf = *kbps as f64 * 1000.0 / self.fps; - let target_total: f64 = planned_bits.iter().sum(); + let target_total = *planned_total; let smooth = 0.5 * bpf * self.fps.max(1.0); let m = ((self.total_bits + smooth) / (self.model_done + smooth)).clamp(0.3, 3.0); let inflight: f64 = self.inflight.iter().sum(); - let remaining_plan: f64 = planned_bits[i..].iter().sum(); + let remaining_plan = remaining_bits[i]; let remaining_budget = (target_total - self.total_bits - m * inflight).max(0.05 * remaining_plan.max(1.0)); let f = (m * remaining_plan / remaining_budget).clamp(0.25, 4.0); let q = plan[i] * f; @@ -271,3 +278,31 @@ impl RateControl { self.pass1.push(FrameStat { slice_type: t, qscale: qp2qscale(qp), bits, cplx }); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_pass_plan_caches_total_and_inclusive_suffixes() { + let stats = PassStats { + fps: 24.0, + frames: vec![ + FrameStat { slice_type: SliceType::I, qscale: 1.0, bits: 2_000, cplx: 20.0 }, + FrameStat { slice_type: SliceType::P, qscale: 1.5, bits: 1_000, cplx: 10.0 }, + FrameStat { slice_type: SliceType::B, qscale: 2.0, bits: 500, cplx: 5.0 }, + ], + }; + + let RcKind::TwoPass { planned_bits, planned_total, remaining_bits, .. } = RateControl::plan_two_pass(&stats, 1_000) else { + panic!("two-pass planning must return a two-pass rate-control plan"); + }; + + assert_eq!(remaining_bits.len(), planned_bits.len() + 1); + assert!((planned_total - planned_bits.iter().sum::()).abs() < f64::EPSILON); + for i in 0..planned_bits.len() { + assert!((remaining_bits[i] - planned_bits[i..].iter().sum::()).abs() < f64::EPSILON); + } + assert_eq!(remaining_bits[planned_bits.len()], 0.0); + } +} diff --git a/crates/h264enc/tests/common/mod.rs b/crates/h264enc/tests/it/common/mod.rs similarity index 100% rename from crates/h264enc/tests/common/mod.rs rename to crates/h264enc/tests/it/common/mod.rs diff --git a/crates/h264enc/tests/crf_level_and_note.rs b/crates/h264enc/tests/it/crf_level_and_note.rs similarity index 98% rename from crates/h264enc/tests/crf_level_and_note.rs rename to crates/h264enc/tests/it/crf_level_and_note.rs index b93ebf66b..58306c073 100644 --- a/crates/h264enc/tests/crf_level_and_note.rs +++ b/crates/h264enc/tests/it/crf_level_and_note.rs @@ -1,7 +1,7 @@ //! Constant quality has no bitrate cap, so the level must allow what low factors spend; and every //! stream says which settings made it. -mod common; +use crate::common; use common::synth; use filmcraft_h264enc::*; diff --git a/crates/h264enc/tests/ffmpeg_oracle.rs b/crates/h264enc/tests/it/ffmpeg_oracle.rs similarity index 99% rename from crates/h264enc/tests/ffmpeg_oracle.rs rename to crates/h264enc/tests/it/ffmpeg_oracle.rs index e19cfc49b..409bf4102 100644 --- a/crates/h264enc/tests/ffmpeg_oracle.rs +++ b/crates/h264enc/tests/it/ffmpeg_oracle.rs @@ -3,7 +3,7 @@ //! Every stream must decode with zero errors, with the exact frame count, and the decoder's output must be //! bit-identical to the encoder's own reconstruction (catches prediction/transform/deblocking mismatches). -mod common; +use crate::common; use common::{Yuv, annexb_nals, ffmpeg, ffmpeg_decode, ffmpeg_source, ffprobe, out_dir, psnr, synth}; use filmcraft_h264enc::*; diff --git a/crates/h264enc/tests/it/main.rs b/crates/h264enc/tests/it/main.rs new file mode 100644 index 000000000..f673a96da --- /dev/null +++ b/crates/h264enc/tests/it/main.rs @@ -0,0 +1,3 @@ +mod common; +mod crf_level_and_note; +mod ffmpeg_oracle; diff --git a/crates/hevc/README.md b/crates/hevc/README.md index 43c266eda..347037d02 100644 --- a/crates/hevc/README.md +++ b/crates/hevc/README.md @@ -1,7 +1,7 @@ # filmcraft-hevc Clean-room, pure-Rust (no `unsafe`) H.265 / HEVC decoder, implemented from the public ITU-T -Rec. H.265 (ISO/IEC 23008-2) specification. It is an L0 crate: it depends only on +Rec. H.265 (08/2021, ISO/IEC 23008-2) specification. It is an L0 crate: it depends only on `filmcraft-bitstream`, `thiserror`, and optionally `rayon` (default feature `threads`), and builds for `wasm32-unknown-unknown`. @@ -69,6 +69,11 @@ the memory traffic in prediction reads, in-loop filters and output) and as `u16` slice decoder and the filters are generic over the sample type (`picture::Sample`), monomorphised once per type. +Unavailable references keep distinct frame IDs and POCs while sharing one immutable gray backing +for the active picture format. This backing is created only when a reference is missing, replaced +on geometry or plane-depth changes, and released on flush. Jobs still reading an older format keep +its rows alive. Each backing retains the full coded picture dimensions. + Draft mode (`Decoder::set_draft(true)`; FilmCraft enables it with Settings ▸ Playback ▸ Draft decoding while the Program monitor plays at 1/2 or 1/4): sub-layer non-reference pictures (TRAIL_N, RASL_N, ...) of the highest temporal sub-layer — no later picture predicts from them, @@ -96,7 +101,7 @@ with ffmpeg; tests print a message and skip when ffmpeg is absent). ffmpeg. - Robustness: hvcC input with pts round trip; randomly corrupted, truncated and garbage input never panics or deadlocks (single- and multi-threaded). -- Conformance (`tests/conformance.rs`): every fixture is decoded with 1 thread, 3 threads and every +- Conformance (`tests/it/conformance.rs`): every fixture is decoded with 1 thread, 3 threads and every core and compared sample-exactly with `ffmpeg -f rawvideo -pix_fmt yuv420p / yuv420p10le`; on mismatch the first differing frame, plane, sample, CTB and 8x8 block are reported. Output pts/POC order is checked too, and that no picture is flagged draft without draft mode. `compare_detects_mismatch` @@ -135,12 +140,12 @@ libx265 unless noted (x265 enables WPP by default, so most streams carry entry p | vt_main / vt_main10 | 640x360 | Apple VideoToolbox (hevc_videotoolbox), macOS only | | synthetic (7 streams) | 96x64 | tiles, PCM, slices, dependent segments, LTR, list modification | -`cargo test -p filmcraft-hevc --test conformance coverage_report -- --ignored --nocapture` prints the +`cargo test -p filmcraft-hevc --test it conformance::coverage_report -- --ignored --nocapture` prints the decoder statistics per fixture. ## Performance -`cargo test --release -p filmcraft-hevc --test perf -- --ignored --nocapture` (bit-exactness is +`cargo test --release -p filmcraft-hevc --test it perf:: -- --ignored --nocapture` (bit-exactness is verified first). Apple M4 Pro (14 cores), measured while the machine was heavily shared (load average 50-100), so these are lower bounds: diff --git a/crates/hevc/src/decoder.rs b/crates/hevc/src/decoder.rs index 97a0fb7f9..f3a56f2d7 100644 --- a/crates/hevc/src/decoder.rs +++ b/crates/hevc/src/decoder.rs @@ -77,6 +77,8 @@ pub struct Decoder { pending: Option, next_id: u32, active_sps: Option>, + /// One lazily allocated gray backing for unavailable references in the active format. + gray_reference: Option, first_picture: bool, after_eos: bool, /// RASL pictures associated with the last IRAP are skipped (it had NoRaslOutputFlag = 1). @@ -132,6 +134,7 @@ impl Decoder { pending: None, next_id: 1, active_sps: None, + gray_reference: None, first_picture: true, after_eos: false, skip_rasl: false, @@ -225,6 +228,7 @@ impl Decoder { f.wait_complete(); } self.first_picture = true; + self.gray_reference = None; pics } @@ -340,6 +344,9 @@ impl Decoder { )?; sps.check_supported()?; pps.check_supported()?; + // `start_picture` creates the frame and DPB state. Do not allocate those for a NAL that + // contains only a syntactically valid slice header and no CABAC payload. + ensure!(sh.data_offset < rbsp.len(), "empty slice segment data"); if first { self.start_picture(&sh, &sps, &pps, pts)?; } else { @@ -389,6 +396,9 @@ impl Decoder { if changed && self.active_sps.is_some() { self.dpb.flush(&mut outs); } + if changed { + self.gray_reference = None; + } self.active_sps = Some(sps.clone()); self.dpb.max_dec_pic_buffering = sps.max_dec_pic_buffering as usize; self.dpb.max_num_reorder = sps.max_num_reorder as usize; @@ -409,12 +419,14 @@ impl Decoder { let (w, h) = (sps.width as usize, sps.height as usize); let template = Frame::new(0, 0, w, h, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma); let next_id = &mut self.next_id; + let gray_reference = &mut self.gray_reference; let mut missing = 0u64; let mut make_missing = |p: i32| -> FrameRef { missing += 1; let id = *next_id; *next_id += 1; - Arc::new(Frame::gray(id, p, &template)) + let gray = gray_reference.get_or_insert_with(|| Frame::gray(0, 0, &template)); + Arc::new(gray.with_identity(id, p)) }; let rps = self.dpb.apply_rps(sh, poc, max_lsb, irap_no_rasl, &mut make_missing)?; if missing > 0 { @@ -632,6 +644,141 @@ fn make_picture(o: &Output) -> Picture { mod tests { use super::*; + fn missing_refs(decoder: &mut Decoder, poc: u32, deltas: &[i32]) -> Vec { + let stream = crate::synth_tests::missing_reference_picture(poc, deltas); + for nal in annexb_nals(&stream) { + decoder.handle_nal(nal, i64::from(poc)).unwrap(); + } + decoder.pending.as_ref().unwrap().rps.st_curr_before.iter().map(|r| r.frame.clone()).collect() + } + + fn assert_shared_gray_rows(a: &Frame, b: &Frame) { + fn check(a: &Frame, b: &Frame) { + let (gy, gc) = (1u16 << (a.bit_depth - 1), 1u16 << (a.bit_depth_c - 1)); + for row in 0..a.num_rows() { + let a = a.row::(row).unwrap(); + let b = b.row::(row).unwrap(); + assert!(a.y.iter().all(|v| v.to_u16() == gy)); + assert!(a.cb.iter().chain(a.cr.iter()).all(|v| v.to_u16() == gc)); + assert!(a.col.iter().all(|v| v.flags == 0)); + assert!(std::ptr::eq(a.y.as_ref(), b.y.as_ref()), "missing references duplicated luma row {row}"); + assert!(std::ptr::eq(a.cb.as_ref(), b.cb.as_ref()), "missing references duplicated Cb row {row}"); + assert!(std::ptr::eq(a.cr.as_ref(), b.cr.as_ref()), "missing references duplicated Cr row {row}"); + assert!(std::ptr::eq(a.col.as_ref(), b.col.as_ref()), "missing references duplicated motion row {row}"); + } + } + assert_eq!(a.num_rows(), b.num_rows()); + assert!(a.is_complete() && b.is_complete()); + assert_eq!((a.width, a.height, a.log2_ctb, a.bit_depth, a.bit_depth_c), (b.width, b.height, b.log2_ctb, b.bit_depth, b.bit_depth_c)); + if stores_u8(a.bit_depth, a.bit_depth_c) { + check::(a, b); + } else { + check::(a, b); + } + } + + fn backing(frame: &Frame) -> usize { + if stores_u8(frame.bit_depth, frame.bit_depth_c) { + frame.row::(0).unwrap().y.as_ptr() as usize + } else { + frame.row::(0).unwrap().y.as_ptr() as usize + } + } + + #[test] + /// Missing references keep their identities while sharing one small immutable gray backing. + fn missing_reference_rows_are_shared_with_distinct_identities() { + for threads in [1, 4] { + let mut decoder = Decoder::with_threads(threads); + let refs = missing_refs(&mut decoder, 4, &[-1, -2]); + assert_eq!(refs.len(), 2); + assert_eq!((refs[0].poc, refs[1].poc), (3, 2)); + assert_ne!(refs[0].id, refs[1].id); + assert_shared_gray_rows(&refs[0], &refs[1]); + } + } + + #[test] + /// Concurrent/pending pictures must not each allocate their own full gray samples. + fn missing_reference_rows_are_shared_across_pictures() { + for threads in [1, 4] { + let mut decoder = Decoder::with_threads(threads); + let first = missing_refs(&mut decoder, 4, &[-1, -2]); + let second = missing_refs(&mut decoder, 5, &[-2, -3]); + assert_eq!((first.len(), second.len()), (2, 2)); + assert_eq!((second[0].poc, second[1].poc), (3, 2)); + let ids: std::collections::HashSet<_> = first.iter().chain(second.iter()).map(|f| f.id).collect(); + assert_eq!(ids.len(), 4); + for frame in first.iter().chain(second.iter()) { + assert_shared_gray_rows(&first[0], frame); + } + let pictures = decoder.flush(); + assert_eq!(pictures.len(), 2); + for picture in pictures { + assert!(picture.y.as_u8().unwrap().iter().chain(picture.u.as_u8().unwrap()).chain(picture.v.as_u8().unwrap()).all(|&v| v == 128)); + } + assert!(decoder.take_error().is_none()); + assert_eq!(decoder.stats().missing_refs, 4); + } + } + + #[test] + /// Geometry and independent plane-depth changes replace the cache without invalidating readers. + fn missing_reference_cache_tracks_format_and_keeps_old_rows_alive() { + let mut decoder = Decoder::with_threads(1); + let mut held = missing_refs(&mut decoder, 4, &[-1, -2]); + let original = decoder.pending.take().unwrap(); + let mut previous = (96, 64, 4, 8, 8); + let formats = [ + (96, 64, 4, 8, 8), + (128, 64, 4, 8, 8), + (128, 32, 4, 8, 8), + (128, 32, 5, 8, 8), + (128, 32, 5, 10, 8), + (128, 32, 5, 10, 10), + (128, 32, 5, 8, 10), + (128, 32, 5, 8, 8), + ]; + for format in formats { + let mut sps = (*original.sps).clone(); + (sps.width, sps.height, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma) = format; + decoder.start_picture(&original.first_sh, &Arc::new(sps), &original.pps, 0).unwrap(); + let pending = decoder.pending.take().unwrap(); + let refs: Vec<_> = pending.rps.st_curr_before.iter().map(|r| r.frame.clone()).collect(); + assert_shared_gray_rows(&refs[0], &refs[1]); + if format == previous { + assert_eq!(backing(held.last().unwrap()), backing(&refs[0])); + } else { + assert_ne!(backing(held.last().unwrap()), backing(&refs[0])); + } + assert_ne!(held.last().unwrap().id, refs[0].id); + held.extend(refs); + previous = format; + } + for frame in held { + assert_shared_gray_rows(&frame, &frame); + } + } + + #[test] + /// Ordinary streams allocate no gray cache; flush releases it even with retained reference rows. + fn missing_reference_cache_is_lazy_and_released_on_flush() { + let mut decoder = Decoder::with_threads(1); + let stream = crate::synth_tests::missing_reference_picture(0, &[]); + let mut pictures = decoder.decode(&stream, 0).unwrap(); + assert!(decoder.gray_reference.is_none()); + pictures.extend(decoder.flush()); + assert_eq!(pictures.len(), 1); + let refs = missing_refs(&mut decoder, 4, &[-1, -2]); + assert!(decoder.gray_reference.is_some()); + assert_eq!(decoder.flush().len(), 1); + assert!(decoder.gray_reference.is_none()); + let next = missing_refs(&mut decoder, 7, &[-1, -2]); + assert_ne!(backing(&refs[0]), backing(&next[0])); + assert_shared_gray_rows(&refs[0], &refs[1]); + assert_shared_gray_rows(&next[0], &next[1]); + } + /// The branch-free block counts classify every flag combination as the if / else-if chain did. #[test] fn block_counts_match_the_classification_chain() { diff --git a/crates/hevc/src/inter.rs b/crates/hevc/src/inter.rs index 1f55a5b1d..5292b65b7 100644 --- a/crates/hevc/src/inter.rs +++ b/crates/hevc/src/inter.rs @@ -56,7 +56,7 @@ fn filt_w<'a, P: Px + 'a, const T: usize, const W: usize>(tap: impl Fn(usize) -> let mut acc = [0i32; W]; for k in 0..T { let c = f[k] as i32; - let Some(p) = tap(k).and_then(|s| s.first_chunk::()) else { + let Some(p) = tap(k).and_then(<[P]>::first_chunk::) else { return; }; for j in 0..W { @@ -80,7 +80,7 @@ fn filt16_w<'a, P: Px + 'a, const T: usize, const W: usize>(tap: impl Fn(usize) let mut acc = [0i16; W]; for k in 0..T { let c = f[k] as i16; - let Some(p) = tap(k).and_then(|s| s.first_chunk::()) else { + let Some(p) = tap(k).and_then(<[P]>::first_chunk::) else { return; }; for j in 0..W { diff --git a/crates/hevc/src/params.rs b/crates/hevc/src/params.rs index a7f7daf0b..9b24d433b 100644 --- a/crates/hevc/src/params.rs +++ b/crates/hevc/src/params.rs @@ -6,6 +6,16 @@ use crate::spec_tables::{DEFAULT_SCALING_INTER, DEFAULT_SCALING_INTRA}; use crate::tables::scan_diag; use filmcraft_bitstream::BitReader; +/// HEVC picture state includes several per-pixel and per-block work buffers. This permits 8K +/// pictures while rejecting dimensions that could exhaust memory during decode. +pub const MAX_LUMA_SAMPLES: u64 = 1 << 26; + +fn check_picture_size(width: u32, height: u32) -> Result<()> { + ensure!(width > 0 && height > 0 && width <= 16888 && height <= 16888, "picture size {width}x{height} out of range"); + ensure!(u64::from(width) * u64::from(height) <= MAX_LUMA_SAMPLES, "picture size {width}x{height} exceeds the decoded-frame budget"); + Ok(()) +} + /// General profile / tier / level information. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ProfileTierLevel { @@ -478,7 +488,7 @@ impl Sps { let separate_colour_plane = if chroma_format_idc == 3 { r.read_flag()? } else { false }; let width = r.read_ue()?; let height = r.read_ue()?; - ensure!(width > 0 && height > 0 && width <= 16888 && height <= 16888, "picture size {width}x{height} out of range"); + check_picture_size(width, height)?; let mut conf_win = (0, 0, 0, 0); if r.read_flag()? { conf_win = (r.read_ue()?, r.read_ue()?, r.read_ue()?, r.read_ue()?); @@ -921,3 +931,15 @@ impl Layout { self.tile_id[self.rs_to_ts[rs as usize] as usize] } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picture_size_has_an_aggregate_pixel_budget() { + check_picture_size(8192, 8192).unwrap(); + let err = check_picture_size(8192, 8193).unwrap_err(); + assert!(err.to_string().contains("decoded-frame budget")); + } +} diff --git a/crates/hevc/src/picture.rs b/crates/hevc/src/picture.rs index ffc7ee077..c87f919f5 100644 --- a/crates/hevc/src/picture.rs +++ b/crates/hevc/src/picture.rs @@ -61,7 +61,7 @@ impl Sample for u8 { } } fn wrap_rows(rows: Box<[OnceLock>]>) -> Rows { - Rows::U8(rows) + Rows::U8(rows.into()) } } @@ -94,7 +94,7 @@ impl Sample for u16 { } } fn wrap_rows(rows: Box<[OnceLock>]>) -> Rows { - Rows::U16(rows) + Rows::U16(rows.into()) } } @@ -114,9 +114,10 @@ pub struct FrameRow { } /// The CTB rows of a frame, in its storage type. +#[derive(Clone)] pub enum Rows { - U8(Box<[OnceLock>]>), - U16(Box<[OnceLock>]>), + U8(Arc<[OnceLock>]>), + U16(Arc<[OnceLock>]>), } /// A decoded (or in-progress) picture as stored in the DPB and referenced by later pictures. @@ -391,6 +392,23 @@ impl Frame { f } + /// A distinct logical frame sharing this frame's rows. Used for fully published, immutable + /// gray references: deblocking needs a unique frame ID even when their samples are identical. + pub fn with_identity(&self, id: u32, poc: i32) -> Frame { + Frame { + id, + poc, + width: self.width, + height: self.height, + cwidth: self.cwidth, + cheight: self.cheight, + log2_ctb: self.log2_ctb, + bit_depth: self.bit_depth, + bit_depth_c: self.bit_depth_c, + rows: self.rows.clone(), + } + } + /// A frame with every row published from full-picture planes and no motion. #[cfg(test)] pub fn from_planes(id: u32, poc: i32, like: &Frame, y: &[u16], cb: &[u16], cr: &[u16]) -> Frame { diff --git a/crates/hevc/src/synth_tests.rs b/crates/hevc/src/synth_tests.rs index aee0419fe..839810183 100644 --- a/crates/hevc/src/synth_tests.rs +++ b/crates/hevc/src/synth_tests.rs @@ -457,6 +457,28 @@ fn whole(n: usize) -> Vec { vec![Segment { start: 0, len: n, dependent: false }] } +/// Tiny parsed-picture fixture for missing-reference storage regressions in the decoder. +pub(crate) fn missing_reference_picture(poc: u32, deltas: &[i32]) -> Vec { + let mut stream = Vec::new(); + stream.extend(vps()); + stream.extend(sps(true)); + stream.extend(pps(0, &None, false)); + let picture = PicDesc { + nal_type: 1, + poc, + intra: deltas.is_empty(), + cu: if deltas.is_empty() { Cu::Pcm } else { Cu::Skip }, + pps_id: 0, + st: deltas.iter().map(|&d| (d, true)).collect(), + lt: vec![], + num_ref: deltas.len() as u32, + list_entry: None, + segments: whole(WC * HC), + }; + encode_picture(&picture, &None, &content(0), &mut stream, &mut None); + stream +} + /// Encode the test sequence; returns the stream and the exact expected pictures (when the PCM samples /// are not filtered). fn sequence(tiles: &Tiles, pcm_lf_disabled: bool, segments: Vec) -> (Vec, Vec) { diff --git a/crates/hevc/tests/fuzz_hostile.rs b/crates/hevc/tests/fuzz_hostile.rs new file mode 100644 index 000000000..3ba49a261 --- /dev/null +++ b/crates/hevc/tests/fuzz_hostile.rs @@ -0,0 +1,203 @@ +//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and +//! random mutation of small libx265 streams must never panic or run away. Skips without ffmpeg. + +#[path = "it/common/mod.rs"] +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{Command, Stdio}; + +use filmcraft_hevc::Decoder; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// (name, encoder, extra encoder args, pix_fmt) +const SEEDS: &[(&str, &str, &str, &str)] = &[ + ("hevc_fz_main_b", "libx265", "-x265-params log-level=error:bframes=2:keyint=8:min-keyint=8:sao=1:amp=1", "yuv420p"), + ("hevc_fz_main10", "libx265", "-x265-params log-level=error:bframes=1:keyint=6:min-keyint=6", "yuv420p10le"), + ("hevc_fz_wpp_slices", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6:wpp=1:slices=3", "yuv420p"), + // x265 cannot encode tiles; kvazaar can, at 256 x 64 luma per tile at least (Main profile) + ("hevc_fz_tiles_kvazaar", "libkvazaar", "-s 512x128 -kvazaar-params tiles=2x2,gop=0,period=6,qp=40", "yuv420p"), + ("hevc_fz_422_10", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6", "yuv422p10le"), +]; + +fn make(ff: &std::path::Path, name: &str, enc: &str, args: &str, pix: &str) -> Option> { + let out = filmcraft_testkit::fixtures_dir("hevc").join(format!("{name}.hevc")); + let p = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let a: Vec<&str> = args.split_whitespace().collect(); + matches!( + Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", enc]) + .args(a) + .args(["-f", "hevc"]) + .arg(tmp) + .stdin(Stdio::null()) + .status(), + Ok(s) if s.success() + ) + })?; + std::fs::read(p).ok() +} + +static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + for threads in [1, 3] { + let mut dec = Decoder::with_threads(threads); + for au in common::split_access_units(d) { + let _ = dec.decode(au, 0); + } + let _ = dec.decode(d, 1); + let _ = dec.flush(); + let _ = dec.take_error(); + } + })); + assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=400` runs all of it (see [`Depth`]). +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("hevc fuzz") else { return }; + let depth = Depth::from_env(); + for &(name, enc, args, pix) in SEEDS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, enc) { + filmcraft_testkit::skip("hevc fuzz", &format!("this ffmpeg has no {enc} encoder (seed {name})")); + continue; + } + let Some(seed) = make(&ff, name, enc, args, pix) else { + panic!("ffmpeg has {enc} but could not encode the {name} seed"); + }; + for n in depth.truncations(seed.len()) { + run_all(&seed[..n], &format!("{name} truncate {n}")); + } + header_flips(&depth, &seed, name); + random_rounds(&depth, &seed, name); + } +} + +/// SPS/PPS/first slice header region: every byte to a few values, every bit. +fn header_flips(depth: &Depth, seed: &[u8], name: &str) { + for p in 0..seed.len().min(120) { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } + let mut d = seed.to_vec(); + d[p] = v; + run_all(&d, &format!("{name} byte {p}={v:#x}")); + } + for b in 0..8 { + if !depth.bit(p, b) { + continue; + } + let mut d = seed.to_vec(); + d[p] ^= 1 << b; + run_all(&d, &format!("{name} flip {p}.{b}")); + } + } +} + +fn random_rounds(depth: &Depth, seed: &[u8], name: &str) { + let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64); + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { + let mut d = seed.to_vec(); + for _ in 0..1 + rng.below(6) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + if depth.round(i) { + run_all(&d, &format!("{name} random {i}")); + } + } +} + +const VALUES: [u8; 5] = [0, 0xff, 0x80, 0x01, 0x7f]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 300 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 400 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 300, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=400 runs everything)"); + Depth { full: false, truncations: 300, rounds: 400, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) +} diff --git a/crates/hevc/tests/common/mod.rs b/crates/hevc/tests/it/common/mod.rs similarity index 99% rename from crates/hevc/tests/common/mod.rs rename to crates/hevc/tests/it/common/mod.rs index a8dcb8da2..61c1033f9 100644 --- a/crates/hevc/tests/common/mod.rs +++ b/crates/hevc/tests/it/common/mod.rs @@ -73,7 +73,7 @@ pub const FIXTURES: &[Fixture] = &[ fx!("odd_1918x1078", "testsrc2", 1918, 1078, 3, NOISE, 8, ["-preset", "veryfast", "-x265-params", "log-level=error"]), fx!("hd_1080p", "testsrc2", 1920, 1080, 3, "", 8, ["-preset", "fast", "-x265-params", "log-level=error"]), fx!("uhd_4k_main10", "testsrc2", 3840, 2160, 2, "", 10, ["-preset", "ultrafast", "-x265-params", "log-level=error"]), - // performance reference streams (tests/perf.rs) + // performance reference streams (tests/it/perf.rs) fx!("bench_1080p", "testsrc2", 1920, 1080, 60, "noise=alls=3:allf=t", 8, ["-preset", "medium", "-crf", "24", "-x265-params", "log-level=error"]), fx!("bench_4k", "testsrc2", 3840, 2160, 20, "noise=alls=3:allf=t", 10, ["-preset", "fast", "-crf", "26", "-x265-params", "log-level=error"]), // Apple VideoToolbox (hardware encoder, macOS only) diff --git a/crates/hevc/tests/conformance.rs b/crates/hevc/tests/it/conformance.rs similarity index 99% rename from crates/hevc/tests/conformance.rs rename to crates/hevc/tests/it/conformance.rs index 4fa8b8cbe..7b50cbaf0 100644 --- a/crates/hevc/tests/conformance.rs +++ b/crates/hevc/tests/it/conformance.rs @@ -1,7 +1,7 @@ //! Bit-exactness against ffmpeg's decoder on libx265 / VideoToolbox fixtures (skipped when ffmpeg is //! absent). -mod common; +use crate::common; macro_rules! fixture_tests { ($($name:ident),* $(,)?) => { diff --git a/crates/hevc/tests/it/main.rs b/crates/hevc/tests/it/main.rs new file mode 100644 index 000000000..be033f189 --- /dev/null +++ b/crates/hevc/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod conformance; +mod perf; +mod robustness; diff --git a/crates/hevc/tests/perf.rs b/crates/hevc/tests/it/perf.rs similarity index 91% rename from crates/hevc/tests/perf.rs rename to crates/hevc/tests/it/perf.rs index 4091166b6..ab8144a12 100644 --- a/crates/hevc/tests/perf.rs +++ b/crates/hevc/tests/it/perf.rs @@ -1,7 +1,7 @@ //! Decode speed (ignored by default; run with -//! `cargo test --release -p filmcraft-hevc --test perf -- --ignored --nocapture`). +//! `cargo test --release -p filmcraft-hevc --test it perf:: -- --ignored --nocapture`). -mod common; +use crate::common; use filmcraft_hevc::Decoder; use std::time::Instant; diff --git a/crates/hevc/tests/robustness.rs b/crates/hevc/tests/it/robustness.rs similarity index 99% rename from crates/hevc/tests/robustness.rs rename to crates/hevc/tests/it/robustness.rs index 075d5fde2..50f35b0d4 100644 --- a/crates/hevc/tests/robustness.rs +++ b/crates/hevc/tests/it/robustness.rs @@ -1,6 +1,6 @@ //! hvcC / length-prefixed input and resilience against damaged streams. -mod common; +use crate::common; use filmcraft_bitstream::annexb_nals; use filmcraft_hevc::Decoder; diff --git a/crates/hevc/tools/extract_tables.py b/crates/hevc/tools/extract_tables.py index 8b9fa3979..7d6cbc89f 100644 --- a/crates/hevc/tools/extract_tables.py +++ b/crates/hevc/tools/extract_tables.py @@ -13,8 +13,18 @@ initType following Table 9-4, default scaling lists (Table 7-6), intraPredAngle """ import re import sys +from pathlib import Path -text = open(sys.argv[1], encoding="utf-8").read().replace("−", "-").replace("–", "-") + +def _spec_path(arg): + """Resolve a CLI path argument and require it to be an existing regular file.""" + path = Path(arg).resolve() + if not path.is_file(): + sys.exit(f"not a readable file: {arg}") + return path + + +text = open(_spec_path(sys.argv[1]), encoding="utf-8").read().replace("−", "-").replace("–", "-") lines = text.split("\n") @@ -34,7 +44,7 @@ def body_after(title_regex, stop_regex=r"^\s*(Table \d+-\d+ - |\d+\s+Rec\. ITU|R def init_values(table): vals = [] for l in body_after(r"Table %s - Values of initValue" % re.escape(table)): - m = re.match(r"^\s*initValue\s+(.*)$", l) + m = re.match(r"^\s*initValue\s+(\S.*)$", l) if m: vals += [int(v) for v in m.group(1).split()] return vals @@ -108,7 +118,7 @@ w("") # Table 7-6: default 8x8 scaling lists (intra matrixId 0..2, inter 3..5) intra, inter = [], [] for l in body_after(r"Table 7-6 - Specification of default values of ScalingList"): - m = re.match(r"^\s*ScalingList\[ 1\.\.3 \]\[ (0\.\.2|3\.\.5) \]\[ i \]\s+(.*)$", l) + m = re.match(r"^\s*ScalingList\[ 1\.\.3 \]\[ (0\.\.2|3\.\.5) \]\[ i \]\s+(\S.*)$", l) if m: (intra if m.group(1) == "0..2" else inter).extend(int(v) for v in m.group(2).split()) assert len(intra) == 64 and len(inter) == 64 @@ -177,7 +187,9 @@ w("") def taps(eq_no, var, n): for l in lines: if "(%s)" % eq_no in l: - expr = l.split("=", 1)[1].split(">>")[0] + expr = " ".join(l.split("=", 1)[1].split(">>")[0].split()) + if len(expr) > 8192: + raise SystemExit("equation %s is unexpectedly long" % eq_no) coef = {} for sign, c, k in re.findall(r"([+-]?)\s*(?:(\d+)\s*\*\s*)?%s0,\s*(-?\d)" % var, expr): coef[int(k)] = (-1 if sign == "-" else 1) * (int(c) if c else 1) diff --git a/crates/interchange/README.md b/crates/interchange/README.md index edaff851f..61eb4cfc3 100644 --- a/crates/interchange/README.md +++ b/crates/interchange/README.md @@ -25,7 +25,7 @@ read off files it exports, never from the application itself. The other formats' editions are listed in the module docs (`edl`, `fcp7`, `fcpxml`, `otio`, `ale`). **Invariant — Unicode media URLs:** file URL prefix probes must not panic when their byte boundary -falls inside a UTF-8 character. Timeline importers use the shared converter; `tests/file_urls.rs` +falls inside a UTF-8 character. Timeline importers use the shared converter; `tests/it/file_urls.rs` checks expected Unicode paths through the public converter, resolver and EDL importer. ## AAF and OMF @@ -76,7 +76,7 @@ none of the exports brings one back. Each such clip is named in the export `Repo | Format | Written as | On import into FilmCraft | |---|---|---| | FCP7 XML | a clip item without media (its layers as FilmCraft filter ids) | not read back; the import report names the skipped clip item | -| FCPXML | a clip without media | not read back | +| FCPXML | a gap on V1; left out on other tracks (a gap exists only in the primary storyline) | not read back | | OTIO | a clip with a `MissingReference` (effects in the `filmcraft` metadata) | offline media | | EDL | an ordinary event with the reel the reel mode gives | an ordinary clip | | AAF | a gap | nothing | @@ -85,7 +85,11 @@ OMF carries no video at all. ## Tests -- `tests/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps, +- `src/otio.rs` (unit tests): OTIO repeats a nested sequence for every use, so compound clips that each hold + several uses of the next are exponential (a 3 KB FCPXML nested 12 deep made the export run for minutes and grow to + gigabytes). After 25 000 clips written inside nested sequences the rest are gaps, with one warning; an ordinary + project is far below that and unchanged. +- `tests/it/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps, two-sided and one-sided transitions, links, markers, start timecodes, constant and keyframed gain, media markers and timecode); 512-byte sectors; breakout to mono; embedded trimmed audio (sample data and source offsets); separate consolidated files with a video mixdown; OMF round @@ -93,7 +97,7 @@ OMF carries no video at all. random corruption never panic. Nested sequences: AAF round trips as compositions (nests in nests, a nest used twice, another frame and sample rate, breakout to mono), OMF with the rendered sound, the audio needs of nests, a sequence inside itself. -- `tests/uncarried_clips.rs`: every format's export report names each graphic clip and adjustment +- `tests/it/uncarried_clips.rs`: every format's export report names each graphic clip and adjustment layer once, as a warning; a sequence without them reports nothing; what a re-import gives back matches the report; the FCP7 XML import names a clip item whose file is not defined. - `src/aaf/tests.rs`: written files checked against the required properties of every class diff --git a/crates/interchange/src/aaf/store.rs b/crates/interchange/src/aaf/store.rs index 02234c463..e9ef2e0e1 100644 --- a/crates/interchange/src/aaf/store.rs +++ b/crates/interchange/src/aaf/store.rs @@ -359,10 +359,12 @@ struct R<'a> { cf: CompoundFile<'a>, paths: Vec>, objects: usize, + materialized_stream_bytes: usize, } const MAX_DEPTH: usize = 200; const MAX_OBJECTS: usize = 2_000_000; +const MAX_MATERIALIZED_STREAM_BYTES: usize = 256 << 20; /// Parse the object tree of an AAF compound file (the root object). pub(crate) fn read(bytes: &[u8]) -> Result { @@ -385,7 +387,7 @@ pub(crate) fn read(bytes: &[u8]) -> Result { } } } - let mut r = R { cf, paths, objects: 0 }; + let mut r = R { cf, paths, objects: 0, materialized_stream_bytes: 0 }; r.obj(0, 0) } @@ -401,9 +403,15 @@ impl R<'_> { self.cf.child(parent, name).ok_or_else(|| bad(format!("missing storage or stream \"{name}\""))) } - fn stream(&self, parent: usize, name: &str) -> Result, String> { + fn stream(&mut self, parent: usize, name: &str) -> Result, String> { let id = self.child(parent, name)?; - self.cf.read(id).map_err(|e| e.to_string()) + let data = self.cf.read(id).map_err(|e| e.to_string())?; + self.materialized_stream_bytes = self + .materialized_stream_bytes + .checked_add(data.len()) + .filter(|&n| n <= MAX_MATERIALIZED_STREAM_BYTES) + .ok_or_else(|| bad("AAF materialized stream data exceeds the memory limit"))?; + Ok(data) } fn obj(&mut self, entry: usize, depth: usize) -> Result { @@ -435,6 +443,9 @@ impl R<'_> { let (Some(pid), Some(sf), Some(len)) = (rd_u16(&props, 4 + i * 6), rd_u16(&props, 6 + i * 6), rd_u16(&props, 8 + i * 6)) else { return Err(bad("truncated property index")); }; + if o.props.iter().any(|(previous, _)| *previous == pid) { + return Err(bad("duplicate property id")); + } let v = props.get(at..at + len as usize).ok_or_else(|| bad("truncated property value"))?; at += len as usize; let value = match sf { diff --git a/crates/interchange/src/common.rs b/crates/interchange/src/common.rs index 2823ff755..be47a4317 100644 --- a/crates/interchange/src/common.rs +++ b/crates/interchange/src/common.rs @@ -13,6 +13,11 @@ use filmcraft_time::{FrameRate, Tick}; use crate::{Imported, Report}; +/// A timeline import may not materialise an unbounded number of sparse track +/// labels. This remains well above common editorial timelines while keeping +/// hostile lane/channel numbers from allocating every intervening track. +pub(crate) const MAX_IMPORTED_TRACKS: usize = 256; + // --------------------------------------------------------------------------------------------- // Paths and URLs // --------------------------------------------------------------------------------------------- @@ -65,7 +70,9 @@ pub fn file_url_to_path(url: &str) -> String { return url.to_string(); } let rest = &url[7..]; - let rest = if rest.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest }; + let is_localhost = + rest.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("localhost")) && rest.get(9..).is_some_and(|after| after.is_empty() || after.starts_with('/')); + let rest = if is_localhost { &rest[9..] } else { rest }; let decoded = percent_decode(rest); // file:///C:/x -> C:/x if decoded.len() >= 3 && decoded.starts_with('/') && is_windows_abs(&decoded[1..]) { @@ -408,12 +415,18 @@ impl Builder { Track::new(id, kind, name) } - pub fn ensure_tracks(&mut self, seq: &mut Sequence, kind: TrackKind, n: usize) { + /// Ensure `n` tracks of one kind exist. Returns false when an imported + /// label would exceed the supported per-kind track count. + pub fn ensure_tracks(&mut self, seq: &mut Sequence, kind: TrackKind, n: usize) -> bool { + if n > MAX_IMPORTED_TRACKS { + return false; + } while seq.tracks(kind).len() < n { let i = seq.tracks(kind).len(); let t = self.track(kind, i); seq.tracks_mut(kind).push(t); } + true } pub fn link_id(&mut self) -> u64 { @@ -463,15 +476,17 @@ impl Builder { /// Place `item` on the first track at or after `preferred` where it does not overlap anything, /// creating tracks as needed. Returns the track index. - pub fn place(&mut self, seq: &mut Sequence, kind: TrackKind, preferred: usize, item: TrackItem) -> usize { + pub fn place(&mut self, seq: &mut Sequence, kind: TrackKind, preferred: usize, item: TrackItem) -> Option { let mut i = preferred; loop { - self.ensure_tracks(seq, kind, i + 1); + if i >= MAX_IMPORTED_TRACKS || !self.ensure_tracks(seq, kind, i + 1) { + return None; + } let t = &mut seq.tracks_mut(kind)[i]; if !t.items.iter().any(|o| o.start < item.end() && item.start < o.end()) { t.items.push(item); t.sort(); - return i; + return Some(i); } i += 1; } @@ -692,6 +707,14 @@ mod tests { } } + #[test] + fn file_url_localhost_prefix_host_is_kept() { + assert_eq!(file_url_to_path("file://localhost-nas/share/a.mov"), "//localhost-nas/share/a.mov"); + assert_eq!(file_url_to_path("file://LOCALHOSTX/a"), "//LOCALHOSTX/a"); + assert_eq!(file_url_to_path("file://localhost/a/b"), "/a/b"); + assert_eq!(file_url_to_path("file://LocalHost/a"), "/a"); + } + #[test] fn paths() { assert_eq!(resolve_path("media/a.mov", Some("/proj")), "/proj/media/a.mov"); @@ -713,4 +736,14 @@ mod tests { assert_eq!(rate_from_f64(48000.0), FrameRate::new(48000, 1)); assert_eq!(frames_round(FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5) + Tick(10)), 5); } + + #[test] + fn imported_tracks_are_bounded() { + let mut b = Builder::new("tracks"); + let mut seq = empty_sequence(Default::default()); + assert!(b.ensure_tracks(&mut seq, TrackKind::Video, MAX_IMPORTED_TRACKS)); + assert_eq!(seq.video_tracks.len(), MAX_IMPORTED_TRACKS); + assert!(!b.ensure_tracks(&mut seq, TrackKind::Video, MAX_IMPORTED_TRACKS + 1)); + assert_eq!(seq.video_tracks.len(), MAX_IMPORTED_TRACKS); + } } diff --git a/crates/interchange/src/comp.rs b/crates/interchange/src/comp.rs index 2ade96bcc..32c8698f2 100644 --- a/crates/interchange/src/comp.rs +++ b/crates/interchange/src/comp.rs @@ -698,7 +698,10 @@ pub(crate) fn to_project(doc: Document, opts: &ImportOptions, report: &mut Repor None => groups.push((k, vec![i])), } } - let doc_stem = opts.name.clone().unwrap_or_else(|| "Imported".into()); + // This becomes a directory name for embedded AAF/OMF audio. `ImportOptions::name` normally + // comes from the document filename, where backslashes are ordinary Unix filename characters; + // `resolve_path` deliberately treats them as separators, so retain only one safe component. + let doc_stem = safe_path_component(opts.name.as_deref().unwrap_or("Imported")); let mut used_paths = std::collections::HashSet::new(); for ((_, path), members) in &groups { let srcs: Vec<&Source> = members.iter().map(|&i| &doc.sources[i]).collect(); @@ -788,6 +791,13 @@ pub(crate) fn to_project(doc: Document, opts: &ImportOptions, report: &mut Repor Ok((b.finish(), extracted)) } +/// A display name used as a generated path component, never a caller-selected path fragment. +fn safe_path_component(name: &str) -> String { + let safe: String = name.chars().map(|c| if c.is_alphanumeric() || " -_.".contains(c) { c } else { '_' }).collect(); + let safe = safe.trim(); + if safe.is_empty() || matches!(safe, "." | "..") { "Imported".into() } else { safe.into() } +} + fn build_sequence( b: &mut Builder, comp: &Composition, @@ -1004,3 +1014,15 @@ pub(crate) fn ticks_to_units(t: Tick, num: i64, den: i64) -> (i64, bool) { let n = q + if r * 2 >= d { 1 } else { 0 }; (n.clamp(i64::MIN as i128, i64::MAX as i128) as i64, r == 0) } + +#[cfg(test)] +mod path_tests { + use super::safe_path_component; + + #[test] + fn generated_embedded_audio_folder_is_one_safe_component() { + for (input, expected) in [(r"..\escape", ".._escape"), ("../escape", ".._escape"), ("..", "Imported"), ("", "Imported")] { + assert_eq!(safe_path_component(input), expected, "{input:?}"); + } + } +} diff --git a/crates/interchange/src/edl.rs b/crates/interchange/src/edl.rs index ecee03914..5f114254c 100644 --- a/crates/interchange/src/edl.rs +++ b/crates/interchange/src/edl.rs @@ -375,7 +375,9 @@ impl Ctx<'_> { } } let id = ti.id; - self.b.ensure_tracks(&mut self.seq, kind, idx + 1); + if !self.b.ensure_tracks(&mut self.seq, kind, idx + 1) { + continue; + } let mut next = self.b.p.next_id; let track = &mut self.seq.tracks_mut(kind)[idx]; overwrite(track, ti, &mut || { @@ -487,7 +489,9 @@ fn import_event(ctx: &mut Ctx, ev: &Event, report: &mut Report) { let chans: BTreeSet = l2.chans.iter().copied().collect(); for ch in chans { let (kind, idx) = ctx.track_index(ch, 0); - ctx.b.ensure_tracks(&mut ctx.seq, kind, idx + 1); + if !ctx.b.ensure_tracks(&mut ctx.seq, kind, idx + 1) { + continue; + } let to = placed.iter().find(|(k, i, _)| *k == kind && *i == idx).map(|p| p.2); let track = &mut ctx.seq.tracks_mut(kind)[idx]; let from = if from_black { None } else { track.items.iter().find(|i| i.end() == t0).map(|i| i.id) }; diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs index e0e9075d9..2bc67d9e7 100644 --- a/crates/interchange/src/fcp7.rs +++ b/crates/interchange/src/fcp7.rs @@ -27,9 +27,9 @@ use filmcraft_time::{FrameRate, Tick, format_timecode_frames}; use roxmltree::Node; use crate::common::{ - Builder, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, frames_round, gain_to_db, item_media, item_path, kind_from_ext, label_from, on_frame, - param, param_modified, path_to_file_url, rate_from_timebase, resolve_path, set_param, settings_for, standard_effects, timebase, transition_effect, - transition_name, unscaled, + Builder, MAX_IMPORTED_TRACKS, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, frames_round, gain_to_db, item_media, item_path, kind_from_ext, + label_from, on_frame, param, param_modified, path_to_file_url, rate_from_timebase, resolve_path, set_param, settings_for, standard_effects, timebase, + transition_effect, transition_name, unscaled, }; use crate::xml::{XmlWriter, bool_str, child, child_bool, child_f64, child_i64, child_text, children, elements, num, path_text}; use crate::{Error, ExportOptions, Format, ImportOptions, Imported, Report, Result}; @@ -343,7 +343,10 @@ impl<'a, 'i> Imp<'a, 'i, '_> { for (kind, tag) in [(TrackKind::Video, "video"), (TrackKind::Audio, "audio")] { let Some(m) = media.and_then(|m| child(m, tag)) else { continue }; for (ti, tn) in children(m, "track").enumerate() { - self.b.ensure_tracks(&mut seq, kind, ti + 1); + if !self.b.ensure_tracks(&mut seq, kind, ti + 1) { + self.report.warn(format!("FCP7 {kind:?} tracks beyond {MAX_IMPORTED_TRACKS} were skipped")); + break; + } self.track(&mut seq, id, kind, ti, tn, rate, (w, h)); } } @@ -378,7 +381,9 @@ impl<'a, 'i> Imp<'a, 'i, '_> { let t = &mut seq.tracks_mut(kind)[ti]; if t.items.iter().any(|o| o.start < ti_item.end() && ti_item.start < o.end()) { self.report.warn("overlapping clip items on one track; the later one was moved to a new track"); - self.b.place(seq, kind, ti + 1, ti_item); + if self.b.place(seq, kind, ti + 1, ti_item).is_none() { + self.report.warn(format!("FCP7 {kind:?} track exceeds the {MAX_IMPORTED_TRACKS}-track import limit; clip was skipped")); + } } else { t.items.push(ti_item); } diff --git a/crates/interchange/src/fcpxml.rs b/crates/interchange/src/fcpxml.rs index e231fff5e..571ad82d0 100644 --- a/crates/interchange/src/fcpxml.rs +++ b/crates/interchange/src/fcpxml.rs @@ -14,7 +14,7 @@ //! clip markers. Transitions are supported on the primary storyline (and read from secondary //! storylines); transitions elsewhere are reported. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use filmcraft_geom::Vec2; use filmcraft_media::MediaKind; @@ -26,8 +26,8 @@ use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use roxmltree::Node; use crate::common::{ - Builder, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, gain_to_db, generator_of, item_media, item_path, kind_from_ext, param, - param_modified, path_to_file_url, resolve_path, scaled, set_param, settings_for, standard_effects, transition_effect, transition_name, + Builder, MAX_IMPORTED_TRACKS, MediaSpec, base_item, db_to_gain, empty_sequence, file_name, gain_to_db, generator_of, item_media, item_path, kind_from_ext, + param, param_modified, path_to_file_url, resolve_path, scaled, set_param, settings_for, standard_effects, transition_effect, transition_name, }; use crate::xml::{child, children, elements, num}; use crate::{Error, ExportOptions, Format, ImportOptions, Imported, Report, Result}; @@ -105,10 +105,11 @@ fn audio_rate_str(sr: u32) -> String { fn parse_audio_rate(s: &str) -> Option { let s = s.trim(); - match s.strip_suffix('k') { + let r = match s.strip_suffix('k') { Some(k) => k.parse::().ok().map(|v| (v * 1000.0).round() as u32), None => s.parse().ok(), - } + }; + r.filter(|&sr| sr > 0 && sr <= 384_000) } // --------------------------------------------------------------------------------------------- @@ -132,6 +133,7 @@ struct Imp<'a, 'i, 'r> { assets: HashMap, media_nodes: HashMap>, media_items: HashMap, + media_active: HashSet, effects: HashMap, } @@ -163,6 +165,7 @@ pub(crate) fn import(text: &str, opts: &ImportOptions, report: &mut Report) -> R assets: HashMap::new(), media_nodes: HashMap::new(), media_items: HashMap::new(), + media_active: HashSet::new(), effects: HashMap::new(), }; if let Some(res) = child(root, "resources") { @@ -281,9 +284,15 @@ impl<'a, 'i> Imp<'a, 'i, '_> { } let n = *self.media_nodes.get(id)?; let s = child(n, "sequence")?; - // Reserve first so recursion terminates. + if !self.media_active.insert(id.into()) { + self.report.warn(format!("recursive FCPXML media resource {id:?} ignored")); + return None; + } + // Mark the resource before descending: a compound clip can reference itself through a + // ref-clip before a completed sequence is available to cache. let name = n.attribute("name").unwrap_or("Compound Clip").to_string(); let item = self.sequence(s, &name, None); + self.media_active.remove(id); self.media_items.insert(id.into(), item); Some(item) } @@ -437,14 +446,17 @@ impl<'a, 'i> Imp<'a, 'i, '_> { TrackKind::Video => lane.max(0) as usize, TrackKind::Audio => { if lane < 0 { - (-lane - 1) as usize + lane.unsigned_abs() as usize - 1 } else { lane as usize } } }; let id = ti.id; - self.b.place(seq, kind, pref, ti); + if self.b.place(seq, kind, pref, ti).is_none() { + self.report.warn(format!("FCPXML {kind:?} lane exceeds the {MAX_IMPORTED_TRACKS}-track import limit; clip was skipped")); + continue; + } placed.push((kind, id)); if kind == TrackKind::Video { vclip = Some(id); @@ -462,7 +474,7 @@ impl<'a, 'i> Imp<'a, 'i, '_> { let Some(l) = c.attribute("lane").and_then(|l| l.parse::().ok()) else { continue }; let off = attr_time(c, "offset").unwrap_or(Tick::ZERO); let ct0 = t0 + (off - local_start); - let clane = if lane > 0 && l > 0 { lane + l } else { l }; + let clane = if lane > 0 && l > 0 { lane.saturating_add(l) } else { l }; if c.has_tag_name("spine") { // Secondary storyline: children offsets are in the storyline's local time. let first = elements(c).filter(|x| !x.has_tag_name("transition")).find_map(|x| attr_time(x, "offset")).unwrap_or(Tick::ZERO); @@ -673,8 +685,8 @@ pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: out.push_str(r); } out.push_str(" \n"); - out.push_str(&format!(" \n \n", crate::xml::escape(&p.name))); - out.push_str(&format!(" \n", crate::xml::escape(&name))); + out.push_str(&format!(" \n \n", crate::xml::escape_attr(&p.name))); + out.push_str(&format!(" \n", crate::xml::escape_attr(&name))); out.push_str(&body); out.push_str(" \n \n \n\n"); Ok(out) @@ -728,7 +740,7 @@ impl Exp<'_, '_> { let start = m.info.start_timecode.map(|f| mrate.tick_of(f)).unwrap_or(Tick::ZERO); let mut a = format!( " { } let path = item_path(self.p, item).unwrap_or(""); let src = if crate::common::is_absolute(path) { path_to_file_url(path, false) } else { path.to_string() }; - a.push_str(&format!(">\n \n \n", crate::xml::escape(&src))); + a.push_str(&format!(">\n \n \n", crate::xml::escape_attr(&src))); self.res_out.push(a); self.assets.insert(item, Res { id: id.clone() }); let _ = file_name; @@ -764,7 +776,7 @@ impl Exp<'_, '_> { let body = self.sequence_xml(item, &fmt, 3); self.building.pop(); let name = self.p.item(item).map(|i| i.name.clone()).unwrap_or_default(); - self.res_out.push(format!(" \n{body} \n", crate::xml::escape(&name))); + self.res_out.push(format!(" \n{body} \n", crate::xml::escape_attr(&name))); id } @@ -773,7 +785,11 @@ impl Exp<'_, '_> { return r.id.clone(); } let id = self.rid(); - self.res_out.push(format!(" \n", crate::xml::escape(name), crate::xml::escape(id_hint))); + self.res_out.push(format!( + " \n", + crate::xml::escape_attr(name), + crate::xml::escape_attr(id_hint) + )); self.effects.insert(name.to_string(), Res { id: id.clone() }); id } @@ -871,6 +887,10 @@ impl Exp<'_, '_> { if merged_audio.contains(&it.id) { continue; } + if let Some(what) = self.unsupported(it, rate) { + self.report.warn(format!("{what}: it is left out and is not read back on import")); + continue; + } let Some(pi) = els.iter().position(|e| e.start <= it.start && it.start < e.end) else { self.report.warn("a connected clip could not be attached and was dropped"); continue; @@ -934,34 +954,33 @@ impl Exp<'_, '_> { "", fmt_time(tc0 + tr.start, rate), fmt_time(tr.duration, rate), - n = crate::xml::escape(&name) + n = crate::xml::escape_attr(&name) ) } + /// How the report names a clip FCPXML cannot carry (a generator, a title, an adjustment layer); `None` for any + /// other clip. Such a clip has no asset to reference, and an empty `ref` is not a valid IDREF: Final Cut Pro + /// rejects the whole file. A `` exists only in a spine, so a connected one is left out instead. + fn unsupported(&self, it: &TrackItem, rate: FrameRate) -> Option { + if let Some(g) = generator_of(self.p, base_item(self.p, it.item)) { + return Some(format!("generator \"{}\" is not supported in FCPXML", g.label())); + } + crate::common::uncarried_clip(self.p, it, rate).map(|clip| format!("{clip} is not supported in FCPXML")) + } + /// A clip element at `offset` (in its parent's local time). #[allow(clippy::too_many_arguments)] fn clip_el(&mut self, it: &TrackItem, audio: Option<&TrackItem>, kind: TrackKind, offset: Tick, lane: Option, seq: &Sequence, primary: bool) -> El { let rate = seq.settings.frame_rate; let base = base_item(self.p, it.item); let is_seq = matches!(self.p.item(base).map(|i| &i.kind), Some(ItemKind::Sequence(_))); - let generator = generator_of(self.p, base).cloned(); let lane_attr = lane.map(|l| format!(" lane=\"{l}\"")).unwrap_or_default(); - let name = crate::xml::escape(&it.name); - if let Some(g) = generator { - self.report.warn(format!("generator \"{}\" is not supported in FCPXML; written as a gap", g.label())); - return El { - start: it.start, - end: it.end(), - local: offset, - xml_open: format!( - " { let origin = self.p.sequence(base).map(|s| s.settings.frame_rate.tick_of(s.start_timecode)).unwrap_or(Tick::ZERO); ("ref-clip", r, origin) } else { - let r = match crate::common::uncarried_clip(self.p, it, rate) { - Some(clip) => { - self.report.warn(format!("{clip} is not supported in FCPXML: it is written as a clip without media and is not read back on import")); - String::new() - } - None => self.asset(base, rate), - }; + let r = self.asset(base, rate); let m = item_media(self.p, base); let mrate = m.and_then(|m| m.info.video.as_ref().map(|v| v.frame_rate)).unwrap_or(rate); let st = m.and_then(|m| m.info.start_timecode).map(|f| mrate.tick_of(f)).unwrap_or(Tick::ZERO); @@ -1043,7 +1056,7 @@ impl Exp<'_, '_> { let n = e.def().map(|d| d.name).unwrap_or(&e.effect).to_string(); let r = self.effect(&n, &e.effect); let tag = if kind == TrackKind::Video { "filter-video" } else { "filter-audio" }; - let mut s = format!("<{tag} ref=\"{r}\" name=\"{}\"", crate::xml::escape(&n)); + let mut s = format!("<{tag} ref=\"{r}\" name=\"{}\"", crate::xml::escape_attr(&n)); if !e.enabled { s.push_str(" enabled=\"0\""); } @@ -1138,6 +1151,23 @@ impl Exp<'_, '_> { } } +/// A primary-storyline `` standing in for a clip FCPXML cannot carry; it keeps the clip's place and length. +fn gap_el(it: &TrackItem, name: &str, offset: Tick, rate: FrameRate) -> El { + El { + start: it.start, + end: it.end(), + local: offset, + xml_open: format!( + " String { if params.is_empty() { format!("<{tag}{attrs}/>") } else { format!("<{tag}{attrs}>\n{}\n", params.join("\n")) } } @@ -1169,8 +1199,8 @@ fn kparam_xml( fn marker_xml(m: &Marker, local: Tick, rate: FrameRate) -> String { let tag = if m.kind == MarkerKind::Chapter { "chapter-marker" } else { "marker" }; let dur = if m.duration > Tick::ZERO { m.duration } else { rate.frame_duration() }; - let note = if m.comment.is_empty() { String::new() } else { format!(" note=\"{}\"", crate::xml::escape(&m.comment)) }; - format!("<{tag} start=\"{}\" duration=\"{}\" value=\"{}\"{note}/>", fmt_time(local, rate), fmt_time(dur, rate), crate::xml::escape(&m.name)) + let note = if m.comment.is_empty() { String::new() } else { format!(" note=\"{}\"", crate::xml::escape_attr(&m.comment)) }; + format!("<{tag} start=\"{}\" duration=\"{}\" value=\"{}\"{note}/>", fmt_time(local, rate), fmt_time(dur, rate), crate::xml::escape_attr(&m.name)) } #[cfg(test)] diff --git a/crates/interchange/src/omf/bento.rs b/crates/interchange/src/omf/bento.rs index d3a0a80d7..2648b81b0 100644 --- a/crates/interchange/src/omf/bento.rs +++ b/crates/interchange/src/omf/bento.rs @@ -72,6 +72,10 @@ enum Seg { At(u64, u32), } +/// Bento values are metadata, not essence payloads. Limit reassembled segments so repeated +/// references cannot amplify a small OMF into an enormous allocation. +const MAX_VALUE_BYTES: usize = 16 << 20; + /// Builds a Bento container. pub(crate) struct Writer { data: Vec, @@ -311,7 +315,18 @@ impl<'a> Container<'a> { /// The bytes of a value. pub fn value(&self, p: &Prop) -> Result, String> { + let total = p.segs.iter().try_fold(0usize, |total, s| { + let len = match s { + Seg::Imm(b) => b.len(), + Seg::At(_, len) => *len as usize, + }; + total.checked_add(len).ok_or_else(|| "Bento value length overflows".to_string()) + })?; + if total > MAX_VALUE_BYTES { + return Err("Bento value exceeds the decoded-value budget".into()); + } let mut out = Vec::new(); + out.try_reserve_exact(total).map_err(|_| "cannot allocate Bento value")?; for s in &p.segs { match s { Seg::Imm(b) => out.extend_from_slice(b), @@ -355,3 +370,15 @@ impl<'a> Container<'a> { key } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repeated_segments_cannot_expand_a_value_past_its_budget() { + let p = Prop { prop: 1, ty: 1, segs: vec![Seg::At(0, MAX_VALUE_BYTES as u32), Seg::Imm(vec![0])] }; + let c = Container { data: &[], objects: BTreeMap::new(), names: HashMap::new(), by_name: HashMap::new() }; + assert!(c.value(&p).unwrap_err().contains("budget")); + } +} diff --git a/crates/interchange/src/omf/read.rs b/crates/interchange/src/omf/read.rs index 3b2d1b369..b46673602 100644 --- a/crates/interchange/src/omf/read.rs +++ b/crates/interchange/src/omf/read.rs @@ -10,6 +10,8 @@ use crate::{Error, Report, Result}; type Uid = [u8; 12]; +const MAX_REFERENCE_VISITS: usize = 65_536; + struct R<'a, 'b> { c: &'b Container<'a>, report: &'b mut Report, @@ -51,8 +53,10 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result { } let cmobs: Vec = mob_list.iter().copied().filter(|&m| class_of(m) == Some(*b"CMOB")).collect(); let mut referenced = HashSet::new(); + let mut visited = HashSet::new(); + let mut visits = 0; for &m in &cmobs { - r.collect_refs(m, &mut referenced, 0); + r.collect_refs(m, &mut referenced, &mut visited, &mut visits, 0); } let primary: Vec = r.refs(head, "OMFI:HEAD:PrimaryMobs").into_iter().filter(|m| cmobs.contains(m)).collect(); let top: Vec = if !primary.is_empty() { @@ -122,10 +126,11 @@ impl R<'_, '_> { self.i32(o, "OMFI:CPNT:Length").unwrap_or(0).max(0) } - fn collect_refs(&self, o: u32, out: &mut HashSet, depth: usize) { - if depth > 64 { + fn collect_refs(&self, o: u32, out: &mut HashSet, visited: &mut HashSet, visits: &mut usize, depth: usize) { + if depth > 64 || !visited.insert(o) || *visits >= MAX_REFERENCE_VISITS { return; } + *visits += 1; if self.class(o) == *b"SCLP" && let Some(id) = self.uid(o, "OMFI:SCLP:SourceID") { @@ -133,12 +138,12 @@ impl R<'_, '_> { } for p in ["OMFI:MOBJ:Slots", "OMFI:SEQU:Components", "OMFI:EFFE:EffectSlots"] { for c in self.refs(o, p) { - self.collect_refs(c, out, depth + 1); + self.collect_refs(c, out, visited, visits, depth + 1); } } for p in ["OMFI:MSLT:Segment", "OMFI:ESLT:ArgValue"] { if let Some(c) = self.r#ref(o, p) { - self.collect_refs(c, out, depth + 1); + self.collect_refs(c, out, visited, visits, depth + 1); } } } diff --git a/crates/interchange/src/otio.rs b/crates/interchange/src/otio.rs index 421bea985..dbf010247 100644 --- a/crates/interchange/src/otio.rs +++ b/crates/interchange/src/otio.rs @@ -113,17 +113,27 @@ fn color_label(s: &str) -> Label { // Export // --------------------------------------------------------------------------------------------- +/// Most clips an export writes inside nested sequences. OTIO has no references: every use of a nested +/// sequence is written in full, so a compound clip that holds three uses of another, nested ten deep, +/// is 3^10 copies, and a 3 KB document made the export run for minutes and grow to gigabytes. Each +/// clip is about 10 KB of JSON, so this bounds the extra output near 250 MB; real projects use a +/// few thousand. Past it, further nested sequences are written as gaps, with a warning. +const MAX_NESTED_ITEMS: usize = 25_000; + struct Exp<'a, 'r> { p: &'a Project, opts: &'a ExportOptions, report: &'r mut Report, stack: Vec, + /// Clips written inside nested sequences so far, and the limit for it (see [`MAX_NESTED_ITEMS`]). + nested_items: usize, + nested_limit: usize, } pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: &mut Report) -> Result { let seq = p.sequence(seq_id).ok_or(Error::NoSequence(seq_id))?; let rate = seq.settings.frame_rate; - let mut x = Exp { p, opts, report, stack: vec![seq_id] }; + let mut x = Exp { p, opts, report, stack: vec![seq_id], nested_items: 0, nested_limit: MAX_NESTED_ITEMS }; let name = opts.name.clone().unwrap_or_else(|| p.item(seq_id).map(|i| i.name.clone()).unwrap_or_default()); let stack = x.stack_json(seq_id, &name, None); let tl = json!({ @@ -247,6 +257,9 @@ impl Exp<'_, '_> { } fn item_json(&mut self, it: &TrackItem, kind: TrackKind, rate: FrameRate) -> Value { + if self.stack.len() > 1 { + self.nested_items = self.nested_items.saturating_add(1); + } let base = base_item(self.p, it.item); let mut effects = Vec::new(); if let Some(h) = it.frame_hold { @@ -269,6 +282,17 @@ impl Exp<'_, '_> { self.report.warn("recursive nested sequence skipped"); return json!({"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": tr(Tick::ZERO, it.duration, rate), "effects": [], "markers": [], "enabled": true, "metadata": {}}); } + if self.nested_items >= self.nested_limit { + // once: later nested sequences hit this too, and the message would repeat + if self.nested_items < usize::MAX { + self.nested_items = usize::MAX; + self.report.warn(format!( + "more than {} clips written inside nested sequences: the rest are gaps (OTIO repeats a nested sequence for every use)", + self.nested_limit + )); + } + return json!({"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": tr(Tick::ZERO, it.duration, rate), "effects": [], "markers": [], "enabled": true, "metadata": {}}); + } self.stack.push(base); let mut s = self.stack_json(base, &it.name, Some((it.source_in, it.duration, rate))); self.stack.pop(); @@ -823,4 +847,58 @@ mod tests { let back = read_rt(&rt(t, FrameRate::FPS_24)).unwrap(); assert!((back - t).abs() <= Tick(1)); } + + /// A tree of compound clips: `uses` uses of the next level in each, `levels` media levels deep, leaf clips in the last. + fn nested_tree(levels: usize, uses: usize) -> String { + let mut xml = String::from( + r#" +"#, + ); + for level in 0..levels { + let inner: String = (0..uses) + .map(|i| { + if level + 1 < levels { + format!(r#""#, level + 1) + } else { + format!(r#""#) + } + }) + .collect(); + xml.push_str(&format!(r#"{inner}"#)); + } + xml.push_str(r#""#); + xml + } + + /// OTIO repeats a nested sequence for every use, so 10 uses nested 3 deep is 111 copies and a 3 KB + /// document nested 12 deep made the export run for minutes and grow to gigabytes. Past the limit on clips + /// written inside nested sequences the rest are gaps, with one warning. The test lowers the limit so it + /// stays tiny: without the check all 112 stacks are written. + #[test] + fn nested_copies_stop_at_the_limit() { + let (imported, _) = crate::import(nested_tree(3, 10).as_bytes(), crate::Format::Fcpxml, None).expect("import succeeds"); + let seq = imported.sequences[0]; + let opts = ExportOptions::default(); + let write = |limit: usize| { + let mut report = Report::default(); + let mut x = Exp { p: &imported.project, opts: &opts, report: &mut report, stack: vec![seq], nested_items: 0, nested_limit: limit }; + let stacks = x.stack_json(seq, "t", None).to_string().matches("\"Stack.1\"").count(); + (stacks, report) + }; + let (all, report) = write(usize::MAX - 1); + assert_eq!(all, 112); + assert!(!report.mentions("nested sequences"), "{report}"); + let (capped, report) = write(200); + assert!(capped < 60, "{capped} stacks"); + assert!(report.mentions("clips written inside nested sequences"), "{report}"); + assert_eq!(report.warnings().filter(|w| w.message.contains("inside nested sequences")).map(|w| w.count).sum::(), 1); + } + + /// An ordinary project with nested sequences is far below the real limit and exports untouched. + #[test] + fn ordinary_nesting_is_not_capped() { + let (imported, _) = crate::import(nested_tree(3, 4).as_bytes(), crate::Format::Fcpxml, None).expect("import succeeds"); + let (_, report) = crate::export(&imported.project, imported.sequences[0], crate::Format::Otio, &ExportOptions::default()).expect("export succeeds"); + assert!(!report.mentions("inside nested sequences"), "{report}"); + } } diff --git a/crates/interchange/src/xml.rs b/crates/interchange/src/xml.rs index 6bd1c51dc..3bc1120bb 100644 --- a/crates/interchange/src/xml.rs +++ b/crates/interchange/src/xml.rs @@ -20,6 +20,21 @@ pub(crate) fn escape(s: &str) -> String { o } +/// Like `escape`, but also encodes whitespace that attribute-value +/// normalization would otherwise turn into spaces. +pub(crate) fn escape_attr(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\n' => o.push_str(" "), + '\r' => o.push_str(" "), + '\t' => o.push_str(" "), + c => o.push_str(&escape(c.encode_utf8(&mut [0; 4]))), + } + } + o +} + /// Pretty-printing XML writer (two-space indent, one element per line). pub(crate) struct XmlWriter { out: String, @@ -46,7 +61,7 @@ impl XmlWriter { self.out.push('<'); self.out.push_str(name); for (k, v) in attrs { - let _ = write!(self.out, " {k}=\"{}\"", escape(v)); + let _ = write!(self.out, " {k}=\"{}\"", escape_attr(v)); } } @@ -144,6 +159,17 @@ pub(crate) fn num(v: f64) -> String { } pub(crate) fn parse(text: &str) -> Result, String> { + // FCP7 XML commonly carries a plain `` declaration, which + // roxmltree accepts only with DTD parsing enabled. Internal subsets are not + // needed by any supported interchange format and can expand entities far + // beyond the input size, so reject them before handing the document to the + // parser. External entities remain unresolved because no resolver is set. + if let Some(start) = text.find("').map_or(text.len(), |end| start + end)]; + if declaration.contains('[') { + return Err("internal DTD subsets are not supported".into()); + } + } let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; roxmltree::Document::parse_with_options(text, opts).map_err(|e| e.to_string()) } @@ -169,4 +195,22 @@ mod tests { assert_eq!(num(-0.0), "0"); assert_eq!(num(0.1 + 0.2).parse::().unwrap(), 0.1 + 0.2); } + + #[test] + fn attr_whitespace_roundtrip() { + let mut w = XmlWriter::new(None); + w.open("a", &[("name", "x\ny\tz\r")]); + w.text("b", "a\nb"); + let s = w.finish(); + let d = parse(&s).unwrap(); + let a = d.root_element(); + assert_eq!(a.attribute("name"), Some("x\ny\tz\r")); + assert_eq!(child(a, "b").and_then(|b| b.text()), Some("a\nb")); + } + + #[test] + fn rejects_internal_dtd_entity_expansion() { + let text = "]>&word;"; + assert_eq!(parse(text).unwrap_err(), "internal DTD subsets are not supported"); + } } diff --git a/crates/interchange/tests/aaf_omf.rs b/crates/interchange/tests/it/aaf_omf.rs similarity index 99% rename from crates/interchange/tests/aaf_omf.rs rename to crates/interchange/tests/it/aaf_omf.rs index 3e16492f2..4a065677c 100644 --- a/crates/interchange/tests/aaf_omf.rs +++ b/crates/interchange/tests/it/aaf_omf.rs @@ -1,6 +1,6 @@ //! AAF and OMF: export → import round trips (no external oracle exists for these formats). -mod support; +use crate::support; use filmcraft_interchange::aaf::{self, AafOptions}; use filmcraft_interchange::essence::{AudioEssence, EssenceData, EssenceKey, MediaOptions, MixdownVideo, NeedOptions, NestNeeds, audio_needs}; diff --git a/crates/interchange/tests/api.rs b/crates/interchange/tests/it/api.rs similarity index 99% rename from crates/interchange/tests/api.rs rename to crates/interchange/tests/it/api.rs index 6a212793c..7d19eb3a9 100644 --- a/crates/interchange/tests/api.rs +++ b/crates/interchange/tests/it/api.rs @@ -1,6 +1,6 @@ //! Crate API: detection, merging fragments into a project, timecode rebasing. -mod support; +use crate::support; use std::collections::HashSet; diff --git a/crates/interchange/tests/edl.rs b/crates/interchange/tests/it/edl.rs similarity index 99% rename from crates/interchange/tests/edl.rs rename to crates/interchange/tests/it/edl.rs index 14100d146..db7369589 100644 --- a/crates/interchange/tests/edl.rs +++ b/crates/interchange/tests/it/edl.rs @@ -1,6 +1,6 @@ //! CMX 3600 EDL: golden documents (hand-written from the public CMX 3600 conventions) and round trips. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, ImportOptions, ReelMode, detect, export, export_edl_per_track, import, import_with}; use filmcraft_project::{ItemKind, MediaRef, TrackKind, TransitionAlign}; diff --git a/crates/interchange/tests/fcp7.rs b/crates/interchange/tests/it/fcp7.rs similarity index 99% rename from crates/interchange/tests/fcp7.rs rename to crates/interchange/tests/it/fcp7.rs index aad1d5f0e..663ce7337 100644 --- a/crates/interchange/tests/fcp7.rs +++ b/crates/interchange/tests/it/fcp7.rs @@ -1,7 +1,7 @@ //! Final Cut Pro 7 XML (xmeml): a hand-written Premiere-style document (written from the public //! xmeml element descriptions) and export → import round trips. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, Imported, detect, export, import}; use filmcraft_media::Generator; diff --git a/crates/interchange/tests/fcpxml.rs b/crates/interchange/tests/it/fcpxml.rs similarity index 93% rename from crates/interchange/tests/fcpxml.rs rename to crates/interchange/tests/it/fcpxml.rs index 12892da57..065c341fd 100644 --- a/crates/interchange/tests/fcpxml.rs +++ b/crates/interchange/tests/it/fcpxml.rs @@ -1,7 +1,7 @@ //! FCPXML: a hand-written document (from the public FCPXML element reference) with connected //! clips, a transition, a retimed clip, a gap and a compound clip; plus export → import round trips. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, FcpxmlVersion, Format, detect, import}; use filmcraft_project::{ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, TrackKind, TransitionAlign}; @@ -98,6 +98,24 @@ fn doc() -> String { ) } +#[test] +fn self_referencing_compound_media_is_ignored_without_recursing() { + let xml = r#" + + + + + + + + + + "#; + let (imported, report) = import(xml.as_bytes(), Format::Fcpxml, None).unwrap(); + assert!(!imported.sequences.is_empty()); + assert!(report.mentions("recursive FCPXML media"), "{report:?}"); +} + #[test] fn golden_connected_clips() { let d = doc(); @@ -163,6 +181,16 @@ fn golden_connected_clips() { assert!(matches!(&m.media, MediaRef::File { path } if path == "/proj/boats.mov")); } +#[test] +fn hostile_lane_numbers_do_not_create_sparse_tracks() { + let d = doc().replace("lane=\"-1\"", "lane=\"-2147483648\"").replace("lane=\"1\"", "lane=\"2147483647\""); + let (imp, rep) = import(d.as_bytes(), Format::Fcpxml, Some("/proj")).unwrap(); + let s = imp.project.sequence(only_seq(&imp)).unwrap(); + assert!(s.video_tracks.len() <= 256); + assert!(s.audio_tracks.len() <= 256); + assert!(rep.has_warnings()); +} + fn project() -> (Project, filmcraft_project::ItemId) { let r = FrameRate::FPS_29_97; let mut p = Project::new("Lib"); diff --git a/crates/interchange/tests/file_urls.rs b/crates/interchange/tests/it/file_urls.rs similarity index 100% rename from crates/interchange/tests/file_urls.rs rename to crates/interchange/tests/it/file_urls.rs diff --git a/crates/interchange/tests/it/main.rs b/crates/interchange/tests/it/main.rs new file mode 100644 index 000000000..81da996e9 --- /dev/null +++ b/crates/interchange/tests/it/main.rs @@ -0,0 +1,12 @@ +mod aaf_omf; +mod api; +mod edl; +mod fcp7; +mod fcpxml; +mod file_urls; +mod media_marker_export; +mod nested_sequence_markers; +mod otio; +mod point_markers; +mod support; +mod uncarried_clips; diff --git a/crates/interchange/tests/media_marker_export.rs b/crates/interchange/tests/it/media_marker_export.rs similarity index 99% rename from crates/interchange/tests/media_marker_export.rs rename to crates/interchange/tests/it/media_marker_export.rs index 752cc8b5d..e209b0693 100644 --- a/crates/interchange/tests/media_marker_export.rs +++ b/crates/interchange/tests/it/media_marker_export.rs @@ -1,6 +1,6 @@ //! Source-media (master clip) markers must survive FCP7 XML export for xmeml 4 and 5. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, export, import}; use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind}; diff --git a/crates/interchange/tests/nested_sequence_markers.rs b/crates/interchange/tests/it/nested_sequence_markers.rs similarity index 99% rename from crates/interchange/tests/nested_sequence_markers.rs rename to crates/interchange/tests/it/nested_sequence_markers.rs index 9f69b755e..6e4eca92b 100644 --- a/crates/interchange/tests/nested_sequence_markers.rs +++ b/crates/interchange/tests/it/nested_sequence_markers.rs @@ -4,7 +4,7 @@ //! sequence's markers when the using clip has none, and must not put clip instance markers on the //! nested sequence. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format}; use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind}; diff --git a/crates/interchange/tests/otio.rs b/crates/interchange/tests/it/otio.rs similarity index 99% rename from crates/interchange/tests/otio.rs rename to crates/interchange/tests/it/otio.rs index 0b952add4..857de1e79 100644 --- a/crates/interchange/tests/otio.rs +++ b/crates/interchange/tests/it/otio.rs @@ -1,7 +1,7 @@ //! OpenTimelineIO: a hand-written plain OTIO document (from the public OTIO schema docs) with gaps, //! transitions, markers and legacy `Clip.1`; lossless round trips through `metadata.filmcraft`. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, detect, import}; use filmcraft_media::Generator; diff --git a/crates/interchange/tests/point_markers.rs b/crates/interchange/tests/it/point_markers.rs similarity index 99% rename from crates/interchange/tests/point_markers.rs rename to crates/interchange/tests/it/point_markers.rs index e0ffb5616..ba96cec58 100644 --- a/crates/interchange/tests/point_markers.rs +++ b/crates/interchange/tests/it/point_markers.rs @@ -1,6 +1,6 @@ //! FCPXML point markers: a one-frame `` is a FilmCraft point (duration 0), not a range. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, import}; use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, Project, TrackKind}; diff --git a/crates/interchange/tests/support/mod.rs b/crates/interchange/tests/it/support/mod.rs similarity index 100% rename from crates/interchange/tests/support/mod.rs rename to crates/interchange/tests/it/support/mod.rs diff --git a/crates/interchange/tests/uncarried_clips.rs b/crates/interchange/tests/it/uncarried_clips.rs similarity index 89% rename from crates/interchange/tests/uncarried_clips.rs rename to crates/interchange/tests/it/uncarried_clips.rs index 2f0902e26..786547074 100644 --- a/crates/interchange/tests/uncarried_clips.rs +++ b/crates/interchange/tests/it/uncarried_clips.rs @@ -2,7 +2,7 @@ //! export used to lose them without a word (FCP7 XML, OTIO, EDL) or with a line that named //! nothing (FCPXML, AAF): every such clip is now named in the export report. -mod support; +use crate::support; use filmcraft_interchange::{ExportOptions, Format, Report, export, import}; use filmcraft_project::{ItemId, ItemKind, Label, ParamValue, Project, TrackKind, find_effect}; @@ -144,3 +144,19 @@ fn fcp7_import_reports_a_clip_item_whose_file_is_not_defined() { let (_, report) = import(&bytes, Format::Fcp7Xml, None).unwrap(); assert!(!report.mentions("does not define"), "{report}"); } + +/// An empty `ref` is not a valid IDREF: Final Cut Pro rejects the whole file. The adjustment layer +/// on V1 becomes a gap in the primary storyline; the connected title is left out (a `` can +/// only sit in a spine). The media clip still references its asset. +#[test] +fn fcpxml_has_no_empty_ref_for_a_title() { + let (p, s) = project_with_a_title(); + let (bytes, report) = export_report(&p, s, Format::Fcpxml); + let xml = String::from_utf8(bytes).unwrap(); + assert!(!xml.contains("ref=\"\""), "{xml}"); + assert!(!xml.contains("Opening"), "{xml}"); + assert_eq!(xml.matches(", Arc<[u8]>, File, ... -let v = mp4.track_of_kind(TrackKind::Video).unwrap(); -let track = &mp4.tracks[v]; -let i = track.sample_at_presentation_time(1000).unwrap(); // track timescale, edit-adjusted -let key = track.sync_sample_before(i); // decode from here -let bytes = mp4.read_sample(&file, v, key)?; // raw sample (AVCC/HVCC length-prefixed NALs, ...) -``` - -- `ByteSource` (`len`, `read_at(offset, buf)`) is the input abstraction. Implementations exist for `[u8]`, - `Vec`, `&T`, `Arc`, `Box`, and `std::fs::File` on non-wasm targets. -- `open` reads only top-level box headers, `ftyp`, `moov` and every `moof`. It never reads `mdat`. - The one exception is the first sample of a `tmcd` track, which it reads (4 bytes) to get the timecode - start frame. -- `Mp4File`: brands, `is_quicktime`, movie `timescale`/`duration`, `tracks`, `metadata`, `fragmented`, - `fragment_count`. -- `Track`: `id`, `kind` (Video/Audio/Timecode/Subtitle/Other), `handler`, `timescale`, `duration`, - `language`, `tkhd` fields (size, matrix, volume, enabled), `entries: Vec`, `samples`, - `edits`, `edit_offset`, `references` (`tref`), `sdtp`, `sample_groups` (`sbgp`+`sgpd`, raw), `pcm_chunked`. - Helpers: `codec()`, `video()`, `audio()`, `presentation_pts(i)`, `sample_at_pts(pts)`, - `sample_at_presentation_time(t)`, `sync_sample_before(i)`, `sync_samples()`. -- `Sample`: `offset`, `size`, `dts`, `pts` (dts + ctts), `duration`, `is_sync` (all samples are sync when - `stss` is absent), `description_index`. All times are raw media time in the track timescale. -- **Edit lists:** `edits` holds the `elst` entries unchanged. `edit_offset` is - `leading empty edits (rescaled to media timescale) − first non-empty edit's media_time`, so - `presentation time = pts + edit_offset`. This matches ffprobe's timestamps for the common cases - (B-frame delay, AAC priming, empty-edit offsets). Multi-segment edits, dwells and rate changes are exposed - raw for the caller to interpret. -- **Uncompressed PCM** (`lpcm`, `sowt`, `twos`, `in24`, `in32`, `fl32`, `fl64`, `raw `, `ipcm`, `fpcm`) is - merged to one `Sample` per chunk (`pcm_chunked = true`), with `duration` in frames. This avoids one - table entry per audio frame. - -### Parsed boxes -`ftyp`, `moov/mvhd`, `trak/tkhd`, `edts/elst` (v0/v1), `tref`, `mdia/mdhd` (v0/v1, ISO and Mac language -codes), `hdlr` (ISO and Pascal names), `minf/stbl`: `stsd`, `stts`, `ctts` (v0/v1, read as signed), `stss`, -`stsc`, `stsz`/`stz2` (4/8/16-bit), `stco`/`co64`, `sdtp`, `sbgp`/`sgpd`. Also `mvex/trex`, `moof/traf/tfhd/tfdt/trun` -(every `tfhd` base-offset mode and multiple `moof`s), and `udta` metadata (QuickTime `©xxx` atoms, -`meta/ilst`, and `meta/keys` (mdta)). `uuid`, `free`, `skip`, `wide`, `sidx` and `mfra` are skipped. -Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are supported. - -### Sample entries → `CodecConfig` -| Format | Config | -|---|---| -| `avc1`/`avc3` + `avcC` | `Avc(AvcConfig)`: profile, level, length size, SPS/PPS, extension bytes | -| `hvc1`/`hev1` + `hvcC` | `Hevc(HevcConfig)`: all header fields plus NAL arrays (`vps()/sps()/pps()`) | -| `av01` + `av1C` | `Av1(Av1Config)` | -| `vp09` + `vpcC` | `Vp9(VpcConfig)` | -| `apch apcn apcs apco ap4h ap4x` | `ProRes { fourcc }` | -| `jpeg mjpa mjpb` | `Jpeg { fourcc }` | -| `AVdn AVdh` | `Dnx { fourcc }` | -| `mp4a` + `esds` (in `wave` too) | `Aac(AacConfig)`: ASC bytes + decoded object type/rate/channels; `Mp3` for OTI 0x69/0x6B | -| `.mp3`/`.mp2` | `Mp3`: MPEG-1/2 audio of any layer (each frame header names it) | -| PCM fourccs | `Pcm(PcmConfig)`: bits, float, endianness (`enda`, lpcm flags, `pcmC`), channels, rate | -| `alac` | `Alac { cookie }` | -| `Opus` + `dOps` | `Opus(OpusConfig)` | -| `fLaC` + `dfLa` | `Flac(FlacConfig)` (STREAMINFO decoded) | -| `ac-3`/`ec-3` | `Ac3 { dac3 }` / `Eac3 { dec3 }` | -| `tmcd` | `Timecode(TimecodeConfig)`: flags (drop frame), timescale, frame duration, fps, start frame, reel name; `format_frame()` | -| anything else | `Unknown { fourcc, raw }` | - -Video entries also parse `colr` (`nclx`/`nclc`/ICC), `pasp`, `clap`, `fiel`, `gama` and `btrt` into -`VideoParams`. The MP4 source in `filmcraft-codecs` crops every frame to `clap`'s clean aperture -before the track's display rotation, and reports that size. Audio entries parse QuickTime sound description v0/v1/v2 into `AudioParams`. - -### Robustness -The parser does not panic on malformed input; every error comes back as an `Error`. Table counts are -checked against the bytes in their box before anything is allocated. A constant-size `stsz` count is -clamped to what fits in the file. `trun` counts are checked, and each track is capped at 2²⁶ samples. -`moov`/`moof` are capped at 1 GiB. Compressed movie headers (`cmov`) are rejected with -`Error::Unsupported`. - -## Muxing - -```rust -use filmcraft_isobmff::*; -let mut w = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4))?; -let v = w.add_track(TrackConfig::new(SampleEntry::avc(avcc, 1920, 1080), 90_000))?; -let mut acfg = TrackConfig::new(SampleEntry::aac(asc, 2, 48_000), 48_000); -acfg.media_start = Some(1024); // AAC encoder delay → edit list -let a = w.add_track(acfg)?; -w.write_sample(v, WriteSample { data: &au, duration: 3000, composition_offset: 6000, is_sync: true })?; -// ... interleave samples in decode order ... -let bytes = w.finish_faststart()?.into_inner(); // or finish() for moov-at-end -``` - -- `Mp4Writer` streams samples into a single `mdat`. `finish()` appends `moov`. - `finish_faststart()` (needs `W: Read + Write + Seek`) moves the media data forward in place and writes - `moov` before it. If `mdat` goes past 4 GiB the header becomes a large-size header, and chunk offsets - switch to `co64` when needed. -- Brands: `Brand::Mp4` writes `isom` with `isom iso2 avc1 mp41 mp42`. `Brand::Mov` writes `qt `, uses - QuickTime `hdlr`/Pascal names, and sets `wide` as the placeholder box. -- Tables written: `stts`, `ctts` (v1 when an offset is negative), `stss` (omitted when every sample is - sync), `stsc`, `stsz` (constant or table), `stco`/`co64`. Consecutive samples of one track share a chunk. -- Sample entries come from `SampleEntry` (`avc`, `hevc`, `prores`, `jpeg`, `aac`, `pcm`, or any demuxed - entry). Remuxing copies codec config, `colr`, `pasp`, `clap`, `fiel`, `gama` and `btrt`. - PCM is written as `sowt`/`twos` (16-bit) or `raw ` (8-bit) or sound description v2 `lpcm` in MOV, and as - `ipcm`/`fpcm` + `pcmC` in MP4. A PCM `write_sample` call can hold any whole number of frames, and the - track timescale must equal the sample rate. -- Edit lists: set `TrackConfig::edits` explicitly, or use `media_start` to get a single edit. -- Timecode: `add_timecode_track(video, TimecodeConfig, start_frame)` adds a `tmcd` track (with `gmhd`) - and a `tref/tmcd` link from the video track. -- `WriterOptions::metadata` writes `©xxx` text atoms to `udta`. -- `FragmentedWriter` writes fMP4. The init segment is `ftyp` + `moov` with `mvex/trex`. Each - `flush_fragment()` then writes `moof` (`mfhd`, `traf` with default-base-is-moof `tfhd`, `tfdt` v1, - `trun` with per-sample duration, size, flags and composition offset) followed by `mdat`. - -## Tests -- Unit tests cover the box reader and hand-built `stbl` edge cases (stz2, co64, ctts v1, empty edits, - 64-bit and size-0 boxes, uuid/wide/skip), codec config round-trips, and allocation guards. -- Property tests (`tests/roundtrip.rs`) write random multi-track files, both progressive (MP4/MOV, - faststart, PCM, edits) and fragmented, then read them back and compare sample tables and bytes. -- ffprobe oracle tests (`tests/oracle_demux.rs`) run on ffmpeg-generated fixtures in - `target/fixtures/isobmff/`: H.264 with B-frames (MP4 and MOV), HEVC, ProRes 422/4444, MJPEG, DNxHR, PCM - s16le/s16be/s24le/f32le, AAC, ALAC, FLAC, Opus, AC-3, VP9, AV1, fragmented (with and without - default-base-is-moof), faststart, an edit-list cut, `tmcd` at 29.97 DF and 23.976, and colr/pasp. They - compare packet offsets, sizes, pts/dts, durations and key flags, plus stream parameters. Tests skip - with a message when ffmpeg or an encoder is missing. -- Muxer oracle tests (`tests/oracle_mux.rs`) remux fixtures with our writers. They check that - `ffmpeg -v error -f null` reports nothing, that ffprobe packets and stream parameters match the source, - and that timecode, the AAC priming edit, ipcm and fMP4 output are correct. -- Mutation fuzzing (`tests/fuzz.rs`) uses a deterministic seed to mutate and truncate synthetic and - ffmpeg files, and checks that nothing panics. - -## Limitations / not yet supported -- `presentation time = pts + edit_offset` only applies the leading empty edits and the first media - edit. Multi-segment edit lists, dwell edits and non-unity rates are exposed raw. ffmpeg handles an edit - that starts mid-frame differently: it snaps to the frame. -- Other data references (external `alis`/`url ` files), encrypted entries (`encv`/`enca`, `sinf`, - `senc`), `cmov`, and QuickTime reference movies are not supported. -- `sidx`/`mfra` are ignored, because the demuxer scans every top-level box. `stsd` entries with a - QuickTime colour table are not decoded. -- Subtitle and text tracks (`tx3g`, `c608`, `wvtt`) and `mp4v` show up as `Unknown`. -- Muxer: one `mdat`, no chunk-size or duration limits (the caller controls interleaving), no `sdtp`, - `sgpd`/`sbgp` or `cslg` output, ISO-style `meta/ilst` metadata is not written, and the fragmented - writer has no PCM, `sidx` or `mfra`. +A re-export of `craft_media::isobmff`. Documentation: https://github.com/storytold/craft-libs/blob/main/crates/media/README.md diff --git a/crates/isobmff/src/bytes.rs b/crates/isobmff/src/bytes.rs deleted file mode 100644 index 97f92690b..000000000 --- a/crates/isobmff/src/bytes.rs +++ /dev/null @@ -1,361 +0,0 @@ -//! Low-level helpers: four-character codes, a bounds-checked big-endian cursor, box iteration -//! over in-memory payloads, and a box builder for the muxers. - -use crate::error::{Error, Result}; -use std::fmt; - -/// A four-character code (box type, sample entry format, brand, handler type…). -#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] -pub struct FourCc(pub [u8; 4]); - -impl FourCc { - pub const fn new(b: &[u8; 4]) -> Self { - FourCc(*b) - } - pub fn as_u32(self) -> u32 { - u32::from_be_bytes(self.0) - } - /// Lossy string form; `©` (0xA9) is mapped to U+00A9, other non-printables to `?`. - pub fn to_string_lossy(self) -> String { - self.0 - .iter() - .map(|&b| match b { - 0xA9 => '©', - 0x20..=0x7E => b as char, - _ => '?', - }) - .collect() - } -} - -impl fmt::Display for FourCc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.to_string_lossy()) - } -} -impl fmt::Debug for FourCc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "FourCc({:?})", self.to_string_lossy()) - } -} -impl From<&[u8; 4]> for FourCc { - fn from(b: &[u8; 4]) -> Self { - FourCc(*b) - } -} -impl PartialEq<[u8; 4]> for FourCc { - fn eq(&self, other: &[u8; 4]) -> bool { - &self.0 == other - } -} -impl PartialEq<&[u8; 4]> for FourCc { - fn eq(&self, other: &&[u8; 4]) -> bool { - &self.0 == *other - } -} -impl PartialEq<&&[u8; 4]> for FourCc { - fn eq(&self, other: &&&[u8; 4]) -> bool { - &self.0 == **other - } -} - -/// Bounds-checked big-endian cursor over a byte slice. -#[derive(Clone)] -pub(crate) struct Cur<'a> { - pub data: &'a [u8], - pub pos: usize, - what: &'static str, -} - -impl<'a> Cur<'a> { - pub fn new(data: &'a [u8], what: &'static str) -> Self { - Cur { data, pos: 0, what } - } - pub fn remaining(&self) -> usize { - self.data.len().saturating_sub(self.pos) - } - pub fn rest(&self) -> &'a [u8] { - self.data.get(self.pos..).unwrap_or(&[]) - } - pub fn bytes(&mut self, n: usize) -> Result<&'a [u8]> { - let end = self.pos.checked_add(n).ok_or(Error::Truncated(self.what))?; - let s = self.data.get(self.pos..end).ok_or(Error::Truncated(self.what))?; - self.pos = end; - Ok(s) - } - pub fn skip(&mut self, n: usize) -> Result<()> { - self.bytes(n).map(|_| ()) - } - pub fn array(&mut self) -> Result<[u8; N]> { - let s = self.bytes(N)?; - let mut a = [0u8; N]; - a.copy_from_slice(s); - Ok(a) - } - pub fn u8(&mut self) -> Result { - Ok(self.array::<1>()?[0]) - } - pub fn u16(&mut self) -> Result { - Ok(u16::from_be_bytes(self.array()?)) - } - pub fn i16(&mut self) -> Result { - Ok(i16::from_be_bytes(self.array()?)) - } - pub fn u24(&mut self) -> Result { - let a = self.array::<3>()?; - Ok(((a[0] as u32) << 16) | ((a[1] as u32) << 8) | a[2] as u32) - } - pub fn u32(&mut self) -> Result { - Ok(u32::from_be_bytes(self.array()?)) - } - pub fn i32(&mut self) -> Result { - Ok(i32::from_be_bytes(self.array()?)) - } - pub fn u64(&mut self) -> Result { - Ok(u64::from_be_bytes(self.array()?)) - } - pub fn i64(&mut self) -> Result { - Ok(i64::from_be_bytes(self.array()?)) - } - pub fn f64(&mut self) -> Result { - Ok(f64::from_be_bytes(self.array()?)) - } - pub fn fourcc(&mut self) -> Result { - Ok(FourCc(self.array()?)) - } - /// Full-box header: (version, flags). - pub fn full_header(&mut self) -> Result<(u8, u32)> { - let v = self.u32()?; - Ok(((v >> 24) as u8, v & 0x00FF_FFFF)) - } - /// Read a count and verify that `count * entry_size` bytes remain (allocation guard). - pub fn count(&mut self, entry_size: usize) -> Result { - let n = self.u32()? as usize; - self.check_count(n, entry_size)?; - Ok(n) - } - pub fn check_count(&self, n: usize, entry_size: usize) -> Result<()> { - match n.checked_mul(entry_size) { - Some(b) if b <= self.remaining() => Ok(()), - _ => Err(Error::Truncated(self.what)), - } - } -} - -/// Header of one box as found in a byte stream. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct BoxHeader { - pub kind: FourCc, - /// Header length including largesize and uuid extended type. - pub header_len: u64, - /// Total box size including header; `None` means "extends to end of the enclosing container". - pub size: Option, - pub uuid: Option<[u8; 16]>, -} - -/// Parse a box header from `buf` (needs up to 32 bytes; fewer if available). -pub(crate) fn parse_box_header(buf: &[u8]) -> Result { - let mut c = Cur::new(buf, "box header"); - let size32 = c.u32()?; - let kind = c.fourcc()?; - let mut header_len = 8u64; - let size = match size32 { - 0 => None, - 1 => { - header_len += 8; - let s = c.u64()?; - Some(s) - } - s => Some(s as u64), - }; - let uuid = if kind == b"uuid" { - header_len += 16; - Some(c.array::<16>()?) - } else { - None - }; - if let Some(s) = size - && s < header_len - { - return Err(Error::Invalid(format!("box '{kind}' size {s} smaller than its header"))); - } - Ok(BoxHeader { kind, header_len, size, uuid }) -} - -/// One child box inside an in-memory payload. -#[derive(Clone, Copy, Debug)] -pub(crate) struct RawBox<'a> { - pub kind: FourCc, - pub payload: &'a [u8], - #[allow(dead_code)] - pub uuid: Option<[u8; 16]>, -} - -/// Iterator over the child boxes of a payload. Yields an error for a malformed box and then stops. -pub(crate) struct Boxes<'a> { - data: &'a [u8], - pos: usize, - done: bool, -} - -pub(crate) fn boxes(data: &[u8]) -> Boxes<'_> { - Boxes { data, pos: 0, done: false } -} - -impl<'a> Iterator for Boxes<'a> { - type Item = Result>; - fn next(&mut self) -> Option { - if self.done { - return None; - } - let rest = &self.data[self.pos..]; - if rest.is_empty() { - self.done = true; - return None; - } - // QuickTime allows a 32-bit zero terminator at the end of some containers. - if rest.len() < 8 { - self.done = true; - if rest.iter().all(|&b| b == 0) { - return None; - } - return Some(Err(Error::Truncated("box header"))); - } - let h = match parse_box_header(rest) { - Ok(h) => h, - Err(e) => { - self.done = true; - return Some(Err(e)); - } - }; - let size = h.size.unwrap_or(rest.len() as u64); - if size > rest.len() as u64 { - self.done = true; - return Some(Err(Error::Invalid(format!("box '{}' size {} exceeds its container ({} bytes left)", h.kind, size, rest.len())))); - } - let size = size as usize; - let payload = &rest[h.header_len as usize..size]; - self.pos += size; - Some(Ok(RawBox { kind: h.kind, payload, uuid: h.uuid })) - } -} - -/// Find the first child of the given type (malformed trailing children are ignored). -pub(crate) fn find<'a>(data: &'a [u8], kind: &[u8; 4]) -> Option<&'a [u8]> { - boxes(data).map_while(|b| b.ok()).find(|b| b.kind == kind).map(|b| b.payload) -} - -/// Growable buffer for building boxes. -#[derive(Default, Clone, Debug)] -pub(crate) struct BoxBuf { - pub buf: Vec, -} - -impl BoxBuf { - pub fn new() -> Self { - Self::default() - } - /// Begin a box; returns a marker to pass to [`end`](Self::end). - pub fn start(&mut self, kind: &[u8; 4]) -> usize { - let at = self.buf.len(); - self.buf.extend_from_slice(&[0, 0, 0, 0]); - self.buf.extend_from_slice(kind); - at - } - pub fn start_full(&mut self, kind: &[u8; 4], version: u8, flags: u32) -> usize { - let at = self.start(kind); - self.u32(((version as u32) << 24) | (flags & 0xFF_FFFF)); - at - } - pub fn end(&mut self, at: usize) { - let size = (self.buf.len() - at) as u32; - self.buf[at..at + 4].copy_from_slice(&size.to_be_bytes()); - } - /// Append a complete box with the given payload. - pub fn leaf(&mut self, kind: &[u8; 4], payload: &[u8]) { - let at = self.start(kind); - self.bytes(payload); - self.end(at); - } - pub fn u8(&mut self, v: u8) { - self.buf.push(v); - } - pub fn u16(&mut self, v: u16) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i16(&mut self, v: i16) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn u32(&mut self, v: u32) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i32(&mut self, v: i32) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn u64(&mut self, v: u64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i64(&mut self, v: i64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn f64(&mut self, v: f64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn bytes(&mut self, b: &[u8]) { - self.buf.extend_from_slice(b); - } - pub fn zeros(&mut self, n: usize) { - self.buf.resize(self.buf.len() + n, 0); - } - pub fn len(&self) -> usize { - self.buf.len() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn header_forms() { - let h = parse_box_header(&[0, 0, 0, 16, b'f', b'r', b'e', b'e']).unwrap(); - assert_eq!((h.kind, h.header_len, h.size), (FourCc(*b"free"), 8, Some(16))); - let h = parse_box_header(&[0, 0, 0, 0, b'm', b'd', b'a', b't']).unwrap(); - assert_eq!(h.size, None); - let mut v = vec![0, 0, 0, 1, b'm', b'd', b'a', b't']; - v.extend_from_slice(&100u64.to_be_bytes()); - let h = parse_box_header(&v).unwrap(); - assert_eq!((h.header_len, h.size), (16, Some(100))); - let mut v = vec![0, 0, 0, 30, b'u', b'u', b'i', b'd']; - v.extend_from_slice(&[7u8; 16]); - let h = parse_box_header(&v).unwrap(); - assert_eq!((h.header_len, h.uuid), (24, Some([7u8; 16]))); - assert!(parse_box_header(&[0, 0, 0, 4, b'f', b'r', b'e', b'e']).is_err()); - assert!(parse_box_header(&[0, 0, 0]).is_err()); - } - - #[test] - fn iterate_children() { - let mut b = BoxBuf::new(); - b.leaf(b"aaaa", &[1, 2]); - let m = b.start(b"bbbb"); - b.leaf(b"cccc", &[]); - b.end(m); - b.u32(0); // QT terminator - let kids: Vec<_> = boxes(&b.buf).collect::>>().unwrap(); - assert_eq!(kids.len(), 2); - assert_eq!(kids[0].payload, &[1, 2]); - assert_eq!(find(&b.buf, b"bbbb").map(|p| p.len()), Some(8)); - // Oversized child → error, no panic. - let bad = [0u8, 0, 0, 99, b'x', b'x', b'x', b'x', 0]; - assert!(boxes(&bad).next().unwrap().is_err()); - } - - #[test] - fn cursor_bounds() { - let mut c = Cur::new(&[0, 0, 0, 5, 1], "t"); - assert!(c.count(1).is_err()); - let mut c = Cur::new(&[0xFF, 0xFF, 0xFF, 0xFF], "t"); - assert!(c.count(usize::MAX).is_err()); - } -} diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs deleted file mode 100644 index 1fbcff9ce..000000000 --- a/crates/isobmff/src/codec.rs +++ /dev/null @@ -1,1724 +0,0 @@ -//! Sample entries (`stsd` children) and codec configuration records: parsing and serialization. - -use crate::bytes::{BoxBuf, Cur, FourCc, boxes}; -use crate::error::{Error, Result}; -use filmcraft_bitstream::BitReader; - -/// One sample description (an entry of `stsd`). -#[derive(Clone, Debug, PartialEq)] -pub struct SampleEntry { - /// Sample entry format (`avc1`, `mp4a`, `apch`, …). - pub format: FourCc, - pub data_reference_index: u16, - pub codec: CodecConfig, - /// Present for visual sample entries. - pub video: Option, - /// Present for audio sample entries. - pub audio: Option, - /// `btrt` box. - pub bitrate: Option, -} - -/// Fields common to all visual sample entries plus the optional descriptive boxes. -#[derive(Clone, Debug, PartialEq, Default)] -pub struct VideoParams { - pub width: u16, - pub height: u16, - /// Horizontal/vertical resolution in pixels per inch (16.16 fixed point; 72 dpi = 0x0048_0000). - pub horiz_resolution: u32, - pub vert_resolution: u32, - pub frame_count: u16, - pub compressor_name: String, - pub depth: u16, - /// QuickTime `vendor` / version fields (zero in ISO files). - pub vendor: FourCc, - pub temporal_quality: u32, - pub spatial_quality: u32, - pub color: Option, - /// `pasp`: (hSpacing, vSpacing). - pub pixel_aspect: Option<(u32, u32)>, - pub clean_aperture: Option, - pub field_info: Option, - /// QuickTime `gama` (16.16 fixed point). - pub gamma: Option, - /// `mdcv`: mastering display colour volume (SMPTE ST 2086). - pub mastering_display: Option, - /// `clli`: content light level (MaxCLL, MaxFALL in cd/m²; 0 = unknown). - pub content_light: Option<(u16, u16)>, -} - -/// SMPTE ST 2086 mastering display metadata, in the units of the `mdcv` box and the H.264/HEVC -/// SEI: chromaticities in 0.00002 steps (order G, B, R as the SEI), luminance in 0.0001 cd/m². -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MasteringDisplay { - pub primaries: [(u16, u16); 3], - pub white_point: (u16, u16), - pub max_luminance: u32, - pub min_luminance: u32, -} - -impl MasteringDisplay { - /// BT.2020 primaries, D65, with the given peak and black in cd/m². - pub fn bt2020(max_nits: f64, min_nits: f64) -> Self { - let q = |v: f64| (v / 0.00002).round() as u16; - MasteringDisplay { - primaries: [(q(0.170), q(0.797)), (q(0.131), q(0.046)), (q(0.708), q(0.292))], - white_point: (q(0.3127), q(0.3290)), - max_luminance: (max_nits * 10_000.0).round() as u32, - min_luminance: (min_nits * 10_000.0).round() as u32, - } - } - pub fn max_nits(&self) -> f64 { - self.max_luminance as f64 / 10_000.0 - } - /// The 24-byte payload shared by `mdcv` and the SEI message. - pub fn to_bytes(&self) -> Vec { - let mut v = Vec::with_capacity(24); - for (x, y) in self.primaries { - v.extend_from_slice(&x.to_be_bytes()); - v.extend_from_slice(&y.to_be_bytes()); - } - v.extend_from_slice(&self.white_point.0.to_be_bytes()); - v.extend_from_slice(&self.white_point.1.to_be_bytes()); - v.extend_from_slice(&self.max_luminance.to_be_bytes()); - v.extend_from_slice(&self.min_luminance.to_be_bytes()); - v - } - pub fn parse(p: &[u8]) -> Option { - if p.len() < 24 { - return None; - } - let u16_at = |i: usize| u16::from_be_bytes([p[i], p[i + 1]]); - let u32_at = |i: usize| u32::from_be_bytes([p[i], p[i + 1], p[i + 2], p[i + 3]]); - Some(MasteringDisplay { - primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))], - white_point: (u16_at(12), u16_at(14)), - max_luminance: u32_at(16), - min_luminance: u32_at(20), - }) - } -} - -/// `colr` box contents. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ColorInfo { - /// ISO `nclx` (with full-range flag). - Nclx { primaries: u16, transfer: u16, matrix: u16, full_range: bool }, - /// QuickTime `nclc`. - Nclc { primaries: u16, transfer: u16, matrix: u16 }, - /// Embedded ICC profile (`prof` restricted/unrestricted `rICC`). - Icc { kind: FourCc, profile: Vec }, -} - -/// `clap` clean aperture (all rationals num/den; offsets are signed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CleanAperture { - pub width: (u32, u32), - pub height: (u32, u32), - pub horiz_offset: (i32, u32), - pub vert_offset: (i32, u32), -} - -/// QuickTime `fiel`: field count (1 progressive, 2 interlaced) and ordering detail -/// (0, 1 = top first, 6 = bottom first, 9 / 14 per the QuickTime spec). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FieldInfo { - pub fields: u8, - pub detail: u8, -} - -/// `btrt` box. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct BitRate { - pub buffer_size: u32, - pub max_bitrate: u32, - pub avg_bitrate: u32, -} - -/// Fields of audio sample entries (ISO v0 plus QuickTime sound description v1/v2 extensions). -#[derive(Clone, Debug, PartialEq, Default)] -pub struct AudioParams { - /// QuickTime sound description version (0, 1, 2). ISO files use 0. - pub qt_version: u16, - pub channels: u32, - /// Bits per sample from the entry (16 for most compressed formats). - pub sample_size: u16, - pub sample_rate: f64, - pub compression_id: i16, - /// v1 fields. - pub samples_per_packet: u32, - pub bytes_per_packet: u32, - pub bytes_per_frame: u32, - pub bytes_per_sample: u32, - /// v2 fields. - pub const_bits_per_channel: u32, - pub lpcm_flags: u32, - pub const_bytes_per_packet: u32, - pub const_frames_per_packet: u32, - /// `enda` little-endian flag inside a QuickTime `wave` box. - pub little_endian: Option, -} - -/// Codec configuration decoded from the sample entry. -#[derive(Clone, Debug, PartialEq)] -pub enum CodecConfig { - Avc(AvcConfig), - Hevc(HevcConfig), - Av1(Av1Config), - Vp9(VpcConfig), - /// Apple ProRes; fourcc is `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`. - ProRes { - fourcc: FourCc, - }, - /// Motion JPEG (`jpeg`, `mjpa`, `mjpb`). - Jpeg { - fourcc: FourCc, - }, - /// Avid DNxHD/DNxHR (`AVdn`, `AVdh`). - Dnx { - fourcc: FourCc, - }, - /// Advanced Professional Video (`apv1` + `apvC`). - Apv(ApvConfig), - Aac(AacConfig), - /// MPEG-1/2 audio (esds object type 0x69/0x6B, or QuickTime `.mp3` / `.mp2`). Usually layer - /// III, but these name no layer: ffmpeg stores MP2 as `mp4a` + 0x6B. Each frame header has it. - Mp3, - Pcm(PcmConfig), - /// ALAC magic cookie (ALACSpecificConfig, 24 bytes, optionally followed by a channel layout). - Alac { - cookie: Vec, - }, - Opus(OpusConfig), - Flac(FlacConfig), - /// `dac3` payload. - Ac3 { - dac3: Vec, - }, - /// `dec3` payload. - Eac3 { - dec3: Vec, - }, - Timecode(TimecodeConfig), - /// Anything else; `raw` is the full sample entry payload (after the 8-byte box header). - Unknown { - fourcc: FourCc, - raw: Vec, - }, -} - -impl CodecConfig { - /// A short human-readable codec name. - pub fn name(&self) -> &'static str { - match self { - CodecConfig::Avc(_) => "h264", - CodecConfig::Hevc(_) => "hevc", - CodecConfig::Av1(_) => "av1", - CodecConfig::Vp9(_) => "vp9", - CodecConfig::ProRes { .. } => "prores", - CodecConfig::Jpeg { .. } => "mjpeg", - CodecConfig::Dnx { .. } => "dnxhd", - CodecConfig::Apv(_) => "apv", - CodecConfig::Aac(_) => "aac", - CodecConfig::Mp3 => "mp3", - CodecConfig::Pcm(_) => "pcm", - CodecConfig::Alac { .. } => "alac", - CodecConfig::Opus(_) => "opus", - CodecConfig::Flac(_) => "flac", - CodecConfig::Ac3 { .. } => "ac3", - CodecConfig::Eac3 { .. } => "eac3", - CodecConfig::Timecode(_) => "timecode", - CodecConfig::Unknown { .. } => "unknown", - } - } -} - -/// `avcC` — AVCDecoderConfigurationRecord (ISO/IEC 14496-15 §5.3.3). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct AvcConfig { - pub profile: u8, - pub compatibility: u8, - pub level: u8, - /// NAL length field size in bytes (1, 2 or 4). - pub length_size: u8, - pub sps: Vec>, - pub pps: Vec>, - /// Bytes after the PPS list (high-profile extension: chroma format, bit depths, SPS-ext). - pub ext: Vec, -} - -impl AvcConfig { - /// Build from parameter sets (NAL units without start codes); profile/level taken from the first SPS. - pub fn new(sps: Vec>, pps: Vec>, length_size: u8) -> Self { - let (profile, compatibility, level) = sps.first().filter(|s| s.len() >= 4).map(|s| (s[1], s[2], s[3])).unwrap_or((0, 0, 0)); - AvcConfig { profile, compatibility, level, length_size, sps, pps, ext: Vec::new() } - } - - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "avcC"); - let _version = c.u8()?; - let profile = c.u8()?; - let compatibility = c.u8()?; - let level = c.u8()?; - let length_size = (c.u8()? & 3) + 1; - let nsps = (c.u8()? & 0x1F) as usize; - let mut sps = Vec::with_capacity(nsps); - for _ in 0..nsps { - let n = c.u16()? as usize; - sps.push(c.bytes(n)?.to_vec()); - } - let npps = c.u8()? as usize; - let mut pps = Vec::with_capacity(npps); - for _ in 0..npps { - let n = c.u16()? as usize; - pps.push(c.bytes(n)?.to_vec()); - } - Ok(AvcConfig { profile, compatibility, level, length_size, sps, pps, ext: c.rest().to_vec() }) - } - - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(1); - b.u8(self.profile); - b.u8(self.compatibility); - b.u8(self.level); - b.u8(0xFC | (self.length_size.clamp(1, 4) - 1)); - b.u8(0xE0 | (self.sps.len().min(31) as u8)); - for s in self.sps.iter().take(31) { - b.u16(s.len() as u16); - b.bytes(s); - } - b.u8(self.pps.len().min(255) as u8); - for p in self.pps.iter().take(255) { - b.u16(p.len() as u16); - b.bytes(p); - } - b.bytes(&self.ext); - b.buf - } -} - -/// One NAL array of an `hvcC` record. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct HevcNalArray { - pub completeness: bool, - pub nal_type: u8, - pub nalus: Vec>, -} - -/// `hvcC` — HEVCDecoderConfigurationRecord (ISO/IEC 14496-15 §8.3.3). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct HevcConfig { - pub general_profile_space: u8, - pub general_tier_flag: bool, - pub general_profile_idc: u8, - pub general_profile_compatibility_flags: u32, - /// 48 bits of general constraint indicator flags. - pub general_constraint_indicator_flags: u64, - pub general_level_idc: u8, - pub min_spatial_segmentation_idc: u16, - pub parallelism_type: u8, - pub chroma_format_idc: u8, - pub bit_depth_luma: u8, - pub bit_depth_chroma: u8, - pub avg_frame_rate: u16, - pub constant_frame_rate: u8, - pub num_temporal_layers: u8, - pub temporal_id_nested: bool, - pub length_size: u8, - pub arrays: Vec, -} - -impl HevcConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "hvcC"); - let _version = c.u8()?; - let b = c.u8()?; - let compat = c.u32()?; - let hi = c.u16()? as u64; - let lo = c.u32()? as u64; - let level = c.u8()?; - let min_seg = c.u16()? & 0x0FFF; - let par = c.u8()? & 3; - let chroma = c.u8()? & 3; - let bdl = (c.u8()? & 7) + 8; - let bdc = (c.u8()? & 7) + 8; - let afr = c.u16()?; - let x = c.u8()?; - let narrays = c.u8()? as usize; - let mut arrays = Vec::new(); - for _ in 0..narrays { - let t = c.u8()?; - let n = c.u16()? as usize; - c.check_count(n, 2)?; - let mut nalus = Vec::with_capacity(n); - for _ in 0..n { - let len = c.u16()? as usize; - nalus.push(c.bytes(len)?.to_vec()); - } - arrays.push(HevcNalArray { completeness: t & 0x80 != 0, nal_type: t & 0x3F, nalus }); - } - Ok(HevcConfig { - general_profile_space: b >> 6, - general_tier_flag: b & 0x20 != 0, - general_profile_idc: b & 0x1F, - general_profile_compatibility_flags: compat, - general_constraint_indicator_flags: (hi << 32) | lo, - general_level_idc: level, - min_spatial_segmentation_idc: min_seg, - parallelism_type: par, - chroma_format_idc: chroma, - bit_depth_luma: bdl, - bit_depth_chroma: bdc, - avg_frame_rate: afr, - constant_frame_rate: x >> 6, - num_temporal_layers: (x >> 3) & 7, - temporal_id_nested: x & 4 != 0, - length_size: (x & 3) + 1, - arrays, - }) - } - - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(1); - b.u8((self.general_profile_space << 6) | ((self.general_tier_flag as u8) << 5) | (self.general_profile_idc & 0x1F)); - b.u32(self.general_profile_compatibility_flags); - b.u16((self.general_constraint_indicator_flags >> 32) as u16); - b.u32(self.general_constraint_indicator_flags as u32); - b.u8(self.general_level_idc); - b.u16(0xF000 | (self.min_spatial_segmentation_idc & 0x0FFF)); - b.u8(0xFC | (self.parallelism_type & 3)); - b.u8(0xFC | (self.chroma_format_idc & 3)); - b.u8(0xF8 | (self.bit_depth_luma.saturating_sub(8) & 7)); - b.u8(0xF8 | (self.bit_depth_chroma.saturating_sub(8) & 7)); - b.u16(self.avg_frame_rate); - b.u8((self.constant_frame_rate << 6) - | ((self.num_temporal_layers & 7) << 3) - | ((self.temporal_id_nested as u8) << 2) - | (self.length_size.clamp(1, 4) - 1)); - b.u8(self.arrays.len() as u8); - for a in &self.arrays { - b.u8(((a.completeness as u8) << 7) | (a.nal_type & 0x3F)); - b.u16(a.nalus.len() as u16); - for n in &a.nalus { - b.u16(n.len() as u16); - b.bytes(n); - } - } - b.buf - } - - fn nalus_of(&self, t: u8) -> Vec<&[u8]> { - self.arrays.iter().filter(|a| a.nal_type == t).flat_map(|a| a.nalus.iter().map(|n| n.as_slice())).collect() - } - pub fn vps(&self) -> Vec<&[u8]> { - self.nalus_of(32) - } - pub fn sps(&self) -> Vec<&[u8]> { - self.nalus_of(33) - } - pub fn pps(&self) -> Vec<&[u8]> { - self.nalus_of(34) - } -} - -/// `av1C` — AV1CodecConfigurationRecord. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct Av1Config { - pub seq_profile: u8, - pub seq_level_idx_0: u8, - pub seq_tier_0: bool, - pub high_bitdepth: bool, - pub twelve_bit: bool, - pub monochrome: bool, - pub chroma_subsampling_x: bool, - pub chroma_subsampling_y: bool, - pub chroma_sample_position: u8, - pub initial_presentation_delay_minus_one: Option, - pub config_obus: Vec, -} - -impl Av1Config { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "av1C"); - let m = c.u8()?; - if m & 0x80 == 0 { - return Err(Error::Invalid("av1C marker bit not set".into())); - } - let a = c.u8()?; - let b = c.u8()?; - let d = c.u8()?; - Ok(Av1Config { - seq_profile: a >> 5, - seq_level_idx_0: a & 0x1F, - seq_tier_0: b & 0x80 != 0, - high_bitdepth: b & 0x40 != 0, - twelve_bit: b & 0x20 != 0, - monochrome: b & 0x10 != 0, - chroma_subsampling_x: b & 0x08 != 0, - chroma_subsampling_y: b & 0x04 != 0, - chroma_sample_position: b & 3, - initial_presentation_delay_minus_one: if d & 0x10 != 0 { Some(d & 0x0F) } else { None }, - config_obus: c.rest().to_vec(), - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(0x81); - b.u8((self.seq_profile << 5) | (self.seq_level_idx_0 & 0x1F)); - b.u8(((self.seq_tier_0 as u8) << 7) - | ((self.high_bitdepth as u8) << 6) - | ((self.twelve_bit as u8) << 5) - | ((self.monochrome as u8) << 4) - | ((self.chroma_subsampling_x as u8) << 3) - | ((self.chroma_subsampling_y as u8) << 2) - | (self.chroma_sample_position & 3)); - b.u8(match self.initial_presentation_delay_minus_one { - Some(d) => 0x10 | (d & 0x0F), - None => 0, - }); - b.bytes(&self.config_obus); - b.buf - } -} - -/// `vpcC` — VP codec configuration (version 1). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct VpcConfig { - pub profile: u8, - pub level: u8, - pub bit_depth: u8, - pub chroma_subsampling: u8, - pub full_range: bool, - pub colour_primaries: u8, - pub transfer_characteristics: u8, - pub matrix_coefficients: u8, - pub codec_init: Vec, -} - -impl VpcConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "vpcC"); - let (version, _) = c.full_header()?; - if version == 0 { - // Version 0 draft layout: profile, level, bitDepth(4)|colorSpace(4), chroma(4)|transfer(3)|range(1) - let profile = c.u8()?; - let level = c.u8()?; - let a = c.u8()?; - let b = c.u8()?; - return Ok(VpcConfig { profile, level, bit_depth: a >> 4, chroma_subsampling: b >> 4, full_range: b & 1 != 0, ..Default::default() }); - } - let profile = c.u8()?; - let level = c.u8()?; - let a = c.u8()?; - let colour_primaries = c.u8()?; - let transfer_characteristics = c.u8()?; - let matrix_coefficients = c.u8()?; - let n = c.u16()? as usize; - let codec_init = c.bytes(n)?.to_vec(); - Ok(VpcConfig { - profile, - level, - bit_depth: a >> 4, - chroma_subsampling: (a >> 1) & 7, - full_range: a & 1 != 0, - colour_primaries, - transfer_characteristics, - matrix_coefficients, - codec_init, - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u32(1 << 24); - b.u8(self.profile); - b.u8(self.level); - b.u8((self.bit_depth << 4) | ((self.chroma_subsampling & 7) << 1) | self.full_range as u8); - b.u8(self.colour_primaries); - b.u8(self.transfer_characteristics); - b.u8(self.matrix_coefficients); - b.u16(self.codec_init.len() as u16); - b.bytes(&self.codec_init); - b.buf - } -} - -/// AAC (MPEG-4 audio) configuration from `esds`. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct AacConfig { - /// AudioSpecificConfig bytes (DecoderSpecificInfo). - pub asc: Vec, - /// Audio object type (2 = AAC-LC, 5 = SBR/HE-AAC, 29 = PS …) as signalled first in the ASC. - pub object_type: u8, - /// Sampling rate signalled by the ASC (core rate; 0 if the ASC is missing). - pub sample_rate: u32, - /// Channel configuration from the ASC (0 = defined elsewhere). - pub channel_config: u8, - /// DecoderConfigDescriptor fields. - pub max_bitrate: u32, - pub avg_bitrate: u32, - pub buffer_size: u32, -} - -const AAC_RATES: [u32; 13] = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350]; - -impl AacConfig { - /// Build from an AudioSpecificConfig, decoding its header fields. - pub fn from_asc(asc: Vec) -> Self { - let (object_type, sample_rate, channel_config) = parse_asc(&asc).unwrap_or((0, 0, 0)); - AacConfig { asc, object_type, sample_rate, channel_config, max_bitrate: 0, avg_bitrate: 0, buffer_size: 0 } - } - /// Channel count implied by the channel configuration (ISO/IEC 14496-3 Table 1.19 with Amd 4: - /// 7 and 12 are 7.1 front / back, 11 is 6.1, 13 is 22.2, 14 is 7.1 with front heights), 0 if - /// unspecified (a PCE) or reserved. - pub fn channels(&self) -> u32 { - match self.channel_config { - 1..=6 => self.channel_config as u32, - 11 => 7, - 7 | 12 | 14 => 8, - 13 => 24, - _ => 0, - } - } -} - -/// Decode (audio object type, sampling frequency, channel configuration) from an AudioSpecificConfig. -pub fn parse_asc(asc: &[u8]) -> Option<(u8, u32, u8)> { - let mut r = BitReader::new(asc); - let aot = |r: &mut BitReader| -> Option { - let t = r.read_bits(5).ok()? as u8; - if t == 31 { Some(32 + r.read_bits(6).ok()? as u8) } else { Some(t) } - }; - let rate = |r: &mut BitReader| -> Option { - let i = r.read_bits(4).ok()? as usize; - if i == 15 { r.read_bits(24).ok() } else { AAC_RATES.get(i).copied() } - }; - let t = aot(&mut r)?; - let sr = rate(&mut r)?; - let ch = r.read_bits(4).ok()? as u8; - Some((t, sr, ch)) -} - -/// Uncompressed PCM description. -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub struct PcmConfig { - pub bits: u16, - pub float: bool, - pub big_endian: bool, - /// Signed integer samples (8-bit `raw ` is unsigned). - pub signed: bool, - pub channels: u32, - pub sample_rate: f64, -} - -impl PcmConfig { - /// Bytes per interleaved audio frame (all channels). - pub fn bytes_per_frame(&self) -> u32 { - (self.bits as u32).div_ceil(8) * self.channels.max(1) - } -} - -/// `dOps` — Opus specific box. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct OpusConfig { - pub output_channels: u8, - pub pre_skip: u16, - pub input_sample_rate: u32, - pub output_gain: i16, - pub channel_mapping_family: u8, - /// StreamCount, CoupledCount and ChannelMapping bytes when family != 0. - pub channel_mapping: Vec, -} - -impl OpusConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "dOps"); - let _version = c.u8()?; - Ok(OpusConfig { - output_channels: c.u8()?, - pre_skip: c.u16()?, - input_sample_rate: c.u32()?, - output_gain: c.i16()?, - channel_mapping_family: c.u8()?, - channel_mapping: c.rest().to_vec(), - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(0); - b.u8(self.output_channels); - b.u16(self.pre_skip); - b.u32(self.input_sample_rate); - b.i16(self.output_gain); - b.u8(self.channel_mapping_family); - b.bytes(&self.channel_mapping); - b.buf - } -} - -/// `dfLa` — FLAC metadata blocks, with STREAMINFO decoded. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct FlacConfig { - /// Raw metadata blocks (each with its 4-byte header) as stored in `dfLa`. - pub metadata_blocks: Vec, - pub sample_rate: u32, - pub channels: u8, - pub bits_per_sample: u8, - pub total_samples: u64, -} - -impl FlacConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "dfLa"); - c.full_header()?; - let blocks = c.rest().to_vec(); - let mut cfg = FlacConfig { metadata_blocks: blocks.clone(), ..Default::default() }; - let mut b = Cur::new(&blocks, "dfLa"); - let hdr = b.u32()?; - if (hdr >> 24) & 0x7F == 0 { - let si = b.bytes(((hdr & 0xFF_FFFF) as usize).min(34))?; - if si.len() >= 18 { - let mut r = BitReader::new(&si[10..18]); - cfg.sample_rate = r.read_bits(20).unwrap_or(0); - cfg.channels = r.read_bits(3).unwrap_or(0) as u8 + 1; - cfg.bits_per_sample = r.read_bits(5).unwrap_or(0) as u8 + 1; - cfg.total_samples = r.read_bits_u64(36).unwrap_or(0); - } - } - Ok(cfg) - } -} - -/// QuickTime timecode sample description (`tmcd`) plus the start frame read from its first sample. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct TimecodeConfig { - /// Bit 0 drop frame, bit 1 24-hour max, bit 2 negative times OK, bit 3 counter. - pub flags: u32, - pub timescale: u32, - pub frame_duration: u32, - /// Nominal frames per second (`numberOfFrames`), e.g. 30 for 29.97. - pub frames_per_second: u8, - /// Frame number of the first timecode sample (filled in by the demuxer). - pub start_frame: Option, - /// Source reel name (`name` box) if present. - pub name: Option, -} - -impl TimecodeConfig { - pub fn drop_frame(&self) -> bool { - self.flags & 1 != 0 - } - /// Format a frame number as `HH:MM:SS:FF` (`;` before the frames when drop-frame). - pub fn format_frame(&self, frame: u32) -> String { - let fps = self.frames_per_second.max(1) as u64; - let mut f = frame as u64; - if self.drop_frame() && fps >= 30 { - let drop = (fps / 30) * 2; // 2 frames per minute at 30, 4 at 60 - let per_min = fps * 60 - drop; - let per_10 = per_min * 10 + drop; - let d = f / per_10; - let m = f % per_10; - f += drop * 9 * d + if m > drop { drop * ((m - drop) / per_min) } else { 0 }; - } - let ff = f % fps; - let s = (f / fps) % 60; - let mi = (f / fps / 60) % 60; - let h = (f / fps / 3600) % 24; - let sep = if self.drop_frame() { ';' } else { ':' }; - format!("{h:02}:{mi:02}:{s:02}{sep}{ff:02}") - } -} - -/// `apvC` — APVDecoderConfigurationBox (`FullBox('apvC', version = 0, flags = 0)` containing -/// `APVDecoderConfigurationRecord`). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct ApvConfig { - pub profile_idc: u8, - pub level_idc: u8, - pub band_idc: u8, - pub frame_width: u32, - pub frame_height: u32, - pub chroma_format_idc: u8, - pub bit_depth_minus8: u8, - pub capture_time_distance: u8, - pub capture_time_distance_ignored: bool, - pub color_description_present: bool, - pub color_primaries: u8, - pub transfer_characteristics: u8, - pub matrix_coefficients: u8, - pub full_range: bool, - /// Raw `APVDecoderConfigurationRecord` bytes (after the 4-byte FullBox header). - pub raw_record: Vec, -} - -impl ApvConfig { - /// Build from a raw `APVDecoderConfigurationRecord` (starting with `configurationVersion = 1`). - pub fn from_record(record: Vec) -> Result { - let mut c = Cur::new(&record, "apvC"); - let version = c.u8()?; - if version != 1 { - return Err(Error::Invalid(format!("unsupported APVDecoderConfigurationRecord version {version}"))); - } - let num_entries = c.u8()?; - let mut cfg = ApvConfig { raw_record: record.clone(), ..Default::default() }; - if num_entries >= 1 && c.remaining() >= 2 { - let _pbu_type = c.u8()?; - let num_fi = c.u8()?; - if num_fi >= 1 && c.remaining() >= 14 { - let flags = c.u8()?; - cfg.color_description_present = (flags & 0x02) != 0; - cfg.capture_time_distance_ignored = (flags & 0x01) != 0; - cfg.profile_idc = c.u8()?; - cfg.level_idc = c.u8()?; - cfg.band_idc = c.u8()?; - cfg.frame_width = c.u32()?; - cfg.frame_height = c.u32()?; - let cb = c.u8()?; - cfg.chroma_format_idc = cb >> 4; - cfg.bit_depth_minus8 = cb & 0x0F; - cfg.capture_time_distance = c.u8()?; - if cfg.color_description_present && c.remaining() >= 4 { - cfg.color_primaries = c.u8()?; - cfg.transfer_characteristics = c.u8()?; - cfg.matrix_coefficients = c.u8()?; - cfg.full_range = (c.u8()? & 0x80) != 0; - } - } - } - Ok(cfg) - } - - /// Parse the payload of an `apvC` box (with or without the 4-byte `FullBox` header). - pub fn parse(data: &[u8]) -> Result { - if data.len() >= 5 && data[..4] == [0, 0, 0, 0] && data[4] == 1 { - Self::from_record(data[4..].to_vec()) - } else if !data.is_empty() && data[0] == 1 { - Self::from_record(data.to_vec()) - } else { - let mut c = Cur::new(data, "apvC"); - let (v, _) = c.full_header()?; - if v != 0 { - return Err(Error::Invalid(format!("unsupported apvC FullBox version {v}"))); - } - Self::from_record(c.rest().to_vec()) - } - } - - /// Serialize the full `apvC` box payload (4-byte `FullBox` header + `APVDecoderConfigurationRecord`). - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u32(0); // FullBox version = 0, flags = 0 - if !self.raw_record.is_empty() { - b.bytes(&self.raw_record); - return b.buf; - } - b.u8(1); // configurationVersion = 1 - b.u8(1); // number_of_configuration_entry = 1 - b.u8(1); // pbu_type = 1 (primary frame) - b.u8(1); // number_of_frame_info = 1 - let flags = (if self.color_description_present { 0x02 } else { 0 }) | (if self.capture_time_distance_ignored { 0x01 } else { 0 }); - b.u8(flags); - b.u8(self.profile_idc); - b.u8(self.level_idc); - b.u8(self.band_idc); - b.u32(self.frame_width); - b.u32(self.frame_height); - b.u8((self.chroma_format_idc << 4) | (self.bit_depth_minus8 & 0x0F)); - b.u8(self.capture_time_distance); - if self.color_description_present { - b.u8(self.color_primaries); - b.u8(self.transfer_characteristics); - b.u8(self.matrix_coefficients); - b.u8(if self.full_range { 0x80 } else { 0 }); - } - b.buf - } -} - -// --------------------------------------------------------------------------------------------- -// Parsing - -fn is_video_fourcc(f: &[u8; 4]) -> bool { - matches!( - f, - b"avc1" - | b"avc3" - | b"hvc1" - | b"hev1" - | b"av01" - | b"vp09" - | b"vp08" - | b"apch" - | b"apcn" - | b"apcs" - | b"apco" - | b"ap4h" - | b"ap4x" - | b"apv1" - | b"jpeg" - | b"mjpa" - | b"mjpb" - | b"AVdn" - | b"AVdh" - | b"mp4v" - | b"encv" - | b"raw " - | b"2vuy" - | b"v210" - ) -} - -fn is_audio_fourcc(f: &[u8; 4]) -> bool { - matches!( - f, - b"mp4a" - | b"lpcm" - | b"sowt" - | b"twos" - | b"in24" - | b"in32" - | b"fl32" - | b"fl64" - | b"ipcm" - | b"fpcm" - | b"alac" - | b"Opus" - | b"fLaC" - | b"ac-3" - | b"ec-3" - | b".mp3" - | b".mp2" - | b"enca" - | b"ulaw" - | b"alaw" - | b"NONE" - ) -} - -/// Parse one `stsd` entry. `handler` is the track handler type; `is_qt` selects QuickTime -/// interpretation of the sound description version field. -pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc, is_qt: bool) -> Result { - let mut c = Cur::new(payload, "sample entry"); - c.skip(6)?; - let data_reference_index = c.u16()?; - let f = &format.0; - let unknown = || CodecConfig::Unknown { fourcc: format, raw: payload.to_vec() }; - if f == b"tmcd" || (handler == b"tmcd" && !is_video_fourcc(f)) { - let mut tc = TimecodeConfig::default(); - c.skip(4)?; - tc.flags = c.u32()?; - tc.timescale = c.u32()?; - tc.frame_duration = c.u32()?; - tc.frames_per_second = c.u8()?; - c.skip(1)?; - for b in boxes(c.rest()).map_while(|b| b.ok()) { - if b.kind == b"name" && b.payload.len() >= 4 { - let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize; - let s = b.payload.get(4..4 + n).unwrap_or(&b.payload[4..]); - tc.name = Some(String::from_utf8_lossy(s).into_owned()); - } - } - return Ok(SampleEntry { format, data_reference_index, codec: CodecConfig::Timecode(tc), video: None, audio: None, bitrate: None }); - } - let video_like = is_video_fourcc(f) || (handler == b"vide" && !is_audio_fourcc(f)); - let audio_like = is_audio_fourcc(f) || (handler == b"soun" && !video_like); - if video_like { - let mut v = VideoParams::default(); - let _version = c.u16()?; - let _revision = c.u16()?; - v.vendor = c.fourcc()?; - v.temporal_quality = c.u32()?; - v.spatial_quality = c.u32()?; - v.width = c.u16()?; - v.height = c.u16()?; - v.horiz_resolution = c.u32()?; - v.vert_resolution = c.u32()?; - c.skip(4)?; - v.frame_count = c.u16()?; - let name = c.bytes(32)?; - let n = (name[0] as usize).min(31); - v.compressor_name = String::from_utf8_lossy(&name[1..1 + n]).into_owned(); - v.depth = c.u16()?; - let _ctab = c.i16()?; - let mut codec = None; - let mut bitrate = None; - for b in boxes(c.rest()).map_while(|b| b.ok()) { - let p = b.payload; - match &b.kind.0 { - b"avcC" if matches!(f, b"avc1" | b"avc3") => codec = Some(CodecConfig::Avc(AvcConfig::parse(p)?)), - b"hvcC" if matches!(f, b"hvc1" | b"hev1") => codec = Some(CodecConfig::Hevc(HevcConfig::parse(p)?)), - b"av1C" if f == b"av01" => codec = Some(CodecConfig::Av1(Av1Config::parse(p)?)), - b"vpcC" if f == b"vp09" => codec = Some(CodecConfig::Vp9(VpcConfig::parse(p)?)), - b"apvC" if f == b"apv1" => codec = Some(CodecConfig::Apv(ApvConfig::parse(p)?)), - b"colr" => v.color = parse_colr(p).ok(), - b"mdcv" => v.mastering_display = MasteringDisplay::parse(p), - b"clli" if p.len() >= 4 => v.content_light = Some((u16::from_be_bytes([p[0], p[1]]), u16::from_be_bytes([p[2], p[3]]))), - b"pasp" => { - let mut c = Cur::new(p, "pasp"); - v.pixel_aspect = Some((c.u32()?, c.u32()?)); - } - b"clap" => { - let mut c = Cur::new(p, "clap"); - v.clean_aperture = Some(CleanAperture { - width: (c.u32()?, c.u32()?), - height: (c.u32()?, c.u32()?), - horiz_offset: (c.i32()?, c.u32()?), - vert_offset: (c.i32()?, c.u32()?), - }); - } - b"fiel" => { - let mut c = Cur::new(p, "fiel"); - v.field_info = Some(FieldInfo { fields: c.u8()?, detail: c.u8()? }); - } - b"gama" => v.gamma = Some(Cur::new(p, "gama").u32()?), - b"btrt" => bitrate = Some(parse_btrt(p)?), - _ => {} - } - } - let codec = match codec { - Some(c) => c, - None => match f { - b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => CodecConfig::ProRes { fourcc: format }, - b"jpeg" | b"mjpa" | b"mjpb" => CodecConfig::Jpeg { fourcc: format }, - b"AVdn" | b"AVdh" => CodecConfig::Dnx { fourcc: format }, - b"apv1" => CodecConfig::Apv(ApvConfig::default()), - _ => unknown(), - }, - }; - return Ok(SampleEntry { format, data_reference_index, codec, video: Some(v), audio: None, bitrate }); - } - if audio_like { - let mut a = AudioParams { qt_version: c.u16()?, ..Default::default() }; - let _revision = c.u16()?; - let _vendor = c.u32()?; - a.channels = c.u16()? as u32; - a.sample_size = c.u16()?; - a.compression_id = c.i16()?; - let _packet_size = c.u16()?; - a.sample_rate = c.u32()? as f64 / 65536.0; - if is_qt && a.qt_version == 1 { - a.samples_per_packet = c.u32()?; - a.bytes_per_packet = c.u32()?; - a.bytes_per_frame = c.u32()?; - a.bytes_per_sample = c.u32()?; - } else if is_qt && a.qt_version == 2 { - let _size_of_struct = c.u32()?; - a.sample_rate = c.f64()?; - a.channels = c.u32()?; - let _always = c.u32()?; - a.const_bits_per_channel = c.u32()?; - a.lpcm_flags = c.u32()?; - a.const_bytes_per_packet = c.u32()?; - a.const_frames_per_packet = c.u32()?; - } - // Flatten QuickTime `wave` children into the list of extension boxes. - let mut ext: Vec<(FourCc, &[u8])> = Vec::new(); - for b in boxes(c.rest()).map_while(|b| b.ok()) { - if b.kind == b"wave" { - for w in boxes(b.payload).map_while(|b| b.ok()) { - ext.push((w.kind, w.payload)); - } - } else { - ext.push((b.kind, b.payload)); - } - } - let get = |k: &[u8; 4]| ext.iter().find(|(kk, _)| kk == k).map(|(_, p)| *p); - if let Some(p) = get(b"enda") { - let v = if p.len() >= 2 { u16::from_be_bytes([p[0], p[1]]) } else { p.first().copied().unwrap_or(0) as u16 }; - a.little_endian = Some(v != 0); - } - let bitrate = get(b"btrt").and_then(|p| parse_btrt(p).ok()); - let codec = match f { - b"mp4a" => match get(b"esds") { - Some(p) => parse_esds(p)?, - None => unknown(), - }, - b".mp3" | b".mp2" => CodecConfig::Mp3, - b"alac" => match get(b"alac") { - Some(p) if p.len() >= 4 => CodecConfig::Alac { cookie: p[4..].to_vec() }, - _ => unknown(), - }, - b"Opus" => match get(b"dOps") { - Some(p) => CodecConfig::Opus(OpusConfig::parse(p)?), - None => unknown(), - }, - b"fLaC" => match get(b"dfLa") { - Some(p) => CodecConfig::Flac(FlacConfig::parse(p)?), - None => unknown(), - }, - b"ac-3" => CodecConfig::Ac3 { dac3: get(b"dac3").unwrap_or(&[]).to_vec() }, - b"ec-3" => CodecConfig::Eac3 { dec3: get(b"dec3").unwrap_or(&[]).to_vec() }, - b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)), - b"ipcm" | b"fpcm" => { - let mut pcm = - PcmConfig { bits: a.sample_size, float: f == b"fpcm", big_endian: true, signed: true, channels: a.channels, sample_rate: a.sample_rate }; - if let Some(p) = get(b"pcmC") { - let mut c = Cur::new(p, "pcmC"); - c.full_header()?; - let flags = c.u8()?; - pcm.big_endian = flags & 1 == 0; - pcm.bits = c.u8()? as u16; - } - CodecConfig::Pcm(pcm) - } - _ => unknown(), - }; - return Ok(SampleEntry { format, data_reference_index, codec, video: None, audio: Some(a), bitrate }); - } - Ok(SampleEntry { format, data_reference_index, codec: unknown(), video: None, audio: None, bitrate: None }) -} - -fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig { - let mut p = PcmConfig { channels: a.channels, sample_rate: a.sample_rate, signed: true, ..Default::default() }; - match f { - b"lpcm" => { - p.bits = a.const_bits_per_channel as u16; - p.float = a.lpcm_flags & 1 != 0; - p.big_endian = a.lpcm_flags & 2 != 0; - p.signed = a.lpcm_flags & 4 != 0 || p.float; - if p.bits == 0 { - p.bits = a.sample_size; - } - } - b"sowt" => { - p.bits = if a.sample_size == 0 { 16 } else { a.sample_size }; - } - b"twos" => { - p.bits = if a.sample_size == 0 { 16 } else { a.sample_size }; - p.big_endian = true; - } - b"raw " | b"NONE" => { - p.bits = if a.sample_size == 0 { 8 } else { a.sample_size }; - p.signed = p.bits != 8; - p.big_endian = true; - } - b"in24" | b"in32" | b"fl32" | b"fl64" => { - p.bits = match f { - b"in24" => 24, - b"in32" | b"fl32" => 32, - _ => 64, - }; - p.float = f[0] == b'f'; - p.big_endian = !a.little_endian.unwrap_or(false); - } - _ => {} - } - p -} - -fn parse_btrt(p: &[u8]) -> Result { - let mut c = Cur::new(p, "btrt"); - Ok(BitRate { buffer_size: c.u32()?, max_bitrate: c.u32()?, avg_bitrate: c.u32()? }) -} - -fn parse_colr(p: &[u8]) -> Result { - let mut c = Cur::new(p, "colr"); - let kind = c.fourcc()?; - Ok(match &kind.0 { - b"nclx" => ColorInfo::Nclx { primaries: c.u16()?, transfer: c.u16()?, matrix: c.u16()?, full_range: c.u8().map(|v| v & 0x80 != 0).unwrap_or(false) }, - b"nclc" => ColorInfo::Nclc { primaries: c.u16()?, transfer: c.u16()?, matrix: c.u16()? }, - _ => ColorInfo::Icc { kind, profile: c.rest().to_vec() }, - }) -} - -fn descriptor(c: &mut Cur) -> Result<(u8, usize)> { - let tag = c.u8()?; - let mut len = 0usize; - for _ in 0..4 { - let b = c.u8()?; - len = (len << 7) | (b & 0x7F) as usize; - if b & 0x80 == 0 { - break; - } - } - Ok((tag, len)) -} - -fn parse_esds(p: &[u8]) -> Result { - let mut c = Cur::new(p, "esds"); - c.full_header()?; - let (tag, len) = descriptor(&mut c)?; - if tag != 3 { - return Err(Error::Invalid(format!("esds: expected ES_Descriptor, got tag {tag}"))); - } - let es = c.bytes(len.min(c.remaining()))?; - let mut c = Cur::new(es, "ES_Descriptor"); - c.u16()?; - let fl = c.u8()?; - if fl & 0x80 != 0 { - c.u16()?; - } - if fl & 0x40 != 0 { - let n = c.u8()? as usize; - c.skip(n)?; - } - if fl & 0x20 != 0 { - c.u16()?; - } - while c.remaining() > 0 { - let (tag, len) = descriptor(&mut c)?; - let body = c.bytes(len.min(c.remaining()))?; - if tag != 4 { - continue; - } - let mut d = Cur::new(body, "DecoderConfigDescriptor"); - let oti = d.u8()?; - let _stream_type = d.u8()?; - let buffer_size = d.u24()?; - let max_bitrate = d.u32()?; - let avg_bitrate = d.u32()?; - let mut asc = Vec::new(); - while d.remaining() > 0 { - let (t, l) = descriptor(&mut d)?; - let b = d.bytes(l.min(d.remaining()))?; - if t == 5 { - asc = b.to_vec(); - break; - } - } - return Ok(match oti { - 0x69 | 0x6B => CodecConfig::Mp3, - _ => { - let mut a = AacConfig::from_asc(asc); - a.max_bitrate = max_bitrate; - a.avg_bitrate = avg_bitrate; - a.buffer_size = buffer_size; - CodecConfig::Aac(a) - } - }); - } - Err(Error::Invalid("esds without DecoderConfigDescriptor".into())) -} - -// --------------------------------------------------------------------------------------------- -// Serialization (muxer) - -fn put_descriptor(b: &mut BoxBuf, tag: u8, body: &[u8]) { - b.u8(tag); - let n = body.len() as u32; - // Always use the 4-byte length form for simplicity (as many encoders do). - b.u8(0x80 | ((n >> 21) & 0x7F) as u8); - b.u8(0x80 | ((n >> 14) & 0x7F) as u8); - b.u8(0x80 | ((n >> 7) & 0x7F) as u8); - b.u8((n & 0x7F) as u8); - b.bytes(body); -} - -pub(crate) fn esds_payload(oti: u8, asc: &[u8], max_bitrate: u32, avg_bitrate: u32, buffer_size: u32) -> Vec { - let mut dcd = BoxBuf::new(); - dcd.u8(oti); - dcd.u8(0x15); // streamType audio (5) << 2 | upstream 0 | reserved 1 - dcd.u8((buffer_size >> 16) as u8); - dcd.u16(buffer_size as u16); - dcd.u32(max_bitrate); - dcd.u32(avg_bitrate); - if !asc.is_empty() { - put_descriptor(&mut dcd, 5, asc); - } - let mut es = BoxBuf::new(); - es.u16(1); // ES_ID - es.u8(0); - put_descriptor(&mut es, 4, &dcd.buf); - put_descriptor(&mut es, 6, &[2]); // SLConfigDescriptor, predefined = 2 (MP4) - let mut b = BoxBuf::new(); - b.u32(0); - put_descriptor(&mut b, 3, &es.buf); - b.buf -} - -impl SampleEntry { - /// Convenience constructor for a video entry. - pub fn video(format: FourCc, codec: CodecConfig, width: u16, height: u16) -> Self { - let compressor_name = match &codec { - CodecConfig::ProRes { fourcc } => match &fourcc.0 { - b"apch" => "Apple ProRes 422 HQ", - b"apcn" => "Apple ProRes 422", - b"apcs" => "Apple ProRes 422 LT", - b"apco" => "Apple ProRes 422 Proxy", - b"ap4h" => "Apple ProRes 4444", - _ => "Apple ProRes 4444 XQ", - }, - CodecConfig::Jpeg { .. } => "Photo - JPEG", - _ => "", - }; - SampleEntry { - format, - data_reference_index: 1, - codec, - video: Some(VideoParams { - width, - height, - horiz_resolution: 0x0048_0000, - vert_resolution: 0x0048_0000, - frame_count: 1, - compressor_name: compressor_name.into(), - depth: 24, - ..Default::default() - }), - audio: None, - bitrate: None, - } - } - - /// Convenience constructor for an audio entry (ISO v0 layout; the muxer upgrades PCM in MOV to v2). - pub fn audio(format: FourCc, codec: CodecConfig, channels: u32, sample_rate: f64, sample_size: u16) -> Self { - SampleEntry { - format, - data_reference_index: 1, - codec, - video: None, - audio: Some(AudioParams { channels, sample_rate, sample_size, ..Default::default() }), - bitrate: None, - } - } - - /// H.264 entry (`avc1`). - pub fn avc(cfg: AvcConfig, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"avc1"), CodecConfig::Avc(cfg), width, height) - } - /// HEVC entry (`hvc1`, parameter sets in the config only). - pub fn hevc(cfg: HevcConfig, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"hvc1"), CodecConfig::Hevc(cfg), width, height) - } - /// AV1 entry (`av01`). - pub fn av1(cfg: Av1Config, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), width, height) - } - /// ProRes entry; `fourcc` one of `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`. - pub fn prores(fourcc: FourCc, width: u16, height: u16) -> Self { - let mut e = Self::video(fourcc, CodecConfig::ProRes { fourcc }, width, height); - if let Some(v) = e.video.as_mut() - && matches!(&fourcc.0, b"ap4h" | b"ap4x") - { - v.depth = 32; - } - e - } - /// Avid DNxHD / DNxHR entry (`AVdn`, or `AVdh` for DNxHR). - pub fn dnx(fourcc: FourCc, width: u16, height: u16) -> Self { - let mut e = Self::video(fourcc, CodecConfig::Dnx { fourcc }, width, height); - if let Some(v) = e.video.as_mut() { - v.depth = 24; - } - e - } - /// Advanced Professional Video entry (`apv1` + `apvC`). - pub fn apv(cfg: ApvConfig, width: u16, height: u16) -> Self { - let mut e = Self::video(FourCc(*b"apv1"), CodecConfig::Apv(cfg), width, height); - if let Some(v) = e.video.as_mut() { - v.compressor_name = "APV".into(); - } - e - } - /// Motion-JPEG entry (`jpeg`). - pub fn jpeg(width: u16, height: u16) -> Self { - let f = FourCc(*b"jpeg"); - Self::video(f, CodecConfig::Jpeg { fourcc: f }, width, height) - } - /// AAC entry (`mp4a` + `esds`) from an AudioSpecificConfig. - pub fn aac(asc: Vec, channels: u32, sample_rate: u32) -> Self { - Self::audio(FourCc(*b"mp4a"), CodecConfig::Aac(AacConfig::from_asc(asc)), channels, sample_rate as f64, 16) - } - /// FLAC entry (`fLaC` + `dfLa`) from the 34-byte STREAMINFO body: the only metadata block. - pub fn flac(streaminfo: &[u8; 34]) -> Self { - let mut blocks = vec![0x80, 0, 0, 34]; - blocks.extend_from_slice(streaminfo); - let mut full = vec![0; 4]; - full.extend_from_slice(&blocks); - let cfg = FlacConfig::parse(&full).unwrap_or(FlacConfig { metadata_blocks: blocks, ..Default::default() }); - let (channels, rate, bits) = (u32::from(cfg.channels), f64::from(cfg.sample_rate), u16::from(cfg.bits_per_sample)); - Self::audio(FourCc(*b"fLaC"), CodecConfig::Flac(cfg), channels, rate, bits) - } - /// PCM entry. The muxer picks the on-disk format (`sowt`/`twos`/`lpcm` for MOV, `ipcm`/`fpcm` for MP4). - pub fn pcm(cfg: PcmConfig) -> Self { - Self::audio(FourCc(*b"lpcm"), CodecConfig::Pcm(cfg), cfg.channels, cfg.sample_rate, cfg.bits) - } - - /// Serialize as a complete sample entry box. For PCM the format is chosen from the brand. - pub(crate) fn write(&self, b: &mut BoxBuf, qt: bool) -> Result<()> { - if let CodecConfig::Unknown { fourcc, raw } = &self.codec { - let m = b.start(&fourcc.0); - b.bytes(raw); - b.end(m); - return Ok(()); - } - if let CodecConfig::Timecode(tc) = &self.codec { - let m = b.start(b"tmcd"); - b.zeros(6); - b.u16(self.data_reference_index); - b.u32(0); - b.u32(tc.flags); - b.u32(tc.timescale); - b.u32(tc.frame_duration); - b.u8(tc.frames_per_second); - b.u8(0); - if let Some(name) = &tc.name { - let n = b.start(b"name"); - b.u16(name.len() as u16); - b.u16(0); - b.bytes(name.as_bytes()); - b.end(n); - } - b.end(m); - return Ok(()); - } - if let Some(v) = &self.video { - let m = b.start(&self.format.0); - b.zeros(6); - b.u16(self.data_reference_index); - b.u16(0); - b.u16(0); - b.u32(v.vendor.as_u32()); - b.u32(v.temporal_quality); - b.u32(v.spatial_quality); - b.u16(v.width); - b.u16(v.height); - b.u32(v.horiz_resolution); - b.u32(v.vert_resolution); - b.u32(0); - b.u16(v.frame_count); - let name = v.compressor_name.as_bytes(); - let n = name.len().min(31); - let mut nb = [0u8; 32]; - nb[0] = n as u8; - nb[1..1 + n].copy_from_slice(&name[..n]); - b.bytes(&nb); - b.u16(v.depth); - b.i16(-1); - match &self.codec { - CodecConfig::Avc(c) => b.leaf(b"avcC", &c.to_bytes()), - CodecConfig::Hevc(c) => b.leaf(b"hvcC", &c.to_bytes()), - CodecConfig::Av1(c) => b.leaf(b"av1C", &c.to_bytes()), - CodecConfig::Vp9(c) => b.leaf(b"vpcC", &c.to_bytes()), - CodecConfig::Apv(c) => b.leaf(b"apvC", &c.to_bytes()), - _ => {} - } - if let Some(g) = v.gamma { - b.leaf(b"gama", &g.to_be_bytes()); - } - if let Some(fi) = v.field_info { - b.leaf(b"fiel", &[fi.fields, fi.detail]); - } - if let Some(ci) = &v.color { - let m = b.start(b"colr"); - match ci { - ColorInfo::Nclx { primaries, transfer, matrix, full_range } => { - b.bytes(b"nclx"); - b.u16(*primaries); - b.u16(*transfer); - b.u16(*matrix); - b.u8(if *full_range { 0x80 } else { 0 }); - } - ColorInfo::Nclc { primaries, transfer, matrix } => { - b.bytes(b"nclc"); - b.u16(*primaries); - b.u16(*transfer); - b.u16(*matrix); - } - ColorInfo::Icc { kind, profile } => { - b.bytes(&kind.0); - b.bytes(profile); - } - } - b.end(m); - } - if let Some(md) = &v.mastering_display { - b.leaf(b"mdcv", &md.to_bytes()); - } - if let Some((cll, fall)) = v.content_light { - let mut d = cll.to_be_bytes().to_vec(); - d.extend_from_slice(&fall.to_be_bytes()); - b.leaf(b"clli", &d); - } - if let Some((h, vv)) = v.pixel_aspect { - let m = b.start(b"pasp"); - b.u32(h); - b.u32(vv); - b.end(m); - } - if let Some(c) = v.clean_aperture { - let m = b.start(b"clap"); - b.u32(c.width.0); - b.u32(c.width.1); - b.u32(c.height.0); - b.u32(c.height.1); - b.i32(c.horiz_offset.0); - b.u32(c.horiz_offset.1); - b.i32(c.vert_offset.0); - b.u32(c.vert_offset.1); - b.end(m); - } - if let Some(br) = self.bitrate { - write_btrt(b, br); - } - b.end(m); - return Ok(()); - } - let a = self.audio.clone().ok_or_else(|| Error::Mux("sample entry has neither video nor audio params".into()))?; - // Choose the on-disk format and sound description version. - let (format, version, pcm) = match &self.codec { - CodecConfig::Pcm(p) => { - if qt { - if p.bits == 16 && !p.float { - (if p.big_endian { *b"twos" } else { *b"sowt" }, 0u16, Some(*p)) - } else if p.bits == 8 && !p.float { - (*b"raw ", 0, Some(*p)) - } else { - (*b"lpcm", 2, Some(*p)) - } - } else { - (if p.float { *b"fpcm" } else { *b"ipcm" }, 0, Some(*p)) - } - } - _ => (self.format.0, if qt { a.qt_version.min(2) } else { 0 }, None), - }; - let m = b.start(&format); - b.zeros(6); - b.u16(self.data_reference_index); - if version == 2 { - let p = pcm.unwrap_or_default(); - b.u16(2); - b.u16(0); - b.u32(0); - b.u16(3); - b.u16(16); - b.i16(-2); - b.u16(0); - b.u32(65536); - b.u32(72); - b.f64(p.sample_rate); - b.u32(p.channels); - b.u32(0x7F00_0000); - b.u32(p.bits as u32); - let flags = (p.float as u32) | ((p.big_endian as u32) << 1) | (((p.signed && !p.float) as u32) << 2) | 8; - b.u32(flags); - b.u32(p.bytes_per_frame()); - b.u32(1); - } else { - b.u16(version); - b.u16(0); - b.u32(0); - b.u16(a.channels.min(u16::MAX as u32) as u16); - b.u16(match pcm { - Some(p) => p.bits, - None => { - if a.sample_size == 0 { - 16 - } else { - a.sample_size - } - } - }); - b.i16(if version == 1 { a.compression_id } else { 0 }); - b.u16(0); - let sr = if a.sample_rate < 65536.0 { (a.sample_rate * 65536.0).round() as u32 } else { 0 }; - b.u32(sr); - if version == 1 { - b.u32(a.samples_per_packet); - b.u32(a.bytes_per_packet); - b.u32(a.bytes_per_frame); - b.u32(a.bytes_per_sample); - } - } - match &self.codec { - CodecConfig::Aac(c) => { - b.leaf(b"esds", &esds_payload(0x40, &c.asc, c.max_bitrate, c.avg_bitrate, c.buffer_size)); - } - CodecConfig::Mp3 if format == *b"mp4a" => { - b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0)); - } - CodecConfig::Alac { cookie } => { - let m = b.start_full(b"alac", 0, 0); - b.bytes(cookie); - b.end(m); - } - CodecConfig::Opus(o) => b.leaf(b"dOps", &o.to_bytes()), - CodecConfig::Flac(fl) => { - let m = b.start_full(b"dfLa", 0, 0); - b.bytes(&fl.metadata_blocks); - b.end(m); - } - CodecConfig::Ac3 { dac3 } => b.leaf(b"dac3", dac3), - CodecConfig::Eac3 { dec3 } => b.leaf(b"dec3", dec3), - CodecConfig::Pcm(p) if !qt => { - let m = b.start_full(b"pcmC", 0, 0); - b.u8(if p.big_endian { 0 } else { 1 }); - b.u8(p.bits as u8); - b.end(m); - } - CodecConfig::Pcm(p) if p.channels == 6 => { - // QuickTime channel layout: kAudioChannelLayoutTag_MPEG_5_1_A (L R C LFE Ls Rs) - let m = b.start_full(b"chan", 0, 0); - b.u32((121 << 16) | 6); - b.u32(0); - b.u32(0); - b.end(m); - } - _ => {} - } - if let Some(br) = self.bitrate { - write_btrt(b, br); - } - b.end(m); - Ok(()) - } -} - -fn write_btrt(b: &mut BoxBuf, br: BitRate) { - let m = b.start(b"btrt"); - b.u32(br.buffer_size); - b.u32(br.max_bitrate); - b.u32(br.avg_bitrate); - b.end(m); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(e: &SampleEntry, qt: bool, handler: &[u8; 4]) -> SampleEntry { - let mut b = BoxBuf::new(); - e.write(&mut b, qt).unwrap(); - let bx = crate::bytes::boxes(&b.buf).next().unwrap().unwrap(); - parse_sample_entry(bx.kind, bx.payload, FourCc(*handler), qt).unwrap() - } - - #[test] - fn avcc_roundtrip() { - let cfg = AvcConfig::new(vec![vec![0x67, 0x64, 0x00, 0x1F, 0xAC]], vec![vec![0x68, 0xEB]], 4); - assert_eq!(cfg.profile, 0x64); - assert_eq!(AvcConfig::parse(&cfg.to_bytes()).unwrap(), cfg); - let mut e = SampleEntry::avc(cfg, 1920, 1080); - e.video.as_mut().unwrap().color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false }); - e.video.as_mut().unwrap().pixel_aspect = Some((4, 3)); - e.video.as_mut().unwrap().field_info = Some(FieldInfo { fields: 2, detail: 9 }); - e.video.as_mut().unwrap().clean_aperture = Some(CleanAperture { width: (1920, 1), height: (1080, 1), horiz_offset: (-2, 1), vert_offset: (0, 1) }); - e.video.as_mut().unwrap().mastering_display = Some(MasteringDisplay::bt2020(1000.0, 0.0001)); - e.video.as_mut().unwrap().content_light = Some((1000, 400)); - e.bitrate = Some(BitRate { buffer_size: 1, max_bitrate: 2, avg_bitrate: 3 }); - assert_eq!(roundtrip(&e, false, b"vide"), e); - assert_eq!(MasteringDisplay::bt2020(1000.0, 0.0001).max_nits(), 1000.0); - } - - #[test] - fn hvcc_roundtrip() { - let cfg = HevcConfig { - general_profile_idc: 1, - general_profile_compatibility_flags: 0x6000_0000, - general_constraint_indicator_flags: 0x9000_0000_0000, - general_level_idc: 93, - chroma_format_idc: 1, - bit_depth_luma: 8, - bit_depth_chroma: 8, - length_size: 4, - num_temporal_layers: 1, - temporal_id_nested: true, - arrays: vec![ - HevcNalArray { completeness: true, nal_type: 32, nalus: vec![vec![0x40, 1]] }, - HevcNalArray { completeness: true, nal_type: 33, nalus: vec![vec![0x42, 1, 2]] }, - HevcNalArray { completeness: true, nal_type: 34, nalus: vec![vec![0x44, 1]] }, - ], - ..Default::default() - }; - let back = HevcConfig::parse(&cfg.to_bytes()).unwrap(); - assert_eq!(back, cfg); - assert_eq!(back.sps(), vec![&[0x42u8, 1, 2][..]]); - } - - #[test] - fn av1_vp9_opus_roundtrip() { - let a = Av1Config { seq_profile: 1, seq_level_idx_0: 8, high_bitdepth: true, config_obus: vec![1, 2], ..Default::default() }; - assert_eq!(Av1Config::parse(&a.to_bytes()).unwrap(), a); - let v = VpcConfig { - profile: 2, - level: 31, - bit_depth: 10, - chroma_subsampling: 1, - full_range: true, - colour_primaries: 9, - transfer_characteristics: 16, - matrix_coefficients: 9, - codec_init: vec![], - }; - assert_eq!(VpcConfig::parse(&v.to_bytes()).unwrap(), v); - let o = OpusConfig { output_channels: 2, pre_skip: 312, input_sample_rate: 48000, output_gain: 0, channel_mapping_family: 0, channel_mapping: vec![] }; - assert_eq!(OpusConfig::parse(&o.to_bytes()).unwrap(), o); - } - - #[test] - fn aac_channel_configuration_is_an_index() { - // 48 kHz AAC LC: configuration → channels (Table 1.19 with Amd 4; #922) - let channels = |c: u16| AacConfig::from_asc(((2 << 11) | (3 << 7) | (c << 3)).to_be_bytes().to_vec()).channels(); - assert_eq!([0, 1, 6, 7, 8, 11, 12, 13, 14, 15].map(channels), [0, 1, 6, 8, 0, 7, 8, 24, 8, 0]); - } - - #[test] - fn aac_esds_roundtrip() { - let e = SampleEntry::aac(vec![0x12, 0x10], 2, 44100); - match &e.codec { - CodecConfig::Aac(a) => { - assert_eq!((a.object_type, a.sample_rate, a.channel_config), (2, 44100, 2)); - } - _ => panic!(), - } - assert_eq!(roundtrip(&e, false, b"soun"), e); - assert_eq!(roundtrip(&e, true, b"soun"), e); - } - - /// QuickTime's `.mp2` entry (what ffmpeg writes for MP2 in MOV) is MPEG audio, as `.mp3` is, - /// even under a handler that does not say "sound" (#800). - #[test] - fn quicktime_mpeg_audio_entries() { - for fourcc in [*b".mp3", *b".mp2"] { - let e = SampleEntry::audio(FourCc(fourcc), CodecConfig::Mp3, 2, 48000.0, 16); - for handler in [b"soun", b"\0\0\0\0"] { - let back = roundtrip(&e, true, handler); - assert_eq!(back.codec, CodecConfig::Mp3, "{fourcc:?} {handler:?}"); - assert_eq!(back.audio.map(|a| (a.channels, a.sample_rate)), Some((2, 48000.0)), "{fourcc:?} {handler:?}"); - } - } - } - - #[test] - fn pcm_entries() { - for (bits, float, be, qt) in [ - (16, false, false, true), - (16, false, true, true), - (24, false, false, true), - (32, true, false, true), - (24, false, true, false), - (32, true, false, false), - ] { - let p = PcmConfig { bits, float, big_endian: be, signed: true, channels: 2, sample_rate: 48000.0 }; - let e = SampleEntry::pcm(p); - let back = roundtrip(&e, qt, b"soun"); - assert_eq!(back.codec, CodecConfig::Pcm(p), "{bits} {float} {be} {qt}"); - } - } - - #[test] - fn timecode_and_prores() { - let tc = TimecodeConfig { flags: 1, timescale: 30000, frame_duration: 1001, frames_per_second: 30, start_frame: None, name: Some("reel".into()) }; - let e = SampleEntry { - format: FourCc(*b"tmcd"), - data_reference_index: 1, - codec: CodecConfig::Timecode(tc.clone()), - video: None, - audio: None, - bitrate: None, - }; - assert_eq!(roundtrip(&e, true, b"tmcd"), e); - assert_eq!(tc.format_frame(107892), "01:00:00;00"); - let nd = TimecodeConfig { flags: 0, frames_per_second: 24, ..Default::default() }; - assert_eq!(nd.format_frame(86400), "01:00:00:00"); - let p = SampleEntry::prores(FourCc(*b"apch"), 1920, 1080); - assert_eq!(roundtrip(&p, true, b"vide"), p); - } - - #[test] - fn asc_parse() { - assert_eq!(parse_asc(&[0x11, 0x90]), Some((2, 48000, 2))); - assert_eq!(parse_asc(&[]), None); - } -} diff --git a/crates/isobmff/src/demux.rs b/crates/isobmff/src/demux.rs deleted file mode 100644 index b9b685ac0..000000000 --- a/crates/isobmff/src/demux.rs +++ /dev/null @@ -1,1172 +0,0 @@ -//! Demuxer: parses box structure lazily (headers + `moov`/`moof` only) and builds per-track sample tables. - -use crate::bytes::{Cur, FourCc, boxes, find, parse_box_header}; -use crate::codec::{CodecConfig, SampleEntry, parse_sample_entry}; -use crate::error::{Error, Result, invalid}; -use crate::source::ByteSource; -use std::collections::HashMap; - -/// Hard cap on per-track sample count (after PCM chunk merging). -const MAX_SAMPLES: usize = 1 << 26; -/// Hard cap on the size of an in-memory `moov`/`moof` box. -const MAX_HEADER_BOX: u64 = 1 << 30; - -/// One sample (access unit) of a track, in the track's media timescale. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub struct Sample { - /// Absolute byte offset in the file. - pub offset: u64, - pub size: u32, - /// Decode timestamp (raw media time, edit lists not applied). - pub dts: i64, - /// Presentation timestamp = dts + composition offset (raw media time). - pub pts: i64, - pub duration: u32, - pub is_sync: bool, - /// 1-based index into [`Track::entries`]. - pub description_index: u32, -} - -/// One edit list entry (`elst`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Edit { - /// Duration of this edit in the movie timescale. - pub segment_duration: u64, - /// Start time within the media (track timescale); `-1` marks an empty edit. - pub media_time: i64, - /// Media rate as 16.16 fixed point (0x0001_0000 = normal speed, 0 = dwell). - pub media_rate: i32, -} - -/// Broad classification of a track by its handler. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrackKind { - Video, - Audio, - Timecode, - Subtitle, - Other, -} - -/// A sample-to-group mapping (`sbgp`) with the matching descriptions (`sgpd`) kept raw. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct SampleGroup { - pub grouping_type: FourCc, - /// (sample_count, group_description_index) runs. - pub runs: Vec<(u32, u32)>, - /// Raw description entries from `sgpd`. - pub descriptions: Vec>, -} - -/// A demuxed track. -#[derive(Clone, Debug, PartialEq)] -pub struct Track { - pub id: u32, - pub kind: TrackKind, - pub handler: FourCc, - pub handler_name: String, - /// Media timescale (`mdhd`). - pub timescale: u32, - /// Media duration in the media timescale (`mdhd`). - pub duration: u64, - /// Track duration in the movie timescale (`tkhd`). - pub tkhd_duration: u64, - /// ISO 639-2/T language code (e.g. `und`, `eng`). - pub language: String, - /// `tkhd` presentation size (16.16 fixed point converted to integer pixels, rounded down). - pub width: u32, - pub height: u32, - pub enabled: bool, - pub alternate_group: u16, - /// `tkhd` volume (8.8 fixed point). - pub volume: i16, - /// `tkhd` transformation matrix. - pub matrix: [i32; 9], - pub entries: Vec, - pub samples: Vec, - pub edits: Vec, - /// Offset to add to raw pts to get presentation time (track timescale), derived from the - /// leading empty edits and the first non-empty edit's `media_time`. - pub edit_offset: i64, - /// Track references (`tref`): (type, track ids). - pub references: Vec<(FourCc, Vec)>, - /// Raw `sdtp` bytes (one per sample) if present. - pub sdtp: Vec, - pub sample_groups: Vec, - /// True if uncompressed audio samples were merged into one [`Sample`] per chunk. - pub pcm_chunked: bool, - sync_index: Vec, - pts_order: Vec, -} - -impl Track { - /// The first sample entry's codec configuration. - pub fn codec(&self) -> Option<&CodecConfig> { - self.entries.first().map(|e| &e.codec) - } - pub fn video(&self) -> Option<&crate::codec::VideoParams> { - self.entries.first().and_then(|e| e.video.as_ref()) - } - /// Clockwise quarter turns (0–3) the `tkhd` matrix applies for display, or None when it is not - /// a pure 0/90/180/270° rotation (mirrored, scaled or sheared — left to the caller). - /// - /// ISO/IEC 14496-12 §8.3.2: a sample point (p, q) is displayed at p′ = a·p + c·q + x, - /// q′ = b·p + d·q + y for the matrix {a, b, u, c, d, v, x, y, w}, with y pointing down. So - /// {0, 1, −1, 0} sends the top row to the right-hand column (90° clockwise — iPhone portrait - /// video), {−1, 0, 0, −1} is 180° and {0, −1, 1, 0} is 270°. Only the signs matter: a·d − b·c - /// carries the scale, which the presentation size already reflects. - pub fn display_rotation(&self) -> Option { - let [a, b, _, c, d, _, _, _, _] = self.matrix; - match (a.signum(), b.signum(), c.signum(), d.signum()) { - (1, 0, 0, 1) => Some(0), - (0, 1, -1, 0) => Some(1), - (-1, 0, 0, -1) => Some(2), - (0, -1, 1, 0) => Some(3), - _ => None, - } - } - pub fn audio(&self) -> Option<&crate::codec::AudioParams> { - self.entries.first().and_then(|e| e.audio.as_ref()) - } - /// Presentation (edit-adjusted) pts of sample `i`. - pub fn presentation_pts(&self, i: usize) -> Option { - self.samples.get(i).map(|s| s.pts.saturating_add(self.edit_offset)) - } - /// Index of the sample being presented at raw media time `pts` (the last sample, in - /// presentation order, whose pts is ≤ `pts`; the first sample if `pts` precedes all). - pub fn sample_at_pts(&self, pts: i64) -> Option { - if self.pts_order.is_empty() { - return None; - } - let k = self.pts_order.partition_point(|&i| self.samples[i as usize].pts <= pts); - Some(self.pts_order[k.saturating_sub(1)] as usize) - } - /// Like [`sample_at_pts`](Self::sample_at_pts) but for edit-adjusted presentation time. - pub fn sample_at_presentation_time(&self, t: i64) -> Option { - self.sample_at_pts(t.saturating_sub(self.edit_offset)) - } - /// The closest sync sample at or before `index` (decode order); 0 if there is none. - pub fn sync_sample_before(&self, index: usize) -> usize { - let k = self.sync_index.partition_point(|&i| i as usize <= index); - if k == 0 { 0 } else { self.sync_index[k - 1] as usize } - } - /// Indices of all sync samples. - pub fn sync_samples(&self) -> &[u32] { - &self.sync_index - } - - fn finish_indexes(&mut self) { - self.sync_index = self.samples.iter().enumerate().filter(|(_, s)| s.is_sync).map(|(i, _)| i as u32).collect(); - let mut order: Vec = (0..self.samples.len() as u32).collect(); - order.sort_by_key(|&i| (self.samples[i as usize].pts, i)); - self.pts_order = order; - } -} - -/// Text metadata from `udta` (QuickTime `©xxx` atoms and iTunes-style `meta/ilst`, incl. `keys`). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct Metadata { - pub entries: Vec<(String, String)>, -} - -impl Metadata { - pub fn get(&self, key: &str) -> Option<&str> { - self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) - } - /// Title from `©nam` or the `com.apple.quicktime.title` key. - pub fn title(&self) -> Option<&str> { - self.get("©nam").or_else(|| self.get("com.apple.quicktime.title")).or_else(|| self.get("title")) - } -} - -/// A parsed MP4/MOV file (headers and sample tables; media data stays in the source). -#[derive(Clone, Debug, PartialEq)] -pub struct Mp4File { - pub major_brand: FourCc, - pub minor_version: u32, - pub compatible_brands: Vec, - /// QuickTime file (major brand `qt ` or no `ftyp`). - pub is_quicktime: bool, - /// Movie timescale (`mvhd`). - pub timescale: u32, - /// Movie duration in the movie timescale (`mvhd`; for fragmented files, the longest track). - pub duration: u64, - pub tracks: Vec, - pub metadata: Metadata, - /// File uses movie fragments (`mvex` present). - pub fragmented: bool, - /// Byte offset of the `moov` box. - pub moov_offset: u64, - /// Number of `moof` boxes found. - pub fragment_count: usize, -} - -impl Mp4File { - /// Read the bytes of sample `index` of track `track` (index into [`tracks`](Self::tracks)). - pub fn read_sample(&self, src: &S, track: usize, index: usize) -> Result> { - let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; - read_range(src, s.offset, s.size as u64) - } - pub fn sample_at_pts(&self, track: usize, pts: i64) -> Option { - self.tracks.get(track)?.sample_at_pts(pts) - } - pub fn sync_sample_before(&self, track: usize, index: usize) -> Option { - Some(self.tracks.get(track)?.sync_sample_before(index)) - } - /// First track of the given kind. - pub fn track_of_kind(&self, kind: TrackKind) -> Option { - self.tracks.iter().position(|t| t.kind == kind) - } -} - -fn read_range(src: &S, offset: u64, size: u64) -> Result> { - let end = offset.checked_add(size).ok_or(Error::Truncated("sample"))?; - if end > src.len() { - return Err(Error::Truncated("sample data")); - } - let mut v = vec![0u8; size as usize]; - src.read_at(offset, &mut v)?; - Ok(v) -} - -/// Parse an MP4/MOV file. Only box headers, `ftyp`, `moov` and `moof` boxes are read. -pub fn open(src: S) -> Result { - let len = src.len(); - let mut offset = 0u64; - let mut ftyp: Option> = None; - let mut moov: Option<(u64, Vec)> = None; - let mut moofs: Vec<(u64, Vec)> = Vec::new(); - while offset + 8 <= len { - let n = (len - offset).min(32) as usize; - let mut hb = [0u8; 32]; - src.read_at(offset, &mut hb[..n])?; - let h = parse_box_header(&hb[..n])?; - let size = h.size.unwrap_or(len - offset); - let end = offset.saturating_add(size).min(len); - let body_len = end.saturating_sub(offset + h.header_len); - let read_body = |max: u64| -> Result> { - if body_len > max { - return Err(Error::TooLarge("header box")); - } - read_range(&src, offset + h.header_len, body_len) - }; - match &h.kind.0 { - b"ftyp" if ftyp.is_none() => ftyp = Some(read_body(1 << 16)?), - b"moov" if moov.is_none() => moov = Some((offset, read_body(MAX_HEADER_BOX)?)), - b"moof" => moofs.push((offset, read_body(MAX_HEADER_BOX)?)), - _ => {} - } - if h.size.is_none() { - break; - } - offset = offset.saturating_add(size); - } - let (moov_offset, moov) = moov.ok_or(Error::NoMoov)?; - let mut file = Mp4File { - major_brand: FourCc::default(), - minor_version: 0, - compatible_brands: Vec::new(), - is_quicktime: true, - timescale: 0, - duration: 0, - tracks: Vec::new(), - metadata: Metadata::default(), - fragmented: false, - moov_offset, - fragment_count: moofs.len(), - }; - if let Some(f) = &ftyp { - let mut c = Cur::new(f, "ftyp"); - file.major_brand = c.fourcc()?; - file.minor_version = c.u32().unwrap_or(0); - while c.remaining() >= 4 { - file.compatible_brands.push(c.fourcc()?); - } - file.is_quicktime = file.major_brand == b"qt "; - } - let mut trex: HashMap = HashMap::new(); - let mut trak_payloads = Vec::new(); - for b in boxes(&moov) { - let b = b?; - match &b.kind.0 { - b"cmov" => return Err(Error::Unsupported("compressed movie header (cmov)".into())), - b"mvhd" => { - let mut c = Cur::new(b.payload, "mvhd"); - let (v, _) = c.full_header()?; - if v == 1 { - c.skip(16)?; - file.timescale = c.u32()?; - file.duration = c.u64()?; - } else { - c.skip(8)?; - file.timescale = c.u32()?; - file.duration = c.u32()? as u64; - } - } - b"trak" => trak_payloads.push(b.payload), - b"mvex" => { - file.fragmented = true; - for m in boxes(b.payload).map_while(|b| b.ok()) { - if m.kind == b"trex" { - let mut c = Cur::new(m.payload, "trex"); - c.full_header()?; - let id = c.u32()?; - trex.insert(id, Trex { description_index: c.u32()?, duration: c.u32()?, size: c.u32()?, flags: c.u32()? }); - } - } - } - b"udta" => parse_udta(b.payload, &mut file.metadata), - b"meta" => parse_meta(b.payload, &mut file.metadata), - _ => {} - } - } - for p in trak_payloads { - let t = parse_trak(p, &file, len)?; - file.tracks.push(t); - } - // Movie fragments. - let mut next_dts: Vec = file.tracks.iter().map(|t| t.samples.last().map(|s| s.dts + s.duration as i64).unwrap_or(0)).collect(); - for (moof_off, moof) in &moofs { - parse_moof(*moof_off, moof, &mut file.tracks, &trex, &mut next_dts, len)?; - } - for t in &mut file.tracks { - if file.fragmented && !moofs.is_empty() { - let d: u64 = t.samples.iter().map(|s| s.duration as u64).sum(); - t.duration = t.duration.max(d); - if t.timescale > 0 && file.timescale > 0 { - let md = (t.duration as u128 * file.timescale as u128 / t.timescale as u128) as u64; - file.duration = file.duration.max(md); - } - } - t.finish_indexes(); - // Timecode start frame from the first sample. - if let Some(CodecConfig::Timecode(tc)) = t.entries.first_mut().map(|e| &mut e.codec) - && let Some(s) = t.samples.first() - && s.size >= 4 - { - let mut b = [0u8; 4]; - if src.read_at(s.offset, &mut b).is_ok() { - tc.start_frame = Some(u32::from_be_bytes(b)); - } - } - } - Ok(file) -} - -#[derive(Clone, Copy, Debug, Default)] -struct Trex { - description_index: u32, - duration: u32, - size: u32, - flags: u32, -} - -fn parse_language(code: u16) -> String { - if code < 0x400 { - // QuickTime Macintosh language code. - return if code == 0 { "eng".into() } else { "und".into() }; - } - let c = |s: u16| (((code >> s) & 0x1F) as u8 + 0x60) as char; - let s: String = [c(10), c(5), c(0)].into_iter().collect(); - if s.chars().all(|ch| ch.is_ascii_lowercase()) { s } else { "und".into() } -} - -fn rescale(v: i128, from: u32, to: u32) -> i64 { - if from == 0 { - return 0; - } - (v * to as i128 / from as i128).clamp(i64::MIN as i128, i64::MAX as i128) as i64 -} - -fn parse_trak(p: &[u8], file: &Mp4File, file_len: u64) -> Result { - let mut t = Track { - id: 0, - kind: TrackKind::Other, - handler: FourCc::default(), - handler_name: String::new(), - timescale: 0, - duration: 0, - tkhd_duration: 0, - language: "und".into(), - width: 0, - height: 0, - enabled: true, - alternate_group: 0, - volume: 0, - matrix: [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000], - entries: Vec::new(), - samples: Vec::new(), - edits: Vec::new(), - edit_offset: 0, - references: Vec::new(), - sdtp: Vec::new(), - sample_groups: Vec::new(), - pcm_chunked: false, - sync_index: Vec::new(), - pts_order: Vec::new(), - }; - let mut mdia = None; - for b in boxes(p) { - let b = b?; - match &b.kind.0 { - b"tkhd" => { - let mut c = Cur::new(b.payload, "tkhd"); - let (v, flags) = c.full_header()?; - t.enabled = flags & 1 != 0; - if v == 1 { - c.skip(16)?; - t.id = c.u32()?; - c.skip(4)?; - t.tkhd_duration = c.u64()?; - } else { - c.skip(8)?; - t.id = c.u32()?; - c.skip(4)?; - t.tkhd_duration = c.u32()? as u64; - } - c.skip(8)?; - let _layer = c.i16()?; - t.alternate_group = c.u16()?; - t.volume = c.i16()?; - c.skip(2)?; - for m in t.matrix.iter_mut() { - *m = c.i32()?; - } - t.width = c.u32()? >> 16; - t.height = c.u32()? >> 16; - } - b"edts" => { - if let Some(e) = find(b.payload, b"elst") { - let mut c = Cur::new(e, "elst"); - let (v, _) = c.full_header()?; - let n = c.count(if v == 1 { 20 } else { 12 })?; - for _ in 0..n { - let (segment_duration, media_time) = if v == 1 { (c.u64()?, c.i64()?) } else { (c.u32()? as u64, c.i32()? as i64) }; - t.edits.push(Edit { segment_duration, media_time, media_rate: c.i32()? }); - } - } - } - b"tref" => { - for r in boxes(b.payload).map_while(|b| b.ok()) { - let ids = r.payload.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(); - t.references.push((r.kind, ids)); - } - } - b"mdia" => mdia = Some(b.payload), - _ => {} - } - } - let mdia = mdia.ok_or_else(|| Error::Invalid("trak without mdia".into()))?; - let mut minf = None; - for b in boxes(mdia) { - let b = b?; - match &b.kind.0 { - b"mdhd" => { - let mut c = Cur::new(b.payload, "mdhd"); - let (v, _) = c.full_header()?; - if v == 1 { - c.skip(16)?; - t.timescale = c.u32()?; - t.duration = c.u64()?; - } else { - c.skip(8)?; - t.timescale = c.u32()?; - t.duration = c.u32()? as u64; - } - t.language = parse_language(c.u16()?); - } - b"hdlr" => { - let mut c = Cur::new(b.payload, "hdlr"); - c.full_header()?; - let _pre = c.u32()?; - t.handler = c.fourcc()?; - c.skip(12)?; - let name = c.rest(); - // ISO: null-terminated UTF-8; QuickTime: Pascal string. - let s = if !name.is_empty() && name[0] as usize == name.len() - 1 && name.len() > 1 { - &name[1..] - } else { - name.split(|&b| b == 0).next().unwrap_or(&[]) - }; - t.handler_name = String::from_utf8_lossy(s).into_owned(); - } - b"minf" => minf = Some(b.payload), - _ => {} - } - } - t.kind = match &t.handler.0 { - b"vide" => TrackKind::Video, - b"soun" => TrackKind::Audio, - b"tmcd" => TrackKind::Timecode, - b"text" | b"sbtl" | b"subt" | b"clcp" => TrackKind::Subtitle, - _ => TrackKind::Other, - }; - // Edit offset in media timescale. - let mut empty = 0i128; - for e in &t.edits { - if e.media_time == -1 { - empty += e.segment_duration as i128; - } else { - t.edit_offset = rescale(empty, file.timescale, t.timescale).saturating_sub(e.media_time); - break; - } - } - if let Some(stbl) = minf.and_then(|m| find(m, b"stbl")) { - parse_stbl(stbl, &mut t, file.is_quicktime, file_len)?; - } - Ok(t) -} - -struct SttsCursor<'a> { - runs: &'a [(u32, u32)], - run: usize, - used: u32, -} - -impl SttsCursor<'_> { - /// Advance by `n` samples, returning the summed duration. - fn take(&mut self, mut n: u64) -> u64 { - let mut total = 0u64; - while n > 0 && self.run < self.runs.len() { - let (count, delta) = self.runs[self.run]; - let avail = (count - self.used) as u64; - let k = avail.min(n); - total = total.saturating_add(k.saturating_mul(delta as u64)); - n -= k; - self.used += k as u32; - if self.used >= count { - self.run += 1; - self.used = 0; - } - } - total - } - fn next_value(&mut self) -> u32 { - while self.run < self.runs.len() && self.runs[self.run].0 == 0 { - self.run += 1; - } - let Some(&(_, d)) = self.runs.get(self.run) else { return 0 }; - self.take(1); - d - } -} - -fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64) -> Result<()> { - let mut stts: Vec<(u32, u32)> = Vec::new(); - let mut ctts: Vec<(u32, u32)> = Vec::new(); - let mut stss: Option> = None; - let mut stsc: Vec<(u32, u32, u32)> = Vec::new(); - let mut const_size = 0u32; - let mut sample_count = 0usize; - let mut sizes: Vec = Vec::new(); - let mut chunks: Vec = Vec::new(); - let mut sbgp: Vec<(FourCc, Vec<(u32, u32)>)> = Vec::new(); - let mut sgpd: HashMap>> = HashMap::new(); - for b in boxes(stbl) { - let b = b?; - let mut c = Cur::new(b.payload, "stbl child"); - match &b.kind.0 { - b"stsd" => { - c.full_header()?; - let n = c.count(8)?; - for e in boxes(c.rest()).take(n) { - let e = e?; - t.entries.push(parse_sample_entry(e.kind, e.payload, t.handler, is_qt)?); - } - } - b"stts" => { - c.full_header()?; - let n = c.count(8)?; - stts = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - } - b"ctts" => { - c.full_header()?; - let n = c.count(8)?; - ctts = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - } - b"stss" => { - c.full_header()?; - let n = c.count(4)?; - let mut v: Vec = (0..n).map(|_| c.u32()).collect::>()?; - v.sort_unstable(); - stss = Some(v); - } - b"stsc" => { - c.full_header()?; - let n = c.count(12)?; - stsc = (0..n).map(|_| Ok((c.u32()?, c.u32()?, c.u32()?))).collect::>()?; - } - b"stsz" => { - c.full_header()?; - const_size = c.u32()?; - let n = c.u32()? as usize; - if const_size == 0 { - c.check_count(n, 4)?; - sizes = (0..n).map(|_| c.u32()).collect::>()?; - sample_count = n; - } else { - // Samples of constant size must fit in the file: clamp absurd counts. - sample_count = (n as u64).min(file_len / const_size as u64) as usize; - } - } - b"stz2" => { - c.full_header()?; - let field = c.u32()? & 0xFF; - let n = c.u32()? as usize; - let bits = match field { - 4 | 8 | 16 => field as usize, - _ => return invalid(format!("stz2 field size {field}")), - }; - if n.checked_mul(bits).is_none_or(|b| b.div_ceil(8) > c.remaining()) { - return Err(Error::Truncated("stz2")); - } - let d = c.rest(); - sizes = (0..n) - .map(|i| match bits { - 4 => ((d[i / 2] >> if i % 2 == 0 { 4 } else { 0 }) & 0xF) as u32, - 8 => d[i] as u32, - _ => u16::from_be_bytes([d[2 * i], d[2 * i + 1]]) as u32, - }) - .collect(); - sample_count = n; - } - b"stco" => { - c.full_header()?; - let n = c.count(4)?; - chunks = (0..n).map(|_| c.u32().map(|v| v as u64)).collect::>()?; - } - b"co64" => { - c.full_header()?; - let n = c.count(8)?; - chunks = (0..n).map(|_| c.u64()).collect::>()?; - } - b"sdtp" => { - c.full_header()?; - t.sdtp = c.rest().to_vec(); - } - b"sbgp" => { - let (v, _) = c.full_header()?; - let gt = c.fourcc()?; - if v == 1 { - c.u32()?; - } - let n = c.count(8)?; - let runs = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - sbgp.push((gt, runs)); - } - b"sgpd" => { - let (v, _) = c.full_header()?; - let gt = c.fourcc()?; - let default_len = if v >= 1 { c.u32()? } else { 0 }; - if v >= 2 { - c.u32()?; - } - let n = c.count(0)?; - let mut descs = Vec::new(); - for _ in 0..n { - let l = if v >= 1 && default_len == 0 { c.u32()? } else { default_len }; - if l == 0 { - // Version 0 without known length: keep the remainder as a single entry. - descs.push(c.rest().to_vec()); - break; - } - descs.push(c.bytes(l as usize)?.to_vec()); - } - sgpd.insert(gt, descs); - } - _ => {} - } - } - t.sample_groups = sbgp.into_iter().map(|(gt, runs)| SampleGroup { grouping_type: gt, runs, descriptions: sgpd.remove(>).unwrap_or_default() }).collect(); - if sample_count == 0 || chunks.is_empty() || stsc.is_empty() { - return Ok(()); - } - if stsc[0].0 == 0 || stsc.windows(2).any(|w| w[1].0 <= w[0].0) { - return invalid("stsc first_chunk values are not increasing from 1"); - } - let pcm = match t.entries.first().map(|e| &e.codec) { - Some(CodecConfig::Pcm(p)) if const_size != 0 => Some(*p), - _ => None, - }; - let mut stts_c = SttsCursor { runs: &stts, run: 0, used: 0 }; - let mut ctts_c = SttsCursor { runs: &ctts, run: 0, used: 0 }; - let mut samples = Vec::new(); - let mut sidx = 0usize; - let mut dts = 0i64; - let mut stsc_i = 0usize; - if let Some(p) = pcm { - t.pcm_chunked = true; - let a = t.audio().cloned().unwrap_or_default(); - let frame_bytes = |frames: u64| -> u64 { - if a.qt_version == 1 && a.bytes_per_frame > 0 && a.samples_per_packet > 0 { - frames / a.samples_per_packet as u64 * a.bytes_per_frame as u64 - } else if const_size > 1 { - frames * const_size as u64 - } else { - frames * p.bytes_per_frame() as u64 - } - }; - if chunks.len() > MAX_SAMPLES { - return Err(Error::TooLarge("chunk count")); - } - samples.reserve(chunks.len()); - for (ci, &off) in chunks.iter().enumerate() { - while stsc_i + 1 < stsc.len() && stsc[stsc_i + 1].0 as usize <= ci + 1 { - stsc_i += 1; - } - let spc = (stsc[stsc_i].1 as usize).min(sample_count - sidx); - if spc == 0 { - continue; - } - let dur = stts_c.take(spc as u64); - let size = frame_bytes(spc as u64).min(u32::MAX as u64) as u32; - samples.push(Sample { - offset: off, - size, - dts, - pts: dts, - duration: dur.min(u32::MAX as u64) as u32, - is_sync: true, - description_index: stsc[stsc_i].2, - }); - dts = dts.saturating_add(dur.min(i64::MAX as u64) as i64); - sidx += spc; - if sidx >= sample_count { - break; - } - } - } else { - if sample_count > MAX_SAMPLES { - return Err(Error::TooLarge("sample count")); - } - samples.reserve(sample_count); - let mut stss_i = 0usize; - 'chunks: for (ci, &off) in chunks.iter().enumerate() { - while stsc_i + 1 < stsc.len() && stsc[stsc_i + 1].0 as usize <= ci + 1 { - stsc_i += 1; - } - let (_, spc, sdi) = stsc[stsc_i]; - let mut pos = off; - for _ in 0..spc { - if sidx >= sample_count { - break 'chunks; - } - let size = if const_size != 0 { const_size } else { sizes[sidx] }; - let duration = stts_c.next_value(); - let cts = ctts_c.next_value() as i32 as i64; - let is_sync = match &stss { - None => true, - Some(v) => { - while stss_i < v.len() && (v[stss_i] as usize) < sidx + 1 { - stss_i += 1; - } - stss_i < v.len() && v[stss_i] as usize == sidx + 1 - } - }; - samples.push(Sample { offset: pos, size, dts, pts: dts.saturating_add(cts), duration, is_sync, description_index: sdi }); - pos = pos.saturating_add(size as u64); - dts = dts.saturating_add(duration as i64); - sidx += 1; - } - } - } - t.samples = samples; - Ok(()) -} - -fn parse_moof(moof_off: u64, moof: &[u8], tracks: &mut [Track], trex: &HashMap, next_dts: &mut [i64], file_len: u64) -> Result<()> { - // End of data of the previous traf, for the base-offset default of subsequent trafs. - let mut prev_end: Option = None; - for b in boxes(moof) { - let b = b?; - if b.kind != b"traf" { - continue; - } - let mut tfhd = None; - let mut tfdt = None; - let mut truns = Vec::new(); - for c in boxes(b.payload) { - let c = c?; - match &c.kind.0 { - b"tfhd" => tfhd = Some(c.payload), - b"tfdt" => tfdt = Some(c.payload), - b"trun" => truns.push(c.payload), - _ => {} - } - } - let tfhd = tfhd.ok_or_else(|| Error::Invalid("traf without tfhd".into()))?; - let mut c = Cur::new(tfhd, "tfhd"); - let (_, fl) = c.full_header()?; - let id = c.u32()?; - let Some(ti) = tracks.iter().position(|t| t.id == id) else { continue }; - let d = trex.get(&id).copied().unwrap_or_default(); - let base = if fl & 1 != 0 { - c.u64()? - } else if fl & 0x20000 != 0 { - moof_off - } else { - prev_end.unwrap_or(moof_off) - }; - let sdi = if fl & 2 != 0 { c.u32()? } else { d.description_index }; - let def_dur = if fl & 8 != 0 { c.u32()? } else { d.duration }; - let def_size = if fl & 0x10 != 0 { c.u32()? } else { d.size }; - let def_flags = if fl & 0x20 != 0 { c.u32()? } else { d.flags }; - if let Some(p) = tfdt { - let mut c = Cur::new(p, "tfdt"); - let (v, _) = c.full_header()?; - let t = if v == 1 { c.u64()? } else { c.u32()? as u64 }; - next_dts[ti] = t.min(i64::MAX as u64) as i64; - } - let mut data_pos = base; - for tr in truns { - let mut c = Cur::new(tr, "trun"); - let (v, tf) = c.full_header()?; - let per = [0x100u32, 0x200, 0x400, 0x800].iter().filter(|&&f| tf & f != 0).count() * 4; - let n = c.u32()? as usize; - if per > 0 { - c.check_count(n, per)?; - } else if (n as u64).saturating_mul(def_size.max(1) as u64) > file_len { - return Err(Error::TooLarge("trun sample count")); - } - if tracks[ti].samples.len() + n > MAX_SAMPLES { - return Err(Error::TooLarge("sample count")); - } - if tf & 1 != 0 { - data_pos = base.wrapping_add_signed(c.i32()? as i64); - } - let first_flags = if tf & 4 != 0 { Some(c.u32()?) } else { None }; - let t = &mut tracks[ti]; - t.samples.reserve(n); - for i in 0..n { - let dur = if tf & 0x100 != 0 { c.u32()? } else { def_dur }; - let size = if tf & 0x200 != 0 { c.u32()? } else { def_size }; - let flags = if tf & 0x400 != 0 { c.u32()? } else { def_flags }; - // first_sample_flags overrides the flags of the first sample only. - let flags = if i == 0 { first_flags.unwrap_or(flags) } else { flags }; - let cto = if tf & 0x800 != 0 { - let raw = c.u32()?; - if v == 0 { raw as i64 } else { raw as i32 as i64 } - } else { - 0 - }; - let dts = next_dts[ti]; - t.samples.push(Sample { - offset: data_pos, - size, - dts, - pts: dts.saturating_add(cto), - duration: dur, - is_sync: flags & 0x1_0000 == 0, - description_index: sdi, - }); - data_pos = data_pos.saturating_add(size as u64); - next_dts[ti] = dts.saturating_add(dur as i64); - } - } - prev_end = Some(data_pos); - } - Ok(()) -} - -fn parse_udta(p: &[u8], md: &mut Metadata) { - for b in boxes(p).map_while(|b| b.ok()) { - if b.kind == b"meta" { - parse_meta(b.payload, md); - } else if b.kind.0[0] == 0xA9 && b.payload.len() >= 4 { - let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize; - if let Some(s) = b.payload.get(4..4 + n) { - md.entries.push((b.kind.to_string_lossy(), String::from_utf8_lossy(s).into_owned())); - } - } - } -} - -fn parse_meta(p: &[u8], md: &mut Metadata) { - // QuickTime `meta` is a plain box; ISO `meta` is a full box. - let body = if p.len() >= 8 && &p[4..8] == b"hdlr" { p } else { p.get(4..).unwrap_or(&[]) }; - let mut keys: Vec = Vec::new(); - if let Some(k) = find(body, b"keys") { - let mut c = Cur::new(k, "keys"); - if c.full_header().is_ok() - && let Ok(n) = c.count(8) - { - for _ in 0..n { - let Ok(sz) = c.u32() else { break }; - let Ok(_ns) = c.u32() else { break }; - let Ok(name) = c.bytes((sz as usize).saturating_sub(8)) else { break }; - keys.push(String::from_utf8_lossy(name).into_owned()); - } - } - } - let Some(ilst) = find(body, b"ilst") else { return }; - for item in boxes(ilst).map_while(|b| b.ok()) { - let idx = item.kind.as_u32() as usize; - let key = if !keys.is_empty() && idx >= 1 && idx <= keys.len() { keys[idx - 1].clone() } else { item.kind.to_string_lossy() }; - if let Some(d) = find(item.payload, b"data") - && d.len() >= 8 - { - let ty = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) & 0xFF_FFFF; - if ty == 1 || ty == 0 { - md.entries.push((key, String::from_utf8_lossy(&d[8..]).into_owned())); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn language_codes() { - // "und" = 0x55C4 - assert_eq!(parse_language(0x55C4), "und"); - assert_eq!(parse_language(0), "eng"); - assert_eq!(parse_language(0x15C7), "eng"); - } - - #[test] - fn stts_cursor() { - let runs = [(2, 10), (0, 99), (3, 5)]; - let mut c = SttsCursor { runs: &runs, run: 0, used: 0 }; - assert_eq!(c.next_value(), 10); - assert_eq!(c.take(3), 10 + 5 + 5); - assert_eq!(c.next_value(), 5); - assert_eq!(c.next_value(), 0); - } - - #[test] - fn rejects_no_moov_and_cmov() { - let mut b = crate::bytes::BoxBuf::new(); - b.leaf(b"free", &[0; 4]); - assert!(matches!(open(b.buf.clone()), Err(Error::NoMoov))); - let m = b.start(b"moov"); - b.leaf(b"cmov", &[]); - b.end(m); - assert!(matches!(open(b.buf), Err(Error::Unsupported(_)))); - } - - #[test] - fn metadata_qt_and_ilst() { - let mut b = crate::bytes::BoxBuf::new(); - let m = b.start(&[0xA9, b'n', b'a', b'm']); - b.u16(2); - b.u16(0); - b.bytes(b"hi"); - b.end(m); - let meta = b.start_full(b"meta", 0, 0); - b.leaf(b"hdlr", &[0; 24]); - let il = b.start(b"ilst"); - let it = b.start(&[0xA9, b'A', b'R', b'T']); - let d = b.start(b"data"); - b.u32(1); - b.u32(0); - b.bytes(b"me"); - b.end(d); - b.end(it); - b.end(il); - b.end(meta); - let mut md = Metadata::default(); - parse_udta(&b.buf, &mut md); - assert_eq!(md.title(), Some("hi")); - assert_eq!(md.get("©ART"), Some("me")); - } - - use crate::bytes::BoxBuf; - - fn full(b: &mut BoxBuf, kind: &[u8; 4], v: u8, f: impl FnOnce(&mut BoxBuf)) { - let m = b.start_full(kind, v, 0); - f(b); - b.end(m); - } - - /// Hand-built file: uuid/wide/skip boxes, stz2 sizes, co64, ctts v1 with negative offsets, - /// stss, sdtp, sbgp/sgpd, and an edit list with a leading empty edit. - fn handmade(stsz_override: Option<(u32, u32)>) -> Vec { - let mut b = BoxBuf::new(); - let m = b.start(b"ftyp"); - b.bytes(b"isom"); - b.u32(0); - b.bytes(b"isom"); - b.end(m); - let m = b.start(b"uuid"); - b.bytes(&[0xAB; 16]); - b.bytes(b"payload"); - b.end(m); - b.leaf(b"wide", &[]); - b.leaf(b"skip", &[1, 2, 3]); - // mdat with 64-bit size: 4 samples of 3,1,2,4 bytes. - let data_start = b.len() as u64 + 16; - b.u32(1); - b.bytes(b"mdat"); - b.u64(16 + 10); - b.bytes(&[1, 1, 1, 2, 3, 3, 4, 4, 4, 4]); - let moov = b.start(b"moov"); - full(&mut b, b"mvhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(1000); - b.u32(500); - b.zeros(80); - }); - let trak = b.start(b"trak"); - full(&mut b, b"tkhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(7); - b.u32(0); - b.u32(500); - b.zeros(52); - b.u32(320 << 16); - b.u32(240 << 16); - }); - let edts = b.start(b"edts"); - full(&mut b, b"elst", 1, |b| { - b.u32(2); - b.u64(100); - b.i64(-1); - b.i32(0x10000); - b.u64(400); - b.i64(20); - b.i32(0x10000); - }); - b.end(edts); - let mdia = b.start(b"mdia"); - full(&mut b, b"mdhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(100); - b.u32(40); - b.u16(0x15C7); - b.u16(0); - }); - full(&mut b, b"hdlr", 0, |b| { - b.u32(0); - b.bytes(b"vide"); - b.zeros(12); - b.bytes(b"Video\0"); - }); - let minf = b.start(b"minf"); - let stbl = b.start(b"stbl"); - full(&mut b, b"stsd", 0, |b| { - b.u32(1); - crate::codec::SampleEntry::jpeg(320, 240).write(b, false).unwrap(); - }); - full(&mut b, b"stts", 0, |b| { - b.u32(1); - b.u32(4); - b.u32(10); - }); - full(&mut b, b"ctts", 1, |b| { - b.u32(2); - b.u32(2); - b.i32(-5); - b.u32(2); - b.i32(5); - }); - full(&mut b, b"stss", 0, |b| { - b.u32(2); - b.u32(3); - b.u32(1); - }); - full(&mut b, b"stsc", 0, |b| { - b.u32(2); - b.u32(1); - b.u32(3); - b.u32(1); - b.u32(2); - b.u32(1); - b.u32(1); - }); - match stsz_override { - Some((size, count)) => full(&mut b, b"stsz", 0, |b| { - b.u32(size); - b.u32(count); - }), - None => full(&mut b, b"stz2", 0, |b| { - b.u32(4); - b.u32(4); - b.u8(0x31); - b.u8(0x24); - }), - } - full(&mut b, b"co64", 0, |b| { - b.u32(2); - b.u64(data_start); - b.u64(data_start + 6); - }); - full(&mut b, b"sdtp", 0, |b| b.bytes(&[0x20, 0x10, 0x10, 0x10])); - full(&mut b, b"sbgp", 0, |b| { - b.bytes(b"roll"); - b.u32(1); - b.u32(4); - b.u32(1); - }); - full(&mut b, b"sgpd", 1, |b| { - b.bytes(b"roll"); - b.u32(2); - b.u32(1); - b.i16(-1); - }); - b.end(stbl); - b.end(minf); - b.end(mdia); - b.end(trak); - b.end(moov); - // Trailing box extending to EOF (size 0). - b.u32(0); - b.bytes(b"free"); - b.bytes(&[9; 5]); - b.buf - } - - #[test] - fn handmade_sample_table() { - let d = handmade(None); - let f = open(d.as_slice()).unwrap(); - assert_eq!(f.major_brand, *b"isom"); - let t = &f.tracks[0]; - assert_eq!((t.id, t.kind, t.timescale, t.language.as_str()), (7, TrackKind::Video, 100, "eng")); - assert_eq!((t.width, t.height), (320, 240)); - assert_eq!(t.handler_name, "Video"); - let sizes: Vec = t.samples.iter().map(|s| s.size).collect(); - assert_eq!(sizes, [3, 1, 2, 4]); - let offs: Vec = t.samples.iter().map(|s| s.offset - t.samples[0].offset).collect(); - assert_eq!(offs, [0, 3, 4, 6]); - let pts: Vec = t.samples.iter().map(|s| s.pts).collect(); - assert_eq!(pts, [-5, 5, 25, 35]); - let sync: Vec = t.samples.iter().map(|s| s.is_sync).collect(); - assert_eq!(sync, [true, false, true, false]); - for (i, want) in [vec![1u8, 1, 1], vec![2], vec![3, 3], vec![4; 4]].iter().enumerate() { - assert_eq!(&f.read_sample(d.as_slice(), 0, i).unwrap(), want); - } - // Empty edit of 100 movie ticks = 10 media ticks, then media starts at 20. - assert_eq!(t.edits.len(), 2); - assert_eq!(t.edit_offset, 10 - 20); - assert_eq!(t.presentation_pts(2), Some(15)); - assert_eq!(t.sdtp.len(), 4); - assert_eq!(t.sample_groups[0].grouping_type, *b"roll"); - assert_eq!(t.sample_groups[0].descriptions, vec![vec![0xFF, 0xFF]]); - assert!(f.read_sample(d.as_slice(), 0, 4).is_err()); - assert!(f.read_sample(d.as_slice(), 1, 0).is_err()); - } - - #[test] - fn absurd_counts_are_bounded() { - // Constant-size stsz claiming 4 billion samples is clamped to what fits in the file. - let d = handmade(Some((1, u32::MAX))); - let f = open(d.as_slice()).unwrap(); - assert!(f.tracks[0].samples.len() <= d.len()); - // A table claiming more entries than its box holds fails cleanly. - let mut d = handmade(None); - let p = d.windows(4).position(|w| w == b"stts").unwrap(); - d[p + 8..p + 12].copy_from_slice(&u32::MAX.to_be_bytes()); - assert!(matches!(open(d.as_slice()), Err(Error::Truncated(_)))); - // Box larger than its parent. - let mut d = handmade(None); - let p = d.windows(4).position(|w| w == b"stsc").unwrap(); - d[p - 4..p].copy_from_slice(&0x7FFF_FFFFu32.to_be_bytes()); - assert!(open(d.as_slice()).is_err()); - } -} diff --git a/crates/isobmff/src/error.rs b/crates/isobmff/src/error.rs deleted file mode 100644 index b5dfd752f..000000000 --- a/crates/isobmff/src/error.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::io; - -/// Errors produced by the demuxer and muxer. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("I/O error: {0}")] - Io(#[from] io::Error), - /// A box or structure ended before all required fields were read. - #[error("truncated data while reading {0}")] - Truncated(&'static str), - /// Structurally invalid data. - #[error("invalid data: {0}")] - Invalid(String), - /// Valid but unsupported feature (e.g. compressed `cmov` movie header). - #[error("unsupported: {0}")] - Unsupported(String), - /// A count or size exceeds sanity limits relative to the file size. - #[error("size limit exceeded: {0}")] - TooLarge(&'static str), - /// No `moov` box was found. - #[error("no moov box found")] - NoMoov, - /// A track index was out of range. - #[error("track index {0} out of range")] - NoSuchTrack(usize), - /// A sample index was out of range. - #[error("sample index {0} out of range")] - NoSuchSample(usize), - /// Muxer usage error (e.g. invalid track config, bad sample). - #[error("muxer: {0}")] - Mux(String), -} - -pub type Result = std::result::Result; - -pub(crate) fn invalid(msg: impl Into) -> Result { - Err(Error::Invalid(msg.into())) -} diff --git a/crates/isobmff/src/lib.rs b/crates/isobmff/src/lib.rs index 6c0b657d8..50871ab54 100644 --- a/crates/isobmff/src/lib.rs +++ b/crates/isobmff/src/lib.rs @@ -1,28 +1,7 @@ -//! Clean-room ISO Base Media File Format (MP4, ISO/IEC 14496-12/14/15) and QuickTime (MOV) support. -//! -//! - **Demux:** [`open`] parses box headers plus `moov` (and `moof` for fragmented files) from a -//! [`ByteSource`] and builds per-track [`Sample`] tables; media data is read on demand with -//! [`Mp4File::read_sample`]. -//! - **Mux:** [`Mp4Writer`] writes progressive MP4/MOV (optionally faststart); -//! [`FragmentedWriter`] writes fragmented MP4. -//! -//! See the crate README for supported boxes and limitations. +//! ISO BMFF (MP4) and QuickTime (MOV) demuxer and muxer: a re-export of +//! [`craft_media::isobmff`] from craft-libs. Supported boxes and limitations are in the +//! craft-media README. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] -mod bytes; -mod codec; -mod demux; -mod error; -mod mux; -mod source; - -pub use bytes::FourCc; -pub use codec::{ - AacConfig, ApvConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, HevcNalArray, - MasteringDisplay, OpusConfig, PcmConfig, SampleEntry, TimecodeConfig, VideoParams, VpcConfig, parse_asc, -}; -pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open}; -pub use error::{Error, Result}; -pub use mux::{Brand, FragmentedWriter, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; -pub use source::ByteSource; +pub use craft_media::isobmff::*; diff --git a/crates/isobmff/src/mux.rs b/crates/isobmff/src/mux.rs deleted file mode 100644 index 880d49a2c..000000000 --- a/crates/isobmff/src/mux.rs +++ /dev/null @@ -1,963 +0,0 @@ -//! Muxers: progressive MP4/MOV ([`Mp4Writer`], optional faststart) and fragmented MP4 ([`FragmentedWriter`]). - -use crate::bytes::BoxBuf; -use crate::codec::{CodecConfig, SampleEntry, TimecodeConfig}; -use crate::demux::Edit; -use crate::error::{Error, Result}; -use std::io::{Read, Seek, SeekFrom, Write}; - -/// Output flavour: determines brands and some sample-entry encodings. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Brand { - /// ISO MP4: `isom` major brand, compatible `isom iso2 avc1 mp41 mp42`. - Mp4, - /// QuickTime MOV: `qt ` major brand. - Mov, -} - -/// Movie-level writer options. -#[derive(Clone, Debug)] -pub struct WriterOptions { - pub brand: Brand, - /// Movie timescale (`mvhd`); default 1000. - pub movie_timescale: u32, - /// Text metadata written to `moov/udta` (keys like `©nam`, `©ART`, `©cmt`, `©too`). - pub metadata: Vec<(String, String)>, -} - -impl WriterOptions { - pub fn new(brand: Brand) -> Self { - WriterOptions { brand, movie_timescale: 1000, metadata: Vec::new() } - } -} - -/// Per-track configuration. -#[derive(Clone, Debug)] -pub struct TrackConfig { - pub entry: SampleEntry, - /// Media timescale. For PCM it must equal the sample rate (one tick per audio frame). - pub timescale: u32, - /// ISO 639-2/T language (`und` by default). - pub language: String, - /// Explicit edit list (segment durations in the movie timescale). Takes precedence over `media_start`. - pub edits: Vec, - /// Convenience: create one edit starting at this media time (e.g. AAC encoder delay / priming, - /// or the first pts of a B-frame stream) spanning the rest of the media. - pub media_start: Option, - /// `hdlr` name; a sensible default is used when `None`. - pub handler_name: Option, -} - -impl TrackConfig { - pub fn new(entry: SampleEntry, timescale: u32) -> Self { - TrackConfig { entry, timescale, language: "und".into(), edits: Vec::new(), media_start: None, handler_name: None } - } -} - -/// A sample handed to the muxers. -#[derive(Clone, Copy, Debug)] -pub struct WriteSample<'a> { - pub data: &'a [u8], - /// Duration in the media timescale. Ignored for PCM (each frame lasts one tick). - pub duration: u32, - /// pts − dts in the media timescale. - pub composition_offset: i32, - pub is_sync: bool, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Handler { - Video, - Audio, - Timecode, - Other, -} - -fn handler_of(e: &SampleEntry) -> Handler { - if matches!(e.codec, CodecConfig::Timecode(_)) { - Handler::Timecode - } else if e.video.is_some() { - Handler::Video - } else if e.audio.is_some() { - Handler::Audio - } else { - Handler::Other - } -} - -/// Accumulated sample tables for one track. -#[derive(Clone, Debug, Default)] -struct Tables { - stts: Vec<(u32, u32)>, - ctts: Vec<(u32, i32)>, - sizes: Vec, - /// Constant sample size (PCM) — then `sizes` is unused. - const_size: Option, - sample_count: u64, - /// 1-based sync sample numbers. - sync: Vec, - all_sync: bool, - /// (offset relative to file start before shifting, sample count) - chunks: Vec<(u64, u32)>, - media_duration: u64, - /// End of the last sample on the composition timeline (decode time + composition offset + - /// duration, maximum over samples). - comp_end: u64, -} - -impl Tables { - fn new() -> Self { - Tables { all_sync: true, ..Default::default() } - } - fn push_run(runs: &mut Vec<(u32, T)>, count: u32, v: T) { - if let Some(last) = runs.last_mut() - && last.1 == v - && last.0.checked_add(count).is_some() - { - last.0 += count; - return; - } - runs.push((count, v)); - } -} - -struct WTrack { - cfg: TrackConfig, - id: u32, - handler: Handler, - pcm_frame_bytes: Option, - t: Tables, - /// Track id of a linked timecode track (`tref/tmcd`). - tmcd_ref: Option, - /// For timecode tracks: (video track index, start frame). - tmcd_source: Option<(usize, u32)>, -} - -fn pack_language(lang: &str) -> u16 { - let b = lang.as_bytes(); - if b.len() == 3 && b.iter().all(|c| c.is_ascii_lowercase()) { - (((b[0] - 0x60) as u16) << 10) | (((b[1] - 0x60) as u16) << 5) | ((b[2] - 0x60) as u16) - } else { - 0x55C4 // "und" - } -} - -fn rescale(v: u64, from: u32, to: u32) -> u64 { - if from == 0 { - return 0; - } - (v as u128 * to as u128 / from as u128).min(u64::MAX as u128) as u64 -} - -const MATRIX: [i32; 9] = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000]; - -fn write_ftyp(b: &mut BoxBuf, brand: Brand) { - let m = b.start(b"ftyp"); - match brand { - Brand::Mp4 => { - b.bytes(b"isom"); - b.u32(0x200); - for c in [b"isom", b"iso2", b"avc1", b"mp41", b"mp42"] { - b.bytes(c); - } - } - Brand::Mov => { - b.bytes(b"qt "); - b.u32(0x200); - b.bytes(b"qt "); - } - } - b.end(m); -} - -fn movie_duration(tracks: &[WTrack], movie_ts: u32) -> u64 { - tracks.iter().map(|t| presentation_duration(t, movie_ts)).max().unwrap_or(0) -} - -fn effective_edits(t: &WTrack, movie_ts: u32) -> Vec { - if !t.cfg.edits.is_empty() { - return t.cfg.edits.clone(); - } - if let Some(start) = t.cfg.media_start { - // presentation runs from `start` to the end of the last sample in composition order (with - // B-frame reordering that is past the end of the decode timeline) - let rest = t.t.comp_end.max(t.t.media_duration).saturating_sub(start.max(0) as u64); - return vec![Edit { segment_duration: rescale(rest, t.cfg.timescale, movie_ts), media_time: start, media_rate: 0x10000 }]; - } - Vec::new() -} - -fn presentation_duration(t: &WTrack, movie_ts: u32) -> u64 { - let e = effective_edits(t, movie_ts); - if e.is_empty() { rescale(t.t.media_duration, t.cfg.timescale, movie_ts) } else { e.iter().map(|e| e.segment_duration).sum() } -} - -fn write_mvhd(b: &mut BoxBuf, timescale: u32, duration: u64, next_id: u32) { - let v1 = duration > u32::MAX as u64; - let m = b.start_full(b"mvhd", v1 as u8, 0); - if v1 { - b.u64(0); - b.u64(0); - b.u32(timescale); - b.u64(duration); - } else { - b.u32(0); - b.u32(0); - b.u32(timescale); - b.u32(duration as u32); - } - b.u32(0x10000); - b.u16(0x100); - b.zeros(10); - for v in MATRIX { - b.i32(v); - } - b.zeros(24); - b.u32(next_id); - b.end(m); -} - -fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, force_co64: bool) -> Result<()> { - let trak = b.start(b"trak"); - let dur = presentation_duration(t, movie_ts); - let v1 = dur > u32::MAX as u64; - let m = b.start_full(b"tkhd", v1 as u8, 3); - if v1 { - b.u64(0); - b.u64(0); - b.u32(t.id); - b.u32(0); - b.u64(dur); - } else { - b.u32(0); - b.u32(0); - b.u32(t.id); - b.u32(0); - b.u32(dur as u32); - } - b.zeros(8); - b.i16(0); - b.u16(0); - b.i16(if t.handler == Handler::Audio { 0x100 } else { 0 }); - b.u16(0); - for v in MATRIX { - b.i32(v); - } - let (w, h) = match &t.cfg.entry.video { - Some(v) => { - let w = match v.pixel_aspect { - Some((hs, vs)) if hs > 0 && vs > 0 => (v.width as u64 * hs as u64 / vs as u64) as u32, - _ => v.width as u32, - }; - (w, v.height as u32) - } - None => (0, 0), - }; - b.u32(w << 16); - b.u32(h << 16); - b.end(m); - let edits = effective_edits(t, movie_ts); - if !edits.is_empty() { - let e = b.start(b"edts"); - let v1 = edits.iter().any(|e| e.segment_duration > u32::MAX as u64 || e.media_time > i32::MAX as i64 || e.media_time < i32::MIN as i64); - let l = b.start_full(b"elst", v1 as u8, 0); - b.u32(edits.len() as u32); - for ed in &edits { - if v1 { - b.u64(ed.segment_duration); - b.i64(ed.media_time); - } else { - b.u32(ed.segment_duration as u32); - b.i32(ed.media_time as i32); - } - b.i32(ed.media_rate); - } - b.end(l); - b.end(e); - } - if let Some(id) = t.tmcd_ref { - let r = b.start(b"tref"); - let m = b.start(b"tmcd"); - b.u32(id); - b.end(m); - b.end(r); - } - let mdia = b.start(b"mdia"); - let mv1 = t.t.media_duration > u32::MAX as u64; - let m = b.start_full(b"mdhd", mv1 as u8, 0); - if mv1 { - b.u64(0); - b.u64(0); - b.u32(t.cfg.timescale); - b.u64(t.t.media_duration); - } else { - b.u32(0); - b.u32(0); - b.u32(t.cfg.timescale); - b.u32(t.t.media_duration as u32); - } - b.u16(pack_language(&t.cfg.language)); - b.u16(0); - b.end(m); - let (htype, default_name) = match t.handler { - Handler::Video => (b"vide", "VideoHandler"), - Handler::Audio => (b"soun", "SoundHandler"), - Handler::Timecode => (b"tmcd", "TimeCodeHandler"), - Handler::Other => (b"meta", "MetaHandler"), - }; - let name = t.cfg.handler_name.clone().unwrap_or_else(|| default_name.into()); - let m = b.start_full(b"hdlr", 0, 0); - b.bytes(if qt { b"mhlr" } else { &[0, 0, 0, 0] }); - b.bytes(htype); - b.zeros(12); - if qt { - let n = name.len().min(255); - b.u8(n as u8); - b.bytes(&name.as_bytes()[..n]); - } else { - b.bytes(name.as_bytes()); - b.u8(0); - } - b.end(m); - let minf = b.start(b"minf"); - match t.handler { - Handler::Video => { - let m = b.start_full(b"vmhd", 0, 1); - b.zeros(8); - b.end(m); - } - Handler::Audio => { - let m = b.start_full(b"smhd", 0, 0); - b.zeros(4); - b.end(m); - } - Handler::Timecode => { - let g = b.start(b"gmhd"); - let m = b.start_full(b"gmin", 0, 0); - b.u16(0x40); - b.u16(0x8000); - b.u16(0x8000); - b.u16(0x8000); - b.i16(0); - b.u16(0); - b.end(m); - let tm = b.start(b"tmcd"); - let m = b.start_full(b"tcmi", 0, 0); - b.u16(0); - b.u16(0); - b.u16(12); - b.u16(0); - b.bytes(&[0xFF; 6]); - b.zeros(6); - b.u8(0); - b.end(m); - b.end(tm); - b.end(g); - } - Handler::Other => { - let m = b.start_full(b"nmhd", 0, 0); - b.end(m); - } - } - if qt { - // QuickTime data handler reference. - let m = b.start_full(b"hdlr", 0, 0); - b.bytes(b"dhlr"); - b.bytes(b"url "); - b.zeros(12); - b.u8(0); - b.end(m); - } - let d = b.start(b"dinf"); - let r = b.start_full(b"dref", 0, 0); - b.u32(1); - let u = b.start_full(b"url ", 0, 1); - b.end(u); - b.end(r); - b.end(d); - let stbl = b.start(b"stbl"); - let m = b.start_full(b"stsd", 0, 0); - b.u32(1); - t.cfg.entry.write(b, qt)?; - b.end(m); - let tb = &t.t; - let m = b.start_full(b"stts", 0, 0); - b.u32(tb.stts.len() as u32); - for &(c, d) in &tb.stts { - b.u32(c); - b.u32(d); - } - b.end(m); - if tb.ctts.iter().any(|&(_, o)| o != 0) { - let neg = tb.ctts.iter().any(|&(_, o)| o < 0); - let m = b.start_full(b"ctts", neg as u8, 0); - b.u32(tb.ctts.len() as u32); - for &(c, o) in &tb.ctts { - b.u32(c); - b.i32(o); - } - b.end(m); - } - if !tb.all_sync { - let m = b.start_full(b"stss", 0, 0); - b.u32(tb.sync.len() as u32); - for &s in &tb.sync { - b.u32(s); - } - b.end(m); - } - let m = b.start_full(b"stsc", 0, 0); - let mut runs: Vec<(u32, u32)> = Vec::new(); - for (i, &(_, n)) in tb.chunks.iter().enumerate() { - if runs.last().is_none_or(|r| r.1 != n) { - runs.push((i as u32 + 1, n)); - } - } - b.u32(runs.len() as u32); - for (first, n) in runs { - b.u32(first); - b.u32(n); - b.u32(1); - } - b.end(m); - let m = b.start_full(b"stsz", 0, 0); - match tb.const_size { - Some(s) => { - b.u32(s); - b.u32(tb.sample_count.min(u32::MAX as u64) as u32); - } - None => { - let first = tb.sizes.first().copied().unwrap_or(0); - // A uniform size of 0 would mean "table follows" (ISO/IEC 14496-12 §8.7.3), so - // all-empty samples must still be written as a table. - if first != 0 && !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) { - b.u32(first); - b.u32(tb.sizes.len() as u32); - } else { - b.u32(0); - b.u32(tb.sizes.len() as u32); - for &s in &tb.sizes { - b.u32(s); - } - } - } - } - b.end(m); - let co64 = force_co64 || tb.chunks.iter().any(|&(o, _)| o + shift > u32::MAX as u64); - let m = b.start_full(if co64 { b"co64" } else { b"stco" }, 0, 0); - b.u32(tb.chunks.len() as u32); - for &(o, _) in &tb.chunks { - if co64 { - b.u64(o + shift); - } else { - b.u32((o + shift) as u32); - } - } - b.end(m); - b.end(stbl); - b.end(minf); - b.end(mdia); - b.end(trak); - Ok(()) -} - -fn write_udta(b: &mut BoxBuf, md: &[(String, String)]) { - let items: Vec<([u8; 4], &str)> = md - .iter() - .filter_map(|(k, v)| { - let mut chars = k.chars(); - if chars.next()? != '©' { - return None; - } - let rest: Vec = chars.map(|c| c as u32 as u8).collect(); - (rest.len() == 3).then(|| ([0xA9, rest[0], rest[1], rest[2]], v.as_str())) - }) - .collect(); - if items.is_empty() { - return; - } - let u = b.start(b"udta"); - for (k, v) in items { - let m = b.start(&k); - b.u16(v.len() as u16); - b.u16(0x55C4); - b.bytes(v.as_bytes()); - b.end(m); - } - b.end(u); -} - -fn validate_track(cfg: &TrackConfig) -> Result> { - if cfg.timescale == 0 { - return Err(Error::Mux("timescale must be non-zero".into())); - } - if let CodecConfig::Pcm(p) = &cfg.entry.codec { - if p.bits == 0 || p.channels == 0 { - return Err(Error::Mux("PCM needs bits and channels".into())); - } - if (p.sample_rate - cfg.timescale as f64).abs() > 0.5 { - return Err(Error::Mux("PCM track timescale must equal the sample rate".into())); - } - return Ok(Some(p.bytes_per_frame())); - } - Ok(None) -} - -/// Progressive MP4/MOV writer: `ftyp`, then one `mdat` streamed to `W`, then `moov` at [`finish`](Self::finish) -/// (or moved in front of `mdat` by [`finish_faststart`](Self::finish_faststart)). -pub struct Mp4Writer { - w: W, - opts: WriterOptions, - tracks: Vec, - /// Offset of the placeholder box (8 bytes) preceding the mdat header. - pad_pos: u64, - mdat_pos: u64, - pos: u64, - last_track: Option, -} - -impl Mp4Writer { - /// Start a file: writes `ftyp`, a placeholder box and the `mdat` header. - pub fn new(mut w: W, opts: WriterOptions) -> Result { - let start = w.stream_position()?; - let mut b = BoxBuf::new(); - write_ftyp(&mut b, opts.brand); - let pad_pos = start + b.len() as u64; - b.leaf(if opts.brand == Brand::Mov { b"wide" } else { b"free" }, &[]); - let mdat_pos = start + b.len() as u64; - b.leaf(b"mdat", &[]); - w.write_all(&b.buf)?; - let pos = start + b.len() as u64; - Ok(Mp4Writer { w, opts, tracks: Vec::new(), pad_pos, mdat_pos, pos, last_track: None }) - } - - /// Add a track; returns its index (track ids are index + 1). - pub fn add_track(&mut self, cfg: TrackConfig) -> Result { - let pcm = validate_track(&cfg)?; - let handler = handler_of(&cfg.entry); - let mut t = Tables::new(); - t.const_size = pcm; - self.tracks.push(WTrack { id: self.tracks.len() as u32 + 1, handler, pcm_frame_bytes: pcm, t, cfg, tmcd_ref: None, tmcd_source: None }); - Ok(self.tracks.len() - 1) - } - - /// Replace a track's sample entry before the file is finished (the `moov` is written last), for - /// codecs whose configuration is only complete at the end, like the FLAC STREAMINFO in `dfLa`. - pub fn set_sample_entry(&mut self, track: usize, entry: SampleEntry) -> Result<()> { - let t = self.tracks.get_mut(track).ok_or_else(|| Error::Invalid(format!("no track {track}")))?; - t.cfg.entry = entry; - Ok(()) - } - - /// Add a QuickTime timecode track referenced by `video_track`. Its single sample (the start - /// frame number) is written at finish, spanning the video track's duration. - pub fn add_timecode_track(&mut self, video_track: usize, tc: TimecodeConfig, start_frame: u32) -> Result { - if video_track >= self.tracks.len() { - return Err(Error::NoSuchTrack(video_track)); - } - if tc.timescale == 0 || tc.frame_duration == 0 || tc.frames_per_second == 0 { - return Err(Error::Mux("timecode needs timescale, frame duration and fps".into())); - } - let entry = SampleEntry { - format: crate::bytes::FourCc(*b"tmcd"), - data_reference_index: 1, - codec: CodecConfig::Timecode(tc.clone()), - video: None, - audio: None, - bitrate: None, - }; - let idx = self.add_track(TrackConfig::new(entry, tc.timescale))?; - self.tracks[idx].tmcd_source = Some((video_track, start_frame)); - let id = self.tracks[idx].id; - self.tracks[video_track].tmcd_ref = Some(id); - Ok(idx) - } - - /// Append one sample to track `track` (decode order). For PCM tracks `data` may hold any whole - /// number of audio frames. - pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> { - let tr = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - if tr.tmcd_source.is_some() { - return Err(Error::Mux("timecode track samples are written automatically".into())); - } - let (count, dur) = match tr.pcm_frame_bytes { - Some(fb) => { - if !s.data.len().is_multiple_of(fb as usize) { - return Err(Error::Mux("PCM data is not a whole number of frames".into())); - } - ((s.data.len() / fb as usize) as u32, 1u32) - } - None => { - if s.data.len() > u32::MAX as usize { - return Err(Error::Mux("sample larger than 4 GiB".into())); - } - (1, s.duration) - } - }; - if count == 0 { - return Ok(()); - } - self.w.write_all(s.data)?; - let offset = self.pos; - self.pos += s.data.len() as u64; - let contiguous = self.last_track == Some(track); - self.last_track = Some(track); - let tr = &mut self.tracks[track]; - let t = &mut tr.t; - Tables::push_run(&mut t.stts, count, dur); - Tables::push_run(&mut t.ctts, count, if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset }); - if tr.pcm_frame_bytes.is_none() { - t.sizes.push(s.data.len() as u32); - if s.is_sync { - t.sync.push(t.sample_count as u32 + 1); - } else { - t.all_sync = false; - } - } - t.sample_count += count as u64; - let ct = if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset as i64 }; - t.comp_end = t.comp_end.max((t.media_duration as i64 + ct + count as i64 * dur as i64).max(0) as u64); - t.media_duration += count as u64 * dur as u64; - match t.chunks.last_mut() { - Some(c) if contiguous => c.1 += count, - _ => t.chunks.push((offset, count)), - } - Ok(()) - } - - fn finish_mdat(&mut self) -> Result<()> { - // Timecode samples. - for i in 0..self.tracks.len() { - let Some((vi, start)) = self.tracks[i].tmcd_source else { continue }; - let v = &self.tracks[vi]; - let vd = v.t.media_duration; - let vts = v.cfg.timescale; - let tts = self.tracks[i].cfg.timescale; - let tc_dur = match &self.tracks[i].cfg.entry.codec { - CodecConfig::Timecode(tc) => tc.frame_duration, - _ => 1, - }; - let d = rescale(vd, vts, tts).max(tc_dur as u64).min(u32::MAX as u64) as u32; - self.w.write_all(&start.to_be_bytes())?; - let off = self.pos; - self.pos += 4; - self.last_track = None; - let t = &mut self.tracks[i].t; - t.stts.push((1, d)); - t.sizes.push(4); - t.sync.push(1); - t.sample_count = 1; - t.media_duration = d as u64; - t.chunks.push((off, 1)); - } - let size = self.pos - self.mdat_pos; - if size <= u32::MAX as u64 { - self.w.seek(SeekFrom::Start(self.mdat_pos))?; - self.w.write_all(&(size as u32).to_be_bytes())?; - } else { - // Merge the placeholder into a 16-byte large-size mdat header. - let mut h = [0u8; 16]; - h[..4].copy_from_slice(&1u32.to_be_bytes()); - h[4..8].copy_from_slice(b"mdat"); - h[8..].copy_from_slice(&(self.pos - self.pad_pos).to_be_bytes()); - self.w.seek(SeekFrom::Start(self.pad_pos))?; - self.w.write_all(&h)?; - } - self.w.seek(SeekFrom::Start(self.pos))?; - Ok(()) - } - - fn build_moov(&self, shift: u64, force_co64: bool) -> Result> { - let qt = self.opts.brand == Brand::Mov; - let ts = self.opts.movie_timescale.max(1); - let mut b = BoxBuf::new(); - let m = b.start(b"moov"); - write_mvhd(&mut b, ts, movie_duration(&self.tracks, ts), self.tracks.len() as u32 + 1); - for t in &self.tracks { - write_trak(&mut b, t, ts, qt, shift, force_co64)?; - } - write_udta(&mut b, &self.opts.metadata); - b.end(m); - Ok(b.buf) - } - - /// Finish the file with `moov` after `mdat`. Returns the underlying writer. - pub fn finish(mut self) -> Result { - self.finish_mdat()?; - let moov = self.build_moov(0, false)?; - self.w.write_all(&moov)?; - self.w.flush()?; - Ok(self.w) - } -} - -impl Mp4Writer { - /// Finish with `moov` in front of `mdat` ("fast start"): media data is shifted forward in place. - pub fn finish_faststart(mut self) -> Result { - self.finish_mdat()?; - let mut moov = self.build_moov(0, false)?; - let mut force = false; - loop { - let shift = moov.len() as u64; - let next = self.build_moov(shift, force)?; - if next.len() == moov.len() { - moov = next; - break; - } - // Offsets crossed the 32-bit boundary: switch to co64 (size only grows, so this converges). - force = true; - moov = next; - } - let shift = moov.len() as u64; - let start = self.pad_pos; - let end = self.pos; - let mut buf = vec![0u8; 1 << 20]; - let mut cur = end; - while cur > start { - let n = (cur - start).min(buf.len() as u64) as usize; - cur -= n as u64; - self.w.seek(SeekFrom::Start(cur))?; - self.w.read_exact(&mut buf[..n])?; - self.w.seek(SeekFrom::Start(cur + shift))?; - self.w.write_all(&buf[..n])?; - } - self.w.seek(SeekFrom::Start(start))?; - self.w.write_all(&moov)?; - self.w.seek(SeekFrom::Start(end + shift))?; - self.w.flush()?; - Ok(self.w) - } -} - -/// Buffered fragment sample: (data, duration, composition offset, sync). -type PendingSample = (Vec, u32, i32, bool); - -/// Fragmented MP4 writer: an init segment (`ftyp` + `moov` with `mvex`) followed by -/// `moof`+`mdat` fragments. Samples are buffered until [`flush_fragment`](Self::flush_fragment). -pub struct FragmentedWriter { - w: W, - opts: WriterOptions, - tracks: Vec, - pending: Vec>, - next_dts: Vec, - seq: u32, - init_written: bool, -} - -impl FragmentedWriter { - pub fn new(w: W, opts: WriterOptions) -> Self { - FragmentedWriter { w, opts, tracks: Vec::new(), pending: Vec::new(), next_dts: Vec::new(), seq: 0, init_written: false } - } - - /// Add a track (before the first fragment). PCM is not supported in fragments. - pub fn add_track(&mut self, cfg: TrackConfig) -> Result { - if self.init_written { - return Err(Error::Mux("tracks must be added before the first fragment".into())); - } - if validate_track(&cfg)?.is_some() { - return Err(Error::Mux("PCM is not supported by the fragmented writer".into())); - } - let handler = handler_of(&cfg.entry); - self.tracks.push(WTrack { id: self.tracks.len() as u32 + 1, handler, pcm_frame_bytes: None, t: Tables::new(), cfg, tmcd_ref: None, tmcd_source: None }); - self.pending.push(Vec::new()); - self.next_dts.push(0); - Ok(self.tracks.len() - 1) - } - - fn write_init(&mut self) -> Result<()> { - let qt = self.opts.brand == Brand::Mov; - let ts = self.opts.movie_timescale.max(1); - let mut b = BoxBuf::new(); - write_ftyp(&mut b, self.opts.brand); - let m = b.start(b"moov"); - write_mvhd(&mut b, ts, 0, self.tracks.len() as u32 + 1); - for t in &self.tracks { - write_trak(&mut b, t, ts, qt, 0, false)?; - } - let mv = b.start(b"mvex"); - for t in &self.tracks { - let x = b.start_full(b"trex", 0, 0); - b.u32(t.id); - b.u32(1); - b.u32(0); - b.u32(0); - b.u32(0); - b.end(x); - } - b.end(mv); - write_udta(&mut b, &self.opts.metadata); - b.end(m); - self.w.write_all(&b.buf)?; - self.init_written = true; - Ok(()) - } - - /// Buffer a sample for the next fragment. - pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> { - let p = self.pending.get_mut(track).ok_or(Error::NoSuchTrack(track))?; - if s.data.len() > u32::MAX as usize { - return Err(Error::Mux("sample larger than 4 GiB".into())); - } - p.push((s.data.to_vec(), s.duration, s.composition_offset, s.is_sync)); - Ok(()) - } - - fn build_moof(&self, data_offsets: &[i32]) -> Vec { - let mut b = BoxBuf::new(); - let m = b.start(b"moof"); - let h = b.start_full(b"mfhd", 0, 0); - b.u32(self.seq); - b.end(h); - for (i, t) in self.tracks.iter().enumerate() { - let p = &self.pending[i]; - if p.is_empty() { - continue; - } - let tf = b.start(b"traf"); - let h = b.start_full(b"tfhd", 0, 0x20000); - b.u32(t.id); - b.end(h); - let h = b.start_full(b"tfdt", 1, 0); - b.u64(self.next_dts[i]); - b.end(h); - let neg = p.iter().any(|s| s.2 < 0); - let h = b.start_full(b"trun", neg as u8, 0x1 | 0x100 | 0x200 | 0x400 | 0x800); - b.u32(p.len() as u32); - b.i32(data_offsets[i]); - for (d, dur, cto, sync) in p { - b.u32(*dur); - b.u32(d.len() as u32); - b.u32(if *sync { 0x0200_0000 } else { 0x0101_0000 }); - b.i32(*cto); - } - b.end(h); - b.end(tf); - } - b.end(m); - b.buf - } - - /// Write all buffered samples as one `moof` + `mdat` fragment (no-op if nothing is buffered). - pub fn flush_fragment(&mut self) -> Result<()> { - if !self.init_written { - self.write_init()?; - } - if self.pending.iter().all(|p| p.is_empty()) { - return Ok(()); - } - self.seq += 1; - let probe = self.build_moof(&vec![0; self.tracks.len()]); - let data_len: u64 = self.pending.iter().flatten().map(|s| s.0.len() as u64).sum(); - let large = data_len + 8 > u32::MAX as u64; - let hdr = if large { 16 } else { 8 }; - let mut offs = Vec::with_capacity(self.tracks.len()); - let mut acc = probe.len() as u64 + hdr; - for p in &self.pending { - offs.push(i32::try_from(acc).map_err(|_| Error::Mux("fragment too large".into()))?); - acc += p.iter().map(|s| s.0.len() as u64).sum::(); - } - let moof = self.build_moof(&offs); - self.w.write_all(&moof)?; - if large { - self.w.write_all(&1u32.to_be_bytes())?; - self.w.write_all(b"mdat")?; - self.w.write_all(&(data_len + 16).to_be_bytes())?; - } else { - self.w.write_all(&((data_len + 8) as u32).to_be_bytes())?; - self.w.write_all(b"mdat")?; - } - for (i, p) in self.pending.iter_mut().enumerate() { - for (d, dur, _, _) in p.drain(..) { - self.w.write_all(&d)?; - self.next_dts[i] += dur as u64; - } - } - Ok(()) - } - - /// Flush remaining samples and return the writer. - pub fn finish(mut self) -> Result { - self.flush_fragment()?; - self.w.flush()?; - Ok(self.w) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codec::{AvcConfig, PcmConfig}; - use std::io::Cursor; - - fn avc_entry() -> SampleEntry { - SampleEntry::avc(AvcConfig::new(vec![vec![0x67, 0x42, 0, 0x1E]], vec![vec![0x68, 0xCE]], 4), 64, 48) - } - - #[test] - fn language_pack() { - assert_eq!(pack_language("und"), 0x55C4); - assert_eq!(pack_language("eng"), 0x15C7); - assert_eq!(pack_language("EN"), 0x55C4); - } - - #[test] - fn simple_roundtrip_and_faststart() { - for fast in [false, true] { - let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); - let v = w.add_track(TrackConfig::new(avc_entry(), 12800)).unwrap(); - let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 2, sample_rate: 48000.0 }; - let a = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap(); - for i in 0..10u8 { - let data = vec![i; 100 + i as usize]; - w.write_sample(v, WriteSample { data: &data, duration: 512, composition_offset: if i % 2 == 0 { 1024 } else { 0 }, is_sync: i % 5 == 0 }) - .unwrap(); - w.write_sample(a, WriteSample { data: &[i; 400], duration: 0, composition_offset: 0, is_sync: true }).unwrap(); - } - let out = if fast { w.finish_faststart() } else { w.finish() }.unwrap().into_inner(); - let f = crate::open(out.as_slice()).unwrap(); - assert_eq!(f.tracks.len(), 2); - let t = &f.tracks[0]; - assert_eq!(t.samples.len(), 10); - for (i, s) in t.samples.iter().enumerate() { - assert_eq!(s.size as usize, 100 + i); - assert_eq!(s.dts, 512 * i as i64); - assert_eq!(s.is_sync, i % 5 == 0); - assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 100 + i]); - } - let at = &f.tracks[1]; - assert!(at.pcm_chunked); - assert_eq!(at.samples.len(), 10); - assert_eq!(at.samples[3].size, 400); - assert_eq!(at.samples[3].duration, 100); - assert_eq!(f.read_sample(out.as_slice(), 1, 3).unwrap(), vec![3u8; 400]); - if fast { - assert!(f.moov_offset < t.samples[0].offset); - } - } - } - - #[test] - fn fragmented_roundtrip() { - let mut w = FragmentedWriter::new(Vec::new(), WriterOptions::new(Brand::Mp4)); - let v = w.add_track(TrackConfig::new(avc_entry(), 1000)).unwrap(); - for i in 0..6u8 { - w.write_sample(v, WriteSample { data: &[i; 10], duration: 40, composition_offset: -(i as i32 % 2), is_sync: i % 3 == 0 }).unwrap(); - if i % 3 == 2 { - w.flush_fragment().unwrap(); - } - } - let out = w.finish().unwrap(); - let f = crate::open(out.as_slice()).unwrap(); - assert!(f.fragmented); - assert_eq!(f.fragment_count, 2); - let t = &f.tracks[0]; - assert_eq!(t.samples.len(), 6); - for (i, s) in t.samples.iter().enumerate() { - assert_eq!(s.dts, 40 * i as i64); - assert_eq!(s.pts, s.dts - (i as i64 % 2)); - assert_eq!(s.is_sync, i % 3 == 0); - assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 10]); - } - assert_eq!(t.sync_sample_before(5), 3); - } -} diff --git a/crates/isobmff/src/source.rs b/crates/isobmff/src/source.rs deleted file mode 100644 index 0dad82b20..000000000 --- a/crates/isobmff/src/source.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Random-access byte sources for the demuxer. - -use std::io; -use std::sync::Arc; - -/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). -/// -/// Implementations must be able to serve reads at arbitrary offsets through `&self`. -pub trait ByteSource { - /// Total length in bytes. - fn len(&self) -> u64; - /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; - /// True if the source holds no bytes. - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -fn eof() -> io::Error { - io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") -} - -impl ByteSource for [u8] { - fn len(&self) -> u64 { - <[u8]>::len(self) as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let start = usize::try_from(offset).map_err(|_| eof())?; - let end = start.checked_add(buf.len()).ok_or_else(eof)?; - let src = self.get(start..end).ok_or_else(eof)?; - buf.copy_from_slice(src); - Ok(()) - } -} - -impl ByteSource for Vec { - fn len(&self) -> u64 { - self.as_slice().len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - self.as_slice().read_at(offset, buf) - } -} - -impl ByteSource for &T { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Arc { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Box { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -#[cfg(not(target_arch = "wasm32"))] -impl ByteSource for std::fs::File { - fn len(&self) -> u64 { - self.metadata().map(|m| m.len()).unwrap_or(0) - } - #[cfg(unix)] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) - } - #[cfg(windows)] - fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { - use std::os::windows::fs::FileExt; - while !buf.is_empty() { - let n = self.seek_read(buf, offset)?; - if n == 0 { - return Err(eof()); - } - offset += n as u64; - buf = &mut buf[n..]; - } - Ok(()) - } - #[cfg(not(any(unix, windows)))] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - use std::io::{Read, Seek, SeekFrom}; - let mut f = self; - f.seek(SeekFrom::Start(offset))?; - f.read_exact(buf) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slice_reads() { - let v = vec![1u8, 2, 3, 4]; - let mut b = [0u8; 2]; - v.read_at(1, &mut b).unwrap(); - assert_eq!(b, [2, 3]); - assert!(v.read_at(3, &mut b).is_err()); - assert!(v.read_at(u64::MAX, &mut b).is_err()); - let a: Arc<[u8]> = Arc::from(v.clone()); - a.read_at(2, &mut b).unwrap(); - assert_eq!(b, [3, 4]); - assert_eq!(ByteSource::len(&a), 4); - let s: &[u8] = &v; - assert_eq!(ByteSource::len(&s), 4); - } -} diff --git a/crates/isobmff/tests/common/mod.rs b/crates/isobmff/tests/it/common/mod.rs similarity index 100% rename from crates/isobmff/tests/common/mod.rs rename to crates/isobmff/tests/it/common/mod.rs diff --git a/crates/isobmff/tests/fuzz.rs b/crates/isobmff/tests/it/fuzz.rs similarity index 99% rename from crates/isobmff/tests/fuzz.rs rename to crates/isobmff/tests/it/fuzz.rs index b6d2fcea6..17c376a5e 100644 --- a/crates/isobmff/tests/fuzz.rs +++ b/crates/isobmff/tests/it/fuzz.rs @@ -1,6 +1,6 @@ //! Deterministic mutation fuzzing: the demuxer must never panic on corrupted input. -mod common; +use crate::common; use filmcraft_isobmff::*; use std::io::Cursor; diff --git a/crates/isobmff/tests/it/main.rs b/crates/isobmff/tests/it/main.rs new file mode 100644 index 000000000..48e623e19 --- /dev/null +++ b/crates/isobmff/tests/it/main.rs @@ -0,0 +1,5 @@ +mod common; +mod fuzz; +mod oracle_demux; +mod oracle_mux; +mod roundtrip; diff --git a/crates/isobmff/tests/oracle_demux.rs b/crates/isobmff/tests/it/oracle_demux.rs similarity index 99% rename from crates/isobmff/tests/oracle_demux.rs rename to crates/isobmff/tests/it/oracle_demux.rs index 45ffe3b58..9e2cd0b28 100644 --- a/crates/isobmff/tests/oracle_demux.rs +++ b/crates/isobmff/tests/it/oracle_demux.rs @@ -1,6 +1,6 @@ //! Oracle tests: compare our demuxer's sample tables and codec parameters with ffprobe. -mod common; +use crate::common; use common::*; use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind, open}; diff --git a/crates/isobmff/tests/oracle_mux.rs b/crates/isobmff/tests/it/oracle_mux.rs similarity index 99% rename from crates/isobmff/tests/oracle_mux.rs rename to crates/isobmff/tests/it/oracle_mux.rs index 4b9144241..5383211f6 100644 --- a/crates/isobmff/tests/oracle_mux.rs +++ b/crates/isobmff/tests/it/oracle_mux.rs @@ -1,6 +1,6 @@ //! Muxer oracle tests: remux ffmpeg-made files with our writers and validate with ffmpeg/ffprobe. -mod common; +use crate::common; use common::*; use filmcraft_isobmff::*; diff --git a/crates/isobmff/tests/roundtrip.rs b/crates/isobmff/tests/it/roundtrip.rs similarity index 100% rename from crates/isobmff/tests/roundtrip.rs rename to crates/isobmff/tests/it/roundtrip.rs diff --git a/crates/matroska/Cargo.toml b/crates/matroska/Cargo.toml index 9f64162fb..1cebf9d31 100644 --- a/crates/matroska/Cargo.toml +++ b/crates/matroska/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-media = { workspace = true, features = ["matroska"] } [dev-dependencies] filmcraft-testkit = { workspace = true } diff --git a/crates/matroska/README.md b/crates/matroska/README.md index 40210ad0a..47b9cb623 100644 --- a/crates/matroska/README.md +++ b/crates/matroska/README.md @@ -1,115 +1,3 @@ # filmcraft-matroska -A clean-room Matroska (MKV) and WebM demuxer, plus a minimal muxer, written from the public -specifications: RFC 8794 (EBML), RFC 9559 (Matroska), the Matroska codec mappings and the WebM -container guidelines. Layer L0: it depends only on `std`, has no `unsafe`, and builds for -`wasm32-unknown-unknown`. No GPL/LGPL code (FFmpeg, libmatroska, mkvtoolnix) was consulted; -FFmpeg is used only as an external test oracle. - -## Demuxing - -```rust -use filmcraft_matroska::{Demuxer, TrackKind, open}; - -// Sample tables, same shape as filmcraft-isobmff (any ByteSource: &[u8], Vec, Arc<[u8]>, File…) -let file = std::fs::File::open("clip.mkv")?; -let mkv = open(&file)?; -let v = mkv.track_of_kind(TrackKind::Video).unwrap(); -let k = mkv.keyframe_before(v, 2_000_000_000).unwrap(); // sample index -let frame = mkv.read_sample(&file, v, k)?; - -// Streaming packets + seeking (from a ByteSource, any Read + Seek, or a byte slice) -let mut d = Demuxer::from_reader(std::io::BufReader::new(std::fs::File::open("clip.webm")?))?; -let sp = d.seek(v, 2_000_000_000)?; // lands on the keyframe with pts ≤ 2 s -while let Some(p) = d.next_packet()? { // file order, all tracks - // p.track, p.pts (ticks), p.pts_ns, p.duration, p.keyframe, p.data -} -``` - -- **Open** (`open`, `open_with(src, &OpenOptions { index, verify_crc })`): EBML header (DocType - `matroska`/`webm`), the first Segment, and its level-1 elements: SeekHead (followed for elements - after the clusters), Info, Tracks, Cues, Chapters, Tags, Attachments (listed; payload located by - offset/size, never read). With `index: true` (default) every Cluster is scanned — element and block - headers only, never frame payloads — to build per-track `Sample` tables (`offset`, `size`, `pts`, - `duration`, `keyframe`, cluster, block offset, lace index) and keyframe lists. With `index: false` - open stops at the first Cluster. -- **Timestamps.** Matroska ticks are `TimestampScale` ns (usually 1 ms); `Track::timebase` is that - as a reduced fraction of a second. `pts` is in ticks, `pts_ns` in exact nanoseconds. `CodecDelay` - is subtracted (RFC 9559), as FFmpeg does (Opus/Vorbis priming gives negative pts). Laced frames get - `block ts + i × (BlockDuration or DefaultDuration)/n`; if neither is known, later laces repeat the - block timestamp and have `duration == 0` (`Packet::lace` tells which). -- **Keyframes:** SimpleBlock keyframe flag; BlockGroup = keyframe iff it has no ReferenceBlock. -- **Lacing:** Xiph, fixed-size, EBML. **Unknown sizes:** Segment (live/streamed output) and - Clusters end at the next top-level element. **CRC-32:** optional verification of level-1 - elements and clusters; mismatches go to `MkvFile::crc_errors` (not fatal). **Void** skipped. -- **Damage:** undecodable/oversized elements trigger a byte scan for the next plausible level-1 - element (Cluster whose first child is Timestamp, or a sized metadata element); a truncated final - block is dropped. Problems are listed in `MkvFile::warnings`. -- **Seeking** (`Demuxer::seek(track, time_ns)`): uses the sample index when present; otherwise Cues - (refined by scanning the cued cluster for the exact keyframe); otherwise builds the index by - scanning all clusters. `Demuxer::keyframe_index(track)` returns the keyframe list. -- **Tracks:** number/UID/type/flags, `DefaultDuration`, name, language (+BCP 47), CodecID, - CodecPrivate, CodecName, `CodecDelay`, `SeekPreRoll`; `VideoInfo` (pixel/display size, crop, - interlacing, stereo, alpha mode, `pixel_aspect()`), `Colour` (matrix, range, transfer, primaries, - subsampling/siting, MaxCLL/MaxFALL, SMPTE 2086 mastering metadata); `AudioInfo` (rate, output - rate, channels, bit depth); `ContentEncoding`s. -- **Content encodings:** header stripping is undone in every packet / `read_sample`. zlib/bzlib/ - lzo compression and encryption are reported (`Track::frames_readable() == false`); packets are - then returned as stored and `read_sample` fails with `Unsupported`. - -### Codec mapping (`Codec`) - -| CodecID | `Codec` | payload | -|---|---|---| -| `V_MPEG4/ISO/AVC` | `Avc` | avcC (CodecPrivate) | -| `V_MPEGH/ISO/HEVC` | `Hevc` | hvcC | -| `V_VP8`, `V_VP9` | `Vp8`, `Vp9` | VP9 CodecPrivate features | -| `V_AV1` | `Av1` | av1C | -| `V_PRORES` | `ProRes` | FourCC; frames get the 8-byte `size`+`icpf` header re-added | -| `V_MJPEG`, `V_MS/VFW/FOURCC` (MJPG) | `Mjpeg` | | -| `V_MS/VFW/FOURCC` (other) | `VfwFourcc` | BITMAPINFOHEADER | -| `A_AAC`, legacy `A_AAC/MPEG{2,4}/…` | `Aac` | AudioSpecificConfig (synthesised for legacy IDs, incl. SBR) | -| `A_OPUS` | `Opus` | OpusHead | -| `A_VORBIS` | `Vorbis` | three headers split from Xiph-laced CodecPrivate | -| `A_FLAC` | `Flac` | `fLaC` + metadata blocks | -| `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE` | `Pcm` | bit depth from Audio | -| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2`, `A_MPEG/L1` | `Ac3`, `Eac3`, `Mp3`, `Mp2`, `Mp1` | | -| `S_TEXT/UTF8` | `SubRip` | | -| `S_TEXT/WEBVTT`, `D_WEBVTT/*` (WebM) | `WebVtt` | WebM blocks keep `id\nsettings\npayload` | -| `S_TEXT/ASS`, `S_TEXT/SSA` | `Ass` | script header | -| anything else | `Other(id)` | `Track::codec_private` | - -`Codec::name()` matches FFmpeg's `codec_name` for these. - -## Muxing (minimal) - -`MkvWriter::new(w: Write + Seek, Vec, MuxOptions)` → `write_frame(track, pts_ns, -keyframe, data, duration_ns)` → `finish()`. Writes the EBML header, Segment, SeekHead, Info (with -Duration), Tracks, Clusters of SimpleBlocks (new cluster at a video keyframe after -`cluster_duration_ns`), BlockGroups for explicit durations, optional audio lacing (Xiph, EBML, -fixed-size), and Cues (video keyframes, or the first frame of each track per cluster). No -CRC-32, Chapters, Tags, Attachments, or content encodings. - -## Tests - -- Unit: EBML VINTs (known/unknown sizes, IDs, signed lace deltas), typed values, CRC-32, all lacing - modes, codec mapping, a hand-built file (unknown-size Segment/Clusters, header stripping, - BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32 pass/fail, Cue-less - seek), and mux → demux round trips for every lacing mode. -- Oracle (`tests/oracle.rs`, skipped without ffmpeg/ffprobe; fixtures in `target/fixtures/matroska`): - H.264+AAC, VP9+Opus WebM, FLAC, Vorbis, SRT+ASS subtitles, WebVTT WebM, Cues at front, a - streamed (pipe) file without Cues, HEVC with HDR colour, ProRes+PCM, MJPEG+AC-3, AV1. Every packet - is compared with `ffprobe -show_packets` (stream, size, keyframe flag, pts, duration), plus codec - names, time bases, dimensions, audio params and duration; the sample index must equal the packet - stream. Seeks (indexed, Cues-only, and index-on-demand) must land on the latest keyframe ≤ target. - Our muxer's laced output (Xiph/EBML/fixed) is read back by ffprobe and compared the same way. - A damaged file must resync and deliver the packets after the damage; truncated files must not fail. - -## Limitations - -- Only the first Segment is read (no linked/chained segments, no ordered-chapter editions playback). -- zlib/bzlib/lzo compressed and encrypted tracks are detected but their frames are not decoded. -- BlockAdditions (e.g. VP9 alpha, WebVTT settings in Matroska) are skipped; `EncryptedBlock` ignored. -- `TrackTimestampScale` (deprecated) is ignored. -- Lace timestamps without any duration information repeat the block timestamp (FFmpeg derives them - from codec parsers). +A re-export of `craft_media::matroska`. Documentation: https://github.com/storytold/craft-libs/blob/main/crates/media/README.md diff --git a/crates/matroska/src/codec.rs b/crates/matroska/src/codec.rs deleted file mode 100644 index 8d83a974b..000000000 --- a/crates/matroska/src/codec.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Matroska CodecID → codec mapping (Matroska codec specifications, RFC 9559 §5.1.4.1.21 and the -//! codec mappings registry). - -/// Codec of a track, derived from `CodecID` and `CodecPrivate`. -/// -/// Configuration records are passed through as the raw bytes found in `CodecPrivate`, which for -/// the ISO codecs are exactly the ISOBMFF configuration boxes' payloads (avcC, hvcC, av1C, ASC…). -#[derive(Clone, Debug, PartialEq)] -pub enum Codec { - /// `V_MPEG4/ISO/AVC`; payload is an `AVCDecoderConfigurationRecord` (avcC). - Avc { avcc: Vec }, - /// `V_MPEGH/ISO/HEVC`; payload is an `HEVCDecoderConfigurationRecord` (hvcC). - Hevc { hvcc: Vec }, - /// `V_VP8`. - Vp8, - /// `V_VP9`; `CodecPrivate` (optional) holds VP9 codec feature metadata. - Vp9 { private: Vec }, - /// `V_AV1`; payload is an `AV1CodecConfigurationRecord` (av1C). - Av1 { av1c: Vec }, - /// `V_APV`; payload is an `APVDecoderConfigurationRecord` (apvC), if present. - Apv { apvc: Vec }, - /// `V_PRORES`; `fourcc` from `CodecPrivate` (e.g. `apcn`), if present. - ProRes { fourcc: Option<[u8; 4]> }, - /// `V_MJPEG`, or `V_MS/VFW/FOURCC` with an `MJPG` compression FourCC. - Mjpeg, - /// `V_MS/VFW/FOURCC` with any other FourCC; `bitmap_info_header` is the whole `CodecPrivate`. - VfwFourcc { fourcc: [u8; 4], bitmap_info_header: Vec }, - /// `A_AAC` (and the legacy `A_AAC/MPEG{2,4}/` IDs); `asc` is the - /// `AudioSpecificConfig` (synthesised for legacy IDs without `CodecPrivate`). - Aac { asc: Vec }, - /// `A_OPUS`; `head` is the `OpusHead` identification header. - Opus { head: Vec }, - /// `A_VORBIS`; the three Vorbis headers (identification, comment, setup), split from the - /// Xiph-laced `CodecPrivate`. - Vorbis { headers: Vec> }, - /// `A_FLAC`; `private` is `fLaC` + metadata blocks (STREAMINFO first). - Flac { private: Vec }, - /// `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE`. Bit depth comes from the Audio element. - Pcm { float: bool, big_endian: bool, bits: u16 }, - /// `A_AC3`. - Ac3, - /// `A_EAC3`. - Eac3, - /// `A_MPEG/L3`. - Mp3, - /// `A_MPEG/L2`. - Mp2, - /// `A_MPEG/L1`. - Mp1, - /// `S_TEXT/UTF8` (SubRip-style plain text). - SubRip, - /// `S_TEXT/WEBVTT`, or WebM's `D_WEBVTT/{SUBTITLES,CAPTIONS,DESCRIPTIONS,METADATA}`. - WebVtt, - /// `S_TEXT/ASS` / `S_TEXT/SSA`; `header` is the script header from `CodecPrivate`. - Ass { header: Vec, ssa: bool }, - /// Any other CodecID (kept verbatim along with `Track::codec_private`). - Other(String), -} - -impl Codec { - /// Short codec name, matching FFmpeg's `codec_name` where one exists (useful for logs/UI). - pub fn name(&self) -> String { - match self { - Codec::Avc { .. } => "h264".into(), - Codec::Hevc { .. } => "hevc".into(), - Codec::Vp8 => "vp8".into(), - Codec::Vp9 { .. } => "vp9".into(), - Codec::Av1 { .. } => "av1".into(), - Codec::Apv { .. } => "apv".into(), - Codec::ProRes { .. } => "prores".into(), - Codec::Mjpeg => "mjpeg".into(), - Codec::VfwFourcc { fourcc, .. } => String::from_utf8_lossy(fourcc).trim().to_ascii_lowercase(), - Codec::Aac { .. } => "aac".into(), - Codec::Opus { .. } => "opus".into(), - Codec::Vorbis { .. } => "vorbis".into(), - Codec::Flac { .. } => "flac".into(), - Codec::Pcm { float, big_endian, bits } => { - let k = if *float { - 'f' - } else if *bits == 8 { - 'u' - } else { - 's' - }; - if *bits == 8 { format!("pcm_{k}8") } else { format!("pcm_{k}{bits}{}", if *big_endian { "be" } else { "le" }) } - } - Codec::Ac3 => "ac3".into(), - Codec::Eac3 => "eac3".into(), - Codec::Mp3 => "mp3".into(), - Codec::Mp2 => "mp2".into(), - Codec::Mp1 => "mp1".into(), - Codec::SubRip => "subrip".into(), - Codec::WebVtt => "webvtt".into(), - Codec::Ass { ssa, .. } => if *ssa { "ssa" } else { "ass" }.into(), - Codec::Other(id) => id.clone(), - } - } -} - -const AAC_RATES: [u32; 13] = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350]; - -/// Build a 2-byte (or 5-byte with SBR extension) AudioSpecificConfig for legacy `A_AAC/...` IDs. -fn synth_asc(object_type: u8, rate: f64, out_rate: Option, channels: u64, sbr: bool) -> Vec { - let rate_index = |r: f64| AAC_RATES.iter().position(|&x| x as f64 == r).unwrap_or(4) as u8; - let ri = rate_index(rate); - let ch = channels.min(7) as u8; - let mut v = vec![(object_type << 3) | (ri >> 1), ((ri & 1) << 7) | (ch << 3)]; - if sbr { - // backward-compatible explicit signalling: sync 0x2b7, SBR object type 5, flag, ext rate index - let ext = rate_index(out_rate.unwrap_or(rate * 2.0)); - v.push(0x56); - v.push(0xE5); - v.push(0x80 | (ext << 3)); - } - v -} - -/// Split a Xiph-laced `CodecPrivate` (Vorbis/Theora headers). -fn split_xiph(p: &[u8]) -> Vec> { - let Some(&n) = p.first() else { return Vec::new() }; - let mut pos = 1; - let mut sizes = Vec::new(); - for _ in 0..n { - let mut s = 0usize; - loop { - let Some(&b) = p.get(pos) else { return Vec::new() }; - pos += 1; - s += b as usize; - if b != 255 { - break; - } - } - sizes.push(s); - } - let mut out = Vec::new(); - for s in sizes { - let Some(h) = p.get(pos..pos + s) else { return Vec::new() }; - out.push(h.to_vec()); - pos += s; - } - out.push(p[pos.min(p.len())..].to_vec()); - out -} - -/// Map a CodecID + CodecPrivate (+ audio params for legacy AAC / PCM) to a [`Codec`]. -pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInfo>) -> Codec { - let p = private.to_vec(); - match id { - "V_MPEG4/ISO/AVC" => Codec::Avc { avcc: p }, - "V_MPEGH/ISO/HEVC" => Codec::Hevc { hvcc: p }, - "V_VP8" => Codec::Vp8, - "V_VP9" => Codec::Vp9 { private: p }, - "V_AV1" => Codec::Av1 { av1c: p }, - "V_APV" => Codec::Apv { apvc: p }, - "V_PRORES" => Codec::ProRes { fourcc: private.get(..4).and_then(|f| f.try_into().ok()) }, - "V_MJPEG" => Codec::Mjpeg, - "V_MS/VFW/FOURCC" => { - let fourcc: [u8; 4] = private.get(16..20).and_then(|f| f.try_into().ok()).unwrap_or(*b" "); - match &fourcc { - b"MJPG" | b"mjpg" | b"AVRn" | b"AVDJ" => Codec::Mjpeg, - b"H264" | b"h264" | b"avc1" | b"X264" | b"x264" => Codec::Avc { avcc: Vec::new() }, - b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => Codec::ProRes { fourcc: Some(fourcc) }, - b"apv1" | b"APV1" => Codec::Apv { apvc: Vec::new() }, - _ => Codec::VfwFourcc { fourcc, bitmap_info_header: p }, - } - } - "A_AAC" => Codec::Aac { asc: p }, - "A_OPUS" => Codec::Opus { head: p }, - "A_VORBIS" => Codec::Vorbis { headers: split_xiph(private) }, - "A_FLAC" => Codec::Flac { private: p }, - "A_PCM/INT/LIT" | "A_PCM/INT/BIG" | "A_PCM/FLOAT/IEEE" => { - let bits = audio.and_then(|a| a.bit_depth).unwrap_or(if id == "A_PCM/FLOAT/IEEE" { 32 } else { 16 }) as u16; - Codec::Pcm { float: id == "A_PCM/FLOAT/IEEE", big_endian: id == "A_PCM/INT/BIG", bits } - } - "A_AC3" | "A_AC3/BSID9" | "A_AC3/BSID10" => Codec::Ac3, - "A_EAC3" => Codec::Eac3, - "A_MPEG/L3" => Codec::Mp3, - "A_MPEG/L2" => Codec::Mp2, - "A_MPEG/L1" => Codec::Mp1, - "S_TEXT/UTF8" => Codec::SubRip, - "S_TEXT/WEBVTT" | "D_WEBVTT/SUBTITLES" | "D_WEBVTT/CAPTIONS" | "D_WEBVTT/DESCRIPTIONS" | "D_WEBVTT/METADATA" => Codec::WebVtt, - "S_TEXT/ASS" | "S_ASS" => Codec::Ass { header: p, ssa: false }, - "S_TEXT/SSA" | "S_SSA" => Codec::Ass { header: p, ssa: true }, - _ if id.starts_with("A_AAC/") => { - if !private.is_empty() { - return Codec::Aac { asc: p }; - } - let (rate, out_rate, ch) = audio.map_or((48000.0, None, 2), |a| (a.sampling_frequency, a.output_sampling_frequency, a.channels)); - let object_type = if id.ends_with("/MAIN") { - 1 - } else if id.ends_with("/SSR") { - 3 - } else if id.ends_with("/LTP") { - 4 - } else { - 2 - }; - let sbr = id.ends_with("/SBR"); - Codec::Aac { asc: synth_asc(object_type, rate, out_rate, ch, sbr) } - } - _ => Codec::Other(id.to_string()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mapping() { - assert_eq!(map_codec("V_MPEG4/ISO/AVC", &[1, 2], None), Codec::Avc { avcc: vec![1, 2] }); - assert_eq!(map_codec("V_APV", &[1, 2, 3], None), Codec::Apv { apvc: vec![1, 2, 3] }); - assert_eq!(map_codec("V_APV", &[], None).name(), "apv"); - assert_eq!(map_codec("V_PRORES", b"apcn", None), Codec::ProRes { fourcc: Some(*b"apcn") }); - let mut bih = vec![0u8; 40]; - bih[16..20].copy_from_slice(b"MJPG"); - assert_eq!(map_codec("V_MS/VFW/FOURCC", &bih, None), Codec::Mjpeg); - bih[16..20].copy_from_slice(b"FFV1"); - assert!(matches!(map_codec("V_MS/VFW/FOURCC", &bih, None), Codec::VfwFourcc { fourcc, .. } if &fourcc == b"FFV1")); - let a = crate::AudioInfo { sampling_frequency: 44100.0, channels: 2, bit_depth: Some(24), output_sampling_frequency: None }; - assert_eq!(map_codec("A_PCM/INT/LIT", &[], Some(&a)).name(), "pcm_s24le"); - assert_eq!(map_codec("A_PCM/FLOAT/IEEE", &[], Some(&a)), Codec::Pcm { float: true, big_endian: false, bits: 24 }); - // legacy AAC LC 44.1k stereo → 0x12 0x10 - assert_eq!(map_codec("A_AAC/MPEG4/LC", &[], Some(&a)), Codec::Aac { asc: vec![0x12, 0x10] }); - assert_eq!(map_codec("S_TEXT/UTF8", &[], None).name(), "subrip"); - assert_eq!(map_codec("X_WHATEVER", &[], None), Codec::Other("X_WHATEVER".into())); - } - - #[test] - fn xiph_split() { - let mut p = vec![2u8, 3, 2]; - p.extend_from_slice(&[1, 1, 1, 2, 2, 3, 3, 3, 3]); - let h = split_xiph(&p); - assert_eq!(h, vec![vec![1, 1, 1], vec![2, 2], vec![3, 3, 3, 3]]); - } -} diff --git a/crates/matroska/src/demux.rs b/crates/matroska/src/demux.rs deleted file mode 100644 index 4278a1b83..000000000 --- a/crates/matroska/src/demux.rs +++ /dev/null @@ -1,1219 +0,0 @@ -//! Matroska / WebM demuxer: EBML header, Segment walk, cluster scanning, packet reading, seeking. - -use std::collections::{HashSet, VecDeque}; - -use crate::ebml::{self, Children, Lacing, Size, parse_lacing, read_id, read_size}; -use crate::error::{Error, Result}; -use crate::ids::*; -use crate::meta::*; -use crate::source::{ByteSource, ReadSeekSource}; -use crate::track::{Sample, Track, TrackKind, gcd}; - -const WIN: usize = 64 * 1024; -/// Upper bound for level-1 metadata elements read into memory (Info, Tracks, Cues, Tags…). -const MAX_META: u64 = 256 << 20; - -/// Options for [`open_with`] / [`Demuxer::with_options`]. -#[derive(Clone, Debug)] -pub struct OpenOptions { - /// Scan every cluster at open to build per-track sample tables and keyframe indices - /// (reads only element and block headers, never frame payloads). Default `true`. - /// - /// With `false`, open stops at the first Cluster and uses the SeekHead to find Cues, Tags, - /// Chapters… placed after the clusters; [`Demuxer::seek`] then uses Cues (or builds the index - /// on first use if there are none). - pub index: bool, - /// Verify CRC-32 elements of level-1 elements (and clusters while indexing). Mismatches are - /// recorded in [`MkvFile::crc_errors`]; parsing continues. Default `false`. - pub verify_crc: bool, -} - -impl Default for OpenOptions { - fn default() -> Self { - Self { index: true, verify_crc: false } - } -} - -/// A parsed Matroska / WebM file (first Segment). -#[derive(Clone, Debug)] -pub struct MkvFile { - /// EBML `DocType`: `matroska` or `webm`. - pub doc_type: String, - pub doc_type_version: u64, - pub doc_type_read_version: u64, - /// Absolute offset of the Segment's data (base for SeekHead and Cues positions). - pub segment_data_start: u64, - /// Absolute end of the Segment (file end for unknown-size / live segments). - pub segment_end: u64, - /// Segment size field was "unknown" (live / streamed output). - pub live: bool, - pub info: SegmentInfo, - pub tracks: Vec, - pub seek_head: Vec, - /// Cue points, sorted by time. - pub cues: Vec, - /// Clusters in file order (complete only when [`MkvFile::indexed`]). - pub clusters: Vec, - pub chapters: Vec, - pub tags: Vec, - pub attachments: Vec, - /// Absolute offset of the first Cluster. - pub first_cluster: Option, - /// Sample tables have been built by scanning all clusters. - pub indexed: bool, - /// Number of CRC-32 elements verified (with `verify_crc`). - pub crc_checked: u32, - /// Absolute offsets of elements whose CRC-32 did not match. - pub crc_errors: Vec, - /// Non-fatal problems met while parsing (damage, resyncs, truncation). - pub warnings: Vec, -} - -/// One demuxed frame. -#[derive(Clone, Debug, PartialEq)] -pub struct Packet { - /// Index into [`MkvFile::tracks`]. - pub track: usize, - /// Matroska `TrackNumber`. - pub track_number: u64, - /// Presentation timestamp in timestamp ticks (the track timebase, [`Track::timebase`]). - pub pts: i64, - /// Presentation timestamp in nanoseconds. - pub pts_ns: i64, - /// Duration in ticks (0 if unknown). - pub duration: u64, - /// Duration in nanoseconds (0 if unknown). - pub duration_ns: u64, - pub keyframe: bool, - pub discardable: bool, - pub invisible: bool, - /// `DiscardPadding` (ns) from the enclosing BlockGroup. - pub discard_padding_ns: Option, - /// Absolute offset of the stored frame data. - pub offset: u64, - /// Index of the frame within a laced block (0 when not laced). Laced frames after the first - /// only have exact timestamps when the block or track carries a duration; otherwise they - /// repeat the block timestamp and `duration` is 0. - pub lace: u16, - /// Frame bytes with header stripping undone. Frames of tracks with other content encodings - /// (zlib, encryption) are returned as stored; see [`Track::frames_readable`]. - pub data: Vec, -} - -/// Result of [`Demuxer::seek`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SeekPoint { - pub track: usize, - /// Keyframe timestamp in ticks and ns. - pub pts: i64, - pub pts_ns: i64, - /// Sample index of the keyframe when the file is indexed. - pub sample: Option, - pub cluster_offset: u64, - pub block_offset: u64, -} - -/// A keyframe index entry ([`Demuxer::keyframe_index`]). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Keyframe { - pub sample: usize, - pub pts: i64, - pub pts_ns: i64, - pub block_offset: u64, -} - -// --------------------------------------------------------------------------------------------- -// I/O with a small read window -// --------------------------------------------------------------------------------------------- - -#[derive(Default, Clone, Debug)] -struct Cache { - buf: Vec, - start: u64, -} - -struct Io<'a, S: ByteSource + ?Sized> { - src: &'a S, - len: u64, - cache: &'a mut Cache, -} - -#[derive(Clone, Copy, Debug)] -struct Hdr { - id: u32, - pos: u64, - size: Size, - hlen: usize, -} - -impl Hdr { - fn data_start(&self) -> u64 { - self.pos + self.hlen as u64 - } - /// End offset, clamped to `limit`; unknown sizes run to `limit`. - fn end(&self, limit: u64) -> u64 { - match self.size { - Size::Known(n) => self.data_start().saturating_add(n).min(limit), - Size::Unknown => limit, - } - } - fn overruns(&self, limit: u64) -> bool { - matches!(self.size, Size::Known(n) if self.data_start().saturating_add(n) > limit) - } -} - -impl<'a, S: ByteSource + ?Sized> Io<'a, S> { - fn new(src: &'a S, cache: &'a mut Cache) -> Self { - let len = src.len(); - Self { src, len, cache } - } - - /// Up to `n` (≤ WIN) bytes at `pos` (fewer at EOF). - fn peek(&mut self, pos: u64, n: usize) -> Result<&[u8]> { - let n = n.min(WIN); - if pos >= self.len { - return Ok(&[]); - } - let want_end = (pos + n as u64).min(self.len); - let c = &*self.cache; - if !(pos >= c.start && want_end <= c.start + c.buf.len() as u64) { - let fill = ((self.len - pos) as usize).min(WIN); - let mut buf = std::mem::take(&mut self.cache.buf); - buf.resize(fill, 0); - self.src.read_at(pos, &mut buf)?; - self.cache.buf = buf; - self.cache.start = pos; - } - let o = (pos - self.cache.start) as usize; - Ok(&self.cache.buf[o..o + (want_end - pos) as usize]) - } - - /// Exactly `n` bytes at `pos`. - fn read_vec(&mut self, pos: u64, n: u64) -> Result> { - if pos.checked_add(n).is_none_or(|e| e > self.len) { - return Err(Error::Io(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of file"))); - } - if n as usize <= WIN / 2 { - return Ok(self.peek(pos, n as usize)?.to_vec()); - } - let mut v = vec![0u8; n as usize]; - self.src.read_at(pos, &mut v)?; - Ok(v) - } - - /// Element header at `pos` (`None` if undecodable or past `limit`). - fn header(&mut self, pos: u64, limit: u64) -> Result> { - if pos >= limit { - return Ok(None); - } - let b = self.peek(pos, 12)?; - let Some((id, il)) = read_id(b) else { return Ok(None) }; - let Some((size, sl)) = read_size(&b[il..]) else { return Ok(None) }; - let hlen = il + sl; - if pos + hlen as u64 > limit { - return Ok(None); - } - Ok(Some(Hdr { id, pos, size, hlen })) - } - - /// Scan forward from `from` for a plausible level-1 element (Cluster preferred), up to `limit`. - fn resync(&mut self, from: u64, limit: u64) -> Result> { - let mut pos = from; - while pos + 4 <= limit { - let chunk = self.peek(pos, WIN)?.to_vec(); - if chunk.len() < 4 { - return Ok(None); - } - for i in 0..chunk.len() - 3 { - if chunk[i] & 0xF0 != 0x10 { - continue; - } - let id = u32::from_be_bytes([chunk[i], chunk[i + 1], chunk[i + 2], chunk[i + 3]]); - if !is_top_level(id) || id == EBML || id == SEGMENT { - continue; - } - let p = pos + i as u64; - if self.plausible(p, id, limit)? { - return Ok(Some(p)); - } - } - pos += (chunk.len() - 3) as u64; - } - Ok(None) - } - - fn plausible(&mut self, p: u64, id: u32, limit: u64) -> Result { - let Some(h) = self.header(p, limit)? else { return Ok(false) }; - if h.id != id || h.overruns(limit) { - return Ok(false); - } - if h.size == Size::Unknown && id != CLUSTER { - return Ok(false); - } - // first child must decode and fit; clusters must start with Timestamp (after CRC-32/Void) - let mut cp = h.data_start(); - let end = h.end(limit); - for _ in 0..3 { - let Some(c) = self.header(cp, end)? else { return Ok(false) }; - if c.overruns(end) { - return Ok(false); - } - if id != CLUSTER { - return Ok(true); - } - match c.id { - TIMESTAMP => return Ok(true), - CRC32 | VOID => cp = c.end(end), - _ => return Ok(false), - } - } - Ok(false) - } - - /// End of an unknown-size master element: the first child that is a top-level ID or undecodable. - fn unknown_end(&mut self, data_start: u64, limit: u64) -> Result { - let mut pos = data_start; - while pos < limit { - let Some(h) = self.header(pos, limit)? else { return Ok(pos) }; - if is_top_level(h.id) || h.size == Size::Unknown { - return Ok(pos); - } - pos = h.end(limit); - } - Ok(limit) - } -} - -// --------------------------------------------------------------------------------------------- -// Blocks -// --------------------------------------------------------------------------------------------- - -#[derive(Clone, Debug)] -struct BlockRef { - track: usize, - block_offset: u64, - /// Timestamp in ticks (cluster + relative). - ts: i64, - /// Absolute (offset, size) of each frame. - frames: Vec<(u64, u32)>, - block_duration: Option, - keyframe: bool, - discardable: bool, - invisible: bool, - discard_padding: Option, - /// (offset, size) of the `BlockAdditional` with `BlockAddID` 1 (WebM: the VP8/VP9 alpha - /// layer), kept for unlaced blocks only. - addition: Option<(u64, u32)>, -} - -#[derive(Clone, Copy, Debug)] -struct FrameRef { - track: usize, - offset: u64, - size: u32, - pts: i64, - pts_ns: i64, - duration: u64, - duration_ns: u64, - keyframe: bool, - discardable: bool, - invisible: bool, - discard_padding: Option, - block_offset: u64, - lace: u16, - addition: Option<(u64, u32)>, -} - -fn track_lookup(tracks: &[Track]) -> impl Fn(u64) -> Option + '_ { - move |n| tracks.iter().position(|t| t.number == n) -} - -/// Parse the Block/SimpleBlock header at `data_start` (`len` bytes). -/// Returns (track index, relative ts, flags, frames) or `None` for unknown tracks / damage. -fn parse_block( - io: &mut Io<'_, S>, - data_start: u64, - len: u64, - lookup: &dyn Fn(u64) -> Option, -) -> Result)>> { - let head = io.peek(data_start, 16.min(len as usize))?; - let Some((tn, tl, _)) = ebml::read_vint(head) else { return Ok(None) }; - if head.len() < tl + 3 { - return Ok(None); - } - let rel = i16::from_be_bytes([head[tl], head[tl + 1]]); - let flags = head[tl + 2]; - let Some(track) = lookup(tn) else { return Ok(None) }; - let hdr = (tl + 3) as u64; - let lacing = Lacing::from_flags(flags); - let frames = if lacing == Lacing::None { - vec![(data_start + hdr, (len - hdr) as u32)] - } else { - let body = io.read_vec(data_start + hdr, len - hdr)?; - match parse_lacing(lacing, &body) { - Ok(f) => f.into_iter().map(|(o, l)| (data_start + hdr + o as u64, l as u32)).collect(), - Err(_) => return Ok(None), - } - }; - Ok(Some((track, rel, flags, frames))) -} - -/// The `BlockAdditional` with `BlockAddID` 1 (the default ID) in a `BlockAdditions` element: -/// (offset, size) of its data. Other IDs, empty additions and damaged children are skipped. -fn block_addition(io: &mut Io<'_, S>, start: u64, end: u64) -> Result> { - let mut pos = start; - while pos < end { - let Some(more) = io.header(pos, end)? else { break }; - let me = more.end(end); - if more.id == BLOCK_MORE { - let (mut id, mut data) = (1u64, None); - let mut p = more.data_start(); - while p < me { - let Some(c) = io.header(p, me)? else { break }; - let ce = c.end(me); - let len = ce.saturating_sub(c.data_start()); - match c.id { - BLOCK_ADD_ID => id = ebml::uint(io.peek(c.data_start(), len.min(8) as usize)?), - BLOCK_ADDITIONAL => data = u32::try_from(len).ok().filter(|&n| n > 0).map(|n| (c.data_start(), n)), - _ => {} - } - p = ce; - } - if id == 1 && data.is_some() { - return Ok(data); - } - } - pos = me; - } - Ok(None) -} - -/// Parse one SimpleBlock or BlockGroup element. -fn parse_block_elem( - io: &mut Io<'_, S>, - h: &Hdr, - end: u64, - cluster_ts: i64, - lookup: &dyn Fn(u64) -> Option, -) -> Result> { - let ds = h.data_start(); - match h.id { - SIMPLE_BLOCK => { - let Some((track, rel, flags, frames)) = parse_block(io, ds, end - ds, lookup)? else { return Ok(None) }; - Ok(Some(BlockRef { - track, - block_offset: h.pos, - ts: cluster_ts + rel as i64, - frames, - block_duration: None, - keyframe: flags & 0x80 != 0, - discardable: flags & 0x01 != 0, - invisible: flags & 0x08 != 0, - discard_padding: None, - addition: None, - })) - } - BLOCK_GROUP => { - let mut pos = ds; - let mut block = None; - let (mut dur, mut has_ref, mut pad, mut addition) = (None, false, None, None); - while pos < end { - let Some(c) = io.header(pos, end)? else { break }; - let ce = c.end(end); - match c.id { - BLOCK => block = parse_block(io, c.data_start(), ce - c.data_start(), lookup)?, - BLOCK_ADDITIONS => addition = block_addition(io, c.data_start(), ce)?, - BLOCK_DURATION => dur = Some(ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)), - REFERENCE_BLOCK => has_ref = true, - DISCARD_PADDING => pad = Some(ebml::int(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)), - _ => {} - } - pos = ce; - } - let Some((track, rel, flags, frames)) = block else { return Ok(None) }; - // with lacing, an addition belongs to the block, not to one of its frames - let addition = addition.filter(|_| frames.len() == 1); - Ok(Some(BlockRef { - track, - block_offset: h.pos, - ts: cluster_ts + rel as i64, - frames, - block_duration: dur, - keyframe: !has_ref, - discardable: false, - invisible: flags & 0x08 != 0, - discard_padding: pad, - addition, - })) - } - _ => Ok(None), - } -} - -/// Expand a block into frames with per-lace timestamps. -fn expand(b: &BlockRef, t: &Track, scale: u64, skip: u16, out: &mut impl Extend) { - let n = b.frames.len() as u64; - // total block duration in ns - let block_ns: Option = match (b.block_duration, t.default_duration_ns) { - (Some(d), _) => Some(d as u128 * scale as u128), - (None, Some(dd)) => Some(dd as u128 * n as u128), - _ => None, - }; - let scale_i = scale as i128; - // round half away from zero - let to_ticks = |ns: i128| -> i64 { (if ns >= 0 { (ns + scale_i / 2) / scale_i } else { (ns - scale_i / 2) / scale_i }) as i64 }; - // CodecDelay is subtracted from every timestamp (RFC 9559 §5.1.4.1.25); in ticks it is rounded - // once, so tick timestamps stay on the block grid. - let delay_ns = t.codec_delay_ns as i128; - let delay_ticks = to_ticks(delay_ns); - let base_ns = b.ts as i128 * scale_i; - out.extend(b.frames.iter().enumerate().skip(skip as usize).map(|(i, &(offset, size))| { - let (start, end) = match block_ns { - Some(total) => ((total * i as u128 / n as u128) as i128, Some((total * (i as u128 + 1) / n as u128) as i128)), - None => (0, None), - }; - let pts_ns = base_ns + start - delay_ns; - let pts = to_ticks(base_ns + start) - delay_ticks; - let duration = end.map_or(0, |e| (to_ticks(e) - to_ticks(start)).max(0) as u64); - FrameRef { - track: b.track, - offset, - size, - pts, - pts_ns: pts_ns as i64, - duration, - duration_ns: end.map_or(0, |e| (e - start).max(0) as u64), - keyframe: b.keyframe, - discardable: b.discardable, - invisible: b.invisible, - discard_padding: b.discard_padding, - block_offset: b.block_offset, - lace: i as u16, - addition: b.addition, - } - })); -} - -/// Scan a whole cluster (headers only), returning its info and blocks. -fn scan_cluster( - io: &mut Io<'_, S>, - h: &Hdr, - limit: u64, - tracks: &[Track], - warnings: &mut Vec, -) -> Result<(ClusterInfo, Vec)> { - let lookup = track_lookup(tracks); - let ds = h.data_start(); - let known_end = match h.size { - Size::Known(_) => Some(h.end(limit)), - Size::Unknown => None, - }; - let lim = known_end.unwrap_or(limit); - let mut ts: i64 = 0; - let mut blocks = Vec::new(); - let mut pos = ds; - let end = loop { - if pos >= lim { - break lim; - } - let Some(c) = io.header(pos, lim)? else { - warnings.push(format!("damaged cluster at {}: undecodable element at {pos}", h.pos)); - break io.resync(pos + 1, limit)?.unwrap_or(limit); - }; - if known_end.is_none() && is_top_level(c.id) { - break pos; - } - if c.size == Size::Unknown || c.overruns(lim) { - warnings.push(format!("damaged cluster at {}: element 0x{:X} at {pos} overruns", h.pos, c.id)); - if c.overruns(lim) && matches!(c.id, SIMPLE_BLOCK | BLOCK_GROUP) && io.len <= c.data_start() + c.size.known().unwrap_or(0) { - break lim; // truncated file: drop the partial block - } - break io.resync(pos + 1, limit)?.unwrap_or(limit); - } - let ce = c.end(lim); - match c.id { - TIMESTAMP => ts = ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?) as i64, - SIMPLE_BLOCK | BLOCK_GROUP => { - if let Some(b) = parse_block_elem(io, &c, ce, ts, &lookup)? { - blocks.push(b); - } - } - _ => {} - } - pos = ce; - }; - Ok((ClusterInfo { offset: h.pos, data_start: ds, end, timestamp: ts.max(0) as u64 }, blocks)) -} - -// --------------------------------------------------------------------------------------------- -// Open -// --------------------------------------------------------------------------------------------- - -/// Open a Matroska/WebM file with default options (full sample index). -pub fn open(src: S) -> Result { - open_with(src, &OpenOptions::default()) -} - -/// Open with explicit options. -pub fn open_with(src: S, opts: &OpenOptions) -> Result { - let mut cache = Cache::default(); - open_impl(&src, opts, &mut cache) -} - -fn open_impl(src: &S, opts: &OpenOptions, cache: &mut Cache) -> Result { - let mut io = Io::new(src, cache); - let len = io.len; - // EBML header (tolerate leading junk in the first 64 KiB) - let start = { - let b = io.peek(0, WIN)?; - let magic = [0x1A, 0x45, 0xDF, 0xA3]; - b.windows(4).position(|w| w == magic).ok_or_else(|| Error::NotMatroska("no EBML header".into()))? as u64 - }; - let eh = io.header(start, len)?.ok_or_else(|| Error::NotMatroska("bad EBML header".into()))?; - let hdr_end = eh.end(len); - let hdr = io.read_vec(eh.data_start(), hdr_end - eh.data_start())?; - let (mut doc_type, mut dtv, mut dtrv) = ("matroska".to_string(), 1, 1); - for e in Children::new(&hdr) { - match e.id { - DOC_TYPE => doc_type = ebml::string(e.data), - DOC_TYPE_VERSION => dtv = ebml::uint(e.data), - DOC_TYPE_READ_VERSION => dtrv = ebml::uint(e.data), - EBML_MAX_ID_LENGTH if ebml::uint(e.data) > 4 => return Err(Error::Unsupported("EBMLMaxIDLength > 4".into())), - EBML_MAX_SIZE_LENGTH if ebml::uint(e.data) > 8 => return Err(Error::Unsupported("EBMLMaxSizeLength > 8".into())), - _ => {} - } - } - if doc_type != "matroska" && doc_type != "webm" { - return Err(Error::NotMatroska(format!("DocType {doc_type:?}"))); - } - // Segment - let mut pos = hdr_end; - let seg = loop { - let h = io.header(pos, len)?.ok_or_else(|| Error::Invalid("no Segment".into()))?; - if h.id == SEGMENT { - break h; - } - if h.size == Size::Unknown { - return Err(Error::Invalid("unknown-size element before Segment".into())); - } - pos = h.end(len); - }; - let mut file = MkvFile { - doc_type, - doc_type_version: dtv, - doc_type_read_version: dtrv, - segment_data_start: seg.data_start(), - segment_end: seg.end(len), - live: seg.size == Size::Unknown, - info: SegmentInfo::default(), - tracks: Vec::new(), - seek_head: Vec::new(), - cues: Vec::new(), - clusters: Vec::new(), - chapters: Vec::new(), - tags: Vec::new(), - attachments: Vec::new(), - first_cluster: None, - indexed: false, - crc_checked: 0, - crc_errors: Vec::new(), - warnings: Vec::new(), - }; - if seg.overruns(len) { - file.warnings.push("Segment extends past end of file (truncated)".into()); - } - let mut w = Walker { opts, parsed: HashSet::new(), blocks_seen: false }; - w.walk(&mut io, &mut file)?; - finish_tracks(&mut file); - Ok(file) -} - -struct Walker<'o> { - opts: &'o OpenOptions, - parsed: HashSet, - blocks_seen: bool, -} - -impl Walker<'_> { - fn walk(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile) -> Result<()> { - let seg_end = file.segment_end; - let mut pos = file.segment_data_start; - while pos < seg_end { - let Some(h) = io.header(pos, seg_end)? else { - match io.resync(pos + 1, seg_end)? { - Some(p) => { - file.warnings.push(format!("undecodable element at {pos}; resynced at {p}")); - pos = p; - continue; - } - None => break, - } - }; - match h.id { - CLUSTER => { - if file.first_cluster.is_none() { - file.first_cluster = Some(h.pos); - // make sure Info/Tracks are known before interpreting blocks - self.follow_seek_head(io, file, &[INFO, TRACKS])?; - if !self.opts.index { - break; - } - } - let (ci, blocks) = scan_cluster(io, &h, seg_end, &file.tracks, &mut file.warnings)?; - if self.opts.verify_crc && h.size != Size::Unknown { - let data = io.read_vec(h.data_start(), ci.end - h.data_start())?; - self.check_crc(file, h.pos, &data); - } - add_blocks(file, file.clusters.len() as u32, &blocks); - self.blocks_seen = true; - file.clusters.push(ci); - pos = ci.end.max(h.data_start()); - } - SEEK_HEAD | INFO | TRACKS | CUES | CHAPTERS | TAGS | ATTACHMENTS => { - pos = self.level1(io, file, &h, seg_end)?; - } - EBML | SEGMENT => break, // chained segment: only the first one is read - _ => { - if h.size == Size::Unknown { - pos = io.unknown_end(h.data_start(), seg_end)?; - } else if h.overruns(seg_end) && h.id != VOID { - match io.resync(pos + 1, seg_end)? { - Some(p) => { - file.warnings.push(format!("oversized element 0x{:X} at {pos}; resynced at {p}", h.id)); - pos = p; - } - None => break, - } - } else { - pos = h.end(seg_end); - } - } - } - } - self.follow_seek_head(io, file, &[INFO, TRACKS, CUES, CHAPTERS, TAGS, ATTACHMENTS, SEEK_HEAD])?; - // follow a second-level SeekHead reached through the first one - self.follow_seek_head(io, file, &[CUES, CHAPTERS, TAGS, ATTACHMENTS])?; - file.cues.sort_by_key(|c| c.time); - file.indexed = self.opts.index; - Ok(()) - } - - fn follow_seek_head(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, ids: &[u32]) -> Result<()> { - let entries = file.seek_head.clone(); - for e in entries { - if !ids.contains(&e.id) { - continue; - } - let Some(abs) = file.segment_data_start.checked_add(e.position) else { continue }; - if self.parsed.contains(&abs) || abs >= file.segment_end { - continue; - } - match io.header(abs, file.segment_end)? { - Some(h) if h.id == e.id => { - self.level1(io, file, &h, file.segment_end)?; - } - _ => file.warnings.push(format!("SeekHead entry 0x{:X} -> {abs} does not point at that element", e.id)), - } - } - Ok(()) - } - - fn check_crc(&self, file: &mut MkvFile, pos: u64, data: &[u8]) { - if let Some(ok) = ebml::verify_crc(data) { - file.crc_checked += 1; - if !ok { - file.crc_errors.push(pos); - file.warnings.push(format!("CRC-32 mismatch in element at {pos}")); - } - } - } - - /// Parse a level-1 metadata element; returns its end. - fn level1(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, h: &Hdr, limit: u64) -> Result { - let end = if h.size == Size::Unknown { io.unknown_end(h.data_start(), limit)? } else { h.end(limit) }; - if h.overruns(limit) { - file.warnings.push(format!("element 0x{:X} at {} truncated", h.id, h.pos)); - } - if !self.parsed.insert(h.pos) { - return Ok(end); - } - if h.id == ATTACHMENTS { - self.attachments(io, file, h.data_start(), end)?; - return Ok(end); - } - let n = end - h.data_start(); - if n > MAX_META { - file.warnings.push(format!("element 0x{:X} at {} too large ({n} bytes); skipped", h.id, h.pos)); - return Ok(end); - } - let data = io.read_vec(h.data_start(), n)?; - if self.opts.verify_crc { - self.check_crc(file, h.pos, &data); - } - match h.id { - SEEK_HEAD => file.seek_head.extend(parse_seekhead(&data)), - INFO => file.info = parse_info(&data), - TRACKS if file.tracks.is_empty() => { - file.tracks = parse_tracks(&data, file.info.timestamp_scale); - } - CUES => parse_cues(&data, &mut file.cues), - CHAPTERS => parse_chapters(&data, &mut file.chapters), - TAGS => parse_tags(&data, &mut file.tags), - _ => {} - } - Ok(end) - } - - fn attachments(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, start: u64, end: u64) -> Result<()> { - let mut pos = start; - while pos < end { - let Some(h) = io.header(pos, end)? else { break }; - let he = h.end(end); - if h.id == ATTACHED_FILE { - let mut a = Attachment::default(); - let mut p = h.data_start(); - while p < he { - let Some(c) = io.header(p, he)? else { break }; - let ce = c.end(he); - let n = ce - c.data_start(); - if c.id == FILE_DATA { - a.data_offset = c.data_start(); - a.data_size = n; - } else if n <= 4096 { - let d = io.peek(c.data_start(), n as usize)?; - match c.id { - FILE_NAME => a.name = ebml::string(d), - FILE_MEDIA_TYPE => a.media_type = ebml::string(d), - FILE_DESCRIPTION => a.description = Some(ebml::string(d)), - FILE_UID => a.uid = ebml::uint(d), - _ => {} - } - } - p = ce; - } - file.attachments.push(a); - } - pos = he; - } - Ok(()) - } -} - -fn add_blocks(file: &mut MkvFile, cluster: u32, blocks: &[BlockRef]) { - let scale = file.info.timestamp_scale; - let mut frames: Vec = Vec::new(); - for b in blocks { - frames.clear(); - let t = &file.tracks[b.track]; - expand(b, t, scale, 0, &mut frames); - let t = &mut file.tracks[b.track]; - for f in &frames { - if f.keyframe { - t.sync.push(t.samples.len() as u32); - } - t.samples.push(Sample { - offset: f.offset, - size: f.size, - pts: f.pts, - duration: f.duration, - keyframe: f.keyframe, - cluster, - block_offset: f.block_offset, - lace: f.lace, - addition: f.addition, - }); - } - } -} - -fn finish_tracks(file: &mut MkvFile) { - let scale = file.info.timestamp_scale; - let g = gcd(scale, 1_000_000_000); - for t in &mut file.tracks { - t.timebase = (scale / g, 1_000_000_000 / g); - } -} - -// --------------------------------------------------------------------------------------------- -// MkvFile helpers -// --------------------------------------------------------------------------------------------- - -impl MkvFile { - /// True for WebM (`DocType` = `webm`). - pub fn is_webm(&self) -> bool { - self.doc_type == "webm" - } - - /// Segment duration in ns: `Info/Duration` if present, else the end of the last indexed sample. - pub fn duration_ns(&self) -> Option { - if let Some(d) = self.info.duration { - return Some((d * self.info.timestamp_scale as f64).round() as u64); - } - let end = self.tracks.iter().flat_map(|t| t.samples.iter().map(|s| s.pts + s.duration as i64)).max()?; - Some((end.max(0) as u64).saturating_mul(self.info.timestamp_scale)) - } - - /// Index of the track with this `TrackNumber`. - pub fn track_by_number(&self, number: u64) -> Option { - self.tracks.iter().position(|t| t.number == number) - } - - /// Index of the first track of the given kind (preferring the default flag). - pub fn track_of_kind(&self, kind: TrackKind) -> Option { - let mut it = self.tracks.iter().enumerate().filter(|(_, t)| t.kind == kind); - let first = it.clone().next().map(|x| x.0); - it.find(|(_, t)| t.default).map(|x| x.0).or(first) - } - - /// Read sample `index` of `track` (header stripping undone). - pub fn read_sample(&self, src: &S, track: usize, index: usize) -> Result> { - let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; - if !t.frames_readable() { - return Err(Error::Unsupported(format!("content encoding of track {} (compression/encryption)", t.number))); - } - let mut data = t.frame_prefix(s.size as u64); - let at = data.len(); - data.resize(at + s.size as usize, 0); - src.read_at(s.offset, &mut data[at..])?; - Ok(data) - } - - /// The `BlockAdditional` (ID 1) of sample `index` of `track`, `None` when the block has none - /// (see [`Sample::addition`]). Stored as is: header stripping applies to frames only. - pub fn read_sample_addition(&self, src: &S, track: usize, index: usize) -> Result>> { - let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; - let Some((offset, size)) = s.addition else { return Ok(None) }; - if offset.checked_add(size as u64).is_none_or(|end| end > src.len()) { - return Err(Error::Invalid("block addition past end of file".into())); - } - let mut data = vec![0; size as usize]; - src.read_at(offset, &mut data)?; - Ok(Some(data)) - } - - /// Keyframe sample of `track` with the greatest pts ≤ `time_ns` (indexed files only). - pub fn keyframe_before(&self, track: usize, time_ns: i64) -> Option { - let t = self.tracks.get(track)?; - t.keyframe_before_pts(t.ns_to_ticks(time_ns)) - } -} - -// --------------------------------------------------------------------------------------------- -// Demuxer (streaming packets + seeking) -// --------------------------------------------------------------------------------------------- - -#[derive(Clone, Copy, Debug)] -struct InCluster { - ts: i64, - end: Option, -} - -/// Packet reader over a [`ByteSource`]: iterates frames in file order and seeks to keyframes. -pub struct Demuxer { - src: S, - file: MkvFile, - opts: OpenOptions, - cache: Cache, - pos: u64, - cluster: Option, - pending: VecDeque, - skip_laces: Option<(u64, u16)>, - done: bool, -} - -impl Demuxer> { - /// Open from any `Read + Seek` stream. - pub fn from_reader(r: R) -> Result { - Self::new(ReadSeekSource::new(r)?) - } -} - -impl<'a> Demuxer<&'a [u8]> { - /// Open from an in-memory byte slice. - pub fn from_slice(data: &'a [u8]) -> Result { - Self::new(data) - } -} - -impl Demuxer { - /// Open with default options (full index). - pub fn new(src: S) -> Result { - Self::with_options(src, OpenOptions::default()) - } - - pub fn with_options(src: S, opts: OpenOptions) -> Result { - let mut cache = Cache::default(); - let file = open_impl(&src, &opts, &mut cache)?; - let pos = file.first_cluster.unwrap_or(file.segment_end); - Ok(Self { src, file, opts, cache, pos, cluster: None, pending: VecDeque::new(), skip_laces: None, done: false }) - } - - pub fn file(&self) -> &MkvFile { - &self.file - } - pub fn tracks(&self) -> &[Track] { - &self.file.tracks - } - pub fn source(&self) -> &S { - &self.src - } - pub fn into_parts(self) -> (S, MkvFile) { - (self.src, self.file) - } - /// Segment duration in ns. - pub fn duration_ns(&self) -> Option { - self.file.duration_ns() - } - - /// Scan all clusters and build sample tables (no-op if already indexed). - pub fn build_index(&mut self) -> Result<()> { - if self.file.indexed { - return Ok(()); - } - let opts = OpenOptions { index: true, verify_crc: self.opts.verify_crc }; - self.file = open_impl(&self.src, &opts, &mut self.cache)?; - Ok(()) - } - - /// Keyframes of `track` (builds the index if needed). - pub fn keyframe_index(&mut self, track: usize) -> Result> { - self.build_index()?; - let t = self.file.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - Ok(t.keyframes() - .iter() - .map(|&k| { - let s = &t.samples[k as usize]; - Keyframe { sample: k as usize, pts: s.pts, pts_ns: t.ticks_to_ns(s.pts), block_offset: s.block_offset } - }) - .collect()) - } - - /// Rewind to the first cluster. - pub fn rewind(&mut self) { - self.pos = self.file.first_cluster.unwrap_or(self.file.segment_end); - self.cluster = None; - self.pending.clear(); - self.skip_laces = None; - self.done = false; - } - - /// Read sample `index` of `track` (requires an index). - pub fn read_sample(&self, track: usize, index: usize) -> Result> { - self.file.read_sample(&self.src, track, index) - } - - /// Position the packet stream at the keyframe of `track` with the greatest pts ≤ `time_ns` - /// (or the track's first keyframe if none precedes it). The next [`Demuxer::next_packet`] - /// call returns packets starting at that keyframe's block (all tracks). - /// - /// Uses the sample index when present, else Cues (refined by scanning the cued cluster), else - /// builds the index first. - pub fn seek(&mut self, track: usize, time_ns: i64) -> Result { - let t = self.file.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let ticks = t.ns_to_ticks(time_ns); - if !self.file.indexed { - let tn = t.number; - let cue = self - .file - .cues - .iter() - .filter(|c| c.time as i64 <= ticks) - .filter_map(|c| c.positions.iter().find(|p| p.track == tn).map(|p| (c.time, *p))) - .next_back() - .or_else(|| self.file.cues.iter().find_map(|c| c.positions.iter().find(|p| p.track == tn).map(|p| (c.time, *p)))); - if let Some((_, cp)) = cue - && let Some(sp) = self.seek_via_cue(track, ticks, cp.cluster_position)? - { - return Ok(sp); - } - self.build_index()?; - } - let t = &self.file.tracks[track]; - let k = t.keyframe_before_pts(ticks).ok_or_else(|| Error::Invalid(format!("track {} has no keyframes", t.number)))?; - let s = t.samples[k]; - let c = self.file.clusters[s.cluster as usize]; - let sp = SeekPoint { track, pts: s.pts, pts_ns: t.ticks_to_ns(s.pts), sample: Some(k), cluster_offset: c.offset, block_offset: s.block_offset }; - self.position_at(c, s.block_offset, s.lace); - Ok(sp) - } - - fn position_at(&mut self, c: ClusterInfo, block_offset: u64, lace: u16) { - self.pending.clear(); - self.done = false; - self.pos = block_offset; - let known = c.end; - self.cluster = Some(InCluster { ts: c.timestamp as i64, end: Some(known) }); - self.skip_laces = (lace > 0).then_some((block_offset, lace)); - } - - fn seek_via_cue(&mut self, track: usize, ticks: i64, cluster_position: u64) -> Result> { - let abs = self.file.segment_data_start + cluster_position; - let mut io = Io::new(&self.src, &mut self.cache); - let Some(h) = io.header(abs, self.file.segment_end)? else { return Ok(None) }; - if h.id != CLUSTER { - self.file.warnings.push(format!("cue points at {abs}, which is not a Cluster")); - return Ok(None); - } - let mut warnings = Vec::new(); - let (ci, blocks) = scan_cluster(&mut io, &h, self.file.segment_end, &self.file.tracks, &mut warnings)?; - let t = &self.file.tracks[track]; - let mut frames = Vec::new(); - for b in blocks.iter().filter(|b| b.track == track && b.keyframe) { - expand(b, t, self.file.info.timestamp_scale, 0, &mut frames); - } - let best = frames.iter().filter(|f| f.pts <= ticks).max_by_key(|f| f.pts).or_else(|| frames.iter().min_by_key(|f| f.pts)).copied(); - let Some(f) = best else { return Ok(None) }; - let sp = SeekPoint { track, pts: f.pts, pts_ns: f.pts_ns, sample: None, cluster_offset: ci.offset, block_offset: f.block_offset }; - self.position_at(ci, f.block_offset, f.lace); - Ok(Some(sp)) - } - - /// Next frame in file order, or `None` at the end of the Segment. - pub fn next_packet(&mut self) -> Result> { - loop { - if let Some(f) = self.pending.pop_front() { - return self.materialize(f).map(Some); - } - if self.done || !self.fill()? { - self.done = true; - return Ok(None); - } - } - } - - fn materialize(&mut self, f: FrameRef) -> Result { - let t = &self.file.tracks[f.track]; - let mut data = if t.frames_readable() { t.frame_prefix(f.size as u64) } else { Vec::new() }; - let at = data.len(); - data.resize(at + f.size as usize, 0); - let mut io = Io::new(&self.src, &mut self.cache); - if (f.size as usize) <= WIN / 2 { - let b = io.peek(f.offset, f.size as usize)?; - if b.len() != f.size as usize { - return Err(Error::Invalid("frame data past end of file".into())); - } - data[at..].copy_from_slice(b); - } else { - self.src.read_at(f.offset, &mut data[at..])?; - } - Ok(Packet { - track: f.track, - track_number: t.number, - pts: f.pts, - pts_ns: f.pts_ns, - duration: f.duration, - duration_ns: f.duration_ns, - keyframe: f.keyframe, - discardable: f.discardable, - invisible: f.invisible, - discard_padding_ns: f.discard_padding, - offset: f.offset, - lace: f.lace, - data, - }) - } - - /// Advance over elements until at least one frame is pending. Returns false at the end. - fn fill(&mut self) -> Result { - let seg_end = self.file.segment_end; - let scale = self.file.info.timestamp_scale; - let mut io = Io::new(&self.src, &mut self.cache); - let lookup = track_lookup(&self.file.tracks); - loop { - if let Some(c) = self.cluster { - let lim = c.end.unwrap_or(seg_end); - if self.pos >= lim { - self.cluster = None; - continue; - } - let Some(h) = io.header(self.pos, lim)? else { - self.cluster = None; - match io.resync(self.pos + 1, seg_end)? { - Some(p) => { - self.pos = p; - continue; - } - None => return Ok(false), - } - }; - if c.end.is_none() && is_top_level(h.id) { - self.cluster = None; - continue; - } - if h.size == Size::Unknown || h.overruns(lim) { - self.cluster = None; - match io.resync(self.pos + 1, seg_end)? { - Some(p) => { - self.pos = p; - continue; - } - None => return Ok(false), - } - } - let he = h.end(lim); - self.pos = he; - match h.id { - TIMESTAMP => { - let v = ebml::uint(io.peek(h.data_start(), (he - h.data_start()) as usize)?) as i64; - self.cluster = Some(InCluster { ts: v, end: c.end }); - } - SIMPLE_BLOCK | BLOCK_GROUP => { - if let Some(b) = parse_block_elem(&mut io, &h, he, c.ts, &lookup)? { - let skip = match self.skip_laces.take() { - Some((off, n)) if off == h.pos => n, - _ => 0, - }; - expand(&b, &self.file.tracks[b.track], scale, skip, &mut self.pending); - if !self.pending.is_empty() { - return Ok(true); - } - } - } - _ => {} - } - continue; - } - if self.pos >= seg_end { - return Ok(false); - } - let Some(h) = io.header(self.pos, seg_end)? else { - match io.resync(self.pos + 1, seg_end)? { - Some(p) => { - self.pos = p; - continue; - } - None => return Ok(false), - } - }; - match h.id { - CLUSTER => { - self.cluster = Some(InCluster { ts: 0, end: h.size.known().map(|_| h.end(seg_end)) }); - self.pos = h.data_start(); - } - EBML | SEGMENT => return Ok(false), - _ => { - self.pos = if h.size == Size::Unknown { io.unknown_end(h.data_start(), seg_end)? } else { h.end(seg_end) }; - if self.pos <= h.pos { - return Ok(false); - } - } - } - } - } -} - -impl Iterator for Demuxer { - type Item = Result; - fn next(&mut self) -> Option> { - match self.next_packet() { - Ok(Some(p)) => Some(Ok(p)), - Ok(None) => None, - Err(e) => { - self.done = true; - Some(Err(e)) - } - } - } -} diff --git a/crates/matroska/src/ebml.rs b/crates/matroska/src/ebml.rs deleted file mode 100644 index 6c2db76de..000000000 --- a/crates/matroska/src/ebml.rs +++ /dev/null @@ -1,476 +0,0 @@ -//! EBML primitives (RFC 8794): variable-length integers, element headers, typed values, CRC-32, -//! and Matroska block lacing (RFC 9559 §10.3). - -use crate::error::{Result, invalid}; - -/// Length in bytes of a VINT whose first byte is `b` (1..=8), or `None` for a zero first byte. -#[inline] -pub fn vint_len(b: u8) -> Option { - if b == 0 { None } else { Some(b.leading_zeros() as usize + 1) } -} - -/// Element data size: known, or "unknown" (all value bits set). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Size { - Known(u64), - Unknown, -} - -impl Size { - pub fn known(self) -> Option { - match self { - Size::Known(n) => Some(n), - Size::Unknown => None, - } - } -} - -/// Decode an unsigned VINT (marker bit stripped). Returns `(value, length, all_ones)`. -pub fn read_vint(data: &[u8]) -> Option<(u64, usize, bool)> { - let first = *data.first()?; - let len = vint_len(first)?; - if data.len() < len { - return None; - } - let mask = if len == 8 { 0 } else { 0xFFu8 >> len }; - let mut v = (first & mask) as u64; - for &b in &data[1..len] { - v = (v << 8) | b as u64; - } - let all_ones = v == (1u64 << (7 * len)) - 1; - Some((v, len, all_ones)) -} - -/// Decode an element data size. All-ones is the reserved "unknown size". -pub fn read_size(data: &[u8]) -> Option<(Size, usize)> { - let (v, len, ones) = read_vint(data)?; - Some((if ones { Size::Unknown } else { Size::Known(v) }, len)) -} - -/// Decode an element ID (marker bits kept, 1..=4 bytes). Rejects all-ones value bits (reserved). -/// All-zero value bits are accepted, because Matroska's `ChapterDisplay` uses ID `0x80`. -pub fn read_id(data: &[u8]) -> Option<(u32, usize)> { - let first = *data.first()?; - let len = vint_len(first)?; - if len > 4 || data.len() < len { - return None; - } - let (v, _, ones) = read_vint(&data[..len])?; - if ones || (v == 0 && len > 1) { - return None; - } - let mut id = 0u32; - for &b in &data[..len] { - id = (id << 8) | b as u32; - } - Some((id, len)) -} - -/// Decode a signed VINT as used by EBML lacing (value − (2^(7·len−1) − 1)). -pub fn read_svint(data: &[u8]) -> Option<(i64, usize)> { - let (v, len, _) = read_vint(data)?; - let bias = (1i64 << (7 * len - 1)) - 1; - Some((v as i64 - bias, len)) -} - -/// Encode an element ID (already carrying its marker bits). -pub fn write_id(out: &mut Vec, id: u32) { - let n = if id >= 0x100_0000 { - 4 - } else if id >= 0x1_0000 { - 3 - } else if id >= 0x100 { - 2 - } else { - 1 - }; - out.extend_from_slice(&id.to_be_bytes()[4 - n..]); -} - -/// Encode a data size as a VINT using the shortest length (or `min_len` if larger). -pub fn write_size(out: &mut Vec, size: u64, min_len: usize) { - let mut len = 1; - while len < 8 && size >= (1u64 << (7 * len)) - 1 { - len += 1; - } - let len = len.max(min_len).min(8); - let v = size | (1u64 << (7 * len)); - out.extend_from_slice(&v.to_be_bytes()[8 - len..]); -} - -/// Encode the "unknown size" marker of the given length. -pub fn write_unknown_size(out: &mut Vec, len: usize) { - out.push(0xFFu8 >> (len - 1)); - out.extend(std::iter::repeat_n(0xFF, len - 1)); -} - -/// Big-endian unsigned integer of 0..=8 bytes. -pub fn uint(data: &[u8]) -> u64 { - data.iter().take(8).fold(0u64, |a, &b| (a << 8) | b as u64) -} - -/// Big-endian two's-complement signed integer of 0..=8 bytes. -pub fn int(data: &[u8]) -> i64 { - let n = data.len().min(8); - if n == 0 { - return 0; - } - let v = uint(&data[..n]); - let shift = 64 - 8 * n as u32; - ((v << shift) as i64) >> shift -} - -/// IEEE float of 0, 4 or 8 bytes. -pub fn float(data: &[u8]) -> f64 { - match data.len() { - 4 => f32::from_be_bytes(data.try_into().unwrap_or([0; 4])) as f64, - 8 => f64::from_be_bytes(data.try_into().unwrap_or([0; 8])), - _ => 0.0, - } -} - -/// String / UTF-8 value: bytes up to the first NUL, lossily decoded. -pub fn string(data: &[u8]) -> String { - let end = data.iter().position(|&b| b == 0).unwrap_or(data.len()); - String::from_utf8_lossy(&data[..end]).into_owned() -} - -/// One element inside an in-memory master element. -#[derive(Clone, Copy, Debug)] -pub struct Elem<'a> { - pub id: u32, - /// Offset of the element header, relative to the start of the parent's data. - pub offset: usize, - pub header_len: usize, - pub data: &'a [u8], - /// The size field was "unknown" (data runs to the end of the parent). - pub unknown_size: bool, - /// The declared size overran the parent and was truncated. - pub truncated: bool, -} - -/// Iterator over the children of an in-memory master element. -/// -/// Damaged data ends the iteration (see [`Children::damaged`]); oversize elements are truncated to -/// the parent's end. -pub struct Children<'a> { - data: &'a [u8], - pos: usize, - damaged: bool, -} - -impl<'a> Children<'a> { - pub fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0, damaged: false } - } - /// True if iteration stopped on an undecodable header. - pub fn damaged(&self) -> bool { - self.damaged - } -} - -impl<'a> Iterator for Children<'a> { - type Item = Elem<'a>; - fn next(&mut self) -> Option> { - let rest = self.data.get(self.pos..)?; - if rest.is_empty() { - return None; - } - let Some((id, il)) = read_id(rest) else { - self.damaged = true; - self.pos = self.data.len(); - return None; - }; - let Some((size, sl)) = read_size(&rest[il..]) else { - self.damaged = true; - self.pos = self.data.len(); - return None; - }; - let hl = il + sl; - let avail = (rest.len() - hl) as u64; - let (len, unknown, truncated) = match size { - Size::Known(n) if n <= avail => (n as usize, false, false), - Size::Known(_) => (avail as usize, false, true), - Size::Unknown => (avail as usize, true, false), - }; - let e = Elem { id, offset: self.pos, header_len: hl, data: &rest[hl..hl + len], unknown_size: unknown, truncated }; - self.pos += hl + len; - Some(e) - } -} - -/// CRC-32 (ISO 3309 / IEEE 802.3, reflected polynomial 0xEDB88320), as used by EBML CRC-32 elements. -pub fn crc32(data: &[u8]) -> u32 { - crc32_update(0xFFFF_FFFF, data) ^ 0xFFFF_FFFF -} - -/// Incremental form: start with `0xFFFF_FFFF`, finish with `^ 0xFFFF_FFFF`. -pub fn crc32_update(mut crc: u32, data: &[u8]) -> u32 { - static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new(); - let t = TABLE.get_or_init(|| { - let mut t = [0u32; 256]; - for (i, e) in t.iter_mut().enumerate() { - let mut c = i as u32; - for _ in 0..8 { - c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; - } - *e = c; - } - t - }); - for &b in data { - crc = t[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8); - } - crc -} - -/// Check a master element's leading CRC-32 child, if present. `Some(true/false)` if there is one. -pub fn verify_crc(master_data: &[u8]) -> Option { - let mut it = Children::new(master_data); - let first = it.next()?; - if first.id != crate::ids::CRC32 || first.data.len() != 4 { - return None; - } - let stored = u32::from_le_bytes(first.data.try_into().ok()?); - let rest = &master_data[first.offset + first.header_len + 4..]; - Some(crc32(rest) == stored) -} - -/// Block lacing mode (flags bits 1–2). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Lacing { - None, - Xiph, - FixedSize, - Ebml, -} - -impl Lacing { - pub fn from_flags(flags: u8) -> Lacing { - match (flags >> 1) & 3 { - 0 => Lacing::None, - 1 => Lacing::Xiph, - 2 => Lacing::FixedSize, - _ => Lacing::Ebml, - } - } -} - -/// Split laced block payload. `data` starts right after the block's flags byte and runs to the end -/// of the block. Returns `(offset, len)` of each frame relative to `data`. -pub fn parse_lacing(lacing: Lacing, data: &[u8]) -> Result> { - if lacing == Lacing::None { - return Ok(vec![(0, data.len())]); - } - let Some(&n1) = data.first() else { return invalid("laced block without frame count") }; - let count = n1 as usize + 1; - let mut pos = 1usize; - let mut sizes = Vec::with_capacity(count); - match lacing { - Lacing::None => return Ok(vec![(0, data.len())]), - Lacing::Xiph => { - for _ in 0..count - 1 { - let mut s = 0usize; - loop { - let Some(&b) = data.get(pos) else { return invalid("truncated Xiph lace header") }; - pos += 1; - s += b as usize; - if b != 255 { - break; - } - } - sizes.push(s); - } - } - Lacing::Ebml => { - if count > 1 { - let Some((first, l, _)) = read_vint(&data[pos..]) else { return invalid("bad EBML lace size") }; - pos += l; - let mut prev = first as i64; - sizes.push(first as usize); - for _ in 1..count - 1 { - let Some((d, l)) = read_svint(&data[pos..]) else { return invalid("bad EBML lace delta") }; - pos += l; - prev += d; - if prev < 0 { - return invalid("negative EBML lace size"); - } - sizes.push(prev as usize); - } - } - } - Lacing::FixedSize => { - let rest = data.len() - pos; - if !rest.is_multiple_of(count) { - return invalid("fixed-size lacing does not divide block"); - } - sizes.extend(std::iter::repeat_n(rest / count, count - 1)); - } - } - let used: usize = sizes.iter().try_fold(0usize, |a, &s| a.checked_add(s)).unwrap_or(usize::MAX); - if pos > data.len() || used > data.len() - pos { - return invalid("lace sizes exceed block"); - } - sizes.push(data.len() - pos - used); - let mut out = Vec::with_capacity(count); - for s in sizes { - out.push((pos, s)); - pos += s; - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn vint_roundtrip() { - for &v in &[0u64, 1, 126, 127, 128, 16382, 16383, 16384, 1 << 20, (1 << 56) - 2] { - let mut b = Vec::new(); - write_size(&mut b, v, 1); - let (s, l) = read_size(&b).unwrap(); - assert_eq!(s, Size::Known(v), "value {v}"); - assert_eq!(l, b.len()); - } - // 127 needs 2 bytes because 0xFF is "unknown" - let mut b = Vec::new(); - write_size(&mut b, 127, 1); - assert_eq!(b, [0x40, 0x7F]); - // padded length - let mut b = Vec::new(); - write_size(&mut b, 5, 8); - assert_eq!(b, [1, 0, 0, 0, 0, 0, 0, 5]); - assert_eq!(read_size(&b).unwrap(), (Size::Known(5), 8)); - } - - #[test] - fn vint_unknown_and_invalid() { - assert_eq!(read_size(&[0xFF]).unwrap(), (Size::Unknown, 1)); - assert_eq!(read_size(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]).unwrap(), (Size::Unknown, 8)); - for l in 1..=8 { - let mut b = Vec::new(); - write_unknown_size(&mut b, l); - assert_eq!(read_size(&b).unwrap(), (Size::Unknown, l)); - } - assert!(read_size(&[0x00, 1, 2]).is_none()); - assert!(read_size(&[0x40]).is_none()); // truncated - assert_eq!(read_vint(&[0x81]).unwrap(), (1, 1, false)); - assert_eq!(read_vint(&[0x10, 0x00, 0x00, 0x01]).unwrap(), (1, 4, false)); - } - - #[test] - fn ids() { - assert_eq!(read_id(&[0x1A, 0x45, 0xDF, 0xA3, 0x99]).unwrap(), (crate::ids::EBML, 4)); - assert_eq!(read_id(&[0xA3]).unwrap(), (0xA3, 1)); - assert_eq!(read_id(&[0x42, 0x86]).unwrap(), (0x4286, 2)); - assert!(read_id(&[0xFF]).is_none()); // reserved - assert_eq!(read_id(&[0x80]).unwrap(), (0x80, 1)); // ChapterDisplay - assert!(read_id(&[0x40, 0x00]).is_none()); // zero value - assert!(read_id(&[0x08, 0, 0, 0, 1]).is_none()); // 5-byte ID - for id in [0xA3u32, 0x4286, 0x2AD7B1, 0x1F43B675] { - let mut b = Vec::new(); - write_id(&mut b, id); - assert_eq!(read_id(&b).unwrap(), (id, b.len())); - } - } - - #[test] - fn signed_vint() { - assert_eq!(read_svint(&[0xBF]).unwrap(), (0, 1)); // 63 - 63 - assert_eq!(read_svint(&[0x80]).unwrap(), (-63, 1)); - assert_eq!(read_svint(&[0xC0]).unwrap(), (1, 1)); - assert_eq!(read_svint(&[0x5F, 0xFF]).unwrap(), (0, 2)); - assert_eq!(read_svint(&[0x60, 0x00]).unwrap(), (1, 2)); - } - - #[test] - fn values() { - assert_eq!(uint(&[]), 0); - assert_eq!(uint(&[1, 0]), 256); - assert_eq!(int(&[0xFF]), -1); - assert_eq!(int(&[0xFF, 0xFE]), -2); - assert_eq!(int(&[0x7F]), 127); - assert_eq!(float(&1.5f32.to_be_bytes()), 1.5); - assert_eq!(float(&2.25f64.to_be_bytes()), 2.25); - assert_eq!(string(b"abc\0\0"), "abc"); - } - - #[test] - fn crc() { - assert_eq!(crc32(b"123456789"), 0xCBF4_3926); - let payload = [0x42u8, 0x86, 0x81, 0x01]; - let mut m = vec![0xBF, 0x84]; - m.extend_from_slice(&crc32(&payload).to_le_bytes()); - m.extend_from_slice(&payload); - assert_eq!(verify_crc(&m), Some(true)); - m[7] ^= 1; - assert_eq!(verify_crc(&m), Some(false)); - assert_eq!(verify_crc(&payload), None); - } - - #[test] - fn children_iter() { - // two elements, then one whose size overruns - let d = [0x42, 0x86, 0x81, 0x01, 0xEC, 0x82, 0, 0, 0xA3, 0x85, 1, 2]; - let v: Vec<_> = Children::new(&d).collect(); - assert_eq!(v.len(), 3); - assert_eq!(v[0].id, 0x4286); - assert_eq!(v[0].data, &[1]); - assert_eq!(v[1].id, 0xEC); - assert_eq!(v[2].data, &[1, 2]); - assert!(v[2].truncated); - let mut it = Children::new(&[0x00, 0x01]); - assert!(it.next().is_none()); - assert!(it.damaged()); - } - - fn frames<'a>(d: &'a [u8], f: &[(usize, usize)]) -> Vec<&'a [u8]> { - f.iter().map(|&(o, l)| &d[o..o + l]).collect() - } - - #[test] - fn lacing_xiph() { - // 3 frames: 300, 2, rest(3) - let mut d = vec![2u8, 255, 45, 2]; - d.extend(std::iter::repeat_n(7u8, 300)); - d.extend_from_slice(&[1, 2]); - d.extend_from_slice(&[3, 4, 5]); - let f = parse_lacing(Lacing::Xiph, &d).unwrap(); - assert_eq!(f.iter().map(|x| x.1).collect::>(), [300, 2, 3]); - assert_eq!(frames(&d, &f)[2], &[3, 4, 5]); - // exact multiple of 255 needs a terminating 0 - let mut d = vec![1u8, 255, 0]; - d.extend(std::iter::repeat_n(1u8, 255)); - d.push(9); - let f = parse_lacing(Lacing::Xiph, &d).unwrap(); - assert_eq!(f.iter().map(|x| x.1).collect::>(), [255, 1]); - assert!(parse_lacing(Lacing::Xiph, &[1, 200, 1]).is_err()); - } - - #[test] - fn lacing_ebml() { - // 4 frames: 5, 3 (delta -2), 4 (delta +1), rest 2 - let mut d = vec![3u8, 0x85, 0xBF - 2, 0xBF + 1]; - d.extend_from_slice(&[1; 5]); - d.extend_from_slice(&[2; 3]); - d.extend_from_slice(&[3; 4]); - d.extend_from_slice(&[4; 2]); - let f = parse_lacing(Lacing::Ebml, &d).unwrap(); - assert_eq!(f.iter().map(|x| x.1).collect::>(), [5, 3, 4, 2]); - assert_eq!(frames(&d, &f), [&[1u8; 5][..], &[2; 3], &[3; 4], &[4; 2]]); - assert!(parse_lacing(Lacing::Ebml, &[1, 0x90]).is_err()); - } - - #[test] - fn lacing_fixed_and_none() { - let d = [2u8, 1, 1, 2, 2, 3, 3]; - let f = parse_lacing(Lacing::FixedSize, &d).unwrap(); - assert_eq!(frames(&d, &f), [&[1u8, 1][..], &[2, 2], &[3, 3]]); - assert!(parse_lacing(Lacing::FixedSize, &[2, 1, 1]).is_err()); - assert_eq!(parse_lacing(Lacing::None, &[9, 9]).unwrap(), [(0, 2)]); - assert_eq!(Lacing::from_flags(0x80 | 0x06), Lacing::Ebml); - assert_eq!(Lacing::from_flags(0x02), Lacing::Xiph); - assert_eq!(Lacing::from_flags(0x04), Lacing::FixedSize); - } -} diff --git a/crates/matroska/src/error.rs b/crates/matroska/src/error.rs deleted file mode 100644 index 718c5c505..000000000 --- a/crates/matroska/src/error.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::fmt; -use std::io; - -/// Errors produced by the demuxer. -#[derive(Debug)] -pub enum Error { - /// Underlying I/O failure. - Io(io::Error), - /// Structurally invalid data that could not be recovered from. - Invalid(String), - /// Valid but unsupported feature (e.g. zlib-compressed or encrypted tracks when reading data). - Unsupported(String), - /// Not an EBML file, or an EBML file whose DocType is not `matroska`/`webm`. - NotMatroska(String), - /// A track index was out of range. - NoSuchTrack(usize), - /// A sample index was out of range. - NoSuchSample(usize), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Io(e) => write!(f, "I/O error: {e}"), - Error::Invalid(s) => write!(f, "invalid data: {s}"), - Error::Unsupported(s) => write!(f, "unsupported: {s}"), - Error::NotMatroska(s) => write!(f, "not a Matroska/WebM file: {s}"), - Error::NoSuchTrack(i) => write!(f, "track index {i} out of range"), - Error::NoSuchSample(i) => write!(f, "sample index {i} out of range"), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Error::Io(e) => Some(e), - _ => None, - } - } -} - -impl From for Error { - fn from(e: io::Error) -> Self { - Error::Io(e) - } -} - -pub type Result = std::result::Result; - -pub(crate) fn invalid(msg: impl Into) -> Result { - Err(Error::Invalid(msg.into())) -} diff --git a/crates/matroska/src/ids.rs b/crates/matroska/src/ids.rs deleted file mode 100644 index 3b7e025c4..000000000 --- a/crates/matroska/src/ids.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! EBML / Matroska element IDs (RFC 8794, RFC 9559). IDs keep their VINT marker bits. -#![allow(dead_code)] - -// EBML header + global elements -pub const EBML: u32 = 0x1A45_DFA3; -pub const EBML_VERSION: u32 = 0x4286; -pub const EBML_READ_VERSION: u32 = 0x42F7; -pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2; -pub const EBML_MAX_SIZE_LENGTH: u32 = 0x42F3; -pub const DOC_TYPE: u32 = 0x4282; -pub const DOC_TYPE_VERSION: u32 = 0x4287; -pub const DOC_TYPE_READ_VERSION: u32 = 0x4285; -pub const CRC32: u32 = 0xBF; -pub const VOID: u32 = 0xEC; - -// Segment and level-1 elements -pub const SEGMENT: u32 = 0x1853_8067; -pub const SEEK_HEAD: u32 = 0x114D_9B74; -pub const INFO: u32 = 0x1549_A966; -pub const TRACKS: u32 = 0x1654_AE6B; -pub const CLUSTER: u32 = 0x1F43_B675; -pub const CUES: u32 = 0x1C53_BB6B; -pub const ATTACHMENTS: u32 = 0x1941_A469; -pub const CHAPTERS: u32 = 0x1043_A770; -pub const TAGS: u32 = 0x1254_C367; - -/// True for IDs that can only appear directly inside a Segment (or are the EBML/Segment headers): -/// used to find the end of unknown-size elements. -pub fn is_top_level(id: u32) -> bool { - matches!(id, EBML | SEGMENT | SEEK_HEAD | INFO | TRACKS | CLUSTER | CUES | ATTACHMENTS | CHAPTERS | TAGS) -} - -// SeekHead -pub const SEEK: u32 = 0x4DBB; -pub const SEEK_ID: u32 = 0x53AB; -pub const SEEK_POSITION: u32 = 0x53AC; - -// Info -pub const SEGMENT_UUID: u32 = 0x73A4; -pub const SEGMENT_FILENAME: u32 = 0x7384; -pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1; -pub const DURATION: u32 = 0x4489; -pub const DATE_UTC: u32 = 0x4461; -pub const TITLE: u32 = 0x7BA9; -pub const MUXING_APP: u32 = 0x4D80; -pub const WRITING_APP: u32 = 0x5741; - -// Cluster -pub const TIMESTAMP: u32 = 0xE7; -pub const POSITION: u32 = 0xA7; -pub const PREV_SIZE: u32 = 0xAB; -pub const SIMPLE_BLOCK: u32 = 0xA3; -pub const BLOCK_GROUP: u32 = 0xA0; -pub const ENCRYPTED_BLOCK: u32 = 0xAF; -pub const SILENT_TRACKS: u32 = 0x5854; -pub const BLOCK: u32 = 0xA1; -pub const BLOCK_ADDITIONS: u32 = 0x75A1; -pub const BLOCK_MORE: u32 = 0xA6; -pub const BLOCK_ADD_ID: u32 = 0xEE; -pub const BLOCK_ADDITIONAL: u32 = 0xA5; -pub const BLOCK_DURATION: u32 = 0x9B; -pub const REFERENCE_PRIORITY: u32 = 0xFA; -pub const REFERENCE_BLOCK: u32 = 0xFB; -pub const CODEC_STATE: u32 = 0xA4; -pub const DISCARD_PADDING: u32 = 0x75A2; - -pub fn is_cluster_child(id: u32) -> bool { - matches!(id, TIMESTAMP | POSITION | PREV_SIZE | SIMPLE_BLOCK | BLOCK_GROUP | ENCRYPTED_BLOCK | SILENT_TRACKS | CRC32 | VOID) -} - -// Tracks -pub const TRACK_ENTRY: u32 = 0xAE; -pub const TRACK_NUMBER: u32 = 0xD7; -pub const TRACK_UID: u32 = 0x73C5; -pub const TRACK_TYPE: u32 = 0x83; -pub const FLAG_ENABLED: u32 = 0xB9; -pub const FLAG_DEFAULT: u32 = 0x88; -pub const FLAG_FORCED: u32 = 0x55AA; -pub const FLAG_HEARING_IMPAIRED: u32 = 0x55AB; -pub const FLAG_VISUAL_IMPAIRED: u32 = 0x55AC; -pub const FLAG_ORIGINAL: u32 = 0x55AE; -pub const FLAG_COMMENTARY: u32 = 0x55AF; -pub const FLAG_LACING: u32 = 0x9C; -pub const DEFAULT_DURATION: u32 = 0x23_E383; -pub const TRACK_TIMESTAMP_SCALE: u32 = 0x23_314F; -pub const NAME: u32 = 0x536E; -pub const LANGUAGE: u32 = 0x22_B59C; -pub const LANGUAGE_BCP47: u32 = 0x22_B59D; -pub const CODEC_ID: u32 = 0x86; -pub const CODEC_PRIVATE: u32 = 0x63A2; -pub const CODEC_NAME: u32 = 0x25_8688; -pub const CODEC_DELAY: u32 = 0x56AA; -pub const SEEK_PRE_ROLL: u32 = 0x56BB; - -// Video -pub const VIDEO: u32 = 0xE0; -pub const FLAG_INTERLACED: u32 = 0x9A; -pub const FIELD_ORDER: u32 = 0x9D; -pub const STEREO_MODE: u32 = 0x53B8; -pub const ALPHA_MODE: u32 = 0x53C0; -pub const PIXEL_WIDTH: u32 = 0xB0; -pub const PIXEL_HEIGHT: u32 = 0xBA; -pub const PIXEL_CROP_BOTTOM: u32 = 0x54AA; -pub const PIXEL_CROP_TOP: u32 = 0x54BB; -pub const PIXEL_CROP_LEFT: u32 = 0x54CC; -pub const PIXEL_CROP_RIGHT: u32 = 0x54DD; -pub const DISPLAY_WIDTH: u32 = 0x54B0; -pub const DISPLAY_HEIGHT: u32 = 0x54BA; -pub const DISPLAY_UNIT: u32 = 0x54B2; -pub const COLOUR_SPACE: u32 = 0x2E_B524; -pub const COLOUR: u32 = 0x55B0; -pub const PROJECTION: u32 = 0x7670; -pub const PROJECTION_TYPE: u32 = 0x7671; -pub const PROJECTION_POSE_YAW: u32 = 0x7673; -pub const PROJECTION_POSE_PITCH: u32 = 0x7674; -pub const PROJECTION_POSE_ROLL: u32 = 0x7675; - -// Colour -pub const MATRIX_COEFFICIENTS: u32 = 0x55B1; -pub const BITS_PER_CHANNEL: u32 = 0x55B2; -pub const CHROMA_SUBSAMPLING_HORZ: u32 = 0x55B3; -pub const CHROMA_SUBSAMPLING_VERT: u32 = 0x55B4; -pub const CB_SUBSAMPLING_HORZ: u32 = 0x55B5; -pub const CB_SUBSAMPLING_VERT: u32 = 0x55B6; -pub const CHROMA_SITING_HORZ: u32 = 0x55B7; -pub const CHROMA_SITING_VERT: u32 = 0x55B8; -pub const RANGE: u32 = 0x55B9; -pub const TRANSFER_CHARACTERISTICS: u32 = 0x55BA; -pub const PRIMARIES: u32 = 0x55BB; -pub const MAX_CLL: u32 = 0x55BC; -pub const MAX_FALL: u32 = 0x55BD; -pub const MASTERING_METADATA: u32 = 0x55D0; -pub const PRIMARY_R_X: u32 = 0x55D1; -pub const PRIMARY_R_Y: u32 = 0x55D2; -pub const PRIMARY_G_X: u32 = 0x55D3; -pub const PRIMARY_G_Y: u32 = 0x55D4; -pub const PRIMARY_B_X: u32 = 0x55D5; -pub const PRIMARY_B_Y: u32 = 0x55D6; -pub const WHITE_POINT_X: u32 = 0x55D7; -pub const WHITE_POINT_Y: u32 = 0x55D8; -pub const LUMINANCE_MAX: u32 = 0x55D9; -pub const LUMINANCE_MIN: u32 = 0x55DA; - -// Audio -pub const AUDIO: u32 = 0xE1; -pub const SAMPLING_FREQUENCY: u32 = 0xB5; -pub const OUTPUT_SAMPLING_FREQUENCY: u32 = 0x78B5; -pub const CHANNELS: u32 = 0x9F; -pub const BIT_DEPTH: u32 = 0x6264; - -// ContentEncodings -pub const CONTENT_ENCODINGS: u32 = 0x6D80; -pub const CONTENT_ENCODING: u32 = 0x6240; -pub const CONTENT_ENCODING_ORDER: u32 = 0x5031; -pub const CONTENT_ENCODING_SCOPE: u32 = 0x5032; -pub const CONTENT_ENCODING_TYPE: u32 = 0x5033; -pub const CONTENT_COMPRESSION: u32 = 0x5034; -pub const CONTENT_COMP_ALGO: u32 = 0x4254; -pub const CONTENT_COMP_SETTINGS: u32 = 0x4255; -pub const CONTENT_ENCRYPTION: u32 = 0x5035; - -// Cues -pub const CUE_POINT: u32 = 0xBB; -pub const CUE_TIME: u32 = 0xB3; -pub const CUE_TRACK_POSITIONS: u32 = 0xB7; -pub const CUE_TRACK: u32 = 0xF7; -pub const CUE_CLUSTER_POSITION: u32 = 0xF1; -pub const CUE_RELATIVE_POSITION: u32 = 0xF0; -pub const CUE_DURATION: u32 = 0xB2; -pub const CUE_BLOCK_NUMBER: u32 = 0x5378; - -// Attachments -pub const ATTACHED_FILE: u32 = 0x61A7; -pub const FILE_DESCRIPTION: u32 = 0x467E; -pub const FILE_NAME: u32 = 0x466E; -pub const FILE_MEDIA_TYPE: u32 = 0x4660; -pub const FILE_DATA: u32 = 0x465C; -pub const FILE_UID: u32 = 0x46AE; - -// Chapters -pub const EDITION_ENTRY: u32 = 0x45B9; -pub const EDITION_UID: u32 = 0x45BC; -pub const EDITION_FLAG_HIDDEN: u32 = 0x45BD; -pub const EDITION_FLAG_DEFAULT: u32 = 0x45DB; -pub const EDITION_FLAG_ORDERED: u32 = 0x45DD; -pub const CHAPTER_ATOM: u32 = 0xB6; -pub const CHAPTER_UID: u32 = 0x73C4; -pub const CHAPTER_STRING_UID: u32 = 0x5654; -pub const CHAPTER_TIME_START: u32 = 0x91; -pub const CHAPTER_TIME_END: u32 = 0x92; -pub const CHAPTER_FLAG_HIDDEN: u32 = 0x98; -pub const CHAPTER_FLAG_ENABLED: u32 = 0x4598; -pub const CHAPTER_DISPLAY: u32 = 0x80; -pub const CHAP_STRING: u32 = 0x85; -pub const CHAP_LANGUAGE: u32 = 0x437C; -pub const CHAP_LANGUAGE_BCP47: u32 = 0x437D; -pub const CHAP_COUNTRY: u32 = 0x437E; - -// Tags -pub const TAG: u32 = 0x7373; -pub const TARGETS: u32 = 0x63C0; -pub const TARGET_TYPE_VALUE: u32 = 0x68CA; -pub const TARGET_TYPE: u32 = 0x63CA; -pub const TAG_TRACK_UID: u32 = 0x63C5; -pub const TAG_EDITION_UID: u32 = 0x63C9; -pub const TAG_CHAPTER_UID: u32 = 0x63C4; -pub const TAG_ATTACHMENT_UID: u32 = 0x63C6; -pub const SIMPLE_TAG: u32 = 0x67C8; -pub const TAG_NAME: u32 = 0x45A3; -pub const TAG_LANGUAGE: u32 = 0x447A; -pub const TAG_LANGUAGE_BCP47: u32 = 0x447B; -pub const TAG_DEFAULT: u32 = 0x4484; -pub const TAG_STRING: u32 = 0x4487; -pub const TAG_BINARY: u32 = 0x4485; diff --git a/crates/matroska/src/lib.rs b/crates/matroska/src/lib.rs index 5901e5e97..94428f619 100644 --- a/crates/matroska/src/lib.rs +++ b/crates/matroska/src/lib.rs @@ -1,38 +1,6 @@ -//! Clean-room Matroska (MKV) and WebM demuxer, written from RFC 8794 (EBML), RFC 9559 (Matroska) -//! and the WebM container guidelines. -//! -//! - [`open`] / [`open_with`] parse the EBML header and the first Segment (Info, Tracks, SeekHead, -//! Cues, Chapters, Tags, Attachments) and, by default, scan every Cluster's block headers to build -//! per-track [`Sample`] tables — the same shape as `filmcraft-isobmff`, so callers can treat both -//! containers alike. Frame data is read on demand with [`MkvFile::read_sample`]. -//! - [`Demuxer`] iterates [`Packet`]s in file order (from any [`ByteSource`], `Read + Seek`, or a -//! byte slice) and seeks to the keyframe preceding a time, via the index, Cues, or a cluster scan. -//! -//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`. +//! Matroska (MKV) and WebM demuxer and muxer: a re-export of [`craft_media::matroska`] from +//! craft-libs. Supported elements and limitations are in the craft-media README. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] -mod codec; -mod demux; -pub mod ebml; -mod error; -mod ids; -mod meta; -mod mux; -mod source; -#[cfg(test)] -mod synthetic_tests; -mod track; - -pub use codec::Codec; -pub use demux::{Demuxer, Keyframe, MkvFile, OpenOptions, Packet, SeekPoint, open, open_with}; -pub use error::{Error, Result}; -pub use meta::{Attachment, Chapter, ChapterDisplay, ClusterInfo, CuePoint, CuePosition, Edition, SeekEntry, SegmentInfo, SimpleTag, Tag, TagTargets}; -pub use mux::{LacingMode, MkvWriter, MuxOptions, TrackSpec}; -pub use source::{ByteSource, ReadSeekSource}; -pub use track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Sample, Track, TrackKind, VideoInfo}; - -/// Element IDs used by the demuxer (marker bits included), for callers inspecting raw elements. -pub mod element_ids { - pub use crate::ids::*; -} +pub use craft_media::matroska::*; diff --git a/crates/matroska/src/meta.rs b/crates/matroska/src/meta.rs deleted file mode 100644 index 7406be14f..000000000 --- a/crates/matroska/src/meta.rs +++ /dev/null @@ -1,572 +0,0 @@ -//! Segment-level metadata: Info, SeekHead, Cues, Chapters, Tags, Attachments — types and parsers -//! for their in-memory element data. - -use crate::codec::map_codec; -use crate::ebml::{Children, float, int, string, uint}; -use crate::ids::*; -use crate::track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Track, TrackKind, VideoInfo, gcd}; - -// A malformed Matroska file may contain arbitrarily deep ChapterAtom and SimpleTag chains. -const MAX_META_DEPTH: usize = 64; - -/// `Info` element. -#[derive(Clone, Debug, PartialEq)] -pub struct SegmentInfo { - /// Nanoseconds per timestamp tick (default 1 000 000 = 1 ms). - pub timestamp_scale: u64, - /// `Duration` in timestamp ticks (float), if present. - pub duration: Option, - pub title: Option, - pub muxing_app: String, - pub writing_app: String, - /// `DateUTC`: nanoseconds since 2001-01-01T00:00:00 UTC. - pub date_utc: Option, - pub segment_uuid: Option<[u8; 16]>, -} - -impl Default for SegmentInfo { - fn default() -> Self { - Self { - timestamp_scale: 1_000_000, - duration: None, - title: None, - muxing_app: String::new(), - writing_app: String::new(), - date_utc: None, - segment_uuid: None, - } - } -} - -/// One `SeekHead` entry: level-1 element ID and its position relative to the Segment data start. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SeekEntry { - pub id: u32, - pub position: u64, -} - -/// One `CueTrackPositions` of a `CuePoint`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CuePosition { - pub track: u64, - /// Cluster position relative to the Segment data start. - pub cluster_position: u64, - /// Block position relative to the Cluster data start. - pub relative_position: Option, - pub block_number: Option, - pub duration: Option, -} - -/// One `CuePoint`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CuePoint { - /// In timestamp ticks. - pub time: u64, - pub positions: Vec, -} - -/// A cluster found while scanning. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ClusterInfo { - /// Absolute offset of the Cluster element header. - pub offset: u64, - /// Absolute offset of the first child. - pub data_start: u64, - /// Absolute end offset (for unknown-size clusters, where the next top-level element starts). - pub end: u64, - /// Cluster `Timestamp` in ticks. - pub timestamp: u64, -} - -/// One `ChapterDisplay`. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ChapterDisplay { - pub string: String, - pub languages: Vec, - pub languages_bcp47: Vec, - pub countries: Vec, -} - -/// `ChapterAtom` (possibly nested). -#[derive(Clone, Debug, PartialEq)] -pub struct Chapter { - pub uid: u64, - pub string_uid: Option, - pub start_ns: u64, - pub end_ns: Option, - pub hidden: bool, - pub enabled: bool, - pub displays: Vec, - pub children: Vec, -} - -impl Chapter { - /// First display string, if any. - pub fn title(&self) -> Option<&str> { - self.displays.first().map(|d| d.string.as_str()) - } -} - -/// `EditionEntry`. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Edition { - pub uid: Option, - pub hidden: bool, - pub default: bool, - pub ordered: bool, - pub chapters: Vec, -} - -/// `Targets` of a `Tag`. Empty UID lists mean "the whole segment". -#[derive(Clone, Debug, PartialEq)] -pub struct TagTargets { - pub type_value: u64, - pub type_name: Option, - pub track_uids: Vec, - pub edition_uids: Vec, - pub chapter_uids: Vec, - pub attachment_uids: Vec, -} - -impl Default for TagTargets { - fn default() -> Self { - Self { type_value: 50, type_name: None, track_uids: Vec::new(), edition_uids: Vec::new(), chapter_uids: Vec::new(), attachment_uids: Vec::new() } - } -} - -/// `SimpleTag` (possibly nested). -#[derive(Clone, Debug, PartialEq)] -pub struct SimpleTag { - pub name: String, - pub language: String, - pub default: bool, - pub string: Option, - pub binary: Option>, - pub children: Vec, -} - -/// `Tag`. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Tag { - pub targets: TagTargets, - pub simple: Vec, -} - -/// `AttachedFile` (listed only; the payload is located by `data_offset`/`data_size`). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Attachment { - pub uid: u64, - pub name: String, - pub media_type: String, - pub description: Option, - pub data_offset: u64, - pub data_size: u64, -} - -pub(crate) fn parse_info(d: &[u8]) -> SegmentInfo { - let mut info = SegmentInfo::default(); - for e in Children::new(d) { - match e.id { - TIMESTAMP_SCALE => { - let v = uint(e.data); - if v > 0 { - info.timestamp_scale = v; - } - } - DURATION => info.duration = Some(float(e.data)).filter(|v| v.is_finite() && *v >= 0.0), - TITLE => info.title = Some(string(e.data)), - MUXING_APP => info.muxing_app = string(e.data), - WRITING_APP => info.writing_app = string(e.data), - DATE_UTC => info.date_utc = Some(int(e.data)), - SEGMENT_UUID => info.segment_uuid = e.data.try_into().ok(), - _ => {} - } - } - info -} - -pub(crate) fn parse_seekhead(d: &[u8]) -> Vec { - let mut out = Vec::new(); - for s in Children::new(d).filter(|e| e.id == SEEK) { - let (mut id, mut pos) = (None, None); - for e in Children::new(s.data) { - match e.id { - SEEK_ID => id = Some(uint(e.data) as u32), - SEEK_POSITION => pos = Some(uint(e.data)), - _ => {} - } - } - if let (Some(id), Some(position)) = (id, pos) { - out.push(SeekEntry { id, position }); - } - } - out -} - -pub(crate) fn parse_cues(d: &[u8], out: &mut Vec) { - for cp in Children::new(d).filter(|e| e.id == CUE_POINT) { - let mut time = None; - let mut positions = Vec::new(); - for e in Children::new(cp.data) { - match e.id { - CUE_TIME => time = Some(uint(e.data)), - CUE_TRACK_POSITIONS => { - let mut p = CuePosition { track: 0, cluster_position: u64::MAX, relative_position: None, block_number: None, duration: None }; - for c in Children::new(e.data) { - match c.id { - CUE_TRACK => p.track = uint(c.data), - CUE_CLUSTER_POSITION => p.cluster_position = uint(c.data), - CUE_RELATIVE_POSITION => p.relative_position = Some(uint(c.data)), - CUE_BLOCK_NUMBER => p.block_number = Some(uint(c.data)), - CUE_DURATION => p.duration = Some(uint(c.data)), - _ => {} - } - } - if p.track != 0 && p.cluster_position != u64::MAX { - positions.push(p); - } - } - _ => {} - } - } - if let Some(time) = time - && !positions.is_empty() - { - out.push(CuePoint { time, positions }); - } - } -} - -fn parse_chapter(d: &[u8], depth: usize) -> Chapter { - let mut c = Chapter { uid: 0, string_uid: None, start_ns: 0, end_ns: None, hidden: false, enabled: true, displays: Vec::new(), children: Vec::new() }; - for e in Children::new(d) { - match e.id { - CHAPTER_UID => c.uid = uint(e.data), - CHAPTER_STRING_UID => c.string_uid = Some(string(e.data)), - CHAPTER_TIME_START => c.start_ns = uint(e.data), - CHAPTER_TIME_END => c.end_ns = Some(uint(e.data)), - CHAPTER_FLAG_HIDDEN => c.hidden = uint(e.data) != 0, - CHAPTER_FLAG_ENABLED => c.enabled = uint(e.data) != 0, - CHAPTER_DISPLAY => { - let mut disp = ChapterDisplay::default(); - for x in Children::new(e.data) { - match x.id { - CHAP_STRING => disp.string = string(x.data), - CHAP_LANGUAGE => disp.languages.push(string(x.data)), - CHAP_LANGUAGE_BCP47 => disp.languages_bcp47.push(string(x.data)), - CHAP_COUNTRY => disp.countries.push(string(x.data)), - _ => {} - } - } - if disp.languages.is_empty() { - disp.languages.push("eng".into()); - } - c.displays.push(disp); - } - CHAPTER_ATOM if depth < MAX_META_DEPTH => c.children.push(parse_chapter(e.data, depth + 1)), - _ => {} - } - } - c -} - -pub(crate) fn parse_chapters(d: &[u8], out: &mut Vec) { - for ed in Children::new(d).filter(|e| e.id == EDITION_ENTRY) { - let mut edition = Edition::default(); - for e in Children::new(ed.data) { - match e.id { - EDITION_UID => edition.uid = Some(uint(e.data)), - EDITION_FLAG_HIDDEN => edition.hidden = uint(e.data) != 0, - EDITION_FLAG_DEFAULT => edition.default = uint(e.data) != 0, - EDITION_FLAG_ORDERED => edition.ordered = uint(e.data) != 0, - CHAPTER_ATOM => edition.chapters.push(parse_chapter(e.data, 0)), - _ => {} - } - } - out.push(edition); - } -} - -fn parse_simple_tag(d: &[u8], depth: usize) -> SimpleTag { - let mut t = SimpleTag { name: String::new(), language: "und".into(), default: true, string: None, binary: None, children: Vec::new() }; - for e in Children::new(d) { - match e.id { - TAG_NAME => t.name = string(e.data), - TAG_LANGUAGE => t.language = string(e.data), - TAG_DEFAULT => t.default = uint(e.data) != 0, - TAG_STRING => t.string = Some(string(e.data)), - TAG_BINARY => t.binary = Some(e.data.to_vec()), - SIMPLE_TAG if depth < MAX_META_DEPTH => t.children.push(parse_simple_tag(e.data, depth + 1)), - _ => {} - } - } - t -} - -pub(crate) fn parse_tags(d: &[u8], out: &mut Vec) { - for tg in Children::new(d).filter(|e| e.id == TAG) { - let mut tag = Tag::default(); - for e in Children::new(tg.data) { - match e.id { - TARGETS => { - for x in Children::new(e.data) { - match x.id { - TARGET_TYPE_VALUE => tag.targets.type_value = uint(x.data), - TARGET_TYPE => tag.targets.type_name = Some(string(x.data)), - TAG_TRACK_UID => tag.targets.track_uids.push(uint(x.data)), - TAG_EDITION_UID => tag.targets.edition_uids.push(uint(x.data)), - TAG_CHAPTER_UID => tag.targets.chapter_uids.push(uint(x.data)), - TAG_ATTACHMENT_UID => tag.targets.attachment_uids.push(uint(x.data)), - _ => {} - } - } - } - SIMPLE_TAG => tag.simple.push(parse_simple_tag(e.data, 0)), - _ => {} - } - } - out.push(tag); - } -} - -fn parse_colour(d: &[u8]) -> Colour { - let mut c = Colour::default(); - for e in Children::new(d) { - let v = || Some(uint(e.data)); - match e.id { - MATRIX_COEFFICIENTS => c.matrix_coefficients = v(), - BITS_PER_CHANNEL => c.bits_per_channel = v(), - CHROMA_SUBSAMPLING_HORZ => c.chroma_subsampling_horz = v(), - CHROMA_SUBSAMPLING_VERT => c.chroma_subsampling_vert = v(), - CB_SUBSAMPLING_HORZ => c.cb_subsampling_horz = v(), - CB_SUBSAMPLING_VERT => c.cb_subsampling_vert = v(), - CHROMA_SITING_HORZ => c.chroma_siting_horz = v(), - CHROMA_SITING_VERT => c.chroma_siting_vert = v(), - RANGE => c.range = v(), - TRANSFER_CHARACTERISTICS => c.transfer_characteristics = v(), - PRIMARIES => c.primaries = v(), - MAX_CLL => c.max_cll = v(), - MAX_FALL => c.max_fall = v(), - MASTERING_METADATA => { - let mut m = MasteringMetadata::default(); - for x in Children::new(e.data) { - let f = float(x.data); - match x.id { - PRIMARY_R_X => m.primaries[0].0 = f, - PRIMARY_R_Y => m.primaries[0].1 = f, - PRIMARY_G_X => m.primaries[1].0 = f, - PRIMARY_G_Y => m.primaries[1].1 = f, - PRIMARY_B_X => m.primaries[2].0 = f, - PRIMARY_B_Y => m.primaries[2].1 = f, - WHITE_POINT_X => m.white_point.0 = f, - WHITE_POINT_Y => m.white_point.1 = f, - LUMINANCE_MAX => m.luminance_max = f, - LUMINANCE_MIN => m.luminance_min = f, - _ => {} - } - } - c.mastering = Some(m); - } - _ => {} - } - } - c -} - -fn parse_video(d: &[u8]) -> VideoInfo { - let mut v = VideoInfo::default(); - for e in Children::new(d) { - let u = uint(e.data); - match e.id { - PIXEL_WIDTH => v.pixel_width = u as u32, - PIXEL_HEIGHT => v.pixel_height = u as u32, - DISPLAY_WIDTH => v.display_width = Some(u as u32), - DISPLAY_HEIGHT => v.display_height = Some(u as u32), - DISPLAY_UNIT => v.display_unit = u, - PIXEL_CROP_TOP => v.crop.0 = u as u32, - PIXEL_CROP_BOTTOM => v.crop.1 = u as u32, - PIXEL_CROP_LEFT => v.crop.2 = u as u32, - PIXEL_CROP_RIGHT => v.crop.3 = u as u32, - FLAG_INTERLACED => v.flag_interlaced = u, - FIELD_ORDER => v.field_order = Some(u), - STEREO_MODE => v.stereo_mode = Some(u), - ALPHA_MODE => v.alpha_mode = u, - COLOUR_SPACE => v.colour_space = e.data.try_into().ok(), - COLOUR => v.colour = Some(parse_colour(e.data)), - PROJECTION => { - let mut p = Projection::default(); - for x in Children::new(e.data) { - match x.id { - PROJECTION_TYPE => p.projection_type = uint(x.data), - PROJECTION_POSE_YAW => p.yaw = float(x.data), - PROJECTION_POSE_PITCH => p.pitch = float(x.data), - PROJECTION_POSE_ROLL => p.roll = float(x.data), - _ => {} - } - } - v.projection = Some(p); - } - _ => {} - } - } - v -} - -fn parse_audio(d: &[u8]) -> AudioInfo { - let mut a = AudioInfo::default(); - for e in Children::new(d) { - match e.id { - SAMPLING_FREQUENCY => a.sampling_frequency = float(e.data), - OUTPUT_SAMPLING_FREQUENCY => a.output_sampling_frequency = Some(float(e.data)), - CHANNELS => a.channels = uint(e.data), - BIT_DEPTH => a.bit_depth = Some(uint(e.data)), - _ => {} - } - } - a -} - -fn parse_encodings(d: &[u8]) -> Vec { - let mut list: Vec<(u64, ContentEncoding)> = Vec::new(); - for ce in Children::new(d).filter(|e| e.id == CONTENT_ENCODING) { - let (mut order, mut scope, mut ty) = (0u64, 1u64, 0u64); - let mut comp: Option<(u64, Vec)> = None; - for e in Children::new(ce.data) { - match e.id { - CONTENT_ENCODING_ORDER => order = uint(e.data), - CONTENT_ENCODING_SCOPE => scope = uint(e.data), - CONTENT_ENCODING_TYPE => ty = uint(e.data), - CONTENT_COMPRESSION => { - let (mut algo, mut settings) = (0u64, Vec::new()); - for x in Children::new(e.data) { - match x.id { - CONTENT_COMP_ALGO => algo = uint(x.data), - CONTENT_COMP_SETTINGS => settings = x.data.to_vec(), - _ => {} - } - } - comp = Some((algo, settings)); - } - _ => {} - } - } - let enc = if ty == 1 { - ContentEncoding::Encryption { scope } - } else { - let (algo, settings) = comp.unwrap_or((0, Vec::new())); - if algo == 3 { ContentEncoding::HeaderStripping { bytes: settings, scope } } else { ContentEncoding::Compression { algo, settings, scope } } - }; - list.push((order, enc)); - } - // highest order is applied last on muxing → first to undo; keep decode order - list.sort_by_key(|e| std::cmp::Reverse(e.0)); - list.into_iter().map(|e| e.1).collect() -} - -pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec { - let g = gcd(timestamp_scale, 1_000_000_000); - let timebase = (timestamp_scale / g, 1_000_000_000 / g); - let mut out = Vec::new(); - for te in Children::new(d).filter(|e| e.id == TRACK_ENTRY) { - let mut t = Track { - number: 0, - uid: 0, - kind: TrackKind::Other(0), - enabled: true, - default: true, - forced: false, - hearing_impaired: false, - lacing: true, - default_duration_ns: None, - name: String::new(), - language: "eng".into(), - language_bcp47: None, - codec_id: String::new(), - codec_private: Vec::new(), - codec_name: String::new(), - codec: crate::Codec::Other(String::new()), - codec_delay_ns: 0, - seek_pre_roll_ns: 0, - video: None, - audio: None, - encodings: Vec::new(), - timebase, - samples: Vec::new(), - sync: Vec::new(), - }; - for e in Children::new(te.data) { - match e.id { - TRACK_NUMBER => t.number = uint(e.data), - TRACK_UID => t.uid = uint(e.data), - TRACK_TYPE => t.kind = TrackKind::from_type(uint(e.data)), - FLAG_ENABLED => t.enabled = uint(e.data) != 0, - FLAG_DEFAULT => t.default = uint(e.data) != 0, - FLAG_FORCED => t.forced = uint(e.data) != 0, - FLAG_HEARING_IMPAIRED => t.hearing_impaired = uint(e.data) != 0, - FLAG_LACING => t.lacing = uint(e.data) != 0, - DEFAULT_DURATION => t.default_duration_ns = Some(uint(e.data)).filter(|&v| v > 0), - NAME => t.name = string(e.data), - LANGUAGE => t.language = string(e.data), - LANGUAGE_BCP47 => t.language_bcp47 = Some(string(e.data)), - CODEC_ID => t.codec_id = string(e.data), - CODEC_PRIVATE => t.codec_private = e.data.to_vec(), - CODEC_NAME => t.codec_name = string(e.data), - CODEC_DELAY => t.codec_delay_ns = uint(e.data), - SEEK_PRE_ROLL => t.seek_pre_roll_ns = uint(e.data), - VIDEO => t.video = Some(parse_video(e.data)), - AUDIO => t.audio = Some(parse_audio(e.data)), - CONTENT_ENCODINGS => t.encodings = parse_encodings(e.data), - _ => {} - } - } - if t.number == 0 { - continue; - } - if t.kind == TrackKind::Audio && t.audio.is_none() { - t.audio = Some(AudioInfo::default()); - } - // CodecPrivate may itself be header-stripped (scope bit 2 = private data) - for enc in &t.encodings { - if let ContentEncoding::HeaderStripping { bytes, scope } = enc - && scope & 2 != 0 - { - let mut p = bytes.clone(); - p.extend_from_slice(&t.codec_private); - t.codec_private = p; - } - } - t.codec = map_codec(&t.codec_id, &t.codec_private, t.audio.as_ref()); - out.push(t); - } - out -} - -#[cfg(test)] -mod metadata_depth_tests { - use super::*; - - #[test] - fn deeply_nested_chapters_stop_at_depth_limit() { - // Unknown-size elements make every pair the sole child of the previous ChapterAtom. - let data = [0xB6, 0xFF].repeat(100_000); - let chapter = parse_chapter(&data, 0); - let mut current = &chapter; - for _ in 0..MAX_META_DEPTH { - assert_eq!(current.children.len(), 1); - current = ¤t.children[0]; - } - assert!(current.children.is_empty()); - } - - #[test] - fn deeply_nested_simple_tags_stop_at_depth_limit() { - let data = [0x67, 0xC8, 0xFF].repeat(100_000); - let tag = parse_simple_tag(&data, 0); - let mut current = &tag; - for _ in 0..MAX_META_DEPTH { - assert_eq!(current.children.len(), 1); - current = ¤t.children[0]; - } - assert!(current.children.is_empty()); - } -} diff --git a/crates/matroska/src/mux.rs b/crates/matroska/src/mux.rs deleted file mode 100644 index f809b1beb..000000000 --- a/crates/matroska/src/mux.rs +++ /dev/null @@ -1,539 +0,0 @@ -//! Minimal Matroska / WebM muxer: SimpleBlocks (optionally laced), BlockGroups for explicit -//! durations, Cues, SeekHead, Info with Duration. Output must be `Write + Seek` (sizes and the -//! SeekHead are back-patched on [`MkvWriter::finish`]). - -use std::io::{Seek, SeekFrom, Write}; - -use crate::ebml::{write_id, write_size, write_unknown_size}; -use crate::error::{Error, Result}; -use crate::ids::*; -use crate::track::TrackKind; - -/// Lacing used for audio tracks. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LacingMode { - None, - Xiph, - Ebml, - /// Fixed-size lacing when consecutive frames have equal sizes, otherwise EBML lacing. - FixedOrEbml, -} - -/// Track description for [`MkvWriter`]. -#[derive(Clone, Debug)] -pub struct TrackSpec { - pub kind: TrackKind, - pub codec_id: String, - pub codec_private: Vec, - pub default_duration_ns: Option, - pub language: Option, - pub name: Option, - /// Pixel width/height for video tracks. - pub video_size: Option<(u32, u32)>, - /// (sampling frequency, channels, bit depth) for audio tracks. - pub audio: Option<(f64, u64, Option)>, - pub codec_delay_ns: u64, - pub seek_pre_roll_ns: u64, -} - -impl TrackSpec { - pub fn new(kind: TrackKind, codec_id: impl Into) -> Self { - Self { - kind, - codec_id: codec_id.into(), - codec_private: Vec::new(), - default_duration_ns: None, - language: None, - name: None, - video_size: None, - audio: None, - codec_delay_ns: 0, - seek_pre_roll_ns: 0, - } - } -} - -/// Muxer options. -#[derive(Clone, Debug)] -pub struct MuxOptions { - /// `webm` or `matroska`. - pub doc_type: String, - /// Nanoseconds per tick (default 1 000 000). - pub timestamp_scale: u64, - /// Start a new cluster at the next video keyframe (or any frame without video) after this long. - pub cluster_duration_ns: u64, - pub lacing: LacingMode, - /// Maximum frames per laced block. - pub max_lace_frames: usize, - pub title: Option, - pub writing_app: String, -} - -impl Default for MuxOptions { - fn default() -> Self { - Self { - doc_type: "matroska".into(), - timestamp_scale: 1_000_000, - cluster_duration_ns: 1_000_000_000, - lacing: LacingMode::None, - max_lace_frames: 8, - title: None, - writing_app: "filmcraft".into(), - } - } -} - -pub(crate) fn el(out: &mut Vec, id: u32, data: &[u8]) { - write_id(out, id); - write_size(out, data.len() as u64, 1); - out.extend_from_slice(data); -} - -pub(crate) fn el_uint(out: &mut Vec, id: u32, v: u64) { - let n = (8 - v.leading_zeros() as usize / 8).max(1); - el(out, id, &v.to_be_bytes()[8 - n..]); -} - -pub(crate) fn el_float(out: &mut Vec, id: u32, v: f64) { - el(out, id, &v.to_be_bytes()); -} - -pub(crate) fn el_str(out: &mut Vec, id: u32, s: &str) { - el(out, id, s.as_bytes()); -} - -fn void(out: &mut Vec, total: usize) { - debug_assert!(total >= 2); - let sl = if total - 2 <= 126 { 1 } else { 8 }; - write_id(out, VOID); - write_size(out, (total - 1 - sl) as u64, sl); - out.resize(out.len() + total - 1 - sl, 0); -} - -#[derive(Clone, Debug)] -struct Pending { - track: usize, - ts: i64, - frames: Vec>, -} - -const SEEK_HEAD_SPACE: usize = 128; - -/// Streaming Matroska/WebM writer. -pub struct MkvWriter { - w: W, - opts: MuxOptions, - tracks: Vec, - segment_size_pos: u64, - segment_data_start: u64, - seek_head_pos: u64, - duration_pos: u64, - info_pos: u64, - tracks_pos: u64, - cluster: Vec, - cluster_ts: Option, - cluster_start_ns: i64, - pending: Option, - cues: Vec<(u64, u64, u64)>, // (time ticks, track number, cluster position) - cued_tracks_in_cluster: Vec, - end_ns: i64, - has_video: bool, -} - -impl MkvWriter { - pub fn new(mut w: W, tracks: Vec, opts: MuxOptions) -> Result { - if tracks.is_empty() || opts.timestamp_scale == 0 { - return Err(Error::Invalid("muxer needs at least one track and a non-zero timestamp scale".into())); - } - let mut out = Vec::new(); - let mut h = Vec::new(); - el_uint(&mut h, EBML_VERSION, 1); - el_uint(&mut h, EBML_READ_VERSION, 1); - el_uint(&mut h, EBML_MAX_ID_LENGTH, 4); - el_uint(&mut h, EBML_MAX_SIZE_LENGTH, 8); - el_str(&mut h, DOC_TYPE, &opts.doc_type); - el_uint(&mut h, DOC_TYPE_VERSION, 4); - el_uint(&mut h, DOC_TYPE_READ_VERSION, 2); - el(&mut out, EBML, &h); - write_id(&mut out, SEGMENT); - let base = w.stream_position()?; - let segment_size_pos = base + out.len() as u64; - write_unknown_size(&mut out, 8); - let segment_data_start = base + out.len() as u64; - let seek_head_pos = segment_data_start; - void(&mut out, SEEK_HEAD_SPACE); - // Info - let info_pos = base + out.len() as u64; - let mut info = Vec::new(); - el_uint(&mut info, TIMESTAMP_SCALE, opts.timestamp_scale); - el_str(&mut info, MUXING_APP, "filmcraft-matroska"); - el_str(&mut info, WRITING_APP, &opts.writing_app); - if let Some(t) = &opts.title { - el_str(&mut info, TITLE, t); - } - let dur_off_in_info = info.len() + 2 + 1; // ID (2) + size (1) - el_float(&mut info, DURATION, 0.0); - write_id(&mut out, INFO); - write_size(&mut out, info.len() as u64, 1); - let duration_pos = base + out.len() as u64 + dur_off_in_info as u64; - out.extend_from_slice(&info); - // Tracks - let tracks_pos = base + out.len() as u64; - let mut tr = Vec::new(); - for (i, t) in tracks.iter().enumerate() { - let mut e = Vec::new(); - el_uint(&mut e, TRACK_NUMBER, i as u64 + 1); - el_uint(&mut e, TRACK_UID, 0x1000 + i as u64 + 1); - let ty = match t.kind { - TrackKind::Video => 1, - TrackKind::Audio => 2, - TrackKind::Subtitle => 0x11, - TrackKind::Complex => 3, - TrackKind::Logo => 0x10, - TrackKind::Buttons => 0x12, - TrackKind::Control => 0x20, - TrackKind::Metadata => 0x21, - TrackKind::Other(n) => n as u64, - }; - el_uint(&mut e, TRACK_TYPE, ty); - el_uint(&mut e, FLAG_LACING, (t.kind == TrackKind::Audio && opts.lacing != LacingMode::None) as u64); - if let Some(l) = &t.language { - el_str(&mut e, LANGUAGE, l); - } - if let Some(n) = &t.name { - el_str(&mut e, NAME, n); - } - el_str(&mut e, CODEC_ID, &t.codec_id); - if !t.codec_private.is_empty() { - el(&mut e, CODEC_PRIVATE, &t.codec_private); - } - if let Some(d) = t.default_duration_ns { - el_uint(&mut e, DEFAULT_DURATION, d); - } - if t.codec_delay_ns > 0 { - el_uint(&mut e, CODEC_DELAY, t.codec_delay_ns); - } - if t.seek_pre_roll_ns > 0 { - el_uint(&mut e, SEEK_PRE_ROLL, t.seek_pre_roll_ns); - } - if let Some((pw, ph)) = t.video_size { - let mut v = Vec::new(); - el_uint(&mut v, PIXEL_WIDTH, pw as u64); - el_uint(&mut v, PIXEL_HEIGHT, ph as u64); - el(&mut e, VIDEO, &v); - } - if let Some((rate, ch, bits)) = t.audio { - let mut a = Vec::new(); - el_float(&mut a, SAMPLING_FREQUENCY, rate); - el_uint(&mut a, CHANNELS, ch); - if let Some(b) = bits { - el_uint(&mut a, BIT_DEPTH, b); - } - el(&mut e, AUDIO, &a); - } - el(&mut tr, TRACK_ENTRY, &e); - } - el(&mut out, TRACKS, &tr); - w.write_all(&out)?; - let has_video = tracks.iter().any(|t| t.kind == TrackKind::Video); - let n = tracks.len(); - Ok(Self { - w, - opts, - tracks, - segment_size_pos, - segment_data_start, - seek_head_pos, - duration_pos, - info_pos, - tracks_pos, - cluster: Vec::new(), - cluster_ts: None, - cluster_start_ns: 0, - pending: None, - cues: Vec::new(), - cued_tracks_in_cluster: vec![false; n], - end_ns: 0, - has_video, - }) - } - - /// Add one frame. `pts_ns` is the presentation time (codec delay is added back for storage); - /// frames must be supplied in the desired file order. `duration_ns` forces a BlockGroup with a - /// BlockDuration when it differs from the track's DefaultDuration. - pub fn write_frame(&mut self, track: usize, pts_ns: i64, keyframe: bool, data: &[u8], duration_ns: Option) -> Result<()> { - let spec = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - // the Matroska ProRes mapping stores frames without the 8-byte `size`+`icpf` header - let data = if spec.codec_id == "V_PRORES" && data.get(4..8) == Some(b"icpf") { &data[8..] } else { data }; - let scale = self.opts.timestamp_scale as i64; - let stored_ns = pts_ns + spec.codec_delay_ns as i64; - if stored_ns < 0 { - return Err(Error::Invalid("negative timestamp".into())); - } - let ts = (stored_ns + scale / 2) / scale; - let dur_end = pts_ns + duration_ns.or(spec.default_duration_ns).unwrap_or(0) as i64; - self.end_ns = self.end_ns.max(dur_end).max(pts_ns); - let is_video = spec.kind == TrackKind::Video; - let lacing = spec.kind == TrackKind::Audio && self.opts.lacing != LacingMode::None; - let explicit_dur = duration_ns.filter(|&d| Some(d) != spec.default_duration_ns); - // new cluster? - let start_new = match self.cluster_ts { - None => true, - Some(cts) => { - let long = stored_ns - self.cluster_start_ns >= self.opts.cluster_duration_ns as i64; - (long && (!self.has_video || (is_video && keyframe))) || ts - cts > 30_000 || ts < cts - } - }; - if start_new { - self.flush_cluster()?; - self.cluster_ts = Some(ts); - self.cluster_start_ns = stored_ns; - el_uint(&mut self.cluster, TIMESTAMP, ts as u64); - } - let cts = self.cluster_ts.unwrap_or(0); - // cue: video keyframes, or the first frame of each track per cluster when there is no video - if (is_video && keyframe) || (!self.has_video && !self.cued_tracks_in_cluster[track]) { - self.cued_tracks_in_cluster[track] = true; - let pos = self.w.stream_position()? - self.segment_data_start; - if self.cues.last().is_none_or(|c| c.2 != pos || c.1 != track as u64 + 1) { - self.cues.push((ts as u64, track as u64 + 1, pos)); - } - } - if lacing && explicit_dur.is_none() { - let same = self.pending.as_ref().is_some_and(|p| p.track == track && p.frames.len() < self.opts.max_lace_frames); - if !same { - self.flush_pending(); - } - match &mut self.pending { - Some(p) => p.frames.push(data.to_vec()), - None => self.pending = Some(Pending { track, ts: ts - cts, frames: vec![data.to_vec()] }), - } - return Ok(()); - } - self.flush_pending(); - let mut block = Vec::new(); - write_size(&mut block, track as u64 + 1, 1); - block.extend_from_slice(&((ts - cts) as i16).to_be_bytes()); - if let Some(d) = explicit_dur { - block.push(0); - block.extend_from_slice(data); - let mut g = Vec::new(); - el(&mut g, BLOCK, &block); - el_uint(&mut g, BLOCK_DURATION, (d as i64 / scale) as u64); - if !keyframe { - el(&mut g, REFERENCE_BLOCK, &[0xFF]); - } - el(&mut self.cluster, BLOCK_GROUP, &g); - } else { - block.push(if keyframe { 0x80 } else { 0 }); - block.extend_from_slice(data); - el(&mut self.cluster, SIMPLE_BLOCK, &block); - } - Ok(()) - } - - fn flush_pending(&mut self) { - let Some(p) = self.pending.take() else { return }; - let mut block = Vec::new(); - write_size(&mut block, p.track as u64 + 1, 1); - block.extend_from_slice(&(p.ts as i16).to_be_bytes()); - let n = p.frames.len(); - let mode = if n == 1 { LacingMode::None } else { self.opts.lacing }; - let fixed = p.frames.iter().all(|f| f.len() == p.frames[0].len()); - match mode { - LacingMode::None => block.push(0x80), - LacingMode::Xiph => { - block.push(0x80 | 0x02); - block.push((n - 1) as u8); - for f in &p.frames[..n - 1] { - let mut s = f.len(); - while s >= 255 { - block.push(255); - s -= 255; - } - block.push(s as u8); - } - } - LacingMode::FixedOrEbml if fixed => { - block.push(0x80 | 0x04); - block.push((n - 1) as u8); - } - LacingMode::Ebml | LacingMode::FixedOrEbml => { - block.push(0x80 | 0x06); - block.push((n - 1) as u8); - write_size(&mut block, p.frames[0].len() as u64, 1); - for i in 1..n - 1 { - let d = p.frames[i].len() as i64 - p.frames[i - 1].len() as i64; - // signed VINT: smallest length whose range holds d - let mut len = 1; - while len < 8 && d.unsigned_abs() >= (1u64 << (7 * len - 1)) - 1 { - len += 1; - } - let v = (d + (1i64 << (7 * len - 1)) - 1) as u64 | (1u64 << (7 * len)); - block.extend_from_slice(&v.to_be_bytes()[8 - len..]); - } - } - } - for f in &p.frames { - block.extend_from_slice(f); - } - el(&mut self.cluster, SIMPLE_BLOCK, &block); - } - - fn flush_cluster(&mut self) -> Result<()> { - self.flush_pending(); - if self.cluster_ts.is_some() { - let mut out = Vec::new(); - el(&mut out, CLUSTER, &self.cluster); - self.w.write_all(&out)?; - } - self.cluster.clear(); - self.cluster_ts = None; - self.cued_tracks_in_cluster.iter_mut().for_each(|c| *c = false); - Ok(()) - } - - /// Write Cues, back-patch SeekHead / Segment size / Duration, and return the writer. - pub fn finish(mut self) -> Result { - self.flush_cluster()?; - let cues_pos = self.w.stream_position()?; - let mut cues = Vec::new(); - for &(time, track, pos) in &self.cues { - let mut ctp = Vec::new(); - el_uint(&mut ctp, CUE_TRACK, track); - el_uint(&mut ctp, CUE_CLUSTER_POSITION, pos); - let mut cp = Vec::new(); - el_uint(&mut cp, CUE_TIME, time); - el(&mut cp, CUE_TRACK_POSITIONS, &ctp); - el(&mut cues, CUE_POINT, &cp); - } - let has_cues = !cues.is_empty(); - if has_cues { - let mut out = Vec::new(); - el(&mut out, CUES, &cues); - self.w.write_all(&out)?; - } - let end = self.w.stream_position()?; - // SeekHead - let mut sh = Vec::new(); - let mut entries = vec![(INFO, self.info_pos), (TRACKS, self.tracks_pos)]; - if has_cues { - entries.push((CUES, cues_pos)); - } - for (id, pos) in entries { - let mut s = Vec::new(); - let mut idb = Vec::new(); - write_id(&mut idb, id); - el(&mut s, SEEK_ID, &idb); - el_uint(&mut s, SEEK_POSITION, pos - self.segment_data_start); - el(&mut sh, SEEK, &s); - } - let mut out = Vec::new(); - el(&mut out, SEEK_HEAD, &sh); - if out.len() + 2 > SEEK_HEAD_SPACE { - return Err(Error::Invalid("SeekHead does not fit its reserved space".into())); - } - let rest = SEEK_HEAD_SPACE - out.len(); - void(&mut out, rest); - self.w.seek(SeekFrom::Start(self.seek_head_pos))?; - self.w.write_all(&out)?; - // Duration - let dur = self.end_ns as f64 / self.opts.timestamp_scale as f64; - self.w.seek(SeekFrom::Start(self.duration_pos))?; - self.w.write_all(&dur.to_be_bytes())?; - // Segment size - let mut sz = Vec::new(); - write_size(&mut sz, end - self.segment_data_start, 8); - self.w.seek(SeekFrom::Start(self.segment_size_pos))?; - self.w.write_all(&sz)?; - self.w.seek(SeekFrom::Start(end))?; - self.w.flush()?; - Ok(self.w) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Demuxer, TrackKind}; - use std::io::Cursor; - - fn roundtrip(lacing: LacingMode) { - let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG"); - v.video_size = Some((16, 16)); - v.default_duration_ns = Some(40_000_000); - let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT"); - a.audio = Some((48000.0, 1, Some(16))); - a.default_duration_ns = Some(10_000_000); - let mut s = TrackSpec::new(TrackKind::Subtitle, "S_TEXT/UTF8"); - s.language = Some("fre".into()); - let opts = MuxOptions { lacing, ..Default::default() }; - let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![v, a, s], opts).unwrap(); - let mut expect = Vec::new(); - for i in 0..50i64 { - let vf = vec![i as u8; 100 + i as usize]; - m.write_frame(0, i * 40_000_000, i % 10 == 0, &vf, None).unwrap(); - expect.push((0usize, i * 40, vf, i % 10 == 0)); - for k in 0..4 { - let n = if lacing == LacingMode::FixedOrEbml { 960 } else { 900 + (i as usize * 7 + k * 13) % 300 }; - let af = vec![(i * 4 + k as i64) as u8; n]; - let t = i * 40 + k as i64 * 10; - m.write_frame(1, t * 1_000_000, true, &af, None).unwrap(); - expect.push((1, t, af, true)); - } - if i % 20 == 5 { - m.write_frame(2, i * 40_000_000, true, b"hello", Some(500_000_000)).unwrap(); - expect.push((2, i * 40, b"hello".to_vec(), true)); - } - } - let bytes = m.finish().unwrap().into_inner(); - let mut d = Demuxer::from_slice(&bytes).unwrap(); - assert!(d.file().warnings.is_empty(), "{:?}", d.file().warnings); - assert_eq!(d.tracks()[2].language, "fre"); - assert!(!d.file().cues.is_empty()); - assert_eq!(d.duration_ns(), Some(2_300_000_000)); - let got: Vec<_> = d.by_ref().map(|p| p.unwrap()).collect(); - assert_eq!(got.len(), expect.len()); - for (g, e) in got.iter().zip(&expect) { - assert_eq!((g.track, g.pts, &g.data, g.keyframe), (e.0, e.1, &e.2, e.3)); - } - let sub = got.iter().find(|p| p.track == 2).unwrap(); - assert_eq!(sub.duration, 500); - // the index agrees with the packet stream - let f = d.file(); - for (ti, t) in f.tracks.iter().enumerate() { - let pk: Vec<_> = got.iter().filter(|p| p.track == ti).collect(); - assert_eq!(t.samples.len(), pk.len()); - for (i, p) in pk.iter().enumerate() { - assert_eq!(f.read_sample(&bytes[..], ti, i).unwrap(), p.data); - assert_eq!(t.samples[i].pts, p.pts); - } - } - // seeking - let sp = d.seek(0, 1_230_000_000).unwrap(); - assert_eq!(sp.pts, 1200); - let p = d.next_packet().unwrap().unwrap(); - assert_eq!((p.track, p.pts, p.keyframe), (0, 1200, true)); - let sp = d.seek(1, 1_235_000_000).unwrap(); - assert_eq!(sp.pts, 1230); - let p = d.next_packet().unwrap().unwrap(); - assert_eq!((p.track, p.pts), (1, 1230)); - } - - #[test] - fn roundtrip_unlaced() { - roundtrip(LacingMode::None); - } - #[test] - fn roundtrip_xiph() { - roundtrip(LacingMode::Xiph); - } - #[test] - fn roundtrip_ebml() { - roundtrip(LacingMode::Ebml); - } - #[test] - fn roundtrip_fixed() { - roundtrip(LacingMode::FixedOrEbml); - } -} diff --git a/crates/matroska/src/source.rs b/crates/matroska/src/source.rs deleted file mode 100644 index da10d89ff..000000000 --- a/crates/matroska/src/source.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Random-access byte sources for the demuxer. - -use std::cell::RefCell; -use std::io::{self, Read, Seek, SeekFrom}; -use std::sync::Arc; - -/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). -/// -/// Same shape as `filmcraft_isobmff::ByteSource`, so one adapter can serve both demuxers. -pub trait ByteSource { - /// Total length in bytes. - fn len(&self) -> u64; - /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; - /// True if the source holds no bytes. - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -fn eof() -> io::Error { - io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") -} - -impl ByteSource for [u8] { - fn len(&self) -> u64 { - <[u8]>::len(self) as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let start = usize::try_from(offset).map_err(|_| eof())?; - let end = start.checked_add(buf.len()).ok_or_else(eof)?; - let src = self.get(start..end).ok_or_else(eof)?; - buf.copy_from_slice(src); - Ok(()) - } -} - -impl ByteSource for Vec { - fn len(&self) -> u64 { - self.as_slice().len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - self.as_slice().read_at(offset, buf) - } -} - -impl ByteSource for &T { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Arc { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Box { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -#[cfg(not(target_arch = "wasm32"))] -impl ByteSource for std::fs::File { - fn len(&self) -> u64 { - self.metadata().map(|m| m.len()).unwrap_or(0) - } - #[cfg(unix)] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) - } - #[cfg(windows)] - fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { - use std::os::windows::fs::FileExt; - while !buf.is_empty() { - let n = self.seek_read(buf, offset)?; - if n == 0 { - return Err(eof()); - } - offset += n as u64; - buf = &mut buf[n..]; - } - Ok(()) - } - #[cfg(not(any(unix, windows)))] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let mut f = self; - f.seek(SeekFrom::Start(offset))?; - f.read_exact(buf) - } -} - -/// Adapts any `Read + Seek` (e.g. `BufReader`, `Cursor>`) into a [`ByteSource`]. -/// -/// The length is taken once at construction; the stream position is tracked so sequential reads -/// do not issue redundant seeks. -pub struct ReadSeekSource { - inner: RefCell<(R, u64)>, - len: u64, -} - -impl ReadSeekSource { - /// Wrap a reader; seeks to the end once to learn the length. - pub fn new(mut r: R) -> io::Result { - let len = r.seek(SeekFrom::End(0))?; - Ok(Self { inner: RefCell::new((r, len)), len }) - } - /// Unwrap the reader. - pub fn into_inner(self) -> R { - self.inner.into_inner().0 - } -} - -impl ByteSource for ReadSeekSource { - fn len(&self) -> u64 { - self.len - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - if offset.checked_add(buf.len() as u64).is_none_or(|e| e > self.len) { - return Err(eof()); - } - let mut g = self.inner.borrow_mut(); - let (r, pos) = &mut *g; - if *pos != offset { - r.seek(SeekFrom::Start(offset))?; - *pos = offset; - } - let res = r.read_exact(buf); - match res { - Ok(()) => { - *pos = offset + buf.len() as u64; - Ok(()) - } - Err(e) => { - *pos = u64::MAX; // unknown; force a seek next time - Err(e) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slice_and_reader_sources() { - let v = vec![1u8, 2, 3, 4]; - let mut b = [0u8; 2]; - v.read_at(1, &mut b).unwrap(); - assert_eq!(b, [2, 3]); - assert!(v.read_at(3, &mut b).is_err()); - assert!(v.read_at(u64::MAX, &mut b).is_err()); - let r = ReadSeekSource::new(io::Cursor::new(v.clone())).unwrap(); - assert_eq!(r.len(), 4); - r.read_at(2, &mut b).unwrap(); - assert_eq!(b, [3, 4]); - r.read_at(0, &mut b).unwrap(); - assert_eq!(b, [1, 2]); - assert!(r.read_at(3, &mut b).is_err()); - } -} diff --git a/crates/matroska/src/synthetic_tests.rs b/crates/matroska/src/synthetic_tests.rs deleted file mode 100644 index f10df3ba8..000000000 --- a/crates/matroska/src/synthetic_tests.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Hand-built files exercising features FFmpeg does not write: unknown-size Segment/Clusters, -//! header stripping, BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32. - -use crate::codec::Codec; -use crate::ebml::{self, write_id, write_unknown_size}; -use crate::ids::*; -use crate::mux::{el, el_float, el_str, el_uint}; -use crate::track::ContentEncoding; -use crate::{Demuxer, OpenOptions, Packet, open_with}; - -fn block(track: u8, rel: i16, flags: u8, data: &[u8]) -> Vec { - let mut b = vec![0x80 | track]; - b.extend_from_slice(&rel.to_be_bytes()); - b.push(flags); - b.extend_from_slice(data); - b -} - -fn synthetic() -> Vec { - let mut f = Vec::new(); - let mut h = Vec::new(); - el_str(&mut h, DOC_TYPE, "matroska"); - el_uint(&mut h, DOC_TYPE_VERSION, 4); - el(&mut f, EBML, &h); - write_id(&mut f, SEGMENT); - write_unknown_size(&mut f, 8); - // Info with a correct CRC-32 and a Void - let mut info = Vec::new(); - el_uint(&mut info, TIMESTAMP_SCALE, 1_000_000); - el_str(&mut info, TITLE, "Synthetic"); - el(&mut info, VOID, &[0; 3]); - let mut crc_info = Vec::new(); - el(&mut crc_info, CRC32, &ebml::crc32(&info).to_le_bytes()); - crc_info.extend_from_slice(&info); - el(&mut f, INFO, &crc_info); - // Tracks - let mut v = Vec::new(); - el_uint(&mut v, TRACK_NUMBER, 1); - el_uint(&mut v, TRACK_UID, 11); - el_uint(&mut v, TRACK_TYPE, 1); - el_str(&mut v, CODEC_ID, "V_MPEG4/ISO/AVC"); - el(&mut v, CODEC_PRIVATE, &[1, 0x64, 0, 0x1F]); - el_uint(&mut v, DEFAULT_DURATION, 40_000_000); - let mut mm = Vec::new(); - for (id, val) in [(PRIMARY_R_X, 0.708), (PRIMARY_R_Y, 0.292), (PRIMARY_G_X, 0.17), (PRIMARY_G_Y, 0.797), (PRIMARY_B_X, 0.131), (PRIMARY_B_Y, 0.046)] { - el_float(&mut mm, id, val); - } - el_float(&mut mm, WHITE_POINT_X, 0.3127); - el_float(&mut mm, WHITE_POINT_Y, 0.329); - el_float(&mut mm, LUMINANCE_MAX, 1000.0); - el_float(&mut mm, LUMINANCE_MIN, 0.0001); - let mut col = Vec::new(); - el_uint(&mut col, MATRIX_COEFFICIENTS, 9); - el_uint(&mut col, RANGE, 2); - el_uint(&mut col, TRANSFER_CHARACTERISTICS, 16); - el_uint(&mut col, PRIMARIES, 9); - el_uint(&mut col, MAX_CLL, 1000); - el_uint(&mut col, MAX_FALL, 400); - el(&mut col, MASTERING_METADATA, &mm); - let mut vid = Vec::new(); - el_uint(&mut vid, PIXEL_WIDTH, 1440); - el_uint(&mut vid, PIXEL_HEIGHT, 1080); - el_uint(&mut vid, DISPLAY_WIDTH, 1920); - el_uint(&mut vid, DISPLAY_HEIGHT, 1080); - el(&mut vid, COLOUR, &col); - // rectangular, a quarter turn clockwise (portrait phone video) - let mut proj = Vec::new(); - el_uint(&mut proj, PROJECTION_TYPE, 0); - el_float(&mut proj, PROJECTION_POSE_ROLL, -90.0); - el(&mut vid, PROJECTION, &proj); - el(&mut v, VIDEO, &vid); - // header stripping: every frame starts with 00 00 00 01 - let mut comp = Vec::new(); - el_uint(&mut comp, CONTENT_COMP_ALGO, 3); - el(&mut comp, CONTENT_COMP_SETTINGS, &[0, 0, 0, 1]); - let mut enc = Vec::new(); - el(&mut enc, CONTENT_COMPRESSION, &comp); - let mut encs = Vec::new(); - el(&mut encs, CONTENT_ENCODING, &enc); - el(&mut v, CONTENT_ENCODINGS, &encs); - let mut a = Vec::new(); - el_uint(&mut a, TRACK_NUMBER, 2); - el_uint(&mut a, TRACK_TYPE, 2); - el_str(&mut a, CODEC_ID, "A_AAC/MPEG4/LC/SBR"); - el_str(&mut a, LANGUAGE, "ger"); - let mut au = Vec::new(); - el_float(&mut au, SAMPLING_FREQUENCY, 24000.0); - el_float(&mut au, OUTPUT_SAMPLING_FREQUENCY, 48000.0); - el_uint(&mut au, CHANNELS, 2); - el(&mut a, AUDIO, &au); - let mut tr = Vec::new(); - el(&mut tr, TRACK_ENTRY, &v); - el(&mut tr, TRACK_ENTRY, &a); - el(&mut f, TRACKS, &tr); - // Chapters - let mut disp = Vec::new(); - el_str(&mut disp, CHAP_STRING, "Intro"); - let mut atom = Vec::new(); - el_uint(&mut atom, CHAPTER_UID, 5); - el_uint(&mut atom, CHAPTER_TIME_START, 0); - el_uint(&mut atom, CHAPTER_TIME_END, 40_000_000); - el(&mut atom, CHAPTER_DISPLAY, &disp); - let mut ed = Vec::new(); - el(&mut ed, CHAPTER_ATOM, &atom); - let mut ch = Vec::new(); - el(&mut ch, EDITION_ENTRY, &ed); - el(&mut f, CHAPTERS, &ch); - // Attachments - let mut af = Vec::new(); - el_str(&mut af, FILE_NAME, "font.ttf"); - el_str(&mut af, FILE_MEDIA_TYPE, "font/ttf"); - el_uint(&mut af, FILE_UID, 77); - el(&mut af, FILE_DATA, &[9; 100]); - let mut att = Vec::new(); - el(&mut att, ATTACHED_FILE, &af); - el(&mut f, ATTACHMENTS, &att); - // Cluster 1 (unknown size): keyframe SimpleBlock, BlockGroup with ReferenceBlock + duration - write_id(&mut f, CLUSTER); - write_unknown_size(&mut f, 1); - el_uint(&mut f, TIMESTAMP, 1000); - el(&mut f, SIMPLE_BLOCK, &block(1, 0, 0x80, &[0x65, 1, 2])); - el(&mut f, SIMPLE_BLOCK, &block(2, 5, 0x80, &[0xAA; 10])); - let mut g = Vec::new(); - el(&mut g, BLOCK, &block(1, 40, 0, &[0x41, 3])); - el(&mut g, REFERENCE_BLOCK, &[0xD8]); - el_uint(&mut g, BLOCK_DURATION, 80); - el(&mut f, BLOCK_GROUP, &g); - el(&mut f, SIMPLE_BLOCK, &block(9, 0, 0x80, &[1])); // unknown track: ignored - // Cluster 2 (unknown size), ended by Tags - write_id(&mut f, CLUSTER); - write_unknown_size(&mut f, 8); - el_uint(&mut f, TIMESTAMP, 1120); - el(&mut f, SIMPLE_BLOCK, &block(1, 0, 0x80, &[0x65, 4])); - let mut st = Vec::new(); - el_str(&mut st, TAG_NAME, "ARTIST"); - el_str(&mut st, TAG_STRING, "Nobody"); - let mut tg = Vec::new(); - let mut tgt = Vec::new(); - el_uint(&mut tgt, TAG_TRACK_UID, 11); - el(&mut tg, TARGETS, &tgt); - el(&mut tg, SIMPLE_TAG, &st); - let mut tags = Vec::new(); - el(&mut tags, TAG, &tg); - el(&mut f, TAGS, &tags); - f -} - -#[test] -fn synthetic_file() { - let bytes = synthetic(); - let f = open_with(&bytes[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); - assert!(f.live); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - assert_eq!((f.crc_checked, f.crc_errors.len()), (1, 0)); - assert_eq!(f.info.title.as_deref(), Some("Synthetic")); - let v = &f.tracks[0]; - assert!(matches!(&v.codec, Codec::Avc { avcc } if avcc[1] == 0x64)); - let vi = v.video.as_ref().unwrap(); - assert_eq!(vi.pixel_aspect(), (4, 3)); - assert_eq!(vi.projection.as_ref().map(|p| p.roll), Some(-90.0)); - assert_eq!(vi.display_rotation(), Some(1)); - let c = vi.colour.as_ref().unwrap(); - assert!(c.full_range()); - assert_eq!((c.matrix_coefficients, c.transfer_characteristics, c.primaries, c.max_cll, c.max_fall), (Some(9), Some(16), Some(9), Some(1000), Some(400))); - let m = c.mastering.as_ref().unwrap(); - assert_eq!(m.primaries[1], (0.17, 0.797)); - assert_eq!(m.white_point, (0.3127, 0.329)); - assert_eq!((m.luminance_max, m.luminance_min), (1000.0, 0.0001)); - assert_eq!(v.encodings, vec![ContentEncoding::HeaderStripping { bytes: vec![0, 0, 0, 1], scope: 1 }]); - // legacy AAC ID: ASC synthesised with explicit SBR signalling (LC, 24 kHz, stereo → 48 kHz) - assert_eq!(f.tracks[1].codec, Codec::Aac { asc: vec![0x13, 0x10, 0x56, 0xE5, 0x98] }); - assert_eq!(f.tracks[1].language, "ger"); - assert_eq!(f.chapters[0].chapters[0].title(), Some("Intro")); - assert_eq!(f.chapters[0].chapters[0].end_ns, Some(40_000_000)); - assert_eq!(f.attachments.len(), 1); - let at = &f.attachments[0]; - assert_eq!((at.name.as_str(), at.media_type.as_str(), at.uid), ("font.ttf", "font/ttf", 77)); - assert_eq!(&bytes[at.data_offset as usize..][..100], &[9; 100]); - assert_eq!(f.tags[0].targets.track_uids, vec![11]); - assert_eq!(f.tags[0].simple[0].string.as_deref(), Some("Nobody")); - assert_eq!(f.clusters.len(), 2); - assert_eq!(v.samples.len(), 3); - assert_eq!(v.keyframes(), &[0, 2]); - - let d = Demuxer::from_slice(&bytes).unwrap(); - let p: Vec = d.map(|p| p.unwrap()).collect(); - assert_eq!(p.len(), 4); - assert_eq!((p[0].track, p[0].pts, p[0].keyframe, p[0].duration), (0, 1000, true, 40)); - assert_eq!(p[0].data, [0, 0, 0, 1, 0x65, 1, 2]); - assert_eq!((p[1].track, p[1].pts, p[1].data.len()), (1, 1005, 10)); - assert_eq!((p[2].pts, p[2].keyframe, p[2].duration), (1040, false, 80)); - assert_eq!(p[2].data, [0, 0, 0, 1, 0x41, 3]); - assert_eq!((p[3].pts, p[3].pts_ns), (1120, 1_120_000_000)); - assert_eq!(f.read_sample(&bytes[..], 0, 1).unwrap(), p[2].data); - - // CRC mismatch is reported, not fatal - let mut bad = bytes.clone(); - let title = bad.windows(9).position(|w| w == b"Synthetic").unwrap(); - bad[title] = b's'; - let f = open_with(&bad[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); - assert_eq!(f.crc_errors.len(), 1); - - // seeking without index or cues builds the index - let mut d = Demuxer::with_options(&bytes[..], OpenOptions { index: false, verify_crc: false }).unwrap(); - assert_eq!(d.file().tracks.len(), 2); - assert_eq!(d.seek(0, 1_100_000_000).unwrap().pts, 1000); - assert_eq!(d.seek(0, 1_130_000_000).unwrap().pts, 1120); - assert_eq!(d.next_packet().unwrap().unwrap().pts, 1120); - assert!(d.file().indexed); -} diff --git a/crates/matroska/src/track.rs b/crates/matroska/src/track.rs deleted file mode 100644 index ecdecabe8..000000000 --- a/crates/matroska/src/track.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! Track descriptions (RFC 9559 §5.1.4). - -use crate::codec::Codec; - -/// Track type (`TrackType`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrackKind { - Video, - Audio, - Complex, - Logo, - Subtitle, - Buttons, - Control, - Metadata, - Other(u8), -} - -impl TrackKind { - pub(crate) fn from_type(t: u64) -> TrackKind { - match t { - 1 => TrackKind::Video, - 2 => TrackKind::Audio, - 3 => TrackKind::Complex, - 0x10 => TrackKind::Logo, - 0x11 => TrackKind::Subtitle, - 0x12 => TrackKind::Buttons, - 0x20 => TrackKind::Control, - 0x21 => TrackKind::Metadata, - n => TrackKind::Other(n as u8), - } - } -} - -/// SMPTE ST 2086 mastering display metadata (`MasteringMetadata`). Chromaticities are CIE 1931 xy, -/// luminance in cd/m². -#[derive(Clone, Debug, Default, PartialEq)] -pub struct MasteringMetadata { - /// Red, green, blue primaries as (x, y). - pub primaries: [(f64, f64); 3], - pub white_point: (f64, f64), - pub luminance_max: f64, - pub luminance_min: f64, -} - -/// `Colour` element. Code points use ITU-T H.273 values (as Matroska does). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Colour { - /// `MatrixCoefficients` (H.273; 2 = unspecified). - pub matrix_coefficients: Option, - pub bits_per_channel: Option, - pub chroma_subsampling_horz: Option, - pub chroma_subsampling_vert: Option, - pub cb_subsampling_horz: Option, - pub cb_subsampling_vert: Option, - pub chroma_siting_horz: Option, - pub chroma_siting_vert: Option, - /// `Range`: 0 unspecified, 1 broadcast (limited), 2 full, 3 defined by matrix/transfer. - pub range: Option, - /// `TransferCharacteristics` (H.273). - pub transfer_characteristics: Option, - /// `Primaries` (H.273). - pub primaries: Option, - /// Maximum content light level (cd/m²). - pub max_cll: Option, - /// Maximum frame-average light level (cd/m²). - pub max_fall: Option, - pub mastering: Option, -} - -impl Colour { - /// True if `Range` says full range. - pub fn full_range(&self) -> bool { - self.range == Some(2) - } -} - -/// `Video` element. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct VideoInfo { - pub pixel_width: u32, - pub pixel_height: u32, - /// `DisplayWidth`/`DisplayHeight`, in `display_unit` (0 = pixels, 3 = display aspect ratio). - pub display_width: Option, - pub display_height: Option, - pub display_unit: u64, - /// Crop (top, bottom, left, right) in pixels. - pub crop: (u32, u32, u32, u32), - /// `FlagInterlaced`: 0 undetermined, 1 interlaced, 2 progressive. - pub flag_interlaced: u64, - /// `FieldOrder` (0 progressive, 1 tff, 2 undetermined, 6 bff, 9/14 interleaved). - pub field_order: Option, - pub stereo_mode: Option, - /// `AlphaMode`: 1 if BlockAdditions carry alpha. - pub alpha_mode: u64, - /// `ColourSpace` FourCC (uncompressed video). - pub colour_space: Option<[u8; 4]>, - pub colour: Option, - pub projection: Option, -} - -/// `Projection` element (RFC 9559 §5.1.4.1.28.41): pose angles in degrees. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Projection { - /// 0 rectangular, 1 equirectangular, 2 cubemap, 3 mesh. - pub projection_type: u64, - /// Clockwise around the up vector. - pub yaw: f64, - /// Counter-clockwise around the right vector. - pub pitch: f64, - /// Counter-clockwise around the forward vector. - pub roll: f64, -} - -impl VideoInfo { - /// Clockwise quarter turns (0–3) to display a rectangular video, from `ProjectionPoseRoll` - /// (a counter-clockwise angle, so −90 is a quarter turn clockwise). None for other - /// projections, flips (non-zero yaw / pitch) or angles that are not a multiple of 90°. - pub fn display_rotation(&self) -> Option { - let Some(p) = &self.projection else { return Some(0) }; - if p.projection_type != 0 || p.yaw != 0.0 || p.pitch != 0.0 || p.roll % 90.0 != 0.0 { - return None; - } - Some((-(p.roll / 90.0) as i64).rem_euclid(4) as u8) - } - - /// Pixel aspect ratio derived from the display size (pixels or aspect-ratio units), reduced. - pub fn pixel_aspect(&self) -> (u32, u32) { - let pw = self.pixel_width.saturating_sub(self.crop.2.saturating_add(self.crop.3)) as u64; - let ph = self.pixel_height.saturating_sub(self.crop.0.saturating_add(self.crop.1)) as u64; - let (Some(dw), Some(dh)) = (self.display_width, self.display_height) else { return (1, 1) }; - if pw == 0 || ph == 0 || dw == 0 || dh == 0 || !matches!(self.display_unit, 0 | 3) { - return (1, 1); - } - // PAR = (dw/dh) / (pw/ph) - let (n, d) = (dw as u64 * ph, dh as u64 * pw); - let g = gcd(n, d); - ((n / g) as u32, (d / g) as u32) - } -} - -pub(crate) fn gcd(mut a: u64, mut b: u64) -> u64 { - while b != 0 { - (a, b) = (b, a % b); - } - a.max(1) -} - -/// `Audio` element. -#[derive(Clone, Debug, PartialEq)] -pub struct AudioInfo { - /// `SamplingFrequency` (Hz). - pub sampling_frequency: f64, - /// `OutputSamplingFrequency` (e.g. SBR output rate). - pub output_sampling_frequency: Option, - pub channels: u64, - pub bit_depth: Option, -} - -impl Default for AudioInfo { - fn default() -> Self { - Self { sampling_frequency: 8000.0, output_sampling_frequency: None, channels: 1, bit_depth: None } - } -} - -/// One `ContentEncoding`. -#[derive(Clone, Debug, PartialEq)] -pub enum ContentEncoding { - /// `ContentCompAlgo` 3: the given bytes were stripped from the start of every frame. - HeaderStripping { bytes: Vec, scope: u64 }, - /// Other compression (0 zlib, 1 bzlib, 2 lzo1x): not supported for reading frame data. - Compression { algo: u64, settings: Vec, scope: u64 }, - /// Encryption (not supported for reading frame data). - Encryption { scope: u64 }, -} - -/// One entry of a track's sample (frame) table. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Sample { - /// Byte offset of the (stored) frame data in the file. - pub offset: u64, - /// Stored size (excluding header-stripped bytes; see [`Track::sample_size`]). - pub size: u32, - /// Presentation timestamp in the track timebase (`TimestampScale` ticks). - pub pts: i64, - /// Duration in timebase ticks (from `BlockDuration` or `DefaultDuration`), 0 if unknown. - pub duration: u64, - pub keyframe: bool, - /// Index of the owning cluster in [`crate::MkvFile::clusters`]. - pub cluster: u32, - /// Offset of the block element (SimpleBlock / BlockGroup) header. - pub block_offset: u64, - /// Index of this frame within a laced block. - pub lace: u16, - /// (offset, size) of the block's `BlockAdditional` with `BlockAddID` 1, when it has one and - /// is not laced. In WebM this is the alpha layer of a VP8/VP9 track with `AlphaMode` 1: a - /// second, independently coded stream whose luma plane is the frame's alpha. - pub addition: Option<(u64, u32)>, -} - -/// A Matroska track (`TrackEntry`) plus, once indexed, its sample table. -#[derive(Clone, Debug)] -pub struct Track { - pub number: u64, - pub uid: u64, - pub kind: TrackKind, - pub enabled: bool, - pub default: bool, - pub forced: bool, - pub hearing_impaired: bool, - pub lacing: bool, - /// `DefaultDuration` in nanoseconds. - pub default_duration_ns: Option, - pub name: String, - /// `Language` (ISO 639-2, default `eng`). - pub language: String, - pub language_bcp47: Option, - pub codec_id: String, - pub codec_private: Vec, - pub codec_name: String, - pub codec: Codec, - /// `CodecDelay` in nanoseconds. - pub codec_delay_ns: u64, - /// `SeekPreRoll` in nanoseconds. - pub seek_pre_roll_ns: u64, - pub video: Option, - pub audio: Option, - pub encodings: Vec, - /// Timebase numerator/denominator in seconds: `TimestampScale / 1e9`, reduced. - pub timebase: (u64, u64), - /// Frame table (empty unless the file was opened with indexing). - pub samples: Vec, - pub(crate) sync: Vec, -} - -impl Track { - /// Bytes prepended to every frame by header-stripping compression, if any. - pub fn stripped_header(&self) -> Option<&[u8]> { - self.encodings.iter().find_map(|e| match e { - ContentEncoding::HeaderStripping { bytes, scope } if scope & 1 != 0 => Some(bytes.as_slice()), - _ => None, - }) - } - - /// True if frames can be returned verbatim or with header stripping undone. - pub fn frames_readable(&self) -> bool { - self.encodings.iter().all(|e| match e { - ContentEncoding::HeaderStripping { .. } => true, - ContentEncoding::Compression { scope, .. } | ContentEncoding::Encryption { scope } => scope & 1 == 0, - }) - } - - /// Bytes to put in front of a stored frame of `stored_size` bytes to get the codec frame: - /// header-stripped bytes, or for `V_PRORES` the 8-byte frame header (`size` + `icpf`) that the - /// Matroska ProRes mapping omits. - pub fn frame_prefix(&self, stored_size: u64) -> Vec { - let mut p = self.stripped_header().map(<[u8]>::to_vec).unwrap_or_default(); - if matches!(self.codec, Codec::ProRes { .. }) { - let total = stored_size + p.len() as u64 + 8; - let mut h = (total as u32).to_be_bytes().to_vec(); - h.extend_from_slice(b"icpf"); - h.extend_from_slice(&p); - p = h; - } - p - } - - /// Frame size of sample `i` as returned by `read_sample` / packets (stored size plus prefix). - pub fn sample_size(&self, i: usize) -> Option { - let s = self.samples.get(i)?; - Some(s.size as u64 + self.frame_prefix(s.size as u64).len() as u64) - } - - /// Indices of keyframe samples, ascending by file order. - pub fn keyframes(&self) -> &[u32] { - &self.sync - } - - /// Timebase ticks → nanoseconds. - pub fn ticks_to_ns(&self, t: i64) -> i64 { - (t as i128 * self.timebase.0 as i128 * 1_000_000_000 / self.timebase.1 as i128) as i64 - } - - /// Nanoseconds → timebase ticks (floor). - pub fn ns_to_ticks(&self, ns: i64) -> i64 { - let n = ns as i128 * self.timebase.1 as i128; - let d = self.timebase.0 as i128 * 1_000_000_000; - n.div_euclid(d) as i64 - } - - /// Index of the last sample with `pts <= t` (in file order), or the first sample. - pub fn sample_at_pts(&self, t: i64) -> Option { - if self.samples.is_empty() { - return None; - } - let mut best: Option = None; - for (i, s) in self.samples.iter().enumerate() { - if s.pts <= t && best.is_none_or(|b| s.pts >= self.samples[b].pts) { - best = Some(i); - } - } - Some(best.unwrap_or(0)) - } - - /// Index of the keyframe with the greatest `pts <= t` (the first keyframe if none precedes `t`). - pub fn keyframe_before_pts(&self, t: i64) -> Option { - let mut best: Option = None; - for &k in &self.sync { - let s = &self.samples[k as usize]; - if s.pts <= t && best.is_none_or(|b| s.pts >= self.samples[b].pts) { - best = Some(k as usize); - } - } - best.or_else(|| self.sync.first().map(|&k| k as usize)) - } - - /// Index of the nearest keyframe at or before sample `index` in file order. - pub fn sync_sample_before(&self, index: usize) -> usize { - match self.sync.binary_search(&(index as u32)) { - Ok(i) => self.sync[i] as usize, - Err(0) => 0, - Err(i) => self.sync[i - 1] as usize, - } - } -} - -#[cfg(test)] -mod rotation_tests { - use super::{Projection, VideoInfo}; - - fn with_roll(projection_type: u64, yaw: f64, pitch: f64, roll: f64) -> VideoInfo { - VideoInfo { projection: Some(Projection { projection_type, yaw, pitch, roll }), ..Default::default() } - } - - #[test] - fn display_rotation_maps_rectangular_roll_to_clockwise_quarter_turns() { - assert_eq!(VideoInfo::default().display_rotation(), Some(0), "no Projection: as stored"); - // ProjectionPoseRoll is counter-clockwise: -90 is a quarter turn clockwise - for (roll, turns) in [(0.0, 0), (-90.0, 1), (180.0, 2), (-180.0, 2), (90.0, 3), (-270.0, 3), (270.0, 1), (360.0, 0)] { - assert_eq!(with_roll(0, 0.0, 0.0, roll).display_rotation(), Some(turns), "roll {roll}"); - } - } - - #[test] - fn display_rotation_leaves_non_rectangular_flipped_and_odd_angles_alone() { - assert_eq!(with_roll(1, 0.0, 0.0, -90.0).display_rotation(), None, "equirectangular"); - assert_eq!(with_roll(0, 180.0, 0.0, 0.0).display_rotation(), None, "yaw flip"); - assert_eq!(with_roll(0, 0.0, 180.0, 0.0).display_rotation(), None, "pitch flip"); - assert_eq!(with_roll(0, 0.0, 0.0, 45.0).display_rotation(), None, "not a quarter turn"); - assert_eq!(with_roll(0, 0.0, 0.0, f64::NAN).display_rotation(), None, "malformed angle"); - } -} - -#[cfg(test)] -mod aspect_overflow_tests { - use super::VideoInfo; - - #[test] - fn hostile_crop_sums_do_not_overflow() { - let video = VideoInfo { - pixel_width: 640, - pixel_height: 480, - display_width: Some(640), - display_height: Some(480), - crop: (u32::MAX, 1, u32::MAX, 1), - ..Default::default() - }; - assert_eq!(video.pixel_aspect(), (1, 1)); - } - - #[test] - fn ordinary_pixel_aspect_is_unchanged() { - let video = VideoInfo { pixel_width: 1920, pixel_height: 1080, display_width: Some(1440), display_height: Some(1080), ..Default::default() }; - assert_eq!(video.pixel_aspect(), (3, 4)); - } -} diff --git a/crates/matroska/tests/oracle.rs b/crates/matroska/tests/oracle.rs index 5eaae0a03..f53b09978 100644 --- a/crates/matroska/tests/oracle.rs +++ b/crates/matroska/tests/oracle.rs @@ -58,8 +58,14 @@ fn compare(ffprobe: &Path, path: &Path) -> (MkvFile, Vec) { assert!((pts - p.pts).abs() <= p.lace as i64, "{ctx}: pts ffprobe {pts} vs {}", p.pts); pts_checked += 1; } + // The last audio or video frame of a track cut by `-t` is shorter than a codec frame. + // ffmpeg 8 writes a shortened BlockDuration there and reports the codec frame duration; a + // remux by our writer stores DefaultDuration and ffprobe reports the shortened one. So + // that frame's duration is not compared. A subtitle's duration is its cue's, and is. + let last_of_track = packets[i + 1..].iter().all(|q| q.track != p.track); if let Some(dur) = int(&f["duration"]) && p.duration != 0 + && (!last_of_track || file.tracks[p.track].kind == TrackKind::Subtitle) { assert!((dur - p.duration as i64).abs() <= 1, "{ctx}: duration ffprobe {dur} vs {}", p.duration); dur_checked += 1; @@ -226,11 +232,11 @@ fn live_stream_without_cues() { } #[test] -fn hevc_hdr_colour() { +fn hevc_hdr_color() { let Some((_, f, _)) = run("hevc_hdr.mkv") else { return }; let t = &f.tracks[0]; assert!(matches!(&t.codec, Codec::Hevc { hvcc } if hvcc.first() == Some(&1))); - let c = t.video.as_ref().unwrap().colour.as_ref().expect("Colour element"); + let c = t.video.as_ref().unwrap().color.as_ref().expect("Colour element"); assert_eq!(c.primaries, Some(9)); assert_eq!(c.transfer_characteristics, Some(16)); assert_eq!(c.matrix_coefficients, Some(9)); @@ -286,12 +292,14 @@ fn laced_remux() { let specs: Vec = file .tracks .iter() - .map(|t| { + .enumerate() + .map(|(ti, t)| { let mut s = TrackSpec::new(t.kind, t.codec_id.clone()); s.codec_private = t.codec_private.clone(); + // audio without a DefaultDuration gets its mean packet spacing, so it can lace s.default_duration_ns = t.default_duration_ns.or_else(|| { - // give AAC a DefaultDuration so every lace gets a timestamp - matches!(t.codec, Codec::Aac { .. }).then(|| (1024.0 * 1e9 / t.audio.as_ref().unwrap().sampling_frequency) as u64) + let pts: Vec = packets.iter().filter(|p| p.track == ti).map(|p| p.pts_ns).collect(); + (t.kind == TrackKind::Audio && pts.len() > 1).then(|| ((pts[pts.len() - 1] - pts[0]) / (pts.len() as i64 - 1)) as u64) }); s.video_size = t.video.as_ref().map(|v| (v.pixel_width, v.pixel_height)); s.audio = t.audio.as_ref().map(|a| (a.sampling_frequency, a.channels, a.bit_depth)); @@ -316,7 +324,7 @@ fn laced_remux() { assert_eq!(got.len(), packets.len()); for (a, b) in got.iter().zip(&packets) { assert_eq!(a.data, b.data, "{src} {mode:?}: payload"); - assert_eq!(a.track, b.track); + assert_eq!((a.track, a.pts), (b.track, b.pts), "{src} {mode:?}: timestamp"); } std::fs::remove_file(&outp).unwrap(); } diff --git a/crates/media/examples/demo_frames.rs b/crates/media/examples/demo_frames.rs index 1d1b4a24e..697f8b234 100644 --- a/crates/media/examples/demo_frames.rs +++ b/crates/media/examples/demo_frames.rs @@ -12,7 +12,7 @@ fn main() { let rows = gens.len().div_ceil(cols); let mut sheet = image::RgbaImage::new(w * cols as u32, h * rows as u32); for (i, g) in gens.iter().enumerate() { - let px = render(g, w, h, 3.3, 79, FrameRate::FPS_23_976); + let px = render(g, w, h, 3.3, 79, FrameRate::FPS_23_976).unwrap(); let img = image::RgbaImage::from_raw(w, h, px).unwrap(); image::imageops::overlay(&mut sheet, &img, ((i % cols) as u32 * w) as i64, ((i / cols) as u32 * h) as i64); } diff --git a/crates/media/src/animated.rs b/crates/media/src/animated.rs index e20188a14..b327b89ed 100644 --- a/crates/media/src/animated.rs +++ b/crates/media/src/animated.rs @@ -1,11 +1,11 @@ -//! Animated images (APNG and animated GIF) as movie clips (#402). +//! Animated images (APNG, animated GIF, and animated WebP) as movie clips (#402). //! //! The still decoders read only the first picture of these files. [`open`] decodes every frame //! instead, composited onto the full canvas by the `image` crate (APNG blend and dispose operations, -//! GIF disposal methods), and keeps them with their timing: +//! GIF and WebP disposal methods), and keeps them with their timing: //! -//! - each frame lasts its own delay; GIF delays of 10 ms or less play as 100 ms and APNG delays -//! under 10 ms as 10 ms, as browsers do, so a zero delay never makes a zero-length frame; +//! - each frame lasts its own delay; GIF delays of 10 ms or less play as 100 ms and APNG and WebP +//! delays under 10 ms as 10 ms, as browsers do, so a zero delay never makes a zero-length frame; //! - consecutive identical frames are merged (one picture, the sum of their delays); //! - the clip is one pass through the animation (the file's loop count is not applied); //! - transparency is kept: the source reports alpha when any frame has a pixel that is not fully @@ -31,29 +31,37 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, pub const MAX_FRAMES: usize = 10_000; /// Most bytes of decoded RGBA pictures kept from one file (after merging identical frames). pub const MAX_DECODED_BYTES: usize = 1 << 30; +/// Most RGBA bytes in one animation canvas while its decoder produces a frame. +const MAX_DECODED_FRAME_BYTES: usize = 512 << 20; /// Animated image formats. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Format { Apng, Gif, + WebP, } impl Format { + /// Identify an animated image format from its container signature. fn sniff(b: &[u8]) -> Option { if b.starts_with(b"\x89PNG\r\n\x1a\n") { Some(Format::Apng) } else if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") { Some(Format::Gif) + } else if b.get(0..4) == Some(&b"RIFF"[..]) && b.get(8..12) == Some(&b"WEBP"[..]) { + Some(Format::WebP) } else { None } } + /// Return the codec label shown in media metadata. fn codec(self) -> &'static str { match self { Format::Apng => "APNG", Format::Gif => "GIF", + Format::WebP => "WebP", } } @@ -66,6 +74,7 @@ impl Format { let (numer, denom) = match self { Format::Gif if ms <= 10 => (100, 1), Format::Apng if ms < 10 => (10, 1), + Format::WebP if ms < 10 => (10, 1), _ => (numer, denom), }; let ticks = numer.saturating_mul(TICKS_PER_SECOND as u128) / denom.saturating_mul(1000); @@ -81,8 +90,8 @@ pub struct AnimatedSource { starts: Vec, } -/// Decode `bytes` as an animated image. `None` when it is not an APNG or GIF with at least two -/// frames that decode (the caller then opens it as a still). +/// Decode `bytes` as an animated image. `None` when it is not an APNG, GIF, or animated WebP with +/// at least two frames that decode (the caller then opens it as a still). pub fn open(name: &str, bytes: &[u8]) -> Option> { let format = Format::sniff(bytes)?; let frames = match format { @@ -99,19 +108,34 @@ pub fn open(name: &str, bytes: &[u8]) -> Option> { d.set_limits(Limits::default()).ok()?; d.into_frames() } + Format::WebP => { + let mut d = image::codecs::webp::WebPDecoder::new(Cursor::new(bytes)).ok()?; + if !d.has_animation() { + return None; + } + let (width, height) = d.dimensions(); + if rgba_bytes(width, height).is_none_or(|bytes| bytes > MAX_DECODED_FRAME_BYTES) { + return Some(Err(MediaError::Unsupported(format!("{name}: animated WebP canvas exceeds the {MAX_DECODED_FRAME_BYTES}-byte frame limit")))); + } + d.set_limits(Limits::default()).ok()?; + d.into_frames() + } }; AnimatedSource::from_frames(name, format, frames, bytes.len() as u64).map(Ok) } impl AnimatedSource { + /// Build a clip from decoded full-canvas frames, merging consecutive identical pictures. fn from_frames(name: &str, format: Format, frames: Frames<'_>, file_size: u64) -> Option { let mut pictures: Vec<(u32, u32, Vec)> = Vec::new(); let mut durations: Vec = Vec::new(); + let mut decoded_frames = 0usize; let mut decoded = 0usize; let mut has_alpha = false; for frame in frames.take(MAX_FRAMES) { // a damaged frame ends the animation at the frames before it let Ok(frame) = frame else { break }; + decoded_frames = decoded_frames.saturating_add(1); let ticks = format.frame_ticks(frame.delay()); let buffer = frame.into_buffer(); let (w, h) = buffer.dimensions(); @@ -131,7 +155,7 @@ impl AnimatedSource { pictures.push((w, h, rgba)); durations.push(ticks); } - if pictures.len() < 2 { + if pictures.len() < 2 && !(format == Format::WebP && decoded_frames >= 2) { return None; } let (width, height) = pictures.first().map(|p| (p.0, p.1))?; @@ -182,6 +206,11 @@ impl AnimatedSource { } } +/// Checked byte size of an RGBA8 image. +fn rgba_bytes(width: u32, height: u32) -> Option { + usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?.checked_mul(4) +} + /// The clip's frame rate: the rate of its most common frame duration, when that is a sensible video /// rate (1 to 120 frames per second), else 30 fps. Frames are shown for their own durations /// whatever this says; it sets the clip's timebase (e.g. a sequence made from the clip). @@ -207,14 +236,17 @@ fn rate_of(durations: &[Tick]) -> FrameRate { } impl MediaSource for AnimatedSource { + /// Return the clip's media metadata. fn info(&self) -> &MediaInfo { &self.info } + /// Return the picture displayed at the requested media time. fn video_frame(&self, req: FrameRequest) -> Result> { self.frames.get(self.index_at(req.time)).cloned().ok_or(MediaError::NoStream("video")) } + /// Animated image files do not provide audio. fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result { Err(MediaError::NoStream("audio")) } diff --git a/crates/media/src/animated_tests.rs b/crates/media/src/animated_tests.rs index be77ca879..d3db3b7de 100644 --- a/crates/media/src/animated_tests.rs +++ b/crates/media/src/animated_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::{FrameRequest, MediaKind, open_bytes}; +use crate::{FrameRequest, MediaError, MediaKind, open_bytes}; const W: u32 = 4; const H: u32 = 2; @@ -51,10 +51,71 @@ fn gif(frames: &[(Vec, u32)]) -> Vec { out } +/// A small animated WebP built from lossless WebP frame bitstreams. +fn webp(frames: &[(Vec, u32)]) -> Vec { + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(kind); + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_le_bytes()); + out.extend_from_slice(data); + if !data.len().is_multiple_of(2) { + out.push(0); + } + } + + fn u24(out: &mut [u8], value: u32) { + out[0] = value as u8; + out[1] = (value >> 8) as u8; + out[2] = (value >> 16) as u8; + } + + fn frame_chunk(pixels: &[u8], duration_ms: u32) -> Vec { + let mut encoded = Vec::new(); + image::codecs::webp::WebPEncoder::new_lossless(&mut encoded).encode(pixels, W, H, image::ExtendedColorType::Rgba8).unwrap(); + + let mut pos = 12; + let bitstream = loop { + let header = encoded.get(pos..pos + 8).unwrap(); + let size = u32::from_le_bytes(header[4..8].try_into().unwrap()) as usize; + let end = pos + 8 + size + size % 2; + if &header[..4] == b"VP8L" { + break encoded.get(pos..end).unwrap().to_vec(); + } + pos = end; + }; + + let mut data = vec![0; 16]; + u24(&mut data[6..9], W - 1); + u24(&mut data[9..12], H - 1); + u24(&mut data[12..15], duration_ms); + data[15] = 0x02; // replace the previous canvas instead of blending + data.extend_from_slice(&bitstream); + data + } + + let mut chunks = Vec::new(); + let mut extended = [0; 10]; + extended[0] = 0x12; // alpha and animation flags + u24(&mut extended[4..7], W - 1); + u24(&mut extended[7..10], H - 1); + chunk(&mut chunks, b"VP8X", &extended); + chunk(&mut chunks, b"ANIM", &[0, 0, 0, 0, 0, 0]); + for (pixels, duration_ms) in frames { + chunk(&mut chunks, b"ANMF", &frame_chunk(pixels, *duration_ms)); + } + + let mut out = b"RIFF".to_vec(); + out.extend_from_slice(&u32::try_from(chunks.len() + 4).unwrap().to_le_bytes()); + out.extend_from_slice(b"WEBP"); + out.extend_from_slice(&chunks); + out +} + +/// Convert a whole number of milliseconds to media ticks. fn ms(n: i64) -> Tick { Tick(n * TICKS_PER_SECOND / 1000) } +/// Read the decoded RGBA pixels shown at a media time. fn pixels(s: &dyn MediaSource, t: Tick) -> Vec { match &s.video_frame(FrameRequest::full(t)).unwrap().data { filmcraft_frame::PixelData::Rgba8(p) => p.to_vec(), @@ -111,13 +172,62 @@ fn animated_gif_plays_every_frame_with_transparency() { assert_eq!(&pixels(&*s, ms(40))[0..4], &[0, 255, 0, 255]); } +#[test] +fn animated_webp_plays_every_frame_with_timing_and_transparency() { + let (red, green) = (picture([255, 0, 0], 128), picture([0, 255, 0], 0)); + let bytes = webp(&[(red.clone(), 40), (green.clone(), 60)]); + let s = open_bytes("wave.webp", bytes.into(), &[]).unwrap(); + let info = s.info(); + assert_eq!(info.kind, MediaKind::Movie); + assert_eq!(info.video.as_ref().unwrap().codec, "WebP"); + assert_eq!(info.duration, ms(100)); + assert!(info.video.as_ref().unwrap().has_alpha); + assert_eq!(pixels(&*s, Tick::ZERO), red); + assert_eq!(pixels(&*s, ms(39)), red); + assert_eq!(pixels(&*s, ms(40)), green); +} + +#[test] +fn identical_webp_frames_remain_a_clip_when_merged() { + let frame = picture([20, 40, 60], 255); + let bytes = webp(&[(frame.clone(), 40), (frame, 60)]); + let decoded = open("hold.webp", &bytes).expect("animated WebP").expect("decodes"); + assert_eq!(decoded.frame_count(), 1, "equal pictures still merge"); + assert_eq!(decoded.info().kind, MediaKind::Movie); + assert_eq!(decoded.info().duration, ms(100), "merged frames keep their encoded duration"); + + let imported = open_bytes("hold.webp", bytes.into(), &[]).unwrap(); + assert_eq!(imported.info().kind, MediaKind::Movie, "the still-image fallback must not discard the duration"); + assert_eq!(imported.info().duration, ms(100)); +} + +#[test] +fn animated_webp_rejects_an_oversized_canvas_before_decoding_frames() { + let frame = picture([1, 2, 3], 255); + let mut bytes = webp(&[(frame.clone(), 40), (frame, 60)]); + // VP8X canvas width and height are stored as 24-bit values minus one. + for (offset, dimension) in [(24, 25_000u32), (27, 25_000u32)] { + let value = (dimension - 1).to_le_bytes(); + bytes[offset..offset + 3].copy_from_slice(&value[..3]); + } + assert!(matches!( + open("large.webp", &bytes), + Some(Err(MediaError::Unsupported(message))) if message.contains("frame limit") + )); +} + #[test] fn single_frame_files_stay_stills() { let one_frame_apng = apng(&[(picture([1, 2, 3], 255), (1, 10))]); let one_frame_gif = gif(&[(picture([1, 2, 3], 255), 100)]); + let one_frame_webp = webp(&[(picture([1, 2, 3], 255), 100)]); let mut plain = std::io::Cursor::new(Vec::new()); image::RgbaImage::from_raw(W, H, picture([1, 2, 3], 0)).unwrap().write_to(&mut plain, image::ImageFormat::Png).unwrap(); - for (name, bytes) in [("one.png", one_frame_apng), ("one.gif", one_frame_gif), ("plain.png", plain.into_inner())] { + let mut plain_webp = Vec::new(); + image::codecs::webp::WebPEncoder::new_lossless(&mut plain_webp).encode(&picture([1, 2, 3], 0), W, H, image::ExtendedColorType::Rgba8).unwrap(); + for (name, bytes) in + [("one.png", one_frame_apng), ("one.gif", one_frame_gif), ("one.webp", one_frame_webp), ("plain.png", plain.into_inner()), ("plain.webp", plain_webp)] + { assert!(open(name, &bytes).is_none(), "{name}"); let s = open_bytes(name, bytes.into(), &[]).unwrap(); assert_eq!(s.info().kind, MediaKind::Still, "{name}"); @@ -129,7 +239,8 @@ fn damaged_files_never_panic() { let frames: Vec<_> = (0..6u8).map(|i| (picture([i * 40, 0, 255 - i * 40], i * 40), (1, 10))).collect(); let png_bytes = apng(&frames); let gif_bytes = gif(&frames.iter().map(|(p, _)| (p.clone(), 100)).collect::>()); - for whole in [png_bytes, gif_bytes] { + let webp_bytes = webp(&frames.iter().map(|(p, _)| (p.clone(), 100)).collect::>()); + for whole in [png_bytes, gif_bytes, webp_bytes] { for cut in (0..whole.len()).step_by(7) { // truncated: opens as fewer frames, a still or an error, never a panic let _ = open_bytes("cut", whole[..cut].to_vec().into(), &[]); @@ -166,6 +277,8 @@ fn frame_delays_have_browser_minimums_and_never_overflow() { assert_eq!(Format::Apng.frame_ticks(d(40, 1)), ms(40)); assert_eq!(Format::Apng.frame_ticks(d(1000, 3)), Tick(TICKS_PER_SECOND / 3)); assert!(Format::Apng.frame_ticks(d(u32::MAX, 1)).0 > 0); + assert_eq!(Format::WebP.frame_ticks(d(0, 1)), ms(10)); + assert_eq!(Format::WebP.frame_ticks(d(40, 1)), ms(40)); } #[test] diff --git a/crates/media/src/generators.rs b/crates/media/src/generators.rs index 97d42d1de..482652438 100644 --- a/crates/media/src/generators.rs +++ b/crates/media/src/generators.rs @@ -3,6 +3,9 @@ //! //! Every generator is a pure function of (settings, media time, output scale): deterministic and //! resolution-independent, so low-resolution playback renders directly at the reduced size. +//! +//! Counting Leader beeps last one source frame at each second mark. Use exact audio sample +//! positions and the rational source frame rate for the gate, without rounded timestamp boundaries. use std::f32::consts::{PI, TAU}; use std::sync::Arc; @@ -16,6 +19,10 @@ use serde::{Deserialize, Serialize}; use crate::digits::{draw_text, text_width}; use crate::{AudioStreamInfo, FrameCache, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo}; +/// Generator frames are RGBA8 and cached. Keep one project-requested frame within the media +/// working-set budget before allocating it. +const MAX_GENERATOR_PIXELS: u64 = 1 << 25; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum DemoScene { Aurora, @@ -150,7 +157,7 @@ impl MediaSource for GeneratorSource { let (w, h) = (((v.width as f32 * s).round() as u32).max(2), ((v.height as f32 * s).round() as u32).max(2)); let t = rate.tick_of(frame).seconds() as f32; self.cache.get_or_insert_with((frame, bucket), || { - let px = render(&self.generator, w, h, t, frame, rate); + let px = render(&self.generator, w, h, t, frame, rate)?; Ok(Arc::new(VideoFrame::rgba8(w, h, px).with_pts(rate.tick_of(frame)))) }) } @@ -162,13 +169,14 @@ impl MediaSource for GeneratorSource { let mut out = AudioBuffer::silence(sample_rate, 2, frames); let sr = sample_rate as f32; let total = self.info.duration.to_units_floor(sample_rate as i64); + let frame_rate = self.info.frame_rate().sane(); for i in 0..frames { let n = start + i as i64; if n < 0 || n >= total { continue; } let t = n as f32 / sr; - let (l, r) = audio_sample(&self.generator, t, n); + let (l, r) = audio_sample(&self.generator, t, n, sample_rate, frame_rate); out.channels[0][i] = l; out.channels[1][i] = r; } @@ -188,7 +196,7 @@ fn hash(n: i64) -> f32 { (x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0 } -fn audio_sample(g: &Generator, t: f32, n: i64) -> (f32, f32) { +fn audio_sample(g: &Generator, t: f32, n: i64, sample_rate: u32, frame_rate: FrameRate) -> (f32, f32) { match g { Generator::BarsAndTone => { let s = (TAU * 1000.0 * t).sin() * db(-20.0); @@ -200,8 +208,9 @@ fn audio_sample(g: &Generator, t: f32, n: i64) -> (f32, f32) { } Generator::CountingLeader => { // 1 kHz beep for one frame at each second mark (the "2-pop" style beep). - let frac = t.fract(); - let s = if frac < 1.0 / 24.0 { (TAU * 1000.0 * t).sin() * db(-20.0) } else { 0.0 }; + let in_frame = sample_rate != 0 + && i128::from(n.rem_euclid(i64::from(sample_rate))) * i128::from(frame_rate.num) < i128::from(sample_rate) * i128::from(frame_rate.den); + let s = if in_frame { (TAU * 1000.0 * t).sin() * db(-20.0) } else { 0.0 }; (s, s) } Generator::Demo(scene) => demo_audio(*scene, t, n), @@ -286,9 +295,16 @@ fn to8(c: [f32; 3]) -> [u8; 4] { } /// Render a generator frame as straight-alpha sRGB RGBA8. -pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate) -> Vec { +pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate) -> Result> { + let pixels = u64::from(w).checked_mul(u64::from(h)).ok_or_else(|| MediaError::Decode("generator frame dimensions overflow".into()))?; + if pixels > MAX_GENERATOR_PIXELS { + return Err(MediaError::Decode(format!("generator frame has {pixels} pixels; limit is {MAX_GENERATOR_PIXELS}"))); + } let (wu, hu) = (w as usize, h as usize); - let mut px = vec![0u8; wu * hu * 4]; + let len = wu.checked_mul(hu).and_then(|n| n.checked_mul(4)).ok_or_else(|| MediaError::Decode("generator frame byte count overflows".into()))?; + let mut px = Vec::new(); + px.try_reserve_exact(len).map_err(|_| MediaError::Decode("cannot allocate generator frame".into()))?; + px.resize(len, 0); match g { Generator::BarsAndTone => bars(&mut px, wu, hu), Generator::ColorMatte { color } => { @@ -310,7 +326,7 @@ pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate }); } } - px + Ok(px) } fn bars(px: &mut [u8], w: usize, h: usize) { @@ -553,13 +569,14 @@ mod tests { let g = GeneratorSource::demo(DemoScene::OceanSunset); let a = g.video_frame(FrameRequest { time: Tick(TICKS_PER_SECOND), scale: 0.25 }).unwrap(); assert_eq!((a.width, a.height), (480, 270)); - let b = render(&Generator::Demo(DemoScene::OceanSunset), 480, 270, FrameRate::FPS_23_976.tick_of(23).seconds() as f32, 23, FrameRate::FPS_23_976); + let b = + render(&Generator::Demo(DemoScene::OceanSunset), 480, 270, FrameRate::FPS_23_976.tick_of(23).seconds() as f32, 23, FrameRate::FPS_23_976).unwrap(); assert_eq!(a.to_rgba8(), b); } #[test] fn bars_values() { - let px = render(&Generator::BarsAndTone, 800, 600, 0.0, 0, FrameRate::FPS_25); + let px = render(&Generator::BarsAndTone, 800, 600, 0.0, 0, FrameRate::FPS_25).unwrap(); // first bar after the side panel is 75% white let i = (10 * 800 + 150) * 4; assert_eq!(&px[i..i + 4], &[191, 191, 191, 255]); @@ -569,13 +586,127 @@ mod tests { assert!((peak - db(-20.0)).abs() < 1e-3); } + #[test] + fn counting_leader_beeps_last_exactly_one_source_frame() { + let sample_rate = 48_000; + for rate in [FrameRate::FPS_24, FrameRate::FPS_25, FrameRate::FPS_30, FrameRate::FPS_50, FrameRate::FPS_60, FrameRate::FPS_23_976, FrameRate::FPS_29_97] + { + let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); + let carrier = GeneratorSource::new(Generator::Tone { hz: 1000.0, db: -20.0 }, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); + let frame_samples = (sample_rate as u64 * rate.den as u64).div_ceil(rate.num as u64) as usize; + for start in [0, sample_rate as i64, 2 * sample_rate as i64] { + let audio = source.audio(start, frame_samples + 8, sample_rate).unwrap(); + let expected = carrier.audio(start, frame_samples, sample_rate).unwrap(); + for (channel, carrier_channel) in audio.channels.iter().zip(&expected.channels) { + assert!(carrier_channel.iter().any(|s| s.abs() > 0.05), "{rate:?}, start {start}: carrier should be audible"); + assert_eq!( + &channel[..frame_samples], + carrier_channel.as_slice(), + "{rate:?}, start {start}: in-frame beep differs from the ungated carrier" + ); + assert!(channel[frame_samples..].iter().all(|s| *s == 0.0), "{rate:?}, start {start}: beep extends beyond one frame"); + } + } + } + } + + #[test] + fn counting_leader_custom_rate_stops_at_exact_sample_boundary() { + let sample_rate = 41_000; + let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, FrameRate { num: 41, den: 1 }, Tick(3 * TICKS_PER_SECOND)); + let carrier = GeneratorSource::new(Generator::Tone { hz: 1000.0, db: -20.0 }, 64, 36, FrameRate { num: 41, den: 1 }, Tick(3 * TICKS_PER_SECOND)); + for start in [0, 41_000, 82_000] { + let audio = source.audio(start, 1_008, sample_rate).unwrap(); + let expected = carrier.audio(start, 1_000, sample_rate).unwrap(); + for (channel, carrier_channel) in audio.channels.iter().zip(&expected.channels) { + assert_eq!(&channel[..1_000], carrier_channel.as_slice(), "custom-rate beep stops before the frame boundary"); + assert!(channel[1_000..].iter().all(|s| *s == 0.0), "sample exactly at frame boundary must be silent"); + } + } + } + + #[test] + fn counting_leader_gate_is_independent_of_request_boundaries() { + for sample_rate in [41_000u32, 44_100, 48_000] { + for rate in [FrameRate::FPS_24, FrameRate::FPS_60, FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate { num: 41, den: 1 }] { + let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); + let frame_samples = (u64::from(sample_rate) * rate.den as u64).div_ceil(rate.num as u64) as usize; + let start = i64::from(sample_rate) - 3; + let audio = source.audio(start, frame_samples + 8, sample_rate).unwrap(); + for channel in &audio.channels { + assert!(channel[..3].iter().all(|s| *s == 0.0), "{rate:?}, {sample_rate}: previous second should be silent"); + assert!(channel[3..3 + frame_samples].iter().any(|s| s.abs() > 0.05), "{rate:?}, {sample_rate}: missing beep"); + assert!(channel[3 + frame_samples..].iter().all(|s| *s == 0.0), "{rate:?}, {sample_rate}: beep exceeds frame"); + } + let first = source.audio(start, 5, sample_rate).unwrap(); + let second = source.audio(start + 5, frame_samples + 3, sample_rate).unwrap(); + for c in 0..2 { + let split: Vec<_> = first.channels[c].iter().chain(&second.channels[c]).copied().collect(); + assert_eq!(split, audio.channels[c], "{rate:?}, {sample_rate}: split requests change samples"); + } + let end = source.audio(i64::from(sample_rate) + frame_samples as i64 - 2, 6, sample_rate).unwrap(); + assert!(end.channels.iter().all(|channel| channel[2..].iter().all(|s| *s == 0.0)), "{rate:?}, {sample_rate}: end-crossing block leaks"); + } + } + } + + #[test] + fn counting_leader_malformed_rates_use_default_frame_gate() { + let sample_rate = 48_000u32; + let default_rate = FrameRate::default(); + let frame_samples = (u64::from(sample_rate) * default_rate.den as u64).div_ceil(default_rate.num as u64); + let start = frame_samples as i64 - 2; + let carrier = GeneratorSource::new(Generator::Tone { hz: 1000.0, db: -20.0 }, 64, 36, default_rate, Tick(3 * TICKS_PER_SECOND)); + let expected = carrier.audio(start, 2, sample_rate).unwrap(); + assert!(expected.channels.iter().all(|channel| channel.iter().any(|sample| sample.abs() > 0.05))); + for rate in [FrameRate { num: 41, den: 0 }, FrameRate { num: 0, den: 0 }, FrameRate { num: i64::MIN, den: 1 }, FrameRate { num: 1, den: i64::MIN }] { + let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); + let audio = source.audio(start, 8, sample_rate).unwrap(); + for (channel, carrier_channel) in audio.channels.iter().zip(&expected.channels) { + assert_eq!(&channel[..2], carrier_channel.as_slice(), "{rate:?}: fallback beep stops before the frame boundary"); + assert!(channel[2..].iter().all(|sample| *sample == 0.0), "{rate:?}: fallback gate exceeds one frame"); + } + let fallback = GeneratorSource::new(Generator::CountingLeader, 64, 36, default_rate, Tick(3 * TICKS_PER_SECOND)); + assert_eq!(audio.channels, fallback.audio(start, 8, sample_rate).unwrap().channels, "{rate:?}: changed fallback"); + } + } + + #[test] + fn counting_leader_gate_handles_zero_and_hostile_rates() { + let invalid = [FrameRate { num: 0, den: 0 }, FrameRate { num: i64::MIN, den: 1 }, FrameRate { num: 1, den: i64::MIN }, FrameRate { num: 41, den: 0 }]; + let rates = + invalid.into_iter().chain([FrameRate { num: 1, den: i64::MAX }, FrameRate { num: i64::MAX, den: 1 }, FrameRate { num: i64::MAX, den: i64::MAX }]); + for rate in rates { + let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); + for sample_rate in [0, 1, 48_000, u32::MAX] { + for start in [-2, 0, 1] { + let audio = std::panic::catch_unwind(|| source.audio(start, 8, sample_rate)).unwrap().unwrap(); + assert!(audio.channels.iter().flatten().all(|s| s.is_finite()), "{rate:?}, {sample_rate}: non-finite audio"); + if sample_rate == 0 { + assert!(audio.channels.iter().flatten().all(|s| *s == 0.0)); + } + if invalid.contains(&rate) { + let fallback = GeneratorSource::new(Generator::CountingLeader, 64, 36, FrameRate::default(), Tick(3 * TICKS_PER_SECOND)); + assert_eq!(audio.channels, fallback.audio(start, 8, sample_rate).unwrap().channels, "{rate:?}: changed fallback"); + } + } + } + } + } + #[test] fn all_scenes_render() { for s in DemoScene::ALL { - let px = render(&Generator::Demo(s), 64, 36, 1.5, 36, FrameRate::FPS_24); + let px = render(&Generator::Demo(s), 64, 36, 1.5, 36, FrameRate::FPS_24).unwrap(); assert!(px.as_chunks::<4>().0.iter().any(|p| p[0] > 20 || p[1] > 20 || p[2] > 20)); } - let px = render(&Generator::CountingLeader, 64, 36, 1.5, 36, FrameRate::FPS_24); + let px = render(&Generator::CountingLeader, 64, 36, 1.5, 36, FrameRate::FPS_24).unwrap(); assert_eq!(px.len(), 64 * 36 * 4); } + + #[test] + fn oversized_generator_frames_are_rejected_before_allocation() { + let err = render(&Generator::BlackVideo, 32_768, 32_768, 0.0, 0, FrameRate::FPS_24).unwrap_err(); + assert!(err.to_string().contains("limit"), "{err}"); + } } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index f875705ed..ea8c4a7d7 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -53,6 +53,59 @@ pub enum MediaError { pub type Result = std::result::Result; +/// Up to 384 kHz audio (surpassing professional 192 kHz formats). +pub const MAX_AUDIO_SAMPLE_RATE: u32 = 384_000; +/// Up to 64 discrete channels (exceeding standard 32-channel immersive layouts). +pub const MAX_AUDIO_CHANNELS: u32 = 64; +/// Bounded planar audio buffer: 64 channels * 192 kHz * 10 seconds of f32 is ~492 MiB. +pub const MAX_AUDIO_BUFFER_BYTES: usize = 512 * 1024 * 1024; + +/// Bound container-advertised audio stream dimensions before they are trusted by the app or +/// allocator. +/// +/// Out-of-range values are rejected rather than clamped: publishing a lower sample rate without +/// resampling the samples would make the stream play at the wrong speed, and a clamped channel +/// count would misdescribe the interleaving. +pub fn bounded_audio_dimensions(sample_rate: u32, channels: u32) -> Result<(u32, u32)> { + if sample_rate == 0 || sample_rate > MAX_AUDIO_SAMPLE_RATE { + return Err(MediaError::Decode(format!("unsupported audio sample rate {sample_rate}"))); + } + if channels == 0 || channels > MAX_AUDIO_CHANNELS { + return Err(MediaError::Decode(format!("unsupported audio channel count {channels}"))); + } + Ok((sample_rate, channels)) +} + +/// Check an adapter-owned planar `f32` audio allocation before constructing it. +pub fn validate_audio_buffer_shape(channels: usize, frames: usize) -> Result<()> { + if channels == 0 || channels > MAX_AUDIO_CHANNELS as usize { + return Err(MediaError::Decode(format!("unsupported audio channel count {channels}"))); + } + let bytes = channels + .checked_mul(frames) + .and_then(|samples| samples.checked_mul(std::mem::size_of::())) + .ok_or_else(|| MediaError::Decode("audio buffer dimensions overflow".into()))?; + if bytes > MAX_AUDIO_BUFFER_BYTES { + return Err(MediaError::Decode(format!("audio buffer of {bytes} bytes exceeds the {} byte limit", MAX_AUDIO_BUFFER_BYTES))); + } + Ok(()) +} + +/// Number of source frames needed to resample `frames` output frames. Arithmetic is checked so +/// corrupted rates cannot wrap into a small allocation. +pub fn resample_source_frames(frames: usize, source_rate: u32, output_rate: u32) -> Result { + if output_rate == 0 { + return Err(MediaError::Decode("zero requested audio sample rate".into())); + } + let source = usize::try_from(source_rate).map_err(|_| MediaError::Decode("invalid source sample rate".into()))?; + let output = usize::try_from(output_rate).map_err(|_| MediaError::Decode("invalid output sample rate".into()))?; + frames + .checked_mul(source) + .and_then(|n| n.checked_add(output.saturating_sub(1))) + .map(|n| n / output) + .ok_or_else(|| MediaError::Decode("resampled audio window overflows".into())) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum MediaKind { /// Video, possibly with audio. @@ -203,7 +256,7 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result Result { } let table = h.end; let px_len = dw.checked_mul(dh).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("size".into()))?; + let frame_len = fw.checked_mul(fh).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("display window size".into()))?; + let decoded_bytes = px_len + .checked_mul(std::mem::size_of::()) + .and_then(|n| if h.data == h.display { Some(n) } else { frame_len.checked_mul(std::mem::size_of::()).and_then(|m| n.checked_add(m)) }) + .ok_or_else(|| bad("decoded size".into()))?; + if decoded_bytes > MAX_DECODED_BYTES { + return Err(MediaError::Unsupported(format!("{name}: EXR decoded buffers exceed the {MAX_DECODED_BYTES}-byte limit"))); + } + let chunks = dh.div_ceil(lpc); + let table_end = table.checked_add(chunks.checked_mul(8).ok_or_else(|| bad("offset table size".into()))?).ok_or_else(|| bad("offset table size".into()))?; + if table_end > b.len() { + return Err(bad("offset table is truncated".into())); + } + for i in 0..chunks { + let at = table + i * 8; + let off = le32(b, at).zip(le32(b, at + 4)).map(|(lo, hi)| u64::from(lo) | u64::from(hi) << 32); + let off = off.and_then(|o| usize::try_from(o).ok()).ok_or_else(|| bad(format!("offset of chunk {i}")))?; + let size = le32(b, off.checked_add(4).ok_or_else(|| bad(format!("chunk {i}")))?).ok_or_else(|| bad(format!("chunk {i}")))? as usize; + let end = off.checked_add(8).and_then(|n| n.checked_add(size)).ok_or_else(|| bad(format!("chunk {i}")))?; + if end > b.len() { + return Err(bad(format!("chunk {i} is truncated"))); + } + let y = le32(b, off).map(|y| y as i32); + let want = i64::from(h.data.y0) + (i * lpc) as i64; + if y.map(i64::from) != Some(want) { + return Err(bad(format!("chunk {i} is not at line {want}"))); + } + } let mut px = vec![0f32; px_len]; if !alpha { px.par_chunks_mut(4).for_each(|p| { diff --git a/crates/media/src/still/hdr.rs b/crates/media/src/still/hdr.rs index 4e0765bc3..12ff4e72d 100644 --- a/crates/media/src/still/hdr.rs +++ b/crates/media/src/still/hdr.rs @@ -197,8 +197,17 @@ pub fn decode(name: &str, b: &[u8]) -> Result { if (b.len().saturating_sub(pos) as u64).saturating_mul(4096) < (w as u64).saturating_mul(h as u64) { return Err(err(name, "truncated pixel data")); } - let mut px = vec![1f32; w * h * 4]; + // Validate every encoded scanline before allocating the complete float image. A short HDR + // header can otherwise request up to a GiB of zero-initialized pixels and only then fail on + // its first missing or malformed scanline. + let pixel_data = pos; let mut scan = vec![[0u8; 4]; n2]; + for _ in 0..n1 { + scanline(name, b, &mut pos, &mut scan)?; + } + + let mut px = vec![1f32; w * h * 4]; + pos = pixel_data; for i in 0..n1 { scanline(name, b, &mut pos, &mut scan)?; for (j, &p) in scan.iter().enumerate() { diff --git a/crates/media/src/still/hdr/tests.rs b/crates/media/src/still/hdr/tests.rs index 3e0b5564a..612b660bc 100644 --- a/crates/media/src/still/hdr/tests.rs +++ b/crates/media/src/still/hdr/tests.rs @@ -112,6 +112,9 @@ fn hostile_input_is_an_error_not_a_panic() { } assert!(decode("a.hdr", &file("-Y 2 +X 2", &[0; 8])).is_err()); assert!(decode("a.hdr", &file("-Y 99999999 +X 99999999", &[0; 16])).is_err()); + // Pass the coarse compressed-size check but fail scanline validation. This must be rejected + // before a 1 GiB float image is initialized. + assert!(decode("a.hdr", &file("-Y 67108864 +X 1", &[0; 16_384])).is_err()); assert!(decode("a.hdr", &file("-Y 0 +X 4", &[])).is_err()); assert!(decode("a.hdr", &file("-Y 2 -Y 2", &[0; 16])).is_err()); assert!(decode("a.hdr", &file("junk", &[0; 16])).is_err()); diff --git a/crates/media/src/wav.rs b/crates/media/src/wav.rs index a15f0d6b2..94da63438 100644 --- a/crates/media/src/wav.rs +++ b/crates/media/src/wav.rs @@ -115,7 +115,13 @@ impl WavSource { let (tag, ch, rate, bits) = fmt.ok_or_else(|| MediaError::Decode(format!("{name}: missing fmt chunk")))?; let (off, dlen) = data.ok_or_else(|| MediaError::Decode(format!("{name}: missing data chunk")))?; let float = tag == 3; - if !(tag == 1 || float) || ch == 0 || !matches!(bits, 8 | 16 | 24 | 32 | 64) { + if !(tag == 1 || float) + || ch == 0 + || rate == 0 + || u32::from(ch) > crate::MAX_AUDIO_CHANNELS + || rate > crate::MAX_AUDIO_SAMPLE_RATE + || !matches!(bits, 8 | 16 | 24 | 32 | 64) + { return Err(MediaError::Unsupported(format!("{name}: WAV format tag {tag}, {bits} bits"))); } let frame_bytes = u64::from(ch) * u64::from(bits / 8); @@ -191,6 +197,7 @@ impl MediaSource for WavSource { } fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result { let total = self.frames; + crate::validate_audio_buffer_shape(self.channels, frames)?; let mut out = AudioBuffer::silence(sample_rate, self.channels, frames); if total == 0 || sample_rate == 0 { return Ok(out); @@ -343,4 +350,15 @@ mod tests { let src = WavSource::parse("short.wav", with_chunk(&wav, b"bext", &[0u8; 100]).into()).unwrap(); assert_eq!(src.time_reference(), None); } + + #[test] + fn hostile_header_dimensions_are_rejected_before_publishing_or_reading() { + let mut wav = write_wav16(&[0.0; 2], 1, 48_000); + // `fmt ` starts at byte 12: channels at +22, rate at +24. + wav[22..24].copy_from_slice(&u16::MAX.to_le_bytes()); + assert!(WavSource::parse("wide.wav", wav.into()).is_err()); + let mut wav = write_wav16(&[0.0; 2], 1, 48_000); + wav[24..28].copy_from_slice(&(crate::MAX_AUDIO_SAMPLE_RATE + 1).to_le_bytes()); + assert!(WavSource::parse("fast.wav", wav.into()).is_err()); + } } diff --git a/crates/media/tests/exr_oracle.rs b/crates/media/tests/it/exr_oracle.rs similarity index 100% rename from crates/media/tests/exr_oracle.rs rename to crates/media/tests/it/exr_oracle.rs diff --git a/crates/media/tests/hdr_oracle.rs b/crates/media/tests/it/hdr_oracle.rs similarity index 100% rename from crates/media/tests/hdr_oracle.rs rename to crates/media/tests/it/hdr_oracle.rs diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs new file mode 100644 index 000000000..43b1a5bcb --- /dev/null +++ b/crates/media/tests/it/main.rs @@ -0,0 +1,3 @@ +mod exr_oracle; +mod hdr_oracle; +mod tiff_ycbcr; diff --git a/crates/media/tests/tiff_ycbcr.rs b/crates/media/tests/it/tiff_ycbcr.rs similarity index 100% rename from crates/media/tests/tiff_ycbcr.rs rename to crates/media/tests/it/tiff_ycbcr.rs diff --git a/crates/mpeg2v/README.md b/crates/mpeg2v/README.md index 0562ffed3..2ccc86feb 100644 --- a/crates/mpeg2v/README.md +++ b/crates/mpeg2v/README.md @@ -38,6 +38,9 @@ parameters, so a random-access point without its own sequence header still decod - Main Profile at Low/Main/High-1440/High Level, 4:2:2 Profile (IMX / D-10, XDCAM HD422), Simple Profile, MPEG-1 (including D pictures). 4:4:4 is refused (no profile in use for it). +- Pictures above 2^25 luma samples (8192 x 4096; 8K UHD at 7680 x 4320 fits, and the largest MPEG-2 level, + High, is 1920 x 1152) are refused as `Unsupported`: the 14-bit header fields allow 16383 x 16383, which + would ask for gigabytes per frame from hostile input. - Frame pictures with frame and field motion compensation, dual prime, frame and field DCT; field pictures with field, 16x8 and dual-prime motion compensation, the second field of a P frame predicted from the first; open and closed GOPs; B pictures without a forward @@ -77,7 +80,7 @@ are compared within a tolerance: **per frame max |Δ| ≤ 4 and PSNR ≥ 58 dB, samples differing**. Frame count, display order, picture types, interlacing and field order are exact (ffprobe). -`tests/oracle.rs` (FFmpeg-encoded elementary streams): +`tests/it/oracle.rs` (FFmpeg-encoded elementary streams): | Fixture | Content | Max Δ | Differing | PSNR | |---|---|---|---|---| @@ -110,7 +113,7 @@ FFmpeg's integer IDCT saturates differently from the exact one. ## Performance -`cargo test --release -p filmcraft-mpeg2v --test perf -- --ignored --nocapture` decodes the +`cargo test --release -p filmcraft-mpeg2v --test it perf:: -- --ignored --nocapture` decodes the 1080i fixture (1920×1080 4:2:0, 25 Mb/s, IBBP, field MC/DCT; FFmpeg-encoded) on an M4 Pro (14 cores) that was heavily loaded by other jobs (load average ≈ 190), best of five runs: **271 fps on one thread, 274 fps with slice rows on rayon** (runs under heavier momentary load @@ -126,5 +129,5 @@ fell to 77–150 fps). These are lower bounds. rounding, field addressing, edge clamping. - `src/tests.rs`: a hand-built intra picture decodes to its DC; truncation at every byte and 3000 random mutations never panic. -- `src/synth_tests.rs`, `tests/oracle.rs`: see above (single-threaded decode identical to the +- `src/synth_tests.rs`, `tests/it/oracle.rs`: see above (single-threaded decode identical to the threaded one). diff --git a/crates/mpeg2v/src/lib.rs b/crates/mpeg2v/src/lib.rs index 261742c25..6448207cd 100644 --- a/crates/mpeg2v/src/lib.rs +++ b/crates/mpeg2v/src/lib.rs @@ -44,6 +44,9 @@ use headers::{EXTENSION, GROUP_START, PICTURE_START, SEQUENCE_END, SEQUENCE_HEAD use mc::Frame; use slice::{PicParams, Target, decode_slice, slice_row}; +/// One access unit must not retain an unbounded number of cropped decoded pictures. +const MAX_OUTPUT_PICTURES_PER_ACCESS_UNIT: usize = 8; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("invalid MPEG video: {0}")] @@ -163,6 +166,9 @@ struct Seq { /// MPEG-1 pel_aspect_ratio (height/width of a sample) ×10000, Table 2-D.2. const MPEG1_PEL_ASPECT: [u32; 15] = [0, 10000, 6735, 7031, 7615, 8055, 8437, 8935, 9157, 9815, 10255, 10695, 10950, 11575, 12015]; +/// Largest accepted luma area (8192 x 4096; 8K UHD is 7680 x 4320). +const MAX_PICTURE_AREA: u64 = 1 << 25; + impl Seq { fn new(hdr: SequenceHeader, ext: Option, display: Option) -> Result { let (w, h) = match &ext { @@ -175,7 +181,9 @@ impl Seq { 3 => ChromaFormat::Yuv444, _ => return Err(Error::Invalid("reserved chroma_format".into())), }; - if w > 16383 || h > 16383 { + // The 14-bit sizes allow 16383 x 16383 (gigabytes per frame, several frames and a gray reference + // held); no real stream exceeds 8K, so cap the area there. + if w > 16383 || h > 16383 || u64::from(w) * u64::from(h) > MAX_PICTURE_AREA { return Err(Error::Unsupported(format!("{w}x{h}"))); } let progressive_sequence = ext.is_none_or(|e| e.progressive_sequence); @@ -333,11 +341,16 @@ pub fn access_units(es: &[u8]) -> Vec> { } PICTURE_START => { // picture_structure from the picture coding extension that follows - let structure = codes[k + 1..] + let structure = codes .iter() - .take_while(|c| c.1 == EXTENSION || c.1 == headers::USER_DATA) - .find_map(|&(p, c)| { - let end = codes.iter().find(|x| x.0 > p).map_or(es.len(), |x| x.0); + .enumerate() + .skip(k + 1) + .take_while(|(_, c)| c.1 == EXTENSION || c.1 == headers::USER_DATA) + .find_map(|(next, &(p, c))| { + // `next` is this unit's position in `codes`, so its successor gives the + // payload end directly. Searching `codes` from the beginning here made a + // stream of short picture/extension pairs quadratic to index. + let end = codes.get(next + 1).map_or(es.len(), |x| x.0); (c == EXTENSION).then(|| Extension::parse(&es[(p + 4).min(end)..end]).ok()).flatten() }) .and_then(|e| match e { @@ -413,6 +426,7 @@ pub struct Decoder { gray: Option>, pending: Option, out: Vec, + output_overflow: bool, errors: usize, last_error: Option<&'static str>, threads: bool, @@ -437,6 +451,7 @@ impl Decoder { gray: None, pending: None, out: Vec::new(), + output_overflow: false, errors: 0, last_error: None, threads: cfg!(feature = "threads"), @@ -466,6 +481,7 @@ impl Decoder { /// Decode a chunk of the elementary stream holding whole pictures (an access unit) tagged /// with `pts`. Returns the pictures that became displayable, in display order. pub fn decode(&mut self, data: &[u8], pts: i64) -> Result> { + self.output_overflow = false; let codes = start_codes(data); let mut err = None; for (k, &(pos, code)) in codes.iter().enumerate() { @@ -544,6 +560,10 @@ impl Decoder { } self.apply_sequence(); self.finish_picture(data, pts); + if self.output_overflow { + self.out.clear(); + return Err(Error::Invalid("access unit produces too many decoded pictures".into())); + } let out = std::mem::take(&mut self.out); match err { Some(e) if out.is_empty() && self.seq.is_none() => Err(e), @@ -561,7 +581,7 @@ impl Decoder { { a.shown = true; let p = to_picture(&a.frame, &a.meta); - self.out.push(p); + self.push_output(p); } std::mem::take(&mut self.out) } @@ -574,6 +594,7 @@ impl Decoder { self.building = None; self.pending = None; self.out.clear(); + self.output_overflow = false; self.closed_gop = false; } @@ -597,6 +618,11 @@ impl Decoder { fn finish_picture(&mut self, data: &[u8], pts: i64) { let Some(pd) = self.pending.take() else { return }; + if pd.slices.is_empty() { + self.errors += 1; + self.last_error = Some("picture has no slices"); + return; + } let Some(seq) = self.seq.clone() else { self.errors += 1; return; @@ -696,13 +722,21 @@ impl Decoder { if let Some(mut prev) = self.bwd.take() { if !prev.shown { prev.shown = true; - self.out.push(to_picture(&prev.frame, &prev.meta)); + self.push_output(to_picture(&prev.frame, &prev.meta)); } self.fwd = Some(prev); } self.bwd = Some(Anchor { frame: Arc::new(frame), meta, shown: false }); } else { - self.out.push(to_picture(&frame, &meta)); + self.push_output(to_picture(&frame, &meta)); + } + } + + fn push_output(&mut self, picture: Picture) { + if self.out.len() >= MAX_OUTPUT_PICTURES_PER_ACCESS_UNIT { + self.output_overflow = true; + } else { + self.out.push(picture); } } } diff --git a/crates/mpeg2v/src/synth_tests.rs b/crates/mpeg2v/src/synth_tests.rs index 0d4dd5453..33baf11a8 100644 --- a/crates/mpeg2v/src/synth_tests.rs +++ b/crates/mpeg2v/src/synth_tests.rs @@ -725,7 +725,7 @@ fn check(cfg: Cfg) { } let psnr = 10.0 * (255f64 * 255.0 * n as f64 / sse.max(1e-9)).log10(); println!("{}: {} frames, max diff {worst}, {:.3}% differ, PSNR {psnr:.2} dB", cfg.name, pics.len(), 100.0 * differ as f64 / n as f64); - // the same criteria as the ffmpeg-encoded fixtures (tests/oracle.rs) + // the same criteria as the ffmpeg-encoded fixtures (tests/it/oracle.rs) assert!(worst <= 4 && psnr >= 58.0, "{} (seed {:#x}): max diff {worst}, PSNR {psnr:.2}, first at {first_bad:?}", cfg.name, cfg.seed); } diff --git a/crates/mpeg2v/src/tests.rs b/crates/mpeg2v/src/tests.rs index ca5f2e5e5..eeae62aae 100644 --- a/crates/mpeg2v/src/tests.rs +++ b/crates/mpeg2v/src/tests.rs @@ -1,5 +1,5 @@ //! Unit tests over hand-built streams (headers, robustness). Decoding accuracy against ffmpeg is -//! in `tests/oracle.rs`. +//! in `tests/it/oracle.rs`. use super::*; diff --git a/crates/mpeg2v/tests/fuzz_hostile.rs b/crates/mpeg2v/tests/fuzz_hostile.rs new file mode 100644 index 000000000..5d3aa21a2 --- /dev/null +++ b/crates/mpeg2v/tests/fuzz_hostile.rs @@ -0,0 +1,200 @@ +//! Hostile-input robustness: truncation at every length, byte/bit mutation and header-field +//! sweeps over a small ffmpeg-generated stream must never panic or run away (errors are fine). +//! Skips when ffmpeg is missing. + +#[path = "it/common/mod.rs"] +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use filmcraft_mpeg2v::{Decoder, access_units, probe, scan_access_unit}; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +const SPECS: [common::Spec; 3] = [ + common::Spec { + name: "m2v_fuzz_seed", + lavfi: "testsrc2=size=64x48:rate=25", + frames: 8, + enc: &["-c:v", "mpeg2video", "-bf", "2", "-g", "6", "-b:v", "500k"], + pix_fmt: "yuv420p", + }, + common::Spec { + name: "m2v_fuzz_seed_interlaced", + lavfi: "testsrc2=size=96x64:rate=25,tinterlace=mode=interleave_top,setfield=tff", + frames: 6, + enc: &["-c:v", "mpeg2video", "-flags", "+ilme+ildct", "-bf", "1", "-g", "6", "-b:v", "800k"], + pix_fmt: "yuv420p", + }, + common::Spec { + name: "m2v_fuzz_seed_422", + lavfi: "testsrc2=size=64x48:rate=25", + frames: 4, + enc: &["-c:v", "mpeg2video", "-pix_fmt", "yuv422p", "-profile:v", "0", "-b:v", "1M"], + pix_fmt: "yuv422p", + }, +]; + +static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|info| { + PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + eprintln!("PANIC: {info}"); + })); + }); +} + +fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); + count_panics(); + let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); + let res = catch_unwind(AssertUnwindSafe(|| { + let _ = probe(d); + let _ = scan_access_unit(d); + for threads in [false, true] { + let mut dec = Decoder::new(); + dec.set_threads(threads); + for (i, r) in access_units(d).into_iter().enumerate() { + let _ = dec.decode(&d[r], i as i64); + } + // also the whole buffer as one unit + let _ = dec.decode(d, 99); + let _ = dec.flush(); + } + })); + assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); +} + +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + +/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=2500` runs all of it (see [`Depth`]). +#[test] +fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("mpeg2v fuzz") else { return }; + let depth = Depth::from_env(); + for spec in &SPECS { + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "mpeg2video") { + filmcraft_testkit::skip("mpeg2v fuzz", &format!("this ffmpeg has no mpeg2video encoder (seed {})", spec.name)); + continue; + } + let Some(path) = common::make(&ff, spec) else { + panic!("ffmpeg has mpeg2video but could not encode the {} seed", spec.name); + }; + fuzz(&depth, spec.name, &std::fs::read(path).unwrap()); + } +} + +fn fuzz(depth: &Depth, name: &str, seed: &[u8]) { + // every truncation length (stride keeps it quick on larger seeds) + for n in depth.truncations(seed.len()) { + run_all(&seed[..n], &format!("{name} truncate {n}")); + } + // header region: every byte to 0 / 0xff / 0x80 / each single bit + for p in 0..seed.len().min(96) { + for (i, v) in VALUES.into_iter().enumerate() { + if !depth.value(p, i) { + continue; + } + let mut d = seed.to_vec(); + d[p] = v; + run_all(&d, &format!("{name} byte {p}={v:#x}")); + } + for b in 0..8 { + if !depth.bit(p, b) { + continue; + } + let mut d = seed.to_vec(); + d[p] ^= 1 << b; + run_all(&d, &format!("{name} flip {p}.{b}")); + } + } + // random multi-mutations + let mut rng = Rng(0x9e3779b97f4a7c15); + // Skipped rounds still draw their mutations, so round i matches round i of a full run. + for i in 0..depth.rounds { + let mut d = seed.to_vec(); + for _ in 0..1 + rng.below(6) { + let p = rng.below(d.len()); + if rng.below(2) == 0 { + d[p] ^= 1 << rng.below(8); + } else { + d[p] = rng.next() as u8; + } + } + if depth.round(i) { + run_all(&d, &format!("{name} random {i}")); + } + } +} + +const VALUES: [u8; 4] = [0, 0xff, 0x80, 0x01]; + +/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 600 truncation +/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random +/// rounds per seed: 2500 is the full depth, more hunts longer. Unset, CI runs a third of the +/// truncation points and of the full depth's random rounds and, for each header byte, one value +/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC +/// day number), so every case of the full sweep runs within a few days, and a random round's +/// mutations match the same round of a full run. +struct Depth { + full: bool, + truncations: usize, + rounds: usize, + rotation: usize, +} + +impl Depth { + fn from_env() -> Self { + match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) { + Some(rounds) => Depth { full: true, truncations: 600, rounds, rotation: 0 }, + None => { + let rotation = rotation(); + eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=2500 runs everything)"); + Depth { full: false, truncations: 600, rounds: 2500, rotation } + } + } + } + /// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one. + fn truncations(&self, len: usize) -> std::iter::StepBy> { + let stride = (len / self.truncations).max(1); + let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) }; + (start..len).step_by(step) + } + fn value(&self, p: usize, i: usize) -> bool { + self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len() + } + fn bit(&self, p: usize, b: usize) -> bool { + self.full || (b + p + self.rotation % 4).is_multiple_of(4) + } + fn round(&self, i: usize) -> bool { + self.full || i % 3 == self.rotation % 3 + } +} + +/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC). +fn rotation() -> usize { + std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + usize::try_from(secs / 86_400).unwrap_or(0) + }) +} diff --git a/crates/mpeg2v/tests/common/mod.rs b/crates/mpeg2v/tests/it/common/mod.rs similarity index 100% rename from crates/mpeg2v/tests/common/mod.rs rename to crates/mpeg2v/tests/it/common/mod.rs diff --git a/crates/mpeg2v/tests/it/hostile_dims.rs b/crates/mpeg2v/tests/it/hostile_dims.rs new file mode 100644 index 000000000..01dcf25f8 --- /dev/null +++ b/crates/mpeg2v/tests/it/hostile_dims.rs @@ -0,0 +1,73 @@ +//! A 14-byte sequence header must not make the decoder size its frame buffers at 16383 x 16383 +//! (gigabytes per picture). Hand-built, no ffmpeg. + +use filmcraft_mpeg2v::Decoder; + +#[derive(Default)] +struct Bits { + bytes: Vec, + n: usize, +} + +impl Bits { + fn put(&mut self, value: u32, bits: usize) { + for i in (0..bits).rev() { + if self.n.is_multiple_of(8) { + self.bytes.push(0); + } + if (value >> i) & 1 == 1 + && let Some(b) = self.bytes.last_mut() + { + *b |= 0x80 >> (self.n % 8); + } + self.n += 1; + } + } +} + +/// Sequence header plus sequence extension for a `w` x `h` 4:4:4 stream. +fn sequence(w: u32, h: u32, chroma_format: u32) -> Vec { + let mut s = Bits::default(); + s.put(w & 0xfff, 12); + s.put(h & 0xfff, 12); + s.put(1, 4); // aspect_ratio_information + s.put(4, 4); // frame_rate_code + s.put(1000, 18); // bit_rate_value + s.put(1, 1); // marker + s.put(100, 10); // vbv_buffer_size_value + s.put(0, 3); // constrained_parameters_flag, load_intra, load_non_intra + let mut e = Bits::default(); + e.put(1, 4); // sequence_extension + e.put(0x48, 8); // profile_and_level_indication + e.put(1, 1); // progressive_sequence + e.put(chroma_format, 2); + e.put(w >> 12, 2); + e.put(h >> 12, 2); + e.put(0, 12); // bit_rate_extension + e.put(1, 1); // marker + e.put(0, 8 + 1 + 2 + 5); // vbv_buffer_size_extension, low_delay, frame_rate_extension_n/d + let mut out = vec![0, 0, 1, 0xb3]; + out.extend(s.bytes); + out.extend([0, 0, 1, 0xb5]); + out.extend(e.bytes); + out +} + +fn accepted(w: u32, h: u32, chroma_format: u32) -> bool { + let mut dec = Decoder::new(); + let _ = dec.decode(&sequence(w, h, chroma_format), 0); + dec.sequence().is_some() +} + +#[test] +fn ordinary_sequences_are_accepted() { + assert!(accepted(1920, 1080, 1)); + assert!(accepted(7680, 4320, 3)); +} + +#[test] +fn gigantic_sequences_are_refused() { + assert!(!accepted(16383, 16383, 3)); + assert!(!accepted(16383, 16383, 1)); + assert!(!accepted(8192, 8192, 1)); +} diff --git a/crates/mpeg2v/tests/it/main.rs b/crates/mpeg2v/tests/it/main.rs new file mode 100644 index 000000000..bf473ddbb --- /dev/null +++ b/crates/mpeg2v/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod hostile_dims; +mod oracle; +mod perf; diff --git a/crates/mpeg2v/tests/oracle.rs b/crates/mpeg2v/tests/it/oracle.rs similarity index 99% rename from crates/mpeg2v/tests/oracle.rs rename to crates/mpeg2v/tests/it/oracle.rs index eed298c7d..00473c955 100644 --- a/crates/mpeg2v/tests/oracle.rs +++ b/crates/mpeg2v/tests/it/oracle.rs @@ -3,7 +3,7 @@ //! definition (any IEEE 1180-accurate IDCT conforms), so frames are compared within a tolerance; //! frame count and display order are exact. -mod common; +use crate::common; use common::*; diff --git a/crates/mpeg2v/tests/perf.rs b/crates/mpeg2v/tests/it/perf.rs similarity index 87% rename from crates/mpeg2v/tests/perf.rs rename to crates/mpeg2v/tests/it/perf.rs index 44281499c..10aaa1d6d 100644 --- a/crates/mpeg2v/tests/perf.rs +++ b/crates/mpeg2v/tests/it/perf.rs @@ -1,7 +1,7 @@ -//! Decode speed (ignored; run with `cargo test --release -p filmcraft-mpeg2v --test perf -- -//! --ignored --nocapture`). Uses the 1080i fixture of `tests/oracle.rs` (ffmpeg-encoded, 25 Mb/s). +//! Decode speed (ignored; run with `cargo test --release -p filmcraft-mpeg2v --test it perf:: -- +//! --ignored --nocapture`). Uses the 1080i fixture of `tests/it/oracle.rs` (ffmpeg-encoded, 25 Mb/s). -mod common; +use crate::common; use common::*; diff --git a/crates/mpegts/README.md b/crates/mpegts/README.md index 15bb9101e..a5543992c 100644 --- a/crates/mpegts/README.md +++ b/crates/mpegts/README.md @@ -65,7 +65,7 @@ let au: Vec = file.read_unit(&bytes, v, 0)?; // the first access unit 33-bit unwrap; truncation every 37 bytes and 400 random mutations never panic. - `src/es.rs`, `src/pes.rs`: frame sizes (MPEG audio layers I-III, MPEG-2 LSF, AC-3 at all rates), MPEG-2 and MPEG-1 PES headers. -- `crates/codecs/tests/mpeg_oracle.rs`: FFmpeg-written TS / M2TS / VOB / MPEG-1 system files — +- `crates/codecs/tests/it/mpeg_oracle.rs`: FFmpeg-written TS / M2TS / VOB / MPEG-1 system files — frame counts and every frame's PTS equal ffprobe's, decoded media against FFmpeg (see `filmcraft-codecs`). diff --git a/crates/mpegts/src/es.rs b/crates/mpegts/src/es.rs index 9735d456e..3219b4d60 100644 --- a/crates/mpegts/src/es.rs +++ b/crates/mpegts/src/es.rs @@ -652,11 +652,14 @@ impl Splitter { match next { Some(d) => { self.skipped += d as u64; - self.trim(self.buf.len() - (i + 1 + d)); + // Keep a cursor into the current window rather than draining its + // prefix for every false sync candidate. + self.next_frame = Some(at + 1 + d as u64); } None => { self.skipped += (self.buf.len() - i - 1) as u64; - self.trim(0); + // A split header can begin in the final few bytes; compact once. + self.trim(7); return; } } diff --git a/crates/mxf/Cargo.toml b/crates/mxf/Cargo.toml index 34b62227f..78609cf63 100644 --- a/crates/mxf/Cargo.toml +++ b/crates/mxf/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +chrono = { workspace = true } [dev-dependencies] filmcraft-testkit = { workspace = true } diff --git a/crates/mxf/README.md b/crates/mxf/README.md index 043772477..070045b98 100644 --- a/crates/mxf/README.md +++ b/crates/mxf/README.md @@ -129,7 +129,7 @@ w.finish()?; - `src/tests.rs`: a small OP1a writer builds synthetic files (temporal offsets, key flags, PCM, timecode); open, presentation order, sample bytes, PCM values; truncation at every 37 bytes and 600 random mutations never panic and only list fully-present samples. -- `crates/codecs/tests/mxf_oracle.rs` (FFmpeg-written fixtures, FFmpeg as the decode oracle): +- `crates/codecs/tests/it/mxf_oracle.rs` (FFmpeg-written fixtures, FFmpeg as the decode oracle): H.264 long-GOP with B pictures (bit-exact every frame + 25 random seeks), H.264 29.97 DF timecode, DNxHR LB (±2), ProRes 422 (±1), MPEG-2 long GOP, XDCAM HD422 and D-10 (±4), OP-Atom VC-3 (clip-wrapped) and PCM, D-10 AES3 audio; PCM sample-exact in every file; diff --git a/crates/mxf/src/file.rs b/crates/mxf/src/file.rs index 75dd27991..07cead857 100644 --- a/crates/mxf/src/file.rs +++ b/crates/mxf/src/file.rs @@ -1,6 +1,7 @@ //! Opening an MXF file: KLV walk, partitions, metadata resolution, sample tables. use std::collections::HashMap; +use std::sync::Arc; use crate::essence::{Codec, PictureInfo, SoundFormat, SoundInfo, decode_pcm, identify_picture, sniff_picture}; use crate::index::{IndexEntry, IndexSegment}; @@ -326,12 +327,13 @@ fn walk(src: &(impl ByteSource + ?Sized)) -> Result { let mut win = Window { src, off: 0, buf: Vec::new() }; let mut pos = run_in; let mut cur_meta: Option = None; + let mut resync_budget = 64 << 20; while pos + 17 <= len { let hdr = win.get(pos, 25)?.to_vec(); let key = Ul(hdr[..16].try_into().unwrap_or([0; 16])); if !key.is_smpte() { // Lost sync (corrupt data): look for the next partition pack. - match resync(src, pos + 1, len)? { + match resync(src, pos + 1, len, &mut resync_budget)? { Some(p) => { w.warnings.push(format!("lost KLV sync at byte {pos}; resumed at {p}")); pos = p; @@ -417,12 +419,14 @@ fn is_essence_item(k: &Ul) -> bool { matches!(k.0[12], 0x05 | 0x06 | 0x07 | 0x15 | 0x16 | 0x17 | 0x18) } -/// The next partition pack key at or after `from` (scans at most 16 MiB). -fn resync(src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result> { +/// The next partition pack key at or after `from` (scans at most 16 MiB and shares `budget` +/// with every other recovery attempt in this file). +fn resync(src: &(impl ByteSource + ?Sized), from: u64, len: u64, budget: &mut usize) -> Result> { let mut at = from; let end = len.min(from + (16 << 20)); - while at < end { - let chunk = read_upto(src, at, 1 << 20)?; + while at < end && *budget > 0 { + let chunk = read_upto(src, at, (1 << 20).min(*budget))?; + *budget = budget.saturating_sub(chunk.len()); if chunk.len() < 16 { break; } @@ -544,8 +548,8 @@ struct TrackRef { origin: i64, start_position: i64, duration: Option, - descriptor: Option, - sub_descriptors: Vec, + descriptor: Option>, + sub_descriptors: Vec>, body_sid: u32, index_sid: u32, } @@ -567,9 +571,13 @@ fn resolve_tracks(md: &Metadata, warnings: &mut Vec) -> (Vec, } let material = md.of_kind(st::MATERIAL_PACKAGE).next(); let mut out = Vec::new(); + let mut descriptor_cache: HashMap<[u8; 16], Arc> = HashMap::new(); + let intern_descriptor = |set: &Set, cache: &mut HashMap<[u8; 16], Arc>| { + if set.instance_uid == [0; 16] { Arc::new(set.clone()) } else { cache.entry(set.instance_uid).or_insert_with(|| Arc::new(set.clone())).clone() } + }; let mut timecode = material.and_then(|m| package_timecode(md, m)); let name = material.and_then(|m| m.string(tag::PACKAGE_NAME)); - let file_track = |pkg: &Set, kind: Option, id: Option, start: i64, duration: Option, name: Option| -> Option { + let mut file_track = |pkg: &Set, kind: Option, id: Option, start: i64, duration: Option, name: Option| -> Option { let umid = umid_of(pkg, tag::PACKAGE_UID).unwrap_or([0; 32]); for r in pkg.references(tag::PACKAGE_TRACKS) { let Some(t) = md.get(&r) else { continue }; @@ -588,10 +596,16 @@ fn resolve_tracks(md: &Metadata, warnings: &mut Vec) -> (Vec, continue; } let desc = descriptor_for(md, pkg, tid, tk); - let subs: Vec = desc + let descriptor = desc.map(|d| intern_descriptor(d, &mut descriptor_cache)); + let subs: Vec> = desc .and_then(|d| d.dynamic_item(&SUB_DESCRIPTORS_ITEM)) .map(|v| { - crate::klv::batch_of(v).into_iter().filter_map(|b| b.get(..16)).filter_map(|b| md.get(&b.try_into().unwrap_or([0; 16]))).cloned().collect() + crate::klv::batch_of(v) + .into_iter() + .filter_map(|b| b.get(..16)) + .filter_map(|b| md.get(&b.try_into().unwrap_or([0; 16]))) + .map(|s| intern_descriptor(s, &mut descriptor_cache)) + .collect() }) .unwrap_or_default(); let (body_sid, index_sid) = sids.get(&umid).copied().unwrap_or((0, 0)); @@ -604,7 +618,7 @@ fn resolve_tracks(md: &Metadata, warnings: &mut Vec) -> (Vec, origin: t.i(tag::ORIGIN).unwrap_or(0), start_position: start, duration, - descriptor: desc.cloned(), + descriptor, sub_descriptors: subs, body_sid, index_sid, @@ -807,7 +821,7 @@ fn build_track( }; match r.kind { TrackKind::Picture => { - let pi = desc.map(picture_info).unwrap_or_default(); + let pi = desc.map(|d| picture_info(d)).unwrap_or_default(); let mpeg_desc = desc.is_some_and(|d| d.kind() == Some(st::MPEG2_VIDEO_DESCRIPTOR)); let mut codec = identify_picture(pi.picture_coding, container, mpeg_desc); if r.sub_descriptors.iter().any(|s| s.kind() == Some(st::AVC_SUBDESCRIPTOR)) && !matches!(codec, Codec::Avc { .. }) { @@ -828,7 +842,7 @@ fn build_track( build_pictures(src, &mut t, elements, index, warnings)?; } TrackKind::Sound => { - let si = desc.map(sound_info).unwrap_or(SoundInfo { channels: 1, bits: 16, ..Default::default() }); + let si = desc.map(|d| sound_info(d)).unwrap_or(SoundInfo { channels: 1, bits: 16, ..Default::default() }); let d10 = container.is_some_and(|c| c.item_starts_with(&[0x0D, 0x01, 0x03, 0x01, 0x02, 0x01])); let aes_element = d10 || key4[0] == 0x06 && key4[2] == 0x10; let pcm_desc = desc.is_some_and(|d| matches!(d.kind(), Some(st::AES3_DESCRIPTOR) | Some(st::WAVE_DESCRIPTOR))); diff --git a/crates/mxf/src/write/mod.rs b/crates/mxf/src/write/mod.rs index d18fdfb27..939cbd77e 100644 --- a/crates/mxf/src/write/mod.rs +++ b/crates/mxf/src/write/mod.rs @@ -243,6 +243,26 @@ impl StartTimecode { } } +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact instead of collapsing out-of-range years to the epoch. +fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + /// An MXF timestamp (UTC). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Timestamp { @@ -256,27 +276,19 @@ pub struct Timestamp { } impl Timestamp { - /// From seconds since the Unix epoch (proleptic Gregorian, UTC). + /// From seconds since the Unix epoch (proleptic Gregorian, UTC). Years far outside chrono's own + /// range are preserved (the old hand-rolled conversion kept them) with month, day and time + /// intact; only the `year` is clamped to the MXF `u16` field, exactly as the old code did. pub fn from_unix(secs: i64) -> Timestamp { - let days = secs.div_euclid(86_400); - let rem = secs.rem_euclid(86_400); - // civil-from-days (H. Hinnant) - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; + use chrono::{Datelike, Timelike}; + let (y, t) = utc_parts(secs); Timestamp { year: y.clamp(0, 65535) as u16, - month: m as u8, - day: d as u8, - hour: (rem / 3600) as u8, - minute: (rem / 60 % 60) as u8, - second: (rem % 60) as u8, + month: t.month() as u8, + day: t.day() as u8, + hour: t.hour() as u8, + minute: t.minute() as u8, + second: t.second() as u8, millisecond: 0, } } @@ -529,6 +541,16 @@ impl MxfWriter { if !cfg.edit_rate.is_valid() { return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF edit rate must be positive")); } + // OP1a pads every sound track to a whole edit unit on flush. Bound that unit before a + // low but technically positive edit rate can turn padding into a multi-gigabyte Vec. + const MAX_PCM_EDIT_UNIT_BYTES: u128 = 64 << 20; + for sound in &cfg.sound { + let samples = (u128::from(sound.sample_rate) * cfg.edit_rate.den as u128).div_ceil(cfg.edit_rate.num as u128); + let bytes = samples.saturating_mul(u128::from(sound.block_align())); + if bytes > MAX_PCM_EDIT_UNIT_BYTES { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF PCM edit unit exceeds the allocation budget")); + } + } let n_essence = cfg.picture.is_some() as usize + cfg.sound.len(); if n_essence == 0 { return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF file without essence tracks")); diff --git a/crates/mxf/src/write/tests.rs b/crates/mxf/src/write/tests.rs index ddf935211..5ce7560a8 100644 --- a/crates/mxf/src/write/tests.rs +++ b/crates/mxf/src/write/tests.rs @@ -47,6 +47,15 @@ fn op1a_config(rate: Rational, coding: PictureCoding) -> WriterConfig { cfg } +#[test] +fn op1a_rejects_an_unbounded_pcm_edit_unit_before_padding() { + let mut cfg = WriterConfig::new(Pattern::Op1a, Rational::new(1, 36_000), PackageIds::from_seed("low-rate", "Low Rate")); + cfg.picture = Some(PictureDesc::new(PictureCoding::ProRes { profile: 3 }, 64, 36)); + cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 16 }]; + let err = MxfWriter::new(Cursor::new(Vec::new()), cfg).err().unwrap(); + assert!(err.to_string().contains("PCM edit unit")); +} + /// 10 ProRes-like pictures at 25 fps with stereo 16-bit and mono 24-bit sound pushed in uneven chunks. fn build_op1a() -> Vec { let cfg = op1a_config(Rational::new(25, 1), PictureCoding::ProRes { profile: 3 }); @@ -267,6 +276,22 @@ fn timestamps() { assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (2023, 11, 14, 22, 13, 20)); let t = Timestamp::from_unix(951_782_400); // 2000-02-29 assert_eq!((t.year, t.month, t.day), (2000, 2, 29)); + // Years far outside chrono's own range are preserved with month/day/time intact and only the + // year clamped to the MXF `u16`, exactly as the old hand-rolled conversion did (not collapsed + // to the epoch — reviewer FredApps). `10_000_000_000_000` s → 318857-05-20 17:46:40. + let t = Timestamp::from_unix(10_000_000_000_000); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (65535, 5, 20, 17, 46, 40)); + // Year 10000 (just past chrono's range) still fits the u16 field. + let t = Timestamp::from_unix(253_402_300_800); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (10000, 1, 1, 0, 0, 0)); + // A negative year clamps to 0 but keeps its month/day/time. + let t = Timestamp::from_unix(-10_000_000_000_000); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (0, 8, 13, 6, 13, 20)); + // The `i64` extremes the old code accepted. + let t = Timestamp::from_unix(i64::MAX); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (65535, 12, 4, 15, 30, 7)); + let t = Timestamp::from_unix(i64::MIN); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (0, 1, 27, 8, 29, 52)); } #[test] diff --git a/crates/ogg/README.md b/crates/ogg/README.md index a3d7f9196..851b9863e 100644 --- a/crates/ogg/README.md +++ b/crates/ogg/README.md @@ -37,6 +37,6 @@ let packet = ogg.read_packet(&bytes, s, k)?; ## Tests `src/tests.rs` writes synthetic pages: timing with pre-skip and end trimming, packets across pages, -a corrupted page (CRC), every truncation and 300 mutations. `crates/codecs/tests/ogg_oracle.rs` +a corrupted page (CRC), every truncation and 300 mutations. `crates/codecs/tests/it/ogg_oracle.rs` compares with FFmpeg's libopus / Vorbis decodes (exact length; SNR ≥ 50 dB CELT, ≥ 10 dB SILK, Vorbis 139 dB) and checks random seeks against the continuous decode. diff --git a/crates/ogg/src/lib.rs b/crates/ogg/src/lib.rs index a251cca3e..2f734b104 100644 --- a/crates/ogg/src/lib.rs +++ b/crates/ogg/src/lib.rs @@ -211,11 +211,12 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { let mut warnings = Vec::new(); let mut pos = 0u64; let mut found_any = false; + let mut recovery_budget = 64 << 20; let mut hdr = [0u8; 27 + 255]; while pos + 27 <= len { src.read_at(pos, &mut hdr[..27])?; if &hdr[..4] != b"OggS" || hdr[4] != 0 { - match find_capture(src, pos + 1, len)? { + match find_capture(src, pos + 1, len, &mut recovery_budget)? { Some(p) => { if found_any { warnings.push(format!("lost sync at byte {pos}; resumed at {p}")); @@ -328,12 +329,13 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { Ok(OggFile { streams, warnings }) } -fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result> { +fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64, budget: &mut usize) -> Result> { let mut at = from; - while at + 4 <= len { - let n = ((len - at) as usize).min(1 << 20); + while at + 4 <= len && *budget > 0 { + let n = ((len - at) as usize).min(1 << 20).min(*budget); let mut buf = vec![0u8; n]; src.read_at(at, &mut buf)?; + *budget = budget.saturating_sub(n); if let Some(i) = buf.windows(4).position(|w| w == b"OggS") { return Ok(Some(at + i as u64)); } diff --git a/crates/ogg/src/tests.rs b/crates/ogg/src/tests.rs index f1879b36b..b2d7c4c77 100644 --- a/crates/ogg/src/tests.rs +++ b/crates/ogg/src/tests.rs @@ -2,6 +2,15 @@ use super::*; +#[test] +fn recovery_scan_stops_when_its_shared_budget_is_spent() { + let source = b"junkOggS".to_vec(); + let mut budget = 3; + assert_eq!(find_capture(&source, 0, source.len() as u64, &mut budget).unwrap(), None); + let mut budget = source.len(); + assert_eq!(find_capture(&source, 0, source.len() as u64, &mut budget).unwrap(), Some(4)); +} + /// Write one page holding `segments` (lacing already computed by `lace`). fn page(out: &mut Vec, flags: u8, granule: i64, serial: u32, seq: u32, lacing: &[u8], body: &[u8]) { let start = out.len(); diff --git a/crates/opus/src/header.rs b/crates/opus/src/header.rs index 2fe0c752a..2e51ce270 100644 --- a/crates/opus/src/header.rs +++ b/crates/opus/src/header.rs @@ -90,6 +90,38 @@ impl OpusHead { v } + /// Validate a header independently of its wire parser. This is required for callers of + /// [`crate::Decoder::from_head`], which may construct an `OpusHead` directly with `usize` + /// values that cannot occur in the byte format. + pub fn validate(&self) -> Result<()> { + if !(1..=255).contains(&self.channels) || self.mapping.len() != self.channels { + return Err(Error::InvalidHeader("bad channel count or mapping length")); + } + if self.mapping_family == 0 { + if self.channels > 2 || self.streams != 1 || self.coupled_streams != self.channels - 1 || self.mapping.iter().copied().ne(0..self.channels as u8) { + return Err(Error::InvalidHeader("invalid family 0 mapping")); + } + return Ok(()); + } + if self.mapping_family == 3 { + return Err(Error::Unsupported("channel mapping family 3 (projection)")); + } + if self.streams == 0 + || self.streams > 255 + || self.coupled_streams > self.streams + || self.streams.checked_add(self.coupled_streams).is_none_or(|n| n > 255) + { + return Err(Error::InvalidHeader("bad stream counts")); + } + if self.mapping_family == 1 && !(1..=8).contains(&self.channels) { + return Err(Error::InvalidHeader("family 1 allows 1 to 8 channels")); + } + if self.mapping.iter().any(|&m| m != 255 && m as usize >= self.streams + self.coupled_streams) { + return Err(Error::InvalidHeader("channel mapping out of range")); + } + Ok(()) + } + fn build(channels: usize, pre_skip: u16, input_sample_rate: u32, output_gain: i16, family: u8, rest: &[u8]) -> Result { if channels == 0 { return Err(Error::InvalidHeader("zero channels")); @@ -120,7 +152,9 @@ impl OpusHead { } (streams, coupled, mapping) }; - Ok(OpusHead { channels, pre_skip, input_sample_rate, output_gain, mapping_family: family, streams, coupled_streams: coupled, mapping }) + let head = OpusHead { channels, pre_skip, input_sample_rate, output_gain, mapping_family: family, streams, coupled_streams: coupled, mapping }; + head.validate()?; + Ok(head) } /// Linear output gain factor. @@ -176,4 +210,19 @@ mod tests { assert_eq!(head.pre_skip, 312); assert_eq!(head.channels, 2); } + + #[test] + fn directly_constructed_headers_are_checked_before_decoder_allocation() { + let head = OpusHead { + channels: 1, + pre_skip: 0, + input_sample_rate: 48_000, + output_gain: 0, + mapping_family: 255, + streams: usize::MAX, + coupled_streams: 0, + mapping: vec![0], + }; + assert!(crate::Decoder::from_head(head, 48_000).is_err()); + } } diff --git a/crates/opus/src/multistream.rs b/crates/opus/src/multistream.rs index a41ba10c3..f47c38179 100644 --- a/crates/opus/src/multistream.rs +++ b/crates/opus/src/multistream.rs @@ -32,6 +32,7 @@ impl Decoder { /// Creates a decoder for a parsed header at `sample_rate` (8/12/16/24/48 kHz). pub fn from_head(head: OpusHead, sample_rate: u32) -> Result { + head.validate()?; let mut streams = Vec::with_capacity(head.streams); for s in 0..head.streams { let ch = if s < head.coupled_streams { 2 } else { 1 }; diff --git a/crates/opus/tests/common/mod.rs b/crates/opus/tests/it/common/mod.rs similarity index 86% rename from crates/opus/tests/common/mod.rs rename to crates/opus/tests/it/common/mod.rs index 6c6ec2754..01cc76605 100644 --- a/crates/opus/tests/common/mod.rs +++ b/crates/opus/tests/it/common/mod.rs @@ -32,12 +32,15 @@ fn spectra(x: &[f32], channels: usize, ch: usize, rate: u32) -> Vec<[f64; NFREQ] let w: Vec = (0..win).map(|i| 0.5 - 0.5 * (2.0 * std::f64::consts::PI * (i as f64 + 0.5) / win as f64).cos()).collect(); let nb = NFREQ / scale; let mut out = Vec::with_capacity(nframes); + let tw = twiddles(win); let mut buf = vec![(0f64, 0f64); win]; + let mut spec = vec![(0f64, 0f64); win]; + let mut scratch = vec![(0f64, 0f64); win]; for f in 0..nframes { for i in 0..win { buf[i] = (x[(f * step + i) * channels + ch] as f64 * w[i], 0.0); } - let spec = fft(&buf); + fft(&buf, 1, &mut spec, &tw, &mut scratch); let mut ps = [0f64; NFREQ]; for (k, p) in ps.iter_mut().enumerate().take(nb) { *p = spec[k].0 * spec[k].0 + spec[k].1 * spec[k].1; @@ -47,28 +50,49 @@ fn spectra(x: &[f32], channels: usize, ch: usize, rate: u32) -> Vec<[f64; NFREQ] out } -/// Recursive mixed-radix DFT (sizes with factors 2, 3, 5). -fn fft(x: &[(f64, f64)]) -> Vec<(f64, f64)> { - let n = x.len(); +/// `e^(-2πi·j/n)` for `j` in `0..n`: the twiddle factors of every sub-transform of a size-`n` FFT. +fn twiddles(n: usize) -> Vec<(f64, f64)> { + (0..n) + .map(|j| { + let ang = -2.0 * std::f64::consts::PI * j as f64 / n as f64; + (ang.cos(), ang.sin()) + }) + .collect() +} + +/// Recursive mixed-radix DFT (sizes with factors 2, 3, 5) of `x[0], x[xs], x[2·xs], …` into +/// `out`. `tw` is `twiddles` of the top-level size, which `out.len()` divides; `scratch` holds at +/// least `out.len()` values. +fn fft(x: &[(f64, f64)], xs: usize, out: &mut [(f64, f64)], tw: &[(f64, f64)], scratch: &mut [(f64, f64)]) { + let n = out.len(); if n == 1 { - return x.to_vec(); + out[0] = x[0]; + return; } let p = [2usize, 3, 5].into_iter().find(|p| n.is_multiple_of(*p)).unwrap_or(n); let m = n / p; - let subs: Vec> = (0..p).map(|r| fft(&x.iter().skip(r).step_by(p).copied().collect::>())).collect(); - let mut out = vec![(0f64, 0f64); n]; - for (k, o) in out.iter_mut().enumerate() { + let stride = tw.len() / n; + for r in 0..p { + fft(&x[r * xs..], xs * p, &mut out[r * m..(r + 1) * m], tw, scratch); + } + for (k, o) in scratch[..n].iter_mut().enumerate() { + let j = k % m; let mut acc = (0f64, 0f64); - for (r, sub) in subs.iter().enumerate() { - let v = sub[k % m]; - let ang = -2.0 * std::f64::consts::PI * (r * k) as f64 / n as f64; - let (c, s) = (ang.cos(), ang.sin()); + // e = r·k mod n, kept below n without a division per term. + let mut e = 0; + for r in 0..p { + let v = out[r * m + j]; + let (c, s) = tw[e * stride]; acc.0 += v.0 * c - v.1 * s; acc.1 += v.0 * s + v.1 * c; + e += k; + if e >= n { + e -= n; + } } *o = acc; } - out + out.copy_from_slice(&scratch[..n]); } /// Opus quality metric in the spirit of `opus_compare` (RFC 6716 §6.1): a per-band, diff --git a/crates/opus/tests/it/main.rs b/crates/opus/tests/it/main.rs new file mode 100644 index 000000000..48dc2080a --- /dev/null +++ b/crates/opus/tests/it/main.rs @@ -0,0 +1,4 @@ +mod common; +mod oracle; +mod robustness; +mod vectors; diff --git a/crates/opus/tests/oracle.rs b/crates/opus/tests/it/oracle.rs similarity index 99% rename from crates/opus/tests/oracle.rs rename to crates/opus/tests/it/oracle.rs index 0917479ed..f80236737 100644 --- a/crates/opus/tests/oracle.rs +++ b/crates/opus/tests/it/oracle.rs @@ -7,7 +7,7 @@ //! streams containing SILK differ by the (non-normative) SILK resampler, so they are held to a //! lower SNR bound and the spectral quality metric. -mod common; +use crate::common; use std::process::Command; diff --git a/crates/opus/tests/robustness.rs b/crates/opus/tests/it/robustness.rs similarity index 100% rename from crates/opus/tests/robustness.rs rename to crates/opus/tests/it/robustness.rs diff --git a/crates/opus/tests/vectors.rs b/crates/opus/tests/it/vectors.rs similarity index 99% rename from crates/opus/tests/vectors.rs rename to crates/opus/tests/it/vectors.rs index e36529b7c..7b36fbe7a 100644 --- a/crates/opus/tests/vectors.rs +++ b/crates/opus/tests/it/vectors.rs @@ -7,7 +7,7 @@ //! `.dec` output using our implementation of the opus_compare-style quality metric //! (see `common::opus_quality`), for stereo (`.dec`) and mono (`m.dec`) output at 48 kHz. -mod common; +use crate::common; use std::path::PathBuf; diff --git a/crates/platform/README.md b/crates/platform/README.md index b78c57735..00371ba97 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -289,18 +289,18 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main | test | what | |---|---| -| `tests/videotoolbox.rs` (macOS) | H.264 High, HEVC Main (open GOP: CRA + RASL) and HEVC Main 10, 640×360 (coded 368: cropping) with B-frames: every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample, mid-stream `flush`, and a full pass after resets; forced mid-stream failures (`VtDecoder::fail_after`) at five points continue with the software decoder's exact output; seeded mutation of samples and parameter sets (bit flips, truncation, corrupt length prefixes) never panics or hangs; HEVC 4:2:2 10-bit is bit-exact with ffmpeg's decode | -| `tests/fallback.rs` (every OS) | `HybridDecoder` with a stand-in hardware decoder failing after N samples (every sync sample ± a few, first / last sample, after a seek): output identical to the software decoder; in-band parameter sets identical to the sample entry's stay in hardware, different ones switch to software | +| `tests/it/videotoolbox.rs` (macOS) | H.264 High, HEVC Main (open GOP: CRA + RASL) and HEVC Main 10, 640×360 (coded 368: cropping) with B-frames: every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample, mid-stream `flush`, and a full pass after resets; forced mid-stream failures (`VtDecoder::fail_after`) at five points continue with the software decoder's exact output; seeded mutation of samples and parameter sets (bit flips, truncation, corrupt length prefixes) never panics or hangs; HEVC 4:2:2 10-bit is bit-exact with ffmpeg's decode | +| `tests/it/fallback.rs` (every OS) | `HybridDecoder` with a stand-in hardware decoder failing after N samples (every sync sample ± a few, first / last sample, after a seek): output identical to the software decoder; in-band parameter sets identical to the sample entry's stay in hardware, different ones switch to software | | `tests/setting.rs` | Hardware decoding Off gives the software decoder through `make_video_decoder` and the media stack (no hardware frames); Auto gives VideoToolbox where available | -| `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 | -| `tests/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | -| `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | -| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | -| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | -| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | -| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | +| `tests/it/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 | +| `tests/it/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | +| `tests/it/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | +| `tests/it/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | +| `tests/it/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | +| `tests/it/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | +| `tests/it/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | | `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe | -| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | +| `tests/it/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | | `tests/nvenc_hevc_hdr_export.rs` (Windows, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity | | `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) | | `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages | diff --git a/crates/platform/src/hybrid.rs b/crates/platform/src/hybrid.rs index c010e79e0..8323938b8 100644 --- a/crates/platform/src/hybrid.rs +++ b/crates/platform/src/hybrid.rs @@ -21,6 +21,9 @@ const LOG_MAX_SAMPLES: usize = 600; const LOG_MAX_BYTES: usize = 256 << 20; /// Presentation times remembered as returned (older ones are forgotten first). const EMITTED_MAX: usize = 4096; +/// Pictures held while software replays a hardware run. The replay log is bounded by bytes and +/// samples, but an invalid stream can still make one sample produce many decoded pictures. +const REPLAY_OUTPUT_MAX: usize = 4096; /// A hardware decoder with a transparent software fallback. pub struct HybridDecoder { @@ -99,6 +102,16 @@ impl HybridDecoder { } } + /// Retain software replay output without letting a corrupt access unit turn the bounded + /// sample replay into an unbounded frame queue. + fn retain_replay(frames: &mut Vec, mut out: Vec) -> Result<()> { + if frames.len().saturating_add(out.len()) > REPLAY_OUTPUT_MAX { + return Err(CodecError::Decode("software fallback replay produced too many frames".into())); + } + frames.append(&mut out); + Ok(()) + } + /// In-band parameter sets (sequence header, key frame format) that differ from the sample /// entry's (the hardware session was set up from those). fn parameter_sets_changed(&self, sample: &[u8]) -> bool { @@ -157,7 +170,7 @@ impl HybridDecoder { for (i, (s, p)) in log.iter().enumerate() { let Some(sw) = self.sw.as_mut() else { break }; match sw.decode(s, *p) { - Ok(out) => frames.extend(out), + Ok(out) => Self::retain_replay(&mut frames, out)?, Err(e) if i == last => rejected = Some(e), Err(e) => log::warn!("software replay after the hardware decoder failed: {e}"), } @@ -250,3 +263,24 @@ impl VideoDecoder for HybridDecoder { } } } + +#[cfg(test)] +mod tests { + use filmcraft_frame::VideoFrame; + + use super::*; + + fn frame(pts: i64) -> DecodedFrame { + DecodedFrame { pts, frame: VideoFrame::rgba8(1, 1, vec![0, 0, 0, 255]), draft: false } + } + + #[test] + fn replay_output_has_a_hard_limit() { + let mut retained = Vec::new(); + HybridDecoder::retain_replay(&mut retained, (0..REPLAY_OUTPUT_MAX as i64).map(frame).collect()).unwrap(); + + let err = HybridDecoder::retain_replay(&mut retained, vec![frame(REPLAY_OUTPUT_MAX as i64)]).unwrap_err(); + assert!(err.to_string().contains("too many frames")); + assert_eq!(retained.len(), REPLAY_OUTPUT_MAX); + } +} diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index 4344cb52f..2f83fa00f 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -108,7 +108,7 @@ impl VideoEncoder for NvencEncoder { return Err(ExportError::Encode(format!("NVENC: a {}x{} picture for a {}x{} encoder", f.width, f.height, self.w, self.h))); } let (w, h) = (self.w as usize, self.h as usize); - let hint = if self.enc.codec() == Codec::H264 { " (turn Export ▸ Hardware encoding off to use the software encoder)" } else { "" }; + let hint = if self.enc.codec() == Codec::H264 { " (turn Export > Hardware encoding off to use the software encoder)" } else { "" }; let ps = match (self.enc.is_ten_bit(), f.hdr) { // Main 10 takes the encoded R'G'B' floats: 10-bit limited-range BT.2020 NCL (or the signal's matrix) (true, Some(rgb)) => { diff --git a/crates/platform/src/vaapi/h264.rs b/crates/platform/src/vaapi/h264.rs index bc79ee9a4..6e709c4ad 100644 --- a/crates/platform/src/vaapi/h264.rs +++ b/crates/platform/src/vaapi/h264.rs @@ -333,7 +333,7 @@ impl Front { let fpoc = if mmco5 { 0 } else { p.poc.frame() }; let meta = Arc::new(output_meta(&p.sps, p.pts, p.first.idr)); let mut outs = Vec::new(); - self.dpb.store_picture(&p.first, surface, fpoc, p.sps.max_frame_num(), p.sps.max_num_ref_frames as usize, meta, &mut outs); + self.dpb.store_picture(&p.first, surface, fpoc, p.sps.max_frame_num(), p.sps.max_num_ref_frames as usize, meta, &mut outs).map_err(decode_err)?; self.read_out(outs, frames) } } diff --git a/crates/platform/src/videotoolbox.rs b/crates/platform/src/videotoolbox.rs index 2ac6723ca..b1c04740b 100644 --- a/crates/platform/src/videotoolbox.rs +++ b/crates/platform/src/videotoolbox.rs @@ -48,6 +48,16 @@ const NV16_VIDEO: u32 = u32::from_be_bytes(*b"422v"); const NV16_FULL: u32 = u32::from_be_bytes(*b"422f"); const P210_VIDEO: u32 = u32::from_be_bytes(*b"x422"); const P210_FULL: u32 = u32::from_be_bytes(*b"xf22"); +/// H.264 and HEVC parameter parsing both enforce a maximum decoded-picture +/// buffer and reorder depth of 16 pictures. +const MAX_REORDER_DEPTH: usize = 16; + +fn validate_reorder_depth(reorder: usize) -> std::result::Result<(), String> { + if reorder > MAX_REORDER_DEPTH { + return Err("VideoToolbox reorder depth is too large".into()); + } + Ok(()) +} /// How decoded buffers are laid out and what each output picture carries. #[derive(Clone, Debug)] @@ -437,6 +447,7 @@ impl VtDecoder { /// A decoder for the stream, or why VideoToolbox does not take it (unsupported format, no /// hardware decoder, session creation failed). pub fn new(info: NalStreamInfo) -> std::result::Result { + validate_reorder_depth(info.reorder)?; let layout = layout(&info)?; if !hardware_available(info.codec) { return Err("no hardware decoder".into()); @@ -569,3 +580,14 @@ impl VideoDecoder for VtDecoder { self.info.is_disposable(sample) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reorder_depth_matches_supported_stream_limits() { + assert!(validate_reorder_depth(MAX_REORDER_DEPTH).is_ok()); + assert!(validate_reorder_depth(MAX_REORDER_DEPTH + 1).is_err()); + } +} diff --git a/crates/platform/tests/common/mod.rs b/crates/platform/tests/it/common/mod.rs similarity index 96% rename from crates/platform/tests/common/mod.rs rename to crates/platform/tests/it/common/mod.rs index e4f39c59e..74f11bdae 100644 --- a/crates/platform/tests/common/mod.rs +++ b/crates/platform/tests/it/common/mod.rs @@ -9,6 +9,10 @@ use filmcraft_codecs::{DecodedFrame, VideoDecoder}; use filmcraft_frame::PixelData; use filmcraft_isobmff::SampleEntry; +/// NVENC sessions are a limited resource on consumer GPUs, and the encoder counters are +/// process-wide: one NVENC test at a time across every module of the binary. +pub static NVENC_SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// The parity fixtures: (file, ffmpeg arguments). B-frames everywhere (reordering), a 360-line /// picture (coded 368: cropping), two GOPs or more (seeks), and an open-GOP HEVC stream (CRA with /// RASL pictures). diff --git a/crates/platform/tests/fallback.rs b/crates/platform/tests/it/fallback.rs similarity index 84% rename from crates/platform/tests/fallback.rs rename to crates/platform/tests/it/fallback.rs index 8c00e52f5..3b7da8d5c 100644 --- a/crates/platform/tests/fallback.rs +++ b/crates/platform/tests/it/fallback.rs @@ -5,7 +5,7 @@ //! damaged sample that the hardware decoded but the software decoder rejects, inside the replayed //! run, loses no picture. -mod common; +use crate::common; use common::*; use filmcraft_codecs::hw::NalStreamInfo; @@ -51,30 +51,31 @@ fn failures_anywhere_continue_with_the_software_output() { for (name, _) in FIXTURES { let Some(path) = named(&ff, name) else { continue }; let s = read_stream(&path); - let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples); let syncs: Vec = (0..s.samples.len()).filter(|&i| s.sync[i]).collect(); - let mut points: Vec = vec![0, 1, 3, 7, s.samples.len() - 1, s.samples.len()]; - for &k in &syncs { - points.extend([k, k + 1, k + 2, k + 5]); - } + let [_, k, .., last] = syncs[..] else { panic!("{name}: three GOPs or more") }; + // The stream up to a few samples into its last GOP: past the last failure point the + // hybrid only passes the software decoder's output on. + let samples = &s.samples[..(last + 6).min(s.samples.len())]; + let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), samples); + // The hybrid's paths differ by what the replay log holds and what the hardware had + // pending: in the first GOP (nothing out yet, then pictures held for reordering), at and + // just after the second restart point (the log starts over, or an HEVC CRA keeps the GOP + // before it), at the last one (a CRA drops the GOP before the previous one), at the last + // sample and never. Failing anywhere else takes one of those paths. + let points = [0, 1, 3, 7, k, k + 1, k + 2, k + 5, last, last + 2, samples.len() - 1, samples.len()]; for fail_after in points { let mut d = hybrid(&s, fail_after); - let out = decode_all(&mut d, &s.samples); - assert_eq!(d.is_hardware(), fail_after >= s.samples.len(), "{name}: fell back at {fail_after}"); + let out = decode_all(&mut d, samples); + assert_eq!(d.is_hardware(), fail_after >= samples.len(), "{name}: fell back at {fail_after}"); assert_same(&format!("{name} failing after {fail_after} samples"), &out, &reference); } // a failure after a seek (reset) into the second GOP - if let Some(&k) = syncs.get(1) { - let tail = &s.samples[k..]; - let expect = { - let mut sw = filmcraft_codecs::software_video_decoder(&s.entry).unwrap(); - decode_all(sw.as_mut(), tail) - }; - let mut d = hybrid(&s, k + 4); - decode_all(&mut d, &s.samples[..k]); - d.reset(); - assert_same(&format!("{name} failing after a seek"), &decode_all(&mut d, tail), &expect); - } + let tail = &samples[k..]; + let expect = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), tail); + let mut d = hybrid(&s, k + 4); + decode_all(&mut d, &samples[..k]); + d.reset(); + assert_same(&format!("{name} failing after a seek"), &decode_all(&mut d, tail), &expect); } } @@ -179,7 +180,9 @@ fn a_sample_software_rejects_during_the_replay_loses_no_pictures() { // the second restart point, and a disposable picture before it that is not the first sample let Some(next) = (1..s.samples.len()).find(|&i| s.sync[i]) else { continue }; let Some(d) = (2..next.saturating_sub(2)).find(|&i| info.is_disposable(&s.samples[i].0)) else { continue }; - let mut stream = s.samples.clone(); + // the first two GOPs: the replay and the restart after it + let end = (next + 1..s.samples.len()).find(|&i| s.sync[i]).unwrap_or(s.samples.len()); + let mut stream = s.samples[..end].to_vec(); stream[d].0 = damaged(&s.samples[d].0, header); let (reference, rejected) = decode_lenient(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &stream); assert_eq!(rejected, [d], "{name}: the software decoder rejects the damaged sample (and only it)"); diff --git a/crates/platform/tests/hardware_encode.rs b/crates/platform/tests/it/hardware_encode.rs similarity index 100% rename from crates/platform/tests/hardware_encode.rs rename to crates/platform/tests/it/hardware_encode.rs diff --git a/crates/platform/tests/hevc_probe.rs b/crates/platform/tests/it/hevc_probe.rs similarity index 100% rename from crates/platform/tests/hevc_probe.rs rename to crates/platform/tests/it/hevc_probe.rs diff --git a/crates/platform/tests/it/main.rs b/crates/platform/tests/it/main.rs new file mode 100644 index 000000000..b863c7d04 --- /dev/null +++ b/crates/platform/tests/it/main.rs @@ -0,0 +1,13 @@ +mod common; +mod fallback; +mod hardware_encode; +mod hevc_probe; +mod media_foundation; +mod nvenc; +mod nvenc_export; +mod nvenc_hevc; +mod nvenc_hevc_export; +mod nvenc_hevc_main10; +mod nvenc_hevc_probe; +mod nvenc_rgba_input; +mod videotoolbox; diff --git a/crates/platform/tests/media_foundation.rs b/crates/platform/tests/it/media_foundation.rs similarity index 99% rename from crates/platform/tests/media_foundation.rs rename to crates/platform/tests/it/media_foundation.rs index 31c53a7b7..5b7a09d7f 100644 --- a/crates/platform/tests/media_foundation.rs +++ b/crates/platform/tests/it/media_foundation.rs @@ -6,7 +6,7 @@ //! hang. Skips without ffmpeg (fixture generator) or without a hardware decoder. #![cfg(target_os = "windows")] -mod common; +use crate::common; use std::time::Duration; diff --git a/crates/platform/tests/nvenc.rs b/crates/platform/tests/it/nvenc.rs similarity index 100% rename from crates/platform/tests/nvenc.rs rename to crates/platform/tests/it/nvenc.rs diff --git a/crates/platform/tests/nvenc_export.rs b/crates/platform/tests/it/nvenc_export.rs similarity index 95% rename from crates/platform/tests/nvenc_export.rs rename to crates/platform/tests/it/nvenc_export.rs index e0d32a20c..d02bcca35 100644 --- a/crates/platform/tests/nvenc_export.rs +++ b/crates/platform/tests/it/nvenc_export.rs @@ -13,9 +13,6 @@ use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceS use filmcraft_render::SourceMap; use filmcraft_time::{FrameRate, Tick, TimeRange}; -/// The encoder counters are process-wide: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn project(w: u32, h: u32, frames: i64) -> (Arc, filmcraft_project::ItemId, SourceMap) { let rate = FrameRate::FPS_24; let mut p = Project::new("x"); @@ -79,7 +76,7 @@ fn settings(path: String, hw: HardwareEncoding) -> ExportSettings { #[test] fn hardware_export_matches_the_software_export() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); let (p, seq, m) = project(1280, 720, 72); let soft_path = tmp("soft.mp4"); @@ -117,7 +114,7 @@ fn hardware_export_matches_the_software_export() { #[test] fn what_nvenc_does_not_take_goes_to_the_software_encoder() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); let (p, seq, m) = project(640, 360, 24); let run = |name: &str, f: &dyn Fn(&mut ExportSettings)| { diff --git a/crates/platform/tests/nvenc_hevc.rs b/crates/platform/tests/it/nvenc_hevc.rs similarity index 95% rename from crates/platform/tests/nvenc_hevc.rs rename to crates/platform/tests/it/nvenc_hevc.rs index acd2bee9b..f0aebcc45 100644 --- a/crates/platform/tests/nvenc_hevc.rs +++ b/crates/platform/tests/it/nvenc_hevc.rs @@ -43,9 +43,6 @@ fn config(w: u32, h: u32) -> Config { } } -/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - type Sources = Vec<(Vec, Vec, Vec)>; /// `None` (the test skips) only on a machine without an HEVC encoder; on one that has it, a @@ -94,7 +91,7 @@ fn hex(b: &[u8]) -> String { #[test] fn the_stream_decodes_to_the_source() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let (w, h) = (1280u32, 720u32); let Some((enc, packets, sources)) = encode_all(&config(w, h), 72) else { return }; assert_eq!(enc.codec(), Codec::Hevc); @@ -179,7 +176,7 @@ fn the_stream_decodes_to_the_source() { #[test] fn nothing_to_encode_with_is_an_error_not_a_crash() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); return; @@ -222,7 +219,7 @@ fn calm(what: &str, f: impl FnOnce() -> T) -> T { #[test] fn hostile_configurations_give_errors_not_crashes() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); return; @@ -266,7 +263,7 @@ fn hostile_configurations_give_errors_not_crashes() { #[test] fn an_encoder_dropped_mid_stream_releases_everything() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); return; @@ -285,7 +282,7 @@ fn an_encoder_dropped_mid_stream_releases_everything() { #[test] fn high_bitrates_move_up_a_level_and_stay_in_the_main_tier() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // 1080p30 at a 12 / 18 Mbit/s peak: above level 4's Main tier (12), where NVENC's own choice was // level 4 High tier, which many hardware decoders refuse for (w, h, fps, kbps, max_kbps, level_idc) in @@ -302,7 +299,7 @@ fn high_bitrates_move_up_a_level_and_stay_in_the_main_tier() { #[test] fn a_keyframe_every_one_or_two_pictures_is_written_without_b_frames() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // NVENC wants a GOP longer than the B-frame pattern: short GOPs drop the B-frames, they do not fail for keyint in [1u32, 2] { let cfg = Config { keyint, ..config(640, 360) }; @@ -324,7 +321,7 @@ fn a_keyframe_every_one_or_two_pictures_is_written_without_b_frames() { #[test] fn vui_timing_matches_integer_and_fractional_frame_rates() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); for fps in [(24, 1), (25, 1), (30_000, 1_001)] { let mut cfg = config(640, 360); cfg.fps = fps; diff --git a/crates/platform/tests/nvenc_hevc_export.rs b/crates/platform/tests/it/nvenc_hevc_export.rs similarity index 97% rename from crates/platform/tests/nvenc_hevc_export.rs rename to crates/platform/tests/it/nvenc_hevc_export.rs index 95f074bb8..5fb79a9f7 100644 --- a/crates/platform/tests/nvenc_hevc_export.rs +++ b/crates/platform/tests/it/nvenc_hevc_export.rs @@ -21,9 +21,6 @@ use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceS use filmcraft_render::SourceMap; use filmcraft_time::{FrameRate, Tick, TimeRange}; -/// The encoder counters are process-wide: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn project(w: u32, h: u32, frames: i64) -> (Arc, filmcraft_project::ItemId, SourceMap) { let rate = FrameRate::FPS_24; let mut p = Project::new("x"); @@ -115,7 +112,7 @@ fn delta(before: filmcraft_export::HwEncodeStats) -> (u64, u64, u64) { #[test] fn hevc_exports_play_everywhere() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); assert_eq!(filmcraft_export::available(Format::Hevc), hevc_available(), "the format is available iff NVENC can encode HEVC"); if !hevc_available() { @@ -173,7 +170,7 @@ fn hevc_exports_play_everywhere() { #[test] fn hevc_export_with_audio() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); @@ -202,7 +199,7 @@ fn hevc_export_with_audio() { #[test] fn sizes_that_are_not_multiples_of_the_coding_block_are_cropped_back() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC here"); @@ -230,7 +227,7 @@ fn sizes_that_are_not_multiples_of_the_coding_block_are_cropped_back() { #[test] fn what_nvenc_cannot_do_is_an_error_that_says_why() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); filmcraft_platform::register(); let rate = FrameRate::FPS_24; let base = hevc(tmp("declined.mp4")); diff --git a/crates/platform/tests/nvenc_hevc_main10.rs b/crates/platform/tests/it/nvenc_hevc_main10.rs similarity index 96% rename from crates/platform/tests/nvenc_hevc_main10.rs rename to crates/platform/tests/it/nvenc_hevc_main10.rs index 76d0c9ccc..1b49795dd 100644 --- a/crates/platform/tests/nvenc_hevc_main10.rs +++ b/crates/platform/tests/it/nvenc_hevc_main10.rs @@ -16,9 +16,6 @@ const W: usize = 1280; const H: usize = 720; const FRAMES: usize = 48; -/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn config(w: u32, h: u32) -> Config { Config { width: w, @@ -134,7 +131,7 @@ fn hex(b: &[u8]) -> String { } fn round_trip(name: &str, signal: ColorSignal, want_sei: bool) { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((enc, packets, sources)) = encode_all(&config(W as u32, H as u32), &signal, FRAMES) else { return }; assert!(enc.is_ten_bit()); assert_eq!(packets.len(), FRAMES, "{name}: one packet per picture"); @@ -239,7 +236,7 @@ fn calm(what: &str, f: impl FnOnce() -> T) -> T { #[test] fn main_10_and_main_encoders_refuse_each_others_pictures() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_hdr_available() { eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder"); return; @@ -268,7 +265,7 @@ fn main_10_and_main_encoders_refuse_each_others_pictures() { #[test] fn hostile_main_10_configurations_give_errors_not_crashes() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_hdr_available() { eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder"); return; @@ -315,7 +312,7 @@ fn hostile_main_10_configurations_give_errors_not_crashes() { #[test] fn the_probe_follows_the_encoder() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let hdr = hevc_hdr_available(); assert!(!hdr || hevc_available(), "Main 10 implies HEVC"); assert_eq!(hevc_hdr_available(), hdr, "the answer is kept"); diff --git a/crates/platform/tests/nvenc_hevc_probe.rs b/crates/platform/tests/it/nvenc_hevc_probe.rs similarity index 100% rename from crates/platform/tests/nvenc_hevc_probe.rs rename to crates/platform/tests/it/nvenc_hevc_probe.rs diff --git a/crates/platform/tests/nvenc_rgba_input.rs b/crates/platform/tests/it/nvenc_rgba_input.rs similarity index 94% rename from crates/platform/tests/nvenc_rgba_input.rs rename to crates/platform/tests/it/nvenc_rgba_input.rs index 4009f3e68..275b1e227 100644 --- a/crates/platform/tests/nvenc_rgba_input.rs +++ b/crates/platform/tests/it/nvenc_rgba_input.rs @@ -6,9 +6,6 @@ use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available}; -/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. -static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - /// A moving test picture: gradient background, a moving box, a little texture. fn moving(w: usize, h: usize, i: usize) -> Vec { let mut v = vec![0u8; w * h * 4]; @@ -122,7 +119,7 @@ fn check(profile: Profile) { #[test] fn hevc_rgba_input_converts_like_the_export() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC encoder"); return; @@ -132,7 +129,7 @@ fn hevc_rgba_input_converts_like_the_export() { #[test] fn h264_rgba_input_converts_like_the_export() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !available() { eprintln!("SKIPPED: no NVENC"); return; @@ -142,7 +139,7 @@ fn h264_rgba_input_converts_like_the_export() { #[test] fn main10_has_no_rgba_input_and_short_pictures_are_errors() { - let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one = crate::common::NVENC_SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !hevc_available() { eprintln!("SKIPPED: no NVENC HEVC encoder"); return; diff --git a/crates/platform/tests/videotoolbox.rs b/crates/platform/tests/it/videotoolbox.rs similarity index 99% rename from crates/platform/tests/videotoolbox.rs rename to crates/platform/tests/it/videotoolbox.rs index c2ce2f6ae..2db339b1b 100644 --- a/crates/platform/tests/videotoolbox.rs +++ b/crates/platform/tests/it/videotoolbox.rs @@ -6,7 +6,7 @@ //! hang. Skips without ffmpeg (fixture generator) or without a hardware decoder. #![cfg(target_os = "macos")] -mod common; +use crate::common; use std::time::Duration; diff --git a/crates/platform/tests/media_stack.rs b/crates/platform/tests/media_stack.rs index a7d244f5d..e8be399d8 100644 --- a/crates/platform/tests/media_stack.rs +++ b/crates/platform/tests/media_stack.rs @@ -2,6 +2,7 @@ //! decoding Auto and Off (Windows). Its own test binary: the switch is process-wide. #![cfg(target_os = "windows")] +#[path = "it/common/mod.rs"] mod common; use common::*; diff --git a/crates/platform/tests/nvdec.rs b/crates/platform/tests/nvdec.rs index d87f15611..ef1a689dc 100644 --- a/crates/platform/tests/nvdec.rs +++ b/crates/platform/tests/nvdec.rs @@ -4,9 +4,11 @@ //! `reset` + reseek; a forced mid-stream failure must continue in software with the software //! decoder's exact output; what the hardware path does not take is declined; damaged samples give //! errors or fall back, never crash or hang. Skips without ffmpeg (fixture generator) or without -//! an NVIDIA GPU. +//! an NVIDIA GPU. Its own test binary: one test turns the process-wide Hardware decoding switch +//! off and on. #![cfg(all(target_os = "linux", target_pointer_width = "64"))] +#[path = "it/common/mod.rs"] mod common; use std::time::Duration; diff --git a/crates/platform/tests/setting.rs b/crates/platform/tests/setting.rs index c58f4a953..259a02c31 100644 --- a/crates/platform/tests/setting.rs +++ b/crates/platform/tests/setting.rs @@ -2,6 +2,7 @@ //! Off gives the software decoder (and no hardware frames), Auto the hardware one where the //! system has it. Its own test binary: the switch is process-wide. +#[path = "it/common/mod.rs"] mod common; use common::*; diff --git a/crates/platform/tests/vaapi.rs b/crates/platform/tests/vaapi.rs index 507e7768c..f7c140661 100644 --- a/crates/platform/tests/vaapi.rs +++ b/crates/platform/tests/vaapi.rs @@ -8,9 +8,11 @@ //! continue in software with the software decoder's exact output; what the hardware path does not //! take (interlaced and 10-bit H.264, 4:2:2 HEVC) is declined; damaged samples and parameter sets give //! errors or fall back, never crash or hang. Skips without ffmpeg (fixture generator) or without a -//! VA-API driver. +//! VA-API driver. Its own test binary: one test turns the process-wide Hardware decoding switch off +//! and on. #![cfg(target_os = "linux")] +#[path = "it/common/mod.rs"] mod common; use std::time::Duration; diff --git a/crates/platform/tests/vaapi_displays.rs b/crates/platform/tests/vaapi_displays.rs index 8ec45334a..acdb721d8 100644 --- a/crates/platform/tests/vaapi_displays.rs +++ b/crates/platform/tests/vaapi_displays.rs @@ -7,6 +7,7 @@ //! Skips without ffmpeg (fixture generator) or without a VA-API driver. #![cfg(target_os = "linux")] +#[path = "it/common/mod.rs"] mod common; use common::*; diff --git a/crates/project/src/caption.rs b/crates/project/src/caption.rs index d8236e8bd..30c279d26 100644 --- a/crates/project/src/caption.rs +++ b/crates/project/src/caption.rs @@ -239,25 +239,36 @@ pub fn plain_text(s: &str) -> String { while let Some(c) = chars.next() { match c { '<' => { - // a tag runs to the next '>' (if none, keep the '<' literally) - let rest: String = chars.clone().collect(); - if let Some(end) = rest.find('>') { - for _ in 0..rest[..=end].chars().count() { - chars.next(); + // A tag runs to the next '>'. Scan forward once; preserving an unterminated + // tag as literal text must not rescan every later opening bracket. + let mut literal = String::new(); + let mut closed = false; + for next in chars.by_ref() { + if next == '>' { + closed = true; + break; } - } else { + literal.push(next); + } + if !closed { out.push('<'); + out.push_str(&literal); } } '{' if chars.peek() == Some(&'\\') => { // SSA-style override blocks some SRT files carry: {\an8} - let rest: String = chars.clone().collect(); - if let Some(end) = rest.find('}') { - for _ in 0..rest[..=end].chars().count() { - chars.next(); + let mut literal = String::new(); + let mut closed = false; + for next in chars.by_ref() { + if next == '}' { + closed = true; + break; } - } else { + literal.push(next); + } + if !closed { out.push('{'); + out.push_str(&literal); } } '&' => { @@ -300,6 +311,14 @@ mod tests { assert_eq!(plain_text("Hi"), "Hi"); } + #[test] + fn malformed_caption_markup_is_preserved_without_repeated_suffix_scans() { + let angle = "<".repeat(10_000); + let ssa = format!("{{\\{}", "{".repeat(10_000)); + assert_eq!(plain_text(&angle), angle); + assert_eq!(plain_text(&ssa), ssa); + } + #[test] fn caption_at() { let mut t = CaptionTrack::new(TrackId(1), "Subtitle".into(), CaptionFormat::Subtitle); diff --git a/crates/project/src/graphic.rs b/crates/project/src/graphic.rs index 978917e32..23994171a 100644 --- a/crates/project/src/graphic.rs +++ b/crates/project/src/graphic.rs @@ -35,6 +35,47 @@ pub fn is_layer(e: &EffectInstance) -> bool { is_layer_id(&e.effect) } +/// Give every graphic layer without a uid (or with a uid another layer already uses) a fresh one +/// that is unique among `effects`; non-layer effects are left alone. +/// +/// This is the project-format lazy upgrade: files written before layers had uids (or with a +/// duplicate written by a hostile/buggy producer) get stable identities when they are opened, so +/// reorder/delete/undo and the Program monitor's text editor keep addressing the same layer. +/// The optional `layer.uid` field means the file schema does not change; a save simply writes the +/// assigned uid. Returns whether anything changed. +pub fn ensure_layer_uids(effects: &mut [EffectInstance]) -> bool { + let mut used: std::collections::BTreeSet = + effects.iter().filter(|e| is_layer(e)).filter_map(|e| e.layer.as_ref()).map(|x| x.uid).filter(|uid| *uid != 0).collect(); + let mut seen = std::collections::BTreeSet::new(); + let mut next = 1u64; + let mut changed = false; + for e in effects.iter_mut().filter(|e| is_layer(e)) { + let uid = e.layer.as_ref().map_or(0, |x| x.uid); + if uid != 0 && seen.insert(uid) { + continue; + } + // Zero means "not assigned yet"; a repeated uid must move to a free one so two identical + // layers can never share an identity. + while used.contains(&next) { + let Some(after) = next.checked_add(1) else { + // A hostile file used every uid; leave this layer without one rather than loop. + next = 0; + break; + }; + next = after; + } + if next == 0 { + continue; + } + e.layer.get_or_insert_with(Default::default).uid = next; + used.insert(next); + seen.insert(next); + changed = true; + next = next.saturating_add(1); + } + changed +} + fn p(id: &'static str, label: &'static str, kind: ParamKind, default: ParamValue, animatable: bool, group: &'static str) -> ParamDef { ParamDef { id, label, kind, default, animatable, group: if group.is_empty() { None } else { Some(group) } } } @@ -499,6 +540,43 @@ mod tests { assert_eq!(layer_display_name(&e, 0), "Hello"); } + #[test] + fn assigning_a_new_layer_uid_never_reuses_an_existing_layers_uid() { + let make = |uid| { + let mut layer = new_text_layer("text", Vec2::new(10.0, 20.0), 40.0); + layer.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid, ..Default::default() })); + layer + }; + for initial in [[1, 0, 2, 1], [0, 1, 2, 1], [1, 2, 0, 1]] { + let mut layers: Vec<_> = initial.into_iter().map(make).collect(); + assert!(ensure_layer_uids(&mut layers)); + let uids: Vec<_> = layers.iter().map(crate::gtemplate::layer_uid).collect(); + assert_eq!(uids.iter().copied().collect::>().len(), 4, "{uids:?}"); + assert_eq!(uids[initial.iter().position(|uid| *uid == 1).unwrap()], 1); + assert_eq!(uids[initial.iter().position(|uid| *uid == 2).unwrap()], 2); + assert!(!ensure_layer_uids(&mut layers), "a second pass must preserve identity"); + } + } + + #[test] + fn layer_uids_are_unique_and_repair_legacy_or_duplicate_entries() { + let a = new_text_layer("one", Vec2::new(10.0, 20.0), 40.0); + let mut b = new_text_layer("two", Vec2::new(10.0, 20.0), 40.0); + let mut shape = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance(); + // Legacy file: no uids at all; one layer already carries a duplicate uid. + assert_eq!(crate::gtemplate::layer_uid(&a), 0); + b.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid: 7, ..Default::default() })); + shape.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid: 7, ..Default::default() })); + let mut effects = vec![a, b, shape]; + assert!(ensure_layer_uids(&mut effects)); + let uids: Vec = effects.iter().filter(|e| is_layer(e)).map(crate::gtemplate::layer_uid).collect(); + assert!(uids.iter().all(|uid| *uid != 0)); + let unique: std::collections::BTreeSet = uids.iter().copied().collect(); + assert_eq!(unique.len(), uids.len(), "every layer keeps its own identity: {uids:?}"); + // Idempotent: a second pass changes nothing. + assert!(!ensure_layer_uids(&mut effects)); + } + #[test] fn defaults_centre_and_shadow_direction() { let mut e = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance(); diff --git a/crates/project/src/gtemplate.rs b/crates/project/src/gtemplate.rs index be225a8b6..644b9b28f 100644 --- a/crates/project/src/gtemplate.rs +++ b/crates/project/src/gtemplate.rs @@ -35,6 +35,8 @@ //! The built-in templates ([`builtin_templates`]) are original designs made for FilmCraft in code //! (no image, font or template files), under the project licence. +use std::collections::{HashMap, HashSet}; + use filmcraft_geom::Vec2; use filmcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; @@ -278,24 +280,22 @@ impl GraphicsTemplate { if self.canvas[0] == 0 || self.canvas[1] == 0 || self.canvas[0] > 16384 || self.canvas[1] > 16384 { return bad(format!("bad canvas size {:?}", self.canvas)); } - let mut uids = Vec::new(); + let mut layers = HashMap::with_capacity(self.layers.len()); for (i, l) in self.layers.iter().enumerate() { if !graphic::is_layer(l) { return bad(format!("layer {i} is `{}`, not a graphic layer", l.effect)); } let uid = layer_uid(l); - if uid == 0 || uids.contains(&uid) { + if uid == 0 || layers.insert(uid, l).is_some() { return bad(format!("layer {i} has no unique uid")); } - uids.push(uid); } - let mut ids = Vec::new(); + let mut ids = HashSet::with_capacity(self.controls.len()); for c in &self.controls { - if c.id.is_empty() || ids.contains(&c.id) { + if c.id.is_empty() || !ids.insert(c.id.as_str()) { return bad(format!("control `{}` has no unique id", c.name)); } - ids.push(c.id.clone()); - let Some(l) = self.layers.iter().find(|l| layer_uid(l) == c.layer) else { + let Some(l) = layers.get(&c.layer) else { return bad(format!("control `{}` points at a missing layer", c.name)); }; if c.param != "enabled" && !l.params.contains_key(&c.param) { @@ -350,14 +350,8 @@ pub fn layer_uid(e: &EffectInstance) -> u64 { } /// Give every layer without a uid a fresh one (unique among `layers`). -pub fn ensure_uids(layers: &mut [EffectInstance]) { - let mut next = layers.iter().map(layer_uid).max().unwrap_or(0) + 1; - for l in layers.iter_mut() { - if layer_uid(l) == 0 { - l.layer.get_or_insert_with(Default::default).uid = next; - next += 1; - } - } +pub fn ensure_uids(layers: &mut [EffectInstance]) -> bool { + graphic::ensure_layer_uids(layers) } // ------------------------------------------------------------------------------------------------ diff --git a/crates/project/src/keyframe.rs b/crates/project/src/keyframe.rs index 1ddfbc156..bf6754c2c 100644 --- a/crates/project/src/keyframe.rs +++ b/crates/project/src/keyframe.rs @@ -285,6 +285,38 @@ impl Param { self.value_at(t).as_f64().unwrap_or(0.0) } + /// Borrowing text evaluation for read-only observers (the Program monitor's IME producer). + /// + /// Text parameters never interpolate, so the result is the applicable static or keyframed + /// `ParamValue::Text`; an interpolating non-text value yields `None`. Unlike + /// [`Self::value_at`] this does not clone the document-sized string. + pub fn text_ref_at(&self, t: Tick) -> Option<&str> { + fn text(v: &ParamValue) -> Option<&str> { + match v { + ParamValue::Text(s) => Some(s.as_str()), + _ => None, + } + } + let k = &self.keyframes; + if k.is_empty() { + return text(&self.value); + } + if t <= k[0].time { + return text(&k[0].value); + } + let last = &k[k.len() - 1]; + if t >= last.time { + return text(&last.value); + } + let i = match k.binary_search_by_key(&t, |kf| kf.time) { + Ok(i) => return text(&k[i].value), + Err(i) => i - 1, + }; + // Hold semantics match `value_at`: non-interpolating values (including text) stay until + // the next keyframe; an interpolating segment never evaluates to text. + if k[i].interp == Interpolation::Hold || !k[i].value.interpolates() { text(&k[i].value) } else { None } + } + /// Allocation-free [`Param::f64_at`] for scalar parameters (Float / Choice / Bool), for /// per-sample evaluation in the audio mixer. Non-scalar values evaluate to 0. pub fn scalar_at(&self, t: Tick) -> f64 { @@ -405,6 +437,19 @@ mod tests { } } + #[test] + fn text_ref_at_borrows_without_cloning_and_holds_non_interpolating_values() { + let p = Param::new(ParamValue::Text("abc".into())); + assert_eq!(p.text_ref_at(Tick(0)), Some("abc")); + let f = Param::new(ParamValue::Float(1.0)); + assert_eq!(f.text_ref_at(Tick(0)), None); + let mut p = Param::new(ParamValue::Text("a".into())); + p.toggle_animation(Tick(0)); + p.set_at(Tick(100), ParamValue::Text("b".into())); + assert_eq!(p.text_ref_at(Tick(50)), Some("a"), "text does not interpolate"); + assert_eq!(p.text_ref_at(Tick(100)), Some("b")); + } + #[test] fn toggle_off_keeps_value() { let mut p = Param::new(ParamValue::Float(1.0)); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fb55786db..05693820f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -760,6 +760,9 @@ pub struct Track { /// Audio Track Mixer state: automation mode and lanes, sends, output, record arm. #[serde(default)] pub mixer: MixerStrip, + /// Colour label tinting the track's header and lane background (`None` = no colour). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option