From fe410fe046efcf630df1540470d584b8f6a61ea6 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 1 Oct 2026 12:34:38 -0700 Subject: [PATCH 1/2] Ignore tests directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5cd651cc..c6a62304 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ Desktop.ini *.orig *.rej +# Local tests. +tests/ + # Build output, dependency caches, coverage, and profiling output. target/ node_modules/ From 16f4b91753b181b6ee23873db3bf0368d9677961 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 1 Oct 2026 16:40:16 -0700 Subject: [PATCH 2/2] Migrate non-video media processing to Rust Raise the minimum Rust version to 1.99 and move images, HEIF stills, container probing, common audio waveforms, and bounded PDF previews to Rust implementations. Retain FFmpeg for video and validated unsupported audio compatibility, with resource limits and atomic publication. Include regression fixtures, Chromium/no-JS coverage, and the final audit report. Preserve PDF fixture bytes with Git attributes. Local Rust, media, and real-video browser checks passed. In-band HE-AAC and chained/multiplexed Speex compatibility remain limited; Docker build/runtime validation is blocked by the unavailable Rust 1.99 base image. This commit does not certify production readiness. --- .gitattributes | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 4 +- .gitignore | 9 + Cargo.lock | 567 ++++++++++++++++- Cargo.toml | 18 +- Dockerfile | 6 +- README.md | 8 +- SETUP.md | 30 +- docs/admin-redesign-coverage.md | 1 - docs/containers.md | 2 +- docs/non-video-media-migration.md | 360 +++++++++++ docs/strict-lint-exceptions.tsv | 79 ++- src/banner.rs | 102 +-- src/config.rs | 13 +- src/config/admin.rs | 1 - src/config/admin/runtime.rs | 31 +- src/config/template.rs | 2 - src/detect.rs | 67 +- src/handlers/admin/mod.rs | 24 +- src/handlers/board/access_preferences.rs | 4 +- src/handlers/board/create_thread.rs | 2 - src/handlers/board/media.rs | 79 ++- src/handlers/mod.rs | 24 +- src/handlers/posting.rs | 20 +- src/handlers/setup.rs | 8 +- src/handlers/thread.rs | 14 +- src/media/audio.rs | 726 ++++++++++++++++++++++ src/media/convert.rs | 56 +- src/media/ffmpeg.rs | 261 +------- src/media/heif.rs | 277 +++++++++ src/media/images.rs | 307 +++++++++ src/media/images/tests.rs | 303 +++++++++ src/media/mod.rs | 40 +- src/media/pdf.rs | 358 +++++++++++ src/media/probe.rs | 505 +++++++++++++++ src/media/probe/speex.rs | 195 ++++++ src/media/thumbnail.rs | 513 +++------------ src/middleware/state.rs | 2 - src/pending_fs.rs | 2 +- src/server/server.rs | 20 +- src/templates/admin.rs | 17 +- src/templates/admin/maintenance.rs | 9 +- src/templates/admin/network.rs | 1 - src/templates/admin/site_health.rs | 1 - src/test_fixtures.rs | 1 - src/utils/files/storage.rs | 676 +++++--------------- src/workers/mod.rs | 305 +++++---- tests/cli_runtime.rs | 1 - tests/e2e/README.md | 11 +- tests/e2e/helpers.ts | 22 +- tests/e2e/media-toolchain-check.mjs | 22 +- tests/e2e/media-toolchain.spec.ts | 14 +- tests/e2e/non-video-media.spec.ts | 28 + tests/e2e/phase3-media-runtime.spec.ts | 15 +- tests/e2e/upload-regressions.spec.ts | 27 +- tests/e2e/upload-validation.spec.ts | 99 ++- tests/fixtures/media/README.md | 17 + tests/fixtures/media/ac3.m4a | Bin 0 -> 2245 bytes tests/fixtures/media/alpha.heic | Bin 0 -> 779 bytes tests/fixtures/media/audio.mkv | Bin 0 -> 2714 bytes tests/fixtures/media/audio.webm | Bin 0 -> 3012 bytes tests/fixtures/media/compressed.pdf | Bin 0 -> 463 bytes tests/fixtures/media/he-aac.m4a | Bin 0 -> 2230 bytes tests/fixtures/media/irot90.heic | Bin 0 -> 781 bytes tests/fixtures/media/multiple-streams.mp4 | Bin 0 -> 7625 bytes tests/fixtures/media/phone-hdr-LICENSE | 23 + tests/fixtures/media/phone-hdr.heic | Bin 0 -> 238982 bytes tests/fixtures/media/photo-LICENSE-APACHE | 201 ++++++ tests/fixtures/media/photo-LICENSE-MIT | 21 + tests/fixtures/media/photo.heic | Bin 0 -> 55438 bytes tests/fixtures/media/simple.pdf | Bin 0 -> 678 bytes tests/fixtures/media/speech-mode.opus | Bin 0 -> 457 bytes tests/fixtures/media/surround.opus | Bin 0 -> 9044 bytes tests/fixtures/media/text.pdf | Bin 0 -> 581 bytes tests/fixtures/media/tone-alac.m4a | Bin 0 -> 3008 bytes tests/fixtures/media/tone.aac | Bin 0 -> 2283 bytes tests/fixtures/media/tone.flac | Bin 0 -> 10352 bytes tests/fixtures/media/tone.m4a | Bin 0 -> 3068 bytes tests/fixtures/media/tone.mp3 | Bin 0 -> 2540 bytes tests/fixtures/media/tone.ogg | Bin 0 -> 4700 bytes tests/fixtures/media/tone.opus | Bin 0 -> 2587 bytes tests/fixtures/media/tone.spx | Bin 0 -> 692 bytes tests/fixtures/media/tone.wav | Bin 0 -> 23118 bytes tests/fixtures/media/video.mkv | Bin 0 -> 1719 bytes tests/fixtures/media/video.mp4 | Bin 0 -> 1945 bytes tests/fixtures/media/video.webm | Bin 0 -> 1072 bytes 88 files changed, 4684 insertions(+), 1877 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/non-video-media-migration.md create mode 100644 src/media/audio.rs create mode 100644 src/media/heif.rs create mode 100644 src/media/images.rs create mode 100644 src/media/images/tests.rs create mode 100644 src/media/pdf.rs create mode 100644 src/media/probe.rs create mode 100644 src/media/probe/speex.rs create mode 100644 tests/e2e/non-video-media.spec.ts create mode 100644 tests/fixtures/media/README.md create mode 100644 tests/fixtures/media/ac3.m4a create mode 100644 tests/fixtures/media/alpha.heic create mode 100644 tests/fixtures/media/audio.mkv create mode 100644 tests/fixtures/media/audio.webm create mode 100644 tests/fixtures/media/compressed.pdf create mode 100644 tests/fixtures/media/he-aac.m4a create mode 100644 tests/fixtures/media/irot90.heic create mode 100644 tests/fixtures/media/multiple-streams.mp4 create mode 100644 tests/fixtures/media/phone-hdr-LICENSE create mode 100644 tests/fixtures/media/phone-hdr.heic create mode 100644 tests/fixtures/media/photo-LICENSE-APACHE create mode 100644 tests/fixtures/media/photo-LICENSE-MIT create mode 100644 tests/fixtures/media/photo.heic create mode 100644 tests/fixtures/media/simple.pdf create mode 100644 tests/fixtures/media/speech-mode.opus create mode 100644 tests/fixtures/media/surround.opus create mode 100644 tests/fixtures/media/text.pdf create mode 100644 tests/fixtures/media/tone-alac.m4a create mode 100644 tests/fixtures/media/tone.aac create mode 100644 tests/fixtures/media/tone.flac create mode 100644 tests/fixtures/media/tone.m4a create mode 100644 tests/fixtures/media/tone.mp3 create mode 100644 tests/fixtures/media/tone.ogg create mode 100644 tests/fixtures/media/tone.opus create mode 100644 tests/fixtures/media/tone.spx create mode 100644 tests/fixtures/media/tone.wav create mode 100644 tests/fixtures/media/video.mkv create mode 100644 tests/fixtures/media/video.mp4 create mode 100644 tests/fixtures/media/video.webm diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..fb0c87fb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Preserve PDF fixture bytes, including fixed-width cross-reference records. +tests/fixtures/media/*.pdf binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 389eda7b..6cc69c7e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -98,7 +98,7 @@ body: id: data_involvement attributes: label: Database or media involvement - description: State whether SQLite, migrations, uploads, ffmpeg/ffprobe, boards, backups, or restore is involved. Do not upload real data. + description: State whether SQLite, migrations, uploads, media processing, FFmpeg video, boards, backups, or restore is involved. Do not upload real data. placeholder: "SQLite involved; reproduced on a new disposable database. No media involved." validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33400b32..02bcd6d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.91.0 + toolchain: 1.99.0 components: clippy, rustfmt - name: Cache Rust artifacts @@ -68,7 +68,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.91.0 + toolchain: 1.99.0 - name: Cache Rust artifacts uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -104,7 +104,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.91.0 + toolchain: 1.99.0 targets: ${{ matrix.target }} - name: Cache Rust artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4348104b..fba1813e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.91.0 + toolchain: 1.99.0 components: clippy, rustfmt - name: Cache Rust artifacts @@ -121,7 +121,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.91.0 + toolchain: 1.99.0 targets: ${{ matrix.target }} - name: Cache Rust artifacts diff --git a/.gitignore b/.gitignore index c6a62304..c7d66008 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,15 @@ Desktop.ini # Local tests. tests/ +# Migration unit tests compile these sanitized fixtures into the binary. +!tests/ +tests/* +!tests/fixtures/ +tests/fixtures/* +!tests/fixtures/media/ +!tests/e2e/ +tests/e2e/* +!tests/e2e/non-video-media.spec.ts # Build output, dependency caches, coverage, and profiling output. target/ diff --git a/Cargo.lock b/Cargo.lock index 626d5433..49eeb275 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -740,6 +740,20 @@ name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] [[package]] name = "byteorder" @@ -909,6 +923,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1440,7 +1463,7 @@ dependencies = [ "quote", "sha3 0.12.0", "strum", - "syn 2.0.119", + "syn 3.0.6", "unicode-ident", "void", ] @@ -1738,6 +1761,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1769,6 +1801,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1802,6 +1840,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "ff" version = "0.13.1" @@ -1888,6 +1932,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2126,6 +2179,23 @@ dependencies = [ "weezl", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "skrifa", + "smallvec", + "vello_common 0.0.9", +] + [[package]] name = "glob-match" version = "0.2.1" @@ -2155,6 +2225,15 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "h2" version = "0.4.19" @@ -2237,12 +2316,107 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "hayro" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image 0.25.10", + "kurbo", + "pic-scale", + "vello_cpu", +] + +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-syntax", + "kurbo", + "moxcms", + "phf 0.13.1", + "rustc-hash", + "siphasher", + "skrifa", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash", + "smallvec", + "zune-jpeg", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heic-rs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2932,6 +3106,18 @@ dependencies = [ "libc", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2982,6 +3168,12 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3278,6 +3470,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3405,6 +3606,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "opus-decoder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15ae32c275dabb0cd2c863460bf349e9abc3568ba2abf0f9eb7b7d2edeeed07e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "ordered-float" version = "2.10.1" @@ -3578,23 +3788,56 @@ dependencies = [ "base64ct", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.14.0" @@ -3602,7 +3845,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3611,13 +3867,22 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.14.0" @@ -3627,6 +3892,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -3731,6 +4006,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "polyval" version = "0.7.3" @@ -4112,6 +4396,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4174,6 +4468,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -4315,6 +4615,12 @@ dependencies = [ "time", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -4343,12 +4649,16 @@ dependencies = [ "flate2", "futures", "getrandom 0.4.3", + "gif", + "hayro", + "heic-rs", "hex", "hyper", "hyper-util", "image 0.25.10", "ipnet", "kamadak-exif", + "opus-decoder", "parking_lot", "pem-rfc7468 1.0.0", "r2d2", @@ -4371,6 +4681,7 @@ dependencies = [ "sha2 0.11.0", "sha3 0.12.0", "subtle", + "symphonia", "tar", "tempfile", "thiserror 2.0.20", @@ -4952,6 +5263,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" @@ -5140,6 +5461,191 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "symphonia" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6405d5c34ff6f8f7ca08a4101efe66d21e180f7322ef9359900a0e55698a7892" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5bf8e39552d34a3c4c98333370e62f48c92456d2e814f273b5c3ad7c4a5f45c" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445932ecb0c59362fde9c082dd63a75380650c5077fdb8b80b8cac850442a74c" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920a78f96f3cf62932d0c497959c0cc2100ef36b03c7ca1417b260f432a482d8" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73d90b4fcf796137cc683c538282804ff9629f8ad9dbfd881fcbba331ac4e986" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e681a70e1870d34e02abf1dbc51e4267c3f1827801474e8870be8c689fc4dc3" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d015c5c0558864665894b3f4cbd95e10abb01b9c868e751c72670f326a56360e" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5495e7f7e3c7035328d82b6d6e377eef289bb0c4105bdeb557fc93a833f994" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core", +] + [[package]] name = "syn" version = "1.0.109" @@ -6387,7 +6893,7 @@ dependencies = [ "itertools 0.15.0", "memchr", "paste", - "phf", + "phf 0.14.0", "rand 0.10.2", "rangemap", "saturating-time", @@ -6972,6 +7478,53 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vello_common" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common 0.0.8", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 80d59473..06c65a37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,7 @@ name = "rustchan" version = "1.6.0" edition = "2021" -# Keep the package MSRV and cargo-msrv metadata aligned with Arti's Rust 1.91 -# requirement. +# Rust 1.99 is the supported minimum for the package and build tooling. rust-version.workspace = true license = "MIT" description = "Self-contained imageboard — single binary, zero runtime dependencies" @@ -13,13 +12,13 @@ categories = ["web-programming", "multimedia::images"] # Keep `cargo-msrv` metadata aligned with `rust-version`. [package.metadata] -msrv = "1.91" +msrv = "1.99" [workspace] resolver = "2" [workspace.package] -rust-version = "1.91" +rust-version = "1.99" [profile.release] opt-level = 3 @@ -81,6 +80,17 @@ rand_core = "0.10.1" getrandom = { version = "0.4.3", features = ["sys_rng"] } image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp", "tiff", "ico"] } +# Already used by image; inspect GIF repeat/disposal metadata without retaining frames. +gif = { version = "0.14.2", default-features = false, features = ["std", "color_quant"] } +# Container inspection only for video; no video decoders are enabled. +symphonia = { version = "0.6.1", default-features = false, features = ["aac", "adpcm", "alac", "flac", "mp1", "mp2", "mp3", "pcm", "vorbis", "isomp4", "mkv", "ogg", "wav", "exp-video-codecs"] } +# Symphonia supplies Opus packets; this decoder avoids native libopus. +opus-decoder = { version = "0.1.1", default-features = false } +# HEVC intra still pictures only; no native library or video framework. +heic-rs = { version = "0.1.1", default-features = false, features = ["std"] } + +# Pure-Rust first-page PDF previews, with embedded standard fonts. +hayro = { version = "0.7.1", default-features = false, features = ["embed-fonts"] } # Apply JPEG Orientation tags before thumbnailing so phone photos display upright. kamadak-exif = "0.6.1" diff --git a/Dockerfile b/Dockerfile index b373058c..c494dfb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM rust:1.91.0-bookworm AS builder +FROM rust:1.99.0-bookworm AS builder WORKDIR /build # BuildKit retains compiled dependencies between builds without carrying Cargo @@ -19,10 +19,12 @@ LABEL org.opencontainers.image.title="RustChan" \ org.opencontainers.image.source="https://github.com/csd113/RustChan" \ org.opencontainers.image.licenses="MIT" -# FFmpeg and ffprobe enable RustChan's complete media pipeline. curl provides +# FFmpeg handles video and uncovered audio codecs. Rust handles other media. +# The standalone ffprobe executable is unnecessary. curl provides # a bounded readiness probe; ca-certificates support outbound TLS and Arti. RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl ffmpeg && \ + rm -f /usr/bin/ffprobe && \ rm -rf /var/lib/apt/lists/* && \ groupadd --system --gid 10001 rustchan && \ useradd --system --uid 10001 --gid rustchan --home-dir /data \ diff --git a/README.md b/README.md index 7f7693a4..eb4d7267 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Choose the option that suits you: |---|---|---| | [Docker](#start-with-docker) | Running the ready-made app with its media tools included | Docker running on your computer or server | | [Download the app](#download-the-app) | Running RustChan directly, without Docker or compiling code | A supported Linux, macOS, or Windows computer | -| [Build from source](#build-from-source) | Developers who want to build the app themselves | Git and Rust 1.91 or newer | +| [Build from source](#build-from-source) | Developers who want to build the app themselves | Git and Rust 1.99 or newer | ### Start with Docker @@ -62,7 +62,7 @@ docker stop rustchan # Stop the site docker start rustchan # Start it again ``` -The image includes `ffmpeg` and `ffprobe` for video thumbnails, audio waveforms, and supported media conversion. Tor is off by default in Docker. +Images, animated GIF/WebP conversion, HEIC/HEIF, container inspection, common audio waveforms, and supported PDF previews run in Rust. The image includes `ffmpeg` for video thumbnails, WebM transcoding, and audio codecs the Rust decoders do not cover. Tor is off by default in Docker. For **Docker Compose, updates, backups, or a public website**, follow the [container guide](docs/containers.md). Public sites need HTTPS and suitable network settings; complete setup before allowing visitors. The guide also explains how to pin an image to a specific revision instead of following `latest`. @@ -89,11 +89,11 @@ chmod +x rustchan-cli On Windows, run `rustchan-cli.exe` (or `./rustchan-cli.exe` in PowerShell). Follow the terminal's first-run prompts to create your administrator and, optionally, your first board. You can also create boards in [the admin panel](http://localhost:8080/admin). Keep the app running while you use the site. -RustChan creates a `rustchan-data` folder next to the app. Keep that folder when updating. Installing `ffmpeg` and `ffprobe` adds the enhanced media features; see [SETUP.md](SETUP.md) for installation and deployment details. +RustChan creates a `rustchan-data` folder next to the app. Keep that folder when updating. Installing `ffmpeg` enables video processing and the audio compatibility fallback; see [SETUP.md](SETUP.md) for installation and deployment details. ### Build from source -With Git and Rust **1.91 or newer** installed: +With Git and Rust **1.99 or newer** installed: ```bash git clone https://github.com/csd113/RustChan.git diff --git a/SETUP.md b/SETUP.md index eb5feff4..3ac027c8 100644 --- a/SETUP.md +++ b/SETUP.md @@ -2,12 +2,13 @@ Current setup and deployment guide for Linux, macOS, and Windows. -Current version: `1.5.0`. +Current candidate version: `1.6.0`. This guide reflects the current RustChan architecture: - Tor onion hosting is built in via Arti. You do not install or manage a separate `tor` service. -- `ffmpeg` is optional, but strongly recommended if you want WebP thumbnails, WebM transcoding, video thumbnails, and audio waveforms. +- Images, GIF/WebP animation, HEIC/HEIF, container metadata, common audio waveforms, and supported PDF previews run internally in Rust. Unsupported PDF previews use the built-in SVG placeholder. +- `ffmpeg` is optional, but recommended for video thumbnails, WebM transcoding, and uncovered audio codecs. Standalone `ffprobe` and external PDF renderers are not required. - The post edit form and self-delete flow share a 60-second self-action window after posting. ## Contents @@ -33,9 +34,9 @@ This guide reflects the current RustChan architecture: RustChan is a single Rust binary. A basic install only needs: -- Rust toolchain to build it +- Rust 1.99 or newer to build it - a writable runtime data directory (next to the binary by default, or selected with `--data-dir`) -- `ffmpeg` if you want the enhanced media pipeline +- `ffmpeg` for video processing and uncovered audio codecs RustChan does not require: @@ -96,11 +97,10 @@ cargo --version When `ffmpeg` is available, RustChan can: - extract video thumbnails -- generate audio waveform thumbnails -- convert supported image thumbnails to WebP +- generate waveform thumbnails for uncovered audio codecs (for example AC-3 or Speex) - transcode MP4 uploads to WebM when VP9 and Opus are available -Without `ffmpeg`, RustChan still runs, but video and audio handling degrades gracefully. +Without `ffmpeg`, images, animation, metadata inspection, supported PDF previews, and common audio waveforms still work. Video processing and uncovered-codec waveform previews use their existing placeholders. ### Debian / Ubuntu / Raspberry Pi OS @@ -135,7 +135,6 @@ Then make sure the FFmpeg `bin` directory is on `PATH`. ```bash ffmpeg -version -ffprobe -version ``` If you want RustChan to refuse startup when `ffmpeg` is missing, set: @@ -329,7 +328,6 @@ enable_tor_support = true require_ffmpeg = false # ffmpeg_path = "/usr/local/bin/ffmpeg" -# ffprobe_path = "/usr/local/bin/ffprobe" ffmpeg_timeout_secs = 600 [tls] @@ -705,3 +703,17 @@ Back that directory up if the onion address matters. ### Optional administrator-controlled software updates For immutable native Linux version directories, a separate restricted updater service, signing trust setup and automatic backup/rollback, follow [Software Updates](docs/software-updates.md). The existing single-binary service remains deployment-managed until explicitly converted. + +### Internal media processing + +RustChan handles JPEG/PNG/BMP/TIFF, static and animated WebP, GIF conversion, +HEIC/HEIF still pictures, container metadata, and common audio waveforms in Rust. +PDF previews cover a small bounded vector/text subset; compressed streams, +embedded images/fonts and other unbudgeted features use the existing SVG preview +while the original PDF remains available. + +FFmpeg remains the video backend. It also preserves waveform support for audio +variants the internal decoders do not cover, including AC-3, Speex, unsupported +AAC profiles and Opus multistream/surround. See the +[media migration report](docs/non-video-media-migration.md) for format coverage, +resource limits and verification. diff --git a/docs/admin-redesign-coverage.md b/docs/admin-redesign-coverage.md index 22ad3cb8..695525b4 100644 --- a/docs/admin-redesign-coverage.md +++ b/docs/admin-redesign-coverage.md @@ -50,7 +50,6 @@ Every inventory leaf is listed below. All 70 leaves have working controls or a j | Master arbitrary-file upload gate | enable_any_file_uploads_feature | `#media` → `#setting-enable_any_file_uploads_feature` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | | Require media tools at startup | require_ffmpeg | `#media` → `#setting-require_ffmpeg` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | | FFmpeg executable | ffmpeg_path | `#media` → `#setting-ffmpeg_path` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | -| FFprobe executable | ffprobe_path | `#media` → `#setting-ffprobe_path` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | | Generated thumbnail dimension | thumb_size | `#media` → `#setting-thumb_size` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | | Background job queue capacity | job_queue_capacity | `#media` → `#setting-job_queue_capacity` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | | Thumbnail/waveform cache budget | waveform_cache_max_mb | `#media` → `#setting-waveform_cache_max_mb` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | diff --git a/docs/containers.md b/docs/containers.md index 77520f01..306f5518 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -1,6 +1,6 @@ # Container deployment -RustChan's container listens on HTTP port **8080**. The image includes `ffmpeg`, `ffprobe`, and CA certificates. It runs as the unprivileged `rustchan` user (UID/GID 10001). Templates and static assets are embedded in the server binary. +RustChan's container listens on HTTP port **8080**. The image includes `ffmpeg` and CA certificates. It runs as the unprivileged `rustchan` user (UID/GID 10001). Templates and static assets are embedded in the server binary. ## Pull and run diff --git a/docs/non-video-media-migration.md b/docs/non-video-media-migration.md new file mode 100644 index 00000000..220f7a0c --- /dev/null +++ b/docs/non-video-media-migration.md @@ -0,0 +1,360 @@ +# Non-video media backend migration + +Implemented backend migration, with the explicitly authorized FFmpeg compatibility fallback for uncovered audio codecs/variants. All changes remain uncommitted. Rust 1.99 is now the minimum in Cargo, CI, release builds, Docker and setup documentation. Actual video frame decoding and VP9/Opus WebM transcoding remain on FFmpeg. + +**Final verification conclusion:** suitable for review, but production readiness is not established. The final pass below records passing validation, local fixes, remaining AAC/Speex compatibility limits, and the failed Docker base-image resolution. Do not treat the initial migration results as release approval. + +## Pre-implementation audit + +| Original operation / call site | Class | Required behavior | Implemented replacement / retained backend | Semantic regression coverage | +| --- | --- | --- | --- | --- | +| `media/ffmpeg.rs::ffmpeg_image_to_webp`, used by `convert.rs` | IMAGE | JPEG, BMP, TIFF, HEIC/HEIF to metadata-free WebP; PNG retained only when smaller | Existing `image` lossless WebP encoder; `heic-rs` still decoder | Decode output, dimensions, orientation, alpha, metadata and PNG size policy | +| Same helper with GIF input | IMAGE | Animated WebP, composited frames, delays, transparency and repeat behavior | Existing `image` GIF compositor and lossless WebP encoder, bounded animation container writer | Decode every output frame and compare pixels, delays and loops | +| `ffmpeg_image_to_webp_scaled`, used by `banner.rs` | IMAGE | GIF and animated WebP banners scaled without flattening | Same animation pipeline, existing image WebP decoder | Partial frames, disposal, bounds and stored animation compatibility | +| `thumbnail.rs` image branch -> `ffmpeg_thumbnail` | IMAGE | First composited frame thumbnail, alpha, aspect ratio, no upload mutation | Existing `image` decoder/resizer/encoder plus HEIC still decoder | Thumbnail dimensions/pixels with FFmpeg absent | +| `thumbnail.rs` video branch -> `ffmpeg_thumbnail` | VIDEO | Decode compressed video frame, fit thumbnail; SVG fallback on failure | Retain FFmpeg command and libwebp capability for this video operation | Existing video thumbnail tests | +| `probe_stream_kind`, called from upload validation and worker output validation | CONTAINER PROBING | Audio/video classification; MP4/M4A, WebM/MKV, standalone audio; malformed/ambiguous handling | Symphonia ISOBMFF/Matroska/audio format parsers | Old/new stream-kind and malformed fixture comparison | +| `probe_stream_kind_with_ffmpeg` -> two zero-frame stream-map commands | CONTAINER PROBING | Establish ambiguous WebM kind without decoding, neutral download if ambiguous | Same Rust container parsers; unsupported audio containers retain bounded zero-frame FFmpeg maps | Audio WebM, video WebM and ambiguous policy tests | +| `probe_video_codec`, called by transcode preparation/output validation | CONTAINER PROBING | First video codec, skip existing VP9, verify generated VP9 | Same container parsers; actual video processing retained | MP4 H.264 and WebM VP9 before/after | +| `probe_audio_codec`, called by MIME canonicalization | CONTAINER PROBING | Primary codec determines canonical audio MIME | Audio/container parser metadata | MP1/2/3, PCM, Vorbis, FLAC, AAC, Opus and M4A | +| `workers::generate_waveform` / `waveform_prepare` -> `showwavespic` | AUDIO | PNG of thumb width × half-height, streaming decode, deterministic envelope, atomic job persistence, timeout/cancellation | Symphonia plus focused pure-Rust Opus decoder; unavailable codecs/variants use the approved FFmpeg fallback | Supported formats, silence, short/stereo audio, malformed input, job persistence and no executable | +| `thumbnail.rs`: `pdftoppm`, `mutool draw`, `qlmanage -t` | PDF | Bounded first-page WebP or existing static SVG placeholder; rendering failure does not reject valid PDF | Hayro behind a conservative input/work gate; static fallback retained | Simple render, malformed/unsupported PDF fallback | +| `workers::transcode_video` -> `build_vp9_transcode_args` | VIDEO TRANSCODING | Existing libvpx-vp9/libopus flags, color normalization, tuning, timeouts, cancellation, atomicity and queue behavior | Retain existing FFmpeg backend | Existing transcode argument, integration and job tests unchanged | +| `detect.rs`, `media/ffmpeg.rs`: executable `-version` and `-encoders` probes | Capability reporting | Accurate startup/admin status without obsolete non-video tool requirements | Retain FFmpeg/video encoder detection; standalone FFprobe and image/PDF process detection removed | Startup with nonexistent executable paths and reporting tests | + +Related audit surfaces: `media/process.rs` owns bounded subprocess output and process-group cleanup (retained for video); `utils/files/storage.rs` owns validation, orientation, size policy and atomic upload handoff; `workers/mod.rs` owns durable jobs and waveform persistence; application state, posting handlers, maintenance reports, configuration, Dockerfile, README, SETUP and browser media tests carry executable capability assumptions. SVG upload rejection and no-JS/UI hooks remain unchanged. + +## Dependencies and implementation choices + +| Direct dependency | Why selected | Enabled scope / native-library audit | +| --- | --- | --- | +| Existing `image` 0.25.10 | Reuse the existing mature codecs, resizer, orientation support and lossless WebP encoder | JPEG/PNG/GIF/WebP/BMP/TIFF/ICO; no new native image library | +| `gif` 0.14.2 (already transitive) | Read repeat metadata without retaining the whole animation | `default-features = false`; `std`, `color_quant`; pure Rust | +| [Symphonia 0.6.1](https://github.com/pdeljanov/Symphonia) | Established incremental audio decoders and content-based container parsers | Only required audio codecs and ISOBMFF/MKV/Ogg/WAV readers; experimental video **declarations** enable metadata IDs, with no video decoder registered | +| [opus-decoder 0.1.1](https://docs.rs/opus-decoder/0.1.1/opus_decoder/) | Symphonia supplies Opus packets but lacks an Opus decoder | No defaults, no native libopus, no build script; mono/stereo CELT and low-bitrate voice fixtures verified | +| [heic-rs 0.1.1](https://github.com/tbraun96/heic-rs) | Focused HEVC-intra still-picture decoder with container transforms and pixel bounds | `std` only, one decoding thread, no libheif/FFmpeg/system HEVC library or build script | +| [Hayro 0.7.1](https://github.com/LaurenzV/hayro) | First-page vector/text PDF rendering without a native PDF executable/library | No defaults; embedded standard fonts; Rust font, raster, compression and image dependencies | + +`webpkit` was evaluated; its Rust 1.96 requirement is now covered by the requested Rust 1.99 minimum. It would duplicate existing codecs. A small checked RIFF animation writer reuses `image`'s lossless VP8L encoding instead. It writes full composited frames to temporary output without retaining encoded frames for the entire animation. + +`cargo tree` and `cargo tree --edges features` were inspected. The added media dependency closures contain no FFmpeg/libwebp/libheif/native-Opus bindings or multimedia `*-sys` build dependencies. Existing SQLite/TLS/native dependencies elsewhere in the program remain. The lockfile adds 50 package versions and removes no previous package versions. `cargo deny --locked check` passed advisories, bans, licenses and sources (existing duplicate-version and `captcha` manifest-license warnings remain). + +HEIC and Opus decoders are recent focused implementations. Their selected behavior is covered by representative real/generated files, rather than a claim of universal codec conformance. In particular, the Opus crate's multistream splitter rejects a valid standard libopus 5.1 fixture; RustChan deliberately does not use that implementation. Valid mapped Opus streams use the approved compatibility backend. The pinned Opus version maps both unimplemented features and internal transform failures to public `InternalError`. That error now fails closed; only validated multistream metadata explicitly selects compatibility. + +## Before/after format coverage + +| Format/operation | Previous behavior | Current behavior / verified evidence | +| --- | --- | --- | +| JPEG, BMP, TIFF | FFmpeg WebP conversion when available; image-crate fallback previews | Rust lossless WebP; exact decoded pixel equality, dimensions, thumbnails, stripped output metadata | +| PNG, transparent PNG | Keep converted WebP only when smaller | Same size policy in Rust; alpha and decoded pixel equality preserved | +| GIF | FFmpeg animated WebP when codec/tool available | Rust animated WebP; four partial/composited frames, Keep/Previous/Background disposal, transparent pixels, 30/110/230/70 ms delays, infinite and finite loops decoded and compared | +| Static WebP | Preserve upload and generate preview | Existing upload compatibility retained; Rust preview and alpha checks | +| Animated WebP | Preserve stored animation; optional scaled animation for banners | Source animation remains intact; scaled banner output is decoded to prove multiple frames, alpha and timing | +| HEIC/HEIF | FFmpeg still-picture conversion/preview | Rust tiled HEIC and iPhone primary-image decode, alpha and container rotation; exact decoded-pixel equality after lossless WebP encoding and bounded thumbnails | +| MP4/M4A, WebM, MKV | Separate FFprobe, or zero-frame FFmpeg stream inspection | Rust content-based tracks/primary codec inspection; nineteen fixtures compared with FFprobe during final verification, plus malformed input and misleading extensions | +| MP3, FLAC, WAV/PCM, Vorbis, AAC-LC, M4A/ALAC, common Opus, audio WebM/MKV | FFmpeg waveform; placeholder without FFmpeg | Streaming Rust audio and width-sized envelope; MP3/FLAC/WAV/Vorbis/AAC/M4A/Opus/audio-WebM fixtures verified; MKV FLAC track inspection verified | +| Opus mono/stereo, voice/SILK | FFmpeg | Rust, including exact encoder-delay/end-padding handling and header gain | +| Uncovered audio: AC-3, Speex, unsupported AAC profiles, mapped Opus/multistream | FFmpeg when present, otherwise placeholder | Approved FFmpeg compatibility path, selected only for unavailable formats/codecs/variants; AC-3 M4A and standard 5.1 Opus waveform output verified | +| PDF | External Poppler/MuPDF/QuickLook preview when available, otherwise SVG | Hayro renders a small bounded uncompressed vector/text subset; broader or unsafe rendering uses the existing SVG without changing/rejecting the PDF download | +| Video frame thumbnails | FFmpeg/libwebp or SVG | Retained command unchanged; actual WebP video thumbnail browser pass | +| Video → WebM | FFmpeg libvpx-vp9/libopus | Retained encoder arguments, color normalization, CPU tuning, timeouts, cancellation, atomic DB/file lifecycle; actual browser transcode, WebM playback markup and stale-MP4 redirect pass | + +The compatibility fallback was explicitly authorized after reproducing AC-3 M4A failure in Rust and success with the original `showwavespic` command. It overrides the objective's earlier strict no-audio-FFmpeg rule. Common codecs never consult an executable to render their waveforms. + +## Equivalence, privacy and resource limits + +- Static lossless WebP pixels match the decoded source exactly; EXIF, ICC and XMP are absent from newly encoded WebP. JPEG orientation 6 remains correct through conversion and thumbnailing. The existing original-PNG size policy and original-file error fallback remain. +- GIF semantic tests decode every WebP frame and compare pixels, order, delays and repeat behavior against the original GIF compositor. A finite GIF repeat count is mapped to WebP total plays. Existing animated WebP scaling remains animated. +- HEIC fixtures include a 2048 × 1536 tiled Apple-encoded scene, 48 × 48 auxiliary alpha, 40 × 64 rotated color corners, and an actual 1512 × 850 iPhone HDR file. HEIF extension handling uses content. Pixel comparisons with FFmpeg gave mean absolute 8-bit channel differences of about 0.19 for the generated tiled scene and 4.90 for the phone primary SDR image; exact encoded bytes/colorspace rounding are not promised. HDR gain-map rendering is outside the existing primary-thumbnail behavior. Provenance and licenses are in `tests/fixtures/media/README.md`. +- The old Poppler renderer and Hayro produce the same 100 × 80 first-page geometry and red interior for the two-page vector PDF fixture. A valid compressed PDF is accepted unchanged and receives SVG. **PDF rendering coverage is deliberately narrower:** flat page trees, an object ceiling before parsing, and a source-size × viewport-pixel work ceiling precede rendering. Compressed streams, embedded images/fonts, recursion-capable resources and other unbudgeted features use placeholders because Hayro exposes no general allocation/work cancellation budget. +- Decoding is off the async executor. Existing upload limits remain; images have a 40-million-pixel budget and allocation limits. Animation processes frames incrementally, checks dimensions, limits frame count to 10,000, and uses the operator deadline. Output RIFF lengths/durations are checked, animated output is capped at 256 MiB before each frame write, and publication uses temporary files. +- HEIC source reads are bounded to the shared allocation limit; primary/alpha coded SPS dimensions are checked before plane allocation, tile counts are bounded, and the sum of coded tile areas is bounded. Codec-level work is cooperative at existing operation boundaries. +- Audio uses two streaming passes (count frames, then fill peak buckets), a 4-million-interleaved-sample packet ceiling, finite-sample checks, deterministic mean absolute channel amplitude, and existing cancellation/deadline checks. Tests cover silence, a single sample frame, opposite-phase six-channel PCM, 120-second PCM, malformed input, unavailable-codec selection and Opus trimming. No full-clip PCM buffer is retained. +- Symphonia container readers cache regular-file length and enforce cancellation, a deadline, a 64 MiB header-read ceiling, a 16 MiB packet-read ceiling, and a repeated-read budget before progressive parser allocations. Metadata tag/artwork sizes are capped. Malformed input, I/O errors, cancellation and resource failures do not select the compatibility decoder. +- Waveform persistence keeps validated paths, temporary output, database transactions, hashes, rollback cleanup and durable job recovery. Faster waveform jobs exposed a stale-SVG request race; protected-board/path checks now precede a temporary redirect to an existing completed PNG, including the race where the file disappears just before `ServeFile` opens it. + +Useful debug measurements on this Apple-silicon host: isolated HEIC decode was about 1.52 s for the tiled scene, 0.97 s for the phone photo, and 1–2 ms for the small alpha/rotation fixtures. These are debug measurements and include no release-performance claim. + +## Remaining process responsibilities + +- `media/ffmpeg.rs::ffmpeg_thumbnail`: actual video-frame decoding and WebP preview encoding. The function body was compared with HEAD and is unchanged. +- `workers::transcode_video` / `media/ffmpeg.rs::build_vp9_transcode_args`: retained video WebM transcoder. Both the argument builder and CPU profile selection were compared with HEAD and are unchanged. +- `workers::render_waveform_with_compatibility`: original bounded `showwavespic` command for uncovered audio only, sharing the worker deadline, process-group shutdown and atomic publication. +- `media/ffmpeg.rs::probe_uncovered_audio`: bounded zero-frame stream maps only after an unsupported audio container/parser result, preserving accepted codec/container variants. +- FFmpeg version/encoder detection and configuration executable validation remain for the retained backend. No standalone FFprobe or external PDF renderer process wrapper remains. Docker removes the unneeded standalone `/usr/bin/ffprobe` after installing FFmpeg. + +## Initial migration validation (superseded by final verification below) + +All checks use Rust/cargo 1.99.0. Tests include the existing video argument/lifecycle tests, newly generated media fixtures, strong image/animation pixel checks, real HEIC files, primary-codec/stream comparisons, common audio/voice decoding, multistream compatibility regression, PDF rendering/fallback, malformed inputs and atomic waveform persistence. Browser fixtures now contain real containers rather than header-only fake media; byte-exact PNG assertions allow the original smaller-WebP retention policy. + +| Command / pass | Result | +| --- | --- | +| `cargo fmt --all --check` | Passed | +| `cargo check --workspace --all-targets --all-features` | Passed | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo` | Passed | +| `cargo test --workspace --all-features` | Passed: 1,266 tests in each library/application suite, 5 CLI tests, 1 UI fixture test and 1 doc test | +| `cargo tree`; `cargo tree --edges features` | Inspected | +| `cargo deny --locked check` | Passed all policies; existing duplicate-version warnings | +| `CHAN_FFMPEG_PATH=__rustchan_non_video_tools_unavailable__ CHAN_REQUIRE_FFMPEG=0 cargo test --workspace --all-features media::` | Passed: 91 media tests in each library/application suite with FFmpeg unavailable | +| `cargo test --all-features waveform_common_codecs_work_without_tools_and_uncovered_codecs_use_fallback` | Passed for common Opus with tool disabled, AC-3/Opus 5.1 with tool restored, and malformed-input rejection | +| `npx playwright test tests/e2e/upload-validation.spec.ts --project=chromium --project=chromium-nojs --workers=2` | Passed: 55 scenarios with JS, 54 with no JS and one JS-only scenario skipped | +| `npx playwright test tests/e2e/non-video-media.spec.ts --project=chromium --project=chromium-nojs --workers=2` | Passed: all eight common formats complete waveform jobs without FFmpeg in both modes | +| Final combined upload-validation and non-video-media browser run, both Chromium projects | Passed: all 4 tests with the final implementation | +| `RUSTCHAN_E2E_FFMPEG_PATH=/tmp/rustchan-video-validation/ffmpeg-9.0.2/ffmpeg npm run test:e2e:media` | Passed: actual video WebP thumbnail, VP9/Opus WebM, redirect, audio waveform, internal PDF preview and responsive media layouts | +| `git diff --check` | Passed | + +The installed FFmpeg lacked libwebp, so a temporary source build with libwebp/libvpx/libopus was used to run the unchanged strict video browser test. It lives only under `/tmp`; no system FFmpeg installation or runtime video architecture was replaced. Docker was subsequently attempted in the final verification below. Hosted CI/release jobs were not run remotely. + +## Files changed + +- `.github/workflows/ci.yml` +- `.github/workflows/release.yml` +- `Cargo.lock` +- `Cargo.toml` +- `Dockerfile` +- `README.md` +- `SETUP.md` +- `docs/admin-redesign-coverage.md` +- `docs/containers.md` +- `docs/non-video-media-migration.md` +- `docs/strict-lint-exceptions.tsv` +- `src/banner.rs` +- `src/config.rs` +- `src/config/admin.rs` +- `src/config/admin/runtime.rs` +- `src/config/template.rs` +- `src/detect.rs` +- `src/handlers/admin/mod.rs` +- `src/handlers/board/access_preferences.rs` +- `src/handlers/board/create_thread.rs` +- `src/handlers/board/media.rs` +- `src/handlers/mod.rs` +- `src/handlers/posting.rs` +- `src/handlers/setup.rs` +- `src/handlers/thread.rs` +- `src/media/audio.rs` +- `src/media/convert.rs` +- `src/media/ffmpeg.rs` +- `src/media/heif.rs` +- `src/media/images.rs` +- `src/media/images/tests.rs` +- `src/media/mod.rs` +- `src/media/pdf.rs` +- `src/media/probe.rs` +- `src/media/thumbnail.rs` +- `src/middleware/state.rs` +- `src/pending_fs.rs` +- `src/server/server.rs` +- `src/templates/admin.rs` +- `src/templates/admin/maintenance.rs` +- `src/templates/admin/network.rs` +- `src/templates/admin/site_health.rs` +- `src/test_fixtures.rs` +- `src/utils/files/storage.rs` +- `src/workers/mod.rs` +- `tests/cli_runtime.rs` +- `tests/e2e/README.md` +- `tests/e2e/helpers.ts` +- `tests/e2e/media-toolchain-check.mjs` +- `tests/e2e/media-toolchain.spec.ts` +- `tests/e2e/non-video-media.spec.ts` +- `tests/e2e/upload-regressions.spec.ts` +- `tests/e2e/upload-validation.spec.ts` +- `tests/fixtures/media/README.md` +- `tests/fixtures/media/ac3.m4a` +- `tests/fixtures/media/alpha.heic` +- `tests/fixtures/media/audio.mkv` +- `tests/fixtures/media/audio.webm` +- `tests/fixtures/media/compressed.pdf` +- `tests/fixtures/media/irot90.heic` +- `tests/fixtures/media/phone-hdr-LICENSE` +- `tests/fixtures/media/phone-hdr.heic` +- `tests/fixtures/media/photo-LICENSE-APACHE` +- `tests/fixtures/media/photo-LICENSE-MIT` +- `tests/fixtures/media/photo.heic` +- `tests/fixtures/media/simple.pdf` +- `tests/fixtures/media/speech-mode.opus` +- `tests/fixtures/media/surround.opus` +- `tests/fixtures/media/tone.aac` +- `tests/fixtures/media/tone.flac` +- `tests/fixtures/media/tone.m4a` +- `tests/fixtures/media/tone.mp3` +- `tests/fixtures/media/tone.ogg` +- `tests/fixtures/media/tone.opus` +- `tests/fixtures/media/tone.wav` +- `tests/fixtures/media/video.mkv` +- `tests/fixtures/media/video.mp4` +- `tests/fixtures/media/video.webm` + +## Final verification and polish pass + +The current candidate, including changed and untracked source, configuration, templates, documentation, tests and fixtures, was reviewed against the pre-migration HEAD. Existing user changes were preserved. No commit, branch, push, tag, release, backup deletion or known-good replacement was performed. No application dependency was added during this pass. + +### Issues found and local fixes + +- The broad `tests/` ignore hid new compile-time media fixtures and the new browser specification. Narrow exceptions now expose those inputs to Git while generated browser output remains ignored. These untracked files must accompany the candidate when it is eventually committed. +- Unsupported-codec routing was too broad: malformed container errors, resource failures and Opus internal decoder errors could select FFmpeg. Routing now recognizes explicit audited unsupported variants; malformed input, I/O, cancellation, deadlines and limits fail closed. AAC configuration checks distinguish valid unsupported profiles and explicit/implicit SBR metadata from malformed configurations. +- Standard Ogg Speex could disappear inside Symphonia's unsupported Ogg mapping. A bounded page validator now checks identification metadata, framing, CRC, sequence, continuation and completion before selecting compatibility. Real Speex, HE-AAC M4A, AC-3 and mapped surround Opus exercise the worker fallback. +- Container parsing could allocate from hostile packet lengths before the later sample limit. File reads now enforce cancellation/deadlines and header/packet read ceilings before progressive parser allocation. ADTS validation scans fixed headers and seeks over payload instead of buffering the file; reserved frequency indices are rejected. +- PDF page-tree recursion could occur before the existing post-parse object limit. Preflight now rejects recursive/indirect page trees, duplicate objects, revisions and unbudgeted stream/resource structures before Hayro parsing. A checked source-byte × viewport-pixel work ceiling also precedes rendering. Unsupported PDFs keep their original download and deterministic SVG. +- Animated WebP checked the RIFF limit only after encoding the entire animation. The writer now checks a 256 MiB output budget before each frame is published to its temporary output; failures preserve the original upload. +- Completed MKV transcodes could leave stale original URLs returning 404. Validated MP4/MKV URLs now redirect to the completed WebM, including the file-open race. Protected-board and path/symlink checks still precede redirect handling. +- A global test-only PDF renderer override could leak between concurrent tests. The override is now thread-local and guarded; production behavior is unchanged. +- Browser fixtures/assertions assumed obsolete MIME choices, optional local audio encoders and a no-JS mouse gesture that stalled Chromium. Tests now follow the stored media URL, reuse real bundled audio fixtures, assert exact intentional negative-upload diagnostics, and check no-JS resize behavior through CSS. Common-audio browser coverage adds ALAC and SILK/voice Opus. +- Added representative large transparent image, truncated image, malformed container, bounded-read, PDF vector/text and resource regressions. Refreshed the narrow lint-exception ledger, fixture provenance, setup version and stale FFprobe wording. + +### Final validation + +All local Rust checks used Rust/cargo 1.99.0. The following final checks passed after the production changes: + +| Command / pass | Final result | +| --- | --- | +| `cargo fmt --all --check` | Passed | +| `cargo check --workspace --all-targets --all-features` | Passed | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo` | Passed | +| `cargo test --workspace --all-features` | 1,275 library + 1,275 application unit tests, 5 CLI integration tests, 1 UI fixture integration test and 1 doc test: **2,557 passed**, zero failures | +| `cargo tree`; `cargo tree --edges features` | Inspected; no new native multimedia requirement in the added dependency closures | +| `cargo deny --locked check` | Advisories, bans, licenses and sources passed; existing duplicate-version and `captcha` manifest-license warnings remain | +| `CHAN_FFMPEG_PATH=__rustchan_non_video_tools_unavailable__ CHAN_REQUIRE_FFMPEG=0 cargo test --workspace --all-features media::` | **100 + 100 media tests passed** with FFmpeg unavailable | +| Chromium + Chromium no-JS: upload validation, non-video media, media and phase-3 runtime | **14 passed, 4 intentional skips**; upload matrix covers 55 JS scenarios and 54 no-JS scenarios, with one JS-only scenario skipped; ten common audio formats complete jobs without FFmpeg | +| Chromium + Chromium no-JS: real media toolchain, auto-compression, opt-in upload regressions and phase-3 runtime | **14 passed, 8 intentional project/JS-only skips**; actual WebP video thumbnails, VP9/Opus WebM, redirects, playback markup and oversized-upload cleanup verified | +| Chromium + Chromium no-JS: affected PDF/media toolchain rerun after the final PDF work ceiling | **8 passed**, zero skips | +| `git diff --check` | Passed, including the final report edits | + +The combined browser command was: + +```sh +RUSTCHAN_AUDIT_OUTPUT=output/playwright/final-verification-browser-verified npx playwright test tests/e2e/upload-validation.spec.ts tests/e2e/non-video-media.spec.ts tests/e2e/media.spec.ts tests/e2e/phase3-media-runtime.spec.ts --project=chromium --project=chromium-nojs --workers=2 +``` + +The real-video command was: + +```sh +RUSTCHAN_UPLOAD_REGRESSION_E2E=1 RUSTCHAN_E2E_MEDIA_TOOLCHAIN=1 RUSTCHAN_E2E_FFMPEG_PATH=/tmp/rustchan-video-validation/ffmpeg-9.0.2/ffmpeg RUSTCHAN_AUDIT_OUTPUT=output/playwright/final-verification-real-media-verified npx playwright test tests/e2e/media-toolchain.spec.ts tests/e2e/auto-compress.spec.ts tests/e2e/upload-regressions.spec.ts tests/e2e/phase3-media-runtime.spec.ts --project=chromium --project=chromium-nojs --workers=2 +``` + +The final affected browser rerun was: + +```sh +RUSTCHAN_E2E_MEDIA_TOOLCHAIN=1 RUSTCHAN_E2E_FFMPEG_PATH=/tmp/rustchan-video-validation/ffmpeg-9.0.2/ffmpeg RUSTCHAN_AUDIT_OUTPUT=output/playwright/final-verification-pdf-budget npx playwright test tests/e2e/media-toolchain.spec.ts tests/e2e/media.spec.ts --project=chromium --project=chromium-nojs --workers=2 +``` + +Final logs are under `/tmp/rustchan-final-verification/`; browser results are in the ignored `output/playwright/final-verification-*` directories. The temporary FFmpeg build includes libwebp/libvpx/libopus and did not replace the installed executable. A temporary Speex fixture encoder was built from the [official Speex release source](https://www.speex.org/downloads/); it is not an application dependency. + +The retained `ffmpeg_thumbnail`, `build_vp9_transcode_args`, `vp9_encoding_profile`, `wait_for_ffmpeg_output`, `waveform_finalise` and `transcode_video_finalise` function bodies were compared with HEAD and remain unchanged. Nineteen real media fixtures were compared with FFprobe as an oracle, including multi-stream MP4, audio-only containers, M4A, WebM and MKV. FFprobe is not required for normal Rust probing. + +### Unresolved compatibility and release limits + +1. **In-band HE-AAC ADTS is a confirmed gap.** Remuxing the HE-AAC M4A fixture to ADTS preserves a valid 48 kHz, two-channel HE-AAC stream according to FFprobe. Symphonia 0.6.1 instead declares and decodes the 24 kHz AAC-LC core successfully, exposes no SBR profile/configuration through its public metadata, and does not return an unsupported error. The compatibility fallback is therefore not selected. Correct SBR reconstruction and waveform equivalence are not established. Header-signaled HE-AAC M4A now falls back correctly, but that does not repair this packet-signaled case. Accurate detection needs decoder support; a payload signature heuristic or routing all common AAC through FFmpeg would violate the intended routing policy. +2. **Speex coverage is deliberately limited to a validated single logical Ogg stream.** Chained/multiplexed Speex is rejected by the new validator. Other unvalidated AAC configurations and payload-level unsupported features also fail closed. These limits prevent a claim that every previously FFmpeg-supported upload remains equivalent. +3. **Docker build/runtime is unresolved.** Docker Desktop and the daemon were available. The actual `docker build --progress=plain --tag rustchan-final-verification:local .` attempt failed while resolving `docker.io/library/rust:1.99.0-bookworm`: `not found`. No builder step ran. The Linux production binary, runtime startup/health, non-root execution and in-container retained FFmpeg behavior remain unverified. An older Rust base image was not substituted and no image was pushed. +4. Image/HEIC codec calls remain cooperative at bounded operation boundaries; the wrapper cannot forcibly interrupt one library decode. Representative behavior and malformed-input/resource regressions passed, but this is not universal codec conformance or a formal bound on every decoder instruction. Hosted CI/release jobs were not run remotely. + +### Release gates + +| # | Gate | Assessment | +| --- | --- | --- | +| 1 | Common non-video media does not require FFmpeg | **Satisfied for the representative supported formats**, including ALAC and SILK/voice Opus | +| 2 | Video still works through FFmpeg | **Satisfied** by real thumbnails/transcoding/browser playback and unchanged argument builders | +| 3 | Approved uncommon-audio fallback works | **Partially satisfied**: AC-3, standard Speex, HE-AAC M4A and surround Opus pass; in-band HE-AAC ADTS remains uncovered | +| 4 | Malformed errors cannot incorrectly select compatibility fallback | **Satisfied for audited routing and regression cases**; explicit unsupported classification is separated from malformed/I/O/resource/cancellation errors | +| 5 | PDF fallback behaves intentionally | **Satisfied**: supported vector/text render; complex or unbudgeted inputs retain the original and receive SVG | +| 6 | No meaningful supported upload behavior was lost | **Not satisfied**: the AAC gap and Speex/other AAC limits prevent certification | +| 7 | Resource/security limits remain enforced | **Satisfied for audited wrappers and tested cases**, with cooperative library-decode limits disclosed above | +| 8 | Docker validates locally, or inability is explicitly reported | **Failure explicitly reported; build/runtime release validation remains unresolved** because the required base tag is unavailable | +| 9 | Rust 1.99 is consistent | **Satisfied** in active Cargo, CI, release, Docker and user/developer setup requirements; historical references are intentional | +| 10 | No unexplained working-tree changes | **Satisfied**: candidate changes reviewed and this pass's additions/fixes listed below | + +The working tree is safe to inspect and review. It is **not certified for a production-ready migration commit or release** until the compatibility gaps and Docker validation are resolved. All changes remain uncommitted. + +### Files changed during this final pass only + +The broader candidate file list above includes earlier migration changes. This pass touched these 24 files: + +- `.gitignore` +- `.github/ISSUE_TEMPLATE/bug_report.yml` +- `SETUP.md` +- `docs/non-video-media-migration.md` +- `docs/strict-lint-exceptions.tsv` +- `src/handlers/board/media.rs` +- `src/media/audio.rs` +- `src/media/images.rs` +- `src/media/images/tests.rs` +- `src/media/pdf.rs` +- `src/media/probe.rs` +- `src/media/probe/speex.rs` (new in this pass) +- `src/media/thumbnail.rs` +- `src/utils/files/storage.rs` +- `src/workers/mod.rs` +- `tests/e2e/non-video-media.spec.ts` +- `tests/e2e/phase3-media-runtime.spec.ts` +- `tests/e2e/upload-regressions.spec.ts` +- `tests/fixtures/media/README.md` +- `tests/fixtures/media/tone-alac.m4a` (new in this pass) +- `tests/fixtures/media/multiple-streams.mp4` (new in this pass) +- `tests/fixtures/media/he-aac.m4a` (new in this pass) +- `tests/fixtures/media/tone.spx` (new in this pass) +- `tests/fixtures/media/text.pdf` (new in this pass) + +### Final `git status --short` + +```text + M .github/ISSUE_TEMPLATE/bug_report.yml + M .github/workflows/ci.yml + M .github/workflows/release.yml + M .gitignore + M Cargo.lock + M Cargo.toml + M Dockerfile + M README.md + M SETUP.md + M docs/admin-redesign-coverage.md + M docs/containers.md + M docs/strict-lint-exceptions.tsv + M src/banner.rs + M src/config.rs + M src/config/admin.rs + M src/config/admin/runtime.rs + M src/config/template.rs + M src/detect.rs + M src/handlers/admin/mod.rs + M src/handlers/board/access_preferences.rs + M src/handlers/board/create_thread.rs + M src/handlers/board/media.rs + M src/handlers/mod.rs + M src/handlers/posting.rs + M src/handlers/setup.rs + M src/handlers/thread.rs + M src/media/convert.rs + M src/media/ffmpeg.rs + M src/media/mod.rs + M src/media/thumbnail.rs + M src/middleware/state.rs + M src/pending_fs.rs + M src/server/server.rs + M src/templates/admin.rs + M src/templates/admin/maintenance.rs + M src/templates/admin/network.rs + M src/templates/admin/site_health.rs + M src/test_fixtures.rs + M src/utils/files/storage.rs + M src/workers/mod.rs + M tests/cli_runtime.rs + M tests/e2e/README.md + M tests/e2e/helpers.ts + M tests/e2e/media-toolchain-check.mjs + M tests/e2e/media-toolchain.spec.ts + M tests/e2e/phase3-media-runtime.spec.ts + M tests/e2e/upload-regressions.spec.ts + M tests/e2e/upload-validation.spec.ts +?? docs/non-video-media-migration.md +?? src/media/audio.rs +?? src/media/heif.rs +?? src/media/images.rs +?? src/media/images/ +?? src/media/pdf.rs +?? src/media/probe.rs +?? src/media/probe/ +?? tests/e2e/non-video-media.spec.ts +?? tests/fixtures/ +``` diff --git a/docs/strict-lint-exceptions.tsv b/docs/strict-lint-exceptions.tsv index 786f70ae..37b0f877 100644 --- a/docs/strict-lint-exceptions.tsv +++ b/docs/strict-lint-exceptions.tsv @@ -1,6 +1,6 @@ path line attribute lints reason scope src/banner.rs 1125 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn rejects_banner_gif_frame_bomb -src/banner.rs 1148 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn animated_gif_banner_falls_back_to_gif_without_ffmpeg +src/banner.rs 1148 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn animated_gif_banner_converts_to_animated_webp_without_ffmpeg src/banner.rs 1196 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn deleting_banner_asset_removes_webp_and_gif_siblings src/banner.rs 1235 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn animated_webp_banner_is_preserved_without_static_decode src/banner.rs 1264 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn rejects_oversized_banner_dimensions @@ -149,7 +149,7 @@ src/handlers/admin/settings/themes.rs 252 expect clippy::too_many_lines builder src/handlers/board/access_preferences.rs 959 expect clippy::too_many_lines rate limiting, credential checks, signed access grants, and redirect validation form one request fn unlock_board_access src/handlers/board/catalog.rs 26 expect clippy::too_many_lines access control, catalog loading, unread-state calculation, and rendering form one request fn catalog src/handlers/board/create_thread.rs 15 expect clippy::too_many_lines access checks, multipart validation, thread creation, and response cookies form one request fn create_thread -src/handlers/board/media.rs 91 expect clippy::too_many_lines path validation, access policy, legacy redirect handling, and file response form one request fn serve_board_media +src/handlers/board/media.rs 103 expect clippy::too_many_lines path validation, access policy, legacy redirect handling, and file response form one request fn serve_board_media src/handlers/board/pages.rs 37 expect clippy::too_many_lines home-page data loading, activity markers, banner selection, and rendering form one request fn index src/handlers/board/pages.rs 226 expect clippy::too_many_lines board access, pagination, activity markers, banner selection, and rendering form one request fn board_index src/handlers/board/tests.rs 432 expect clippy::too_many_lines the end-to-end test keeps its fixture setup and ordered assertions in one scenario fn post_password_board_write_actions_require_unlock @@ -189,11 +189,8 @@ src/media/prune.rs 524 expect clippy::panic_in_result_fn test assertions intenti src/media/prune.rs 595 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn pruning_removes_secondary_audio_original_with_combo_post src/media/prune.rs 657 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn pruning_refuses_symlink_media_paths src/media/prune.rs 711 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn pruning_refuses_symlink_parent_components -src/media/thumbnail.rs 383 expect clippy::cognitive_complexity the renderer fallback and fail-closed validation sequence is intentionally linear fn pdf_first_page_thumbnail -src/media/thumbnail.rs 387 expect clippy::too_many_lines PDF rendering keeps subprocess validation, fallback, and cleanup in one auditable sequence fn pdf_first_page_thumbnail -src/media/thumbnail.rs 830 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn write_pdf_placeholder_outputs_svg -src/media/thumbnail.rs 845 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn unsupported_image_thumbnail_falls_back_to_svg_placeholder_without_ffmpeg -src/media/thumbnail.rs 868 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn mutool_command_caps_render_dimensions +src/media/thumbnail.rs 496 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn write_pdf_placeholder_outputs_svg +src/media/thumbnail.rs 511 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn unsupported_image_thumbnail_falls_back_to_svg_placeholder_without_ffmpeg src/middleware/state.rs 342 expect clippy::struct_excessive_bools independent runtime capability flags are shared across handlers and workers struct AppState src/middleware/state.rs 435 expect clippy::panic_in_result_fn assertions provide precise regression failures after fallible test-database setup fn db_maintenance_jobs_assign_monotonic_job_ids_and_preserve_them_in_terminal_states src/middleware/state.rs 527 expect clippy::panic_in_result_fn assertions provide precise regression failures after fallible test-database setup fn db_maintenance_jobs_finished_updates_only_matching_running_job @@ -326,11 +323,10 @@ src/tls/self_signed.rs 283 expect clippy::panic_in_result_fn assertions intentio src/tls/self_signed.rs 342 expect clippy::panic_in_result_fn assertions intentionally report violations of missing-key recovery fn missing_key_triggers_regeneration_on_next_startup src/utils/crypto.rs 70 expect clippy::exit fail-closed randomness failures must terminate before issuing weak tokens fn fatal_randomness_error src/utils/crypto.rs 212 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn hash_and_verify_password -src/utils/files/storage.rs 34 expect clippy::struct_excessive_bools the booleans are independent upload-policy and media-capability flags struct SaveUploadOptions -src/utils/files/storage.rs 135 expect clippy::too_many_arguments the arguments mirror independent upload metadata and board limits at the handler boundary fn save_audio_with_image_thumb_from_path -src/utils/files/storage.rs 246 expect clippy::as_conversions,clippy::cast_precision_loss human-readable size display intentionally rounds integral byte counts to one decimal place fn format_file_size -src/utils/files/storage.rs 1499 expect clippy::print_stderr test skip diagnostics must remain visible when optional ffmpeg fixtures are unavailable fn valid_audio_uploads_accept_common_formats_with_ffprobe -src/utils/files/storage.rs 1554 expect clippy::print_stderr test skip diagnostics must remain visible when optional ffmpeg fixtures are unavailable fn valid_mkv_uploads_save_as_video_with_probe +src/utils/files/storage.rs 121 expect clippy::redundant_pub_crate this opaque validated-upload handoff is shared across sibling crate modules fn save_validated_upload_from_path +src/utils/files/storage.rs 247 expect clippy::as_conversions,clippy::cast_precision_loss human-readable size display intentionally rounds integral byte counts to one decimal place fn format_file_size +src/utils/files/storage.rs 349 expect clippy::redundant_pub_crate this opaque validation token crosses the private storage-module boundary struct ValidatedUpload +src/utils/files/storage.rs 385 expect clippy::redundant_pub_crate the posting handler must obtain the opaque token without exposing it publicly fn validate_upload_for_storage src/utils/sanitize.rs 29 expect clippy::expect_used the reply-reference regex is a source literal covered by sanitizer tests item RE_REPLY src/utils/sanitize.rs 37 expect clippy::expect_used the cross-board-link regex is a source literal covered by sanitizer tests item RE_CROSSLINK src/utils/sanitize.rs 46 expect clippy::expect_used the URL regex is a source literal covered by sanitizer tests item RE_URL @@ -338,31 +334,46 @@ src/utils/sanitize.rs 55 expect clippy::expect_used the bold-markup regex is a s src/utils/sanitize.rs 63 expect clippy::expect_used the italic-markup regex is a source literal covered by sanitizer tests item RE_ITALIC src/utils/sanitize.rs 71 expect clippy::expect_used the spoiler-markup regex is a source literal covered by sanitizer tests item RE_SPOILER src/utils/sanitize.rs 80 expect clippy::expect_used the dice-expression regex is a source literal covered by sanitizer tests item RE_DICE -src/workers/mod.rs 699 expect clippy::cognitive_complexity,clippy::too_many_lines claim, execution, completion persistence, retry, and shutdown form one worker state machine fn worker_loop -src/workers/mod.rs 864 expect clippy::cognitive_complexity the dispatcher mirrors the closed set of background job variants fn handle_job -src/workers/mod.rs 996 expect clippy::cognitive_complexity transcode process control and fail-closed cleanup share one lifecycle fn transcode_video -src/workers/mod.rs 1111 expect clippy::cognitive_complexity source validation and codec-specific preparation share one preflight boundary fn transcode_video_prepare -src/workers/mod.rs 1660 expect clippy::cognitive_complexity archive, prune, and filesystem finalization remain one consistency operation fn prune_threads -src/workers/mod.rs 1850 expect clippy::expect_used test database queries stop locally with fixture-specific failure context fn file_hash_count -src/workers/mod.rs 1875 expect clippy::expect_used constructing the constrained test pool is a prerequisite for completion-retry tests fn single_connection_test_pool -src/workers/mod.rs 1905 expect clippy::expect_used claiming the seeded job is a prerequisite for each queue-state assertion fn claim_job -src/workers/mod.rs 1918 expect clippy::expect_used test fixture failures stop the individual queue accounting test with local context fn retryable_failure_restores_queue_pending_count -src/workers/mod.rs 1948 expect clippy::expect_used test fixture failures stop the individual queue accounting test with local context fn permanent_failure_leaves_queue_pending_count_at_zero -src/workers/mod.rs 1984 expect clippy::expect_used the async retry test requires every timed and database fixture step to succeed fn completion_retries_the_same_job_after_pool_exhaustion -src/workers/mod.rs 2037 expect clippy::expect_used the shutdown recovery test requires every timed and database fixture step to succeed fn completion_stops_retrying_after_shutdown_grace_and_startup_can_recover -src/workers/mod.rs 2077 expect clippy::expect_used the completion recovery fixture must resolve within its explicit test timeout fn already_resolved_completion_error_does_not_wedge_worker -src/workers/mod.rs 2113 expect clippy::too_many_lines the regression keeps job seeding, terminal persistence, and replay checks in one end-to-end scenario fn non_transient_done_completion_is_terminal_and_never_replayed -src/workers/mod.rs 2231 expect clippy::expect_used the injected retry-state fixture requires each database and timeout step to succeed fn non_transient_failed_completion_preserves_bounded_attempt_count -src/workers/mod.rs 2349 expect clippy::expect_used temporary-file and database fixture setup must succeed before cleanup assertions fn stale_transcode_persist_removes_unattached_webm_and_writes_no_hash -src/workers/mod.rs 2385 expect clippy::expect_used temporary-file fixture setup must succeed before transcode output assertions fn transcode_finalise_skips_larger_output_and_cleans_temp_file -src/workers/mod.rs 2429 expect clippy::expect_used temporary upload fixture setup must succeed before path validation assertions fn transcode_source_validation_rejects_escape_paths -src/workers/mod.rs 2448 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_thumbs_dir -src/workers/mod.rs 2475 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_board_dir -src/workers/mod.rs 2502 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_thumb_file -src/workers/mod.rs 2526 expect clippy::expect_used temporary-file and database fixture setup must succeed before cleanup assertions fn stale_waveform_finalise_removes_unattached_png_and_writes_no_hash +src/workers/mod.rs 964 expect clippy::cognitive_complexity,clippy::too_many_lines claim, execution, completion persistence, retry, and shutdown form one worker state machine fn worker_loop +src/workers/mod.rs 1130 expect clippy::cognitive_complexity,clippy::too_many_arguments the dispatcher mirrors the closed set of background job variants fn handle_job +src/workers/mod.rs 1269 expect clippy::too_many_arguments transcode process control and fail-closed cleanup share one lifecycle fn transcode_video +src/workers/mod.rs 1380 expect clippy::cognitive_complexity source validation and codec-specific preparation share one preflight boundary fn transcode_video_prepare +src/workers/mod.rs 1551 expect clippy::too_many_arguments the finalisation boundary carries the durable job and all validated filesystem identities fn transcode_video_finalise +src/workers/mod.rs 1593 expect clippy::too_many_arguments output installation and atomic DB completion require the full validated transcode identity fn persist_transcoded_webm +src/workers/mod.rs 1934 expect clippy::too_many_arguments the finalisation boundary explicitly carries all validated paths and DB identity fn waveform_finalise +src/workers/mod.rs 2115 expect clippy::cognitive_complexity archive, prune, and filesystem finalization remain one consistency operation fn prune_threads +src/workers/mod.rs 2561 expect clippy::cognitive_complexity,clippy::too_many_lines the bounded legacy-state matrix is intentionally explicit fn reconcile_media_job_states +src/workers/mod.rs 3050 expect clippy::expect_used test database queries stop locally with fixture-specific failure context fn file_hash_count +src/workers/mod.rs 3227 expect clippy::expect_used fixture setup failures should stop this queue durability test fn required_thread_prune_bypasses_capacity_and_survives_queue_recreation +src/workers/mod.rs 3690 expect clippy::expect_used fixture setup failures should stop this coalescing test fn board_prune_coalesces_and_running_work_leaves_one_dirty_evaluation +src/workers/mod.rs 3891 expect clippy::too_many_lines the legacy reconciliation matrix remains explicit in one fixed-point test fn media_reconciliation_repairs_legacy_matrix_and_reaches_fixed_point +src/workers/mod.rs 4056 expect clippy::expect_used constructing the constrained test pool is a prerequisite for completion-retry tests fn single_connection_test_pool +src/workers/mod.rs 4086 expect clippy::expect_used claiming the seeded job is a prerequisite for each queue-state assertion fn claim_job +src/workers/mod.rs 4099 expect clippy::expect_used test fixture failures stop the individual queue accounting test with local context fn retryable_failure_restores_queue_pending_count +src/workers/mod.rs 4129 expect clippy::expect_used test fixture failures stop the individual queue accounting test with local context fn permanent_failure_leaves_queue_pending_count_at_zero +src/workers/mod.rs 4165 expect clippy::expect_used the async retry test requires every timed and database fixture step to succeed fn completion_retries_the_same_job_after_pool_exhaustion +src/workers/mod.rs 4218 expect clippy::expect_used the shutdown recovery test requires every timed and database fixture step to succeed fn completion_stops_retrying_after_shutdown_grace_and_startup_can_recover +src/workers/mod.rs 4258 expect clippy::expect_used the completion recovery fixture must resolve within its explicit test timeout fn already_resolved_completion_error_does_not_wedge_worker +src/workers/mod.rs 4294 expect clippy::too_many_lines the regression keeps job seeding, terminal persistence, and replay checks in one end-to-end scenario fn non_transient_done_completion_is_terminal_and_never_replayed +src/workers/mod.rs 4412 expect clippy::expect_used the injected retry-state fixture requires each database and timeout step to succeed fn non_transient_failed_completion_preserves_bounded_attempt_count +src/workers/mod.rs 4532 expect clippy::expect_used temporary-file and database fixture setup must succeed before cleanup assertions fn stale_transcode_persist_removes_unattached_webm_and_writes_no_hash +src/workers/mod.rs 4569 expect clippy::expect_used temporary-file fixture setup must succeed before transcode output assertions fn transcode_finalise_skips_larger_output_and_cleans_temp_file +src/workers/mod.rs 4614 expect clippy::expect_used temporary upload fixture setup must succeed before path validation assertions fn transcode_source_validation_rejects_escape_paths +src/workers/mod.rs 4633 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_thumbs_dir +src/workers/mod.rs 4663 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_board_dir +src/workers/mod.rs 4693 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_thumb_file +src/workers/mod.rs 4721 expect clippy::expect_used fixture setup must succeed before eviction assertions fn thumb_cache_evicts_only_unreferenced_files_across_all_media_states +src/workers/mod.rs 4805 expect clippy::expect_used temporary-file and database fixture setup must succeed before cleanup assertions fn stale_waveform_finalise_removes_unattached_png_and_writes_no_hash tests/cli_runtime.rs 1 expect unused_crate_dependencies Cargo exposes all package dependencies to this black-box integration target, which intentionally interacts only with the compiled CLI crate tests/cli_runtime.rs 263 expect clippy::panic_in_result_fn assertions intentionally report violated CLI filesystem invariants fn help_and_version_do_not_create_runtime_state tests/cli_runtime.rs 304 expect clippy::panic_in_result_fn assertions intentionally report violated CLI filesystem invariants fn explicit_data_dir_owns_all_initialized_runtime_state tests/cli_runtime.rs 356 expect clippy::panic_in_result_fn assertions intentionally report violated CLI filesystem invariants fn relative_data_dir_is_rejected_before_filesystem_mutation tests/cli_runtime.rs 396 expect clippy::too_many_lines one subprocess lifecycle verifies the complete direct-HTTPS topology and cookie contract fn direct_https_process_exposes_only_https_and_redirect_with_secure_login_cookies tests/cli_runtime.rs 400 expect clippy::panic_in_result_fn assertions intentionally report violated TLS topology and cookie invariants fn direct_https_process_exposes_only_https_and_redirect_with_secure_login_cookies +src/media/audio.rs 440 expect clippy::cast_possible_truncation,clippy::cast_sign_loss,clippy::as_conversions clamped normalized amplitudes map to a bounded pixel radius fn render_peaks +src/media/heif.rs 12 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn decode +src/media/images.rs 21 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn validate_dimensions +src/media/images.rs 35 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn decode_still +src/media/images.rs 74 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn image_to_webp +src/media/pdf.rs 30 expect clippy::redundant_pub_crate PDF rendering is an internal media helper fn render +src/media/pdf.rs 34 expect clippy::cast_possible_truncation,clippy::cast_sign_loss,clippy::as_conversions finite positive viewport dimensions are clamped to a checked u16 bound before rounding fn render diff --git a/src/banner.rs b/src/banner.rs index 83286eb5..0e318ffd 100644 --- a/src/banner.rs +++ b/src/banner.rs @@ -682,44 +682,17 @@ fn write_animated_gif_banner_asset_scaled( target_path: &Path, original_width: u32, original_height: u32, -) -> Result<(u32, u32)> { - write_animated_gif_banner_asset_scaled_with_caps( - bytes, - target_path, - original_width, - original_height, - crate::media::ffmpeg::detect_ffmpeg(), - crate::media::ffmpeg::check_webp_encoder(), - ) -} - -/// Store an animated GIF as scaled WebP or an unmodified GIF fallback. -fn write_animated_gif_banner_asset_scaled_with_caps( - bytes: &[u8], - target_path: &Path, - original_width: u32, - original_height: u32, - ffmpeg_available: bool, - ffmpeg_webp_available: bool, ) -> Result<(u32, u32)> { let (max_width, max_height) = maybe_shrink_dimensions(original_width, original_height); - if !ffmpeg_available || !ffmpeg_webp_available { - write_gif_banner_fallback(bytes, target_path)?; - return Ok((original_width, original_height)); - } let input_path = animated_gif_temp_path(target_path); std::fs::write(&input_path, bytes) .with_context(|| format!("write {}", input_path.display()))?; let webp_path = banner_webp_path(target_path); - let conversion = crate::media::ffmpeg::ffmpeg_image_to_webp_scaled( - &input_path, - &webp_path, - max_width, - max_height, - ) - .with_context(|| format!("convert animated gif banner {}", input_path.display())); + let conversion = + crate::media::images::image_to_webp(&input_path, &webp_path, Some((max_width, max_height))) + .with_context(|| format!("convert animated gif banner {}", input_path.display())); drop(std::fs::remove_file(&input_path)); match conversion { Ok(()) => { @@ -737,7 +710,7 @@ fn write_animated_gif_banner_asset_scaled_with_caps( } } -/// Store an animated WebP, scaling it when the external encoder is available. +/// Store an animated WebP, scaling its composited frames in Rust. fn write_animated_webp_banner_asset( bytes: &[u8], target_path: &Path, @@ -746,18 +719,14 @@ fn write_animated_webp_banner_asset( ) -> Result<(u32, u32)> { let (max_width, max_height) = maybe_shrink_dimensions(original_width, original_height); let should_scale = (max_width, max_height) != (original_width, original_height); - if should_scale - && crate::media::ffmpeg::detect_ffmpeg() - && crate::media::ffmpeg::check_webp_encoder() - { + if should_scale { let input_path = animated_webp_temp_path(target_path); std::fs::write(&input_path, bytes) .with_context(|| format!("write {}", input_path.display()))?; - let conversion = crate::media::ffmpeg::ffmpeg_image_to_webp_scaled( + let conversion = crate::media::images::image_to_webp( &input_path, target_path, - max_width, - max_height, + Some((max_width, max_height)), ) .with_context(|| format!("scale animated webp banner {}", input_path.display())); drop(std::fs::remove_file(&input_path)); @@ -967,16 +936,16 @@ fn is_animated_webp(bytes: &[u8]) -> bool { #[cfg(test)] mod tests { use super::{ - banner_admin_anchor, banner_asset_content_type, banner_gif_fallback_path, - banner_open_section, banner_storage_path, banner_target_draft, canonicalize_banner_bytes, - choose_active_banner, is_animated_webp, normalize_external_url, normalize_internal_path, - parse_banner_target, resolve_banner_href, safe_return_to, - validate_banner_restore_entry_name, validate_banner_storage_key, - write_animated_gif_banner_asset_scaled_with_caps, write_animated_webp_banner_asset, - DISPLAY_HEIGHT, DISPLAY_WIDTH, MAX_ANIMATED_GIF_FRAMES, + banner_admin_anchor, banner_gif_fallback_path, banner_open_section, banner_storage_path, + banner_target_draft, canonicalize_banner_bytes, choose_active_banner, is_animated_webp, + normalize_external_url, normalize_internal_path, parse_banner_target, resolve_banner_href, + safe_return_to, validate_banner_restore_entry_name, validate_banner_storage_key, + write_animated_gif_banner_asset_scaled, write_animated_webp_banner_asset, DISPLAY_HEIGHT, + DISPLAY_WIDTH, MAX_ANIMATED_GIF_FRAMES, }; use crate::models::{BannerAsset, BannerScope, BannerTargetType}; use anyhow::Result; + use image::AnimationDecoder as _; use image::{codecs::gif::GifEncoder, Delay, Frame, ImageBuffer, ImageFormat, Rgba}; use std::io::Cursor; @@ -1036,13 +1005,13 @@ mod tests { fn banner_target_draft_only_populates_selected_field() { let board = banner_target_draft(BannerTargetType::InternalBoard, "tech"); assert_eq!(board.board_value, "tech"); - assert!(board.thread_value.is_empty()); - assert!(board.external_url.is_empty()); + assert_eq!(board.thread_value.len(), 0); + assert_eq!(board.external_url, ""); let thread = banner_target_draft(BannerTargetType::InternalPath, "/tech/thread/42"); - assert!(thread.board_value.is_empty()); + assert_eq!(thread.board_value.len(), 0); assert_eq!(thread.thread_value, "/tech/thread/42"); - assert!(thread.external_url.is_empty()); + assert_eq!(thread.external_url, ""); } #[test] @@ -1149,7 +1118,7 @@ mod tests { clippy::panic_in_result_fn, reason = "test assertions intentionally panic on failure" )] - fn animated_gif_banner_falls_back_to_gif_without_ffmpeg() -> Result<()> { + fn animated_gif_banner_converts_to_animated_webp_without_ffmpeg() -> Result<()> { let mut bytes = Vec::new(); { let mut encoder = GifEncoder::new(&mut bytes); @@ -1161,34 +1130,31 @@ mod tests { } let target = std::env::temp_dir().join(format!("{}.webp", uuid::Uuid::new_v4().simple())); let gif_path = banner_gif_fallback_path(&target); - let dimensions = write_animated_gif_banner_asset_scaled_with_caps( - &bytes, - &target, - DISPLAY_WIDTH, - DISPLAY_HEIGHT, - false, - false, - )?; + let dimensions = + write_animated_gif_banner_asset_scaled(&bytes, &target, DISPLAY_WIDTH, DISPLAY_HEIGHT)?; assert_eq!( dimensions, (DISPLAY_WIDTH, DISPLAY_HEIGHT), "fallback must preserve source dimensions" ); + assert!(target.exists(), "WebP must be produced without FFmpeg"); + let decoder = image::codecs::webp::WebPDecoder::new(std::io::BufReader::new( + std::fs::File::open(&target)?, + ))?; assert!( - !target.exists(), - "the unavailable WebP output must not be published" + decoder.has_animation(), + "banner animation must survive conversion" ); assert_eq!( - std::fs::read(&gif_path)?, - bytes, - "the GIF fallback must preserve the source bytes" + decoder.into_frames().collect_frames()?.len(), + 2, + "both banner frames must survive" ); - assert_eq!( - banner_asset_content_type(&gif_path), - "image/gif", - "the fallback must be served as GIF" + assert!( + !gif_path.exists(), + "successful conversion must not leave a GIF fallback" ); - drop(std::fs::remove_file(gif_path)); + drop(std::fs::remove_file(target)); Ok(()) } diff --git a/src/config.rs b/src/config.rs index 20c69257..a5c2174f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -535,12 +535,10 @@ struct SettingsFile { /// multiple instances that share the same storage to avoid key collisions. /// Default: "rustchan". tor_service_nickname: Option, - /// Whether startup fails when `FFmpeg` or `FFprobe` is unavailable. + /// Whether startup fails when `FFmpeg` is unavailable. require_ffmpeg: Option, /// Configured `FFmpeg` executable path. ffmpeg_path: Option, - /// Configured `FFprobe` executable path. - ffprobe_path: Option, /// Whether boards may enable arbitrary-file uploads. enable_any_file_uploads_feature: Option, /// How often to run PRAGMA `wal_checkpoint(TRUNCATE)`, in seconds. @@ -965,8 +963,6 @@ pub struct Config { pub require_ffmpeg: bool, /// Explicit ffmpeg binary path, or plain "ffmpeg" for PATH lookup. pub ffmpeg_path: String, - /// Explicit ffprobe binary path, or plain "ffprobe" for PATH lookup. - pub ffprobe_path: String, /// Global feature gate for arbitrary uploads. Boards can only enable the /// per-board toggle when this is true. pub enable_any_file_uploads_feature: bool, @@ -1103,7 +1099,6 @@ impl std::fmt::Debug for Config { .field("tor_service_nickname", &self.tor_service_nickname) .field("require_ffmpeg", &self.require_ffmpeg) .field("ffmpeg_path", &self.ffmpeg_path) - .field("ffprobe_path", &self.ffprobe_path) .field( "enable_any_file_uploads_feature", &self.enable_any_file_uploads_feature, @@ -1387,11 +1382,6 @@ impl Config { .ok() .or(s.ffmpeg_path) .unwrap_or_else(|| "ffmpeg".to_owned()), - ffprobe_path: environment - .var("CHAN_FFPROBE_PATH") - .ok() - .or(s.ffprobe_path) - .unwrap_or_else(|| "ffprobe".to_owned()), enable_any_file_uploads_feature: environment.boolean( "CHAN_ENABLE_ANY_FILE_UPLOADS_FEATURE", s.enable_any_file_uploads_feature.unwrap_or(false), @@ -2527,7 +2517,6 @@ mod tests { tor_service_nickname: "rustchan".to_owned(), require_ffmpeg: false, ffmpeg_path: "ffmpeg".to_owned(), - ffprobe_path: "ffprobe".to_owned(), enable_any_file_uploads_feature: false, bind_addr: "0.0.0.0:8080".to_owned(), database_path: "chan.db".to_owned(), diff --git a/src/config/admin.rs b/src/config/admin.rs index 6ffbc6c3..3e6b18b7 100644 --- a/src/config/admin.rs +++ b/src/config/admin.rs @@ -189,7 +189,6 @@ pub(super) fn startup_network_sources( } "require_ffmpeg" => settings.require_ffmpeg.is_some(), "ffmpeg_path" => settings.ffmpeg_path.is_some(), - "ffprobe_path" => settings.ffprobe_path.is_some(), "thumb_size" => settings.thumb_size.is_some(), "job_queue_capacity" => settings.job_queue_capacity.is_some(), "waveform_cache_max_mb" => settings.waveform_cache_max_mb.is_some(), diff --git a/src/config/admin/runtime.rs b/src/config/admin/runtime.rs index 4899bc8f..05db4b9f 100644 --- a/src/config/admin/runtime.rs +++ b/src/config/admin/runtime.rs @@ -134,25 +134,10 @@ pub fn save_section( let after = super::rewrite_root_settings(&before, &updates)?; let saved = super::resolve_file(&after, &Environment::Values(&empty))?; validate_section(section, &saved)?; - if section == RuntimeSection::Media { - for (tool, old, new, required) in [ - ( - "ffmpeg", - &previous.ffmpeg_path, - &saved.ffmpeg_path, - saved.require_ffmpeg, - ), - ( - "ffprobe", - &previous.ffprobe_path, - &saved.ffprobe_path, - false, - ), - ] { - if old != new || required { - probe_tool(tool, new)?; - } - } + if section == RuntimeSection::Media + && (previous.ffmpeg_path != saved.ffmpeg_path || saved.require_ffmpeg) + { + probe_tool("ffmpeg", &saved.ffmpeg_path)?; } super::save_root_at(&path, &updates, &Environment::Process, |candidate| { validate_section(section, candidate) @@ -240,9 +225,8 @@ static MEDIA_SETTINGS: &[SettingDefinition] = &[ SettingDefinition { key: "max_video_size_mb", label: "New-board video upload default (MiB)", environment: "CHAN_MAX_VIDEO_MB", kind: InputKind::Number(1, 2048), value: |c| (c.max_video_size / (1024 * 1024)).to_string(), help: "Upload limit for newly created boards; edit existing per-board caps under Boards." }, SettingDefinition { key: "max_audio_size_mb", label: "New-board audio upload default (MiB)", environment: "CHAN_MAX_AUDIO_MB", kind: InputKind::Number(1, 512), value: |c| (c.max_audio_size / (1024 * 1024)).to_string(), help: "Upload limit for newly created boards; existing boards retain their stored caps." }, SettingDefinition { key: "enable_any_file_uploads_feature", label: "Master arbitrary-file upload gate", environment: "CHAN_ENABLE_ANY_FILE_UPLOADS_FEATURE", kind: InputKind::Boolean, value: |c| c.enable_any_file_uploads_feature.to_string(), help: "Both this global gate and the per-board arbitrary-file checkbox must be enabled. This does not change individual board preferences." }, - SettingDefinition { key: "require_ffmpeg", label: "Require FFmpeg at startup", environment: "CHAN_REQUIRE_FFMPEG", kind: InputKind::Boolean, value: |c| c.require_ffmpeg.to_string(), help: "Missing FFmpeg becomes a startup error. FFprobe is detected separately; this flag does not make missing FFprobe fatal." }, + SettingDefinition { key: "require_ffmpeg", label: "Require FFmpeg at startup", environment: "CHAN_REQUIRE_FFMPEG", kind: InputKind::Boolean, value: |c| c.require_ffmpeg.to_string(), help: "Missing FFmpeg becomes a startup error. Internal Rust image, metadata, PDF and common-audio processing does not need FFmpeg." }, SettingDefinition { key: "ffmpeg_path", label: "FFmpeg executable", environment: "CHAN_FFMPEG_PATH", kind: InputKind::Text, value: |c| c.ffmpeg_path.clone(), help: "Use ffmpeg for PATH lookup, or an absolute executable path. New tool paths are checked with a bounded version probe before saving." }, - SettingDefinition { key: "ffprobe_path", label: "FFprobe executable", environment: "CHAN_FFPROBE_PATH", kind: InputKind::Text, value: |c| c.ffprobe_path.clone(), help: "Use ffprobe for PATH lookup, or an absolute executable path. Missing FFprobe reduces media metadata support." }, SettingDefinition { key: "thumb_size", label: "Generated thumbnail dimension (pixels)", environment: "CHAN_THUMB_SIZE", kind: InputKind::Number(16, 4096), value: |c| c.thumb_size.to_string(), help: "Applies to thumbnails generated after restart. Existing thumbnails are not regenerated." }, SettingDefinition { key: "job_queue_capacity", label: "Background queue capacity (pending jobs)", environment: "CHAN_JOB_QUEUE_CAPACITY", kind: InputKind::Number(0, 1_000_000), value: |c| c.job_queue_capacity.to_string(), help: "0 is unlimited. Once full, the queue drops new media jobs with a warning. Current queue pressure is shown in Site Health." }, SettingDefinition { key: "waveform_cache_max_mb", label: "Thumbnail/waveform cache budget (MiB)", environment: "CHAN_WAVEFORM_CACHE_MAX_MB", kind: InputKind::Number(0, 1_048_576), value: |c| (c.waveform_cache_max_bytes / (1024 * 1024)).to_string(), help: "Oldest cache files are evicted by a background task when the budget is exceeded. 0 disables eviction." }, @@ -313,8 +297,8 @@ mod tests { ); } ensure!( - keys.len() == 63, - "registry must cover 39 inventoried root controls, 12 TLS leaves and 12 new operator choices" + keys.len() == 62, + "registry must cover 38 inventoried root controls, 12 TLS leaves and 12 new operator choices" ); let config = resolve_file("", &Environment::Values(&BTreeMap::new()))?; ensure!( @@ -467,7 +451,6 @@ mod tests { ); } validate_tool_path("ffmpeg", "ffmpeg")?; - validate_tool_path("ffprobe", "ffprobe")?; Ok(()) } diff --git a/src/config/template.rs b/src/config/template.rs index 452403ba..4bc7a92a 100644 --- a/src/config/template.rs +++ b/src/config/template.rs @@ -142,8 +142,6 @@ ffmpeg_timeout_secs = 600 # Optional explicit ffmpeg binary path. Leave unset to use PATH lookup. # ffmpeg_path = "/usr/local/bin/ffmpeg" -# Optional explicit ffprobe binary path. Leave unset to use PATH lookup. -# ffprobe_path = "/usr/local/bin/ffprobe" # Automatically prune oldest full-size post media when active stored post media # exceeds the configured byte cap. Thumbnails are kept when possible so old diff --git a/src/detect.rs b/src/detect.rs index f6f7ff05..80441b74 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -67,29 +67,6 @@ pub fn detect_ffmpeg(require_ffmpeg: bool) -> ToolStatus { } } -/// Probe the configured `ffprobe` path at startup so bogus explicit paths are -/// detected immediately instead of only failing later on the first `WebM` probe. -pub fn detect_ffprobe() -> bool { - let ok = probe_tool(&crate::config::CONFIG.ffprobe_path); - - if ok { - tracing::info!( - target: "rustchan::detect", - available = true, - "ffprobe detected — WebM codec inspection enabled" - ); - } else { - tracing::warn!( - target: "rustchan::detect", - available = false, - ffprobe_path = %crate::config::CONFIG.ffprobe_path, - "ffprobe not detected — WebM codec inspection will fail for uploads that need it" - ); - } - - ok -} - /// Probe whether the detected ffmpeg has `libwebp` compiled in. pub fn detect_webp_encoder(ffmpeg_ok: bool) -> bool { if !ffmpeg_ok { @@ -102,13 +79,13 @@ pub fn detect_webp_encoder(ffmpeg_ok: bool) -> bool { tracing::info!( target: "rustchan::detect", webp = true, - "ffmpeg libwebp encoder available — image to WebP conversion enabled" + "ffmpeg libwebp encoder available — video-frame thumbnails enabled" ); } else { tracing::warn!( target: "rustchan::detect", webp = false, - "ffmpeg libwebp encoder missing — JPEG/PNG/BMP/TIFF stored in original format" + "ffmpeg libwebp encoder missing — video-frame thumbnails use placeholders" ); if crate::logging::is_tty() { crate::logging::console_print_raw(&webp_install_hint()); @@ -118,40 +95,10 @@ pub fn detect_webp_encoder(ffmpeg_ok: bool) -> bool { has_webp } -/// Detects the available external PDF thumbnail renderers in priority order. +/// Reports the built-in pure-Rust PDF preview renderer. +#[must_use] pub fn detect_pdf_thumbnail_renderers() -> Vec { - let renderers = crate::media::thumbnail::detect_pdf_renderers(); - - if renderers.is_empty() { - tracing::warn!( - target: "rustchan::detect", - available = false, - "no PDF thumbnail renderer detected — PDF uploads still work and will use the built-in generic PDF thumbnail. Install Poppler pdftoppm, MuPDF mutool, or use macOS qlmanage to enable real first-page thumbnails" - ); - if crate::logging::is_tty() { - crate::logging::console_print_raw( - " PDF uploads still work with a built-in generic thumbnail.\n Install Poppler `pdftoppm`, MuPDF `mutool`, or use macOS `qlmanage` for real first-page thumbnails.\n\n", - ); - } - } else { - let detected = renderers - .iter() - .map(|renderer| renderer.binary_name()) - .collect::>() - .join(", "); - let selected = renderers - .first() - .map_or("unknown", |renderer| renderer.binary_name()); - tracing::info!( - target: "rustchan::detect", - available = true, - renderers = %detected, - selected = selected, - "PDF thumbnail renderer detected" - ); - } - - renderers + crate::media::thumbnail::detect_pdf_renderers() } /// Builds the platform-specific `WebP` encoder installation hint. @@ -814,7 +761,7 @@ mod tests { use std::os::unix::fs::symlink; let tempdir = tempfile::tempdir().context("create temporary directory")?; - let script = tempdir.path().join("ffprobe"); + let script = tempdir.path().join("fixture-probe"); symlink("/usr/bin/true", &script).context("symlink true as the probe executable")?; assert!( @@ -826,7 +773,7 @@ mod tests { "the explicit executable path must be probed" ); assert!( - !probe_tool("/definitely/not/a/real/ffprobe"), + !probe_tool("/definitely/not/a/real/fixture-probe"), "a nonexistent executable path must fail the probe" ); Ok(()) diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs index 7b5acee8..00b5b4b5 100644 --- a/src/handlers/admin/mod.rs +++ b/src/handlers/admin/mod.rs @@ -614,7 +614,6 @@ struct AdminPanelSnapshot { media_auto_prune_enabled: bool, media_max_active_content_size_bytes: u64, ffmpeg_available: bool, - ffprobe_available: bool, ffmpeg_webp_available: bool, ffmpeg_vp9_available: bool, ffmpeg_vp9_encoder_available: bool, @@ -814,7 +813,6 @@ struct MaintenanceDomainData { media_auto_prune_enabled: bool, media_max_active_content_size_bytes: u64, ffmpeg_available: bool, - ffprobe_available: bool, ffmpeg_webp_available: bool, ffmpeg_vp9_available: bool, ffmpeg_vp9_encoder_available: bool, @@ -1306,7 +1304,6 @@ fn load_maintenance_domain_data( media_auto_prune_enabled: db::get_media_auto_prune_enabled(conn), media_max_active_content_size_bytes: db::get_media_max_active_content_size_bytes(conn), ffmpeg_available: state.ffmpeg_available, - ffprobe_available: state.ffprobe_available, ffmpeg_webp_available: state.ffmpeg_webp_available, ffmpeg_vp9_available: state.ffmpeg_vp9_available, ffmpeg_vp9_encoder_available: state.ffmpeg_vp9_encoder_available, @@ -1386,7 +1383,6 @@ fn load_admin_panel_snapshot( media_max_active_content_size_bytes: maintenance_domain .media_max_active_content_size_bytes, ffmpeg_available: maintenance_domain.ffmpeg_available, - ffprobe_available: maintenance_domain.ffprobe_available, ffmpeg_webp_available: maintenance_domain.ffmpeg_webp_available, ffmpeg_vp9_available: maintenance_domain.ffmpeg_vp9_available, ffmpeg_vp9_encoder_available: maintenance_domain.ffmpeg_vp9_encoder_available, @@ -1664,15 +1660,14 @@ fn dashboard_dependency_status( ffmpeg_required: bool, ) -> (String, String, crate::templates::AdminDashboardState) { let ffmpeg = detection_word(maintenance.ffmpeg_available); - let ffprobe = detection_word(maintenance.ffprobe_available); - let state = if maintenance.ffmpeg_available && maintenance.ffprobe_available { + let state = if maintenance.ffmpeg_available { crate::templates::AdminDashboardState::Ok } else if ffmpeg_required { crate::templates::AdminDashboardState::ActionNeeded } else { crate::templates::AdminDashboardState::Informational }; - let status = if maintenance.ffmpeg_available && maintenance.ffprobe_available { + let status = if maintenance.ffmpeg_available { "ready" } else if ffmpeg_required { "required tool missing" @@ -1682,7 +1677,7 @@ fn dashboard_dependency_status( ( status.to_owned(), format!( - "ffmpeg {ffmpeg}; ffprobe {ffprobe}; WebP {}; VP9 {}; Opus {}.", + "ffmpeg {ffmpeg}; WebP {}; VP9 {}; Opus {}.", detection_word(maintenance.ffmpeg_webp_available), detection_word(maintenance.ffmpeg_vp9_encoder_available), detection_word(maintenance.ffmpeg_opus_available) @@ -1916,11 +1911,6 @@ fn render_admin_panel_from_snapshot( } else { crate::templates::AdminDetectionStatus::Missing }, - ffprobe: if snapshot.ffprobe_available { - crate::templates::AdminDetectionStatus::Detected - } else { - crate::templates::AdminDetectionStatus::Missing - }, webp_encoder: if snapshot.ffmpeg_webp_available { crate::templates::AdminDetectionStatus::Detected } else { @@ -2003,7 +1993,6 @@ fn build_site_health_view<'a>( tor_detail: &snapshot.dashboard.tor_detail, dependency_summary: crate::templates::AdminSiteHealthDependencySummary { ffmpeg: detection_status(snapshot.ffmpeg_available), - ffprobe: detection_status(snapshot.ffprobe_available), webp: detection_status(snapshot.ffmpeg_webp_available), vp9: detection_status(snapshot.ffmpeg_vp9_encoder_available), opus: detection_status(snapshot.ffmpeg_opus_available), @@ -2049,7 +2038,6 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st OS: {os}-{arch}\n\ SQLite: {sqlite}\n\ ffmpeg: {ffmpeg}\n\ - ffprobe: {ffprobe}\n\ Tor enabled: {tor_enabled} ({tor_detail})\n\ TLS enabled: {tls_enabled}\n\ Reverse proxy: {reverse_proxy}\n\ @@ -2063,7 +2051,6 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st arch = std::env::consts::ARCH, sqlite = rusqlite::version(), ffmpeg = detection_word(snapshot.ffmpeg_available), - ffprobe = detection_word(snapshot.ffprobe_available), warnings = indent_diagnostics_block(&snapshot.site_health.recent_warnings), ) } @@ -2755,7 +2742,7 @@ mod tests { assert_eq!(state, crate::templates::AdminDashboardState::Failure); } - fn maintenance_with_media_tools(ffmpeg: bool, ffprobe: bool) -> MaintenanceDomainData { + fn maintenance_with_media_tools(ffmpeg: bool) -> MaintenanceDomainData { MaintenanceDomainData { db_size_bytes: 0, db_size_warning: false, @@ -2763,7 +2750,6 @@ mod tests { media_auto_prune_enabled: false, media_max_active_content_size_bytes: 0, ffmpeg_available: ffmpeg, - ffprobe_available: ffprobe, ffmpeg_webp_available: false, ffmpeg_vp9_available: false, ffmpeg_vp9_encoder_available: false, @@ -2774,7 +2760,7 @@ mod tests { #[test] fn dashboard_optional_media_tools_are_informational_not_warning() { - let maintenance = maintenance_with_media_tools(false, false); + let maintenance = maintenance_with_media_tools(false); assert_eq!( dashboard_dependency_status(&maintenance, false).2, diff --git a/src/handlers/board/access_preferences.rs b/src/handlers/board/access_preferences.rs index ad8c5582..e46a802b 100644 --- a/src/handlers/board/access_preferences.rs +++ b/src/handlers/board/access_preferences.rs @@ -1324,7 +1324,7 @@ mod tests { chrono::Utc::now().timestamp() - 1, ); - assert!(owned_post_grants_from_jar(&jar).is_empty()); + assert_eq!(owned_post_grants_from_jar(&jar).len(), 0); } #[test] @@ -1387,7 +1387,7 @@ mod tests { value, )); - assert!(owned_post_grants_from_jar(&jar).is_empty()); + assert_eq!(owned_post_grants_from_jar(&jar).len(), 0); } } diff --git a/src/handlers/board/create_thread.rs b/src/handlers/board/create_thread.rs index 878fe4a4..d1c7e388 100644 --- a/src/handlers/board/create_thread.rs +++ b/src/handlers/board/create_thread.rs @@ -130,7 +130,6 @@ pub(in crate::server) async fn create_thread( let pool = state.db.clone(); let job_queue = std::sync::Arc::clone(&state.job_queue); let ffmpeg_available = state.ffmpeg_available; - let ffprobe_available = state.ffprobe_available; let ffmpeg_webp_available = state.ffmpeg_webp_available; move || -> Result { // `spawn_blocking` work is not cancelled when its join handle is @@ -165,7 +164,6 @@ pub(in crate::server) async fn create_thread( upload_dir: CONFIG.upload_dir.clone(), thumb_size: CONFIG.thumb_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, }, ) diff --git a/src/handlers/board/media.rs b/src/handlers/board/media.rs index d1f24bfd..065e67fb 100644 --- a/src/handlers/board/media.rs +++ b/src/handlers/board/media.rs @@ -68,17 +68,36 @@ fn stale_webm_redirect_path(base: &std::path::Path, media_path: &str) -> Option< let path = std::path::Path::new(media_path); if !path .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("mp4")) + .is_some_and(|ext| ext.eq_ignore_ascii_case("mp4") || ext.eq_ignore_ascii_case("mkv")) { return None; } let stem = media_path.get(..media_path.len().saturating_sub(4))?; let webm_path = format!("{stem}.webm"); safe_board_media_file(base, &webm_path).ok()?; - Some(format!("/boards/{webm_path}")) + let location = format!("/boards/{webm_path}"); + HeaderValue::from_str(&location).ok()?; + axum::http::Uri::try_from(location.as_str()).ok()?; + Some(location) } -// Legacy `.mp4` links redirect permanently to transcoded `.webm` files; all +/// A page can still request its SVG just after the worker replaces that file. +/// Resolve only generated thumbnail paths and validate the PNG under the root. +fn stale_waveform_redirect_path(base: &std::path::Path, media_path: &str) -> Option { + if !is_generated_svg_placeholder_thumb(media_path) { + return None; + } + let png_path = std::path::Path::new(media_path).with_extension("png"); + let relative = png_path.to_str()?; + safe_board_media_file(base, relative).ok()?; + let location = format!("/boards/{relative}"); + // Validate untrusted stored paths before constructing an HTTP redirect. + HeaderValue::from_str(&location).ok()?; + axum::http::Uri::try_from(location.as_str()).ok()?; + Some(location) +} + +// Legacy `.mp4` and `.mkv` links redirect to transcoded `.webm` files; all // other validated paths are served directly. #[expect( @@ -152,9 +171,12 @@ pub(in crate::server) async fn serve_board_media( Ok(path) => Some(path), Err(error) if is_not_found_error(&error) - && std::path::Path::new(&media_path) + && (std::path::Path::new(&media_path) .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("mp4")) => + .is_some_and(|ext| { + ext.eq_ignore_ascii_case("mp4") || ext.eq_ignore_ascii_case("mkv") + }) + || is_generated_svg_placeholder_thumb(&media_path)) => { None } @@ -171,6 +193,20 @@ pub(in crate::server) async fn serve_board_media( ServeFile::new(&target).oneshot(req).await.map_or_else( |_| StatusCode::INTERNAL_SERVER_ERROR.into_response(), |resp| { + // The waveform worker can remove the SVG between our path check + // and ServeFile opening it. Resolve the completed PNG again. + if let Some(path) = (resp.status() == StatusCode::NOT_FOUND) + .then(|| stale_waveform_redirect_path(&base, &media_path)) + .flatten() + { + return Redirect::temporary(&path).into_response(); + } + if let Some(path) = (resp.status() == StatusCode::NOT_FOUND) + .then(|| stale_webm_redirect_path(&base, &media_path)) + .flatten() + { + return Redirect::permanent(&path).into_response(); + } let mut resp = resp.map(axum::body::Body::new); crate::cache::set_cache_control( resp.headers_mut(), @@ -216,6 +252,8 @@ pub(in crate::server) async fn serve_board_media( resp.into_response() }, ) + } else if let Some(redirect_path) = stale_waveform_redirect_path(&base, &media_path) { + Redirect::temporary(&redirect_path).into_response() } else if let Some(redirect_path) = stale_webm_redirect_path(&base, &media_path) { Redirect::permanent(&redirect_path).into_response() } else { @@ -408,6 +446,25 @@ mod tests { use super::stale_webm_redirect_path; use anyhow::{ensure, Context as _, Result as AnyResult}; + #[test] + fn stale_waveform_svg_redirects_only_to_an_existing_generated_png() -> AnyResult<()> { + let dir = tempfile::tempdir()?; + let thumbs = dir.path().join("test/thumbs"); + std::fs::create_dir_all(&thumbs)?; + let path = "test/thumbs/clip.svg"; + ensure!(super::stale_waveform_redirect_path(dir.path(), path).is_none()); + std::fs::write(thumbs.join("clip.png"), b"fixture")?; + ensure!( + super::stale_waveform_redirect_path(dir.path(), path).as_deref() + == Some("/boards/test/thumbs/clip.png") + ); + ensure!(super::stale_waveform_redirect_path(dir.path(), "test/clip.svg").is_none()); + ensure!( + super::stale_waveform_redirect_path(dir.path(), "../test/thumbs/clip.svg").is_none() + ); + Ok(()) + } + #[test] fn stale_mp4_redirect_path_accepts_valid_webm_sibling() -> AnyResult<()> { let tempdir = tempfile::tempdir().context("create temporary upload root")?; @@ -416,10 +473,13 @@ mod tests { std::fs::create_dir_all(&board_dir).context("create board directory")?; std::fs::write(board_dir.join("clip.webm"), b"webm").context("write WebM fixture")?; - ensure!( - stale_webm_redirect_path(&upload_root, "test/clip.mp4").as_deref() - == Some("/boards/test/clip.webm") - ); + for path in ["test/clip.mp4", "test/clip.mkv", "test/clip.MKV"] { + ensure!( + stale_webm_redirect_path(&upload_root, path).as_deref() + == Some("/boards/test/clip.webm") + ); + } + ensure!(stale_webm_redirect_path(&upload_root, "test/clip.wav").is_none()); Ok(()) } @@ -438,6 +498,7 @@ mod tests { unix_fs::symlink(&outside, board_dir.join("link")).context("create escaping symlink")?; ensure!(stale_webm_redirect_path(&upload_root, "test/link/clip.mp4").is_none()); + ensure!(stale_webm_redirect_path(&upload_root, "test/link/clip.mkv").is_none()); Ok(()) } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 2f8ad974..eed4192e 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1274,7 +1274,6 @@ pub(crate) fn process_primary_upload( max_audio_size: usize, max_pdf_size: usize, ffmpeg_available: bool, - ffprobe_available: bool, ffmpeg_webp_available: bool, ) -> Result<(Option, Option)> { let Some((upload, fname)) = file_data else { @@ -1285,7 +1284,6 @@ pub(crate) fn process_primary_upload( let detected_mime = crate::utils::files::classify_upload_mime( upload.temp_file.path(), &upload.sniff_bytes, - ffprobe_available, allow_any_files, ) .map_err(|error| classify_upload_error(&error))?; @@ -1343,7 +1341,6 @@ pub(crate) fn process_primary_upload( max_audio_size, max_pdf_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, allow_any_files, }; @@ -1426,15 +1423,10 @@ fn upload_path_belongs_to_board(path: &str, board_short: &str) -> bool { path.split('/').next() == Some(board_short) } -fn temp_upload_mime( - upload: &TempUpload, - ffprobe_available: bool, - allow_any_files: bool, -) -> Result { +fn temp_upload_mime(upload: &TempUpload, allow_any_files: bool) -> Result { crate::utils::files::classify_upload_mime( upload.temp_file.path(), &upload.sniff_bytes, - ffprobe_available, allow_any_files, ) .map_err(|error| classify_upload_error(&error)) @@ -1451,7 +1443,6 @@ pub(crate) fn process_audio_combo( board: &Board, upload_dir: &str, max_audio_size: usize, - ffprobe_available: bool, ) -> Result> { let Some((audio_upload, aud_fname)) = audio_file_data else { return Ok(None); @@ -1480,7 +1471,6 @@ pub(crate) fn process_audio_combo( upload_dir, &board.short_name, max_audio_size, - ffprobe_available, ) .map_err(|e| classify_upload_error(&e))?; @@ -1509,7 +1499,6 @@ pub(crate) fn process_audio_first_uploads( max_audio_size: usize, max_pdf_size: usize, ffmpeg_available: bool, - ffprobe_available: bool, ffmpeg_webp_available: bool, ) -> Result<( Option, @@ -1532,7 +1521,6 @@ pub(crate) fn process_audio_first_uploads( max_audio_size, max_pdf_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, ) }; @@ -1553,14 +1541,13 @@ pub(crate) fn process_audio_first_uploads( board, save_root_str, max_audio_size, - ffprobe_available, )?; return Ok((primary, audio, primary_hash)); } if let Some((audio_upload, audio_name)) = audio_file_data { - let audio_mime = temp_upload_mime(&audio_upload, ffprobe_available, allow_any_files)?; + let audio_mime = temp_upload_mime(&audio_upload, allow_any_files)?; if crate::models::MediaType::from_mime(&audio_mime) != crate::models::MediaType::Audio { return Err(AppError::BadRequest( "The audio slot only accepts audio files.".into(), @@ -2521,7 +2508,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ); let Err(error) = result else { @@ -2585,7 +2571,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ); let Err(error) = result else { @@ -2652,7 +2637,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ) .context("accept PDF upload")?; let uploaded = uploaded.context("PDF upload result was empty")?; @@ -2694,7 +2678,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ); let Err(error) = result else { @@ -2738,7 +2721,6 @@ trailer << /Root 1 0 R >> board.max_pdf_size_bytes(), false, false, - false, ); let Err(error) = result else { @@ -2778,7 +2760,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ); let Err(error) = result else { @@ -2822,7 +2803,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ) .context("accept PDF upload")?; let uploaded = uploaded.context("PDF upload result was empty")?; diff --git a/src/handlers/posting.rs b/src/handlers/posting.rs index aae654cc..e4ad6373 100644 --- a/src/handlers/posting.rs +++ b/src/handlers/posting.rs @@ -49,7 +49,6 @@ pub(super) struct SubmitPostCommand { pub upload_dir: String, pub thumb_size: u32, pub ffmpeg_available: bool, - pub ffprobe_available: bool, pub ffmpeg_webp_available: bool, } @@ -94,7 +93,6 @@ struct UploadConfig<'a> { pub max_audio_size: usize, pub max_pdf_size: usize, pub ffmpeg_available: bool, - pub ffprobe_available: bool, pub ffmpeg_webp_available: bool, } @@ -422,7 +420,6 @@ fn process_uploads( config.max_audio_size, config.max_pdf_size, config.ffmpeg_available, - config.ffprobe_available, config.ffmpeg_webp_available, ); @@ -533,7 +530,6 @@ pub(super) fn submit_post( upload_dir, thumb_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, } = command; @@ -619,7 +615,6 @@ pub(super) fn submit_post( max_audio_size: effective_max_audio_size, max_pdf_size: effective_max_pdf_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, }, )?; @@ -922,7 +917,6 @@ mod tests { upload_dir: upload_dir.to_owned(), thumb_size: 250, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, } } @@ -960,7 +954,6 @@ mod tests { upload_dir: upload_dir.to_owned(), thumb_size: 250, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, } } @@ -994,7 +987,6 @@ mod tests { upload_dir: upload_dir.to_owned(), thumb_size: 250, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, } } @@ -1027,7 +1019,7 @@ mod tests { } fn flac_header_bytes() -> Vec { - b"fLaC\x00\x00\x00\x22tiny test flac bytes".to_vec() + include_bytes!("../../tests/fixtures/media/tone.flac").to_vec() } fn malformed_aac_bytes() -> Result> { @@ -1043,7 +1035,7 @@ mod tests { } fn mp4_header_bytes() -> Vec { - b"\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2mp41".to_vec() + include_bytes!("../../tests/fixtures/media/video.mp4").to_vec() } fn pending_upload_stage_count(upload_dir: &std::path::Path) -> Result { @@ -1512,7 +1504,6 @@ mod tests { max_audio_size: 1024 * 1024, max_pdf_size: 1024 * 1024, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, }, ); @@ -1640,7 +1631,10 @@ mod tests { match error { AppError::BadRequest(message) => { - assert!(message.contains("ADTS stream is malformed")); + assert!( + message.contains("ADTS stream is malformed") + || message.contains("could not validate") + ); } other => bail!("expected BadRequest, got {other:?}"), } @@ -1923,7 +1917,6 @@ mod tests { max_audio_size: 1024 * 1024, max_pdf_size: 1024 * 1024, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, }, ) @@ -2010,7 +2003,6 @@ mod tests { max_audio_size: 1024 * 1024, max_pdf_size: 1024 * 1024, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, }, )?; diff --git a/src/handlers/setup.rs b/src/handlers/setup.rs index 22648852..df62617c 100644 --- a/src/handlers/setup.rs +++ b/src/handlers/setup.rs @@ -1054,11 +1054,6 @@ fn setup_form_page( } else { "missing" }; - let ffprobe = if app_state.ffprobe_available { - "detected" - } else { - "missing" - }; let body = format!( r#"
@@ -1105,7 +1100,7 @@ fn setup_form_page(

The slug becomes the board URL and must contain 1–8 lowercase letters or digits.

Step 6

Uploads and media

Enable only the formats this instance should accept and set whole-MiB limits.

-

Runtime detection: ffmpeg {ffmpeg}; ffprobe {ffprobe}.

+

Runtime detection: ffmpeg {ffmpeg}; image/audio inspection built in.

@@ -1169,7 +1164,6 @@ fn setup_form_page( allow_self_delete = checked(form.allow_self_delete.as_deref()), allow_archive = checked(form.allow_archive.as_deref()), ffmpeg = ffmpeg, - ffprobe = ffprobe, allow_uploads = checked(form.allow_uploads.as_deref()), allow_video = checked(form.allow_video.as_deref()), allow_audio = checked(form.allow_audio.as_deref()), diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index 1c693b0e..f87dbdef 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -396,7 +396,6 @@ pub(in crate::server) async fn post_reply( let pool = state.db.clone(); let job_queue = std::sync::Arc::clone(&state.job_queue); let ffmpeg_available = state.ffmpeg_available; - let ffprobe_available = state.ffprobe_available; let ffmpeg_webp_available = state.ffmpeg_webp_available; move || -> Result { // `spawn_blocking` work is not cancelled when its join handle is @@ -426,7 +425,6 @@ pub(in crate::server) async fn post_reply( upload_dir: CONFIG.upload_dir.clone(), thumb_size: CONFIG.thumb_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, }, ) @@ -1577,10 +1575,8 @@ mod tests { } fn flac_fixture(size: usize) -> Vec { - let mut bytes = vec![0_u8; size.max(4)]; - if let Some(prefix) = bytes.get_mut(..4) { - prefix.copy_from_slice(b"fLaC"); - } + let mut bytes = include_bytes!("../../tests/fixtures/media/tone.flac").to_vec(); + bytes.resize(size.max(bytes.len()), 0); bytes } @@ -1602,7 +1598,7 @@ mod tests { #[tokio::test] async fn create_thread_and_reply_accept_audio_within_board_limit() -> Result<()> { let state = crate::test_support::app_state(); - seed_audio_board(&state, 5_000)?; + seed_audio_board(&state, 20_000)?; let router = Router::new() .route("/{board}", post(crate::handlers::board::create_thread)) @@ -1673,13 +1669,13 @@ mod tests { #[tokio::test] async fn create_thread_rejects_audio_over_board_limit_with_413() -> Result<()> { let state = crate::test_support::app_state(); - seed_audio_board(&state, 5_000)?; + seed_audio_board(&state, 20_000)?; let router = Router::new() .route("/{board}", post(crate::handlers::board::create_thread)) .with_state(state); - let audio = flac_fixture(5_001); + let audio = flac_fixture(20_001); let (boundary, body) = crate::test_support::multipart_body( &[("_csrf", "csrf123"), ("body", "")], Some(("audio_file", "too-large.flac", &audio, "audio/flac")), diff --git a/src/media/audio.rs b/src/media/audio.rs new file mode 100644 index 00000000..7e8346b8 --- /dev/null +++ b/src/media/audio.rs @@ -0,0 +1,726 @@ +//! Incremental PCM decoding and deterministic, bounded-memory waveform images. + +use anyhow::{ensure, Context as _, Result}; +use image::{Rgba, RgbaImage}; +use opus_decoder::OpusDecoder; +use std::path::Path; +use std::time::{Duration, Instant}; +use symphonia::core::codecs::audio::well_known::CODEC_ID_OPUS; +use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions}; +use symphonia::core::formats::TrackType; +use symphonia::core::io::{BitReaderLtr, FiniteBitStream as _, ReadBitsLtr as _}; +use symphonia::core::packet::Packet; +use tokio_util::sync::CancellationToken; + +/// Distinguish unavailable codecs from malformed input and resource failures. +pub(crate) fn is_unsupported(error: &anyhow::Error) -> bool { + // Symphonia also calls missing MP4 atoms and excessive MKV geometry + // Unsupported. Only audited codec limitations may select compatibility. + // Opus InternalError includes transform failures, so it must fail closed. + error.chain().any(|cause| { + matches!( + cause.downcast_ref::(), + Some(symphonia::core::errors::Error::Unsupported( + "Opus multistream requires the compatibility decoder" + | "AAC profile needs the compatibility decoder" + | "audio codec needs the compatibility decoder" + | "Speex requires the compatibility decoder" + | "aac: aac too complex" + )) + ) + }) +} + +/// Maximum interleaved samples retained for a single decoded audio packet. +const MAX_PACKET_SAMPLES: usize = 4 * 1024 * 1024; + +/// Audio codecs share one packet-to-interleaved-PCM boundary. +enum PacketDecoder { + /// Mature general-purpose pure-Rust audio decoders. + Symphonia(Box), + /// Single Opus stream, mono or stereo. + Opus(Box), +} + +impl PacketDecoder { + /// Select a decoder from validated container metadata. + fn new(params: &AudioCodecParameters, channels: usize) -> Result { + if params.codec != CODEC_ID_OPUS { + if let Some(frames) = params.max_frames_per_packet { + ensure!( + u128::from(frames) * u128::try_from(channels)? + <= u128::try_from(MAX_PACKET_SAMPLES)?, + "audio packet exceeds sample budget" + ); + } + return Ok(Self::Symphonia( + symphonia::default::get_codecs() + .make_audio_decoder(params, &AudioDecoderOptions::default())?, + )); + } + let header = params + .extra_data + .as_deref() + .context("Opus header missing")?; + ensure!(header.get(..8) == Some(b"OpusHead"), "invalid Opus header"); + ensure!( + header.get(9).copied().map(usize::from) == Some(channels), + "Opus channels disagree with container" + ); + let mapping = *header.get(18).context("truncated Opus mapping")?; + if mapping == 0 { + ensure!(channels <= 2, "invalid single-stream Opus channel count"); + Ok(Self::Opus(Box::new(OpusDecoder::new(48_000, channels)?))) + } else { + let streams = usize::from(*header.get(19).context("truncated Opus stream count")?); + let coupled = usize::from(*header.get(20).context("truncated Opus coupled count")?); + let channel_map = header + .get(21..21 + channels) + .context("truncated Opus channel map")?; + ensure!( + streams > 0 && coupled <= streams && streams + coupled <= 255, + "invalid Opus stream mapping" + ); + ensure!( + channel_map + .iter() + .all(|slot| *slot == 255 || usize::from(*slot) < streams + coupled), + "invalid Opus channel mapping" + ); + // The current focused decoder's multistream packet splitter is not + // RFC 6716 Appendix B compatible (a valid libopus 5.1 fixture fails). + // Preserve this variant through the authorized compatibility path. + Err(symphonia::core::errors::Error::Unsupported( + "Opus multistream requires the compatibility decoder", + ) + .into()) + } + } + + /// Decode one packet into a reused bounded float buffer. + fn decode(&mut self, packet: &Packet, channels: usize, samples: &mut Vec) -> Result<()> { + match self { + Self::Symphonia(decoder) => { + let pcm = decoder.decode(packet).context("decode audio packet")?; + let count = pcm.samples_interleaved(); + ensure!( + count <= MAX_PACKET_SAMPLES, + "decoded audio packet exceeds sample budget" + ); + ensure!( + pcm.spec().channels().count() == channels, + "audio channel count changed" + ); + samples.resize(count, 0.0); + pcm.copy_to_slice_interleaved(samples.as_mut_slice()); + } + Self::Opus(decoder) => { + ensure!(!packet.data.is_empty(), "empty Opus packet"); + samples.resize( + OpusDecoder::MAX_FRAME_SIZE_48K + .checked_mul(channels) + .context("Opus buffer overflow")?, + 0.0, + ); + let frames = decoder.decode_float(&packet.data, samples, false)?; + samples.truncate( + frames + .checked_mul(channels) + .context("Opus frame overflow")?, + ); + } + } + ensure!( + samples.iter().all(|sample| sample.is_finite()), + "non-finite PCM samples" + ); + Ok(()) + } +} + +/// Opus presentation metadata is independent of the PCM decoder state. +struct OpusPresentation { + /// Exact remaining delay from the identification header, in 48 kHz samples. + delay: usize, + /// Linear output gain from the signed Q8 decibel header field. + gain: f32, + /// Container timestamp units for packet trimming. + time_base: symphonia::core::units::TimeBase, +} + +impl OpusPresentation { + /// Read metadata from the already validated stream parameters. + fn new( + params: &AudioCodecParameters, + time_base: Option, + ) -> Result { + let header = params + .extra_data + .as_deref() + .context("Opus header missing")?; + let delay: [u8; 2] = header + .get(10..12) + .context("Opus delay missing")? + .try_into()?; + let gain: [u8; 2] = header + .get(16..18) + .context("Opus gain missing")? + .try_into()?; + Ok(Self { + delay: usize::from(u16::from_le_bytes(delay)), + // OpusHead gain is Q8 dB (RFC 7845 section 5.1). + gain: 10.0_f32.powf(f32::from(i16::from_le_bytes(gain)) / (256.0 * 20.0)), + time_base: time_base.context("Opus time base missing")?, + }) + } + + /// Return playable frames, removing exact header delay and container padding. + fn samples<'a>( + &mut self, + packet: &Packet, + samples: &'a [f32], + channels: usize, + ) -> Result<&'a [f32]> { + let to_frames = |duration: symphonia::core::units::Duration| -> Result { + let numerator = + u128::from(duration.get()) * u128::from(self.time_base.numer.get()) * 48_000; + let denominator = u128::from(self.time_base.denom.get()); + Ok(usize::try_from( + (numerator + denominator / 2) / denominator, + )?) + }; + // Ogg supplies exact trimming; Matroska can round delay to milliseconds. + let start = to_frames(packet.trim_start)? + .max(self.delay) + .min(samples.len() / channels); + self.delay = self.delay.saturating_sub(start); + let end = to_frames(packet.trim_end)?; + let first = start.checked_mul(channels).context("Opus trim overflow")?; + let last = samples + .len() + .checked_sub(end.checked_mul(channels).context("Opus trim overflow")?) + .context("Opus padding exceeds packet")?; + samples.get(first..last).context("Opus trim exceeds packet") + } +} + +/// Deadline/cancellation policy shared by both streaming waveform passes. +struct AudioBudget<'a> { + /// Time at the start of the job. + started: Instant, + /// Existing operator media-processing timeout. + timeout: Duration, + /// Worker shutdown cancellation token. + cancel: &'a CancellationToken, +} + +/// Detect backward-compatible HE-AAC signaling after an AAC-LC configuration. +/// +/// Symphonia 0.6.1 skips this sync extension when no explicit SBR prefix was +/// present, decoding only the low-rate core while reporting AAC-LC. Inspect the +/// bounded configuration, including extension frequency, before choosing it. +fn aac_needs_compatibility(extra: &[u8]) -> Result { + ensure!(extra.len() <= 64, "AAC configuration exceeds safety budget"); + let mut bits = BitReaderLtr::new(extra); + let profile = bits.read_bits_leq32(5)?; + ensure!(matches!(profile, 1..=5 | 29), "unvalidated AAC profile"); + read_aac_frequency(&mut bits)?; + let channels = bits.read_bits_leq32(4)?; + ensure!(channels <= 7, "invalid AAC channel configuration"); + let explicit_sbr = matches!(profile, 5 | 29); + if explicit_sbr { + read_aac_frequency(&mut bits)?; + ensure!(bits.read_bits_leq32(5)? == 2, "invalid HE-AAC core profile"); + } + let _short_frame = bits.read_bool()?; + if bits.read_bool()? { + bits.ignore_bits(14)?; + } + if bits.read_bool()? { + let _extension_flag3 = bits.read_bool()?; + } + if explicit_sbr || profile != 2 { + return Ok(true); + } + // PCE channel layouts are handled by the decoder's explicit limitation. + if channels == 0 || bits.bits_left() < 16 { + return Ok(false); + } + if bits.read_bits_leq32(11)? != 0x2b7 { + return Ok(false); + } + ensure!( + bits.read_bits_leq32(5)? == 5, + "invalid AAC SBR extension type" + ); + let sbr = bits.read_bool()?; + if sbr { + read_aac_frequency(&mut bits)?; + if bits.bits_left() >= 12 && bits.read_bits_leq32(11)? == 0x548 { + let _parametric_stereo = bits.read_bool()?; + } + } + Ok(sbr) +} + +/// Validate a sampling-frequency index or a nonzero explicit 24-bit frequency. +fn read_aac_frequency(bits: &mut BitReaderLtr<'_>) -> Result<()> { + let index = bits.read_bits_leq32(4)?; + if index == 15 { + ensure!( + bits.read_bits_leq32(24)? > 0, + "invalid AAC sampling frequency" + ); + } else { + ensure!(index <= 12, "invalid AAC sampling frequency index"); + } + Ok(()) +} + +impl AudioBudget<'_> { + /// Check the deadline before decoding another packet or publishing pixels. + fn check(&self) -> Result<()> { + ensure!( + !self.cancel.is_cancelled(), + "audio processing cancelled during shutdown" + ); + ensure!( + self.started.elapsed() < self.timeout, + "audio processing timed out" + ); + Ok(()) + } +} + +/// Walk decoded channel frames without retaining the clip in memory. +fn visit_samples( + input: &Path, + budget: &AudioBudget<'_>, + mut visit: impl FnMut(f32) -> Result<()>, +) -> Result { + budget.check()?; + if super::probe::is_speex(input, budget.started, budget.timeout, Some(budget.cancel))? { + return Err(symphonia::core::errors::Error::Unsupported( + "Speex requires the compatibility decoder", + ) + .into()); + } + let (mut format, read_budget) = + super::probe::open_audio_format(input, budget.started, budget.timeout, budget.cancel)?; + let track = format + .first_track(TrackType::Audio) + .context("media has no audio track")?; + let track_id = track.id; + let params = track + .codec_params + .as_ref() + .and_then(symphonia::core::codecs::CodecParameters::audio) + .context("audio codec parameters missing")?; + if params.codec == symphonia::core::codecs::audio::well_known::CODEC_ID_AAC + && params.extra_data.as_deref().map_or_else( + || Ok(params.profile.is_some_and(|profile| { + profile != symphonia::core::codecs::audio::well_known::profiles::CODEC_PROFILE_AAC_LC + })), + aac_needs_compatibility, + )? + { + return Err(symphonia::core::errors::Error::Unsupported( + "AAC profile needs the compatibility decoder", + ) + .into()); + } + let is_opus = params.codec == CODEC_ID_OPUS; + if !is_opus + && symphonia::default::get_codecs() + .get_audio_decoder(params.codec) + .is_none() + { + return Err(symphonia::core::errors::Error::Unsupported( + "audio codec needs the compatibility decoder", + ) + .into()); + } + let mut opus = if is_opus { + Some(OpusPresentation::new(params, track.time_base)?) + } else { + None + }; + let gain = opus.as_ref().map_or(1.0, |presentation| presentation.gain); + let channels = params + .channels + .as_ref() + .context("audio channels missing")? + .count(); + ensure!( + (1..=255).contains(&channels), + "audio channels exceed safety limit" + ); + let mut decoder = PacketDecoder::new(params, channels)?; + let mut samples = Vec::new(); + let mut frames = 0_u64; + loop { + budget.check()?; + read_budget.start_packet(); + let Some(packet) = format.next_packet().context("read audio packet")? else { + break; + }; + if packet.track_id != track_id { + continue; + } + decoder.decode(&packet, channels, &mut samples)?; + let presentation = if let Some(opus) = &mut opus { + opus.samples(&packet, &samples, channels)? + } else { + samples.as_slice() + }; + // Mean absolute channel amplitude avoids cancelling out-of-phase stereo. + for frame in presentation.chunks_exact(channels) { + let divisor = f32::from(u16::try_from(channels)?); + let amplitude = frame + .iter() + .map(|sample| (sample * gain).abs().min(1.0)) + .sum::() + / divisor; + visit(amplitude)?; + frames = frames + .checked_add(1) + .context("audio sample count overflow")?; + } + } + ensure!(frames > 0, "audio contains no decodable samples"); + budget.check()?; + Ok(frames) +} + +/// Render an audio waveform with exact bucket boundaries and bounded memory. +/// +/// A first streaming pass measures decoded duration; a second fills width-sized +/// peak buckets. This also handles containers without reliable duration metadata. +/// +/// # Errors +/// Returns an error for malformed/unsupported audio, resource limits, cancellation, +/// or output I/O failures. Call on a blocking worker, not an async executor. +pub fn render_waveform( + input: &Path, + output: &Path, + width: u32, + height: u32, + cancel: &CancellationToken, +) -> Result<()> { + super::images::validate_dimensions(width, height)?; + let budget = AudioBudget { + started: Instant::now(), + timeout: Duration::from_secs(crate::config::ffmpeg_timeout_secs()), + cancel, + }; + let total = visit_samples(input, &budget, |_| Ok(()))?; + let mut peaks = vec![0.0_f32; usize::try_from(width)?]; + let mut position = 0_u64; + let actual = visit_samples(input, &budget, |amplitude| { + let bucket = u128::from(position) * u128::from(width) / u128::from(total); + let index = usize::try_from(bucket.min(u128::from(width - 1)))?; + let peak = peaks + .get_mut(index) + .context("waveform bucket out of range")?; + *peak = peak.max(amplitude); + position = position + .checked_add(1) + .context("waveform position overflow")?; + Ok(()) + })?; + ensure!(actual == total, "audio changed between waveform passes"); + let pixels = render_peaks(&peaks, width, height)?; + budget.check()?; + pixels + .save_with_format(output, image::ImageFormat::Png) + .context("write waveform PNG") +} + +/// Draw a symmetric gray amplitude envelope on a transparent canvas. +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::as_conversions, + reason = "clamped normalized amplitudes map to a bounded pixel radius" +)] +fn render_peaks(peaks: &[f32], width: u32, height: u32) -> Result { + let mut image = RgbaImage::new(width, height); + let middle = height / 2; + let max_radius = middle.saturating_sub(1); + let radius_scale = f32::from(u16::try_from(max_radius).context("waveform is too tall")?); + for (column, peak) in peaks.iter().enumerate() { + let radius = (peak.clamp(0.0, 1.0) * radius_scale).round() as u32; + let x = u32::try_from(column)?; + for y in middle.saturating_sub(radius)..=middle.saturating_add(radius).min(height - 1) { + image.put_pixel(x, y, Rgba([0x88, 0x88, 0x88, 255])); + } + } + Ok(image) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compatibility_errors_exclude_malformed_packets_io_and_resource_failures() { + assert!(is_unsupported( + &symphonia::core::errors::Error::Unsupported( + "audio codec needs the compatibility decoder" + ) + .into() + )); + for error in [ + anyhow::Error::from(opus_decoder::OpusError::InternalError), + anyhow::Error::from(opus_decoder::OpusError::InvalidPacket), + anyhow::Error::from(opus_decoder::OpusError::BufferTooSmall), + anyhow::Error::from(symphonia::core::errors::Error::DecodeError( + "invalid header", + )), + anyhow::Error::from(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "deadline", + )), + anyhow::anyhow!("audio packet exceeds sample budget"), + ] { + assert!(!is_unsupported(&error)); + } + for message in [ + "core (probe): no suitable format reader found", + "isomp4: missing ftyp atom", + "isomp4: missing moov atom", + "mkv: missing segment element", + "mkv: video width too large", + "mkv: timestamp scale too large (report this)", + "ogg: page is not marked as first", + "aac: sample rate is required", + "aac: coupling channel element", + "aac: program config", + "aac: predictor data", + "aac: gain control data", + "adts: only 1 aac frame per adts packet is supported", + ] { + assert!( + !is_unsupported(&symphonia::core::errors::Error::Unsupported(message).into()), + "invalid/resource error selected fallback: {message}" + ); + } + } + + #[test] + fn all_supported_audio_fixtures_produce_deterministic_visible_waveforms() -> Result<()> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + let dir = tempfile::tempdir()?; + let first = dir.path().join("first.png"); + let second = dir.path().join("second.png"); + for file in [ + "tone.wav", + "tone.mp3", + "tone.flac", + "tone.ogg", + "tone.opus", + "speech-mode.opus", + "tone.aac", + "tone.m4a", + "tone-alac.m4a", + "audio.webm", + "audio.mkv", + ] { + render_waveform( + &root.join(file), + &first, + 250, + 125, + &CancellationToken::new(), + ) + .with_context(|| format!("waveform for {file}"))?; + render_waveform( + &root.join(file), + &second, + 250, + 125, + &CancellationToken::new(), + )?; + ensure!( + std::fs::read(&first)? == std::fs::read(&second)?, + "{file} waveform is not deterministic" + ); + let pixels = image::open(&first)?.into_rgba8(); + ensure!( + pixels.dimensions() == (250, 125), + "waveform dimensions changed" + ); + ensure!( + pixels + .pixels() + .filter(|pixel| pixel.0.get(3) == Some(&255)) + .count() + > 250, + "{file} waveform looks silent" + ); + } + Ok(()) + } + + /// Build valid PCM without any executable or codec fixture generator. + fn write_pcm(path: &Path, channels: u16, frames: u32, silence: bool) -> Result<()> { + use std::io::Write as _; + let data_len = frames + .checked_mul(u32::from(channels) * 2) + .context("PCM length")?; + let mut file = std::fs::File::create(path)?; + file.write_all(b"RIFF")?; + file.write_all(&(data_len + 36).to_le_bytes())?; + file.write_all(b"WAVEfmt ")?; + file.write_all(&16_u32.to_le_bytes())?; + file.write_all(&1_u16.to_le_bytes())?; + file.write_all(&channels.to_le_bytes())?; + file.write_all(&8000_u32.to_le_bytes())?; + file.write_all(&(8000 * u32::from(channels) * 2).to_le_bytes())?; + file.write_all(&(channels * 2).to_le_bytes())?; + file.write_all(&16_u16.to_le_bytes())?; + file.write_all(b"data")?; + file.write_all(&data_len.to_le_bytes())?; + let mut samples = Vec::with_capacity(usize::try_from(data_len)?); + for frame in 0..frames { + for channel in 0..channels { + let sample = if silence { + 0_i16 + } else if (frame + u32::from(channel)) % 2 == 0 { + 16_000_i16 + } else { + -16_000_i16 + }; + samples.extend_from_slice(&sample.to_le_bytes()); + } + } + file.write_all(&samples)?; + Ok(()) + } + + #[test] + fn actual_silence_single_frame_surround_and_long_pcm_are_streamed() -> Result<()> { + let dir = tempfile::tempdir()?; + let input = dir.path().join("sound.wav"); + let output = dir.path().join("wave.png"); + for (channels, frames, silence) in [ + (1, 1, false), + (6, 800, false), + (2, 480, true), + (1, 960_000, true), + ] { + write_pcm(&input, channels, frames, silence)?; + render_waveform(&input, &output, 250, 125, &CancellationToken::new())?; + let pixels = image::open(&output)?.into_rgba8(); + let ink = pixels + .pixels() + .filter(|pixel| pixel.0.get(3) == Some(&255)) + .count(); + ensure!( + pixels.dimensions() == (250, 125), + "PCM waveform dimensions changed" + ); + if silence { + ensure!(ink == 250, "silence should be a flat line"); + } else { + ensure!(ink > 250, "opposite-phase/single-frame audio disappeared"); + } + } + Ok(()) + } + + #[test] + fn opus_encoder_delay_and_end_padding_do_not_extend_the_waveform() -> Result<()> { + let input = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media/tone.opus"); + let cancel = CancellationToken::new(); + let budget = AudioBudget { + started: Instant::now(), + timeout: Duration::from_secs(30), + cancel: &cancel, + }; + ensure!( + visit_samples(&input, &budget, |_| Ok(()))? == 5760, + "120ms Opus clip includes encoder delay/padding" + ); + Ok(()) + } + + #[test] + fn silence_malformed_and_cancellation_are_safe() -> Result<()> { + let image = render_peaks(&[0.0, 0.0, 0.0], 3, 2)?; + ensure!( + image + .pixels() + .filter(|pixel| pixel.0.get(3) == Some(&255)) + .count() + == 3, + "silence should be a flat center line" + ); + let dir = tempfile::tempdir()?; + let source = dir.path().join("bad.wav"); + let output = dir.path().join("wave.png"); + std::fs::write(&source, b"RIFF\xff\xff\xff\xffWAVE")?; + ensure!( + render_waveform(&source, &output, 250, 125, &CancellationToken::new()).is_err(), + "malformed audio decoded" + ); + ensure!(!output.exists(), "malformed audio published output"); + let cancel = CancellationToken::new(); + cancel.cancel(); + ensure!( + render_waveform(&source, &output, 250, 125, &cancel).is_err(), + "cancelled decoding continued" + ); + Ok(()) + } + + #[test] + fn malformed_container_errors_cannot_select_compatibility() -> Result<()> { + let dir = tempfile::tempdir()?; + let source = dir.path().join("bad.m4a"); + let output = dir.path().join("wave.png"); + for bytes in [ + b"not an audio container".as_slice(), + b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2", + b"OggS\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0", + b"\x1a\x45\xdf\xa3\x01", + ] { + std::fs::write(&source, bytes)?; + let error = render_waveform(&source, &output, 250, 125, &CancellationToken::new()) + .err() + .context("malformed audio was decoded")?; + ensure!( + !is_unsupported(&error), + "malformed input selected fallback: {error:#}" + ); + ensure!(!output.exists(), "malformed input published a waveform"); + } + Ok(()) + } + + #[test] + fn implicit_he_aac_is_unsupported_and_truncated_extensions_are_invalid() -> Result<()> { + ensure!( + aac_needs_compatibility(&[0x13, 0x10, 0x56, 0xe5, 0x98])?, + "implicit SBR was ignored" + ); + ensure!( + !aac_needs_compatibility(&[0x11, 0x90])?, + "AAC-LC selected compatibility" + ); + let error = aac_needs_compatibility(&[0x13, 0x10, 0x56, 0xe5]) + .err() + .context("truncated SBR configuration was accepted")?; + ensure!(!is_unsupported(&error), "truncated SBR selected fallback"); + for bytes in [&[0x28][..], &[0x00, 0x90], &[0x17, 0x10]] { + let error = aac_needs_compatibility(bytes) + .err() + .context("invalid AAC config accepted")?; + ensure!( + !is_unsupported(&error), + "invalid AAC config selected fallback" + ); + } + Ok(()) + } +} diff --git a/src/media/convert.rs b/src/media/convert.rs index 040c5eb2..dcab1452 100644 --- a/src/media/convert.rs +++ b/src/media/convert.rs @@ -2,8 +2,8 @@ // Per-format conversion logic. // // Conversion rules (from project spec): -// jpg / jpeg → WebP (quality 85, metadata stripped) -// gif → WebP (quality 85, -loop 0 preserves animation if libwebp supports it) +// jpg / jpeg → WebP (lossless, metadata stripped) +// gif → animated WebP (composited frames, timing and repeats preserved) // heic/heif → WebP // bmp → WebP // tiff → WebP @@ -14,8 +14,7 @@ // all audio → keep as-is // mp4 → keep as-is (background worker handles MP4→WebM separately) // -// All conversion functions require ffmpeg. Callers must check -// `MediaProcessor::ffmpeg_available` before calling into this module. +// Image decoding and WebP encoding run internally in Rust. // On failure, all functions log a warning and the caller falls back to // storing the original bytes. @@ -23,7 +22,7 @@ use anyhow::{Context as _, Result}; use std::path::{Path, PathBuf}; use uuid::Uuid; -use super::ffmpeg; +use super::images; /// Describes what action the conversion pipeline should take for a given /// source MIME type. @@ -44,10 +43,6 @@ pub enum ConversionAction { #[must_use] pub fn conversion_action(mime: &str) -> ConversionAction { match mime { - // GIF → animated WebP: keeps the media type as Image so it renders in - // an tag rather than a