mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
Merge remote-tracking branch 'origin/main' into fix-338
# Conflicts: # ROADMAP.md # crates/engine/src/commands.rs # crates/engine/src/lib.rs # crates/ui-egui/README.md # crates/ui-egui/src/panels/monitor.rs
This commit is contained in:
@@ -35,6 +35,8 @@ jobs:
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: Linux tarball installer
|
||||
run: python3 packaging/linux/test_install.py
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -21,7 +21,7 @@ panic path, and fix a crash before building on top of it. The cross-app standard
|
||||
code alone (a literal that always parses): a single-item `#[allow(clippy::expect_used)]` with the reason.
|
||||
3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace). The single exception is
|
||||
`crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware
|
||||
video decoding through VideoToolbox and Media Foundation / Direct3D 11; later encoding and Linux) and does nothing else. It
|
||||
video decoding through VideoToolbox, Media Foundation / Direct3D 11 and VA-API; hardware encoding) and does nothing else. It
|
||||
uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a
|
||||
`// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the
|
||||
pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)).
|
||||
|
||||
+3
-1
@@ -26,6 +26,8 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||
| `docs/images/filmcraft-effects.png` | FilmCraft contributors | Original work: FilmCraft screenshot crop; NASA Earth Views (US Government work, public domain) | MIT OR Apache-2.0; footage public domain |
|
||||
| `docs/images/filmcraft-timeline.png` | FilmCraft contributors | Original work: FilmCraft UI crop; thumbnails from Night of the Living Dead (1968) and Carnival of Souls (1962), CC0 Chopin waveform | MIT OR Apache-2.0; film frames public domain |
|
||||
| `assets/app-icon/filmcraft.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; canonical vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: FilmCraft macOS DMG window background, derived from the FilmCraft app icon (`assets/app-icon/filmcraft.svg`); rendered to `background.tiff` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
|
||||
| `assets/app-icon/filmcraft-small.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; lighter vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `assets/app-icon/filmcraft-1024.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; 1024 px render | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `assets/app-icon/filmcraft-macos-512.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; macOS runtime Dock icon (512 px) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
@@ -147,7 +149,7 @@ the original copyright and permission notice in its header.
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `crates/platform/src/nvenc/ffi.rs` | NVIDIA Corporation (`nvEncodeAPI.h`); FilmCraft contributors (Rust transcription) | Structures, function table and constants transcribed from `nvEncodeAPI.h`, NVIDIA Video Codec SDK, API 12.1 (https://developer.nvidia.com/video-codec-sdk) | MIT (Copyright (c) 2010-2023 NVIDIA Corporation; notice kept in the file header) |
|
||||
| `crates/platform/src/nvenc/ffi.rs` | NVIDIA Corporation (`nvEncodeAPI.h`); FilmCraft contributors (Rust transcription) | Structures (including `NV_ENC_CONFIG_HEVC` and its bit-field masks, `NV_ENC_PIC_PARAMS_HEVC`, `NV_ENC_SEI_PAYLOAD`, `NV_ENC_TIME_CODE`), function table, codec / profile GUIDs (H.264, HEVC Main, HEVC Main 10) and constants (`NV_ENC_LEVEL_HEVC_*`, tier, CU size, `NV_ENC_BUFFER_FORMAT_YUV420_10BIT`, `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE`, BT.2020 / PQ / HLG VUI codes) transcribed from `nvEncodeAPI.h`, NVIDIA Video Codec SDK, API 12.1 (https://developer.nvidia.com/video-codec-sdk) | MIT (Copyright (c) 2010-2023 NVIDIA Corporation; notice kept in the file header) |
|
||||
|
||||
## Downloaded at runtime
|
||||
|
||||
|
||||
Generated
+17
@@ -1681,6 +1681,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
@@ -1816,6 +1817,7 @@ dependencies = [
|
||||
"filmcraft-vp9",
|
||||
"image",
|
||||
"log",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
@@ -1843,6 +1845,7 @@ dependencies = [
|
||||
name = "filmcraft-edit"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-time",
|
||||
@@ -2097,16 +2100,20 @@ dependencies = [
|
||||
name = "filmcraft-platform"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-export",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-h264",
|
||||
"filmcraft-hevc",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"libloading",
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-media",
|
||||
@@ -2202,6 +2209,7 @@ dependencies = [
|
||||
"skrifa",
|
||||
"unicode-bidi",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5782,6 +5790,15 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysctl"
|
||||
version = "0.6.0"
|
||||
|
||||
@@ -137,7 +137,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
|
||||
- **Premiere 26's full Video Effects bin (93 effects in 16 folders), the Legacy bin and the obsolete effects old projects use, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
|
||||
- **Motion and opacity on every clip:** position, scale, rotation, anchor point and anti-flicker, plus 26 blend modes.
|
||||
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter, and each animated parameter opens into **value and velocity graphs** with draggable influence handles.
|
||||
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
|
||||
- **A GPU compositor** built on wgpu (Metal, Vulkan, DirectX 12, WebGPU). It samples YUV straight from the decoder with footprint supersampling and blends in linear light. A CPU path renders the same frames, and the two are tested against each other.
|
||||
|
||||
<p align="center">
|
||||
@@ -185,7 +185,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
|
||||
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
|
||||
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF |
|
||||
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: HEVC and AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox) and Windows (Media Foundation), with Linux to follow; H.264 export can use NVIDIA's encoder on Windows (opt-in) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
|
||||
<br>
|
||||
|
||||
@@ -287,9 +287,9 @@ We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)):
|
||||
|
||||
The biggest gaps today:
|
||||
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows; Linux has no hardware path yet. Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only there ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
|
||||
- **Delivery codecs.** H.264 is our only delivery-codec export; no HEVC or AV1 export yet.
|
||||
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
|
||||
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
|
||||
- **AI features.** Few so far; speech to text is optional and off by default.
|
||||
|
||||
@@ -327,6 +327,21 @@ Nothing below the front ends depends on a UI toolkit or OS API. `cargo xtask ci`
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only Vulkan and DirectX 12 by
|
||||
default, not OpenGL. Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
|
||||
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
|
||||
PowerShell, from the folder containing the executable:
|
||||
|
||||
```powershell
|
||||
$env:WGPU_BACKEND = "vulkan"
|
||||
& .\filmcraft.exe
|
||||
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
|
||||
```
|
||||
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
@@ -342,7 +357,22 @@ Installers and executables are code-signed.
|
||||
| Flatpak | `filmcraft-<ver>-linux-x86_64.flatpak` | `filmcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `filmcraft-<ver>-linux-x86_64.deb` | `filmcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `filmcraft-<ver>-linux-x86_64.rpm` | `filmcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `filmcraft-<ver>-linux-x86_64.tar.gz` | `filmcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
| Tarball | `filmcraft-<ver>-linux-x86_64.tar.gz` | `filmcraft-<ver>-linux-aarch64.tar.gz` | Unpack and run `./install.sh` |
|
||||
|
||||
The tarball installer places FilmCraft, its command-line tool and desktop integration in
|
||||
`~/.local`, without administrator permissions. For all users, run
|
||||
`sudo ./install.sh --prefix /usr/local` instead. Running the installer again updates the installation.
|
||||
|
||||
**Gentoo (community-maintained):** the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay)
|
||||
packages the Linux release as `media-video/filmcraft-bin`. It is maintained by the community, not by the
|
||||
FilmCraft team, so report packaging problems to the overlay:
|
||||
|
||||
```sh
|
||||
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
|
||||
emaint sync -r snakebyte
|
||||
echo 'media-video/filmcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/filmcraft
|
||||
emerge --ask media-video/filmcraft-bin
|
||||
```
|
||||
|
||||
### FreeBSD
|
||||
|
||||
|
||||
+11
-8
@@ -35,8 +35,8 @@ before choosing work.
|
||||
| **Feature breadth** (menus, commands, panels) | ~86–95% | **~85%** | Menu diff against Premiere 26.5; effect, transition and audio-effect catalogues complete by test. The long tail is mostly there. |
|
||||
| **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. |
|
||||
| **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. |
|
||||
| **Export** | ~92% | **~70%** | Delivery: H.264 only (ProRes, DNxHR and image sequences cover mastering). No HEVC or AV1 export. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. |
|
||||
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software) and Windows (Media Foundation / Direct3D 11 DXVA, H.264, HEVC Main / Main 10, VP9 and AV1 4:2:0 8/10-bit, #30). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects and export rendering are CPU; H.264 encoding can use VideoToolbox on macOS (opt-in per export, no B-frames) and NVENC on Windows with an NVIDIA GPU (opt-in per export), and H.265 (HEVC) export exists there only, through the same hardware encoder. Linux has no hardware path. 8K isn't real-time (AV1 is on Windows with a hardware decoder). The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
|
||||
| **Export** | ~92% | **~70%** | Delivery: H.264, plus H.265 through a hardware encoder only (NVENC on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: Main 8-bit SDR); ProRes, DNxHR and image sequences cover mastering. No AV1 export, no 10-bit / HDR HEVC on macOS. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. |
|
||||
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software) and Windows (Media Foundation / Direct3D 11 DXVA, H.264, HEVC Main / Main 10, VP9 and AV1 4:2:0 8/10-bit, #30), and for H.264 and HEVC on Linux (VA-API: 4K H.264 at 11 ms of CPU per frame instead of 417 ms, 4K HEVC at 10 ms instead of 203 ms). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects and export rendering are CPU; H.264 encoding can use VideoToolbox on macOS (opt-in per export, no B-frames) and NVENC on Windows with an NVIDIA GPU (opt-in per export), and H.265 (HEVC) export exists there only, through the same hardware encoders (macOS and Windows). Linux decodes H.264 and HEVC in hardware (VP9 and AV1 there are software) and has no hardware encoder. 8K isn't real-time (AV1 is on Windows with a hardware decoder). The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
|
||||
| **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. |
|
||||
| **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. |
|
||||
| **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. |
|
||||
@@ -48,10 +48,10 @@ before choosing work.
|
||||
In priority order. Agents should prefer this work over adding more checklist items.
|
||||
|
||||
1. **Hardware acceleration** (#30), the most visible gap to users:
|
||||
- hardware decode on Linux (VA-API);
|
||||
- hardware decode on Linux (VA-API): H.264 and HEVC are in; VP9 and AV1 remain;
|
||||
- zero-copy decoded frames into wgpu;
|
||||
- the remaining effects on the GPU: Lumetri, keys, Vignette, Video Limiter, masks (31 common effects are done);
|
||||
- GPU export, then hardware encode: H.264 and H.265 on macOS are in (H.264 opt-in; H.265 has no other encoder), H.264 on NVENC (Windows, opt-in); HEVC on Windows and Linux remain.
|
||||
- GPU export, then hardware encode: H.264 and H.265 on macOS are in (H.264 opt-in; H.265 has no other encoder), H.264 and H.265 (Main 8-bit, and Main 10 HDR PQ / HLG) on NVENC (Windows); Main 10 / HDR HEVC on VideoToolbox, Linux and other Windows vendors remain.
|
||||
2. **A measured parity number.** Add `cargo xtask parity`, run in CI. It should cover menus,
|
||||
commands, effects, transitions, panels, preferences and formats against the Premiere snapshot in
|
||||
`plan/premiere/`. It should report presence and fidelity separately (exact, approximate, stub).
|
||||
@@ -63,7 +63,7 @@ In priority order. Agents should prefer this work over adding more checklist ite
|
||||
through XML / AAF.
|
||||
5. **Windows and Linux at runtime.** CI that runs the test suite and a headless playback/export
|
||||
smoke test on both, not just release builds. Fix the Linux audio path (#23).
|
||||
6. **Delivery codecs.** HEVC export is in on macOS (VideoToolbox, 8-bit, no software encoder); still to do: HEVC elsewhere, 10-bit / HDR HEVC, and AV1 (`rav1e`, BSD-2).
|
||||
6. **Delivery codecs.** HEVC export is in on macOS (VideoToolbox, 8-bit, no software encoder) and on Windows (NVENC, 8-bit and Main 10 HDR); still to do: HEVC elsewhere, 10-bit / HDR HEVC on macOS, and AV1 (`rav1e`, BSD-2).
|
||||
7. **Plugin hosting** (VST3 / Audio Units, then OpenFX). Needs FFI, so it falls under the isolated
|
||||
`unsafe` crate rule (AGENTS.md §0.3). **Owner decision needed** before starting.
|
||||
8. **AI features** with openly licensed local models: transcription on by default, text-based
|
||||
@@ -100,7 +100,7 @@ matched against `filmcraft-cli commands` and the UI command table. Effects: the
|
||||
| Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% |
|
||||
| Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% |
|
||||
| Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% |
|
||||
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; no hardware path on Linux, and on Windows only decode and opt-in NVENC H.264 export (see the honest assessment: ~35–40% against Premiere) | ~62% |
|
||||
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; H.264 and HEVC decode in hardware on Linux (VA-API), and on Windows only decode and opt-in NVENC H.264 export (see the honest assessment: ~35–40% against Premiere) | ~62% |
|
||||
| **Weighted total** | | | **~87%** |
|
||||
|
||||
## Estimate to parity
|
||||
@@ -154,13 +154,16 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
|
||||
## Running now
|
||||
|
||||
- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 31 common effects on the GPU (GPU2). Windows: Media Foundation / Direct3D 11 H.264 + HEVC decode (HW2) and VP9 + AV1 (HW3), NVENC H.264 export (HW4, opt-in). Next: VA-API (Linux) decode, zero-copy upload, Lumetri on the GPU.
|
||||
- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 31 common effects on the GPU (GPU2). Windows: Media Foundation / Direct3D 11 H.264 + HEVC decode (HW2) and VP9 + AV1 (HW3), NVENC H.264 export (HW4, opt-in) and H.265 export. Linux: VA-API H.264 + HEVC decode (HW5). Next: VA-API VP9 / AV1, zero-copy upload, Lumetri on the GPU.
|
||||
- **Next:** [Where we are lacking](#where-we-are-lacking) items 1–5. GPU export waits for the export-crate work (#19 and the never-crash follow-up) to land.
|
||||
|
||||
## Log
|
||||
|
||||
- **2026-10-08 (Source range dragging):** implicit full-clip In/Out, video-only/audio-only/linked both controls, picture/waveform drag gestures and one-command undoable timeline placement. Shared Source/Program draggable frame-bounded handles and range translation, cancellation, Source marker/navigation routing and marked-range playback/looping.
|
||||
- **2026-10-08 (Source playback):** normal forward playback for video/audio media and subclips in the Source monitor, an independent Source clock, Play/Pause and focused Space, frame stepping, device-clock audio with wall-clock fallback, seeking, and end-of-clip stopping. Windows desktop review verified a real video file and standalone WAV, including In/Out marking. Reverse/shuttle Source playback and source sequences remain unsupported; no parity percentage changed.
|
||||
- **2026-10-08 (night):** HDR H.265 (HEVC Main 10, PQ and HLG, BT.2020, limited range) export on Windows through NVENC (#30): an HDR sequence now exports as real 10-bit HDR (`rgbf_to_yuv420_10` into P010 input buffers; VUI BT.2020 + PQ / HLG; the HDR10 mastering-display and content-light messages as SEI on every IDR for PQ, through the generic SEI payload array because NVENC 12.1 has no mastering-display field; `colr` / `mdcv` / `clli` in the sample entry), where the GPU reports 10-bit HEVC (new `register_hdr_probe` / `hdr_available` in the export crate; elsewhere, and on macOS, HDR sequences still tone-map to 8-bit SDR HEVC, and `settings.sdr` forces that). ffprobe reads `hevc` / `Main 10` / `hvc1` / `yuv420p10le` / `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv` with the mastering-display and content-light side data for PQ; ffmpeg's decode equals ours code for code; the in-process round trip is at 71.1 dB luma PSNR on the 10-bit scale with 803 distinct levels on a ramp and the peak code 940 intact (RTX 5060). 8-bit SDR H.265 is unchanged.
|
||||
- **2026-10-08:** H.265 (HEVC Main, 8-bit 4:2:0, SDR) export on Windows through NVENC (#30): the NVENC session, ring and NV12 path of the H.264 encoder now serve both codecs (the H.264 export is byte-for-byte unchanged); the format is available where the GPU has an HEVC encoder, choosing it is the opt-in, and `hvcC` is built from the SPS the encoder wrote. ffprobe reads `hevc` / `Main` / `hvc1` / `yuv420p` / BT.709; our own HEVC decoder decodes it at 48.9 dB luma PSNR (RTX 5060). Main 10 and HDR remain.
|
||||
- **2026-10-08 (#285):** `filmcraft-cli` no longer panics with "Broken pipe" when its stdout is closed early (`filmcraft-cli commands | head`): later output is dropped, the rest of the work (script lines, `--save`, `--save-as`) still runs, and the exit status still reports failures. Other stdout write errors are reported and make the exit status 1. No change to the checklist or estimates.
|
||||
|
||||
- **2026-10-06:** 21 community PRs landed (#19, #28, #38, #39, #41–#60). Highlights: hardware decoding is now actually on in the desktop app (#41: `register()` sat inside a `log::info!` that never ran, so #33's VideoToolbox path only worked in the CLI and benchmarks); decode memory on long timelines cut sharply (frame pool, shared GOP-cache budget, positional file reads); exports are byte-identical across machines (#19); crash fix for odd track names over MCP (#44); ripple trim, ripple delete and speed changes move split-edit clips once (#56–#58); Japanese interface using an installed system font (#38; no fonts bundled); MCP annotations, resources, export progress and cancellation (#28).
|
||||
|
||||
@@ -208,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
|
||||
### Interface localization
|
||||
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists in UI state. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved.
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/<code>.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish covers the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry. Searches match the displayed labels as well as English sources. Japanese and Brazilian Portuguese cover the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved.
|
||||
|
||||
@@ -4,6 +4,32 @@
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
/// `println!` that doesn't panic when stdout is closed (`filmcraft-cli commands | head`) with
|
||||
/// "failed printing to stdout: Broken pipe (os error 32)". Once stdout has failed, later output is
|
||||
/// dropped and the work goes on: see `stdout_failed`.
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if !$crate::stdout_gone() {
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// `print!` counterpart of `outln!`.
|
||||
macro_rules! out {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if !$crate::stdout_gone() {
|
||||
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
mod args;
|
||||
mod probe;
|
||||
|
||||
@@ -11,6 +37,28 @@ use args::{Args, parse_value};
|
||||
use filmcraft_automation::BridgeClient;
|
||||
use filmcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Set once a write to stdout has failed; later output is dropped.
|
||||
static STDOUT_GONE: AtomicBool = AtomicBool::new(false);
|
||||
/// Set when stdout failed for a reason other than a closed reader: the exit status becomes 1.
|
||||
static STDOUT_ERROR: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn stdout_gone() -> bool {
|
||||
STDOUT_GONE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// stdout went away. The program doesn't stop there: the rest of `exec`/`run`/`import` (later
|
||||
/// script lines, `--save`, `--save-as`) still runs and the exit status still tells how it went, so
|
||||
/// `exec … --save | head -1` keeps the edit. A reader that stopped early (a closed pipe) is not an
|
||||
/// error, as in ripgrep; any other write error is reported and makes the exit status 1.
|
||||
fn stdout_failed(e: std::io::Error) {
|
||||
if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
return;
|
||||
}
|
||||
eprintln!("filmcraft-cli: can't write to stdout: {e}");
|
||||
STDOUT_ERROR.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
const HELP: &str = "\
|
||||
filmcraft-cli: drive FilmCraft from the shell (headless engine, or the live app with --bridge)
|
||||
@@ -134,7 +182,7 @@ impl Backend {
|
||||
|
||||
fn print(a: &Args, v: &Value) {
|
||||
let s = if a.flag("--compact") { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) };
|
||||
println!("{}", s.unwrap_or_default());
|
||||
outln!("{}", s.unwrap_or_default());
|
||||
}
|
||||
|
||||
/// Export format from the output file's extension.
|
||||
@@ -162,16 +210,23 @@ fn register_hardware_decoders() -> filmcraft_platform::Availability {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
cli().await;
|
||||
if STDOUT_ERROR.load(Ordering::Relaxed) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn cli() {
|
||||
if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "-V")) {
|
||||
println!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
register_hardware_decoders();
|
||||
let a = Args::parse(std::env::args().skip(1));
|
||||
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
|
||||
match cmd {
|
||||
"help" | "--help" | "-h" => print!("{HELP}"),
|
||||
"version" => println!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")),
|
||||
"help" | "--help" | "-h" => out!("{HELP}"),
|
||||
"version" => outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")),
|
||||
"probe" => {
|
||||
let path = a.pos(1).unwrap_or_else(|| usage("probe <media> [--image-sequence]"));
|
||||
match probe::probe(path, a.flag("--image-sequence")) {
|
||||
@@ -189,11 +244,11 @@ async fn main() {
|
||||
src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(f))).unwrap_or_else(|e| fail(e));
|
||||
}
|
||||
let dt = t0.elapsed().as_secs_f64();
|
||||
println!("{n} frames in {dt:.2}s → {:.1} fps (sequential, via Mp4Source)", n as f64 / dt);
|
||||
outln!("{n} frames in {dt:.2}s → {:.1} fps (sequential, via Mp4Source)", n as f64 / dt);
|
||||
let t1 = std::time::Instant::now();
|
||||
let f = src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(n / 2))).unwrap_or_else(|e| fail(e));
|
||||
let (w, h, _) = f.to_linear_f32_decimated(2);
|
||||
println!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);
|
||||
outln!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
"commands" | "describe" => {
|
||||
let mut b = Backend::open(&a);
|
||||
@@ -219,7 +274,7 @@ async fn main() {
|
||||
} else {
|
||||
for c in &list {
|
||||
let s = |k: &str| c[k].as_str().unwrap_or("").to_string();
|
||||
println!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params"));
|
||||
outln!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,9 +301,9 @@ async fn main() {
|
||||
let v: Value = serde_json::from_str(line).unwrap_or_else(|e| usage(format!("line {}: {e}", n + 1)));
|
||||
let id = v["id"].as_str().unwrap_or_else(|| usage(format!("line {}: missing \"id\"", n + 1)));
|
||||
match b.exec(id, v.get("params").cloned().unwrap_or(json!({}))).await {
|
||||
Ok(r) => println!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})),
|
||||
Ok(r) => outln!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})),
|
||||
Err(e) => {
|
||||
println!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e}));
|
||||
outln!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e}));
|
||||
failed += 1;
|
||||
if !a.flag("--keep-going") {
|
||||
std::process::exit(1);
|
||||
@@ -344,7 +399,7 @@ async fn main() {
|
||||
match r {
|
||||
Ok(v) => {
|
||||
print(&a, &v);
|
||||
eprintln!("exported {out} in {:.1}s", t0.elapsed().as_secs_f64());
|
||||
eprintln!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64());
|
||||
}
|
||||
Err(e) => fail(format!("export: {e}")),
|
||||
}
|
||||
@@ -381,6 +436,24 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The files an export wrote, from its result: `path` of a direct export, or each queued item's
|
||||
/// `path` (the format may have changed the extension: `out.mp4` exported as APV is `out.mp4.mov`).
|
||||
/// `requested` when the result names none.
|
||||
fn written_paths(result: &Value, requested: &str) -> Vec<String> {
|
||||
let paths: Vec<String> = match result.get("path").and_then(Value::as_str) {
|
||||
Some(p) => vec![p.to_string()],
|
||||
None => result
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|i| i.get("path").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
};
|
||||
if paths.is_empty() { vec![requested.to_string()] } else { paths }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod format_tests {
|
||||
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
|
||||
@@ -388,7 +461,21 @@ mod format_tests {
|
||||
#[test]
|
||||
fn startup_registers_the_hardware_decoders() {
|
||||
let hardware = super::register_hardware_decoders();
|
||||
assert_eq!(filmcraft_platform::registered(), cfg!(any(target_os = "macos", target_os = "windows")), "{hardware:?}");
|
||||
// always on macOS and Windows; on Linux when a VA-API driver is there
|
||||
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|
||||
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
|
||||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_message_names_the_written_files() {
|
||||
use serde_json::json;
|
||||
let direct = json!({"job": 1, "path": "out-apv.mp4.mov", "result": {"path": "out-apv.mp4.mov"}});
|
||||
assert_eq!(super::written_paths(&direct, "out-apv.mp4"), ["out-apv.mp4.mov"]);
|
||||
let queued = json!({"added": [1, 2], "items": [{"path": "a.mp4.mov"}, {"path": "b.wav"}]});
|
||||
assert_eq!(super::written_paths(&queued, "a.mp4"), ["a.mp4.mov", "b.wav"]);
|
||||
assert_eq!(super::written_paths(&json!({}), "x.mov"), ["x.mov"]);
|
||||
assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -179,9 +179,89 @@ fn export_with_presets() {
|
||||
let v = json_out(&cli(&["--demo", "--data-dir", data_s, "export", q.to_str().unwrap(), "--preset", "Tiny WAV", "--queue", "--range", "entire"]));
|
||||
assert_eq!(v["items"][0]["status"], "done", "{v}");
|
||||
assert!(q.exists());
|
||||
// a format that changes the extension: the completion message names the file written (#341)
|
||||
let apv = dir.join("apv.mp4");
|
||||
let o =
|
||||
cli(&["--demo", "--data-dir", data_s, "export", apv.to_str().unwrap(), "--preset", "APV 422-10", "--start", "0", "--end", "0.1", "--scale", "0.125"]);
|
||||
let v = json_out(&o);
|
||||
let written = v["path"].as_str().unwrap().to_string();
|
||||
assert!(written.ends_with("apv.mp4.mov") && std::path::Path::new(&written).exists(), "{v}");
|
||||
let msg = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(msg.contains(&format!("exported {written} in ")), "{msg}");
|
||||
let qa = dir.join("queued-apv.mp4");
|
||||
let o = cli(&[
|
||||
"--demo",
|
||||
"--data-dir",
|
||||
data_s,
|
||||
"export",
|
||||
qa.to_str().unwrap(),
|
||||
"--preset",
|
||||
"APV 422-10",
|
||||
"--queue",
|
||||
"--start",
|
||||
"0",
|
||||
"--end",
|
||||
"0.1",
|
||||
"--scale",
|
||||
"0.125",
|
||||
]);
|
||||
let v = json_out(&o);
|
||||
let written = v["items"][0]["path"].as_str().unwrap().to_string();
|
||||
assert!(written.ends_with("queued-apv.mp4.mov"), "{v}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("exported {written} in ")), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
// unknown preset: exit status 1 and a message naming it
|
||||
let bad = cli(&["--demo", "--data-dir", data_s, "export", dir.join("x.mp4").to_str().unwrap(), "--preset", "No Such Preset"]);
|
||||
assert_eq!(bad.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("No Such Preset"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `filmcraft-cli commands | head -c 10` panicked with "failed printing to stdout: Broken pipe
|
||||
/// (os error 32)" and exit status 101 (#285).
|
||||
#[test]
|
||||
fn closed_stdout_ends_quietly() {
|
||||
use std::io::Read;
|
||||
for args in [&["commands"][..], &["help"], &["--demo", "commands", "--json"]] {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut first = [0u8; 10];
|
||||
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.is_empty(), "{args:?}: {:?}: {err}", out.status);
|
||||
}
|
||||
}
|
||||
|
||||
/// `run -` with stdout already closed: `run` reads its script to the end before it prints, so its
|
||||
/// first write fails for sure. The rest of the script and the save still happen, and the exit status
|
||||
/// still reports a failing command.
|
||||
fn run_with_closed_stdout(args: &[&str], script: &[u8]) -> Output {
|
||||
let mut child =
|
||||
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
child.stdin.take().unwrap().write_all(script).unwrap();
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_stdout_still_runs_the_script_and_saves() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-cli-pipe-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let proj = dir.join("p.fcproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
let script = b"{\"id\":\"file.newBin\",\"params\":{\"name\":\"Piped One\"}}\n{\"id\":\"file.newBin\",\"params\":{\"name\":\"Piped Two\"}}\n";
|
||||
let out = run_with_closed_stdout(&["--demo", "--save-as", p, "run", "-"], script);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let tree = json_out(&cli(&["--project", p, "inspect", "project"])).to_string();
|
||||
assert!(tree.contains("Piped One") && tree.contains("Piped Two"), "{tree}");
|
||||
|
||||
// a failing command is still a failure, whether or not anyone reads stdout
|
||||
let out = run_with_closed_stdout(&["--demo", "run", "-"], b"{\"id\":\"no.such.command\"}\n");
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let out = run_with_closed_stdout(
|
||||
&["--demo", "--keep-going", "run", "-"],
|
||||
b"{\"id\":\"no.such.command\"}\n{\"id\":\"file.newBin\",\"params\":{\"name\":\"x\"}}\n",
|
||||
);
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -70,9 +70,8 @@ fn file_name(path: &str) -> &str {
|
||||
path.rsplit('/').next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Register a browser `File` (or `Blob` with a name) and return its virtual path. A file with
|
||||
/// the same name and size as an existing entry replaces it (the same file picked again keeps
|
||||
/// its path, so projects referencing it come back online); otherwise a numbered name is used.
|
||||
/// Register a browser `File` (or named `Blob`) at an unused virtual path; collisions get numbered names.
|
||||
/// Name + size do not identify content: replacing an occupied path changes every project item referencing it.
|
||||
pub fn register_blob(name: &str, blob: web_sys::Blob) -> String {
|
||||
let size = blob.size() as u64;
|
||||
let clean: String = name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
|
||||
@@ -85,18 +84,11 @@ pub fn register_blob(name: &str, blob: web_sys::Blob) -> String {
|
||||
let mut n = 1;
|
||||
let path = loop {
|
||||
let p = if n == 1 { format!("/files/{clean}") } else { format!("/files/{stem} ({n}){ext}") };
|
||||
match fs.entries.get(&p) {
|
||||
None => break p,
|
||||
Some(Entry::Blob { size: s, .. }) if *s == size => break p,
|
||||
Some(Entry::Mem(b)) if b.len() as u64 == size => break p,
|
||||
_ => n += 1,
|
||||
if !fs.entries.contains_key(&p) {
|
||||
break p;
|
||||
}
|
||||
n += 1;
|
||||
};
|
||||
if let Some(Entry::Blob { id, .. }) = fs.entries.get(&path) {
|
||||
let id = *id;
|
||||
fs.blobs.remove(&id);
|
||||
fs.chunks.retain(|k, _| k.0 != id);
|
||||
}
|
||||
let id = fs.next_id;
|
||||
fs.next_id += 1;
|
||||
fs.blobs.insert(id, blob);
|
||||
|
||||
@@ -81,12 +81,12 @@ async fn prewarm(path: &str, size: u64) {
|
||||
}
|
||||
|
||||
/// `file.import` one path, retried while its bytes are loading.
|
||||
async fn import_path(path: String) -> Result<Value, String> {
|
||||
async fn import_path(path: String, bin: u64) -> Result<Value, String> {
|
||||
for _ in 0..2000 {
|
||||
let p = path.clone();
|
||||
let (r, pending) = on_ui(move |w, _| {
|
||||
let _ = filmcraft_media::pending::take();
|
||||
let r = w.app.session.execute("file.import", json!({"paths": [p]})).map_err(|e| e.to_string());
|
||||
let r = w.app.session.execute("file.import", json!({"paths": [p], "bin": bin})).map_err(|e| e.to_string());
|
||||
(r, filmcraft_media::pending::take())
|
||||
})
|
||||
.await;
|
||||
@@ -105,6 +105,8 @@ async fn import_path(path: String) -> Result<Value, String> {
|
||||
|
||||
/// Import browser files (no copies): returns `{items, errors, paths}`.
|
||||
pub async fn import_files(files: Vec<web_sys::File>) -> Value {
|
||||
// Capture once per batch: navigation while prewarming must not redirect pending imports.
|
||||
let bin = on_ui(|w, _| w.app.import_bin().0).await;
|
||||
let mut items = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut paths = Vec::new();
|
||||
@@ -113,7 +115,7 @@ pub async fn import_files(files: Vec<web_sys::File>) -> Value {
|
||||
let size = f.size() as u64;
|
||||
let path = fs::register_blob(&name, f.clone().into());
|
||||
prewarm(&path, size).await;
|
||||
match import_path(path.clone()).await {
|
||||
match import_path(path.clone(), bin).await {
|
||||
Ok(v) => {
|
||||
items.extend(v.get("items").and_then(Value::as_array).cloned().unwrap_or_default());
|
||||
errors.extend(v.get("errors").and_then(Value::as_array).cloned().unwrap_or_default());
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod fs;
|
||||
pub mod import;
|
||||
pub mod opfs;
|
||||
pub mod recovery;
|
||||
pub mod recovery_policy;
|
||||
pub mod webcodecs;
|
||||
|
||||
use std::cell::RefCell;
|
||||
@@ -58,6 +59,13 @@ pub fn repaint() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Give a host that builds its own [`WebApp`] the egui context that [`post`] and [`repaint`] wake.
|
||||
/// [`start`] calls it; an embedding app that runs `WebApp` in its own eframe runner calls it once
|
||||
/// from its app creator, or posted actions and JS API requests wait for the next input event.
|
||||
pub fn set_context(ctx: egui::Context) {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
/// Environment facts reported by `filmcraft.info()` (backend, isolation, codecs…).
|
||||
pub fn set_info(key: &str, v: serde_json::Value) {
|
||||
INFO.with(|i| {
|
||||
@@ -150,7 +158,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
canvas,
|
||||
web_options,
|
||||
Box::new(move |cc| {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
set_context(cc.egui_ctx.clone());
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
fs::set_on_data(move || ctx.request_repaint());
|
||||
fs::set_on_write(|path, data| {
|
||||
@@ -177,6 +185,9 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
}
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
import::install_hooks(&mut app);
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218): the browser's languages.
|
||||
app.hooks.system_languages =
|
||||
Some(Box::new(|| web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default()));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
|
||||
@@ -10,8 +10,13 @@ use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle};
|
||||
|
||||
/// Called with whether a queued write reached OPFS.
|
||||
pub type Done = Box<dyn FnOnce(bool)>;
|
||||
|
||||
thread_local! {
|
||||
static QUEUE: RefCell<(BTreeMap<String, Vec<u8>>, bool)> = const { RefCell::new((BTreeMap::new(), false)) };
|
||||
/// Pending writes per path (bytes and the callbacks of every write coalesced into them), and
|
||||
/// whether the writer task is running.
|
||||
static QUEUE: RefCell<(BTreeMap<String, (Vec<u8>, Vec<Done>)>, bool)> = const { RefCell::new((BTreeMap::new(), false)) };
|
||||
}
|
||||
|
||||
/// Whether this browser has OPFS (`navigator.storage.getDirectory`).
|
||||
@@ -60,9 +65,17 @@ async fn write_now(rel: &str, data: &[u8]) -> Result<(), JsValue> {
|
||||
|
||||
/// Queue a write of `data` to `rel` (relative to the OPFS root).
|
||||
pub fn write(rel: String, data: Vec<u8>) {
|
||||
write_then(rel, data, None);
|
||||
}
|
||||
|
||||
/// [`write`], then call `done` with whether it succeeded. A write replaced by a newer one to the
|
||||
/// same path reports the newer one's result.
|
||||
pub fn write_then(rel: String, data: Vec<u8>, done: Option<Done>) {
|
||||
let start = QUEUE.with(|q| {
|
||||
let mut q = q.borrow_mut();
|
||||
q.0.insert(rel, data);
|
||||
let e = q.0.entry(rel).or_default();
|
||||
e.0 = data;
|
||||
e.1.extend(done);
|
||||
!std::mem::replace(&mut q.1, true)
|
||||
});
|
||||
if !start {
|
||||
@@ -81,10 +94,14 @@ pub fn write(rel: String, data: Vec<u8>) {
|
||||
}
|
||||
}
|
||||
});
|
||||
let Some((rel, data)) = next else { break };
|
||||
if let Err(e) = write_now(&rel, &data).await {
|
||||
let Some((rel, (data, done))) = next else { break };
|
||||
let r = write_now(&rel, &data).await;
|
||||
if let Err(e) = &r {
|
||||
log::warn!("OPFS write {rel}: {e:?}");
|
||||
}
|
||||
for d in done {
|
||||
d(r.is_ok());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,24 +6,32 @@
|
||||
//! - Imported media are copied to `media/<name>` (the browser streams the copy; nothing goes
|
||||
//! through wasm memory), so the snapshot's `/files/<name>` references resolve after a reload.
|
||||
//! - At start-up a dirty snapshot is reopened (`?norecover` skips it, `?fresh` also skips the media).
|
||||
//! A skipped snapshot stays dirty until this session writes its own ([`crate::recovery_policy`]).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use filmcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::opfs;
|
||||
use crate::recovery_policy::{Action, Policy};
|
||||
|
||||
const SNAPSHOT: &str = "recovery/snapshot.fcproj";
|
||||
const META: &str = "recovery/meta.json";
|
||||
/// Seconds between snapshots of a changing project.
|
||||
const INTERVAL_S: f64 = 5.0;
|
||||
/// Media larger than this are not copied to OPFS.
|
||||
const KEEP_MAX: f64 = 4.0 * 1024.0 * 1024.0 * 1024.0;
|
||||
|
||||
/// A finished OPFS write, reported back to the next [`Autosave::tick`].
|
||||
enum Outcome {
|
||||
Snapshot { revision: u64, ok: bool },
|
||||
Clean { ok: bool },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Autosave {
|
||||
last_revision: u64,
|
||||
last_time: f64,
|
||||
last_dirty: Option<bool>,
|
||||
policy: Policy,
|
||||
outcomes: Rc<RefCell<Vec<Outcome>>>,
|
||||
}
|
||||
|
||||
impl Autosave {
|
||||
@@ -33,22 +41,44 @@ impl Autosave {
|
||||
return;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
let dirty = s.revision != s.saved_revision;
|
||||
if dirty {
|
||||
if s.revision != self.last_revision && now - self.last_time >= INTERVAL_S {
|
||||
opfs::write(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false));
|
||||
let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()});
|
||||
opfs::write(META.into(), meta.to_string().into_bytes());
|
||||
self.last_revision = s.revision;
|
||||
self.last_time = now;
|
||||
} else if s.revision != self.last_revision {
|
||||
// the UI may go idle: wake up when the snapshot is due
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64((INTERVAL_S - (now - self.last_time)).max(0.05)));
|
||||
for o in self.outcomes.borrow_mut().drain(..) {
|
||||
match o {
|
||||
Outcome::Snapshot { revision, ok } => self.policy.written(revision, ok, now),
|
||||
Outcome::Clean { ok } => self.policy.cleaned(ok, now),
|
||||
}
|
||||
}
|
||||
match self.policy.tick(now, s.revision, s.saved_revision) {
|
||||
Action::Nothing => {}
|
||||
Action::WakeAfter(secs) => ctx.request_repaint_after(std::time::Duration::from_secs_f64(secs)),
|
||||
Action::WriteSnapshot(revision) => {
|
||||
// Snapshot and meta both have to land; either failing retries the pair.
|
||||
let failed = Rc::new(Cell::new(false));
|
||||
let left = Rc::new(Cell::new(2u8));
|
||||
let done = |out: Rc<RefCell<Vec<Outcome>>>, failed: Rc<Cell<bool>>, left: Rc<Cell<u8>>, ctx: egui::Context| -> opfs::Done {
|
||||
Box::new(move |ok| {
|
||||
failed.set(failed.get() || !ok);
|
||||
left.set(left.get().saturating_sub(1));
|
||||
if left.get() == 0 {
|
||||
out.borrow_mut().push(Outcome::Snapshot { revision, ok: !failed.get() });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
})
|
||||
};
|
||||
let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()});
|
||||
let d1 = done(self.outcomes.clone(), failed.clone(), left.clone(), ctx.clone());
|
||||
let d2 = done(self.outcomes.clone(), failed, left, ctx.clone());
|
||||
opfs::write_then(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false), Some(d1));
|
||||
opfs::write_then(META.into(), meta.to_string().into_bytes(), Some(d2));
|
||||
}
|
||||
Action::WriteClean => {
|
||||
let (out, ctx) = (self.outcomes.clone(), ctx.clone());
|
||||
let done: opfs::Done = Box::new(move |ok| {
|
||||
out.borrow_mut().push(Outcome::Clean { ok });
|
||||
ctx.request_repaint();
|
||||
});
|
||||
opfs::write_then(META.into(), json!({"dirty": false}).to_string().into_bytes(), Some(done));
|
||||
}
|
||||
} else if self.last_dirty != Some(false) {
|
||||
opfs::write(META.into(), json!({"dirty": false}).to_string().into_bytes());
|
||||
}
|
||||
self.last_dirty = Some(dirty);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! When the web recovery snapshot is written or marked clean (pure, so it is tested natively in
|
||||
//! `tests/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]).
|
||||
//!
|
||||
//! - A dirty project is snapshotted at most every [`INTERVAL_S`] seconds, once per revision. A write
|
||||
//! that fails is retried after the interval, without waiting for another edit (#197).
|
||||
//! - The meta is marked clean only to retire a snapshot this session wrote. A snapshot left by an
|
||||
//! earlier session that was not reopened (`?norecover`, `?fresh`) stays dirty (#193).
|
||||
|
||||
/// Seconds between snapshots of a changing project.
|
||||
pub const INTERVAL_S: f64 = 5.0;
|
||||
|
||||
/// What the caller should do this frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
Nothing,
|
||||
/// Write the snapshot and a dirty meta for this revision, then report with [`Policy::written`].
|
||||
WriteSnapshot(u64),
|
||||
/// Write a clean meta, then report with [`Policy::cleaned`].
|
||||
WriteClean,
|
||||
/// Nothing now; wake up after this many seconds.
|
||||
WakeAfter(f64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Policy {
|
||||
/// Revision whose snapshot was last written or is being written.
|
||||
last_revision: Option<u64>,
|
||||
last_time: Option<f64>,
|
||||
/// This session wrote a dirty meta that is not yet retired.
|
||||
owns_dirty_meta: bool,
|
||||
/// A clean-meta write is in flight.
|
||||
cleaning: bool,
|
||||
/// Snapshot writes started so far, and how many had started when the clean write did (a
|
||||
/// snapshot started after it makes the meta dirty again).
|
||||
snapshots: u64,
|
||||
snapshots_at_clean: u64,
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
/// Called every frame with the session's revisions and the time in seconds.
|
||||
pub fn tick(&mut self, now: f64, revision: u64, saved_revision: u64) -> Action {
|
||||
if revision != saved_revision {
|
||||
if self.last_revision == Some(revision) {
|
||||
return Action::Nothing;
|
||||
}
|
||||
let since = self.last_time.map_or(f64::INFINITY, |t| now - t);
|
||||
if since >= INTERVAL_S {
|
||||
self.last_revision = Some(revision);
|
||||
self.last_time = Some(now);
|
||||
self.owns_dirty_meta = true;
|
||||
self.snapshots += 1;
|
||||
return Action::WriteSnapshot(revision);
|
||||
}
|
||||
return Action::WakeAfter((INTERVAL_S - since).max(0.05));
|
||||
}
|
||||
if self.owns_dirty_meta && !self.cleaning {
|
||||
let since = self.last_time.map_or(f64::INFINITY, |t| now - t);
|
||||
if self.last_revision.is_some() || since >= INTERVAL_S {
|
||||
self.cleaning = true;
|
||||
self.snapshots_at_clean = self.snapshots;
|
||||
return Action::WriteClean;
|
||||
}
|
||||
return Action::WakeAfter((INTERVAL_S - since).max(0.05));
|
||||
}
|
||||
Action::Nothing
|
||||
}
|
||||
|
||||
/// The snapshot write for `revision` finished.
|
||||
pub fn written(&mut self, revision: u64, ok: bool, now: f64) {
|
||||
if !ok && self.last_revision == Some(revision) {
|
||||
// retry after the interval, even if nothing else changes
|
||||
self.last_revision = None;
|
||||
self.last_time = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The clean-meta write finished.
|
||||
pub fn cleaned(&mut self, ok: bool, now: f64) {
|
||||
self.cleaning = false;
|
||||
if ok {
|
||||
self.owns_dirty_meta = self.snapshots != self.snapshots_at_clean;
|
||||
} else {
|
||||
// retry after the interval
|
||||
self.last_revision = None;
|
||||
self.last_time = Some(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,7 @@
|
||||
//! the job on a later frame, when the decoder's output callback has delivered the picture.
|
||||
//! - A session that has not delivered the frame it was started for is not restarted by other
|
||||
//! requests (thumbnails and the monitor would otherwise keep resetting each other).
|
||||
//! - Outputs are converted to RGBA by drawing the `VideoFrame` on an `OffscreenCanvas` (the
|
||||
//! browser applies the stream's colour description).
|
||||
//! - Native YUV planes feed the desktop frame/compositor contract. Unsupported browser formats or geometry use Canvas RGBA.
|
||||
//! - Any decoder error or unsupported configuration switches the source to our decoder.
|
||||
//!
|
||||
//! `?nowebcodecs` disables it. Support is probed at start-up with `VideoDecoder.isConfigSupported`.
|
||||
@@ -34,6 +33,20 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
mod pixels;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Transfers {
|
||||
native: u64,
|
||||
canvas: u64,
|
||||
closed: u64,
|
||||
stale: u64,
|
||||
bytes: u64,
|
||||
native_ms: f64,
|
||||
canvas_ms: f64,
|
||||
reasons: BTreeMap<&'static str, u64>,
|
||||
}
|
||||
|
||||
/// Samples fed past the wanted one (decoders hold pictures for reordering).
|
||||
const LOOKAHEAD: usize = 8;
|
||||
/// Decoded frames kept per source.
|
||||
@@ -52,6 +65,7 @@ thread_local! {
|
||||
static DECODED: Cell<u64> = const { Cell::new(0) };
|
||||
static FALLBACKS: Cell<u64> = const { Cell::new(0) };
|
||||
static SOURCES: Cell<u64> = const { Cell::new(0) };
|
||||
static TRANSFERS: RefCell<Transfers> = RefCell::new(Transfers::default());
|
||||
}
|
||||
|
||||
fn now_ms() -> f64 {
|
||||
@@ -160,11 +174,15 @@ pub fn stats() -> serde_json::Value {
|
||||
.iter()
|
||||
.map(|(id, s)| {
|
||||
json!({"id": id, "start": s.start, "next": s.next, "target": s.target, "needsKey": s.needs_key, "flushing": s.flushing.get(),
|
||||
"errored": s.errored.get(), "queue": s.queue(), "idleMs": now_ms() - s.last_output.get()})
|
||||
"errored": s.errored.get(), "queue": s.queue(), "copies": s.copying.get(), "idleMs": now_ms() - s.last_output.get()})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions})
|
||||
let transfers = TRANSFERS.with(|s| {
|
||||
let s = s.borrow();
|
||||
json!({"nativeFrames":s.native,"canvasFrames":s.canvas,"closedFrames":s.closed,"staleFrames":s.stale,"nativeBytes":s.bytes,"nativeMs":s.native_ms,"canvasMs":s.canvas_ms,"canvasReasons":s.reasons})
|
||||
});
|
||||
json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions,"transfers":transfers})
|
||||
}
|
||||
|
||||
/// A [`SharedReader`] as the demuxer's byte source.
|
||||
@@ -210,6 +228,8 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
|
||||
});
|
||||
SOURCES.with(|c| c.set(c.get() + 1));
|
||||
log::info!("{name}: video decoded with WebCodecs ({codec})");
|
||||
let color = info.video.as_ref().map(|v| v.color).unwrap_or_default();
|
||||
let par = info.video.as_ref().map(|v| v.par).unwrap_or((1, 1));
|
||||
Some(Ok(Arc::new(WcSource {
|
||||
inner,
|
||||
info,
|
||||
@@ -217,11 +237,12 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
|
||||
file,
|
||||
track,
|
||||
id,
|
||||
config: Config { codec, description, width: w, height: h, rotation },
|
||||
config: Config { codec, description, width: w, height: h, rotation, color, par },
|
||||
state: Mutex::new(Cache::default()),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Config {
|
||||
codec: String,
|
||||
description: Option<Vec<u8>>,
|
||||
@@ -229,6 +250,8 @@ struct Config {
|
||||
height: u32,
|
||||
/// Clockwise quarter turns from the track matrix, applied to decoded frames.
|
||||
rotation: u8,
|
||||
color: filmcraft_color::ColorInfo,
|
||||
par: (u32, u32),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -253,6 +276,9 @@ struct Session {
|
||||
/// Highest presentation time output since the session (re)started.
|
||||
out_max: Rc<Cell<i64>>,
|
||||
out: Rc<RefCell<Vec<(i64, VideoFrame)>>>,
|
||||
copying: Rc<Cell<u32>>,
|
||||
generation: Rc<Cell<u64>>,
|
||||
alive: Rc<Cell<bool>>,
|
||||
_closures: (Closure<dyn FnMut(JsValue)>, Closure<dyn FnMut(JsValue)>),
|
||||
}
|
||||
|
||||
@@ -286,17 +312,65 @@ impl Session {
|
||||
let errored = Rc::new(Cell::new(false));
|
||||
let last_output = Rc::new(Cell::new(now_ms()));
|
||||
let out_max = Rc::new(Cell::new(i64::MIN));
|
||||
let copying = Rc::new(Cell::new(0u32));
|
||||
let generation = Rc::new(Cell::new(0u64));
|
||||
let alive = Rc::new(Cell::new(true));
|
||||
let (o, lo, om) = (out.clone(), last_output.clone(), out_max.clone());
|
||||
let (copies, epoch, active, config, errors) = (copying.clone(), generation.clone(), alive.clone(), cfg.clone(), errored.clone());
|
||||
let on_output = Closure::<dyn FnMut(JsValue)>::new(move |frame: JsValue| {
|
||||
let ts = get(&frame, "timestamp").as_f64().unwrap_or(0.0) as i64;
|
||||
om.set(om.get().max(ts));
|
||||
if let Some(f) = to_rgba(&frame) {
|
||||
o.borrow_mut().push((ts, f));
|
||||
DECODED.with(|c| c.set(c.get() + 1));
|
||||
}
|
||||
let _ = call(&frame, "close", &[]);
|
||||
lo.set(now_ms());
|
||||
crate::repaint();
|
||||
let expected = epoch.get();
|
||||
let (o, lo, om, copies, epoch, active, config, errors) =
|
||||
(o.clone(), lo.clone(), om.clone(), copies.clone(), epoch.clone(), active.clone(), config.clone(), errors.clone());
|
||||
copies.set(copies.get().saturating_add(1));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let begin = now_ms();
|
||||
let converted = match pixels::read(&frame, &config).await {
|
||||
Ok((f, bytes)) => {
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.native = s.native.saturating_add(1);
|
||||
s.bytes = s.bytes.saturating_add(bytes as u64);
|
||||
s.native_ms += now_ms() - begin;
|
||||
});
|
||||
Some(f)
|
||||
}
|
||||
Err(reason) => {
|
||||
let begin = now_ms();
|
||||
let f = to_rgba(&frame);
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.canvas = s.canvas.saturating_add(1);
|
||||
s.canvas_ms += now_ms() - begin;
|
||||
let count = s.reasons.entry(reason).or_default();
|
||||
*count = count.saturating_add(1);
|
||||
});
|
||||
f
|
||||
}
|
||||
};
|
||||
let _ = call(&frame, "close", &[]);
|
||||
copies.set(copies.get().saturating_sub(1));
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.closed = s.closed.saturating_add(1);
|
||||
});
|
||||
if !active.get() || epoch.get() != expected {
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.stale = s.stale.saturating_add(1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Some(f) = converted {
|
||||
o.borrow_mut().push((ts, f));
|
||||
om.set(om.get().max(ts));
|
||||
DECODED.with(|c| c.set(c.get().saturating_add(1)));
|
||||
} else {
|
||||
errors.set(true);
|
||||
}
|
||||
lo.set(now_ms());
|
||||
crate::repaint();
|
||||
});
|
||||
});
|
||||
let e = errored.clone();
|
||||
let on_error = Closure::<dyn FnMut(JsValue)>::new(move |err: JsValue| {
|
||||
@@ -317,6 +391,9 @@ impl Session {
|
||||
last_output,
|
||||
out_max,
|
||||
out,
|
||||
copying,
|
||||
generation,
|
||||
alive,
|
||||
_closures: (on_output, on_error),
|
||||
};
|
||||
s.configure(cfg)?;
|
||||
@@ -341,6 +418,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
self.alive.set(false);
|
||||
self.generation.set(self.generation.get().wrapping_add(1));
|
||||
let _ = call(&self.decoder, "close", &[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// An MP4/MOV source whose video is decoded by the browser.
|
||||
pub struct WcSource {
|
||||
inner: SharedSource,
|
||||
@@ -388,12 +473,15 @@ impl WcSource {
|
||||
// already output but the cache dropped needs a restart).
|
||||
let covers = !s.needs_key && s.start <= i && key <= s.next && (i >= s.next || want > s.out_max.get());
|
||||
if !covers {
|
||||
// Don't interrupt a session that is still working towards its own frame.
|
||||
let busy = !s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target && now_ms() - s.last_output.get() < STALE_MS;
|
||||
// Don't interrupt a session that is still working towards its own frame, unless it
|
||||
// has gone quiet for STALE_MS (a copyTo that never settles must not wedge it).
|
||||
let fresh = now_ms() - s.last_output.get() < STALE_MS;
|
||||
let busy = fresh && (s.copying.get() > 0 || (!s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target));
|
||||
if busy {
|
||||
return Drive::Waiting;
|
||||
}
|
||||
if !s.needs_key || s.next > 0 {
|
||||
s.generation.set(s.generation.get().wrapping_add(1));
|
||||
let _ = call(&s.decoder, "reset", &[]);
|
||||
if s.configure(&self.config).is_err() {
|
||||
return Drive::Failed;
|
||||
@@ -410,7 +498,7 @@ impl WcSource {
|
||||
s.target = want;
|
||||
}
|
||||
let upto = (i + LOOKAHEAD).min(n - 1);
|
||||
while s.next <= upto && s.queue() < MAX_QUEUE {
|
||||
while s.next <= upto && s.queue().saturating_add(s.copying.get()) < MAX_QUEUE {
|
||||
let smp = &t.samples[s.next];
|
||||
let mut data = vec![0u8; smp.size as usize];
|
||||
match self.reader.read_at(smp.offset, &mut data) {
|
||||
@@ -480,10 +568,8 @@ impl WcSource {
|
||||
impl Drop for WcSource {
|
||||
fn drop(&mut self) {
|
||||
SESSIONS.with(|ss| {
|
||||
if let Ok(mut ss) = ss.try_borrow_mut()
|
||||
&& let Some(s) = ss.remove(&self.id)
|
||||
{
|
||||
let _ = call(&s.decoder, "close", &[]);
|
||||
if let Ok(mut ss) = ss.try_borrow_mut() {
|
||||
ss.remove(&self.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Browser planes -> core frames; no color conversion or chroma reconstruction here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
|
||||
use filmcraft_time::Tick;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use super::{Config, call, get, obj};
|
||||
|
||||
// One 8K 4:4:4 16-bit picture + alpha fits within 256 MiB; cap browser-driven allocations.
|
||||
const MAX_COPY_BYTES: u32 = 256 * 1024 * 1024;
|
||||
|
||||
fn integer(value: JsValue) -> Option<u32> {
|
||||
let n = value.as_f64()?;
|
||||
if n.is_finite() && n >= 0.0 && n <= u32::MAX as f64 && n.fract() == 0.0 { Some(n as u32) } else { None }
|
||||
}
|
||||
|
||||
fn color(frame: &JsValue, fallback: ColorInfo) -> Option<ColorInfo> {
|
||||
let c = get(frame, "colorSpace");
|
||||
let matrix = match get(&c, "matrix").as_string().as_deref() {
|
||||
Some("bt709") => Matrix::Bt709,
|
||||
Some("bt470bg" | "smpte170m") => Matrix::Bt601,
|
||||
Some("bt2020-ncl") => Matrix::Bt2020Ncl,
|
||||
None => fallback.matrix,
|
||||
_ => return None,
|
||||
};
|
||||
let transfer = match get(&c, "transfer").as_string().as_deref() {
|
||||
Some("bt709") => Transfer::Bt709,
|
||||
Some("iec61966-2-1") => Transfer::Srgb,
|
||||
Some("smpte2084") => Transfer::Pq,
|
||||
Some("arib-std-b67") => Transfer::Hlg,
|
||||
None => fallback.transfer,
|
||||
_ => return None,
|
||||
};
|
||||
let primaries = match get(&c, "primaries").as_string().as_deref() {
|
||||
Some("bt709") => Primaries::Bt709,
|
||||
Some("bt470bg") => Primaries::Bt601_625,
|
||||
Some("smpte170m") => Primaries::Bt601_525,
|
||||
Some("bt2020") => Primaries::Bt2020,
|
||||
None => fallback.primaries,
|
||||
_ => return None,
|
||||
};
|
||||
let range = match get(&c, "fullRange").as_bool() {
|
||||
Some(true) => Range::Full,
|
||||
Some(false) => Range::Limited,
|
||||
None => fallback.range,
|
||||
};
|
||||
Some(ColorInfo { matrix, transfer, primaries, range })
|
||||
}
|
||||
|
||||
fn plane(raw: &js_sys::Uint8Array, layout: &JsValue, width: u32, height: u32) -> Result<Vec<u8>, &'static str> {
|
||||
let offset = integer(get(layout, "offset")).ok_or("layout")?;
|
||||
let stride = integer(get(layout, "stride")).filter(|s| *s >= width).ok_or("layout")?;
|
||||
let end =
|
||||
height.checked_sub(1).and_then(|h| h.checked_mul(stride)).and_then(|n| offset.checked_add(n)).and_then(|n| n.checked_add(width)).ok_or("layout")?;
|
||||
if end > raw.length() {
|
||||
return Err("layout");
|
||||
}
|
||||
let len = width.checked_mul(height).filter(|n| *n <= MAX_COPY_BYTES).ok_or("allocation")? as usize;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(len).map_err(|_| "memory")?;
|
||||
out.resize(len, 0);
|
||||
if stride == width {
|
||||
raw.subarray(offset, end).copy_to(&mut out);
|
||||
} else {
|
||||
for (y, row) in out.chunks_exact_mut(width as usize).enumerate() {
|
||||
// Checked for the last row above; earlier rows are smaller, but stay checked anyway.
|
||||
let start = u32::try_from(y).ok().and_then(|y| y.checked_mul(stride)).and_then(|n| offset.checked_add(n)).ok_or("layout")?;
|
||||
let row_end = start.checked_add(width).filter(|e| *e <= end).ok_or("layout")?;
|
||||
raw.subarray(start, row_end).copy_to(row);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy native 8-bit YUV planes; unsupported formats/geometry return a counted Canvas fallback reason.
|
||||
pub(super) async fn read(frame: &JsValue, cfg: &Config) -> Result<(VideoFrame, u32), &'static str> {
|
||||
let format = get(frame, "format").as_string().ok_or("format")?;
|
||||
let (chroma, alpha, interleaved) = match format.as_str() {
|
||||
"NV12" => (Chroma::C420, false, true),
|
||||
"I420" => (Chroma::C420, false, false),
|
||||
"I420A" => (Chroma::C420, true, false),
|
||||
"I422" => (Chroma::C422, false, false),
|
||||
"I422A" => (Chroma::C422, true, false),
|
||||
"I444" => (Chroma::C444, false, false),
|
||||
"I444A" => (Chroma::C444, true, false),
|
||||
_ => return Err("format"),
|
||||
};
|
||||
let rect = get(frame, "visibleRect");
|
||||
let w = integer(get(&rect, "width")).filter(|n| *n > 0).ok_or("geometry")?;
|
||||
let h = integer(get(&rect, "height")).filter(|n| *n > 0).ok_or("geometry")?;
|
||||
if integer(get(frame, "displayWidth")) != Some(w)
|
||||
|| integer(get(frame, "displayHeight")) != Some(h)
|
||||
|| get(frame, "rotation").as_f64().is_some_and(|r| r != 0.0)
|
||||
|| get(frame, "flip").as_bool() == Some(true)
|
||||
{
|
||||
return Err("geometry");
|
||||
}
|
||||
let color = color(frame, cfg.color).ok_or("color")?;
|
||||
let options = obj(&[("rect", rect)]);
|
||||
let bytes =
|
||||
integer(call(frame, "allocationSize", &[&options]).map_err(|_| "allocation")?).filter(|n| *n > 0 && *n <= MAX_COPY_BYTES).ok_or("allocation")?;
|
||||
let array: js_sys::Function = get(&js_sys::global(), "Uint8Array").dyn_into().map_err(|_| "allocation")?;
|
||||
let raw: js_sys::Uint8Array =
|
||||
js_sys::Reflect::construct(&array, &js_sys::Array::of1(&bytes.into())).and_then(|v| v.dyn_into()).map_err(|_| "allocation")?;
|
||||
let promise = call(frame, "copyTo", &[raw.as_ref(), &options]).map_err(|_| "copy")?.dyn_into::<js_sys::Promise>().map_err(|_| "copy")?;
|
||||
let layout = js_sys::Array::from(&JsFuture::from(promise).await.map_err(|_| "copy")?);
|
||||
let count = if interleaved {
|
||||
2
|
||||
} else if alpha {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
};
|
||||
if layout.length() != count {
|
||||
return Err("layout");
|
||||
}
|
||||
let (sx, sy) = chroma.shifts();
|
||||
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
|
||||
let y = plane(&raw, &layout.get(0), w, h)?;
|
||||
let (u, v) = if interleaved {
|
||||
let uv = plane(&raw, &layout.get(1), cw.checked_mul(2).ok_or("allocation")?, ch)?;
|
||||
let pairs = uv.as_chunks::<2>().0;
|
||||
let (mut u, mut v) = (Vec::new(), Vec::new());
|
||||
u.try_reserve_exact(pairs.len()).map_err(|_| "memory")?;
|
||||
v.try_reserve_exact(pairs.len()).map_err(|_| "memory")?;
|
||||
u.extend(pairs.iter().map(|p| p[0]));
|
||||
v.extend(pairs.iter().map(|p| p[1]));
|
||||
(u, v)
|
||||
} else {
|
||||
(plane(&raw, &layout.get(1), cw, ch)?, plane(&raw, &layout.get(2), cw, ch)?)
|
||||
};
|
||||
let alpha = if alpha { Some(Arc::new(plane(&raw, &layout.get(3), w, h)?)) } else { None };
|
||||
Ok((
|
||||
VideoFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
data: PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma, alpha },
|
||||
color,
|
||||
par: cfg.par,
|
||||
pts: Tick::ZERO,
|
||||
},
|
||||
bytes,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Web import destinations: active bin tab, browser drop + navigation during pending reads.
|
||||
// Synthetic browser drop event exercises the production handler; physical OS drag/picker UI is outside this test.
|
||||
export async function checkImportBins({ evaluate, media, timeoutMs = 120000 }) {
|
||||
const report = {gates: {}, cases: []};
|
||||
await evaluate('filmcraft.execute("file.newProject", {name: "Import bins"})');
|
||||
const parent = (await evaluate('filmcraft.execute("file.newBin", {name: "Footage"})')).bin;
|
||||
const child = (await evaluate(`filmcraft.execute("file.newBin", {name: "Selects", parent: ${parent}})`)).bin;
|
||||
await evaluate(`(async () => {window.__importBinFixture = new Uint8Array(await (await fetch(${JSON.stringify(media)})).arrayBuffer());})()`);
|
||||
const setBin = bin => evaluate(`filmcraft.request("ui.set", {projectPanel: {bin: ${JSON.stringify(bin)}, activeTab: null}})`);
|
||||
const importExpression = name => `filmcraft.importFiles([new File([window.__importBinFixture], ${JSON.stringify(name)}, {type: "video/mp4"})])`;
|
||||
const locate = async name => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const pollMs = 100;
|
||||
for (;;) {
|
||||
const project = await evaluate('filmcraft.execute("project.inspect")');
|
||||
const find = bin => {
|
||||
for (const entry of bin.children) {
|
||||
if (entry.item !== undefined && entry.name === name) return bin.id;
|
||||
if (entry.children) {const found = find(entry); if (found !== undefined) return found;}
|
||||
}
|
||||
};
|
||||
const found = find(project.root);
|
||||
if (found !== undefined) return found;
|
||||
if (Date.now() >= deadline) throw new Error(`Import never appeared: ${name}`);
|
||||
await new Promise(resolve => setTimeout(resolve, pollMs));
|
||||
}
|
||||
};
|
||||
const check = async (key, expected, name) => {
|
||||
const actual = await locate(name);
|
||||
report.cases.push({key, expected, actual, name}); report.gates[key] = actual === expected;
|
||||
};
|
||||
await evaluate(`filmcraft.request("ui.set", {projectPanel: {bin: ${child}, activeTab: 0, tabs: [{bin: ${parent}}]}})`);
|
||||
await evaluate(importExpression("tab.mp4")); await check("activeTab", parent, "tab.mp4");
|
||||
await setBin(child);
|
||||
await evaluate(`(() => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([window.__importBinFixture], "drop.mp4", {type: "video/mp4"}));
|
||||
window.dispatchEvent(new DragEvent("drop", {dataTransfer: transfer, bubbles: true, cancelable: true}));
|
||||
})()`);
|
||||
await check("browserDrop", child, "drop.mp4");
|
||||
await setBin(child);
|
||||
await evaluate(`(() => {
|
||||
const original = Blob.prototype.arrayBuffer;
|
||||
let release;
|
||||
const pending = new Promise(resolve => {release = resolve;});
|
||||
const gate = window.__importBinGate = {hits: 0, release, original};
|
||||
Blob.prototype.arrayBuffer = function () {gate.hits++; return pending.then(() => original.call(this));};
|
||||
window.__pendingBinImport = ${importExpression("pending.mp4")};
|
||||
})()`);
|
||||
try {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const pollMs = 100;
|
||||
for (;;) {
|
||||
if (await evaluate("window.__importBinGate.hits > 0")) break;
|
||||
if (Date.now() >= deadline) throw new Error("Read-delay probe did not intercept any Blob reads");
|
||||
await new Promise(resolve => setTimeout(resolve, pollMs));
|
||||
}
|
||||
report.delayedReadHits = await evaluate("window.__importBinGate.hits");
|
||||
await setBin(parent);
|
||||
await evaluate("window.__importBinGate.release(); window.__pendingBinImport");
|
||||
await check("destinationCapturedBeforeRead", child, "pending.mp4");
|
||||
} finally {
|
||||
await evaluate("Blob.prototype.arrayBuffer = window.__importBinGate.original; window.__importBinGate.release(); delete window.__importBinGate; delete window.__pendingBinImport; delete window.__importBinFixture");
|
||||
}
|
||||
report.ok = Object.values(report.gates).every(Boolean);
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// #194 regression: same-name/same-size imports must retain independent sources across OPFS recovery.
|
||||
// evaluate(expression) awaits a browser expression; navigate(url) waits for FilmCraft readiness. Generated PCM16 mono WAVs only; no binary fixtures.
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function tone(frequency, seconds) {
|
||||
// RIFF/WAVE PCM16 mono: 44-byte header + 2 bytes/sample; fixture matches #194 (48 kHz, amplitude 12000).
|
||||
const rate = 48000, bytesPerSample = 2, samples = rate * seconds;
|
||||
const b = Buffer.alloc(44 + samples * bytesPerSample);
|
||||
b.write("RIFF", 0); b.writeUInt32LE(b.length - 8, 4); b.write("WAVEfmt ", 8);
|
||||
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
|
||||
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * bytesPerSample, 28);
|
||||
b.writeUInt16LE(bytesPerSample, 32); b.writeUInt16LE(16, 34); b.write("data", 36); b.writeUInt32LE(samples * bytesPerSample, 40);
|
||||
for (let i = 0; i < samples; i++) b.writeInt16LE(Math.round(12000 * Math.sin(2 * Math.PI * frequency * i / rate)), 44 + bytesPerSample * i);
|
||||
return b;
|
||||
}
|
||||
|
||||
export async function checkImportCollision({ evaluate, navigate, url, timeoutMs = 120000 }) {
|
||||
const fixtures = [tone(440, 2), tone(880, 2)];
|
||||
const hashes = fixtures.map(b => createHash("sha256").update(b).digest("hex"));
|
||||
const report = { hashes, fixtureBytes: fixtures.map(b => b.length), gates: {} };
|
||||
const hash = expression => evaluate(`(async () => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", ${expression})), b => b.toString(16).padStart(2, "0")).join(""))()`);
|
||||
const readHash = path => hash(`await filmcraft.readFile(${JSON.stringify(path)})`);
|
||||
const importFile = index => evaluate(`filmcraft.importFiles([new File([Uint8Array.from(atob(${JSON.stringify(fixtures[index].toString("base64"))}), c => c.charCodeAt(0))], "collision.wav", {type: "audio/wav"})])`);
|
||||
await evaluate('filmcraft.execute("file.newProject", {name: "Import collision"})');
|
||||
const a = await importFile(0); report.a = a;
|
||||
if (a.errors?.length || !a.items?.length) throw new Error("baseline import failed: " + JSON.stringify(a));
|
||||
report.initialHash = await readHash(a.paths[0]);
|
||||
report.gates.healthyInitial = report.initialHash === hashes[0];
|
||||
await evaluate(`filmcraft.execute("file.newSequence", {name: "Original A", fromItem: ${a.items[0]}})`);
|
||||
const b = await importFile(1); report.b = b;
|
||||
if (b.errors?.length || !b.items?.length) throw new Error("second import failed: " + JSON.stringify(b));
|
||||
report.afterBHash = await readHash(a.paths[0]);
|
||||
report.gates.distinctPaths = a.paths[0] !== b.paths[0];
|
||||
report.gates.originalPreserved = report.afterBHash === hashes[0];
|
||||
report.beforeProject = await evaluate('filmcraft.execute("project.inspect")');
|
||||
const revision = report.beforeProject.revision;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const persistencePollMs = 100;
|
||||
for (;;) {
|
||||
const snapshot = await evaluate(`(async () => {
|
||||
try {
|
||||
const dir = await (await navigator.storage.getDirectory()).getDirectoryHandle("recovery");
|
||||
const read = async name => JSON.parse(await (await (await dir.getFileHandle(name)).getFile()).text());
|
||||
return {meta: await read("meta.json"), project: await read("snapshot.fcproj")};
|
||||
} catch (e) {return {error: String(e)};}
|
||||
})()`);
|
||||
report.recoveryMeta = snapshot.meta;
|
||||
if (snapshot.meta?.dirty && snapshot.meta.revision === revision && JSON.stringify(snapshot.project).includes("Original A")) break;
|
||||
if (Date.now() >= deadline) throw new Error("Recovery persistence gate failed: " + JSON.stringify(snapshot));
|
||||
await new Promise(resolve => setTimeout(resolve, persistencePollMs));
|
||||
}
|
||||
// Media writes run separately from the snapshot queue; gate their actual bytes before navigation.
|
||||
for (;;) {
|
||||
report.opfs = await evaluate(`(async () => {
|
||||
const dir = await (await navigator.storage.getDirectory()).getDirectoryHandle("media");
|
||||
const files = [];
|
||||
for await (const [name, handle] of dir.entries()) {
|
||||
const bytes = await (await handle.getFile()).arrayBuffer();
|
||||
files.push({name, hash: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), b => b.toString(16).padStart(2, "0")).join("")});
|
||||
}
|
||||
return files;
|
||||
})()`);
|
||||
// Before the fix, A and B intentionally share the same path; qualify the last write there.
|
||||
const expected = new Map([[a.paths[0], hashes[0]], [b.paths[0], hashes[1]]]);
|
||||
if ([...expected].every(([path, digest]) => report.opfs.some(f => f.name === path.slice("/files/".length) && f.hash === digest))) break;
|
||||
if (Date.now() >= deadline) throw new Error("Imported media did not reach OPFS: " + JSON.stringify(report.opfs));
|
||||
await new Promise(resolve => setTimeout(resolve, persistencePollMs));
|
||||
}
|
||||
const recoveryUrl = new URL(url);
|
||||
recoveryUrl.searchParams.delete("fresh"); recoveryUrl.searchParams.delete("norecover");
|
||||
await navigate(recoveryUrl.href);
|
||||
report.recovered = await evaluate('filmcraft.execute("project.inspect")');
|
||||
report.afterReload = {a: await readHash(a.paths[0]), b: await readHash(b.paths[0])};
|
||||
report.gates.recoveredProject = JSON.stringify(report.recovered).includes("Original A");
|
||||
report.gates.recoveredSources = report.afterReload.a === hashes[0] && report.afterReload.b === hashes[1];
|
||||
report.ok = Object.values(report.gates).every(Boolean);
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The web recovery snapshot policy. It is pure, so it runs natively even though the rest of the
|
||||
//! crate is wasm32-only.
|
||||
|
||||
#[path = "../src/recovery_policy.rs"]
|
||||
mod recovery_policy;
|
||||
|
||||
use recovery_policy::{Action, INTERVAL_S, Policy};
|
||||
|
||||
/// #197: a snapshot write that fails is retried after the interval without another edit.
|
||||
#[test]
|
||||
fn failed_snapshot_write_is_retried_without_another_edit() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 3, 0), Action::WriteSnapshot(3));
|
||||
p.written(3, false, 0.1);
|
||||
// not immediately (no spinning on a failing disk)…
|
||||
assert!(matches!(p.tick(1.0, 3, 0), Action::WakeAfter(_)));
|
||||
// …but once the interval has passed, with the revision unchanged
|
||||
assert_eq!(p.tick(0.1 + INTERVAL_S, 3, 0), Action::WriteSnapshot(3));
|
||||
p.written(3, true, 5.2);
|
||||
assert_eq!(p.tick(20.0, 3, 0), Action::Nothing);
|
||||
}
|
||||
|
||||
/// A failure reported for an older revision doesn't make the newer one be written again.
|
||||
#[test]
|
||||
fn stale_failure_is_ignored() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
assert_eq!(p.tick(INTERVAL_S, 2, 0), Action::WriteSnapshot(2));
|
||||
p.written(1, false, INTERVAL_S);
|
||||
assert_eq!(p.tick(3.0 * INTERVAL_S, 2, 0), Action::Nothing);
|
||||
}
|
||||
|
||||
/// #193: a clean session (started with `?norecover` / `?fresh`, so the snapshot left by an
|
||||
/// earlier session was not reopened) never marks that snapshot clean.
|
||||
#[test]
|
||||
fn clean_start_leaves_an_unconsumed_snapshot_dirty() {
|
||||
let mut p = Policy::default();
|
||||
for t in 0..100 {
|
||||
assert_eq!(p.tick(t as f64, 0, 0), Action::Nothing);
|
||||
}
|
||||
}
|
||||
|
||||
/// The snapshot this session wrote is retired once the project is saved.
|
||||
#[test]
|
||||
fn saving_marks_own_snapshot_clean() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
p.written(1, true, 0.1);
|
||||
assert_eq!(p.tick(1.0, 1, 1), Action::WriteClean);
|
||||
assert_eq!(p.tick(1.1, 1, 1), Action::Nothing);
|
||||
p.cleaned(true, 1.2);
|
||||
assert_eq!(p.tick(30.0, 1, 1), Action::Nothing);
|
||||
}
|
||||
|
||||
/// A failed clean write is retried after the interval.
|
||||
#[test]
|
||||
fn failed_clean_write_is_retried() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
p.written(1, true, 0.1);
|
||||
assert_eq!(p.tick(1.0, 1, 1), Action::WriteClean);
|
||||
p.cleaned(false, 1.1);
|
||||
assert!(matches!(p.tick(2.0, 1, 1), Action::WakeAfter(_)));
|
||||
assert_eq!(p.tick(1.1 + INTERVAL_S, 1, 1), Action::WriteClean);
|
||||
}
|
||||
|
||||
/// An edit made while the clean write is in flight keeps the meta dirty after it lands.
|
||||
#[test]
|
||||
fn edit_during_clean_write_keeps_meta_owned() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
assert_eq!(p.tick(10.0, 1, 1), Action::WriteClean);
|
||||
assert_eq!(p.tick(20.0, 2, 1), Action::WriteSnapshot(2));
|
||||
p.cleaned(true, 20.1);
|
||||
// saved again: the newer snapshot must be retired too
|
||||
assert_eq!(p.tick(30.0, 2, 2), Action::WriteClean);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { checkImportBins } from "./import-bins.mjs";
|
||||
import { checkImportCollision } from "./import-collision.mjs";
|
||||
|
||||
const arg = (k, d) => {
|
||||
const i = process.argv.indexOf(`--${k}`);
|
||||
@@ -162,6 +164,19 @@ try {
|
||||
await sleep(500);
|
||||
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
report.steps.modes = { screenshot: await shot("05-modes") };
|
||||
report.steps.importBins = await checkImportBins({evaluate: js, media});
|
||||
if (!report.steps.importBins.ok) throw new Error("import bin regression: " + JSON.stringify(report.steps.importBins.gates));
|
||||
report.steps.importCollision = await checkImportCollision({
|
||||
evaluate: js,
|
||||
url,
|
||||
navigate: async (nextUrl) => {
|
||||
await send("Page.navigate", { url: nextUrl });
|
||||
await until("!!(window.filmcraftLoad && (window.filmcraftLoad.readyMs || window.filmcraftLoad.error))", 120000);
|
||||
const load = await js("window.filmcraftLoad");
|
||||
if (load.error) throw new Error(load.error);
|
||||
},
|
||||
});
|
||||
if (!report.steps.importCollision.ok) throw new Error("import collision regression: " + JSON.stringify(report.steps.importCollision.gates));
|
||||
// still alive? (a panicked app never answers)
|
||||
await Promise.race([js("filmcraft.inspect().then(() => true)"), sleep(5000).then(() => { throw new Error("app stopped answering"); })]);
|
||||
const fatal = logs.filter((l) => /panicked at|RuntimeError|EXCEPTION/.test(l));
|
||||
|
||||
@@ -26,6 +26,7 @@ log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = "0.16"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = "0.21"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
|
||||
/// Restrict the wgpu instance to the primary backends (Vulkan, DirectX 12, Metal), i.e. leave
|
||||
/// OpenGL out, unless `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`)
|
||||
/// names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
|
||||
// out of the instance avoids it. Keep Vulkan and DX12 both: forcing DX12 alone sends
|
||||
// shaders through FXC (which rejects fx.wgsl, silently falling back to the CPU) and
|
||||
// leaves machines without DX12 with no backend at all.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
create.instance_descriptor.backends
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_leaves_out_gl() {
|
||||
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
|
||||
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
let configured = configured_backends(None);
|
||||
assert_eq!(configured, Backends::PRIMARY);
|
||||
assert!(!configured.contains(Backends::GL));
|
||||
assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
assert_eq!(configured_backends(Some(backends)), backends);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! Without a logger every `log::warn!`/`log::error!` in the workspace (GPU compositor fallbacks,
|
||||
//! audio output problems, a panicked frame job or audio mixer, container warnings) vanished. A
|
||||
//! launch from a desktop menu has no terminal, so the file is what a bug report can attach:
|
||||
//! `<data dir>/Logs/filmcraft.log`, next to the crash logs (`--data-dir`, `FILMCRAFT_DATA_DIR` or
|
||||
//! `filmcraft_engine::autosave::default_data_dir`; Help ▸ Reveal Log Files opens the folder). Each
|
||||
//! start moves the previous log to `filmcraft.1.log` (and that one to `.2`), so the log of a run
|
||||
//! that crashed survives the next launch.
|
||||
//!
|
||||
//! Levels: `info` for FilmCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
|
||||
//! `warn,filmcraft_codecs=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
|
||||
//! that starts with it (`filmcraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the data directory is known are kept (up to [`MAX_PENDING`]) and written
|
||||
//! once the file is attached. Writing never panics: a file that can't be created or written leaves
|
||||
//! standard error as the only sink.
|
||||
//!
|
||||
//! Realtime audio: the cpal callbacks (`audio.rs`, `audio_in.rs`, the play-ahead reader) do not
|
||||
//! log; they report through flags and a one-time `eprintln!`. A record takes the sink lock only for
|
||||
//! one `write_all` of an already formatted line (standard error is written outside the lock), so a
|
||||
//! logging thread such as the audio mixer never waits on more than one other line being written.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log directory.
|
||||
pub const LOG_FILE: &str = "filmcraft.log";
|
||||
/// The log directory inside the data directory (shared with the crash logs and Reveal Log Files).
|
||||
pub const LOG_DIR: &str = "Logs";
|
||||
/// How many previous logs are kept (`filmcraft.1.log` … `filmcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,filmcraft*=info";
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("filmcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file`, first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// No file will come: drop the kept lines and keep none from now on.
|
||||
pub fn close(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink (and
|
||||
// stop buffering, since no file will come).
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
||||
/// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]).
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
self.stderr_only();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file this run (no data directory, or it can't be used): stop keeping records for one.
|
||||
pub fn stderr_only(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).close();
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Format before taking the lock, so the lock covers one write only.
|
||||
let thread = std::thread::current();
|
||||
let line = format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] once the data directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Log;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("filmcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_filmcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("filmcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("filmcraft::audio"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("filmcraft_ui_egui::play_ahead"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("filmcraft_codecs::mxf"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,filmcraft_codecs=trace,filmcraft_codecs::mxf=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("filmcraft_codecs::ogg"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("filmcraft_codecs::mxf"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("warn,filmcraft_ui*=debug").level_for("filmcraft_ui_egui::frames"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("warn,filmcraft_ui*=debug").level_for("filmcraft_engine"), LevelFilter::Warn);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive; the last bare level wins.
|
||||
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "a".repeat(100_000);
|
||||
let specs = [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"filmcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=debug",
|
||||
"a=b=c",
|
||||
"warn,,filmcraft=DEBUG",
|
||||
" \t ",
|
||||
"\u{0}",
|
||||
"é*=trace",
|
||||
&long,
|
||||
];
|
||||
for spec in specs {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["filmcraft", "", "🦀", "é", "a::b"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,filmcraft=DEBUG").level_for("filmcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
|
||||
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
|
||||
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("filmcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("filmcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("filmcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
assert!(rotate(&dir.join("below")).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "filmcraft_ui_egui", "audio output unavailable: no output device"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] filmcraft_ui_egui: audio output unavailable: no output device\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_max_pending_lines_drop_nothing() {
|
||||
let dir = temp_dir("exact");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "filmcraft", "FilmCraft starting");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Debug, "filmcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("filmcraft: FilmCraft starting")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("filmcraft_engine::autosave").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// A second launch rotates: the first log becomes `.1`.
|
||||
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
|
||||
assert_eq!(read(&dir.join("filmcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..4 {
|
||||
s.spawn(|| {
|
||||
for _ in 0..100 {
|
||||
record(&logger, log::Level::Info, "filmcraft", "from a worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 400);
|
||||
assert!(text.lines().all(|l| l.ends_with("filmcraft: from a worker")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
|
||||
assert!(e.contains(&dir.display().to_string()), "{e}");
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_lock_does_not_stop_logging() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = logger.sink.lock();
|
||||
panic!("poison the sink");
|
||||
}));
|
||||
assert!(logger.sink.is_poisoned());
|
||||
record(&logger, log::Level::Info, "filmcraft", "after the panic");
|
||||
assert!(read(&path).contains("after the panic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+69
-16
@@ -16,6 +16,9 @@
|
||||
//! with unsaved changes the app asks to recover them; `--recover` recovers the newest without
|
||||
//! asking, `--no-recover` starts without asking (the changes stay available via File ▸ Recover
|
||||
//! Unsaved Changes…).
|
||||
//!
|
||||
//! `log` records go to standard error and `<data dir>/Logs/filmcraft.log` (`RUST_LOG` sets the
|
||||
//! levels); see [`logging`].
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
@@ -24,6 +27,9 @@ mod args;
|
||||
mod audio;
|
||||
mod audio_in;
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod window_raise;
|
||||
@@ -66,6 +72,8 @@ fn tell(text: &str, error: bool) {
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up record are logged (`logging`).
|
||||
let logger = logging::install();
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
|
||||
let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) {
|
||||
@@ -83,10 +91,24 @@ fn main() -> eframe::Result {
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let log_dir = data_dir.clone().or_else(default_data_dir).map(|d| d.join(logging::LOG_DIR));
|
||||
log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
// The log file lives next to the crash logs; opened after the arguments, so `--help`,
|
||||
// `--version` and usage errors leave no file behind. Records logged until now go in first.
|
||||
if let Some(logger) = logger {
|
||||
match log_dir.as_deref().map(|d| logger.attach_dir(d)) {
|
||||
Some(Ok(path)) => log::info!("log file {}", path.display()),
|
||||
Some(Err(e)) => log::warn!("no log file: {e}"),
|
||||
None => {
|
||||
logger.stderr_only();
|
||||
log::warn!("no log file: no data directory (set FILMCRAFT_DATA_DIR or --data-dir)");
|
||||
}
|
||||
}
|
||||
}
|
||||
app_nap::disable();
|
||||
// Panics anywhere go to <data dir>/Logs/crash-<day>.log with a backtrace; the UI pass and
|
||||
// frame workers catch them and keep running (see filmcraft_ui_egui::crash).
|
||||
filmcraft_ui_egui::crash::install(data_dir.clone().or_else(default_data_dir).map(|d| d.join("Logs")));
|
||||
filmcraft_ui_egui::crash::install(log_dir);
|
||||
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
|
||||
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
|
||||
register_hardware_decoders();
|
||||
@@ -107,6 +129,13 @@ fn main() -> eframe::Result {
|
||||
event_loop_builder: agent_event_loop(control_port.is_some()),
|
||||
..Default::default()
|
||||
};
|
||||
// Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
options
|
||||
};
|
||||
let started = eframe::run_native(
|
||||
"FilmCraft",
|
||||
options,
|
||||
@@ -116,7 +145,7 @@ fn main() -> eframe::Result {
|
||||
let mut cfg = AutosaveConfig::new(dir);
|
||||
cfg.local_offset = local_offset;
|
||||
if let Err(e) = session.start_autosave(cfg) {
|
||||
eprintln!("filmcraft: auto-save and crash recovery unavailable: {e}");
|
||||
log::warn!("auto-save and crash recovery unavailable: {e}");
|
||||
}
|
||||
}
|
||||
// voice-over recording reads the microphone through cpal
|
||||
@@ -124,7 +153,7 @@ fn main() -> eframe::Result {
|
||||
let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned();
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
eprintln!("filmcraft: {e}");
|
||||
log::error!("{e}");
|
||||
}
|
||||
} else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" {
|
||||
// Settings ▸ General ▸ At Startup ▸ Open Most Recent
|
||||
@@ -132,7 +161,7 @@ fn main() -> eframe::Result {
|
||||
match recent {
|
||||
Some(p) => {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
eprintln!("filmcraft: {e}");
|
||||
log::error!("{e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -150,8 +179,8 @@ fn main() -> eframe::Result {
|
||||
if recover == Some(true) && !session.recovery_candidates().is_empty() {
|
||||
let id = session.recovery_candidates()[0].id.clone();
|
||||
match session.execute("file.recover", json!({"id": id})) {
|
||||
Ok(r) => eprintln!("filmcraft: recovered {r}"),
|
||||
Err(e) => eprintln!("filmcraft: recovery failed: {e}"),
|
||||
Ok(r) => log::info!("recovered {r}"),
|
||||
Err(e) => log::error!("recovery failed: {e}"),
|
||||
}
|
||||
}
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
@@ -168,27 +197,48 @@ fn main() -> eframe::Result {
|
||||
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`).
|
||||
app.audio = Some(Box::new(audio::CpalOut::new()));
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), exts)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
}));
|
||||
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
|
||||
app.hooks.pick_file_for_relink =
|
||||
Some(Box::new(|exts: &[&str], _hint| rfd::FileDialog::new().add_filter("Media", exts).pick_file().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
|
||||
rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t("Media"), exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
|
||||
rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), exts)
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.open_path = Some(Box::new(open_path));
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
|
||||
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
|
||||
app.hooks.raise_without_focus = Some(Box::new(|| {
|
||||
window_raise::raise_without_focus();
|
||||
}));
|
||||
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter(filter, exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t(filter), exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_open_project =
|
||||
Some(Box::new(|| rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
|
||||
@@ -207,7 +257,7 @@ fn main() -> eframe::Result {
|
||||
// instead of exiting silently, and keep the reason in the crash log.
|
||||
if let Err(e) = &started {
|
||||
let msg = format!("FilmCraft could not start its window: {e}\n\nUpdating the graphics driver usually fixes this.");
|
||||
eprintln!("filmcraft: {msg}");
|
||||
log::error!("{msg}");
|
||||
filmcraft_ui_egui::crash::record(&msg);
|
||||
let _ = rfd::MessageDialog::new().set_title("FilmCraft").set_description(&msg).set_level(rfd::MessageLevel::Error).show();
|
||||
}
|
||||
@@ -277,6 +327,9 @@ mod tests {
|
||||
fn startup_registers_the_hardware_decoders_without_a_logger() {
|
||||
assert!(!log::log_enabled!(log::Level::Info));
|
||||
let hardware = super::register_hardware_decoders();
|
||||
assert_eq!(filmcraft_platform::registered(), cfg!(any(target_os = "macos", target_os = "windows")), "{hardware:?}");
|
||||
// always on macOS and Windows; on Linux when a VA-API driver is there
|
||||
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|
||||
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
|
||||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,22 @@
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
sync_github_name = false
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.swillunderscore]
|
||||
display_name = "Swill Software"
|
||||
|
||||
|
||||
[people.IppSec]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ItzBloxy]
|
||||
display_name = "Bloxy"
|
||||
|
||||
@@ -107,10 +107,11 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
/// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered
|
||||
/// stills of an image sequence (`<stem><digits>.<same extension>`) and the caption sidecar
|
||||
/// stills of an image sequence (`<stem><3+ digits>.<same extension>`, only when the output is a
|
||||
/// PNG/TIFF/BMP sequence) and the caption sidecar
|
||||
/// (`<stem>.srt` / `<stem>.vtt`), each only if it was written since `since`. Other files that
|
||||
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, …) are never touched, even when they
|
||||
/// were saved during the export.
|
||||
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, `<stem>1.mp4` beside a movie, …) are
|
||||
/// never touched, even when they were saved during the export.
|
||||
pub fn remove_partial(path: &str, since: SystemTime) {
|
||||
let p = Path::new(path);
|
||||
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else {
|
||||
@@ -139,9 +140,19 @@ fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool {
|
||||
if let Some(sidecar) = rest.strip_prefix('.') {
|
||||
return sidecar.eq_ignore_ascii_case("srt") || sidecar.eq_ignore_ascii_case("vtt");
|
||||
}
|
||||
// image sequence: one or more digits, then the output's own extension
|
||||
// image sequence: the frame number (at least three digits, like `image_sequence_path`), then the
|
||||
// output's own extension; movies and audio are a single file, so numbered neighbours are not theirs
|
||||
if !is_sequence_extension(ext) {
|
||||
return false;
|
||||
}
|
||||
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
|
||||
digits > 0 && !ext.is_empty() && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
|
||||
digits >= 3 && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
|
||||
}
|
||||
|
||||
/// Whether an export to a file with extension `ext` writes an image sequence (one still per frame).
|
||||
fn is_sequence_extension(ext: &str) -> bool {
|
||||
use filmcraft_engine::export::Format;
|
||||
Format::from_name(ext).is_some_and(|f| matches!(f, Format::PngSequence | Format::TiffSequence | Format::BmpSequence))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -170,6 +181,50 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_files_beside_a_movie_are_kept() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-movie-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let started = SystemTime::now();
|
||||
// a movie export writes one file: `clip1.mp4` and `clip002.mp4` are other exports
|
||||
let ours = ["clip.mp4", "clip.srt"];
|
||||
let theirs = ["clip1.mp4", "clip002.mp4", "clip2.mov"];
|
||||
for f in ours.iter().chain(theirs.iter()) {
|
||||
std::fs::write(dir.join(f), b"x").unwrap();
|
||||
}
|
||||
remove_partial(&dir.join("clip.mp4").to_string_lossy(), started);
|
||||
for f in ours {
|
||||
assert!(!dir.join(f).exists(), "{f} should be removed");
|
||||
}
|
||||
for f in theirs {
|
||||
assert!(dir.join(f).exists(), "{f} must not be removed");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_frames_have_at_least_three_digits() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-digits-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let started = SystemTime::now();
|
||||
// frames are numbered with at least three digits (`shot000.tiff`); `shot2.tiff` is not one
|
||||
let ours = ["shot.tiff", "shot000.tiff", "shot1234.tiff"];
|
||||
let theirs = ["shot2.tiff", "shot12.tiff"];
|
||||
for f in ours.iter().chain(theirs.iter()) {
|
||||
std::fs::write(dir.join(f), b"x").unwrap();
|
||||
}
|
||||
remove_partial(&dir.join("shot.tiff").to_string_lossy(), started);
|
||||
for f in ours {
|
||||
assert!(!dir.join(f).exists(), "{f} should be removed");
|
||||
}
|
||||
for f in theirs {
|
||||
assert!(dir.join(f).exists(), "{f} must not be removed");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_older_than_the_export_are_kept() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-old-{}", std::process::id()));
|
||||
|
||||
@@ -41,3 +41,4 @@ symphonia = { version = "0.5.5", default-features = false, features = ["aac", "m
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
+151
-1
@@ -87,7 +87,7 @@ impl PacketDecoder {
|
||||
/// (HE-AAC, see [`aac_output_rate`]) the decoded core is upsampled to it.
|
||||
pub fn aac(asc: &[u8], sample_rate: u32) -> Result<Self> {
|
||||
if let Ok(dec) = filmcraft_aac::Decoder::new(asc) {
|
||||
let up = (dec.sample_rate().checked_mul(2) == Some(sample_rate)).then(|| Box::new(Upsample2x::new(dec.channels())));
|
||||
let up = upsamples_core(dec.sample_rate(), sample_rate).then(|| Box::new(Upsample2x::new(dec.channels())));
|
||||
return Ok(Self { inner: Inner::Aac { dec: Box::new(dec), asc: asc.to_vec(), up }, channels: 0 });
|
||||
}
|
||||
Self::new(CODEC_TYPE_AAC, sample_rate, Some(asc.to_vec()))
|
||||
@@ -244,6 +244,77 @@ pub fn aac_output_rate<'a>(asc: &[u8], first_units: impl IntoIterator<Item = &'a
|
||||
Some(core)
|
||||
}
|
||||
|
||||
/// Whether [`PacketDecoder::aac`] upsamples a decoded AAC core at `core` Hz to `rate` (HE-AAC).
|
||||
fn upsamples_core(core: u32, rate: u32) -> bool {
|
||||
core.checked_mul(2) == Some(rate)
|
||||
}
|
||||
|
||||
/// A codec whose packets all decode to the same number of samples (see [`fixed_packet_samples`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum FixedFrames<'a> {
|
||||
/// AAC with this AudioSpecificConfig.
|
||||
Aac(&'a [u8]),
|
||||
/// MPEG-1/2 audio, layer I, II or III.
|
||||
MpegAudio,
|
||||
/// AC-3 (and E-AC-3).
|
||||
Ac3,
|
||||
}
|
||||
|
||||
/// Samples each packet decodes to at the stream's output `rate`, for codecs where that is the same
|
||||
/// for every packet: AAC 1024 (2048 when [`PacketDecoder::aac`] upsamples an HE-AAC core); MPEG
|
||||
/// audio and AC-3 from the frame header of `first`, the stream's first packet. `None` when it is
|
||||
/// not known: an AAC configuration our decoder does not read, a header that does not parse, or a
|
||||
/// header whose rate is not `rate`.
|
||||
pub fn fixed_packet_samples(codec: FixedFrames, first: &[u8], rate: u32) -> Option<i64> {
|
||||
let mpegts = match codec {
|
||||
FixedFrames::Aac(asc) => {
|
||||
let core = filmcraft_aac::Decoder::new(asc).ok()?.sample_rate();
|
||||
return Some(if upsamples_core(core, rate) { 2048 } else { 1024 });
|
||||
}
|
||||
FixedFrames::MpegAudio => filmcraft_mpegts::Codec::MpegAudio,
|
||||
FixedFrames::Ac3 => filmcraft_mpegts::Codec::Ac3,
|
||||
};
|
||||
let f = filmcraft_mpegts::frame_info(&mpegts, first)?;
|
||||
(f.sample_rate == rate && f.samples > 0).then_some(f.samples as i64)
|
||||
}
|
||||
|
||||
/// Where the container puts one audio packet, for [`contiguous_starts`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PacketTime {
|
||||
/// Its timestamp, in output samples.
|
||||
pub stamp: i64,
|
||||
/// Whether `stamp` is the packet's own. A Matroska frame after the first in a laced block
|
||||
/// without a duration repeats the block's timestamp.
|
||||
pub stamped: bool,
|
||||
/// Samples it decodes to, when known.
|
||||
pub samples: Option<i64>,
|
||||
}
|
||||
|
||||
/// Packet start positions in output samples: each packet starts where the previous one ends,
|
||||
/// unless its own timestamp is more than `tolerance` samples from there (a real gap or overlap in
|
||||
/// the recording) or the previous packet's length is unknown; then it starts at its timestamp.
|
||||
///
|
||||
/// Container timestamps are coarser than a sample. Matroska stores milliseconds, and a remux to MP4
|
||||
/// (OBS's) turns that rounding into uneven sample durations: a 1024-sample AAC frame at 48 kHz lasts
|
||||
/// 21.33 ms, so frames are stamped at 0, 1008, 2064, 3072… Placing each decoded packet at its
|
||||
/// stamp overwrote the end of one packet and left a hole of silence after another, a click every
|
||||
/// few packets: crackling throughout playback and export (#236).
|
||||
pub fn contiguous_starts(packets: impl IntoIterator<Item = PacketTime>, tolerance: i64) -> Vec<i64> {
|
||||
let tolerance = tolerance.max(0).unsigned_abs();
|
||||
let mut next: Option<i64> = None;
|
||||
packets
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let start = match next {
|
||||
Some(n) if !p.stamped || n.abs_diff(p.stamp) <= tolerance => n,
|
||||
_ => p.stamp,
|
||||
};
|
||||
next = p.samples.and_then(|k| start.checked_add(k));
|
||||
start
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Taps on each side of the interpolation point in [`Upsample2x`].
|
||||
const UPSAMPLE_HALF: usize = 12;
|
||||
|
||||
@@ -739,3 +810,82 @@ mod he_aac_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod packet_time_tests {
|
||||
use super::*;
|
||||
|
||||
/// 1024-sample frames at 48 kHz stamped to the nearest millisecond, then back in samples.
|
||||
fn ms_stamps(count: i64) -> Vec<i64> {
|
||||
(0..count).map(|k| (k * 1024 * 1000 + 24_000) / 48_000 * 48).collect()
|
||||
}
|
||||
|
||||
fn timed(stamps: &[i64], samples: Option<i64>) -> Vec<PacketTime> {
|
||||
stamps.iter().map(|&stamp| PacketTime { stamp, stamped: true, samples }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_stamps_give_back_to_back_packets() {
|
||||
let stamps = ms_stamps(200);
|
||||
assert_eq!(&stamps[..4], [0, 1008, 2064, 3072]);
|
||||
let starts = contiguous_starts(timed(&stamps, Some(1024)), 512);
|
||||
assert_eq!(starts, (0..200).map(|k| k * 1024).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_gaps_and_overlaps_resynchronise() {
|
||||
// a 100 ms gap after packet 3, then a packet stamped 600 samples early
|
||||
let mut stamps: Vec<i64> = (0..8).map(|k| k * 1024).collect();
|
||||
for s in &mut stamps[4..] {
|
||||
*s += 4800;
|
||||
}
|
||||
stamps[7] -= 600;
|
||||
let starts = contiguous_starts(timed(&stamps, Some(1024)), 512);
|
||||
assert_eq!(starts, [0, 1024, 2048, 3072, 8896, 9920, 10944, 11368]);
|
||||
// within the tolerance: runs on
|
||||
stamps[7] += 200;
|
||||
assert_eq!(contiguous_starts(timed(&stamps, Some(1024)), 512)[7], 11968);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstamped_packets_follow_on_and_unknown_lengths_resynchronise() {
|
||||
// a laced block of three frames that repeat its timestamp
|
||||
let packets = [0, 1024, 1024, 1024, 4096].map(|stamp| PacketTime { stamp, stamped: true, samples: Some(1024) });
|
||||
let mut laced = packets;
|
||||
laced[2].stamped = false;
|
||||
laced[3].stamped = false;
|
||||
assert_eq!(contiguous_starts(laced, 512), [0, 1024, 2048, 3072, 4096]);
|
||||
// a packet whose length is unknown: the next one starts at its own stamp
|
||||
let mut unknown = timed(&[0, 1024, 2100, 3124], Some(1024));
|
||||
unknown[1].samples = None;
|
||||
assert_eq!(contiguous_starts(unknown, 512), [0, 1024, 2100, 3124]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_stay_finite() {
|
||||
let packets = [
|
||||
PacketTime { stamp: i64::MAX, stamped: true, samples: Some(i64::MAX) },
|
||||
PacketTime { stamp: i64::MIN, stamped: false, samples: Some(-5) },
|
||||
PacketTime { stamp: 0, stamped: true, samples: Some(i64::MIN) },
|
||||
PacketTime { stamp: 7, stamped: true, samples: None },
|
||||
];
|
||||
assert_eq!(contiguous_starts(packets, i64::MIN).len(), 4);
|
||||
assert_eq!(contiguous_starts(packets, i64::MAX).len(), 4);
|
||||
assert!(contiguous_starts([], 512).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_frame_lengths() {
|
||||
// AAC-LC 48 kHz stereo; HE-AAC (24 kHz core) played at 48 kHz is upsampled
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Aac(&[0x11, 0x90]), &[], 48_000), Some(1024));
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Aac(&[0x13, 0x10]), &[], 48_000), Some(2048));
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Aac(&[]), &[], 48_000), None);
|
||||
// MPEG-1 layer III 44.1 kHz, 128 kbps; MPEG-2 layer III 22.05 kHz
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF, 0xFB, 0x90, 0x44], 44_100), Some(1152));
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF, 0xF3, 0x90, 0x44], 22_050), Some(576));
|
||||
// the header's rate must be the stream's
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF, 0xFB, 0x90, 0x44], 48_000), None);
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF], 44_100), None);
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Ac3, &[0x0B, 0x77], 48_000), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Audio packets placed by container timestamps (#236).
|
||||
//!
|
||||
//! An AAC access unit decodes to 1024 samples, 21.33 ms at 48 kHz. Matroska stores timestamps in
|
||||
//! whole milliseconds, and OBS's remux to MP4 carries that rounding into sample durations of 1008,
|
||||
//! 1008, 1056… Placing each decoded packet at its own timestamp overwrote the end of one packet and
|
||||
//! left a hole of silence after another: playback and export crackled throughout. The streams here
|
||||
//! are AAC from our own encoder muxed with such timestamps; read back block by block, as playback
|
||||
//! reads them, they must be exactly the access units decoded back to back.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_matroska::{LacingMode, MkvWriter, MuxOptions, TrackKind, TrackSpec};
|
||||
use filmcraft_media::MediaSource;
|
||||
|
||||
use crate::audio::PacketDecoder;
|
||||
use crate::{MkvSource, Mp4Source};
|
||||
|
||||
/// A second of a stereo tone (440 Hz left, 660 Hz right) encoded with our AAC encoder: the
|
||||
/// AudioSpecificConfig and the access units.
|
||||
fn aac(rate: u32) -> (Vec<u8>, Vec<Vec<u8>>) {
|
||||
let tone = |hz: f32| (0..rate as usize).map(|i| 0.5 * (std::f32::consts::TAU * hz * i as f32 / rate as f32).sin()).collect::<Vec<f32>>();
|
||||
let (l, r) = (tone(440.0), tone(660.0));
|
||||
let mut enc = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(rate, 2, 160_000)).unwrap();
|
||||
let mut units = enc.encode(&[&l, &r]);
|
||||
units.extend(enc.flush());
|
||||
(enc.audio_specific_config(), units)
|
||||
}
|
||||
|
||||
/// The access units decoded back to back.
|
||||
fn continuous(asc: &[u8], rate: u32, units: &[Vec<u8>]) -> Vec<Vec<f32>> {
|
||||
let mut dec = PacketDecoder::aac(asc, rate).unwrap();
|
||||
let mut out = vec![Vec::new(), Vec::new()];
|
||||
for u in units {
|
||||
for (o, c) in out.iter_mut().zip(dec.decode(u, 0).unwrap()) {
|
||||
o.extend(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Access unit `k`'s start to the nearest millisecond, as muxers stamp it.
|
||||
fn stamp_ms(rate: u32, k: usize) -> i64 {
|
||||
(k as i64 * 1024 * 2000 + rate as i64) / (2 * rate as i64)
|
||||
}
|
||||
|
||||
/// Matroska (1 ms timestamps), with the units after `gap` delayed by `gap_ms` (a real gap).
|
||||
fn mkv(asc: &[u8], rate: u32, units: &[Vec<u8>], lacing: LacingMode, gap: usize, gap_ms: i64) -> Arc<[u8]> {
|
||||
let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC");
|
||||
spec.codec_private = asc.to_vec();
|
||||
spec.audio = Some((rate as f64, 2, None));
|
||||
let opts = MuxOptions { lacing, ..MuxOptions::default() };
|
||||
assert_eq!(opts.timestamp_scale, 1_000_000, "1 ms timestamps");
|
||||
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], opts).unwrap();
|
||||
for (k, u) in units.iter().enumerate() {
|
||||
let ms = stamp_ms(rate, k) + if k >= gap { gap_ms } else { 0 };
|
||||
w.write_frame(0, ms * 1_000_000, true, u, None).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner().into()
|
||||
}
|
||||
|
||||
/// MP4 as OBS remuxes Matroska: each millisecond timestamp rescaled to the sample rate, the
|
||||
/// differences written as sample durations.
|
||||
fn mp4_remuxed(asc: &[u8], rate: u32, units: &[Vec<u8>]) -> Arc<[u8]> {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let t = w.add_track(TrackConfig::new(SampleEntry::aac(asc.to_vec(), 2, rate), rate)).unwrap();
|
||||
let at = |k: usize| (stamp_ms(rate, k) * rate as i64 + 500) / 1000;
|
||||
for (k, u) in units.iter().enumerate() {
|
||||
let duration = (at(k + 1) - at(k)) as u32;
|
||||
w.write_sample(t, WriteSample { data: u, duration, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner().into()
|
||||
}
|
||||
|
||||
/// `frames` samples per channel read in 512-sample blocks at `rate`, as playback reads them.
|
||||
fn played(src: &dyn MediaSource, rate: u32, frames: usize) -> Vec<Vec<f32>> {
|
||||
let mut out = vec![Vec::new(), Vec::new()];
|
||||
let mut pos = 0;
|
||||
while pos < frames {
|
||||
let n = 512.min(frames - pos);
|
||||
let b = src.audio(pos as i64, n, rate).unwrap();
|
||||
for (o, c) in out.iter_mut().zip(&b.channels) {
|
||||
o.extend_from_slice(c);
|
||||
}
|
||||
pos += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Samples of `got` that differ from `want[k - shift]` (silence where that is out of range), and
|
||||
/// the first few.
|
||||
fn mismatches(got: &[Vec<f32>], want: &[Vec<f32>], shift: usize) -> (usize, Vec<(usize, usize)>) {
|
||||
let mut bad = Vec::new();
|
||||
for (c, (g, w)) in got.iter().zip(want).enumerate() {
|
||||
for (k, x) in g.iter().enumerate() {
|
||||
let expect = k.checked_sub(shift).and_then(|j| w.get(j)).copied().unwrap_or(0.0);
|
||||
if x.to_bits() != expect.to_bits() {
|
||||
bad.push((c, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
(bad.len(), bad.into_iter().take(4).collect())
|
||||
}
|
||||
|
||||
/// Runs of two or more exact zeros inside the signal: holes left between packets.
|
||||
fn holes(got: &[Vec<f32>]) -> usize {
|
||||
got.iter().map(|c| c.windows(2).filter(|w| w[0] == 0.0 && w[1] == 0.0).count()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matroska_millisecond_stamps_play_back_to_back() {
|
||||
for rate in [48_000, 44_100] {
|
||||
let (asc, units) = aac(rate);
|
||||
let want = continuous(&asc, rate, &units);
|
||||
let frames = units.len().saturating_sub(1) * 1024;
|
||||
for lacing in [LacingMode::None, LacingMode::Xiph] {
|
||||
let bytes = mkv(&asc, rate, &units, lacing, usize::MAX, 0);
|
||||
let src = MkvSource::open("tone.mkv", bytes.clone()).unwrap();
|
||||
let got = played(&src, rate, frames);
|
||||
let (n, first) = mismatches(&got, &want, 0);
|
||||
assert_eq!(n, 0, "{rate} Hz, {lacing:?}: {n} samples differ from the continuous decode, first (channel, sample) {first:?}");
|
||||
// random access (a fresh source, so a cold decoder primed with the packet before) gives
|
||||
// the same samples
|
||||
let src = MkvSource::open("tone.mkv", bytes).unwrap();
|
||||
for start in [30_011usize, 7_000] {
|
||||
let got = src.audio(start as i64, 4_800, rate).unwrap().channels;
|
||||
let at: Vec<Vec<f32>> = want.iter().map(|c| c[start..start + 4_800].to_vec()).collect();
|
||||
assert_eq!(mismatches(&got, &at, 0).0, 0, "{rate} Hz, {lacing:?}: seek to {start}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obs_remuxed_mp4_plays_back_to_back() {
|
||||
for rate in [48_000, 44_100] {
|
||||
let (asc, units) = aac(rate);
|
||||
let want = continuous(&asc, rate, &units);
|
||||
let bytes = mp4_remuxed(&asc, rate, &units);
|
||||
let src = Mp4Source::open("tone.mp4", bytes).unwrap();
|
||||
let got = played(&src, rate, units.len().saturating_sub(1) * 1024);
|
||||
let (n, first) = mismatches(&got, &want, 0);
|
||||
assert_eq!(n, 0, "{rate} Hz: {n} samples differ from the continuous decode, first (channel, sample) {first:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_gap_stays_where_the_timestamps_put_it() {
|
||||
let rate = 48_000;
|
||||
let (asc, units) = aac(rate);
|
||||
let want = continuous(&asc, rate, &units);
|
||||
// 100 ms of the recording missing before unit 20: it plays as silence, and the units after it
|
||||
// keep their timestamps (to the millisecond)
|
||||
let src = MkvSource::open("gap.mkv", mkv(&asc, rate, &units, LacingMode::None, 20, 100)).unwrap();
|
||||
let shift = ((stamp_ms(rate, 20) + 100) * 48 - 20 * 1024) as usize;
|
||||
assert!((4_700..4_900).contains(&shift), "{shift}");
|
||||
let got = played(&src, rate, units.len().saturating_sub(1) * 1024 + shift);
|
||||
let before: Vec<Vec<f32>> = got.iter().map(|c| c[..20 * 1024].to_vec()).collect();
|
||||
assert_eq!(mismatches(&before, &want, 0).0, 0);
|
||||
assert!(got.iter().all(|c| c[20 * 1024..20 * 1024 + shift].iter().all(|x| *x == 0.0)), "the gap is silent");
|
||||
let after: Vec<Vec<f32>> = got.iter().map(|c| c[20 * 1024 + shift..].to_vec()).collect();
|
||||
let rest: Vec<Vec<f32>> = want.iter().map(|c| c[20 * 1024..].to_vec()).collect();
|
||||
let (n, first) = mismatches(&after, &rest, 0);
|
||||
assert_eq!(n, 0, "{n} samples after the gap differ, first {first:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playing_at_another_rate_leaves_no_holes() {
|
||||
// a 44.1 kHz recording in a 48 kHz sequence (export's default rate)
|
||||
let (asc, units) = aac(44_100);
|
||||
let src = MkvSource::open("tone.mkv", mkv(&asc, 44_100, &units, LacingMode::None, usize::MAX, 0)).unwrap();
|
||||
let frames = (units.len().saturating_sub(2) * 1024) * 48_000 / 44_100;
|
||||
let got = played(&src, 48_000, frames);
|
||||
// skip the encoder's priming (the first frame decodes to near-silence)
|
||||
let tail: Vec<Vec<f32>> = got.iter().map(|c| c[2048..].to_vec()).collect();
|
||||
assert_eq!(holes(&tail), 0);
|
||||
}
|
||||
+208
-20
@@ -140,6 +140,10 @@ struct State {
|
||||
bytes: usize,
|
||||
/// When a frame was last asked of this cache.
|
||||
last_used: Instant,
|
||||
/// Only background requests (thumbnails, [`filmcraft_media::cancel::with_background`]) have
|
||||
/// used the decoder since it was made: it is given up as soon as another background request
|
||||
/// is done, whatever the caches' recency.
|
||||
background: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
@@ -212,10 +216,27 @@ impl Drop for DecodingGuard {
|
||||
/// new one when the playhead reaches them, which costs what a seek costs.
|
||||
const MAX_LIVE_DECODERS: usize = 4;
|
||||
|
||||
/// A cache asked for a frame this recently is in use (a layer of the frame being composited,
|
||||
/// the next clip being prefetched): it keeps its decoder whatever the count.
|
||||
/// A cache asked for a frame this recently may be needed again soon (the next clip being
|
||||
/// prefetched, a clip the user scrubs back to): it keeps its decoder up to [`HARD_DECODERS`], and
|
||||
/// beyond [`MAX_LIVE_DECODERS`] only until it has been idle this long. Its frames are only kept
|
||||
/// within [`FRAME_BUDGET`] once it has been idle for [`RECENT`]: going back to a clip left (or
|
||||
/// paused) more than that long ago may cost a re-decode from its keyframe when other clips have
|
||||
/// filled the budget meanwhile. Frames go least recently used cache first, earliest frames first.
|
||||
const IDLE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// A cache asked for a frame this recently is in use (a layer of the frame being composited): it
|
||||
/// keeps its decoder and frames whatever the count. Caches idle for less than [`IDLE`] but longer
|
||||
/// than this are a burst of navigation (thumbnails of every clip, the playhead jumping from clip
|
||||
/// to clip) that the playhead has already left: they give up decoders beyond [`HARD_DECODERS`]
|
||||
/// and frames beyond [`FRAME_BUDGET`]. Without this, everything touched within [`IDLE`] counted
|
||||
/// as in use, so a burst held every clip's decoder (42 live hardware sessions, 6.5 GB of VRAM)
|
||||
/// and hundreds of MB of frames per clip.
|
||||
const RECENT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Most decoders caches idle for [`RECENT`] to [`IDLE`] keep together (a composite of a few
|
||||
/// layers plus the clips just left behind).
|
||||
const HARD_DECODERS: usize = 8;
|
||||
|
||||
/// Decoded frames all caches may hold together before idle ones give theirs up. Each cache also
|
||||
/// has its own budget, which alone let every clip on a timeline keep hundreds of MB of frames
|
||||
/// nobody was looking at.
|
||||
@@ -228,6 +249,10 @@ struct Pool {
|
||||
decoders: AtomicUsize,
|
||||
max_decoders: usize,
|
||||
idle: Duration,
|
||||
/// Caches idle at least this long are no longer in use (see [`RECENT`]).
|
||||
recent: Duration,
|
||||
/// Decoders the caches idle for [`Self::recent`] but not [`Self::idle`] may keep.
|
||||
hard_decoders: usize,
|
||||
/// Bytes of the frames the caches hold.
|
||||
bytes: AtomicUsize,
|
||||
budget: usize,
|
||||
@@ -258,13 +283,29 @@ fn try_state(c: &Shared) -> Option<MutexGuard<'_, State>> {
|
||||
|
||||
impl Pool {
|
||||
fn new(max_decoders: usize, idle: Duration, budget: usize) -> Self {
|
||||
Self { caches: Mutex::new(Vec::new()), decoders: AtomicUsize::new(0), max_decoders, idle, bytes: AtomicUsize::new(0), budget }
|
||||
Self {
|
||||
caches: Mutex::new(Vec::new()),
|
||||
decoders: AtomicUsize::new(0),
|
||||
max_decoders,
|
||||
idle,
|
||||
recent: idle,
|
||||
hard_decoders: max_decoders,
|
||||
bytes: AtomicUsize::new(0),
|
||||
budget,
|
||||
}
|
||||
}
|
||||
|
||||
/// Caches idle for `recent` (< the idle time) are still trimmed, down to `hard_decoders`.
|
||||
fn tiered(mut self, recent: Duration, hard_decoders: usize) -> Self {
|
||||
self.recent = recent.min(self.idle);
|
||||
self.hard_decoders = hard_decoders.max(self.max_decoders);
|
||||
self
|
||||
}
|
||||
|
||||
/// The pool of every cache in the process.
|
||||
fn global() -> Arc<Pool> {
|
||||
static POOL: std::sync::OnceLock<Arc<Pool>> = std::sync::OnceLock::new();
|
||||
POOL.get_or_init(|| Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET))).clone()
|
||||
POOL.get_or_init(|| Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET).tiered(RECENT, HARD_DECODERS))).clone()
|
||||
}
|
||||
|
||||
fn register(&self, c: &Arc<Shared>) {
|
||||
@@ -273,15 +314,15 @@ impl Pool {
|
||||
g.push(Arc::downgrade(c));
|
||||
}
|
||||
|
||||
/// The idle caches other than `me` that `keep` selects, least recently used first. Other
|
||||
/// The caches other than `me` idle for at least `idle` that `keep` selects, least recently used first. Other
|
||||
/// caches are only ever try-locked, so two caches trimming each other cannot deadlock.
|
||||
fn idle_others(&self, me: &Shared, keep: impl Fn(&State) -> bool) -> Vec<Arc<Shared>> {
|
||||
fn idle_others(&self, me: &Shared, idle: Duration, keep: impl Fn(&State) -> bool) -> Vec<Arc<Shared>> {
|
||||
let all: Vec<Arc<Shared>> = self.caches.lock().unwrap_or_else(PoisonError::into_inner).iter().filter_map(Weak::upgrade).collect();
|
||||
let mut idle: Vec<(Instant, Arc<Shared>)> = all
|
||||
.into_iter()
|
||||
.filter(|c| !std::ptr::eq(Arc::as_ptr(c), me))
|
||||
.filter_map(|c| {
|
||||
let used = try_state(&c).filter(|st| keep(st) && st.last_used.elapsed() >= self.idle).map(|st| st.last_used)?;
|
||||
let used = try_state(&c).filter(|st| keep(st) && st.last_used.elapsed() >= idle).map(|st| st.last_used)?;
|
||||
Some((used, c))
|
||||
})
|
||||
.collect();
|
||||
@@ -295,29 +336,53 @@ impl Pool {
|
||||
#[must_use = "drop the released decoders with no cache lock held"]
|
||||
fn trim_decoders(&self, me: &Shared) -> Vec<Box<dyn VideoDecoder>> {
|
||||
let mut released = Vec::new();
|
||||
if self.decoders.load(Ordering::Relaxed) <= self.max_decoders {
|
||||
return released;
|
||||
}
|
||||
for c in self.idle_others(me, |st| st.decoder.is_some()) {
|
||||
if self.decoders.load(Ordering::Relaxed) <= self.max_decoders {
|
||||
break;
|
||||
// first the caches idle for long, down to the cap; then those a burst has left behind
|
||||
for (idle, cap) in [(self.idle, self.max_decoders), (self.recent, self.hard_decoders)] {
|
||||
if self.decoders.load(Ordering::Relaxed) <= cap {
|
||||
continue;
|
||||
}
|
||||
if let Some(mut st) = try_state(&c) {
|
||||
for c in self.idle_others(me, idle, |st| st.decoder.is_some()) {
|
||||
if self.decoders.load(Ordering::Relaxed) <= cap {
|
||||
break;
|
||||
}
|
||||
if let Some(mut st) = try_state(&c) {
|
||||
released.extend(st.release_decoder(self));
|
||||
}
|
||||
}
|
||||
}
|
||||
released
|
||||
}
|
||||
|
||||
/// Take the decoders of the caches other than `me` that only background requests have used
|
||||
/// (see [`State::background`]) and that are not decoding now. A burst of thumbnails (an import
|
||||
/// of many clips) touches every clip within moments, so the recency rules treat them all as
|
||||
/// in use; each software 4K decoder holds ~0.5 GB. The cache `me` just served keeps its
|
||||
/// decoder (hovering a clip asks for more of its frames). Drop the result with no cache lock
|
||||
/// held.
|
||||
#[must_use = "drop the released decoders with no cache lock held"]
|
||||
fn release_background(&self, me: &Shared) -> Vec<Box<dyn VideoDecoder>> {
|
||||
let all: Vec<Arc<Shared>> = self.caches.lock().unwrap_or_else(PoisonError::into_inner).iter().filter_map(Weak::upgrade).collect();
|
||||
let mut released = Vec::new();
|
||||
for c in all.iter().filter(|c| !std::ptr::eq(Arc::as_ptr(c), me)) {
|
||||
if let Some(mut st) = try_state(c)
|
||||
&& st.background
|
||||
&& st.decoder.is_some()
|
||||
{
|
||||
released.extend(st.release_decoder(self));
|
||||
}
|
||||
}
|
||||
released
|
||||
}
|
||||
|
||||
/// Evict the frames of idle caches, least recently used first, down to the budget. Caches in
|
||||
/// use keep theirs (each within its own budget): a frame evicted before it is shown costs a
|
||||
/// Evict the frames of caches idle for [`RECENT`], least recently used first, down to the
|
||||
/// budget. Caches in use keep theirs (each within its own budget): a frame evicted before it is shown costs a
|
||||
/// re-decode from the keyframe.
|
||||
fn trim_frames(&self, me: &Shared) {
|
||||
let over = || self.bytes.load(Ordering::Relaxed) > self.budget;
|
||||
if !over() {
|
||||
return;
|
||||
}
|
||||
for c in self.idle_others(me, |st| !st.frames.is_empty()) {
|
||||
for c in self.idle_others(me, self.recent, |st| !st.frames.is_empty()) {
|
||||
if !over() {
|
||||
break;
|
||||
}
|
||||
@@ -361,7 +426,7 @@ const CONTINUE_THROUGH: usize = 48;
|
||||
impl GopCache {
|
||||
pub fn new(explicit_color: Option<ColorInfo>) -> Self {
|
||||
// unit tests count decoder restarts: each of their caches gets a pool of its own
|
||||
let pool = if cfg!(test) { Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET)) } else { Pool::global() };
|
||||
let pool = if cfg!(test) { Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET).tiered(RECENT, HARD_DECODERS)) } else { Pool::global() };
|
||||
Self::in_pool(explicit_color, pool)
|
||||
}
|
||||
|
||||
@@ -378,6 +443,7 @@ impl GopCache {
|
||||
drafts: Default::default(),
|
||||
bytes: 0,
|
||||
last_used: Instant::now(),
|
||||
background: false,
|
||||
}),
|
||||
pool: pool.clone(),
|
||||
});
|
||||
@@ -405,6 +471,7 @@ impl GopCache {
|
||||
|
||||
fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) {
|
||||
let f = self.finish(f);
|
||||
st.last_used = Instant::now();
|
||||
if draft {
|
||||
st.drafts.insert(pts);
|
||||
DRAFT.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -467,6 +534,16 @@ impl GopCache {
|
||||
/// ([`VideoDecoder::set_draft`]); its draft frames are cached for draft requests only, and an
|
||||
/// exact request for one re-decodes it.
|
||||
pub fn frame_late(&self, s: &dyn VideoSamples, target: i64, late_before: Option<i64>) -> crate::Result<Arc<VideoFrame>> {
|
||||
let r = self.frame_late_locked(s, target, late_before);
|
||||
if filmcraft_media::cancel::background() {
|
||||
// Outside the cache's lock (see `release_background`). A thumbnail leaves the decoders
|
||||
// of earlier thumbnails no reason to stay: each costs hundreds of MB at 4K.
|
||||
drop(self.shared.pool.release_background(&self.shared));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn frame_late_locked(&self, s: &dyn VideoSamples, target: i64, late_before: Option<i64>) -> crate::Result<Arc<VideoFrame>> {
|
||||
let n = s.count();
|
||||
let i = s.sample_at(target.max(0)).or_else(|| (n > 0).then(|| n - 1)).ok_or_else(|| CodecError::Decode("empty track".into()))?;
|
||||
let want_pts = s.pts(i);
|
||||
@@ -482,11 +559,23 @@ impl GopCache {
|
||||
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _decoding = DecodingGuard::enter(me);
|
||||
st.last_used = Instant::now();
|
||||
if let Some(f) = st.cached(want_pts, draft) {
|
||||
let background = filmcraft_media::cancel::background();
|
||||
if let Some(f) = st.cached(want_pts, draft).cloned() {
|
||||
HITS.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(f.clone());
|
||||
// a monitor asking for a frame of a thumbnail's clip makes its decoder one in use
|
||||
st.background &= background;
|
||||
return Ok(f);
|
||||
}
|
||||
MISSES.fetch_add(1, Ordering::Relaxed);
|
||||
if !background {
|
||||
st.background = false;
|
||||
} else if st.decoder.is_none() {
|
||||
st.background = true;
|
||||
}
|
||||
if !background && st.decoder.as_ref().is_some_and(|d| d.thread_limited()) {
|
||||
// made for a thumbnail (few threads): a monitor gets a full one
|
||||
released.extend(st.release_decoder(&self.shared.pool));
|
||||
}
|
||||
if st.decoder.is_none() {
|
||||
let d = s.make_decoder()?;
|
||||
st.intra = d.intra_only();
|
||||
@@ -683,6 +772,8 @@ mod tests {
|
||||
/// (pts, decoded as a draft picture)
|
||||
held: VecDeque<(i64, bool)>,
|
||||
draft: bool,
|
||||
/// made for background work (few threads, like the H.264 decoder's)
|
||||
limited: bool,
|
||||
resets: Arc<AtomicUsize>,
|
||||
decodes: Arc<AtomicUsize>,
|
||||
}
|
||||
@@ -717,6 +808,9 @@ mod tests {
|
||||
fn name(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
fn thread_limited(&self) -> bool {
|
||||
self.limited
|
||||
}
|
||||
fn intra_only(&self) -> bool {
|
||||
self.intra
|
||||
}
|
||||
@@ -749,6 +843,7 @@ mod tests {
|
||||
intra: self.intra,
|
||||
held: VecDeque::new(),
|
||||
draft: false,
|
||||
limited: filmcraft_media::cancel::background(),
|
||||
resets: self.resets.clone(),
|
||||
decodes: self.decodes.clone(),
|
||||
}))
|
||||
@@ -944,6 +1039,99 @@ mod tests {
|
||||
assert!(s.iter().all(|s| s.resets.load(Ordering::Relaxed) == 1), "no decoder restarted");
|
||||
}
|
||||
|
||||
/// A burst of navigation (every clip of a timeline asked once within moments, as when
|
||||
/// thumbnails are made or the playhead jumps clip to clip) must not keep every clip's decoder
|
||||
/// and frames: only the caches of the frame being composited are in use.
|
||||
#[test]
|
||||
fn a_burst_of_single_requests_does_not_keep_every_decoder_and_frame() {
|
||||
// the real decoder caps and idle time; RECENT scaled down from 500 ms to the test's pace
|
||||
// (3 ms per clip). With the old rule (recent = idle) all 24 decoders stay live.
|
||||
let pool = Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET).tiered(Duration::from_millis(2), HARD_DECODERS));
|
||||
let n = 24;
|
||||
let s: Vec<Samples> = (0..n).map(|_| samples(300, 250, 3, false)).collect();
|
||||
let c: Vec<GopCache> = (0..n).map(|_| GopCache::in_pool(None, pool.clone())).collect();
|
||||
for k in 0..n {
|
||||
assert_eq!(index_of(&c[k].frame(&s[k], 10_000).expect("frame")), 10);
|
||||
std::thread::sleep(Duration::from_millis(3));
|
||||
}
|
||||
assert!(
|
||||
pool.decoders.load(Ordering::Relaxed) <= HARD_DECODERS + 1,
|
||||
"{} live decoders after a burst over {n} clips",
|
||||
pool.decoders.load(Ordering::Relaxed)
|
||||
);
|
||||
// the clip asked last, and a clip in use together with it, keep theirs and carry on
|
||||
let resets = s[n - 1].resets.load(Ordering::Relaxed);
|
||||
assert_eq!(index_of(&c[n - 1].frame(&s[n - 1], 11_000).expect("frame")), 11);
|
||||
assert_eq!(s[n - 1].resets.load(Ordering::Relaxed), resets);
|
||||
}
|
||||
|
||||
/// The thumbnails an import queues: every clip asked once, quickly (the real caps and recency,
|
||||
/// no sleeping: all of them are "recent"). Each 4K software decoder costs ~0.5 GB, and 42
|
||||
/// clips used to hold 6+ GB at once.
|
||||
#[test]
|
||||
fn background_requests_leave_no_decoders_behind() {
|
||||
use filmcraft_media::cancel::with_background;
|
||||
let pool = Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET).tiered(RECENT, HARD_DECODERS));
|
||||
let n = 24;
|
||||
let s: Vec<Samples> = (0..n).map(|_| samples(300, 250, 3, false)).collect();
|
||||
let c: Vec<GopCache> = (0..n).map(|_| GopCache::in_pool(None, pool.clone())).collect();
|
||||
// a clip in use by a monitor
|
||||
assert_eq!(index_of(&c[0].frame(&s[0], 10_000).expect("frame")), 10);
|
||||
for k in 1..n {
|
||||
assert_eq!(index_of(&with_background(true, || c[k].frame(&s[k], 10_000)).expect("frame")), 10);
|
||||
assert!(pool.decoders.load(Ordering::Relaxed) <= 2, "{} live decoders after thumbnail {k}", pool.decoders.load(Ordering::Relaxed));
|
||||
}
|
||||
// the monitor's clip and the last thumbnail keep theirs and carry on without a seek
|
||||
assert_eq!(pool.decoders.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(index_of(&c[0].frame(&s[0], 11_000).expect("frame")), 11);
|
||||
assert_eq!(s[0].resets.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(index_of(&with_background(true, || c[n - 1].frame(&s[n - 1], 12_000)).expect("frame")), 12);
|
||||
assert_eq!(s[n - 1].resets.load(Ordering::Relaxed), 1);
|
||||
// a monitor asking for a frame of a thumbnail's clip makes its decoder one in use
|
||||
assert_eq!(index_of(&c[n - 1].frame(&s[n - 1], 13_000).expect("frame")), 13);
|
||||
for k in 1..4 {
|
||||
with_background(true, || c[k].frame(&s[k], 20_000)).expect("frame");
|
||||
}
|
||||
assert_eq!(index_of(&c[n - 1].frame(&s[n - 1], 14_000).expect("frame")), 14);
|
||||
assert!(c[n - 1].shared.state.lock().unwrap().decoder.is_some(), "the thumbnails left its decoder");
|
||||
// a cache that lost its decoder still decodes (from the sync sample)
|
||||
assert_eq!(index_of(&with_background(true, || c[1].frame(&s[1], 30_000)).expect("frame")), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_monitor_replaces_a_decoder_made_for_a_thumbnail() {
|
||||
use filmcraft_media::cancel::with_background;
|
||||
let pool = Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET).tiered(RECENT, HARD_DECODERS));
|
||||
let (s, c) = (samples(300, 250, 3, false), GopCache::in_pool(None, pool.clone()));
|
||||
assert_eq!(index_of(&with_background(true, || c.frame(&s, 10_000)).expect("frame")), 10);
|
||||
// another thumbnail of the same clip carries on with the thumbnail decoder
|
||||
assert_eq!(index_of(&with_background(true, || c.frame(&s, 12_000)).expect("frame")), 12);
|
||||
assert_eq!(s.resets.load(Ordering::Relaxed), 1);
|
||||
// a frame a monitor waits for (not cached: far ahead) is decoded by a full-thread decoder
|
||||
assert_eq!(index_of(&c.frame(&s, 100_000).expect("frame")), 100);
|
||||
assert!(!c.shared.state.lock().unwrap().decoder.as_ref().is_some_and(|d| d.thread_limited()));
|
||||
assert_eq!(pool.decoders.load(Ordering::Relaxed), 1);
|
||||
// and keeps it for the next frames
|
||||
let resets = s.resets.load(Ordering::Relaxed);
|
||||
assert_eq!(index_of(&c.frame(&s, 101_000).expect("frame")), 101);
|
||||
assert_eq!(s.resets.load(Ordering::Relaxed), resets);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burst_of_single_requests_does_not_keep_every_clips_frames() {
|
||||
// 1×1 RGBA pictures (4 bytes): a shared budget of ten of them
|
||||
let pool = Arc::new(Pool::new(usize::MAX, Duration::from_secs(3600), 40).tiered(Duration::from_millis(2), usize::MAX));
|
||||
let n = 12;
|
||||
let s: Vec<Samples> = (0..n).map(|_| samples(300, 250, 0, false)).collect();
|
||||
let c: Vec<GopCache> = (0..n).map(|_| GopCache::in_pool(None, pool.clone())).collect();
|
||||
for k in 0..n {
|
||||
assert_eq!(index_of(&c[k].frame(&s[k], 10_000).expect("frame")), 10);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let held = pool.bytes.load(Ordering::Relaxed);
|
||||
assert!(held <= 40 + 11 * 4, "{held} bytes held after a burst over {n} clips");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intra_caches_give_up_their_spare_decoders_too() {
|
||||
let pool = Arc::new(Pool::new(1, Duration::ZERO, FRAME_BUDGET));
|
||||
|
||||
@@ -173,6 +173,8 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>) -> std::result::Result<filmcraft
|
||||
filmcraft_media::open_bytes(name, bytes, &openers())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod audio_timing_tests;
|
||||
#[cfg(test)]
|
||||
mod rounded_pts_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
+60
-32
@@ -8,6 +8,9 @@
|
||||
//! subtracts `CodecDelay` from timestamps, so the pre-skip samples land before zero and are never
|
||||
//! read; random access decodes `SeekPreRoll` (at least [`crate::audio::OPUS_PRE_ROLL`]) of preceding
|
||||
//! packets before the target. Packet starts are accumulated from TOC durations (see [`opus_starts`]).
|
||||
//!
|
||||
//! AAC, MPEG audio and AC-3 packets each decode to a fixed number of samples, which millisecond
|
||||
//! timestamps can't express: their packets run on from one another (see [`audio_starts`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -51,35 +54,71 @@ pub struct MkvSource {
|
||||
audio_preroll: i64,
|
||||
}
|
||||
|
||||
/// Audio packet start positions in source sample frames.
|
||||
///
|
||||
/// Block timestamps are quantised to `TimestampScale` (usually 1 ms), so they are only accurate to a
|
||||
/// tick: a 1024-sample AAC frame at 48 kHz lasts 21.33 ms. Packets that all decode to the same
|
||||
/// length (AAC, MPEG audio, AC-3) therefore run on from one another, resynchronising to the
|
||||
/// timestamp only across gaps of more than half a packet (and more than two ticks); see
|
||||
/// [`crate::audio::contiguous_starts`]. Opus packets: [`opus_starts`]. Other codecs start at their
|
||||
/// timestamps.
|
||||
fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec<i64> {
|
||||
use crate::audio::{FixedFrames, PacketTime, contiguous_starts, fixed_packet_samples};
|
||||
let Some(t) = file.tracks.get(ti) else { return Vec::new() };
|
||||
if let Some(h) = opus_head(&t.codec) {
|
||||
return opus_starts(file, bytes, ti, &h, rate);
|
||||
}
|
||||
let (n, d) = tb(t);
|
||||
let at = |pts: i64| (pts as i128 * n as i128 * rate as i128 / d as i128) as i64;
|
||||
let first = || file.read_sample(bytes, ti, 0).unwrap_or_default();
|
||||
let out_rate = u32::try_from(rate).unwrap_or(0);
|
||||
let frame = match &t.codec {
|
||||
Codec::Aac { asc } => fixed_packet_samples(FixedFrames::Aac(asc), &[], out_rate),
|
||||
Codec::Mp3 | Codec::Mp2 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate),
|
||||
Codec::Ac3 => fixed_packet_samples(FixedFrames::Ac3, &first(), out_rate),
|
||||
_ => None,
|
||||
};
|
||||
let Some(frame) = frame else {
|
||||
return t.samples.iter().map(|s| at(s.pts)).collect();
|
||||
};
|
||||
let packets = t.samples.iter().map(|s| PacketTime { stamp: at(s.pts), stamped: own_stamp(s), samples: Some(frame) });
|
||||
contiguous_starts(packets, (frame / 2).max(stamp_tolerance(t, rate)))
|
||||
}
|
||||
|
||||
/// Sample-exact Opus packet start positions (48 kHz frames, pre-skip removed).
|
||||
///
|
||||
/// Block timestamps are quantised to `TimestampScale` (usually 1 ms) and the demuxer subtracts a
|
||||
/// rounded `CodecDelay`, so they are only accurate to a tick. Starts are therefore accumulated from
|
||||
/// each packet's TOC duration, resynchronising to the timestamp only across real gaps (more than
|
||||
/// two ticks off). Pre-skip is the exact `CodecDelay` (or the header's pre-skip when it is absent).
|
||||
/// The demuxer also subtracts a rounded `CodecDelay` from the timestamps. Starts are accumulated
|
||||
/// from each packet's TOC duration, resynchronising to the timestamp only across real gaps (more
|
||||
/// than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's pre-skip when it is
|
||||
/// absent).
|
||||
fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_opus::OpusHead, rate: i64) -> Vec<i64> {
|
||||
let t = &file.tracks[ti];
|
||||
let Some(t) = file.tracks.get(ti) else { return Vec::new() };
|
||||
let (n, d) = tb(t);
|
||||
let scale_ns = (n as i128 * 1_000_000_000 / d as i128).max(1);
|
||||
let delay_ns = t.codec_delay_ns as i128;
|
||||
// The demuxer's rounding of CodecDelay to ticks (half away from zero), undone here.
|
||||
let delay_ticks = ((delay_ns + scale_ns / 2) / scale_ns) as i64;
|
||||
let skip = if t.codec_delay_ns > 0 { (delay_ns * rate as i128 / 1_000_000_000) as i64 } else { head.pre_skip as i64 };
|
||||
let at = |pts: i64| ((pts + delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64 - skip;
|
||||
let tolerance = (2 * scale_ns * rate as i128 / 1_000_000_000) as i64 + 1;
|
||||
let mut starts = Vec::with_capacity(t.samples.len());
|
||||
let mut next: Option<i64> = None;
|
||||
for (i, s) in t.samples.iter().enumerate() {
|
||||
let stamped = at(s.pts);
|
||||
let start = match next {
|
||||
Some(p) if (p - stamped).abs() <= tolerance => p,
|
||||
_ => stamped,
|
||||
};
|
||||
starts.push(start);
|
||||
let dur = file.read_sample(bytes, ti, i).ok().and_then(|p| crate::audio::opus_packet_samples(&p));
|
||||
next = dur.map(|k| start + k as i64);
|
||||
}
|
||||
starts
|
||||
let at = |pts: i64| ((pts.saturating_add(delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64).saturating_sub(skip);
|
||||
let packets = t.samples.iter().enumerate().map(|(i, s)| crate::audio::PacketTime {
|
||||
stamp: at(s.pts),
|
||||
stamped: own_stamp(s),
|
||||
samples: file.read_sample(bytes, ti, i).ok().and_then(|p| crate::audio::opus_packet_samples(&p)).map(|k| k as i64),
|
||||
});
|
||||
crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate))
|
||||
}
|
||||
|
||||
/// Two timestamp ticks in sample frames at `rate`, plus one: how far a packet's Matroska timestamp
|
||||
/// can be from its exact start.
|
||||
fn stamp_tolerance(t: &filmcraft_matroska::Track, rate: i64) -> i64 {
|
||||
let (n, d) = tb(t);
|
||||
i64::try_from(2 * n as i128 * rate as i128 / d as i128).unwrap_or(i64::MAX).saturating_add(1)
|
||||
}
|
||||
|
||||
/// Whether a sample's timestamp is its own: frames after the first in a laced block without a
|
||||
/// duration repeat the block's timestamp.
|
||||
fn own_stamp(s: &filmcraft_matroska::Sample) -> bool {
|
||||
s.lace == 0 || s.duration > 0
|
||||
}
|
||||
|
||||
/// The parsed `OpusHead` of an `A_OPUS` track.
|
||||
@@ -311,18 +350,7 @@ impl MkvSource {
|
||||
start_timecode: None,
|
||||
file_size: Some(bytes.0.len()),
|
||||
};
|
||||
let audio_starts = atrack
|
||||
.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let rate = info.audio.as_ref().map_or(48_000, |a| a.sample_rate) as i64;
|
||||
let (n, d) = tb(t);
|
||||
let at = |pts: i64| (pts as i128 * n as i128 * rate as i128 / d as i128) as i64;
|
||||
match opus_head(&t.codec) {
|
||||
Some(h) => opus_starts(&file, &bytes, i, &h, rate),
|
||||
None => t.samples.iter().map(|s| at(s.pts)).collect(),
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let audio_starts = atrack.map(|i| audio_starts(&file, &bytes, i, info.audio.as_ref().map_or(48_000, |a| a.sample_rate) as i64)).unwrap_or_default();
|
||||
let audio_preroll = atrack
|
||||
.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
|
||||
+42
-21
@@ -37,11 +37,11 @@ pub struct Mp4Source {
|
||||
atrack: Option<usize>,
|
||||
video: GopCache,
|
||||
audio: Mutex<AudioState>,
|
||||
/// Cumulative sample start frames for the audio track (for packet lookup).
|
||||
/// Audio packet start positions in source sample frames (see [`audio_starts`]).
|
||||
audio_starts: Vec<i64>,
|
||||
/// Presentation offset of the audio track in its timescale (edit list, or Opus pre-skip).
|
||||
audio_offset: i64,
|
||||
/// Decoder pre-roll after a seek, in audio track timescale units (0: prime with one packet).
|
||||
/// Decoder pre-roll after a seek, in source sample frames (0: prime with one packet).
|
||||
audio_preroll: i64,
|
||||
}
|
||||
|
||||
@@ -49,6 +49,40 @@ pub fn sniff(b: &[u8]) -> bool {
|
||||
b.len() >= 12 && matches!(&b[4..8], b"ftyp" | b"moov" | b"mdat" | b"wide" | b"free" | b"skip")
|
||||
}
|
||||
|
||||
/// Audio packet start positions in source sample frames at `rate`: the running total of the sample
|
||||
/// durations, except that packets which all decode to the same length (AAC, MPEG audio, AC-3) run
|
||||
/// on from one another, resynchronising to the total only across gaps of more than half a packet.
|
||||
/// A remux from Matroska (OBS's) carries the millisecond rounding of its timestamps into the
|
||||
/// durations: 1008, 1008, 1056… for 1024-sample AAC frames at 48 kHz (see
|
||||
/// [`crate::audio::contiguous_starts`]).
|
||||
fn audio_starts(file: &Mp4File, bytes: &crate::Src, ti: usize, rate: u32) -> Vec<i64> {
|
||||
use crate::audio::{FixedFrames, PacketTime, contiguous_starts, fixed_packet_samples};
|
||||
let Some(t) = file.tracks.get(ti) else { return Vec::new() };
|
||||
let ts = i128::from(t.timescale.max(1));
|
||||
let mut total = 0i128;
|
||||
let stamps: Vec<i64> = t
|
||||
.samples
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let at = total * i128::from(rate) / ts;
|
||||
total += i128::from(s.duration);
|
||||
i64::try_from(at).unwrap_or(i64::MAX)
|
||||
})
|
||||
.collect();
|
||||
let first = || file.read_sample(bytes, ti, 0).unwrap_or_default();
|
||||
let frame = match t.entries.first().map(|e| &e.codec) {
|
||||
Some(CodecConfig::Aac(a)) => fixed_packet_samples(FixedFrames::Aac(&a.asc), &[], rate),
|
||||
Some(CodecConfig::Mp3) => fixed_packet_samples(FixedFrames::MpegAudio, &first(), rate),
|
||||
Some(CodecConfig::Ac3 { .. }) => fixed_packet_samples(FixedFrames::Ac3, &first(), rate),
|
||||
_ => None,
|
||||
};
|
||||
let Some(frame) = frame else { return stamps };
|
||||
// samples per track timescale unit, rounded up: how coarse the running total is
|
||||
let unit = i64::try_from((i128::from(rate) + ts - 1) / ts).unwrap_or(i64::MAX);
|
||||
let packets = stamps.iter().map(|&stamp| PacketTime { stamp, stamped: true, samples: Some(frame) });
|
||||
contiguous_starts(packets, (frame / 2).max(unit.saturating_mul(2).saturating_add(1)))
|
||||
}
|
||||
|
||||
/// HDR static metadata from the sample entry's `mdcv` / `clli` boxes (0 = unknown).
|
||||
fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u16, u16)>) -> Option<filmcraft_color::HdrMetadata> {
|
||||
if md.is_none() && cll.is_none() {
|
||||
@@ -240,20 +274,8 @@ impl Mp4Source {
|
||||
start_timecode,
|
||||
file_size: Some(bytes.0.len()),
|
||||
};
|
||||
let audio_starts = atrack
|
||||
.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let mut acc = 0i64;
|
||||
t.samples
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let st = acc;
|
||||
acc += s.duration as i64;
|
||||
st
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let audio_rate = info.audio.as_ref().map_or(48_000, |a| a.sample_rate);
|
||||
let audio_starts = atrack.map(|i| audio_starts(&file, &bytes, i, audio_rate)).unwrap_or_default();
|
||||
let (audio_offset, audio_preroll) = atrack
|
||||
.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
@@ -261,7 +283,8 @@ impl Mp4Source {
|
||||
match &t.entries[0].codec {
|
||||
CodecConfig::Opus(o) => {
|
||||
let off = if t.edit_offset != 0 { t.edit_offset } else { -(o.pre_skip as i64) * ts / 48_000 };
|
||||
(off, (crate::audio::OPUS_PRE_ROLL as u64 * ts as u64).div_ceil(48_000) as i64)
|
||||
// (Opus plays at 48 kHz: the pre-roll is in its sample frames)
|
||||
(off, crate::audio::OPUS_PRE_ROLL as i64)
|
||||
}
|
||||
_ => (t.edit_offset, 0),
|
||||
}
|
||||
@@ -393,14 +416,12 @@ impl MediaSource for Mp4Source {
|
||||
let offset = i128::from(self.audio_offset) * i128::from(src_rate) / i128::from(track.timescale.max(1));
|
||||
let s0 = i64::try_from(i128::from(p0) - offset).unwrap_or(if offset > 0 { i64::MIN } else { i64::MAX });
|
||||
let need = (frames as f64 * ratio).ceil() as i64 + 2;
|
||||
// Samples-per-unit: the track timescale is usually the sample rate for audio.
|
||||
let unit = src_rate as f64 / track.timescale.max(1) as f64;
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
|
||||
let mut st = self.audio.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let first = self.audio_starts.partition_point(|&x| (x as f64 * unit) as i64 <= s0.max(0)).saturating_sub(1);
|
||||
let first = self.audio_starts.partition_point(|&x| x <= s0.max(0)).saturating_sub(1);
|
||||
let mut i = first;
|
||||
while i < track.samples.len() {
|
||||
let pk_start = (self.audio_starts[i] as f64 * unit) as i64;
|
||||
let pk_start = self.audio_starts[i];
|
||||
if pk_start >= s0.saturating_add(need) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -45,8 +45,19 @@ pub trait VideoDecoder: Send {
|
||||
/// shortcuts that only change pictures nothing references, which come out flagged
|
||||
/// [`DecodedFrame::draft`]. Decoders without such a mode ignore it.
|
||||
fn set_draft(&mut self, _on: bool) {}
|
||||
/// Made with fewer worker threads than a decoder for playback gets, because it was made for
|
||||
/// background work ([`filmcraft_media::cancel::with_background`]). A request for a monitor
|
||||
/// replaces such a decoder.
|
||||
fn thread_limited(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker threads of the H.264 decoders made for background work (thumbnails): a frame-threaded
|
||||
/// 4K decoder holds `threads + 2` pictures in flight (~0.5 GB with 16 threads), and the frame
|
||||
/// workers already decode different files in parallel.
|
||||
const BACKGROUND_H264_THREADS: usize = 2;
|
||||
|
||||
/// NAL unit headers of a length-prefixed (avcC / hvcC) sample: the first two bytes of each unit.
|
||||
fn nal_headers(sample: &[u8], length_size: usize) -> impl Iterator<Item = (u8, u8)> + '_ {
|
||||
let mut pos = 0usize;
|
||||
@@ -200,19 +211,36 @@ pub struct H264Decoder {
|
||||
dec: filmcraft_h264::Decoder,
|
||||
length_size: usize,
|
||||
draft: bool,
|
||||
/// Worker threads (a background decoder gets fewer).
|
||||
threads: usize,
|
||||
}
|
||||
|
||||
impl H264Decoder {
|
||||
/// The worker threads for a decoder made now.
|
||||
fn threads_now() -> usize {
|
||||
if filmcraft_media::cancel::background() { BACKGROUND_H264_THREADS.min(h264_threads()) } else { h264_threads() }
|
||||
}
|
||||
|
||||
fn make_inner(avcc: &[u8], threads: usize) -> Result<filmcraft_h264::Decoder> {
|
||||
let mut dec = filmcraft_h264::Decoder::with_threads(threads);
|
||||
if !avcc.is_empty() {
|
||||
dec.configure_avcc(avcc).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
}
|
||||
Ok(dec)
|
||||
}
|
||||
|
||||
pub fn new(avcc: Vec<u8>) -> Result<Self> {
|
||||
recycle_h264_planes();
|
||||
let dec = filmcraft_h264::Decoder::from_avcc(&avcc).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let threads = Self::threads_now();
|
||||
let dec = Self::make_inner(&avcc, threads)?;
|
||||
let length_size = avcc_length_size(&avcc);
|
||||
Ok(Self { avcc, dec, length_size, draft: false })
|
||||
Ok(Self { avcc, dec, length_size, draft: false, threads })
|
||||
}
|
||||
/// A decoder for Annex B byte-stream samples (start codes, in-band parameter sets: MXF, TS).
|
||||
pub fn annexb() -> Self {
|
||||
recycle_h264_planes();
|
||||
Self { avcc: Vec::new(), dec: filmcraft_h264::Decoder::new(), length_size: 0, draft: false }
|
||||
let threads = Self::threads_now();
|
||||
Self { avcc: Vec::new(), dec: filmcraft_h264::Decoder::with_threads(threads), length_size: 0, draft: false, threads }
|
||||
}
|
||||
fn convert(p: filmcraft_h264::Picture) -> DecodedFrame {
|
||||
use std::sync::Arc;
|
||||
@@ -245,9 +273,7 @@ impl VideoDecoder for H264Decoder {
|
||||
self.dec.flush().into_iter().map(Self::convert).collect()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
if self.avcc.is_empty() {
|
||||
self.dec = filmcraft_h264::Decoder::new();
|
||||
} else if let Ok(d) = filmcraft_h264::Decoder::from_avcc(&self.avcc) {
|
||||
if let Ok(d) = Self::make_inner(&self.avcc, self.threads) {
|
||||
self.dec = d;
|
||||
}
|
||||
self.dec.set_draft(self.draft);
|
||||
@@ -255,6 +281,9 @@ impl VideoDecoder for H264Decoder {
|
||||
fn name(&self) -> &str {
|
||||
"FilmCraft H.264"
|
||||
}
|
||||
fn thread_limited(&self) -> bool {
|
||||
self.threads < h264_threads()
|
||||
}
|
||||
fn set_draft(&mut self, on: bool) {
|
||||
self.draft = on;
|
||||
self.dec.set_draft(on);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Matroska audio made by ffmpeg, against ffmpeg's decode (#236). ffmpeg stamps blocks to the
|
||||
//! millisecond, so at 44.1 kHz AAC (1024-sample), MP3 (1152) and AC-3 (1536) packets are each
|
||||
//! stamped up to half a millisecond off their exact start. Read back block by block, as playback
|
||||
//! reads it, a tone must stay as smooth as ffmpeg's decode of it: no holes or overlaps between
|
||||
//! packets.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
/// Largest |second difference| of `x` from `from` on: a click shows up as a spike.
|
||||
fn roughness(x: &[f32], from: usize) -> f32 {
|
||||
x.windows(3).skip(from).map(|w| (w[0] - 2.0 * w[1] + w[2]).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matroska_audio_plays_without_holes() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let cases: [(&str, u32, &[&str]); 5] = [
|
||||
("tone_aac_48k.mkv", 48_000, &["-c:a", "aac", "-b:a", "160k"]),
|
||||
("tone_aac_44k.mkv", 44_100, &["-c:a", "aac", "-b:a", "160k"]),
|
||||
("tone_mp3_44k.mkv", 44_100, &["-c:a", "libmp3lame", "-b:a", "192k"]),
|
||||
("tone_mp2_44k.mkv", 44_100, &["-c:a", "mp2", "-b:a", "192k"]),
|
||||
("tone_ac3_44k.mkv", 44_100, &["-c:a", "ac3", "-b:a", "192k"]),
|
||||
];
|
||||
for (name, rate, codec) in cases {
|
||||
let input = format!("sine=frequency=440:sample_rate={rate}:duration=3");
|
||||
let args: Vec<&str> = ["-f", "lavfi", "-i", input.as_str(), "-af", "volume=4"].iter().copied().chain(codec.iter().copied()).collect();
|
||||
let Some(path) = fixture(&ff, name, &args) else {
|
||||
eprintln!("SKIPPED ({name}): this ffmpeg can't encode it");
|
||||
continue;
|
||||
};
|
||||
let src = filmcraft_codecs::open_bytes(name, bytes(&path)).unwrap();
|
||||
assert_eq!(src.info().audio.as_ref().map(|a| a.sample_rate), Some(rate), "{name}");
|
||||
let frames = rate as usize * 29 / 10;
|
||||
let mut ours = Vec::with_capacity(frames);
|
||||
while ours.len() < frames {
|
||||
let n = 512.min(frames - ours.len());
|
||||
ours.extend_from_slice(&src.audio(ours.len() as i64, n, rate).unwrap().channels[0]);
|
||||
}
|
||||
let theirs = ffmpeg_audio_f32(&ff, &path, &[]);
|
||||
// past the codecs' start-up (priming, MP3's first frame)
|
||||
let skip = rate as usize / 10;
|
||||
let (r_ours, r_theirs) = (roughness(&ours, skip), roughness(&theirs[..theirs.len().min(frames)], skip));
|
||||
let peak = ours.iter().skip(skip).fold(0f32, |m, x| m.max(x.abs()));
|
||||
println!("{name}: peak {peak:.3}, max |second difference| ours {r_ours:.5}, ffmpeg {r_theirs:.5}");
|
||||
assert!(peak > 0.4, "{name}: the tone is missing (peak {peak})");
|
||||
assert!(r_ours < 2.0 * r_theirs + 0.005, "{name}: clicks between packets (|Δ²| {r_ours} vs ffmpeg's {r_theirs})");
|
||||
}
|
||||
}
|
||||
@@ -177,15 +177,64 @@ fn open(p: &Path) -> MpegSource {
|
||||
MpegSource::open(p.file_name().unwrap().to_str().unwrap(), bytes(p)).unwrap()
|
||||
}
|
||||
|
||||
/// ffprobe's video frames in display order: pts (90 kHz).
|
||||
/// ffprobe's video frames in display order: PTS (90 kHz). A program-stream picture
|
||||
/// need not carry its own PTS; use the decoder's presentation timestamp for that picture.
|
||||
fn ffprobe_frame_pts(file: &Path) -> Option<Vec<i64>> {
|
||||
let fp = filmcraft_testkit::ffprobe()?;
|
||||
let fp = filmcraft_testkit::ffprobe_or_skip("MPEG video frame presentation timestamps")?;
|
||||
let o = std::process::Command::new(fp)
|
||||
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts", "-of", "csv=p=0"])
|
||||
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts,best_effort_timestamp", "-of", "json"])
|
||||
.arg(file)
|
||||
.output()
|
||||
.ok()?;
|
||||
Some(String::from_utf8_lossy(&o.stdout).lines().filter_map(|l| l.trim().trim_end_matches(',').parse().ok()).collect())
|
||||
.unwrap_or_else(|error| panic!("{}: cannot run ffprobe: {error}", file.display()));
|
||||
assert!(o.status.success(), "{}: ffprobe failed: {}", file.display(), String::from_utf8_lossy(&o.stderr));
|
||||
Some(parse_ffprobe_frame_pts(&o.stdout).unwrap_or_else(|error| panic!("{}: {error}", file.display())))
|
||||
}
|
||||
|
||||
fn parse_ffprobe_frame_pts(bytes: &[u8]) -> Result<Vec<i64>, String> {
|
||||
let data: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| format!("invalid ffprobe JSON: {error}"))?;
|
||||
let frames = data.get("frames").and_then(serde_json::Value::as_array).ok_or("ffprobe output is missing its frames array")?;
|
||||
if frames.is_empty() {
|
||||
return Err("ffprobe returned no video frames".into());
|
||||
}
|
||||
frames
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, frame)| {
|
||||
let field = if frame.get("pts").is_some() { "pts" } else { "best_effort_timestamp" };
|
||||
frame.get(field).and_then(serde_json::Value::as_i64).ok_or_else(|| format!("ffprobe video frame {index} is missing an integer {field}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffprobe_pts_preserve_missing_raw_timestamps_and_side_data() {
|
||||
let output = br#"{"frames":[
|
||||
{"pts":131400,"best_effort_timestamp":131401,"side_data_list":[{},{}]},
|
||||
{"best_effort_timestamp":135000,"side_data_list":[{}]},
|
||||
{"pts":138600,"best_effort_timestamp":138600}
|
||||
]}"#;
|
||||
assert_eq!(parse_ffprobe_frame_pts(output).unwrap(), [131400, 135000, 138600], "keep every display frame, with raw PTS preferred when present");
|
||||
assert_eq!(parse_ffprobe_frame_pts(br#"{"frames":[{"pts":-3600},{"pts":0},{"pts":9223372036854775807}]}"#).unwrap(), [-3600, 0, i64::MAX]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ffprobe_pts_fail_instead_of_dropping_frames() {
|
||||
for output in [
|
||||
"not JSON",
|
||||
"{}",
|
||||
r#"{"frames":{}}"#,
|
||||
r#"{"frames":[]}"#,
|
||||
r#"{"frames":[{}]}"#,
|
||||
r#"{"frames":[null]}"#,
|
||||
r#"{"frames":[{"pts":"N/A","best_effort_timestamp":135000}]}"#,
|
||||
r#"{"frames":[{"pts":null,"best_effort_timestamp":135000}]}"#,
|
||||
r#"{"frames":[{"pts":1.5}]}"#,
|
||||
r#"{"frames":[{"pts":9223372036854775808}]}"#,
|
||||
r#"{"frames":[{"best_effort_timestamp":"135000"}]}"#,
|
||||
r#"{"frames":[{"pts":131400},{"best_effort_timestamp":null},{"pts":138600}]}"#,
|
||||
] {
|
||||
assert!(parse_ffprobe_frame_pts(output.as_bytes()).is_err(), "malformed oracle output must fail: {output}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode every frame in order and `seeks` random frames; compare with ffmpeg within `tol`.
|
||||
|
||||
@@ -16,5 +16,6 @@ thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
filmcraft-geom = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Sequence Settings ▸ "Scale motion effects proportionally when changing frame size".
|
||||
//!
|
||||
//! Motion's Position is in sequence pixels and its Scale is relative to the source, so a new frame
|
||||
//! size leaves every clip where it was in pixels: smaller and off-centre in a larger frame, cropped
|
||||
//! in a smaller one. With the option on, the whole picture scales instead, by one factor about the
|
||||
//! frame centre. On an aspect change (16:9 → 9:16) that factor is the one that fits the old frame
|
||||
//! inside the new one (`min(W1/W0, H1/H0)`), so nothing is cropped and the old composition sits
|
||||
//! centred, letterboxed or pillarboxed, rather than filling the new frame.
|
||||
|
||||
use filmcraft_project::{ParamValue, Sequence, TrackKind};
|
||||
|
||||
/// Motion Scale's upper bound (the parameter's range, in %).
|
||||
const MAX_SCALE: f64 = 10_000.0;
|
||||
|
||||
/// The uniform factor `k` and the old and new frame centres for `from` → `to`, or `None` for an
|
||||
/// empty frame on either side.
|
||||
fn fit(from: (u32, u32), to: (u32, u32)) -> Option<(f64, (f64, f64), (f64, f64))> {
|
||||
if from.0 == 0 || from.1 == 0 || to.0 == 0 || to.1 == 0 {
|
||||
return None;
|
||||
}
|
||||
let (w0, h0, w1, h1) = (f64::from(from.0), f64::from(from.1), f64::from(to.0), f64::from(to.1));
|
||||
let k = (w1 / w0).min(h1 / h0);
|
||||
(k.is_finite() && k > 0.0).then_some((k, (w0 / 2.0, h0 / 2.0), (w1 / 2.0, h1 / 2.0)))
|
||||
}
|
||||
|
||||
/// Rescale the Motion of every video clip in `seq` for a frame-size change from `from` to `to`:
|
||||
/// static values and every keyframe. Returns how many clips were rescaled (0 when the size does not
|
||||
/// change).
|
||||
///
|
||||
/// - Position maps through `p' = c1 + k·(p − c0)`. An automatic (NaN) position stays automatic: it
|
||||
/// follows the frame centre on its own.
|
||||
/// - Scale and Scale Width are multiplied by `k`, up to the parameter's 10 000 %. A clip with Scale to
|
||||
/// Frame Size keeps its Scale, because the renderer refits it to the new frame anyway.
|
||||
/// - Anchor Point (source pixels) and Rotation do not depend on the frame size and stay as they are.
|
||||
/// - Only the intrinsic Motion effect changes, as the option's name says: the points of standard
|
||||
/// effects (a Mirror centre, the Transform effect's Position) keep their pixel values.
|
||||
pub fn scale_motion(seq: &mut Sequence, from: (u32, u32), to: (u32, u32)) -> usize {
|
||||
if from == to {
|
||||
return 0;
|
||||
}
|
||||
let Some((k, c0, c1)) = fit(from, to) else { return 0 };
|
||||
let mut n = 0;
|
||||
for track in seq.all_tracks_mut().filter(|t| t.kind == TrackKind::Video) {
|
||||
for item in &mut track.items {
|
||||
let refits = item.scale_to_frame;
|
||||
let Some(motion) = item.effect_mut("motion") else { continue };
|
||||
if let Some(p) = motion.param_mut("position") {
|
||||
p.map_values(|v| match v {
|
||||
ParamValue::Vec2(mut v) => {
|
||||
v.x = c1.0 + k * (v.x - c0.0);
|
||||
v.y = c1.1 + k * (v.y - c0.1);
|
||||
ParamValue::Vec2(v)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
}
|
||||
if !refits {
|
||||
for id in ["scale", "scale_width"] {
|
||||
if let Some(p) = motion.param_mut(id) {
|
||||
p.map_values(|v| match v {
|
||||
ParamValue::Float(s) => ParamValue::Float((s * k).clamp(0.0, MAX_SCALE)),
|
||||
other => other,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use filmcraft_project::{ClipId, ItemId, Keyframe, Label, Project, SequenceSettings, TrackItem, find_effect};
|
||||
use filmcraft_time::Tick;
|
||||
|
||||
fn seq_with(items: Vec<TrackItem>) -> Sequence {
|
||||
let mut p = Project::new("t");
|
||||
let s = p.new_sequence("s", SequenceSettings::default(), 2, 1, None);
|
||||
let mut seq = p.sequence(s).unwrap().clone();
|
||||
seq.video_tracks[0].items = items;
|
||||
seq
|
||||
}
|
||||
|
||||
fn clip(id: u64, pos: (f64, f64), scale: f64) -> TrackItem {
|
||||
let mut motion = find_effect("motion").unwrap().instance();
|
||||
motion.param_mut("position").unwrap().value = ParamValue::Vec2(filmcraft_geom::Vec2::new(pos.0, pos.1));
|
||||
motion.param_mut("scale").unwrap().value = ParamValue::Float(scale);
|
||||
TrackItem {
|
||||
id: ClipId(id),
|
||||
item: ItemId(1),
|
||||
name: format!("c{id}"),
|
||||
label: Label::Iris,
|
||||
start: Tick(id as i64 * 1_000_000_000_000),
|
||||
duration: Tick(1_000_000_000_000),
|
||||
source_in: Tick::ZERO,
|
||||
speed: 1.0,
|
||||
reverse: false,
|
||||
enabled: true,
|
||||
link: None,
|
||||
group: None,
|
||||
effects: vec![motion],
|
||||
markers: vec![],
|
||||
gain_db: 0.0,
|
||||
frame_hold: None,
|
||||
scale_to_frame: false,
|
||||
essential: None,
|
||||
multicam: None,
|
||||
time_interpolation: Default::default(),
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
graphic: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pos(seq: &Sequence, i: usize) -> (f64, f64) {
|
||||
match seq.video_tracks[0].items[i].effect("motion").unwrap().param("position").unwrap().value {
|
||||
ParamValue::Vec2(v) => (v.x, v.y),
|
||||
_ => panic!("position is a point"),
|
||||
}
|
||||
}
|
||||
|
||||
fn scale(seq: &Sequence, i: usize) -> f64 {
|
||||
match seq.video_tracks[0].items[i].effect("motion").unwrap().param("scale").unwrap().value {
|
||||
ParamValue::Float(s) => s,
|
||||
_ => panic!("scale is a number"),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: (f64, f64), b: (f64, f64)) -> bool {
|
||||
(a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_aspect_scales_position_and_scale_together() {
|
||||
let mut seq = seq_with(vec![clip(1, (960.0, 540.0), 100.0), clip(2, (300.0, 90.0), 50.0)]);
|
||||
assert_eq!(scale_motion(&mut seq, (1920, 1080), (1280, 720)), 2);
|
||||
assert!(close(pos(&seq, 0), (640.0, 360.0)), "{:?}", pos(&seq, 0));
|
||||
assert!(close(pos(&seq, 1), (200.0, 60.0)), "{:?}", pos(&seq, 1));
|
||||
assert!((scale(&seq, 0) - 100.0 * 2.0 / 3.0).abs() < 1e-9);
|
||||
assert!((scale(&seq, 1) - 50.0 * 2.0 / 3.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_change_fits_the_old_frame_inside_the_new_one() {
|
||||
// 1920x1080 -> 1080x1920: k = min(1080/1920, 1920/1080) = 0.5625, centred
|
||||
let mut seq = seq_with(vec![clip(1, (960.0, 540.0), 100.0), clip(2, (0.0, 0.0), 100.0)]);
|
||||
scale_motion(&mut seq, (1920, 1080), (1080, 1920));
|
||||
assert!(close(pos(&seq, 0), (540.0, 960.0)), "{:?}", pos(&seq, 0));
|
||||
// the old top-left corner lands on the new frame's left edge, letterboxed vertically
|
||||
assert!(close(pos(&seq, 1), (0.0, 960.0 - 0.5625 * 540.0)), "{:?}", pos(&seq, 1));
|
||||
assert!((scale(&seq, 0) - 56.25).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_scale_too() {
|
||||
let mut c = clip(1, (960.0, 540.0), 100.0);
|
||||
let m = c.effect_mut("motion").unwrap();
|
||||
m.param_mut("position").unwrap().keyframes = vec![
|
||||
Keyframe::new(Tick::ZERO, ParamValue::Vec2(filmcraft_geom::Vec2::new(0.0, 0.0))),
|
||||
Keyframe::new(Tick(10), ParamValue::Vec2(filmcraft_geom::Vec2::new(1920.0, 1080.0))),
|
||||
];
|
||||
m.param_mut("scale").unwrap().keyframes = vec![Keyframe::new(Tick::ZERO, ParamValue::Float(100.0)), Keyframe::new(Tick(10), ParamValue::Float(200.0))];
|
||||
let mut seq = seq_with(vec![c]);
|
||||
scale_motion(&mut seq, (1920, 1080), (3840, 2160));
|
||||
let m = seq.video_tracks[0].items[0].effect("motion").unwrap();
|
||||
let kp: Vec<_> = m.param("position").unwrap().keyframes.iter().map(|k| k.value.clone()).collect();
|
||||
assert_eq!(kp, vec![ParamValue::Vec2(filmcraft_geom::Vec2::new(0.0, 0.0)), ParamValue::Vec2(filmcraft_geom::Vec2::new(3840.0, 2160.0))]);
|
||||
let ks: Vec<_> = m.param("scale").unwrap().keyframes.iter().map(|k| k.value.clone()).collect();
|
||||
assert_eq!(ks, vec![ParamValue::Float(200.0), ParamValue::Float(400.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_position_stays_automatic() {
|
||||
let mut seq = seq_with(vec![clip(1, (f64::NAN, f64::NAN), 100.0)]);
|
||||
scale_motion(&mut seq, (1920, 1080), (1280, 720));
|
||||
let (x, y) = pos(&seq, 0);
|
||||
assert!(x.is_nan() && y.is_nan());
|
||||
assert!((scale(&seq, 0) - 100.0 * 2.0 / 3.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_to_frame_clips_keep_their_scale_but_move() {
|
||||
let mut c = clip(1, (480.0, 270.0), 100.0);
|
||||
c.scale_to_frame = true;
|
||||
let mut seq = seq_with(vec![c]);
|
||||
scale_motion(&mut seq, (1920, 1080), (3840, 2160));
|
||||
assert!(close(pos(&seq, 0), (960.0, 540.0)), "{:?}", pos(&seq, 0));
|
||||
assert_eq!(scale(&seq, 0), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_is_capped_at_the_parameter_range() {
|
||||
let mut seq = seq_with(vec![clip(1, (1.0, 1.0), 5000.0)]);
|
||||
scale_motion(&mut seq, (2, 2), (8, 8));
|
||||
assert_eq!(scale(&seq, 0), MAX_SCALE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_size_or_empty_frame_changes_nothing() {
|
||||
let mut seq = seq_with(vec![clip(1, (100.0, 100.0), 100.0)]);
|
||||
let before = seq.clone();
|
||||
assert_eq!(scale_motion(&mut seq, (1920, 1080), (1920, 1080)), 0);
|
||||
assert_eq!(scale_motion(&mut seq, (0, 1080), (1280, 720)), 0);
|
||||
assert_eq!(scale_motion(&mut seq, (1920, 1080), (1280, 0)), 0);
|
||||
// Debug, not ==: the automatic anchor is NaN, which never equals itself
|
||||
assert_eq!(format!("{:?}", seq.video_tracks[0].items[0].effects), format!("{:?}", before.video_tracks[0].items[0].effects));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod captions;
|
||||
pub mod frame_size;
|
||||
pub mod multicam;
|
||||
pub mod through;
|
||||
pub mod transcript;
|
||||
|
||||
@@ -22,12 +22,13 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use std::time::Duration;
|
||||
|
||||
use filmcraft_format::autosave as names;
|
||||
use filmcraft_project::Project;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use web_time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::settings;
|
||||
|
||||
@@ -470,7 +471,17 @@ impl Preferences {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user data directory (`FILMCRAFT_DATA_DIR` overrides):
|
||||
/// A file with this name next to the executable makes the install portable: its data directory is
|
||||
/// `<exe dir>/data` instead of the per-user one (the Windows portable zip ships it).
|
||||
pub const PORTABLE_MARKER: &str = "portable.txt";
|
||||
|
||||
/// `<exe_dir>/data` when `exe_dir` holds [`PORTABLE_MARKER`], else `None`.
|
||||
pub fn portable_data_dir(exe_dir: &Path) -> Option<PathBuf> {
|
||||
exe_dir.join(PORTABLE_MARKER).is_file().then(|| exe_dir.join("data"))
|
||||
}
|
||||
|
||||
/// The data directory. `FILMCRAFT_DATA_DIR` overrides; a portable install ([`PORTABLE_MARKER`] next
|
||||
/// to the executable) uses `<exe dir>/data`; otherwise the per-user directory:
|
||||
/// macOS `~/Library/Application Support/FilmCraft`, Windows `%APPDATA%\FilmCraft`,
|
||||
/// elsewhere `$XDG_DATA_HOME/filmcraft` or `~/.local/share/filmcraft`.
|
||||
pub fn default_data_dir() -> Option<PathBuf> {
|
||||
@@ -478,6 +489,9 @@ pub fn default_data_dir() -> Option<PathBuf> {
|
||||
if let Some(d) = env("FILMCRAFT_DATA_DIR") {
|
||||
return Some(d);
|
||||
}
|
||||
if let Some(d) = std::env::current_exe().ok().and_then(|exe| exe.parent().and_then(portable_data_dir)) {
|
||||
return Some(d);
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
return env("HOME").map(|h| h.join("Library/Application Support/FilmCraft"));
|
||||
}
|
||||
|
||||
@@ -176,6 +176,19 @@ fn auto_save_ring_rotates_and_can_save_the_project() {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_marker_keeps_data_next_to_the_executable() {
|
||||
// #139: a portable install must not write to %APPDATA%.
|
||||
let d = temp_dir("portable");
|
||||
assert_eq!(autosave::portable_data_dir(&d), None);
|
||||
std::fs::create_dir_all(d.join(autosave::PORTABLE_MARKER)).unwrap();
|
||||
assert_eq!(autosave::portable_data_dir(&d), None, "a directory is not the marker");
|
||||
std::fs::remove_dir_all(d.join(autosave::PORTABLE_MARKER)).unwrap();
|
||||
std::fs::write(d.join(autosave::PORTABLE_MARKER), "").unwrap();
|
||||
assert_eq!(autosave::portable_data_dir(&d), Some(d.join("data")));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_costs_the_ui_thread_only_a_handoff() {
|
||||
// Per change the UI thread pays an Arc clone and a channel send; encoding happens on the worker.
|
||||
|
||||
@@ -131,9 +131,16 @@ pub(crate) fn named_clips(s: &Session, spec: &CommandSpec, p: &Value) -> Option<
|
||||
let clips: Vec<ClipId> = named_ids(spec, p, "clips", "clip")?.into_iter().map(ClipId).filter(|c| seq.find_item(*c).is_some()).collect();
|
||||
(!clips.is_empty()).then_some(clips)
|
||||
}
|
||||
/// Project items named explicitly in `items` / `item`, likewise.
|
||||
/// Project items named explicitly in `items` / `item`, likewise. The Project panel's selection
|
||||
/// holds bins beside items (one id space, see `project.select`), so a bin named here counts too;
|
||||
/// the project's own top bin does not, since no command acts on it (#244).
|
||||
pub(crate) fn named_items(s: &Session, spec: &CommandSpec, p: &Value) -> Option<Vec<ItemId>> {
|
||||
let items: Vec<ItemId> = named_ids(spec, p, "items", "item")?.into_iter().map(ItemId).filter(|i| s.project.item(*i).is_some()).collect();
|
||||
let root = s.project.root.id;
|
||||
let exists = |i: &ItemId| {
|
||||
let b = filmcraft_project::BinId(i.0);
|
||||
s.project.item(*i).is_some() || (b != root && s.project.root.find_bin(b).is_some())
|
||||
};
|
||||
let items: Vec<ItemId> = named_ids(spec, p, "items", "item")?.into_iter().map(ItemId).filter(exists).collect();
|
||||
(!items.is_empty()).then_some(items)
|
||||
}
|
||||
|
||||
@@ -1071,7 +1078,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Media…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
has_seq,
|
||||
crate::export_tools::export_media
|
||||
),
|
||||
@@ -1513,41 +1520,70 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Sequence Settings…",
|
||||
["Sequence"],
|
||||
None,
|
||||
r#"{"width":u32?,"height":u32?,"fps":f64?,"name":str?,"sampleRate":u32?,"mix":"Stereo|Mono|5.1|Adaptive"?}"#,
|
||||
r#"{"width":u32?,"height":u32?,"fps":f64?,"name":str?,"sampleRate":u32?,"mix":"Stereo|Mono|5.1|Adaptive"?,"dropFrame":bool?,"maxRenderQuality":bool?,"workingSpace":"rec709"|"rec2100-pq"|"rec2100-hlg"?,"wideGamut":bool?,"autoToneMap":bool?,"scaleMotion":bool=false}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
const CMD: &str = "sequence.settings";
|
||||
let id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let fps = fps_p(p, "sequence.settings")?;
|
||||
let fps = fps_p(p, CMD)?;
|
||||
let mix = match str_p(p, "mix") {
|
||||
Some(m) => Some(
|
||||
crate::mixer::channels_from(m).ok_or_else(|| bad("sequence.settings", format!("unknown mix `{m}` (Stereo, Mono, 5.1, Adaptive)")))?,
|
||||
),
|
||||
Some(m) => Some(crate::mixer::channels_from(m).ok_or_else(|| bad(CMD, format!("unknown mix `{m}` (Stereo, Mono, 5.1, Adaptive)")))?),
|
||||
None => None,
|
||||
};
|
||||
let working = match str_p(p, "workingSpace") {
|
||||
Some(w) => Some(filmcraft_color::WorkingSpace::parse(w).ok_or_else(|| bad(CMD, format!("unknown working space `{w}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let drop_frame = bool_p(p, "dropFrame");
|
||||
let rate = fps.or_else(|| s.active_sequence().map(|q| q.settings.frame_rate)).unwrap_or(FrameRate::FPS_23_976);
|
||||
if drop_frame == Some(true) && !rate.supports_drop_frame() {
|
||||
return Err(bad(CMD, format!("drop-frame timecode needs a 29.97, 59.94 or 119.88 fps timebase, not {rate}")));
|
||||
}
|
||||
let scale_motion = bool_p(p, "scaleMotion").unwrap_or(false);
|
||||
let p = p.clone();
|
||||
s.edit("Sequence Settings", |pr, _| {
|
||||
let scaled = s.edit("Sequence Settings", |pr, _| {
|
||||
if let Some(n) = str_p(&p, "name") {
|
||||
pr.item_mut(id).ok_or(EngineError::NoSequence)?.name = n.to_string();
|
||||
}
|
||||
let q = pr.sequence_mut(id).ok_or(EngineError::NoSequence)?;
|
||||
if let Some(w) = checked_u32_p(&p, "width", "sequence.settings")? {
|
||||
let old_size = (q.settings.width, q.settings.height);
|
||||
if let Some(w) = checked_u32_p(&p, "width", CMD)? {
|
||||
q.settings.width = w;
|
||||
}
|
||||
if let Some(h) = checked_u32_p(&p, "height", "sequence.settings")? {
|
||||
if let Some(h) = checked_u32_p(&p, "height", CMD)? {
|
||||
q.settings.height = h;
|
||||
}
|
||||
if let Some(f) = fps {
|
||||
q.settings.frame_rate = f;
|
||||
}
|
||||
if let Some(sr) = checked_u32_p(&p, "sampleRate", "sequence.settings")? {
|
||||
if let Some(sr) = checked_u32_p(&p, "sampleRate", CMD)? {
|
||||
q.settings.sample_rate = sr;
|
||||
}
|
||||
if let Some(m) = mix {
|
||||
q.settings.audio_master = m;
|
||||
}
|
||||
q.settings.validate().map_err(|e| bad("sequence.settings", e))
|
||||
if let Some(df) = drop_frame {
|
||||
q.settings.drop_frame = df;
|
||||
}
|
||||
if let Some(v) = bool_p(&p, "maxRenderQuality") {
|
||||
q.settings.max_render_quality = v;
|
||||
}
|
||||
let c = &mut q.settings.color;
|
||||
if let Some(w) = working {
|
||||
c.working = w;
|
||||
}
|
||||
if let Some(v) = bool_p(&p, "wideGamut") {
|
||||
c.wide_gamut = v;
|
||||
}
|
||||
if let Some(v) = bool_p(&p, "autoToneMap") {
|
||||
c.auto_tone_map = v;
|
||||
}
|
||||
q.settings.working_space = q.settings.color.working.label().into();
|
||||
q.settings.validate().map_err(|e| bad(CMD, e))?;
|
||||
let new_size = (q.settings.width, q.settings.height);
|
||||
Ok(if scale_motion { edit::frame_size::scale_motion(q, old_size, new_size) } else { 0 })
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
Ok(json!({"scaledClips": scaled}))
|
||||
}
|
||||
),
|
||||
cmd!("sequence.renderEffectsInToOut", "Render Effects In to Out", ["Sequence"], Some("Enter"), r#"{"wait":bool=false}"#, has_seq, |s, p| {
|
||||
@@ -1609,12 +1645,13 @@ fn build() -> Vec<CommandSpec> {
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
// With no In / Out mark, like Premiere: the start / end of the sequence (#208).
|
||||
cmd!("markers.goToIn", "Go to In", ["Markers"], Some("Shift+I"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.goToIn", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if let Some(i) = s.active_sequence().and_then(|q| q.mark_in) {
|
||||
if let Some(i) = s.active_sequence().map(|q| q.mark_in.unwrap_or(Tick::ZERO)) {
|
||||
s.set_playhead(i);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
@@ -1624,7 +1661,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
return Ok(v);
|
||||
}
|
||||
s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if let Some(o) = s.active_sequence().and_then(|q| q.mark_out) {
|
||||
if let Some(o) = s.active_sequence().map(|q| q.mark_out.unwrap_or_else(|| q.duration())) {
|
||||
s.set_playhead(o);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! | `essentialSound.setType` / `clearType` | assign / clear the audio type (clearing removes its effects and gain) |
|
||||
//! | `essentialSound.set` | one setting by dotted key (`repair.noise.amount`), or several (`values`) |
|
||||
//! | `essentialSound.applyPreset` / `savePreset` / `deletePreset` | built-in and user presets |
|
||||
//! | `essentialSound.autoMatch` | measure integrated loudness (BS.1770) and set the match gain to the target |
|
||||
//! | `essentialSound.autoMatch` | measure integrated loudness (BS.1770, mono clips as one channel) and set the match gain to the target |
|
||||
//! | `essentialSound.generateDucking` | Volume keyframes that duck Music / Ambience under the trigger types |
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -262,15 +262,26 @@ fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Integrated loudness (LUFS) of a clip's own signal (clip gain + clip effects) over its whole
|
||||
/// timeline range, or `None` when it is silent / has no audio.
|
||||
///
|
||||
/// The clip is measured with the channels it has: a stereo (or wider) source as the two channels
|
||||
/// it plays, a mono source or a mono pick as one channel (its two identical sides folded to one),
|
||||
/// the way BS.1770 and ffmpeg's `ebur128` measure a mono programme. Metering the dual-mono pair the
|
||||
/// clip plays would read 3 LU louder than the file and match it 3 dB too quietly (#296).
|
||||
pub fn clip_loudness(item: &TrackItem, sr: u32, sources: &dyn SourceProvider) -> Option<f64> {
|
||||
let a0 = item.start.to_units_floor(sr as i64);
|
||||
let a1 = item.end().to_units_floor(sr as i64);
|
||||
let mut meter = LoudnessMeter::new(sr as f64, 2);
|
||||
let mono = filmcraft_render::audio::clip_is_mono(item, sources)?;
|
||||
let mut meter = LoudnessMeter::new(sr as f64, if mono { 1 } else { 2 });
|
||||
let mut pos = a0;
|
||||
while pos < a1 {
|
||||
let n = (a1 - pos).min(sr as i64) as usize;
|
||||
let [l, r] = filmcraft_render::audio::clip_signal(item, pos, n, sr, sources)?;
|
||||
meter.process(&[&l, &r]);
|
||||
if mono {
|
||||
let m: Vec<f32> = l.iter().zip(&r).map(|(a, b)| 0.5 * (a + b)).collect();
|
||||
meter.process(&[&m]);
|
||||
} else {
|
||||
meter.process(&[&l, &r]);
|
||||
}
|
||||
pos += n as i64;
|
||||
}
|
||||
Some(meter.integrated()).filter(|v| v.is_finite())
|
||||
|
||||
@@ -265,6 +265,41 @@ fn auto_match_hits_the_target_loudness() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #296: a mono file measured ~3 LU too loud. The same speech imported as a 1-channel WAV and as
|
||||
/// a dual-mono stereo WAV must measure 3.01 LU apart (the stereo pair carries twice the energy),
|
||||
/// the mono clip must agree with a one-channel BS.1770 meter over the file (what ffmpeg's
|
||||
/// `ebur128` reports), and a mono pick (Modify ▸ Audio Channels) of the stereo file counts as mono.
|
||||
#[test]
|
||||
fn auto_match_measures_a_mono_clip_as_one_channel() {
|
||||
let sig = speech(12.0, 0.3, |t| (t % 3.0) < 2.2);
|
||||
let (mut s, c) = session_with(&[(sig.clone(), 0.0)]);
|
||||
let stereo = c[0];
|
||||
let mono_wav: Arc<[u8]> = filmcraft_media::wav::write_wav16(&sig, 1, SR).into();
|
||||
let mono_item = crate::commands::import_bytes(&mut s, "/mono.wav", mono_wav, None).unwrap();
|
||||
let r = s.execute("timeline.place", json!({"item": mono_item.0, "audioTrack": "A2", "seconds": 0.0})).unwrap();
|
||||
let mono = ClipId(r["clips"][0].as_u64().unwrap());
|
||||
for c in [stereo, mono] {
|
||||
s.execute("essentialSound.setType", json!({"clips": [c.0], "type": "dialogue"})).unwrap();
|
||||
}
|
||||
let measured = |s: &mut Session, c: ClipId| {
|
||||
let r = s.execute("essentialSound.autoMatch", json!({"clips": [c.0], "target": -23.0})).unwrap();
|
||||
r["clips"][0]["measuredLufs"].as_f64().unwrap()
|
||||
};
|
||||
let (two, one) = (measured(&mut s, stereo), measured(&mut s, mono));
|
||||
let mut file = LoudnessMeter::new(SR as f64, 1);
|
||||
file.process(&[&sig]);
|
||||
let want = file.integrated();
|
||||
println!("stereo (dual mono) {two:.3} LUFS, mono {one:.3} LUFS, mono file {want:.3} LUFS");
|
||||
assert!((one - want).abs() <= 0.1, "mono clip {one} LUFS, the file reads {want} LUFS");
|
||||
assert!((two - one - 3.0103).abs() <= 0.1, "dual mono {two} LUFS should read 3.01 LU above mono {one} LUFS");
|
||||
let st = item(&s, mono).essential.unwrap();
|
||||
assert!((st.loudness.gain_db - (-23.0 - want)).abs() <= 0.1, "match gain {} dB for {want} LUFS", st.loudness.gain_db);
|
||||
// one picked channel of the stereo file plays on both sides: measured as mono too
|
||||
s.execute("clip.audioChannels", json!({"clips": [stereo.0], "channels": [0]})).unwrap();
|
||||
let picked = measured(&mut s, stereo);
|
||||
assert!((picked - want).abs() <= 0.1, "mono pick {picked} LUFS, the file reads {want} LUFS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ducking_generates_volume_keyframes_under_dialogue() {
|
||||
// dialogue in [2, 4] and [6, 7.5] s; music for 10 s
|
||||
|
||||
@@ -175,3 +175,39 @@ fn naming_targets_lifts_only_the_selection_condition() {
|
||||
let _ = s.execute("edit.clear", json!({"clips": [a.id.0]}));
|
||||
assert!(clip(&s, a.id).is_some(), "a clip on a locked track is not removed");
|
||||
}
|
||||
|
||||
/// `project.delete` with explicit ids needs no Project-panel selection, for bins as much as for
|
||||
/// items: the selection can hold both, so a named bin stands in for it (#244).
|
||||
#[test]
|
||||
fn project_delete_takes_explicit_bins_and_items_without_a_selection() {
|
||||
let mut s = demo();
|
||||
let before = s.project.items.len();
|
||||
let empty = s.execute("file.newBin", json!({"name": "Empty"})).unwrap()["bin"].as_u64().unwrap();
|
||||
let outer = s.execute("file.newBin", json!({"name": "Old footage"})).unwrap()["bin"].as_u64().unwrap();
|
||||
let ocean = item_named(&s, "Ocean_Sunset.mp4");
|
||||
s.execute("project.moveToBin", json!({"items": [ocean.0], "bin": outer})).unwrap();
|
||||
s.state.project_selection.clear();
|
||||
assert!(!s.is_enabled("project.delete"));
|
||||
assert_eq!(disabled(s.execute("project.delete", json!({}))), "select an item in the Project panel");
|
||||
// an empty bin, by its id alone
|
||||
let r = s.execute("project.delete", json!({"items": [empty]})).unwrap();
|
||||
assert_eq!(r, json!({"items": 0, "bins": 1}));
|
||||
assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none());
|
||||
// a bin with a clip in it, and an item, each by id alone
|
||||
let r = s.execute("project.delete", json!({"items": [outer]})).unwrap();
|
||||
assert_eq!(r, json!({"items": 1, "bins": 1}));
|
||||
assert!(s.project.item(ocean).is_none());
|
||||
let dunes = item_named(&s, "Desert_Dunes.mp4");
|
||||
let r = s.execute("project.delete", json!({"items": [dunes.0]})).unwrap();
|
||||
assert_eq!(r, json!({"items": 1, "bins": 0}));
|
||||
assert_eq!(s.project.items.len(), before - 2);
|
||||
assert!(s.state.project_selection.is_empty(), "the selection is left as it was");
|
||||
assert!(!s.is_enabled("project.delete"), "menu enablement still follows the selection");
|
||||
// ids of nothing, or of the project's own top bin, do not stand in for a selection
|
||||
let root = s.project.root.id.0;
|
||||
assert_eq!(disabled(s.execute("project.delete", json!({"items": [root]}))), "select an item in the Project panel");
|
||||
assert_eq!(disabled(s.execute("project.delete", json!({"items": [999_999]}))), "select an item in the Project panel");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), before);
|
||||
}
|
||||
|
||||
@@ -230,6 +230,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
|
||||
if let Some(v) = bool_p(p, "sdr") {
|
||||
settings.sdr = v;
|
||||
}
|
||||
if let Some(v) = bool_p(p, "alpha") {
|
||||
settings.alpha = v;
|
||||
}
|
||||
match (crate::commands::checked_u32_p(p, "width", cmd)?, crate::commands::checked_u32_p(p, "height", cmd)?) {
|
||||
(Some(w), Some(h)) => settings.frame_size = Some((w, h)),
|
||||
(None, None) => {}
|
||||
|
||||
@@ -98,7 +98,12 @@ pub(crate) fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32,
|
||||
/// topmost clip at the playhead that is free for the duration (a track is added if needed).
|
||||
fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
|
||||
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
|
||||
place_video_clip(s, name, p, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
// Here `seconds` is duration, not the generic time parser's placement alias.
|
||||
let mut placement = p.clone();
|
||||
if let Some(params) = placement.as_object_mut() {
|
||||
params.remove("seconds");
|
||||
}
|
||||
place_video_clip(s, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
let src = graphic_source(pr, w, h, rate);
|
||||
(src, rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration()))
|
||||
})
|
||||
|
||||
@@ -24,6 +24,47 @@ fn text_of(l: &filmcraft_project::graphic::LayerSpec) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_duration_does_not_set_its_timeline_position() {
|
||||
for command in ["graphics.newText", "graphics.newShape"] {
|
||||
for playhead_frame in [0, 72] {
|
||||
for seconds in [1, 2] {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "Placement", "fps": 24, "width": 128, "height": 128, "video": 1, "audio": 1})).unwrap();
|
||||
s.execute("playhead.set", json!({"frame": playhead_frame})).unwrap();
|
||||
let start = s.playhead();
|
||||
let r = s.execute(command, json!({"seconds": seconds})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (_, it) = q.find_item(clip).unwrap();
|
||||
assert_eq!(it.start, start, "{command}, duration {seconds}, playhead {playhead_frame}");
|
||||
assert_eq!(it.duration, Tick::from_seconds_f64(f64::from(seconds)));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(clip).is_none());
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(clip).unwrap().1.start, start);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_duration_keeps_explicit_time_frame_and_timecode_placement() {
|
||||
for command in ["graphics.newText", "graphics.newShape"] {
|
||||
for mut params in [json!({"time": 0}), json!({"frame": 0}), json!({"timecode": "00:00:00:00"})] {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "Placement", "fps": 24, "width": 128, "height": 128, "video": 1, "audio": 1})).unwrap();
|
||||
s.execute("playhead.set", json!({"frame": 72})).unwrap();
|
||||
params["seconds"] = json!(2);
|
||||
let r = s.execute(command, params).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
let (_, it) = s.active_sequence().unwrap().find_item(clip).unwrap();
|
||||
assert_eq!(it.start, Tick::ZERO, "{command}");
|
||||
assert_eq!(it.duration, Tick::from_seconds_f64(2.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_makes_a_graphic_clip_above_the_footage() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -559,7 +559,7 @@ fn export_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let img = s.render_program(1.0).ok_or(EngineError::NoSequence)?;
|
||||
let (w, h) = (img.w as u32, img.h as u32);
|
||||
let bytes = filmcraft_export::encode_still(format, img.to_rgba8(), w, h).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let bytes = filmcraft_export::encode_still(format, img.to_rgba8(), w, h, false).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mut out = json!({"path": path, "width": w, "height": h});
|
||||
if p.get("import").and_then(Value::as_bool).unwrap_or(false) {
|
||||
|
||||
@@ -554,10 +554,10 @@ impl Session {
|
||||
let Some(p) = self.persistence.as_mut() else { return };
|
||||
for ev in p.drain_events() {
|
||||
match ev {
|
||||
autosave::WorkerEvent::SavedProject { path, revision } => {
|
||||
if self.path.as_deref() == Some(path.as_str()) && revision > self.saved_revision && revision <= self.revision {
|
||||
self.saved_revision = revision;
|
||||
}
|
||||
autosave::WorkerEvent::SavedProject { path, revision }
|
||||
if self.path.as_deref() == Some(path.as_str()) && revision > self.saved_revision && revision <= self.revision =>
|
||||
{
|
||||
self.saved_revision = revision;
|
||||
}
|
||||
autosave::WorkerEvent::Error(m) => {
|
||||
self.log.push(panels::Level::Error, "autosave", m.clone());
|
||||
@@ -1109,3 +1109,5 @@ mod voiceover_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_placement_tests;
|
||||
#[cfg(test)]
|
||||
mod wasm_clock_tests;
|
||||
|
||||
@@ -574,7 +574,7 @@ fn track(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let start_path = mask.path_at(mt0);
|
||||
let run = move || {
|
||||
use std::sync::atomic::Ordering;
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let gray = |t: Tick| -> Option<(filmcraft_render::track::Prepared, f64)> {
|
||||
let f = src.video_frame(filmcraft_media::FrameRequest { time: t, scale }).ok()?;
|
||||
let g = filmcraft_render::track::Gray::from_rgba8(f.width as usize, f.height as usize, &f.to_rgba8());
|
||||
|
||||
@@ -1129,19 +1129,19 @@ pub fn audio_gain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
m => return Err(bad("clip.audioGain", format!("unknown mode `{m}` (set, adjust, normalizeMax, normalizeAll)"))),
|
||||
};
|
||||
let n = new_gain.len();
|
||||
let applied = new_gain.clone();
|
||||
s.edit_sequence("Audio Gain", move |q, _, _| {
|
||||
let applied = s.edit_sequence("Audio Gain", move |q, _, _| {
|
||||
let mut applied = Vec::new();
|
||||
for t in q.audio_tracks.iter_mut() {
|
||||
for i in t.items.iter_mut() {
|
||||
if let Some((_, g)) = new_gain.iter().find(|(c, _)| *c == i.id) {
|
||||
i.gain_db = g.clamp(-96.0, 96.0);
|
||||
applied.push((i.id, i.gain_db));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(applied)
|
||||
})?;
|
||||
Ok(json!({"mode": mode, "clips": n, "gainDb": applied.iter().map(|(c, g)| json!({"clip": c.0, "gainDb": g})).collect::<Vec<_>>()}))
|
||||
Ok(json!({"mode": mode, "clips": applied.len(), "gainDb": applied.iter().map(|(c, g)| json!({"clip": c.0, "gainDb": g})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Effects panel ▸ Set Selected as Default Transition (video or audio, from the effect's kind).
|
||||
|
||||
@@ -312,6 +312,45 @@ fn audio_gain_modes_and_peak() {
|
||||
assert!(s.execute("clip.audioGain", json!({"mode": "loud"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_gain_reports_only_audio_items_it_updates() {
|
||||
for mode in ["set", "adjust"] {
|
||||
for target in [29, 30] {
|
||||
for linked in [true, false] {
|
||||
let mut s = demo();
|
||||
s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap();
|
||||
let r = s.execute("clip.audioGain", json!({"clips": [target], "mode": mode, "db": -6})).unwrap();
|
||||
let applied = linked || target == 30;
|
||||
assert_eq!(r["clips"], usize::from(applied), "{mode}, target {target}, linked {linked}: {r}");
|
||||
assert_eq!(r["gainDb"], if applied { json!([{"clip": 30, "gainDb": -6.0}]) } else { json!([]) });
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(q.find_item(ClipId(29)).unwrap().1.gain_db, 0.0);
|
||||
assert_eq!(q.find_item(ClipId(30)).unwrap().1.gain_db, if applied { -6.0 } else { 0.0 });
|
||||
if applied {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(ClipId(30)).unwrap().1.gain_db, 0.0);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(ClipId(30)).unwrap().1.gain_db, -6.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_gain_reports_the_clamped_stored_gain() {
|
||||
for mode in ["set", "adjust"] {
|
||||
for db in [-200.0_f64, 200.0] {
|
||||
let mut s = demo();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
let r = s.execute("clip.audioGain", json!({"clips": [30], "mode": mode, "db": db})).unwrap();
|
||||
let stored = s.active_sequence().unwrap().find_item(ClipId(30)).unwrap().1.gain_db;
|
||||
assert_eq!(stored, db.clamp(-96.0, 96.0));
|
||||
assert_eq!(r["gainDb"], json!([{"clip": 30, "gainDb": stored}]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_audio_transition_is_used_by_apply() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -550,13 +550,14 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if chosen.is_empty() {
|
||||
return Err(bad("presets.export", "no presets to export"));
|
||||
}
|
||||
filmcraft_format::atomic_write(Path::new(path), &file_bytes(&chosen)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
// through the host: atomic on the desktop, a download on the web (no filesystem there)
|
||||
s.services.write_file(path, &file_bytes(&chosen)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "count": chosen.len()}))
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("presets.import", "need `path`"))?;
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let presets = parse_file(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let names: Vec<String> = presets.iter().map(|x| x.name.clone()).collect();
|
||||
for pr in presets {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::presets::{KeyframeMode, retime};
|
||||
use crate::{Services, Session};
|
||||
use filmcraft_project::{ClipId, ParamValue, TrackItem};
|
||||
use filmcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
|
||||
@@ -203,3 +206,35 @@ fn mask_geometry_scales_to_the_target_frame_size() {
|
||||
assert!((c.x - 480.0).abs() < 1e-6 && (c.y - 270.0).abs() < 1e-6, "{c:?}");
|
||||
assert_eq!(m.feather.value, ParamValue::Float(80.0));
|
||||
}
|
||||
|
||||
/// A host without a filesystem (the web): files exist only through `Services`.
|
||||
#[derive(Default)]
|
||||
struct MemFs(Mutex<BTreeMap<String, Vec<u8>>>);
|
||||
|
||||
impl Services for MemFs {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
self.0.lock().unwrap().get(path).cloned().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.lock().unwrap().insert(path.into(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// #379: export and import go through the host's services, not `std::fs` (which traps on the web).
|
||||
#[test]
|
||||
fn export_and_import_go_through_host_services() {
|
||||
let fs = Arc::new(MemFs::default());
|
||||
let mut s = Session::new(fs.clone());
|
||||
let path = "/exports/owned-preset.prfpset";
|
||||
let r = s.execute("presets.export", json!({"path": path, "names": ["Fade In"]})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
let bytes = fs.read_file(path).unwrap();
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("filmcraft.effect-presets"));
|
||||
// a second session on the same host imports it from there
|
||||
let mut s2 = Session::new(fs);
|
||||
let r = s2.execute("presets.import", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["Fade In"]));
|
||||
assert_eq!(s2.presets.user.len(), 1);
|
||||
assert!(s2.execute("presets.import", json!({"path": "/exports/missing.prfpset"})).is_err());
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
|
||||
let nseg = todo.len();
|
||||
let pool = s.media.clone();
|
||||
let run = move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut bytes = 0u64;
|
||||
let mut done_frames = 0u64;
|
||||
let mut outcome: std::result::Result<(), String> = Ok(());
|
||||
@@ -581,7 +581,7 @@ pub fn render_audio(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (prog, res) = (job.progress.clone(), job.result.clone());
|
||||
let nseg = todo.len();
|
||||
let run = move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut bytes = 0u64;
|
||||
let mut outcome: std::result::Result<(), String> = Ok(());
|
||||
let Some(q) = project.sequence(seq) else { return };
|
||||
|
||||
@@ -208,7 +208,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let prev_dst = crate::previews::dir_for_project(&project_path);
|
||||
let services = s.services.clone();
|
||||
let run = move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let r = execute(proj, work, sources, previews.map(|d| (d, prev_dst)), &project_path, pr, &prog, &*services);
|
||||
let secs = t0.elapsed().as_secs_f64();
|
||||
let r = r.map(|bytes| filmcraft_export::Report {
|
||||
|
||||
@@ -200,7 +200,7 @@ pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'st
|
||||
let outputs: Arc<Mutex<Vec<(ItemId, String)>>> = Arc::default();
|
||||
let (prog, res, outs) = (job.progress.clone(), job.result.clone(), outputs.clone());
|
||||
let run = move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut bytes = 0;
|
||||
let mut err = None;
|
||||
let n = tasks.len();
|
||||
|
||||
@@ -483,3 +483,24 @@ fn selection_follows_playhead() {
|
||||
s.execute("playhead.set", json!({"frame": 10})).unwrap();
|
||||
assert_eq!(s.state.selection, sel, "off again");
|
||||
}
|
||||
|
||||
/// #208: with no In / Out mark, Go to In / Go to Out move to the start / end of the sequence
|
||||
/// (they did nothing); with marks they go to them.
|
||||
#[test]
|
||||
fn go_to_in_out_without_marks_go_to_the_sequence_ends() {
|
||||
let mut s = demo();
|
||||
let end = s.active_sequence().unwrap().duration();
|
||||
let mid = filmcraft_time::Tick(end.0 / 2);
|
||||
s.execute("playhead.set", json!({"time": mid.0})).unwrap();
|
||||
s.execute("markers.goToIn", json!({})).unwrap();
|
||||
assert_eq!(s.playhead(), filmcraft_time::Tick::ZERO, "the start");
|
||||
s.execute("markers.goToOut", json!({})).unwrap();
|
||||
assert_eq!(s.playhead(), end, "the end");
|
||||
// with marks, the marks
|
||||
let at = filmcraft_time::Tick(end.0 / 4);
|
||||
s.execute("playhead.set", json!({"time": at.0})).unwrap();
|
||||
s.execute("markers.markIn", json!({})).unwrap();
|
||||
s.execute("playhead.set", json!({"time": mid.0})).unwrap();
|
||||
s.execute("markers.goToIn", json!({})).unwrap();
|
||||
assert_eq!(s.playhead(), s.active_sequence().unwrap().mark_in.unwrap());
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ pub const PREFS_VERSION: u32 = 2;
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct GeneralPrefs {
|
||||
/// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, or
|
||||
/// `system` (the default: the operating system's preferred language when the interface has it,
|
||||
/// otherwise English).
|
||||
pub interface_language: String,
|
||||
/// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`.
|
||||
pub at_startup: String,
|
||||
/// "When Opening a Project": `showOpenDialog` | `showHome`.
|
||||
@@ -59,6 +63,7 @@ pub struct GeneralPrefs {
|
||||
impl Default for GeneralPrefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interface_language: "system".into(),
|
||||
at_startup: "showHome".into(),
|
||||
when_opening_project: "showOpenDialog".into(),
|
||||
bins_double_click: "openInPlace".into(),
|
||||
@@ -672,6 +677,12 @@ static CATEGORIES: &[Category] = &[
|
||||
id: "general",
|
||||
title: "General",
|
||||
rows: &[
|
||||
f(
|
||||
"general.interfaceLanguage",
|
||||
"Interface Language",
|
||||
Kind::Choice(&[("system", "System Language"), ("en", "English"), ("ja", "日本語"), ("es", "Español"), ("pt-br", "Português (Brasil)")]),
|
||||
true,
|
||||
),
|
||||
f("general.atStartup", "At Startup", Kind::Choice(STARTUP), true),
|
||||
f("general.whenOpeningProject", "When Opening a Project", Kind::Choice(OPENING), false),
|
||||
Row::Group(
|
||||
@@ -955,7 +966,7 @@ static CATEGORIES: &[Category] = &[
|
||||
b("playback.draftDecode", "Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered)", true),
|
||||
f("playback.hardwareDecoding", "Hardware decoding", Kind::Choice(HW_DECODE), true),
|
||||
Row::Note(
|
||||
"Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS) for the H.264 and HEVC streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.",
|
||||
"Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.",
|
||||
),
|
||||
],
|
||||
},
|
||||
@@ -1084,10 +1095,8 @@ pub fn sanitize(v: &mut Value, defaults: &Value) {
|
||||
let Some(slot) = path.iter().try_fold(&mut *v, |v, k| v.get_mut(*k)) else { continue };
|
||||
let def = path.iter().try_fold(defaults, |v, k| v.get(*k)).cloned().unwrap_or(Value::Null);
|
||||
match f.kind {
|
||||
Kind::Choice(_) | Kind::Color => {
|
||||
if validate(f.key, slot).is_err() {
|
||||
*slot = def;
|
||||
}
|
||||
Kind::Choice(_) | Kind::Color if validate(f.key, slot).is_err() => {
|
||||
*slot = def;
|
||||
}
|
||||
Kind::Int { min, max, .. } => match slot.as_f64().filter(|x| x.is_finite()) {
|
||||
Some(x) => *slot = json!(x.round().clamp(min, max) as u64),
|
||||
|
||||
@@ -22,6 +22,36 @@ fn set(s: &mut Session, key: &str, value: Value) {
|
||||
s.execute("prefs.set", json!({"key": key, "value": value})).unwrap_or_else(|e| panic!("{key}: {e}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_language_persists_and_rejects_hostile_values() {
|
||||
let dir = tmp_dir("interface-language");
|
||||
let path = dir.join("preferences.json");
|
||||
let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() };
|
||||
set(&mut s, "general.interfaceLanguage", json!("es"));
|
||||
assert_eq!(Preferences::load(&path).general.interface_language, "es");
|
||||
for value in [json!("xx"), json!("es-MX"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] {
|
||||
assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err());
|
||||
assert_eq!(s.prefs.general.interface_language, "es");
|
||||
}
|
||||
assert_eq!(Preferences::load(&path).general.interface_language, "es");
|
||||
// back to following the operating system (#218)
|
||||
set(&mut s, "general.interfaceLanguage", json!("system"));
|
||||
assert_eq!(Preferences::load(&path).general.interface_language, "system");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A new installation follows the operating system's language (#218); a saved choice is kept.
|
||||
#[test]
|
||||
fn interface_language_defaults_to_the_system_language() {
|
||||
assert_eq!(Preferences::default().general.interface_language, "system");
|
||||
let dir = tmp_dir("interface-language-default");
|
||||
let path = dir.join("preferences.json");
|
||||
assert_eq!(Preferences::load(&path).general.interface_language, "system", "no preferences file yet");
|
||||
std::fs::write(&path, r#"{"general":{"interfaceLanguage":"en"}}"#).unwrap();
|
||||
assert_eq!(Preferences::load(&path).general.interface_language, "en");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
fn write_png(path: &Path, w: u32, h: u32) {
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255])).save(path).unwrap();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ pub type Entry = (&'static str, &'static str, &'static str);
|
||||
/// FilmCraft additions to the per-command defaults: panel-specific shortcuts.
|
||||
pub const FILMCRAFT_PANEL: &[Entry] = &[
|
||||
("project.delete", "Backspace", "Project"),
|
||||
// the forward-delete key (Delete on Windows and Linux keyboards) clears timeline clips too;
|
||||
// application-wide it is Project ▸ Clear (#243)
|
||||
("edit.clear", "Delete", "Timeline"),
|
||||
("file.newBin", "Cmd+B", "Project"),
|
||||
("edit.undo", "Left", "History"),
|
||||
("edit.redo", "Right", "History"),
|
||||
|
||||
@@ -74,6 +74,9 @@ fn defaults_follow_the_registry_and_the_premiere_audit() {
|
||||
#[test]
|
||||
fn assign_reassigns_conflicts_and_undoes() {
|
||||
let mut s = Session::default();
|
||||
// macOS Control folds into the primary modifier on Linux/Windows, so compare changes
|
||||
// against the platform's original conflicts rather than assuming the set starts empty.
|
||||
let baseline = s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"].clone();
|
||||
let r = s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Shift+K"})).unwrap();
|
||||
assert_eq!(r["reassigned"][0]["command"], json!("sequence.addEditAllTracks"), "{r}");
|
||||
assert!(s.shortcuts.primary("sequence.addEditAllTracks").is_none());
|
||||
@@ -85,12 +88,12 @@ fn assign_reassigns_conflicts_and_undoes() {
|
||||
s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Z", "add": true})).unwrap();
|
||||
assert_eq!(s.shortcuts.for_command("edit.undo").iter().filter(|b| b.panel.is_none()).count(), 2);
|
||||
let r = s.execute("shortcuts.set", json!({"command": "edit.redo", "keys": "M", "keepConflicts": true})).unwrap();
|
||||
assert_eq!(r["conflicts"].as_array().unwrap().len(), 1, "{r}");
|
||||
assert_eq!(r["conflicts"].as_array().unwrap().len(), baseline.as_array().unwrap().len() + 1, "{r}");
|
||||
let c = s.execute("shortcuts.conflicts", json!({"platform": "mac"})).unwrap();
|
||||
assert_eq!(c["conflicts"][0]["keys"], json!("M"));
|
||||
// undo / redo within the editor
|
||||
s.execute("shortcuts.undo", json!({})).unwrap();
|
||||
assert!(s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"].as_array().unwrap().is_empty());
|
||||
assert_eq!(s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"], baseline);
|
||||
s.execute("shortcuts.undo", json!({})).unwrap();
|
||||
s.execute("shortcuts.undo", json!({})).unwrap();
|
||||
assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Z"));
|
||||
@@ -132,6 +135,19 @@ fn panel_shortcuts_override_application_ones_with_focus() {
|
||||
assert!(k.as_array().unwrap().iter().any(|b| b["command"] == "edit.copy" && b["keys"] == "Ctrl+C"), "{k}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_clears_timeline_clips_and_project_items() {
|
||||
// #243: with the Timeline focused, Delete (the forward-delete key, labelled Delete on Windows
|
||||
// and Linux keyboards) resolved to Project ▸ Clear and left the selected clips in place.
|
||||
let s = Session::default();
|
||||
let del = Chord::parse("Delete").unwrap();
|
||||
for p in [Platform::Mac, Platform::Windows] {
|
||||
assert_eq!(s.shortcuts.resolve(&del, Some("Timeline"), p).unwrap().command, "edit.clear", "{p:?}");
|
||||
assert_eq!(s.shortcuts.resolve(&del, Some("Project"), p).unwrap().command, "project.delete", "{p:?}");
|
||||
assert_eq!(s.shortcuts.resolve(&del, None, p).unwrap().command, "project.delete", "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_presets_map_other_editors() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -144,6 +144,73 @@ fn sequence_parameters_are_bounded_and_failed_changes_are_atomic() {
|
||||
assert_eq!(s.active_sequence().unwrap().settings.height, 8192, "a 16384x8192 panorama is accepted");
|
||||
}
|
||||
|
||||
/// The first video clip's Motion Position and Scale (static values).
|
||||
fn first_motion(s: &Session) -> (u64, (f64, f64), f64) {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let it = q.video_tracks.iter().flat_map(|t| &t.items).find(|i| i.effect("motion").is_some()).unwrap();
|
||||
let m = it.effect("motion").unwrap();
|
||||
let p = match m.param("position").unwrap().value {
|
||||
filmcraft_project::ParamValue::Vec2(v) => (v.x, v.y),
|
||||
_ => panic!("position is a point"),
|
||||
};
|
||||
let sc = match m.param("scale").unwrap().value {
|
||||
filmcraft_project::ParamValue::Float(v) => v,
|
||||
_ => panic!("scale is a number"),
|
||||
};
|
||||
(it.id.0, p, sc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_settings_scales_motion_with_the_frame_size_in_one_undo_step() {
|
||||
let mut s = demo();
|
||||
let before = (*s.project).clone();
|
||||
let (id, p0, s0) = first_motion(&s);
|
||||
assert_eq!((s.active_sequence().unwrap().settings.width, s.active_sequence().unwrap().settings.height), (1920, 1080));
|
||||
let r = s.execute("sequence.settings", json!({"width":1280,"height":720,"scaleMotion":true})).unwrap();
|
||||
assert!(r["scaledClips"].as_u64().unwrap() >= 1, "{r}");
|
||||
let (id1, p1, s1) = first_motion(&s);
|
||||
assert_eq!(id1, id);
|
||||
assert!((p1.0 - p0.0 * 2.0 / 3.0).abs() < 1e-9 && (p1.1 - p0.1 * 2.0 / 3.0).abs() < 1e-9, "{p0:?} -> {p1:?}");
|
||||
assert!((s1 - s0 * 2.0 / 3.0).abs() < 1e-9, "{s0} -> {s1}");
|
||||
let after = (*s.project).clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, before, "size and Motion come back in one undo step");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, after);
|
||||
// without the option the frame size changes alone (the command's default, as before)
|
||||
let r = s.execute("sequence.settings", json!({"width":3840,"height":2160})).unwrap();
|
||||
assert_eq!(r["scaledClips"], json!(0));
|
||||
assert_eq!(first_motion(&s), (id, p1, s1));
|
||||
assert_eq!(s.active_sequence().unwrap().settings.width, 3840);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_settings_timecode_render_quality_and_colour() {
|
||||
let mut s = demo();
|
||||
s.execute("sequence.settings", json!({"fps":29.97,"dropFrame":true,"maxRenderQuality":true})).unwrap();
|
||||
let st = s.active_sequence().unwrap().settings.clone();
|
||||
assert_eq!(st.frame_rate, FrameRate::FPS_29_97);
|
||||
assert!(st.drop_frame && st.max_render_quality);
|
||||
s.execute("sequence.settings", json!({"dropFrame":false})).unwrap();
|
||||
assert!(!s.active_sequence().unwrap().settings.drop_frame);
|
||||
// drop-frame only exists for the NTSC rates; a refused change leaves everything as it was
|
||||
let before = (*s.project).clone();
|
||||
let history = s.history.undo.len();
|
||||
for params in [json!({"fps":25,"dropFrame":true}), json!({"workingSpace":"rec2020-log"}), json!({"fps":25,"dropFrame":true,"width":1280})] {
|
||||
assert!(s.execute("sequence.settings", params.clone()).is_err(), "{params}");
|
||||
assert_eq!(*s.project, before, "{params}");
|
||||
assert_eq!(s.history.undo.len(), history);
|
||||
}
|
||||
s.execute("sequence.settings", json!({"workingSpace":"rec2100-pq","wideGamut":true,"autoToneMap":true})).unwrap();
|
||||
let st = s.active_sequence().unwrap().settings.clone();
|
||||
assert_eq!(st.color.working, filmcraft_color::WorkingSpace::Rec2100Pq);
|
||||
assert!(st.color.wide_gamut && st.color.auto_tone_map);
|
||||
assert_eq!(st.working_space, "Rec. 2100 PQ");
|
||||
// disabled without a sequence
|
||||
let mut empty = Session::default();
|
||||
assert!(empty.execute("sequence.settings", json!({"width":1280})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_fps_is_refused() {
|
||||
let mut s = demo();
|
||||
@@ -170,6 +237,51 @@ fn effects_and_keyframes() {
|
||||
assert!(it.effect("motion").unwrap().params["scale"].is_animated());
|
||||
}
|
||||
|
||||
/// `effects.addKeyframe` is the keyframe diamond of Effect Controls and Properties: it adds a
|
||||
/// keyframe at the playhead or removes the one there, and the value then follows the keyframes
|
||||
/// that are left (Premiere's Add/Remove Keyframe), unlike the stopwatch, which drops them all.
|
||||
#[test]
|
||||
fn add_keyframe_toggles_the_keyframe_at_the_playhead() {
|
||||
let mut s = demo();
|
||||
let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||||
let scale = |s: &Session| {
|
||||
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1;
|
||||
let p = &it.effect("motion").unwrap().params["scale"];
|
||||
(p.keyframes.len(), p.f64_at(it.source_time_at(s.playhead())))
|
||||
};
|
||||
let key = json!({"clip": c, "effect": "motion", "param": "scale"});
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
s.execute("effects.addKeyframe", key.clone()).unwrap();
|
||||
assert_eq!(scale(&s), (1, 100.0), "the first keyframe turns animation on");
|
||||
s.execute("playhead.set", json!({"seconds": 2.5})).unwrap();
|
||||
s.execute("effects.addKeyframe", key.clone()).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "scale", "value": 50.0})).unwrap();
|
||||
assert_eq!(scale(&s), (2, 50.0));
|
||||
// on a keyframe: only that keyframe goes, and Scale is the other keyframe's 100 again
|
||||
s.execute("effects.addKeyframe", key.clone()).unwrap();
|
||||
assert_eq!(scale(&s), (1, 100.0));
|
||||
s.undo();
|
||||
assert_eq!(scale(&s), (2, 50.0));
|
||||
s.redo();
|
||||
assert_eq!(scale(&s), (1, 100.0));
|
||||
// parameters that name nothing are errors and change nothing
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"clip": c}),
|
||||
json!({"clip": u64::MAX, "param": "scale"}),
|
||||
json!({"clip": c, "effect": "nope", "param": "scale"}),
|
||||
json!({"clip": c, "effect": u64::MAX, "param": "scale"}),
|
||||
json!({"clip": c, "effect": "motion", "param": "nope"}),
|
||||
json!({"clip": c, "effect": "motion", "param": "scale", "mask": 7}),
|
||||
] {
|
||||
assert!(s.execute("effects.addKeyframe", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(scale(&s), (1, 100.0));
|
||||
// the stopwatch ends the animation and keeps the value at the playhead
|
||||
s.execute("effects.toggleAnimation", key).unwrap();
|
||||
assert_eq!(scale(&s), (0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_and_markers() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! The web build runs every job synchronously (`wait || cfg!(target_arch = "wasm32")`), so a job
|
||||
//! body executes on wasm32 — where `std::time::Instant::now()` panics with "time not implemented
|
||||
//! on this platform" and takes the whole app down (#230: Create Proxies and mask tracking both
|
||||
//! did). `web_time::Instant` is `std::time::Instant` on native targets and `performance.now()`
|
||||
//! on the web, so there is never a reason to name the std one in this crate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Every non-test source file of the crate, as (path, contents).
|
||||
fn sources() -> Vec<(String, String)> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![dir];
|
||||
while let Some(d) = stack.pop() {
|
||||
for e in std::fs::read_dir(&d).unwrap().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "media_test_util.rs" {
|
||||
continue;
|
||||
}
|
||||
out.push((p.display().to_string(), std::fs::read_to_string(&p).unwrap()));
|
||||
}
|
||||
}
|
||||
assert!(out.len() > 20, "found {} source files", out.len());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_std_instant_in_code_the_web_build_runs() {
|
||||
let mut hits = Vec::new();
|
||||
for (path, text) in sources() {
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
// `use std::time::{.., Instant, ..}` or a fully qualified `std::time::Instant`
|
||||
let names_std_instant = line.contains("std::time::Instant") || (line.contains("use std::time::") && line.contains("Instant"));
|
||||
if names_std_instant && !line.contains("web_time") && !line.trim_start().starts_with("//") {
|
||||
hits.push(format!("{path}:{}: {}", i + 1, line.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(hits.is_empty(), "std::time::Instant panics on wasm32; use web_time::Instant:\n{}", hits.join("\n"));
|
||||
}
|
||||
|
||||
/// The clock the jobs use works here as well: a job's "Done in 0.0s" report needs an elapsed time.
|
||||
#[test]
|
||||
fn web_time_instant_measures_elapsed_time() {
|
||||
let t0 = web_time::Instant::now();
|
||||
let secs = t0.elapsed().as_secs_f64();
|
||||
assert!((0.0..60.0).contains(&secs), "{secs}");
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! HDR plumbing shared by hardware encoders: the 10-bit 4:2:0 converter, the HDR probe registry, the
|
||||
//! decision whether an export is HDR, and the one definition of the HDR10 static metadata.
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
const BT2020: (f32, f32) = (0.2627, 0.0593);
|
||||
|
||||
fn convert(rgb: &[f32], w: usize, h: usize) -> (Vec<u16>, Vec<u16>, Vec<u16>) {
|
||||
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
|
||||
rgbf_to_yuv420_10(rgb, w, h, BT2020.0, BT2020.1, &mut y, &mut u, &mut v).unwrap();
|
||||
(y, u, v)
|
||||
}
|
||||
|
||||
fn flat(w: usize, h: usize, px: [f32; 3]) -> Vec<f32> {
|
||||
px.iter().copied().cycle().take(w * h * 3).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_white_and_grey_have_the_limited_range_codes() {
|
||||
for (px, code) in [([0.0; 3], 64u16), ([1.0; 3], 940), ([0.5; 3], 502)] {
|
||||
let (y, u, v) = convert(&flat(4, 4, px), 4, 4);
|
||||
assert!(y.iter().all(|c| *c == code), "{px:?}: {y:?}");
|
||||
assert!(u.iter().chain(&v).all(|c| *c == 512), "{px:?}: neutral chroma");
|
||||
assert_eq!((y.len(), u.len(), v.len()), (16, 4, 4));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bt2020_primaries_have_exact_codes() {
|
||||
// (R', G', B') -> (Y, Cb, Cr) with the BT.2020 non-constant-luminance matrix, 10-bit limited range
|
||||
for (px, want) in [
|
||||
([1.0, 0.0, 0.0], (294u16, 387u16, 960u16)),
|
||||
([0.0, 1.0, 0.0], (658, 189, 100)),
|
||||
([0.0, 0.0, 1.0], (116, 960, 476)),
|
||||
([1.0, 0.5, 0.0], (591, 225, 754)),
|
||||
] {
|
||||
let (y, u, v) = convert(&flat(2, 2, px), 2, 2);
|
||||
assert_eq!((y[0], u[0], v[0]), want, "{px:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_and_non_finite_values_clamp_instead_of_wrapping() {
|
||||
// above 1, below 0 and infinities clamp to the legal range; NaN counts as 0 (black, not code 0)
|
||||
let mut px = vec![0.0f32; 4 * 3];
|
||||
px[..3].copy_from_slice(&[2.0, 2.0, 2.0]);
|
||||
px[3..6].copy_from_slice(&[-1.0, -5.0, -0.5]);
|
||||
px[6..9].copy_from_slice(&[f32::INFINITY, f32::INFINITY, f32::INFINITY]);
|
||||
px[9..].copy_from_slice(&[f32::NAN, f32::NEG_INFINITY, f32::NAN]);
|
||||
let (y, u, v) = convert(&px, 4, 1);
|
||||
assert_eq!(y, vec![940, 64, 940, 64]);
|
||||
assert!(u.iter().chain(&v).all(|c| (64..=960).contains(c)), "{u:?} {v:?}");
|
||||
// all NaN: black
|
||||
let (y, u, v) = convert(&[f32::NAN; 2 * 2 * 3], 2, 2);
|
||||
assert_eq!((y, u, v), (vec![64; 4], vec![512], vec![512]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_sizes_average_the_pixels_they_have() {
|
||||
// 3x3: the chroma planes are 2x2; the right column and bottom row average one or two pixels
|
||||
let (y, u, v) = convert(&flat(3, 3, [1.0, 0.0, 0.0]), 3, 3);
|
||||
assert_eq!((y.len(), u.len(), v.len()), (9, 4, 4));
|
||||
assert!(y.iter().all(|c| *c == 294) && u.iter().all(|c| *c == 387) && v.iter().all(|c| *c == 960));
|
||||
// 1x1
|
||||
let (y, u, v) = convert(&[0.0, 0.0, 1.0], 1, 1);
|
||||
assert_eq!((y, u, v), (vec![116], vec![960], vec![476]));
|
||||
// chroma is the mean over the 2x2 block: red and blue pixels give the mean of their Cb and Cr
|
||||
let rgb = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
|
||||
let (y, u, v) = convert(&rgb, 2, 2);
|
||||
assert_eq!(y, vec![294, 116, 294, 116]);
|
||||
assert_eq!((u[0], v[0]), ((387u16 + 960) / 2, (960u16 + 476) / 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ramp_has_every_code_a_10_bit_picture_can_have() {
|
||||
// a smooth horizontal ramp: 877 distinct luma codes in 64..=940, far more than 8 bits hold
|
||||
let w = 1024usize;
|
||||
let rgb: Vec<f32> = (0..w).flat_map(|x| [x as f32 / (w - 1) as f32; 3]).collect();
|
||||
let (y, _, _) = convert(&rgb, w, 1);
|
||||
let mut codes: Vec<u16> = y.clone();
|
||||
codes.dedup();
|
||||
assert_eq!((y[0], y[w - 1]), (64, 940));
|
||||
assert_eq!(codes.len(), 877, "{} distinct codes", codes.len());
|
||||
assert!(y.windows(2).all(|p| p[0] <= p[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_an_error_not_a_panic() {
|
||||
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut run = |rgb: &[f32], w: usize, h: usize| rgbf_to_yuv420_10(rgb, w, h, 0.2627, 0.0593, &mut y, &mut u, &mut v);
|
||||
assert!(run(&[], 0, 0).is_err());
|
||||
assert!(run(&[0.0; 3], 0, 1).is_err());
|
||||
assert!(run(&[0.0; 3], 1, 0).is_err());
|
||||
assert!(run(&[0.0; 11], 2, 2).is_err(), "short");
|
||||
assert!(run(&[0.0; 13], 2, 2).is_err(), "long");
|
||||
assert!(run(&[0.0; 6], 2, 2).is_err());
|
||||
assert!(run(&[0.0; 3], usize::MAX, 2).is_err(), "w*h overflows");
|
||||
assert!(run(&[0.0; 3], usize::MAX / 2, 3).is_err(), "w*h*3 overflows");
|
||||
assert!(run(&[0.0; 3], 1 << 40, 1 << 40).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hdr_probe_registry_follows_its_probes() {
|
||||
static HERE: AtomicBool = AtomicBool::new(false);
|
||||
fn probe() -> bool {
|
||||
HERE.load(Ordering::SeqCst)
|
||||
}
|
||||
// a format nothing registered a probe for is never HDR; registering twice is harmless
|
||||
assert!(!hdr_available(Format::ProRes));
|
||||
register_hdr_probe(Format::ProRes, probe);
|
||||
register_hdr_probe(Format::ProRes, probe);
|
||||
assert!(!hdr_available(Format::ProRes));
|
||||
HERE.store(true, Ordering::SeqCst);
|
||||
assert!(hdr_available(Format::ProRes));
|
||||
assert!(!hdr_available(Format::DnxHr), "probes are per format");
|
||||
HERE.store(false, Ordering::SeqCst);
|
||||
assert!(!hdr_available(Format::ProRes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn which_exports_are_hdr() {
|
||||
use crate::job::hdr_output;
|
||||
let never = |_: Format| false;
|
||||
let hevc_only = |f: Format| f == Format::Hevc;
|
||||
// H.264, ProRes, DNxHR and APV follow the working space, whatever the probes say
|
||||
for f in [Format::H264, Format::ProRes, Format::DnxHr, Format::Apv] {
|
||||
assert!(hdr_output(true, false, f, never), "{f:?}");
|
||||
assert!(!hdr_output(false, false, f, hevc_only), "{f:?}: SDR working space");
|
||||
assert!(!hdr_output(true, true, f, hevc_only), "{f:?}: SDR asked for");
|
||||
}
|
||||
// H.265 only where a registered encoder writes HDR
|
||||
assert!(!hdr_output(true, false, Format::Hevc, never), "no probe: tone-mapped SDR, as with VideoToolbox");
|
||||
assert!(hdr_output(true, false, Format::Hevc, hevc_only));
|
||||
assert!(!hdr_output(true, true, Format::Hevc, hevc_only), "settings.sdr forces SDR");
|
||||
assert!(!hdr_output(false, false, Format::Hevc, hevc_only));
|
||||
// formats without HDR output
|
||||
assert!(!hdr_output(true, false, Format::Mjpeg, |_| true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_pq_has_static_metadata_and_it_is_what_h264_writes() {
|
||||
assert!(ColorSignal::HLG.static_metadata().is_none());
|
||||
assert!(ColorSignal::default().static_metadata().is_none());
|
||||
let (md, cll) = ColorSignal::PQ.static_metadata().unwrap();
|
||||
assert_eq!(cll, (0, 0));
|
||||
assert_eq!(md, filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001));
|
||||
assert_eq!(md.to_bytes().len(), 24);
|
||||
// the box on the sample entry is the same value
|
||||
let mut e = filmcraft_isobmff::SampleEntry::hevc(Default::default(), 64, 64);
|
||||
ColorSignal::PQ.apply_to(&mut e, false);
|
||||
let v = e.video.as_ref().unwrap();
|
||||
assert_eq!((v.mastering_display, v.content_light), (Some(md), Some((0, 0))));
|
||||
let mut e = filmcraft_isobmff::SampleEntry::hevc(Default::default(), 64, 64);
|
||||
ColorSignal::HLG.apply_to(&mut e, false);
|
||||
let v = e.video.as_ref().unwrap();
|
||||
assert!(v.color.is_some() && v.mastering_display.is_none() && v.content_light.is_none());
|
||||
}
|
||||
|
||||
/// Remove emulation-prevention bytes (`00 00 03` -> `00 00`).
|
||||
fn unescape(b: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut zeros = 0;
|
||||
for &x in b {
|
||||
if zeros >= 2 && x == 3 {
|
||||
zeros = 0;
|
||||
continue;
|
||||
}
|
||||
zeros = if x == 0 { zeros + 1 } else { 0 };
|
||||
out.push(x);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_software_h264_sei_is_the_static_metadata_laid_out_as_messages() {
|
||||
let s = ExportSettings { format: Format::H264, signal: ColorSignal::PQ, ..Default::default() };
|
||||
let mut enc = h264_factory(Format::H264, 64, 64, FrameRate::FPS_24, &s).unwrap().unwrap();
|
||||
let rgb = vec![0.5f32; 64 * 64 * 3];
|
||||
let mut packets = enc.encode(&EncoderFrame { width: 64, height: 64, rgba: &[], hdr: Some(&rgb), index: 0 }).unwrap();
|
||||
packets.extend(enc.flush().unwrap());
|
||||
// find the SEI NAL unit (type 6) of the length-prefixed first access unit
|
||||
let data = &packets[0].data;
|
||||
let (mut pos, mut sei) = (0usize, None);
|
||||
while let Some(len) = data.get(pos..pos + 4) {
|
||||
let n = u32::from_be_bytes([len[0], len[1], len[2], len[3]]) as usize;
|
||||
let nal = &data[pos + 4..pos + 4 + n];
|
||||
if nal[0] & 0x1f == 6 {
|
||||
sei = Some(unescape(&nal[1..]));
|
||||
}
|
||||
pos += 4 + n;
|
||||
}
|
||||
let sei = sei.expect("an SEI NAL unit in the first access unit");
|
||||
let (md, (cll, fall)) = ColorSignal::PQ.static_metadata().unwrap();
|
||||
let mut want = vec![137u8, 24];
|
||||
want.extend(md.to_bytes());
|
||||
want.extend([144u8, 4]);
|
||||
want.extend(cll.to_be_bytes());
|
||||
want.extend(fall.to_be_bytes());
|
||||
want.push(0x80);
|
||||
assert_eq!(sei, want);
|
||||
}
|
||||
@@ -85,6 +85,18 @@ pub fn stepped(format: Format) -> bool {
|
||||
matches!(format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
|
||||
}
|
||||
|
||||
/// Whether an export is HDR: an HDR working space, SDR not asked for, and a format whose encoder
|
||||
/// writes HDR (`hdr_available` says so for H.265, which has no built-in encoder).
|
||||
pub(crate) fn hdr_output(working_hdr: bool, sdr: bool, format: Format, hdr_available: impl Fn(Format) -> bool) -> bool {
|
||||
working_hdr
|
||||
&& !sdr
|
||||
&& match format {
|
||||
Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv => true,
|
||||
Format::Hevc => hdr_available(Format::Hevc),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result<Box<dyn VideoEncoder>> {
|
||||
video_factories()
|
||||
.read()
|
||||
@@ -102,9 +114,10 @@ impl Exporter {
|
||||
}
|
||||
settings.validate()?;
|
||||
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
|
||||
// HDR sequences export HDR (H.264 / ProRes / DNxHR / APV) unless SDR is asked for (H.265 export is 8-bit SDR)
|
||||
// HDR sequences export HDR (H.264 / ProRes / DNxHR / APV, and H.265 where a registered encoder
|
||||
// writes Main 10) unless SDR is asked for; elsewhere H.265 is tone-mapped 8-bit SDR
|
||||
let pipe = q.settings.color;
|
||||
let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.video_format(), Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv);
|
||||
let hdr_out = hdr_output(pipe.working.is_hdr(), settings.sdr, settings.video_format(), crate::hdr_available);
|
||||
let mut settings = settings.clone();
|
||||
settings.signal = match (hdr_out, pipe.working) {
|
||||
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,
|
||||
|
||||
+109
-14
@@ -64,7 +64,9 @@ pub enum Format {
|
||||
H264,
|
||||
/// MPEG-4 (or QuickTime, see [`Multiplexer`]), H.265 / HEVC Main (8-bit) video + AAC audio. There is no
|
||||
/// built-in encoder: a platform hardware encoder registers one ([`register_encoder`]) and says so
|
||||
/// with [`register_format_probe`]; [`available`] is false without it.
|
||||
/// with [`register_format_probe`]; [`available`] is false without it. HDR sequences export as HDR
|
||||
/// (Main 10, PQ / HLG) only where an encoder also says so with [`register_hdr_probe`]
|
||||
/// ([`hdr_available`]); elsewhere they are tone-mapped to SDR.
|
||||
#[serde(rename = "hevc", alias = "Hevc")]
|
||||
Hevc,
|
||||
/// QuickTime, Apple ProRes 422 (HQ unless the settings pick another flavour) + PCM.
|
||||
@@ -253,6 +255,10 @@ pub struct ExportSettings {
|
||||
/// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI).
|
||||
#[serde(default)]
|
||||
pub sdr: bool,
|
||||
/// Keep the alpha channel in PNG and TIFF sequences (straight alpha, Premiere's "Include
|
||||
/// Alpha Channel"). Off, every frame is flattened over black (#160). Other formats ignore it.
|
||||
#[serde(default)]
|
||||
pub alpha: bool,
|
||||
/// Output frame size (None = Match Source: the sequence size times `scale`).
|
||||
pub frame_size: Option<(u32, u32)>,
|
||||
/// Output frame rate (None = Match Source).
|
||||
@@ -420,12 +426,18 @@ impl ColorSignal {
|
||||
} else {
|
||||
filmcraft_isobmff::ColorInfo::Nclx { primaries: p, transfer: t, matrix: m, full_range: false }
|
||||
});
|
||||
if self.transfer == 16 {
|
||||
v.mastering_display = Some(filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001));
|
||||
v.content_light = Some((0, 0));
|
||||
if let Some((md, cll)) = self.static_metadata() {
|
||||
v.mastering_display = Some(md);
|
||||
v.content_light = Some(cll);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// HDR10 static metadata of a PQ signal: the mastering display (BT.2020/D65, 1000 / 0.0001 cd/m²)
|
||||
/// and (MaxCLL, MaxFALL) = (0, 0), unknown. `None` for HLG and SDR. The one definition of these
|
||||
/// values: the `mdcv` / `clli` boxes and the SEI messages of every encoder come from here.
|
||||
pub fn static_metadata(&self) -> Option<(filmcraft_isobmff::MasteringDisplay, (u16, u16))> {
|
||||
(self.transfer == 16).then(|| (filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001), (0, 0)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExportSettings {
|
||||
@@ -445,6 +457,7 @@ impl Default for ExportSettings {
|
||||
apv_profile: String::new(),
|
||||
mxf_video_codec: MxfVideoCodec::default(),
|
||||
sdr: false,
|
||||
alpha: false,
|
||||
frame_size: None,
|
||||
frame_rate: None,
|
||||
scaling: Scaling::default(),
|
||||
@@ -692,6 +705,29 @@ pub fn register_format_probe(format: Format, probe: fn() -> bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn hdr_probes() -> &'static RwLock<Vec<FormatProbe>> {
|
||||
static P: OnceLock<RwLock<Vec<FormatProbe>>> = OnceLock::new();
|
||||
P.get_or_init(|| RwLock::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// Say how to find out whether the registered encoder of `format` can write HDR (PQ / HLG) on this
|
||||
/// machine: `probe` runs when [`hdr_available`] asks (it should cache its answer). Only H.265 needs
|
||||
/// one: its encoders are hardware-only and not all of them take 10-bit pictures, so an HDR sequence
|
||||
/// exports as tone-mapped SDR unless a probe says otherwise. Registering the same probe twice is harmless.
|
||||
pub fn register_hdr_probe(format: Format, probe: fn() -> bool) {
|
||||
let mut g = hdr_probes().write().unwrap_or_else(|e| e.into_inner());
|
||||
if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) {
|
||||
g.push((format, probe));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a registered probe says the encoder of `format` writes HDR here (false without one).
|
||||
pub fn hdr_available(format: Format) -> bool {
|
||||
// the probes run outside the lock: one may take a while
|
||||
let probes: Vec<fn() -> bool> = hdr_probes().read().unwrap_or_else(|e| e.into_inner()).iter().filter(|(f, _)| *f == format).map(|(_, p)| *p).collect();
|
||||
probes.into_iter().any(|p| p())
|
||||
}
|
||||
|
||||
/// Whether a format can currently be exported: it has a built-in encoder, or a registered probe
|
||||
/// says its encoder works here.
|
||||
pub fn available(format: Format) -> bool {
|
||||
@@ -1160,6 +1196,59 @@ pub fn rgbf_to_yuv420_8(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &m
|
||||
});
|
||||
}
|
||||
|
||||
/// Limited-range 10-bit 4:2:0 (code values 0..1023, not MSB-aligned) from encoded R'G'B' floats with
|
||||
/// matrix (Kr, Kb): Y = 64 + 876·Y', C = 512 + 896·C, chroma averaged over each 2x2 block (one pixel
|
||||
/// or pair at an odd edge). `rgb` is 3 floats per pixel; NaN counts as 0, infinities and
|
||||
/// out-of-range values clamp to 0..1 first, and codes clamp to 4..1019 like [`rgbf_to_yuv422_10`]
|
||||
/// (so black is 64, peak white 940 and the chroma of any colour stays within 64..960).
|
||||
/// The planes are resized to `w * h` and `ceil(w / 2) * ceil(h / 2)`. A `rgb` slice of the wrong
|
||||
/// length, a zero size or a size whose arithmetic overflows is an error.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn rgbf_to_yuv420_10(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &mut Vec<u16>, u: &mut Vec<u16>, v: &mut Vec<u16>) -> Result<()> {
|
||||
let bad = || ExportError::Encode(format!("a {w}x{h} HDR picture needs w*h*3 floats, got {}", rgb.len()));
|
||||
let px = w.checked_mul(h).ok_or_else(bad)?;
|
||||
if w == 0 || h == 0 || px.checked_mul(3) != Some(rgb.len()) {
|
||||
return Err(bad());
|
||||
}
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let kg = 1.0 - kr - kb;
|
||||
let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr));
|
||||
y.clear();
|
||||
y.resize(px, 0);
|
||||
u.clear();
|
||||
u.resize(cw * ch, 0);
|
||||
v.clear();
|
||||
v.resize(cw * ch, 0);
|
||||
let unit = |x: f32| if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) };
|
||||
let code = |x: f32| x.round().clamp(4.0, 1019.0) as u16;
|
||||
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
|
||||
let rows = yr.len() / w;
|
||||
let mut us = vec![0f32; cw];
|
||||
let mut vs = vec![0f32; cw];
|
||||
let mut cnt = vec![0f32; cw];
|
||||
for (dy, yrow) in yr.chunks_exact_mut(w).enumerate().take(rows) {
|
||||
let row = cy * 2 + dy;
|
||||
let Some(src) = rgb.get(row * w * 3..(row + 1) * w * 3) else { continue };
|
||||
for (x, (&[r, g, b], yo)) in src.as_chunks::<3>().0.iter().zip(yrow.iter_mut()).enumerate() {
|
||||
let (r, g, b) = (unit(r), unit(g), unit(b));
|
||||
let yy = kr * r + kg * g + kb * b;
|
||||
*yo = code(64.0 + 876.0 * yy);
|
||||
if let (Some(a), Some(c), Some(n)) = (us.get_mut(x / 2), vs.get_mut(x / 2), cnt.get_mut(x / 2)) {
|
||||
*a += (b - yy) / sb;
|
||||
*c += (r - yy) / sr;
|
||||
*n += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (cx, (uo, vo)) in ur.iter_mut().zip(vr.iter_mut()).enumerate() {
|
||||
let n = cnt.get(cx).copied().unwrap_or(1.0).max(1.0);
|
||||
*uo = code(512.0 + 896.0 * us.get(cx).copied().unwrap_or(0.0) / n);
|
||||
*vo = code(512.0 + 896.0 * vs.get(cx).copied().unwrap_or(0.0) / n);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Slices per H.264 picture: one per four macroblock rows. The encoder's default follows the core
|
||||
/// count, which would make the stream (and every decoded picture) depend on the machine; this is
|
||||
/// what it picks on a machine with enough cores.
|
||||
@@ -1199,12 +1288,11 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti
|
||||
}
|
||||
if s.signal.is_hdr() {
|
||||
cfg.color = filmcraft_h264enc::ColorConfig { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix, full_range: false };
|
||||
if s.signal.transfer == 16 {
|
||||
let md = filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001).to_bytes();
|
||||
if let Some((md, cll)) = s.signal.static_metadata() {
|
||||
let mut b = [0u8; 24];
|
||||
b.copy_from_slice(&md);
|
||||
b.copy_from_slice(&md.to_bytes());
|
||||
cfg.mastering_display = Some(b);
|
||||
cfg.content_light = Some((0, 0));
|
||||
cfg.content_light = Some(cll);
|
||||
}
|
||||
}
|
||||
Some(
|
||||
@@ -1266,11 +1354,12 @@ impl Write for SharedBuf {
|
||||
|
||||
/// Encode straight sRGB RGBA8 pixels as a PNG file (thumbnails, previews).
|
||||
pub fn encode_png(rgba: Vec<u8>, w: u32, h: u32) -> Result<Vec<u8>> {
|
||||
encode_still(Format::PngSequence, rgba, w, h)
|
||||
encode_still(Format::PngSequence, rgba, w, h, false)
|
||||
}
|
||||
|
||||
/// Encode one still of an image sequence (also Export Frame).
|
||||
pub fn encode_still(format: Format, rgba: Vec<u8>, w: u32, h: u32) -> Result<Vec<u8>> {
|
||||
/// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha
|
||||
/// channel it is given; `alpha` keeps it in TIFF too, which is otherwise written as RGB.
|
||||
pub fn encode_still(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
|
||||
let enc = |e: image::ImageError| ExportError::Encode(e.to_string());
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
match format {
|
||||
@@ -1279,9 +1368,12 @@ pub fn encode_still(format: Format, rgba: Vec<u8>, w: u32, h: u32) -> Result<Vec
|
||||
}
|
||||
Format::TiffSequence | Format::BmpSequence => {
|
||||
let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame size".into()))?;
|
||||
let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
|
||||
let f = if format == Format::TiffSequence { image::ImageFormat::Tiff } else { image::ImageFormat::Bmp };
|
||||
rgb.write_to(&mut out, f).map_err(enc)?
|
||||
if alpha && format == Format::TiffSequence {
|
||||
img.write_to(&mut out, f).map_err(enc)?
|
||||
} else {
|
||||
image::DynamicImage::ImageRgba8(img).to_rgb8().write_to(&mut out, f).map_err(enc)?
|
||||
}
|
||||
}
|
||||
_ => return Err(ExportError::Unsupported(format!("{} is not an image sequence", format.label()))),
|
||||
}
|
||||
@@ -1358,7 +1450,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
let written: Vec<Result<u64>> = (f..end)
|
||||
.into_par_iter()
|
||||
.map(|fi| {
|
||||
let data = encode_still(settings.format, pipe.frame(fi, sources).0, w, h)?;
|
||||
let data = encode_still(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha)?;
|
||||
write_output(settings, &pcm::image_sequence_path(&settings.path, (fi - f0) as u64, count), data)
|
||||
})
|
||||
.collect();
|
||||
@@ -1404,3 +1496,6 @@ mod mxf_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod determinism_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_tests;
|
||||
|
||||
@@ -12,7 +12,7 @@ use filmcraft_time::FrameRate;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay};
|
||||
use crate::{ExportError, ExportSettings, Result};
|
||||
use crate::{ExportError, ExportSettings, Format, Result};
|
||||
|
||||
/// Where the rendered picture lands in the output frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -61,11 +61,29 @@ pub(crate) struct Pipeline {
|
||||
geom: Geometry,
|
||||
opts: RenderOptions,
|
||||
pub hdr_out: bool,
|
||||
/// Keep straight alpha in the RGBA8 output instead of flattening over black.
|
||||
alpha: bool,
|
||||
out_tf: Option<filmcraft_color::OutputTransform>,
|
||||
effects: ExportEffects,
|
||||
overlay: Option<Image>,
|
||||
start_tc: i64,
|
||||
drop_frame: bool,
|
||||
/// Free the pool's float images when the export ends: a standalone export, not one part of a
|
||||
/// batch (`ExportSettings::part_of_batch`).
|
||||
trim_pool: bool,
|
||||
}
|
||||
|
||||
impl Drop for Pipeline {
|
||||
/// The export is over, however it ended (done, failed, cancelled, dropped half way). The float
|
||||
/// images its frames left on the `filmcraft_frame::pool` float shelf are the size of its
|
||||
/// frames, which nothing else asks for, so free them instead of holding up to 320 MiB idle. A
|
||||
/// job still rendering at that moment allocates a few images again; the pool is only a cache.
|
||||
/// Parts of a larger job (render-preview segments, proxies) keep them for the next part.
|
||||
fn drop(&mut self) {
|
||||
if self.trim_pool {
|
||||
filmcraft_frame::pool::trim_f32();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
@@ -98,6 +116,7 @@ impl Pipeline {
|
||||
seq_rate: q.settings.frame_rate,
|
||||
start_tc: q.start_timecode,
|
||||
drop_frame: q.settings.drop_frame,
|
||||
trim_pool: !settings.part_of_batch,
|
||||
project: project.clone(),
|
||||
seq,
|
||||
rate: r.rate,
|
||||
@@ -106,6 +125,7 @@ impl Pipeline {
|
||||
geom,
|
||||
opts,
|
||||
hdr_out,
|
||||
alpha: settings.alpha && matches!(settings.format, Format::PngSequence | Format::TiffSequence),
|
||||
out_tf,
|
||||
effects,
|
||||
overlay,
|
||||
@@ -134,7 +154,7 @@ impl Pipeline {
|
||||
filmcraft_frame::pool::recycle_f32(img.px);
|
||||
return (Vec::new(), out);
|
||||
}
|
||||
let mut rgba = img.over_black_rgba8();
|
||||
let mut rgba = if self.alpha { img.to_rgba8() } else { img.over_black_rgba8() };
|
||||
// the float image is not needed any more: its buffer serves the next frame's layers
|
||||
filmcraft_frame::pool::recycle_f32(img.px);
|
||||
if lim.enabled {
|
||||
|
||||
@@ -509,7 +509,9 @@ impl ExportSettings {
|
||||
v += &format!(", keyframe every {} frames", r.keyint);
|
||||
}
|
||||
Format::Hevc => {
|
||||
v += &format!(", HEVC Main (hardware encoder), {}", self.bitrate_mode.label());
|
||||
// Main 10 only where a registered encoder writes HDR (NVENC); VideoToolbox is 8-bit
|
||||
let profile = if crate::hdr_available(Format::Hevc) { "HEVC Main / Main 10 for HDR" } else { "HEVC Main" };
|
||||
v += &format!(", {profile} (hardware encoder), {}", self.bitrate_mode.label());
|
||||
v += &match self.bitrate_mode {
|
||||
BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)),
|
||||
_ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)),
|
||||
|
||||
@@ -109,6 +109,28 @@ fn image_sequences_are_numbered_like_premiere() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160).
|
||||
#[test]
|
||||
fn image_sequences_keep_alpha_when_asked() {
|
||||
let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None);
|
||||
for (fmt, ext) in [(Format::PngSequence, "png"), (Format::TiffSequence, "tif")] {
|
||||
for alpha in [false, true] {
|
||||
let dir = Scratch::new(&format!("alpha-{ext}-{alpha}"));
|
||||
let mut s = ExportSettings { format: fmt, path: dir.path(&format!("a.{ext}")), alpha, ..Default::default() };
|
||||
s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(2)));
|
||||
export(&p, seq, &s, &m, &Progress::default()).unwrap();
|
||||
let img = image::open(dir.0.join(format!("a000.{ext}"))).unwrap();
|
||||
let px = img.to_rgba8().get_pixel(32, 18).0;
|
||||
if alpha {
|
||||
assert!(img.color().has_alpha(), "{fmt:?} must be written with an alpha channel");
|
||||
assert!((100..=160).contains(&px[3]) && px[2] > 200 && px[0] < 15, "{fmt:?} straight alpha: {px:?}");
|
||||
} else {
|
||||
assert!(px[3] == 255 && px[2] > 100 && px[2] < 240 && px[0] < 15, "{fmt:?} flattened over black: {px:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_audio_only() {
|
||||
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! A standalone export gives back the float images it left on the frame pool
|
||||
//! (`filmcraft_frame::pool`) when it ends, however it ends; one part of a batch keeps them. One test in this file on purpose: the pool is shared by every test of a binary,
|
||||
//! and its numbers mean something only while nothing else renders.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use filmcraft_export::{ExportSettings, Exporter, Format, Progress, Step, export};
|
||||
use filmcraft_frame::pool;
|
||||
use filmcraft_media::generators::GeneratorSource;
|
||||
use filmcraft_media::{Generator, MediaSource};
|
||||
use filmcraft_project::{ItemId, ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind};
|
||||
use filmcraft_render::SourceMap;
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
|
||||
|
||||
/// A red matte filling a 320×180 sequence for a second.
|
||||
fn project() -> (Arc<Project>, ItemId, SourceMap) {
|
||||
let mut p = Project::new("x");
|
||||
let rate = FrameRate::FPS_24;
|
||||
let g = GeneratorSource::new(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 1.0] }, 320, 180, rate, Tick(2 * TICKS_PER_SECOND));
|
||||
let info = g.info().clone();
|
||||
let clip = MediaClip {
|
||||
media: MediaRef::Generator(g.generator.clone()),
|
||||
info: info.clone(),
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
};
|
||||
let red = p.add_item(&info.name, Label::Iris, ItemKind::Media(clip), None);
|
||||
let seq = p.new_sequence("s", SequenceSettings { width: 320, height: 180, frame_rate: rate, ..Default::default() }, 1, 1, None);
|
||||
let item = p.make_track_item(red, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(24)), rate).unwrap();
|
||||
p.sequence_mut(seq).unwrap().video_tracks[0].items.push(item);
|
||||
let mut sources = SourceMap::default();
|
||||
sources.0.insert(red, Arc::new(g));
|
||||
(Arc::new(p), seq, sources)
|
||||
}
|
||||
|
||||
fn idle_mb() -> f64 {
|
||||
pool::stats().f32_idle_bytes as f64 / 1e6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_float_shelf_is_empty_when_a_standalone_export_ends_however_it_ended() {
|
||||
let (p, seq, sources) = project();
|
||||
let dir = std::env::temp_dir().join(format!("fc-pool-trim-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let settings =
|
||||
|format: Format, name: &str| ExportSettings { format, path: dir.join(name).to_string_lossy().to_string(), include_audio: false, ..Default::default() };
|
||||
// an image the pool could keep, standing in for what the frames of an export leave (the frames
|
||||
// themselves do too: the checks below fail without the trim)
|
||||
let leave_something = || {
|
||||
pool::recycle_f32(vec![0.0; 100_000]);
|
||||
assert!(pool::stats().f32_idle_bytes > 0);
|
||||
};
|
||||
let check = |what: &str| assert_eq!(pool::stats().f32_idle_bytes, 0, "after {what}: {:.1} MB idle", idle_mb());
|
||||
|
||||
// images, which run in `export` itself
|
||||
leave_something();
|
||||
export(&p, seq, &settings(Format::PngSequence, "seq.png"), &sources, &Progress::default()).unwrap();
|
||||
check("an image sequence");
|
||||
|
||||
// an encoded video, run to the end
|
||||
leave_something();
|
||||
export(&p, seq, &settings(Format::ProRes, "a.mov"), &sources, &Progress::default()).unwrap();
|
||||
check("a finished video");
|
||||
|
||||
// cancelled before the first frame
|
||||
leave_something();
|
||||
let cancelled = Progress::default();
|
||||
cancelled.cancel.store(true, Ordering::Relaxed);
|
||||
assert!(export(&p, seq, &settings(Format::ProRes, "b.mov"), &sources, &cancelled).is_err());
|
||||
check("a cancelled video");
|
||||
|
||||
// an encoder that is not there: the pipeline was built, the export never started
|
||||
leave_something();
|
||||
assert!(export(&p, seq, &settings(Format::Hevc, "c.mp4"), &sources, &Progress::default()).is_err());
|
||||
check("an export that could not start");
|
||||
|
||||
// stepped by the host (the web build), dropped half way: 6 frames of 24
|
||||
let progress = Progress::default();
|
||||
let mut ex = Exporter::new(p.clone(), seq, &settings(Format::ProRes, "d.mov"), &progress).unwrap();
|
||||
ex.set_batch(2);
|
||||
for _ in 0..3 {
|
||||
assert!(matches!(ex.step(&sources, &progress), Ok(Step::Progress)));
|
||||
}
|
||||
leave_something();
|
||||
drop(ex);
|
||||
check("an export dropped half way");
|
||||
|
||||
// one part of a batch (a render-preview segment, a proxy): the next part reuses the images
|
||||
leave_something();
|
||||
let part = ExportSettings { part_of_batch: true, ..settings(Format::ProRes, "e.mov") };
|
||||
export(&p, seq, &part, &sources, &Progress::default()).unwrap();
|
||||
assert!(pool::stats().f32_idle_bytes > 0, "a part of a batch keeps the float images");
|
||||
pool::trim_f32();
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
+43
-12
@@ -9,6 +9,9 @@
|
||||
//! The compositor's working images (premultiplied linear `f32`, 33 MB at 1080p) get the same
|
||||
//! treatment ([`take_f32_overwritten`], [`recycle_f32`]): allocated and freed once per layer per
|
||||
//! frame they cost a zero-fill and fresh page faults every time.
|
||||
//!
|
||||
//! The shelves are sized for the busiest work, so they are a lot to keep once it is over: whoever
|
||||
//! finishes such work (a standalone export) calls [`trim_f32`].
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
@@ -138,10 +141,22 @@ pub fn recycle(frame: Arc<VideoFrame>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Free the idle float images. A standalone export leaves that shelf full (up to [`F32_MAX_BYTES`]
|
||||
/// of images, each the size of that export's frames) and nothing else asks for those sizes again,
|
||||
/// so they would sit idle until the next export. Images still in use are not touched. The 8- and
|
||||
/// 16-bit plane shelves are left alone: playback recycles decoded frames through them all the time.
|
||||
pub fn trim_f32() {
|
||||
// taken under the lock, freed after it: giving back hundreds of megabytes takes a moment
|
||||
let floats = std::mem::take(&mut *F32.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
drop(floats);
|
||||
}
|
||||
|
||||
/// Idle buffers held and buffers handed out again so far (`perf.stats`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PoolStats {
|
||||
pub idle_bytes: usize,
|
||||
/// The part of `idle_bytes` held as float images ([`recycle_f32`]).
|
||||
pub f32_idle_bytes: usize,
|
||||
pub reused: u64,
|
||||
}
|
||||
|
||||
@@ -150,7 +165,7 @@ pub fn stats() -> PoolStats {
|
||||
let a = U8.lock().unwrap_or_else(PoisonError::into_inner).bytes;
|
||||
let b = U16.lock().unwrap_or_else(PoisonError::into_inner).bytes;
|
||||
let c: usize = F32.lock().unwrap_or_else(PoisonError::into_inner).iter().map(|b| b.len().saturating_mul(4)).sum();
|
||||
PoolStats { idle_bytes: idle(idle(a, b), c), reused: REUSED.load(Ordering::Relaxed) }
|
||||
PoolStats { idle_bytes: idle(idle(a, b), c), f32_idle_bytes: c, reused: REUSED.load(Ordering::Relaxed) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -226,21 +241,37 @@ mod tests {
|
||||
pts: filmcraft_time::Tick::ZERO,
|
||||
})
|
||||
};
|
||||
let before = stats().reused;
|
||||
// a frame someone else still holds is left alone
|
||||
let plane_ptrs = |frame: &VideoFrame| match &frame.data {
|
||||
PixelData::Yuv8 { planes, .. } => planes.each_ref().map(|plane| plane.as_ptr() as usize),
|
||||
_ => panic!("fixture has three byte planes"),
|
||||
};
|
||||
// A frame someone else still holds is left alone. The reuse statistic is global:
|
||||
// concurrent float-buffer tests can change it without touching these planes.
|
||||
let shared = yuv(1);
|
||||
let held = shared.clone();
|
||||
let held_ptrs = plane_ptrs(&held);
|
||||
recycle(shared);
|
||||
assert!(take_u8(n).capacity() >= n);
|
||||
assert_eq!(stats().reused, before);
|
||||
let fresh = take_u8(n);
|
||||
assert!(fresh.capacity() >= n);
|
||||
assert!(!held_ptrs.contains(&(fresh.as_ptr() as usize)), "a shared plane cannot be handed out");
|
||||
assert_eq!(held.byte_size(), n + 2 * c);
|
||||
// an unshared frame's planes come back, empty
|
||||
recycle(yuv(2));
|
||||
// An unshared frame's exact allocations come back, empty. Equal-size chroma
|
||||
// planes may be taken in either order.
|
||||
let unshared = yuv(2);
|
||||
let returned_ptrs = plane_ptrs(&unshared);
|
||||
recycle(unshared);
|
||||
let (y, u, v) = (take_u8(n), take_u8(c), take_u8(c));
|
||||
assert!(y.is_empty() && y.capacity() >= n && u.capacity() >= c && v.capacity() >= c);
|
||||
assert_eq!(stats().reused, before + 3);
|
||||
// and are not handed out twice
|
||||
take_u8(n);
|
||||
assert_eq!(stats().reused, before + 3);
|
||||
assert!(y.is_empty() && u.is_empty() && v.is_empty());
|
||||
assert!(y.capacity() >= n && u.capacity() >= c && v.capacity() >= c);
|
||||
assert_eq!(y.as_ptr() as usize, returned_ptrs[0]);
|
||||
let mut expected_chroma = [returned_ptrs[1], returned_ptrs[2]];
|
||||
let mut actual_chroma = [u.as_ptr() as usize, v.as_ptr() as usize];
|
||||
expected_chroma.sort_unstable();
|
||||
actual_chroma.sort_unstable();
|
||||
assert_eq!(actual_chroma, expected_chroma);
|
||||
// The returned allocations remain owned by y/u/v and cannot be handed out twice.
|
||||
let another = take_u8(n);
|
||||
assert!(another.capacity() >= n);
|
||||
assert!(!returned_ptrs.contains(&(another.as_ptr() as usize)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! `pool::trim_f32` frees the idle float images of the process-wide pool and leaves the plane
|
||||
//! shelves alone. This file holds one test on purpose: the pool is shared by every test of a binary,
|
||||
//! and another test recycling or taking buffers while this one counts them would make the counts
|
||||
//! meaningless.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_frame::{Chroma, PixelData, VideoFrame, pool};
|
||||
|
||||
const FLOATS: usize = 300_000;
|
||||
const BYTES: usize = 200_000;
|
||||
const WORDS: usize = 100_000;
|
||||
|
||||
fn frame(data: PixelData) -> Arc<VideoFrame> {
|
||||
Arc::new(VideoFrame { width: 1, height: 1, data, color: filmcraft_color::ColorInfo::SRGB_FULL, par: (1, 1), pts: filmcraft_time::Tick::ZERO })
|
||||
}
|
||||
|
||||
/// Hand the pool one 8-bit and one 16-bit plane (the other planes are too small to be kept).
|
||||
fn recycle_planes() {
|
||||
let empty = || Arc::new(Vec::<u8>::new());
|
||||
pool::recycle(frame(PixelData::Yuv8 { planes: [Arc::new(vec![7u8; BYTES]), empty(), empty()], chroma: Chroma::C420, alpha: None }));
|
||||
let empty = || Arc::new(Vec::<u16>::new());
|
||||
pool::recycle(frame(PixelData::Yuv16 { planes: [Arc::new(vec![7u16; WORDS]), empty(), empty()], chroma: Chroma::C420, bits: 10, alpha: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_f32_frees_the_float_images_keeps_the_planes_and_the_pool_keeps_working() {
|
||||
assert_eq!(pool::stats().idle_bytes, 0, "nothing has been recycled yet");
|
||||
|
||||
// one idle buffer on each of the three shelves
|
||||
pool::recycle_f32(vec![f32::NAN; FLOATS]);
|
||||
recycle_planes();
|
||||
let planes = BYTES + WORDS * 2;
|
||||
assert_eq!(pool::stats().f32_idle_bytes, FLOATS * 4);
|
||||
assert!(pool::stats().idle_bytes >= FLOATS * 4 + planes, "idle {}", pool::stats().idle_bytes);
|
||||
|
||||
pool::trim_f32();
|
||||
assert_eq!(pool::stats().f32_idle_bytes, 0, "the float shelf is empty");
|
||||
assert!(pool::stats().idle_bytes >= planes, "the plane shelves are untouched");
|
||||
|
||||
// the float image is a fresh (zeroed) one and doesn't count as a reuse; the planes come back
|
||||
let reused = pool::stats().reused;
|
||||
let image = pool::take_f32_overwritten(FLOATS);
|
||||
assert!(image.iter().all(|v| *v == 0.0), "the recycled image was freed");
|
||||
assert_eq!(pool::stats().reused, reused);
|
||||
drop((pool::take_u8(BYTES), pool::take_u16(WORDS)));
|
||||
assert!(pool::stats().reused > reused, "the planes were kept");
|
||||
|
||||
// the pool works after a trim, and trimming twice (or an empty shelf) is fine
|
||||
pool::recycle_f32(image);
|
||||
assert_eq!(pool::stats().f32_idle_bytes, FLOATS * 4);
|
||||
pool::trim_f32();
|
||||
pool::trim_f32();
|
||||
assert_eq!(pool::stats().f32_idle_bytes, 0);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ filmcraft-color = { workspace = true }
|
||||
filmcraft-frame = { workspace = true }
|
||||
filmcraft-geom = { workspace = true }
|
||||
filmcraft-render = { workspace = true }
|
||||
filmcraft-project = { workspace = true }
|
||||
wgpu = { version = "30", default-features = false, features = ["wgsl"] }
|
||||
log = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
@@ -22,5 +23,4 @@ rayon = { workspace = true }
|
||||
pollster = "0.4"
|
||||
wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] }
|
||||
filmcraft-media = { workspace = true }
|
||||
filmcraft-project = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
|
||||
@@ -14,7 +14,7 @@ struct U {
|
||||
p0: vec4<f32>, // opacity, kind (0 rgba8 srgb straight, 1 rgba16f premul linear, 2 yuv), taps, transfer (0 srgb, 1 linear, 2 pq, 3 hlg)
|
||||
p1: vec4<f32>, // y_off y_scale c_off c_scale (code units)
|
||||
p2: vec4<f32>, // kr kb code_scale footprint
|
||||
p3: vec4<f32>, // blend mode (index into filmcraft_render::Blend::ALL), alpha-plane scale (0: none), unused ×2
|
||||
p3: vec4<f32>, // blend mode, alpha-plane scale (0: none), effect-source integer decimation, unused
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: U;
|
||||
@@ -274,6 +274,15 @@ fn linear_to_srgb(v: vec3<f32>) -> vec3<f32> {
|
||||
return select(hi, v * 12.92, v <= vec3(0.0031308));
|
||||
}
|
||||
|
||||
// Effect sources are axis-aligned integer-decimated working images. Raster-interpolated source
|
||||
// coordinates can mix adjacent texels at a nominal pixel center, inventing alpha near zero.
|
||||
// Keep the regular transformed/minified layer path unchanged; only this source draw uses its
|
||||
// exact working-pixel position and the host's integer decimation.
|
||||
@fragment
|
||||
fn fs_fx_source(in: VOut) -> @location(0) vec4<f32> {
|
||||
return layer_color(in.pos.xy * u.p3.z);
|
||||
}
|
||||
|
||||
// Blend modes that need the destination: written without fixed-function blending.
|
||||
@fragment
|
||||
fn fs_blend(in: VOut) -> @location(0) vec4<f32> {
|
||||
@@ -311,11 +320,3 @@ fn fs_full(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
|
||||
return vec4(select(hi, lo, c <= vec3(0.0031308)), 1.0);
|
||||
}
|
||||
|
||||
// Effect inputs use only positive uniform decimation, without rotation or translation.
|
||||
// Reconstruct source coordinates from exact fragment centres: rasterizer interpolation of
|
||||
// `sp` can introduce tiny neighbours, amplified by gamma and alpha inversion at black/zero alpha.
|
||||
@fragment
|
||||
fn fs_fx_source(in: VOut) -> @location(0) vec4<f32> {
|
||||
return layer_color(in.pos.xy / vec2(u.m0.x, u.m0.w));
|
||||
}
|
||||
|
||||
@@ -490,14 +490,73 @@ fn layers_with_effects_fall_back_to_the_cpu_without_a_stage() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The half-float upload is lossless for this input, so a 1:1 effect source draw must be too.
|
||||
/// Odd dimensions expose interpolation drift that can invent opacity before any effect runs.
|
||||
#[test]
|
||||
fn effect_source_identity_preserves_black_and_transparent_pixels() {
|
||||
let Some((dev, q)) = device() else { return };
|
||||
fn effect_source_identity_preserves_odd_sized_half_float_texels() {
|
||||
let Some((dev, q)) = device() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let mut c = GpuCompositor::new(&dev, &q);
|
||||
for (w, h) in [(67u32, 41u32), (96, 54), (64, 32)] {
|
||||
let px = picture(w, h);
|
||||
let frame = VideoFrame::rgba_f32(w, h, px.clone());
|
||||
for n in [0, 1] {
|
||||
let (ow, oh, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: n, ops: Vec::new() }).expect("source image");
|
||||
assert_eq!((ow, oh, gpu.len()), (w, h, px.len()));
|
||||
for (i, (expected, actual)) in px.iter().zip(&gpu).enumerate() {
|
||||
assert_eq!(actual.to_bits(), expected.to_bits(), "{w}x{h} n{n} source component{i}: {actual} != {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_invert_does_not_amplify_invented_opacity_at_odd_source_pixels() {
|
||||
let Some((dev, q)) = device() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let mut c = GpuCompositor::new(&dev, &q);
|
||||
let (w, h) = (67, 41);
|
||||
let px = picture(w, h);
|
||||
let frame = VideoFrame::rgba_f32(w, h, px.clone());
|
||||
let fx = LayerFx { size: (w, h), decimation: 1, ops: vec![] };
|
||||
let (_, _, got) = c.effect_image(&frame, &fx).expect("working image");
|
||||
assert_eq!(got, px, "identity import must not sample neighbouring transparent or black pixels");
|
||||
let op = FxOp::Invert { channel: 4, blend: 0.2 };
|
||||
let mut cpu = filmcraft_render::Image { w: w as usize, h: h as usize, px: px.clone() };
|
||||
op.apply(&mut cpu);
|
||||
let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: 1, ops: vec![op] }).expect("alpha invert image");
|
||||
assert_eq!(gpu.len(), px.len());
|
||||
let (worst, flips) = compare(&cpu.px, &gpu);
|
||||
assert!(worst <= EXACT && flips == 0.0, "alpha invert: {worst}, {flips}");
|
||||
let mut transparent = 0;
|
||||
for (source, result) in px.as_chunks::<4>().0.iter().zip(gpu.as_chunks::<4>().0.iter()) {
|
||||
if source[3] == 0.0 {
|
||||
transparent += 1;
|
||||
assert_eq!(&result[..3], &[0.0; 3], "a transparent source must not acquire neighboring RGB");
|
||||
assert!((result[3] - 0.8).abs() <= f32::EPSILON);
|
||||
}
|
||||
}
|
||||
assert_eq!(transparent, 402);
|
||||
}
|
||||
|
||||
/// The exact source grid keeps the existing box-minification behavior at non-unit factors.
|
||||
#[test]
|
||||
fn effect_source_integer_decimation_preserves_cpu_box_oracle() {
|
||||
let Some((dev, q)) = device() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let mut c = GpuCompositor::new(&dev, &q);
|
||||
for (w, h) in [(67u32, 41u32), (96, 54)] {
|
||||
let frame = VideoFrame::rgba_f32(w, h, picture(w, h));
|
||||
for n in [2, 4, 8] {
|
||||
let (ow, oh, cpu) = frame.to_linear_f32_decimated(n as usize);
|
||||
let (gw, gh, gpu) =
|
||||
c.effect_image(&frame, &LayerFx { size: (ow as u32, oh as u32), decimation: n, ops: Vec::new() }).expect("decimated source image");
|
||||
assert_eq!((gw as usize, gh as usize, gpu.len()), (ow, oh, cpu.len()));
|
||||
let (worst, flips) = compare(&cpu, &gpu);
|
||||
assert!(worst <= EXACT && flips == 0.0, "{w}x{h} n{n} source box: {worst}, {flips}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+98
-15
@@ -36,11 +36,12 @@ use rayon::prelude::*;
|
||||
use filmcraft_color::{Matrix, Range, Transfer};
|
||||
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
|
||||
use filmcraft_render::Blend;
|
||||
use filmcraft_render::plan::{FramePlan, PlanLayer};
|
||||
use filmcraft_render::plan::{FramePlan, PlanLayer, PlanStep};
|
||||
|
||||
pub mod fx;
|
||||
pub mod lut;
|
||||
pub mod mask;
|
||||
mod transition;
|
||||
pub use lut::GpuLut;
|
||||
pub use mask::GpuMask;
|
||||
|
||||
@@ -83,6 +84,10 @@ pub struct GpuCompositor {
|
||||
/// The effect stage (None on devices without compute shaders or storage textures: layers
|
||||
/// with effects are then rendered on the CPU here).
|
||||
fx: Option<fx::FxStage>,
|
||||
transition: transition::PushStage,
|
||||
transition_inputs: [Option<(wgpu::Texture, wgpu::TextureView, (u32, u32))>; 2],
|
||||
pub gpu_transitions: u64,
|
||||
pub cpu_transitions: u64,
|
||||
/// Total bytes uploaded (stats).
|
||||
pub uploaded_bytes: u64,
|
||||
}
|
||||
@@ -149,11 +154,12 @@ impl Prepared {
|
||||
pub struct PreparedPlan {
|
||||
layers: Vec<Option<Prepared>>,
|
||||
image: Option<Prepared>,
|
||||
children: Vec<PreparedPlan>,
|
||||
}
|
||||
|
||||
impl PreparedPlan {
|
||||
pub fn bytes(&self) -> usize {
|
||||
self.layers.iter().flatten().chain(&self.image).map(Prepared::bytes).sum()
|
||||
self.layers.iter().flatten().chain(&self.image).map(Prepared::bytes).sum::<usize>() + self.children.iter().map(Self::bytes).sum::<usize>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,11 +212,25 @@ pub fn prepare_frame(f: &VideoFrame) -> Option<Prepared> {
|
||||
/// Convert every layer of a plan for upload. Thread-safe and GPU-free: run it on a worker.
|
||||
pub fn prepare(plan: &FramePlan) -> PreparedPlan {
|
||||
match plan {
|
||||
FramePlan::Layers { layers, .. } => PreparedPlan { layers: layers.iter().map(|l| prepare_frame(&l.frame)).collect(), image: None },
|
||||
FramePlan::Image(img) => PreparedPlan { layers: Vec::new(), image: Some(Prepared { planes: vec![f32_to_f16_bytes(&img.px)] }) },
|
||||
FramePlan::Layers { layers, .. } => prepare_layers(layers),
|
||||
FramePlan::Image(img) => PreparedPlan { image: Some(Prepared { planes: vec![f32_to_f16_bytes(&img.px)] }), ..Default::default() },
|
||||
FramePlan::Composite { steps, .. } => PreparedPlan {
|
||||
children: steps
|
||||
.iter()
|
||||
.flat_map(|s| match s {
|
||||
PlanStep::Layers(layers) => vec![prepare_layers(layers)],
|
||||
PlanStep::Transition { inputs, .. } => inputs.iter().map(|ls| prepare_layers(ls)).collect(),
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_layers(layers: &[PlanLayer]) -> PreparedPlan {
|
||||
PreparedPlan { layers: layers.iter().map(|l| prepare_frame(&l.frame)).collect(), ..Default::default() }
|
||||
}
|
||||
|
||||
impl GpuCompositor {
|
||||
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -341,6 +361,10 @@ impl GpuCompositor {
|
||||
clock: 0,
|
||||
dummy,
|
||||
fx,
|
||||
transition: transition::PushStage::new(device),
|
||||
transition_inputs: [None, None],
|
||||
gpu_transitions: 0,
|
||||
cpu_transitions: 0,
|
||||
uploaded_bytes: 0,
|
||||
}
|
||||
}
|
||||
@@ -505,15 +529,28 @@ impl GpuCompositor {
|
||||
/// The source description of an uploaded frame.
|
||||
fn src_info(&self, f: &VideoFrame, key: (usize, u32, u32)) -> Option<SrcInfo> {
|
||||
let up = self.uploads.get(&key)?;
|
||||
Some(SrcInfo { kind: up.kind, code_scale: up.code_scale, alpha: up.alpha_scale, chroma: up.chroma, size: (f.width, f.height), color: f.color })
|
||||
let code_levels = match &f.data {
|
||||
PixelData::Yuv8 { .. } => 256.0,
|
||||
PixelData::Yuv16 { bits, .. } => (*bits as f32).exp2(),
|
||||
_ => 1.0,
|
||||
};
|
||||
Some(SrcInfo {
|
||||
kind: up.kind,
|
||||
code_scale: up.code_scale,
|
||||
code_levels,
|
||||
alpha: up.alpha_scale,
|
||||
chroma: up.chroma,
|
||||
size: (f.width, f.height),
|
||||
color: f.color,
|
||||
})
|
||||
}
|
||||
|
||||
fn uniforms(src: &SrcInfo, m: &filmcraft_geom::Affine, opacity: f32, blend: Blend, out: (u32, u32)) -> [f32; 28] {
|
||||
let (kr, kb) = src.color.matrix.kr_kb();
|
||||
let bits_scale = src.code_scale / 255.0; // code units relative to 8-bit
|
||||
let bits_scale = src.code_levels / 256.0; // 2^(bits - 8), independent of texture encoding
|
||||
let (yo, ys, co, cs) = match (src.color.range, src.kind) {
|
||||
(Range::Limited, 2) => (16.0 * bits_scale, 219.0 * bits_scale, 128.0 * bits_scale, 224.0 * bits_scale),
|
||||
(Range::Full, 2) => (0.0, src.code_scale - 1.0, src.code_scale / 2.0, src.code_scale - 1.0),
|
||||
(Range::Full, 2) => (0.0, src.code_levels - 1.0, src.code_levels / 2.0, src.code_levels - 1.0),
|
||||
_ => (0.0, 1.0, 0.0, 1.0),
|
||||
};
|
||||
let transfer = match src.color.transfer {
|
||||
@@ -580,6 +617,10 @@ impl GpuCompositor {
|
||||
/// [`composite`](Self::composite) with texel conversions already done by [`prepare`] (the
|
||||
/// result is identical; only the upload work on this thread differs).
|
||||
pub fn composite_prepared(&mut self, plan: &FramePlan, prep: Option<&PreparedPlan>) -> (wgpu::TextureView, (u32, u32)) {
|
||||
self.render_plan(plan, prep, true, true)
|
||||
}
|
||||
|
||||
fn render_plan(&mut self, plan: &FramePlan, prep: Option<&PreparedPlan>, clear: bool, resolve: bool) -> (wgpu::TextureView, (u32, u32)) {
|
||||
let owned;
|
||||
let (w, h, layers): (u32, u32, &[PlanLayer]) = match plan {
|
||||
FramePlan::Layers { width, height, layers } => (*width as u32, *height as u32, layers.as_slice()),
|
||||
@@ -597,6 +638,7 @@ impl GpuCompositor {
|
||||
owned = [PlanLayer { frame: Arc::new(frame), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }];
|
||||
(img.w as u32, img.h as u32, &owned[..])
|
||||
}
|
||||
FramePlan::Composite { width, height, steps } => return self.composite_steps(*width, *height, steps, prep),
|
||||
};
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
let accum_view = Self::target(&self.device, &mut self.accum, w, h, ACCUM_FORMAT, wgpu::TextureUsages::COPY_SRC);
|
||||
@@ -645,6 +687,7 @@ impl GpuCompositor {
|
||||
let src = self.src_info(&l.frame, *k).unwrap_or(SrcInfo {
|
||||
kind: 1,
|
||||
code_scale: 1.0,
|
||||
code_levels: 1.0,
|
||||
alpha: 0.0,
|
||||
chroma: (1, 1),
|
||||
size: (l.frame.width, l.frame.height),
|
||||
@@ -655,7 +698,10 @@ impl GpuCompositor {
|
||||
Some(lfx) => {
|
||||
// the source drawn onto the working image (box-decimated by n, as the CPU decodes)
|
||||
let n = lfx.decimation.max(1) as f64;
|
||||
let su = Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (lfx.size.0.max(1), lfx.size.1.max(1)));
|
||||
let mut su =
|
||||
Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (lfx.size.0.max(1), lfx.size.1.max(1)));
|
||||
// Effect sources use the working pixel center, avoiding interpolated vertex coordinates.
|
||||
su[26] = n as f32;
|
||||
let sbuf = self.uniform_buffer(&su);
|
||||
let sbg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("fx-source"),
|
||||
@@ -678,7 +724,7 @@ impl GpuCompositor {
|
||||
// what the layer draws: the effect result (linear premultiplied RGBA) or the frame
|
||||
let (u, tex) = match &job {
|
||||
Some(j) => {
|
||||
let s = SrcInfo { kind: 1, code_scale: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color };
|
||||
let s = SrcInfo { kind: 1, code_scale: 1.0, code_levels: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color };
|
||||
(Self::uniforms(&s, &l.matrix, l.opacity, l.blend, (w, h)), [j.result.clone(), self.dummy.clone(), self.dummy.clone(), self.dummy.clone()])
|
||||
}
|
||||
None => {
|
||||
@@ -727,7 +773,7 @@ impl GpuCompositor {
|
||||
// ends it, copies the accumulator under its quad into the backdrop and draws on its own.
|
||||
let accum_tex = self.accum.as_ref().map(|a| a.0.clone());
|
||||
let backdrop_tex = self.backdrop.as_ref().map(|b| b.0.clone());
|
||||
let mut cleared = false;
|
||||
let mut cleared = !clear;
|
||||
let mut i = 0;
|
||||
while i < bind_groups.len() || !cleared {
|
||||
// a layer's effects run right before it is drawn (pooled working textures are reused
|
||||
@@ -768,7 +814,7 @@ impl GpuCompositor {
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
{
|
||||
if resolve {
|
||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("resolve"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -787,7 +833,40 @@ impl GpuCompositor {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
self.queue.submit([enc.finish()]);
|
||||
(out_view, (w, h))
|
||||
(if resolve { out_view } else { accum_view }, (w, h))
|
||||
}
|
||||
|
||||
fn composite_steps(&mut self, width: usize, height: usize, steps: &[PlanStep], prep: Option<&PreparedPlan>) -> (wgpu::TextureView, (u32, u32)) {
|
||||
if steps.iter().any(|s| matches!(s, PlanStep::Transition { effect, .. } if effect.effect != "push")) {
|
||||
self.cpu_transitions += 1;
|
||||
let plan = FramePlan::Composite { width, height, steps: steps.to_vec() };
|
||||
return self.composite_prepared(&FramePlan::Image(filmcraft_render::plan::execute_cpu(&plan)), None);
|
||||
}
|
||||
let empty = FramePlan::Layers { width, height, layers: Vec::new() };
|
||||
let (canvas, dims) = self.render_plan(&empty, None, true, false);
|
||||
let mut children = prep.map(|p| p.children.iter());
|
||||
for step in steps {
|
||||
match step {
|
||||
PlanStep::Layers(layers) => {
|
||||
let p = children.as_mut().and_then(Iterator::next);
|
||||
self.render_plan(&FramePlan::Layers { width, height, layers: layers.clone() }, p, false, false);
|
||||
}
|
||||
PlanStep::Transition { inputs, effect, progress, .. } => {
|
||||
let mut views = [self.dummy.clone(), self.dummy.clone()];
|
||||
for (index, layers) in inputs.iter().enumerate() {
|
||||
let p = children.as_mut().and_then(Iterator::next);
|
||||
// Isolate each clip; queued draws keep the parent accumulator and blend order.
|
||||
std::mem::swap(&mut self.accum, &mut self.transition_inputs[index]);
|
||||
let (view, _) = self.render_plan(&FramePlan::Layers { width, height, layers: layers.clone() }, p, true, false);
|
||||
std::mem::swap(&mut self.accum, &mut self.transition_inputs[index]);
|
||||
views[index] = view;
|
||||
}
|
||||
self.transition.draw(&self.device, &self.queue, &canvas, [&views[0], &views[1]], dims, effect, *progress);
|
||||
self.gpu_transitions += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.render_plan(&empty, None, false, true)
|
||||
}
|
||||
|
||||
/// Run the effect stage of a layer alone: `frame` decoded into its working image and `fx`
|
||||
@@ -799,7 +878,9 @@ impl GpuCompositor {
|
||||
let views = self.uploads.get(&key)?.views.clone();
|
||||
let n = fx.decimation.max(1) as f64;
|
||||
let (w, h) = (fx.size.0.max(1), fx.size.1.max(1));
|
||||
let su = Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (w, h));
|
||||
let mut su = Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (w, h));
|
||||
// Same integer source-grid mapping as the composited effect path.
|
||||
su[26] = n as f32;
|
||||
let sbuf = self.uniform_buffer(&su);
|
||||
let sbg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("fx-source"),
|
||||
@@ -884,11 +965,13 @@ impl GpuCompositor {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a layer's shader samples: the texture kind (0 RGBA8 sRGB, 1 linear premultiplied RGBA
|
||||
/// float, 2 YUV planes), code scale, chroma plane size, picture size and colour description.
|
||||
/// Shader source: kind (0 RGBA8 sRGB, 1 linear premultiplied RGBA, 2 YUV), sampling scales, dimensions, color.
|
||||
struct SrcInfo {
|
||||
kind: u32,
|
||||
/// Texture sample -> code units: 255 for R8Unorm, 2^bits for R16Float.
|
||||
code_scale: f32,
|
||||
/// YUV quantization levels: 2^bits; R8Unorm's decoding scale is one less.
|
||||
code_levels: f32,
|
||||
/// Alpha texture sample → alpha (0: the frame has no alpha plane).
|
||||
alpha: f32,
|
||||
chroma: (u32, u32),
|
||||
|
||||
@@ -9,6 +9,83 @@ fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuv_code_range_uniforms() {
|
||||
let mut mismatches = Vec::new();
|
||||
for bits in [8, 10, 12, 16] {
|
||||
let levels = (1u32 << bits) as f32;
|
||||
for range in [Range::Full, Range::Limited] {
|
||||
let src = SrcInfo {
|
||||
kind: 2,
|
||||
code_scale: if bits == 8 { 255.0 } else { levels },
|
||||
code_levels: levels,
|
||||
alpha: 0.0,
|
||||
chroma: (1, 1),
|
||||
size: (1, 1),
|
||||
color: filmcraft_color::ColorInfo { range, ..filmcraft_color::ColorInfo::REC709 },
|
||||
};
|
||||
let u = GpuCompositor::uniforms(&src, &Affine::IDENTITY, 1.0, Blend::Normal, (1, 1));
|
||||
// Full: 0..2^bits-1, neutral 2^(bits-1). Limited: BT.709 8-bit codes * 2^(bits-8).
|
||||
let expected = match range {
|
||||
Range::Full => [0.0, levels - 1.0, levels / 2.0, levels - 1.0],
|
||||
Range::Limited => [16.0, 219.0, 128.0, 224.0].map(|v| v * (1u32 << (bits - 8)) as f32),
|
||||
};
|
||||
if u[16..20] != expected {
|
||||
mismatches.push(format!("{bits}-bit {range:?}: {:?} != {expected:?}", &u[16..20]));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuv_code_range_gpu_matches_cpu() {
|
||||
let Some((dev, q)) = device() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let mut c = GpuCompositor::new(&dev, &q);
|
||||
let mut prepared_compositor = GpuCompositor::new(&dev, &q);
|
||||
for bits in [8, 10, 12, 16] {
|
||||
let scale = 1u32 << (bits - 8);
|
||||
for range in [Range::Full, Range::Limited] {
|
||||
// Constant chroma isolates range normalization from CPU/GPU chroma reconstruction.
|
||||
for (u, v) in [(128, 128), (64, 192), (192, 64)] {
|
||||
let y = (0..=255).map(|code| (code * scale) as u16).collect::<Vec<_>>();
|
||||
let planes = [Arc::new(y), Arc::new(vec![(u * scale) as u16; 256]), Arc::new(vec![(v * scale) as u16; 256])];
|
||||
let data = if bits == 8 {
|
||||
PixelData::Yuv8 { planes: planes.map(|p| Arc::new(p.iter().map(|v| *v as u8).collect())), chroma: Chroma::C444, alpha: None }
|
||||
} else {
|
||||
PixelData::Yuv16 { planes, chroma: Chroma::C444, bits, alpha: None }
|
||||
};
|
||||
let frame = Arc::new(VideoFrame {
|
||||
width: 256,
|
||||
height: 1,
|
||||
data,
|
||||
color: filmcraft_color::ColorInfo { range, ..filmcraft_color::ColorInfo::REC709 },
|
||||
par: (1, 1),
|
||||
pts: Default::default(),
|
||||
});
|
||||
let cpu = frame.to_rgba8();
|
||||
let plan = FramePlan::Layers { width: 256, height: 1, layers: vec![PlanLayer::new(frame, Affine::IDENTITY, 1.0, Blend::Normal)] };
|
||||
c.composite(&plan);
|
||||
let (w, h, gpu) = c.read_output().expect("readback");
|
||||
assert_eq!((w, h, gpu.len()), (256, 1, cpu.len()));
|
||||
let error = cpu.iter().zip(&gpu).map(|(a, b)| a.abs_diff(*b)).max().expect("pixels");
|
||||
// One output-code tolerance: CPU f32 vs GPU f16 accumulator quantization.
|
||||
assert!(error <= 1, "{bits}-bit {range:?} U={u} V={v}: max error {error}");
|
||||
if u == 128 && v == 128 {
|
||||
assert!(gpu.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "neutral tint: {bits}-bit {range:?}");
|
||||
}
|
||||
let prepared = prepare(&plan);
|
||||
prepared_compositor.composite_prepared(&plan, Some(&prepared));
|
||||
assert_eq!(prepared_compositor.read_output().expect("prepared readback").2, gpu, "prepared: {bits}-bit {range:?}");
|
||||
eprintln!("range gate: {bits}-bit {range:?} U={u} V={v}, max error={error}, prepared=inline");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn yuv_frame(w: u32, h: u32) -> Arc<VideoFrame> {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let y: Vec<u8> = (0..w * h).map(|i| (16 + ((i % w) * 219 / w)) as u8).collect();
|
||||
@@ -85,6 +162,101 @@ fn half_float_conversion() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_push_matches_cpu_with_alpha_and_surrounding_blends() {
|
||||
use filmcraft_project::ParamValue;
|
||||
let Some((dev, q)) = device() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let mut c = GpuCompositor::new(&dev, &q);
|
||||
let (w, h) = (128usize, 72usize);
|
||||
let layer = |side: usize| {
|
||||
let px = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let u = (i % w % 2) as f32;
|
||||
let v = (i / w % 2) as f32;
|
||||
let a = if side == 0 { (u + v) / 2.0 } else { 1.0 - (u + v) / 2.0 };
|
||||
[u * a, v * a, 0.5 * a, a]
|
||||
})
|
||||
.collect();
|
||||
PlanLayer::new(Arc::new(VideoFrame::rgba_f32(w as u32, h as u32, px)), Affine::IDENTITY, 0.7, Blend::Normal)
|
||||
};
|
||||
let mut worst = (0, 0.0f64);
|
||||
for direction in 0..4 {
|
||||
for blur in [0.0, 50.0, 100.0] {
|
||||
for progress in [0.0, 0.25, 0.37, 0.5, 0.75, 1.0] {
|
||||
let mut effect = filmcraft_project::find_effect("push").unwrap().instance();
|
||||
effect.param_mut("direction").unwrap().value = ParamValue::Choice(direction);
|
||||
effect.param_mut("motion_blur").unwrap().value = ParamValue::Float(blur);
|
||||
let mut above = layer(1);
|
||||
above.blend = Blend::Multiply;
|
||||
let plan = FramePlan::Composite {
|
||||
width: w,
|
||||
height: h,
|
||||
steps: vec![
|
||||
PlanStep::Layers(vec![layer(1)]),
|
||||
PlanStep::Transition { inputs: [vec![layer(0)], vec![layer(1)]], effect, progress, scale: 1.0 },
|
||||
PlanStep::Layers(vec![above]),
|
||||
],
|
||||
};
|
||||
let cpu = execute_cpu(&plan).over_black_rgba8();
|
||||
c.composite_prepared(&plan, Some(&prepare(&plan)));
|
||||
let (_, _, gpu) = c.read_output().expect("readback");
|
||||
let mut diffs: Vec<u32> =
|
||||
cpu.chunks_exact(4).zip(gpu.chunks_exact(4)).map(|(a, b)| (0..3).map(|k| a[k].abs_diff(b[k]) as u32).max().unwrap()).collect();
|
||||
diffs.sort_unstable();
|
||||
let p99 = diffs[diffs.len() * 99 / 100];
|
||||
let mean = diffs.iter().sum::<u32>() as f64 / diffs.len() as f64;
|
||||
worst = (worst.0.max(p99), worst.1.max(mean));
|
||||
// Existing compositor acceptance band; moving edges included.
|
||||
assert!(p99 <= 6 && mean < 1.5, "direction {direction}, blur {blur}, progress {progress}: p99 {p99}, mean {mean}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(c.gpu_transitions, 4 * 3 * 6);
|
||||
assert_eq!(c.cpu_transitions, 0);
|
||||
eprintln!("Push 72 cases: worst p99 {}, mean {}", worst.0, worst.1);
|
||||
let fallback = FramePlan::Composite {
|
||||
width: w,
|
||||
height: h,
|
||||
steps: vec![PlanStep::Transition {
|
||||
inputs: [vec![layer(0)], vec![layer(1)]],
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
progress: 0.37,
|
||||
scale: 1.0,
|
||||
}],
|
||||
};
|
||||
c.composite(&fallback);
|
||||
let (_, _, got) = c.read_output().expect("fallback readback");
|
||||
let expected = execute_cpu(&fallback).over_black_rgba8();
|
||||
assert!(got.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1));
|
||||
assert_eq!(c.cpu_transitions, 1);
|
||||
assert_eq!(c.gpu_transitions, 4 * 3 * 6);
|
||||
|
||||
// Reuse both input targets across ordered transitions; preparation advances past intervening layers.
|
||||
let transition = |progress| PlanStep::Transition {
|
||||
inputs: [vec![layer(0)], vec![layer(1)]],
|
||||
effect: filmcraft_project::find_effect("push").unwrap().instance(),
|
||||
progress,
|
||||
scale: 1.0,
|
||||
};
|
||||
let mut middle = layer(0);
|
||||
middle.blend = Blend::Screen;
|
||||
let stacked = FramePlan::Composite {
|
||||
width: w,
|
||||
height: h,
|
||||
steps: vec![PlanStep::Layers(vec![layer(1)]), transition(0.25), PlanStep::Layers(vec![middle]), transition(0.75)],
|
||||
};
|
||||
c.composite_prepared(&stacked, Some(&prepare(&stacked)));
|
||||
let (_, _, gpu) = c.read_output().expect("stacked readback");
|
||||
let cpu = execute_cpu(&stacked).over_black_rgba8();
|
||||
// One byte level tolerates half-float resolve rounding.
|
||||
assert!(gpu.iter().zip(cpu).all(|(a, b)| a.abs_diff(b) <= 1), "stacked transitions exceed one byte level");
|
||||
assert_eq!(c.gpu_transitions, 4 * 3 * 6 + 2);
|
||||
assert_eq!(c.cpu_transitions, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_upload_matches_inline_conversion() {
|
||||
let Some((dev, q)) = device() else {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
use filmcraft_project::{EffectInstance, ParamValue};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
pub(crate) struct PushStage {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
impl PushStage {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("push"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("transition.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("push"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[] },
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: super::ACCUM_FORMAT,
|
||||
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self { pipeline }
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
canvas: &wgpu::TextureView,
|
||||
inputs: [&wgpu::TextureView; 2],
|
||||
size: (u32, u32),
|
||||
effect: &EffectInstance,
|
||||
progress: f32,
|
||||
) {
|
||||
let value = effect.param("direction").map(|p| p.value.clone()).or_else(|| effect.def().and_then(|d| d.param("direction")).map(|p| p.default.clone()));
|
||||
let direction = match value {
|
||||
Some(ParamValue::Choice(v)) => v,
|
||||
_ => 0,
|
||||
};
|
||||
let blur = effect.f64_at("motion_blur", Default::default()) as f32;
|
||||
let blur = if blur.is_finite() { (blur / 100.0).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let progress = if progress.is_finite() { progress.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let values = [size.0 as f32, size.1 as f32, progress, blur, direction as f32, 0.0, 0.0, 0.0];
|
||||
let bytes: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let uniform =
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("push-params"), contents: &bytes, usage: wgpu::BufferUsages::UNIFORM });
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("push"),
|
||||
layout: &self.pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(inputs[0]) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(inputs[1]) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: uniform.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("push") });
|
||||
{
|
||||
let mut pass = super::accum_pass(&mut encoder, canvas, wgpu::LoadOp::Load);
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
struct Params { image: vec4<f32>, direction: vec4<f32> }
|
||||
@group(0) @binding(0) var a: texture_2d<f32>;
|
||||
@group(0) @binding(1) var b: texture_2d<f32>;
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = array<vec2<f32>, 3>(vec2(-1.0,-1.0), vec2(3.0,-1.0), vec2(-1.0,3.0));
|
||||
return vec4(p[i],0.0,1.0);
|
||||
}
|
||||
fn pixel(side: u32, at: vec2<u32>) -> vec4<f32> {
|
||||
if side == 0u { return textureLoad(a,vec2<i32>(at),0); }
|
||||
return textureLoad(b,vec2<i32>(at),0);
|
||||
}
|
||||
fn sample_clamped(side: u32, at: vec2<f32>) -> vec4<f32> {
|
||||
let f = clamp(at-vec2(0.5), vec2(0.0), params.image.xy-vec2(1.0));
|
||||
let lo = vec2<u32>(floor(f));
|
||||
let hi = min(lo+vec2(1u), vec2<u32>(params.image.xy)-vec2(1u));
|
||||
let k = f-vec2<f32>(lo);
|
||||
let va = pixel(side,lo); let vb = pixel(side,vec2(hi.x,lo.y));
|
||||
let vc = pixel(side,vec2(lo.x,hi.y)); let vd = pixel(side,hi);
|
||||
let top = va+(vb-va)*k.x; let bot = vc+(vd-vc)*k.x;
|
||||
return top+(bot-top)*k.y;
|
||||
}
|
||||
fn card(side: u32, offset: vec2<f32>, xy: vec2<f32>) -> vec4<f32> {
|
||||
// CPU Card::hit order preserves rounding at moving edges.
|
||||
let eye = vec3(params.image.xy/2.0, -max(params.image.x,params.image.y)*6.0);
|
||||
let ex = vec3(1.0,0.0,0.0); let ey = vec3(0.0,1.0,0.0);
|
||||
let d = vec3(xy,0.0)-eye; let rhs = eye-vec3(offset,0.0); let nd = -d;
|
||||
let det = dot(ex,cross(ey,nd));
|
||||
let local = vec2(dot(rhs,cross(ey,nd)),dot(ex,cross(rhs,nd)))/det;
|
||||
let edge = min(min(local.x,params.image.x-local.x),min(local.y,params.image.y-local.y));
|
||||
let coverage = clamp(edge+0.5,0.0,1.0);
|
||||
if coverage <= 0.0 { return vec4(0.0); }
|
||||
return sample_clamped(side,local)*coverage;
|
||||
}
|
||||
fn eased(q: f32) -> f32 {
|
||||
if q < 0.5 { return 4.0*q*q*q; }
|
||||
let v = -2.0*q+2.0;
|
||||
return 1.0-v*v*v/2.0;
|
||||
}
|
||||
@fragment
|
||||
fn fs(@builtin(position) at: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let p = params.image.z;
|
||||
if p <= 0.0 { return pixel(0u,vec2<u32>(at.xy)); }
|
||||
if p >= 1.0 { return pixel(1u,vec2<u32>(at.xy)); }
|
||||
var direction = vec2(0.0,1.0);
|
||||
switch u32(params.direction.x) { case 1u: { direction=vec2(-1.0,0.0); } case 2u: { direction=vec2(0.0,-1.0); } case 3u: { direction=vec2(1.0,0.0); } default: {} }
|
||||
let travel = max(dot(abs(direction),params.image.xy),1.0);
|
||||
// CPU shutter: 12 taps at full blur, 0.12 timeline fraction exposure.
|
||||
let blur = params.image.w; var count=1u;
|
||||
if blur > 0.0 { count=clamp(u32(ceil(blur*12.0)),2u,12u); }
|
||||
var sum=vec4(0.0);
|
||||
for(var s=0u;s<count;s++) {
|
||||
var q=p;
|
||||
if count>1u { q=clamp(p+(f32(s)/f32(count-1u)-0.5)*(0.12*blur),0.0,1.0); }
|
||||
let e=eased(q);
|
||||
let va=card(0u,direction*travel*e,at.xy);
|
||||
let vb=card(1u,direction*travel*(e-1.0),at.xy);
|
||||
sum+=vb+va*(1.0-vb.a);
|
||||
}
|
||||
return sum*(1.0/f32(count));
|
||||
}
|
||||
+10
-29
@@ -6,11 +6,11 @@
|
||||
//! jobs of later pictures block per row on the reference data they need, so several pictures decode
|
||||
//! concurrently (frame-level parallelism).
|
||||
|
||||
use crate::dpb::{Dpb, Output, OutputMeta};
|
||||
use crate::dpb::{Dpb, Output, OutputMeta, RefMark};
|
||||
use crate::error::{Error, Result, ensure, invalid, unsupported};
|
||||
use crate::params::{Pps, Sps};
|
||||
use crate::picture::{Frame, FrameRef, MbKind, MbState, Planes, RefPic};
|
||||
use crate::slice::{NalHeader, Poc, PocState, SliceHeader, SliceType, nal_type};
|
||||
use crate::slice::{NalHeader, Poc, PocState, SliceHeader, SliceType, is_new_picture, nal_type};
|
||||
use crate::slicedec::{PicState, SliceDecoder};
|
||||
use crate::transform::LevelScale;
|
||||
use crate::{ColorInfo, Picture};
|
||||
@@ -81,7 +81,7 @@ pub struct Decoder {
|
||||
spss: Vec<Option<Arc<Sps>>>,
|
||||
ppss: Vec<Option<Arc<Pps>>>,
|
||||
nal_length_size: Option<usize>,
|
||||
dpb: Dpb,
|
||||
dpb: Dpb<FrameRef>,
|
||||
poc_state: PocState,
|
||||
prev_ref_frame_num: u32,
|
||||
pending: Option<PendingPic>,
|
||||
@@ -89,7 +89,7 @@ pub struct Decoder {
|
||||
active_sps: Option<Arc<Sps>>,
|
||||
ls_cache: Vec<(Arc<Pps>, Arc<LevelScale>)>,
|
||||
/// Pictures leaving the DPB, converted once their decoding job has finished.
|
||||
out_queue: VecDeque<Output>,
|
||||
out_queue: VecDeque<Output<FrameRef>>,
|
||||
shared: Arc<Shared>,
|
||||
#[cfg(feature = "threads")]
|
||||
pool: Option<rayon::ThreadPool>,
|
||||
@@ -106,27 +106,6 @@ impl Default for Decoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `sh` the first slice of a new primary coded picture relative to `prev` (7.4.1.2.4)?
|
||||
fn is_new_picture(prev: &SliceHeader, sh: &SliceHeader, sps: &Sps) -> bool {
|
||||
if sh.frame_num != prev.frame_num
|
||||
|| sh.pps_id != prev.pps_id
|
||||
|| sh.field_pic != prev.field_pic
|
||||
|| sh.bottom_field != prev.bottom_field
|
||||
|| (sh.nal_ref_idc == 0) != (prev.nal_ref_idc == 0)
|
||||
|| sh.idr != prev.idr
|
||||
|| (sh.idr && prev.idr && sh.idr_pic_id != prev.idr_pic_id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if sps.pic_order_cnt_type == 0 && (sh.pic_order_cnt_lsb != prev.pic_order_cnt_lsb || sh.delta_pic_order_cnt_bottom != prev.delta_pic_order_cnt_bottom) {
|
||||
return true;
|
||||
}
|
||||
if sps.pic_order_cnt_type == 1 && sh.delta_pic_order_cnt != prev.delta_pic_order_cnt {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The worker threads [`Decoder::new`] uses: the available cores (at most 16) with the `threads`
|
||||
/// feature on native targets, 1 otherwise.
|
||||
pub fn default_threads() -> usize {
|
||||
@@ -384,7 +363,9 @@ impl Decoder {
|
||||
let Some(pending) = self.pending.as_mut() else {
|
||||
return invalid("slice without a started picture");
|
||||
};
|
||||
let refs = self.dpb.build_ref_lists(&sh, pending.poc.frame(), sps.max_frame_num())?;
|
||||
let refs = self
|
||||
.dpb
|
||||
.build_ref_lists(&sh, pending.poc.frame(), sps.max_frame_num(), |e| RefPic { frame: e.frame.clone(), long_term: e.mark == RefMark::Long })?;
|
||||
if !sh.slice_type.is_intra() {
|
||||
ensure!(!refs[0].is_empty(), "no reference pictures available for inter slice");
|
||||
if sh.slice_type.is_b() {
|
||||
@@ -464,7 +445,7 @@ impl Decoder {
|
||||
}
|
||||
_ => Planes::gray(mb_w * 16, mb_h * 16),
|
||||
};
|
||||
(Arc::new(Frame::from_planes(id, 0, &planes)), meta.clone())
|
||||
(Arc::new(Frame::from_planes(id, 0, &planes)), 0, meta.clone())
|
||||
};
|
||||
let mut upd = |fnum: u32| poc_state.update_gap_frame(fnum, sps);
|
||||
self.shared.stats.lock().map(|mut s| s.frame_num_gaps += 1).ok();
|
||||
@@ -516,7 +497,7 @@ impl Decoder {
|
||||
run_job(frame, slices, &shared, draft);
|
||||
}
|
||||
|
||||
fn emit(&mut self, outs: Vec<Output>) {
|
||||
fn emit(&mut self, outs: Vec<Output<FrameRef>>) {
|
||||
self.out_queue.extend(outs);
|
||||
}
|
||||
}
|
||||
@@ -613,7 +594,7 @@ fn output_meta(sps: &Sps, pts: i64, key: bool) -> OutputMeta {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_picture(o: &Output) -> Picture {
|
||||
fn make_picture(o: &Output<FrameRef>) -> Picture {
|
||||
let (cx, cy, cw, ch) = o.meta.crop;
|
||||
let (cx, cy, cw, ch) = (cx as usize, cy as usize, cw as usize, ch as usize);
|
||||
let (y, u, v) = o.frame.copy_cropped((cx, cy, cw, ch));
|
||||
|
||||
+36
-32
@@ -1,8 +1,11 @@
|
||||
//! Decoded picture buffer: reference marking (8.2.5), reference list construction (8.2.4) and output
|
||||
//! ordering / bumping (C.4).
|
||||
//!
|
||||
//! The buffer only does bookkeeping on picture headers; what a picture is (`F`: the software
|
||||
//! decoder's [`crate::picture::FrameRef`], a hardware decoder's surface) is up to the decoder, so
|
||||
//! hardware decoders driven by the same headers (VA-API) share this logic and output order.
|
||||
|
||||
use crate::error::{Result, ensure};
|
||||
use crate::picture::{FrameRef, RefPic};
|
||||
use crate::slice::{Mmco, SliceHeader, SliceType};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -28,8 +31,8 @@ pub struct OutputMeta {
|
||||
pub draft: bool,
|
||||
}
|
||||
|
||||
pub struct DpbEntry {
|
||||
pub frame: FrameRef,
|
||||
pub struct DpbEntry<F> {
|
||||
pub frame: F,
|
||||
pub poc: i32,
|
||||
pub frame_num: u32,
|
||||
pub long_term_frame_idx: u32,
|
||||
@@ -39,8 +42,8 @@ pub struct DpbEntry {
|
||||
pub meta: Arc<OutputMeta>,
|
||||
}
|
||||
|
||||
pub struct Dpb {
|
||||
pub entries: Vec<DpbEntry>,
|
||||
pub struct Dpb<F> {
|
||||
pub entries: Vec<DpbEntry<F>>,
|
||||
/// None = "no long-term frame indices".
|
||||
pub max_long_term_frame_idx: Option<u32>,
|
||||
pub capacity: usize,
|
||||
@@ -48,18 +51,18 @@ pub struct Dpb {
|
||||
}
|
||||
|
||||
/// A picture leaving the DPB for output.
|
||||
pub struct Output {
|
||||
pub frame: FrameRef,
|
||||
pub struct Output<F> {
|
||||
pub frame: F,
|
||||
pub poc: i32,
|
||||
pub meta: Arc<OutputMeta>,
|
||||
}
|
||||
|
||||
impl Dpb {
|
||||
impl<F: Clone> Dpb<F> {
|
||||
pub fn new() -> Self {
|
||||
Dpb { entries: Vec::new(), max_long_term_frame_idx: None, capacity: 16, max_reorder: 16 }
|
||||
}
|
||||
|
||||
fn frame_num_wrap(e: &DpbEntry, cur_frame_num: u32, max_frame_num: u32) -> i32 {
|
||||
fn frame_num_wrap(e: &DpbEntry<F>, cur_frame_num: u32, max_frame_num: u32) -> i32 {
|
||||
if e.frame_num > cur_frame_num { e.frame_num as i32 - max_frame_num as i32 } else { e.frame_num as i32 }
|
||||
}
|
||||
|
||||
@@ -73,7 +76,7 @@ impl Dpb {
|
||||
}
|
||||
|
||||
/// Output the picture with the smallest POC (C.4.5.3 "bumping"). Returns false if none waits.
|
||||
fn bump(&mut self, out: &mut Vec<Output>) -> bool {
|
||||
fn bump(&mut self, out: &mut Vec<Output<F>>) -> bool {
|
||||
let Some(i) = self.entries.iter().enumerate().filter(|(_, e)| e.needed_for_output).min_by_key(|(_, e)| e.poc).map(|(i, _)| i) else {
|
||||
return false;
|
||||
};
|
||||
@@ -85,13 +88,13 @@ impl Dpb {
|
||||
}
|
||||
|
||||
/// Output everything in POC order and clear non-reference entries.
|
||||
pub fn flush(&mut self, out: &mut Vec<Output>) {
|
||||
pub fn flush(&mut self, out: &mut Vec<Output<F>>) {
|
||||
while self.bump(out) {}
|
||||
self.prune();
|
||||
}
|
||||
|
||||
/// IDR: mark all references unused; output (or drop) prior pictures.
|
||||
pub fn idr(&mut self, no_output_of_prior_pics: bool, out: &mut Vec<Output>) {
|
||||
pub fn idr(&mut self, no_output_of_prior_pics: bool, out: &mut Vec<Output<F>>) {
|
||||
for e in &mut self.entries {
|
||||
e.mark = RefMark::Unused;
|
||||
}
|
||||
@@ -122,7 +125,8 @@ impl Dpb {
|
||||
self.prune();
|
||||
}
|
||||
|
||||
/// Insert "non-existing" frames for a frame_num gap (8.2.5.2).
|
||||
/// Insert "non-existing" frames for a frame_num gap (8.2.5.2). `make_frame` gives each one's
|
||||
/// picture and POC.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn fill_frame_num_gap(
|
||||
&mut self,
|
||||
@@ -130,17 +134,17 @@ impl Dpb {
|
||||
frame_num: u32,
|
||||
max_frame_num: u32,
|
||||
max_num_ref_frames: usize,
|
||||
make_frame: &mut dyn FnMut(u32) -> (FrameRef, Arc<OutputMeta>),
|
||||
make_frame: &mut dyn FnMut(u32) -> (F, i32, Arc<OutputMeta>),
|
||||
poc_state: &mut dyn FnMut(u32),
|
||||
) {
|
||||
let mut unused = (prev_ref_frame_num + 1) % max_frame_num;
|
||||
let mut guard = 0;
|
||||
while unused != frame_num && guard < max_frame_num {
|
||||
self.sliding_window(unused, max_frame_num, max_num_ref_frames);
|
||||
let (frame, meta) = make_frame(unused);
|
||||
let (frame, poc, meta) = make_frame(unused);
|
||||
poc_state(unused);
|
||||
self.entries.push(DpbEntry {
|
||||
poc: frame.poc,
|
||||
poc,
|
||||
frame,
|
||||
frame_num: unused,
|
||||
long_term_frame_idx: 0,
|
||||
@@ -154,26 +158,26 @@ impl Dpb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial reference picture lists (8.2.4.2) + modification (8.2.4.3) for a frame slice.
|
||||
pub fn build_ref_lists(&self, sh: &SliceHeader, cur_poc: i32, max_frame_num: u32) -> Result<[Vec<RefPic>; 2]> {
|
||||
let mut lists: [Vec<RefPic>; 2] = [Vec::new(), Vec::new()];
|
||||
/// Initial reference picture lists (8.2.4.2) + modification (8.2.4.3) for a frame slice, each
|
||||
/// entry made by `to_ref` from the DPB entry it refers to.
|
||||
pub fn build_ref_lists<R>(&self, sh: &SliceHeader, cur_poc: i32, max_frame_num: u32, to_ref: impl Fn(&DpbEntry<F>) -> R) -> Result<[Vec<R>; 2]> {
|
||||
let mut lists: [Vec<R>; 2] = [Vec::new(), Vec::new()];
|
||||
if sh.slice_type.is_intra() {
|
||||
return Ok(lists);
|
||||
}
|
||||
let cur_fn = sh.frame_num;
|
||||
let pic_num = |e: &DpbEntry| Self::frame_num_wrap(e, cur_fn, max_frame_num);
|
||||
let short: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Short).collect();
|
||||
let mut long: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Long).collect();
|
||||
let pic_num = |e: &DpbEntry<F>| Self::frame_num_wrap(e, cur_fn, max_frame_num);
|
||||
let short: Vec<&DpbEntry<F>> = self.entries.iter().filter(|e| e.mark == RefMark::Short).collect();
|
||||
let mut long: Vec<&DpbEntry<F>> = self.entries.iter().filter(|e| e.mark == RefMark::Long).collect();
|
||||
long.sort_by_key(|e| e.long_term_frame_idx);
|
||||
let to_ref = |e: &&DpbEntry| RefPic { frame: e.frame.clone(), long_term: e.mark == RefMark::Long };
|
||||
let mut entry_lists: [Vec<&DpbEntry>; 2] = [Vec::new(), Vec::new()];
|
||||
let mut entry_lists: [Vec<&DpbEntry<F>>; 2] = [Vec::new(), Vec::new()];
|
||||
if sh.slice_type != SliceType::B {
|
||||
let mut s = short.clone();
|
||||
s.sort_by_key(|e| std::cmp::Reverse(pic_num(e)));
|
||||
entry_lists[0] = s.into_iter().chain(long.iter().copied()).collect();
|
||||
} else {
|
||||
let mut before: Vec<&DpbEntry> = short.iter().copied().filter(|e| e.poc < cur_poc).collect();
|
||||
let mut after: Vec<&DpbEntry> = short.iter().copied().filter(|e| e.poc > cur_poc).collect();
|
||||
let mut before: Vec<&DpbEntry<F>> = short.iter().copied().filter(|e| e.poc < cur_poc).collect();
|
||||
let mut after: Vec<&DpbEntry<F>> = short.iter().copied().filter(|e| e.poc > cur_poc).collect();
|
||||
before.sort_by_key(|e| std::cmp::Reverse(e.poc));
|
||||
after.sort_by_key(|e| e.poc);
|
||||
entry_lists[0] = before.iter().chain(after.iter()).chain(long.iter()).copied().collect();
|
||||
@@ -188,7 +192,7 @@ impl Dpb {
|
||||
let nlists = if sh.slice_type == SliceType::B { 2 } else { 1 };
|
||||
for l in 0..nlists {
|
||||
let n = sh.num_ref_idx_active[l] as usize;
|
||||
let mut list: Vec<Option<&DpbEntry>> = entry_lists[l].iter().copied().map(Some).collect();
|
||||
let mut list: Vec<Option<&DpbEntry<F>>> = entry_lists[l].iter().copied().map(Some).collect();
|
||||
list.truncate(n);
|
||||
list.resize(n, None);
|
||||
if !sh.ref_pic_list_mod[l].is_empty() {
|
||||
@@ -199,7 +203,7 @@ impl Dpb {
|
||||
for m in &sh.ref_pic_list_mod[l] {
|
||||
ensure!(ref_idx < n, "too many ref_pic_list_modification entries");
|
||||
list.push(None); // temporarily one longer
|
||||
let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc {
|
||||
let (target, is_long): (Option<&DpbEntry<F>>, bool) = match m.idc {
|
||||
0 | 1 => {
|
||||
let d = m.value as i32 + 1;
|
||||
let no_wrap = if m.idc == 0 {
|
||||
@@ -249,7 +253,7 @@ impl Dpb {
|
||||
}
|
||||
// Fill missing entries (should not happen in conforming streams) with the first valid entry.
|
||||
let fallback = list.iter().flatten().next().copied().or_else(|| self.entries.last());
|
||||
let out: Vec<RefPic> = list.iter().filter_map(|e| e.or(fallback)).map(|e| to_ref(&e)).collect();
|
||||
let out: Vec<R> = list.iter().filter_map(|e| e.or(fallback)).map(&to_ref).collect();
|
||||
lists[l] = out;
|
||||
}
|
||||
Ok(lists)
|
||||
@@ -327,12 +331,12 @@ impl Dpb {
|
||||
pub fn store_picture(
|
||||
&mut self,
|
||||
sh: &SliceHeader,
|
||||
frame: FrameRef,
|
||||
frame: F,
|
||||
poc: i32,
|
||||
max_frame_num: u32,
|
||||
max_num_ref_frames: usize,
|
||||
meta: Arc<OutputMeta>,
|
||||
out: &mut Vec<Output>,
|
||||
out: &mut Vec<Output<F>>,
|
||||
) {
|
||||
let mut mark = RefMark::Unused;
|
||||
let mut long_idx = 0;
|
||||
@@ -404,7 +408,7 @@ impl Dpb {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Dpb {
|
||||
impl<F: Clone> Default for Dpb<F> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ mod cavlc;
|
||||
mod cavlc_tables;
|
||||
mod deblock;
|
||||
mod decoder;
|
||||
mod dpb;
|
||||
pub mod dpb;
|
||||
mod error;
|
||||
mod inter;
|
||||
mod intra;
|
||||
|
||||
@@ -387,6 +387,27 @@ pub struct PocState {
|
||||
prev_frame_num: u32,
|
||||
}
|
||||
|
||||
/// Is `sh` the first slice of a new primary coded picture relative to `prev` (7.4.1.2.4)?
|
||||
pub fn is_new_picture(prev: &SliceHeader, sh: &SliceHeader, sps: &Sps) -> bool {
|
||||
if sh.frame_num != prev.frame_num
|
||||
|| sh.pps_id != prev.pps_id
|
||||
|| sh.field_pic != prev.field_pic
|
||||
|| sh.bottom_field != prev.bottom_field
|
||||
|| (sh.nal_ref_idc == 0) != (prev.nal_ref_idc == 0)
|
||||
|| sh.idr != prev.idr
|
||||
|| (sh.idr && prev.idr && sh.idr_pic_id != prev.idr_pic_id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if sps.pic_order_cnt_type == 0 && (sh.pic_order_cnt_lsb != prev.pic_order_cnt_lsb || sh.delta_pic_order_cnt_bottom != prev.delta_pic_order_cnt_bottom) {
|
||||
return true;
|
||||
}
|
||||
if sps.pic_order_cnt_type == 1 && sh.delta_pic_order_cnt != prev.delta_pic_order_cnt {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Output of POC computation.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Poc {
|
||||
|
||||
@@ -25,7 +25,7 @@ struct PendingPic {
|
||||
/// Header of the latest independent slice segment (for dependent slice segments).
|
||||
last_sh: SliceHeader,
|
||||
first_sh: SliceHeader,
|
||||
rps: RefPicSet,
|
||||
rps: RefPicSet<FrameRef>,
|
||||
poc: i32,
|
||||
output: bool,
|
||||
meta: Arc<OutputMeta>,
|
||||
@@ -72,7 +72,7 @@ pub struct Decoder {
|
||||
spss: Vec<Option<Arc<Sps>>>,
|
||||
ppss: Vec<Option<Arc<Pps>>>,
|
||||
nal_length_size: Option<usize>,
|
||||
dpb: Dpb,
|
||||
dpb: Dpb<FrameRef>,
|
||||
poc_state: PocState,
|
||||
pending: Option<PendingPic>,
|
||||
next_id: u32,
|
||||
@@ -82,7 +82,7 @@ pub struct Decoder {
|
||||
/// RASL pictures associated with the last IRAP are skipped (it had NoRaslOutputFlag = 1).
|
||||
skip_rasl: bool,
|
||||
layout_cache: Vec<(Arc<Pps>, Arc<Sps>, Arc<Layout>, Option<Arc<Vec<Vec<u8>>>>)>,
|
||||
out_queue: VecDeque<Output>,
|
||||
out_queue: VecDeque<Output<FrameRef>>,
|
||||
shared: Arc<Shared>,
|
||||
#[cfg(feature = "threads")]
|
||||
pool: Option<rayon::ThreadPool>,
|
||||
@@ -578,7 +578,7 @@ fn output_meta(sps: &Sps, pts: i64, key: bool, draft: bool) -> OutputMeta {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_picture(o: &Output) -> Picture {
|
||||
fn make_picture(o: &Output<FrameRef>) -> Picture {
|
||||
let (cx, cy, cw, ch) = o.meta.crop;
|
||||
let (cx, cy, cw, ch) = (cx as usize, cy as usize, cw as usize, ch as usize);
|
||||
let (y, u, v) = o.frame.copy_cropped((cx, cy, cw, ch));
|
||||
|
||||
+47
-26
@@ -1,8 +1,11 @@
|
||||
//! Decoded picture buffer: POC derivation (8.3.1), reference picture set (8.3.2), missing
|
||||
//! reference generation (8.3.3), reference picture lists (8.3.4) and output / bumping (C.5.2).
|
||||
//!
|
||||
//! The buffer only does bookkeeping on picture headers; what a picture is (`F`: the software
|
||||
//! decoder's [`crate::picture::FrameRef`], a hardware decoder's surface) is up to the decoder, so
|
||||
//! hardware decoders driven by the same headers (VA-API) share this logic and output order.
|
||||
|
||||
use crate::error::{Result, ensure};
|
||||
use crate::picture::{FrameRef, RefPic};
|
||||
use crate::slice::SliceHeader;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -29,8 +32,8 @@ pub enum Marking {
|
||||
Long,
|
||||
}
|
||||
|
||||
pub struct Entry {
|
||||
pub frame: FrameRef,
|
||||
pub struct Entry<F> {
|
||||
pub frame: F,
|
||||
pub poc: i32,
|
||||
pub marking: Marking,
|
||||
pub needed_for_output: bool,
|
||||
@@ -39,31 +42,49 @@ pub struct Entry {
|
||||
}
|
||||
|
||||
/// A picture leaving the DPB for output.
|
||||
pub struct Output {
|
||||
pub frame: FrameRef,
|
||||
pub struct Output<F> {
|
||||
pub frame: F,
|
||||
pub poc: i32,
|
||||
pub meta: Arc<OutputMeta>,
|
||||
}
|
||||
|
||||
/// Reference picture set of the current picture (8.3.2) as DPB indices / generated frames.
|
||||
#[derive(Default)]
|
||||
pub struct RefPicSet {
|
||||
pub st_curr_before: Vec<RefPic>,
|
||||
pub st_curr_after: Vec<RefPic>,
|
||||
pub lt_curr: Vec<RefPic>,
|
||||
/// A reference picture of the current picture.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Ref<F> {
|
||||
pub frame: F,
|
||||
pub poc: i32,
|
||||
pub long_term: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Dpb {
|
||||
pub entries: Vec<Entry>,
|
||||
/// Reference picture set of the current picture (8.3.2) as DPB indices / generated frames.
|
||||
pub struct RefPicSet<F> {
|
||||
pub st_curr_before: Vec<Ref<F>>,
|
||||
pub st_curr_after: Vec<Ref<F>>,
|
||||
pub lt_curr: Vec<Ref<F>>,
|
||||
}
|
||||
|
||||
impl<F> Default for RefPicSet<F> {
|
||||
fn default() -> Self {
|
||||
Self { st_curr_before: Vec::new(), st_curr_after: Vec::new(), lt_curr: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Dpb<F> {
|
||||
pub entries: Vec<Entry<F>>,
|
||||
pub max_dec_pic_buffering: usize,
|
||||
pub max_num_reorder: usize,
|
||||
/// SpsMaxLatencyPictures (0 = no limit).
|
||||
pub max_latency: u32,
|
||||
}
|
||||
|
||||
impl Dpb {
|
||||
fn bump(&mut self, outs: &mut Vec<Output>) -> bool {
|
||||
impl<F> Default for Dpb<F> {
|
||||
fn default() -> Self {
|
||||
Self { entries: Vec::new(), max_dec_pic_buffering: 0, max_num_reorder: 0, max_latency: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: Clone> Dpb<F> {
|
||||
fn bump(&mut self, outs: &mut Vec<Output<F>>) -> bool {
|
||||
let Some(i) = (0..self.entries.len()).filter(|&i| self.entries[i].needed_for_output).min_by_key(|&i| self.entries[i].poc) else { return false };
|
||||
let e = &mut self.entries[i];
|
||||
e.needed_for_output = false;
|
||||
@@ -85,7 +106,7 @@ impl Dpb {
|
||||
}
|
||||
|
||||
/// Output everything (end of stream or IRAP without no_output_of_prior_pics).
|
||||
pub fn flush(&mut self, outs: &mut Vec<Output>) {
|
||||
pub fn flush(&mut self, outs: &mut Vec<Output<F>>) {
|
||||
while self.bump(outs) {}
|
||||
for e in &mut self.entries {
|
||||
e.marking = Marking::Unused;
|
||||
@@ -100,7 +121,7 @@ impl Dpb {
|
||||
|
||||
/// C.5.2.2: removal of pictures before decoding the current (non-IRAP-reset) picture, after the RPS
|
||||
/// has been applied.
|
||||
pub fn bump_before_decode(&mut self, outs: &mut Vec<Output>) {
|
||||
pub fn bump_before_decode(&mut self, outs: &mut Vec<Output<F>>) {
|
||||
self.remove_unused();
|
||||
loop {
|
||||
let full = self.entries.len() >= self.max_dec_pic_buffering.max(1);
|
||||
@@ -116,7 +137,7 @@ impl Dpb {
|
||||
}
|
||||
|
||||
/// C.5.2.3: insert the current picture and apply "additional bumping".
|
||||
pub fn insert(&mut self, frame: FrameRef, poc: i32, output: bool, meta: Arc<OutputMeta>, outs: &mut Vec<Output>) {
|
||||
pub fn insert(&mut self, frame: F, poc: i32, output: bool, meta: Arc<OutputMeta>, outs: &mut Vec<Output<F>>) {
|
||||
for e in &mut self.entries {
|
||||
if e.needed_for_output {
|
||||
e.latency += 1;
|
||||
@@ -138,8 +159,8 @@ impl Dpb {
|
||||
poc: i32,
|
||||
max_poc_lsb: i32,
|
||||
irap_no_rasl: bool,
|
||||
make_missing: &mut dyn FnMut(i32) -> FrameRef,
|
||||
) -> Result<RefPicSet> {
|
||||
make_missing: &mut dyn FnMut(i32) -> F,
|
||||
) -> Result<RefPicSet<F>> {
|
||||
let mut set = RefPicSet::default();
|
||||
if sh.nal.is_irap() && irap_no_rasl {
|
||||
for e in &mut self.entries {
|
||||
@@ -196,13 +217,13 @@ impl Dpb {
|
||||
self.entries[i].marking = Marking::Long;
|
||||
}
|
||||
}
|
||||
let mut make = |found: Option<usize>, p: i32, lt: bool, entries: &mut Vec<Entry>| -> RefPic {
|
||||
let mut make = |found: Option<usize>, p: i32, lt: bool, entries: &mut Vec<Entry<F>>| -> Ref<F> {
|
||||
match found {
|
||||
Some(i) => RefPic { frame: entries[i].frame.clone(), poc: entries[i].poc, long_term: lt },
|
||||
Some(i) => Ref { frame: entries[i].frame.clone(), poc: entries[i].poc, long_term: lt },
|
||||
None => {
|
||||
// 8.3.3: generate an unavailable reference picture
|
||||
let frame = make_missing(p);
|
||||
RefPic { frame, poc: p, long_term: lt }
|
||||
Ref { frame, poc: p, long_term: lt }
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -228,8 +249,8 @@ impl Dpb {
|
||||
}
|
||||
|
||||
/// Build RefPicList0/1 (8.3.4).
|
||||
pub fn build_ref_lists(sh: &SliceHeader, rps: &RefPicSet) -> Result<[Vec<RefPic>; 2]> {
|
||||
let mut lists: [Vec<RefPic>; 2] = [Vec::new(), Vec::new()];
|
||||
pub fn build_ref_lists<F: Clone>(sh: &SliceHeader, rps: &RefPicSet<F>) -> Result<[Vec<Ref<F>>; 2]> {
|
||||
let mut lists: [Vec<Ref<F>>; 2] = [Vec::new(), Vec::new()];
|
||||
if sh.is_intra() {
|
||||
return Ok(lists);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
mod cabac;
|
||||
mod decoder;
|
||||
mod dpb;
|
||||
pub mod dpb;
|
||||
mod error;
|
||||
mod filter;
|
||||
mod inter;
|
||||
|
||||
@@ -207,9 +207,4 @@ fn copy_line(line: &[u16], x0: i32, out: &mut [i16]) {
|
||||
pub type FrameRef = Arc<Frame>;
|
||||
|
||||
/// An entry of RefPicList0/1 of the current slice.
|
||||
#[derive(Clone)]
|
||||
pub struct RefPic {
|
||||
pub frame: FrameRef,
|
||||
pub poc: i32,
|
||||
pub long_term: bool,
|
||||
}
|
||||
pub type RefPic = crate::dpb::Ref<FrameRef>;
|
||||
|
||||
@@ -932,6 +932,10 @@ impl Exp<'_, '_> {
|
||||
let hold = c.frame_hold;
|
||||
let src_in = hold.unwrap_or(c.source_in);
|
||||
let src_out = if hold.is_some() { src_in + c.duration } else { c.source_out() };
|
||||
let (start_f, end_f, in_f) = (frames_round(rate, c.start), frames_round(rate, c.end()), frames_round(rate, src_in));
|
||||
// At normal speed (and on a freeze frame) the source span is the record span. Rounded on its own
|
||||
// it can come out a frame shorter or longer than `end - start` (#340), so derive it from them.
|
||||
let out_f = if hold.is_some() || c.speed.abs() == 1.0 { in_f.saturating_add(end_f.saturating_sub(start_f)) } else { frames_round(rate, src_out) };
|
||||
self.w.open(tag, &[("id", xid)]);
|
||||
if generator.is_none() && !is_seq {
|
||||
let mid = self.master_id(base);
|
||||
@@ -940,12 +944,12 @@ impl Exp<'_, '_> {
|
||||
self.w.text("name", &c.name);
|
||||
self.w.text("enabled", bool_str(c.enabled));
|
||||
let media_dur = self.p.item(base).map(|i| i.duration()).unwrap_or(Tick::ZERO).max(src_out);
|
||||
self.w.text("duration", frames_round(rate, media_dur));
|
||||
self.w.text("duration", frames_round(rate, media_dur).max(out_f));
|
||||
write_rate(&mut self.w, rate);
|
||||
self.w.text("start", frames_round(rate, c.start));
|
||||
self.w.text("end", frames_round(rate, c.end()));
|
||||
self.w.text("in", frames_round(rate, src_in));
|
||||
self.w.text("out", frames_round(rate, src_out));
|
||||
self.w.text("start", start_f);
|
||||
self.w.text("end", end_f);
|
||||
self.w.text("in", in_f);
|
||||
self.w.text("out", out_f);
|
||||
self.w.text("pproTicksIn", src_in.0);
|
||||
self.w.text("pproTicksOut", src_out.0);
|
||||
if let Some(g) = &generator {
|
||||
|
||||
@@ -483,3 +483,47 @@ fn a_master_clip_used_by_an_earlier_sequence_still_lands_in_its_bin() {
|
||||
assert_ne!(bin, p.root.id, "the take was left loose at the top");
|
||||
assert_eq!(p.root.find_bin(bin).unwrap().name, "Comedy");
|
||||
}
|
||||
|
||||
/// `start`/`end`/`in`/`out` of every sequence clip item in an exported document.
|
||||
fn clip_spans(xml: &str) -> Vec<(i64, i64, i64, i64)> {
|
||||
let tag = |block: &str, name: &str| -> i64 {
|
||||
let open = format!("<{name}>");
|
||||
let at = block.find(&open).unwrap_or_else(|| panic!("no <{name}> in {block}")) + open.len();
|
||||
block[at..].split('<').next().unwrap().trim().parse().unwrap()
|
||||
};
|
||||
xml.split("<clipitem").skip(1).filter(|b| b.contains("<start>")).map(|b| (tag(b, "start"), tag(b, "end"), tag(b, "in"), tag(b, "out"))).collect()
|
||||
}
|
||||
|
||||
/// #340: a clip placed by seconds in a 23.976 sequence has sub-frame start, duration and source in.
|
||||
/// Its record span and source span were rounded to frames independently, so `out - in` could come
|
||||
/// out a frame shorter than `end - start` (Premiere then reads a different source range).
|
||||
#[test]
|
||||
fn sub_frame_clips_keep_equal_source_and_record_spans() {
|
||||
const SECOND: i64 = 254_016_000_000;
|
||||
let r = FrameRate::FPS_23_976;
|
||||
let mut p = Project::new("P");
|
||||
let a = media(&mut p, "/m/a.mov", true, true, r);
|
||||
let s = sequence(&mut p, "t", r, false);
|
||||
let c = clip(&mut p, s, TrackKind::Video, 0, a, 0, 24, 0);
|
||||
// the issue's case first (start 0, source in 20 s, 3 s long), then starts, ins and lengths in tenths of a second
|
||||
let mut cases = vec![(0, 200, 30)];
|
||||
for start in [0, 1, 5, 17, 33] {
|
||||
for src in [0, 3, 7, 200, 413] {
|
||||
for dur in [1, 4, 30, 71, 125] {
|
||||
cases.push((start, src, dur));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (start, src, dur) in cases {
|
||||
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(c).unwrap();
|
||||
it.start = Tick(start * SECOND / 10);
|
||||
it.source_in = Tick(src * SECOND / 10);
|
||||
it.duration = Tick(dur * SECOND / 10);
|
||||
let (bytes, _) = export(&p, s, Format::Fcp7Xml, &ExportOptions::default()).expect("export");
|
||||
let xml = String::from_utf8(bytes).unwrap();
|
||||
let spans = clip_spans(&xml);
|
||||
assert_eq!(spans.len(), 1, "{xml}");
|
||||
let (start_f, end_f, in_f, out_f) = spans[0];
|
||||
assert_eq!(out_f - in_f, end_f - start_f, "start {start}, in {src}, duration {dur} (tenths of a second): {spans:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1505,10 +1505,8 @@ impl SampleEntry {
|
||||
CodecConfig::Aac(c) => {
|
||||
b.leaf(b"esds", &esds_payload(0x40, &c.asc, c.max_bitrate, c.avg_bitrate, c.buffer_size));
|
||||
}
|
||||
CodecConfig::Mp3 => {
|
||||
if format == *b"mp4a" {
|
||||
b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0));
|
||||
}
|
||||
CodecConfig::Mp3 if format == *b"mp4a" => {
|
||||
b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0));
|
||||
}
|
||||
CodecConfig::Alac { cookie } => {
|
||||
let m = b.start_full(b"alac", 0, 0);
|
||||
|
||||
@@ -713,10 +713,8 @@ impl Walker<'_> {
|
||||
match h.id {
|
||||
SEEK_HEAD => file.seek_head.extend(parse_seekhead(&data)),
|
||||
INFO => file.info = parse_info(&data),
|
||||
TRACKS => {
|
||||
if file.tracks.is_empty() {
|
||||
file.tracks = parse_tracks(&data, file.info.timestamp_scale);
|
||||
}
|
||||
TRACKS if file.tracks.is_empty() => {
|
||||
file.tracks = parse_tracks(&data, file.info.timestamp_scale);
|
||||
}
|
||||
CUES => parse_cues(&data, &mut file.cues),
|
||||
CHAPTERS => parse_chapters(&data, &mut file.chapters),
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
//! [`with_draft`] marks reduced-resolution playback (opt-in): decoders may take spec-safe
|
||||
//! shortcuts that only change pictures nothing references (H.264: no deblocking of non-reference
|
||||
//! pictures). Never set for exports, renders or a frame shown while paused.
|
||||
//!
|
||||
//! [`with_background`] marks work nobody waits on interactively (thumbnails of the Project panel,
|
||||
//! the Media Browser and the timeline): sources should not keep expensive state, such as a
|
||||
//! frame-threaded 4K decoder, for it once it is done.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
@@ -24,6 +28,28 @@ thread_local! {
|
||||
static CURRENT: RefCell<Option<Arc<AtomicBool>>> = const { RefCell::new(None) };
|
||||
static CATCH_UP: Cell<Option<Tick>> = const { Cell::new(None) };
|
||||
static DRAFT: Cell<bool> = const { Cell::new(false) };
|
||||
static BACKGROUND: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Run `f` as background work when `on` (restoring the previous hint after): a thumbnail, not a
|
||||
/// frame a monitor or playback waits for.
|
||||
pub fn with_background<R>(on: bool, f: impl FnOnce() -> R) -> R {
|
||||
/// Restores the previous hint however `f` ends (a panic caught further up must not leave the
|
||||
/// worker thread marked as background).
|
||||
struct Restore(bool);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
BACKGROUND.with(|c| c.set(self.0));
|
||||
}
|
||||
}
|
||||
let _restore = Restore(BACKGROUND.with(|c| c.replace(on)));
|
||||
f()
|
||||
}
|
||||
|
||||
/// Whether the work running on this thread is background work (off unless inside
|
||||
/// [`with_background`]`(true, …)`).
|
||||
pub fn background() -> bool {
|
||||
BACKGROUND.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Run `f` with the draft-decoding hint `on` (restoring the previous one after). Frames decoded
|
||||
@@ -97,6 +123,24 @@ mod tests {
|
||||
assert!(!draft());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_is_off_by_default_and_scoped() {
|
||||
assert!(!background());
|
||||
with_background(true, || {
|
||||
assert!(background());
|
||||
with_background(false, || assert!(!background()));
|
||||
assert!(background());
|
||||
});
|
||||
assert!(!background());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_inside_background_work_does_not_leave_the_thread_marked() {
|
||||
let r = std::panic::catch_unwind(|| with_background(true, || panic!("boom")));
|
||||
assert!(r.is_err());
|
||||
assert!(!background());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catch_up_is_scoped_to_the_closure() {
|
||||
assert_eq!(catch_up(), None);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "filmcraft-platform"
|
||||
description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS), with transparent fallback to the pure-Rust decoders"
|
||||
description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS, Media Foundation on Windows, VA-API on Linux), with transparent fallback to the pure-Rust decoders"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -21,10 +21,13 @@ type_complexity = "allow"
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
|
||||
[dependencies]
|
||||
filmcraft-bitstream = { workspace = true }
|
||||
filmcraft-codecs = { workspace = true }
|
||||
filmcraft-color = { workspace = true }
|
||||
filmcraft-export = { workspace = true }
|
||||
filmcraft-frame = { workspace = true }
|
||||
filmcraft-h264 = { workspace = true }
|
||||
filmcraft-hevc = { workspace = true }
|
||||
filmcraft-isobmff = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -49,6 +52,11 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_LibraryLoader",
|
||||
] }
|
||||
|
||||
# Linux: libva (VA-API) is loaded at run time with libloading (ISC; already in the lockfile through
|
||||
# wgpu), so building needs no libva headers and a system without libva simply decodes in software.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libloading = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
|
||||
+147
-13
@@ -8,7 +8,7 @@ It is the one crate of the workspace allowed to contain `unsafe`, under the rule
|
||||
|
||||
```rust
|
||||
// at startup (the desktop app, filmcraft-cli, the bench)
|
||||
let availability = filmcraft_platform::register(); // Available("VideoToolbox") on macOS, Available("Media Foundation") on Windows
|
||||
let availability = filmcraft_platform::register(); // Available("VideoToolbox") on macOS, Available("Media Foundation") on Windows, Available("VA-API") on Linux with a driver
|
||||
```
|
||||
|
||||
## What it does
|
||||
@@ -61,7 +61,7 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
|
||||
- After a `flush` (which drains the MFT) the MFT only restarts at an IDR picture; the GOP cache
|
||||
always seeks after a flush, and a caller that continues from the middle of a GOP gets an error
|
||||
that `HybridDecoder` answers by replaying the run in software.
|
||||
- **Windows: NVIDIA NVENC H.264 encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's
|
||||
- **Windows: NVIDIA NVENC H.264 and H.265 (HEVC) encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's
|
||||
`nvEncodeAPI64.dll` (API 12.1, no CUDA or SDK) is loaded at run time, so machines without NVIDIA
|
||||
still start. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12
|
||||
input buffers (a ring of eight); the encoder runs preset P5 with high-quality tuning, CABAC (CAVLC
|
||||
@@ -69,7 +69,40 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
|
||||
distance; the parameter sets go into `avcC`. Export ▸ Hardware encoding (off by default) selects it.
|
||||
It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems
|
||||
without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export
|
||||
ends it with an error, since a hardware stream cannot be finished in software.
|
||||
ends it with an error, since a hardware stream cannot be finished in software. The same session,
|
||||
ring and NV12 path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
|
||||
NVENC)](#hardware-h265-hevc-encoding-windows-nvenc).
|
||||
- **Linux: VA-API, H.264 (`avcC`) and HEVC (`hvcC`)**: H.264 8-bit 4:2:0 progressive, Constrained
|
||||
Baseline / Main / High; HEVC Main, Main 10 and Main Still Picture, 4:2:0 8- and 10-bit (`vaapi/`), on Intel (iHD, i965), AMD and other Mesa drivers. `libva.so.2` and `libva-drm.so.2`
|
||||
are loaded at run time (`vaapi/va.rs`; no libva headers needed to build), and each decoder opens
|
||||
its own display on the first DRM render node (`/dev/dri/renderD128`…) with a working driver.
|
||||
VA-API decoding is *stateless*: the host parses the stream and keeps the decoded picture buffer,
|
||||
and the GPU decodes one picture's slice data at a time into a surface. That host side
|
||||
(`vaapi/h264.rs`, `vaapi/hevc.rs`, safe code) is the software decoders' own: their parameter-set
|
||||
and slice-header parsers, POC computation and DPBs (`filmcraft_h264::dpb`, `filmcraft_hevc::dpb`,
|
||||
generic over what a picture is: H.264 reference marking including MMCOs and reference lists with
|
||||
modifications; HEVC reference picture sets and lists, RASL pictures of a CRA the run starts at
|
||||
left out; output order). The two decoders therefore make
|
||||
the same decisions and output the same pictures in the same order, and the parity tests are
|
||||
bit-exact. Each picture is sent as one picture-parameter buffer, the scaling matrices, and a slice
|
||||
parameter + slice data buffer per slice (the NAL unit as stored, emulation prevention included).
|
||||
HEVC pictures are sent the same way per slice segment (scaling lists only when enabled, converted
|
||||
to raster order). Pictures the DPB outputs are read back right away (`vaGetImage` into an NV12 or
|
||||
P010 image, then `biplanar.rs`), **the one GPU to CPU copy**. Surfaces: the stream's DPB size plus two.
|
||||
- *frame_num gaps* (and every start at a non-IDR picture: an open-GOP seek) get "non-existing"
|
||||
frames like the software decoder's: copies of the latest decoded frame, mid-gray when there is
|
||||
none (`vaPutImage`). Pictures predicted from them (the leading pictures of an open GOP after a
|
||||
seek) are concealment in both decoders and can differ: the hardware also reads motion data the
|
||||
stand-ins do not have. Everything from the seek point on is bit-exact.
|
||||
- *Declined up front* (our decoder is used): VP9 and AV1 (not through VA-API yet), H.264 profiles
|
||||
other than Baseline / Main / High, 10-bit H.264, HEVC range extensions and profiles other than
|
||||
Main / Main 10 / Main Still Picture, 4:2:2 / 4:4:4, field / MBAFF coding, no libva, no render
|
||||
node or driver, a profile or size the driver does not decode. *Mid-stream errors*
|
||||
(`HybridDecoder` continues in software): FMO, SP / SI slices, data partitioning, a size or DPB
|
||||
change, a missing reference picture (HEVC: one the RPS names that was never decoded), more than
|
||||
15 HEVC reference frames, any driver error.
|
||||
- An HEVC `flush` ends the stream as in software (references are dropped and the next CRA
|
||||
leaves out its RASL pictures); the GOP cache always seeks after a flush.
|
||||
- **Other systems:** `register()` does nothing and returns `Availability::Unavailable`.
|
||||
- **`HybridDecoder`** (`hybrid.rs`, safe code): the hardware decoder plus the means to build our
|
||||
software decoder for the same `SampleEntry` (`filmcraft_codecs::software_video_decoder`). On a
|
||||
@@ -114,8 +147,10 @@ built-in encoder, which changes three things from H.264:
|
||||
- **Choosing the format is the opt-in.** No `hardware_encoding` setting: Export ▸ Format ▸ H.265
|
||||
(HEVC), or `"format": "hevc"` in `file.exportMedia` (`h265` is accepted too). The format is
|
||||
listed as available only on a machine with a hardware HEVC encoder: `register()` hands
|
||||
`hardware_encode::hevc_available` (one small hardware session, created on the first question) to
|
||||
`filmcraft_export::register_format_probe`.
|
||||
`hardware_encode::hevc_available` (one small hardware session, about 0.1 s) to
|
||||
`filmcraft_export::register_format_probe`, and asks it right away on a thread of its own
|
||||
(`warm_hevc_probe`), so the first draw of the format list does not create the session on the UI
|
||||
thread.
|
||||
- **There is no fallback encoder.** What the hardware path does not take is an error, not a
|
||||
different encoder: two-pass VBR is refused up front (`ExportSettings::validate`), HDR sequences
|
||||
are exported as SDR (the H.265 path is 8-bit), and odd sizes, non-square pixels or a machine
|
||||
@@ -135,18 +170,99 @@ The sample entry's `hvcC` record is the one VideoToolbox wrote for the stream (r
|
||||
format description's sample description extension atoms, with the VPS / SPS / PPS), so profile,
|
||||
level and flags are the encoder's own. If it is missing the export stops with that reason.
|
||||
|
||||
## Hardware H.265 (HEVC) encoding (Windows, NVENC)
|
||||
|
||||
One codec enum (`nvenc::Codec`, chosen by `Profile::HevcMain` the way `VtProfile::HevcMain` does it
|
||||
for VideoToolbox) parameterises the NVENC session: the codec and profile GUIDs, the capability
|
||||
query, the `hevcConfig` member of the codec-configuration union, how NAL units are told apart
|
||||
(`(b0 >> 1) & 0x3f`) and the level numbering (`level × 30`, so 4.1 is 123). The level is the lowest
|
||||
whose **Main tier** limits hold the picture size, sample rate and peak bitrate (H.265 Table A.8,
|
||||
`nvenc::hevc::main_tier_level`): left to choose, NVENC answered 1080p30 at an 18 Mbit/s peak with
|
||||
level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main tier. The H.264 path is unchanged: the same export is the same file, byte for byte.
|
||||
|
||||
- **Choosing the format is the opt-in,** as on macOS: Export ▸ Format ▸ H.265 (HEVC) or
|
||||
`"format": "hevc"`. `register()` hands `nvenc::hevc_available` to
|
||||
`filmcraft_export::register_format_probe`: one small HEVC session (a 640×360 encoder, the answer
|
||||
kept; about half a second in a fresh process, nearly all of it opening the Direct3D 11 device and
|
||||
the NVENC session, which every NVENC export pays too). `register()` also asks it on a thread of its
|
||||
own (`nvenc::warm_hevc_probe`), so the first draw of the format list does not wait for it; a
|
||||
caller that asks while it runs waits for the same answer. The
|
||||
Hardware encoding toggle governs H.264 only: with H.265 selected NVENC is used whether it is Auto
|
||||
or Off, because nothing else can encode it.
|
||||
- **What it writes:** HEVC Main, 8-bit 4:2:0, SDR BT.709 limited range (Main 10 HDR: see below) (the VUI says so, with the
|
||||
frame rate as `time_scale / num_units_in_tick`), progressive, preset P5 with high-quality tuning,
|
||||
constant or one-pass variable bitrate, an IDR at every keyframe distance, one B-frame when the GPU
|
||||
reports support and the GOP is longer than two pictures (shorter ones are written without), in
|
||||
MP4 (`hvc1`) or QuickTime. Pictures are converted exactly like the H.264 path's. The coded size is
|
||||
a whole number of 32-pixel coding tree blocks and the SPS carries a conformance window (1080 is
|
||||
coded as 1088 and cropped back); the `hvcC` is only accepted when the cropped size is the export's.
|
||||
- **Samples are length-prefixed (4 bytes) with the parameter sets only in the `hvcC`:** VPS, SPS,
|
||||
PPS, access unit delimiters and end-of-sequence / bitstream markers are dropped; slices and SEI
|
||||
stay. `nvEncGetSequenceParams` returns all three parameter sets.
|
||||
- **The `hvcC` is built from the SPS the encoder wrote** (`nvenc/hevc.rs`, parsed with
|
||||
`filmcraft_hevc`): profile space, tier, profile, compatibility flags and level from the SPS's
|
||||
profile / tier / level; the 48 general constraint flags and `sps_temporal_id_nesting_flag` read from
|
||||
the SPS bits that carry them; chroma format, bit depths and temporal layers from the SPS; the
|
||||
arrays complete (`array_completeness` 1). Nothing is a constant of ours except what the format
|
||||
leaves unspecified (`avg_frame_rate`, `constant_frame_rate`, `min_spatial_segmentation_idc` and
|
||||
`parallelism_type` are 0). It is built, and validated as Main 8-bit 4:2:0 of the right size, when
|
||||
the encoder is created, so a stream we cannot describe is a declined export and never a default
|
||||
sample entry.
|
||||
- **Timestamps are the H.264 path's:** dts is the decode index minus the B-frame delay, composition
|
||||
offsets are `pts - dts` in frame durations, and the media starts `delay` frames early (edit list).
|
||||
- **There is no fallback encoder.** A request NVENC cannot take is counted in
|
||||
`export.hardware.declined` and ends the export with "H.265 export with NVENC: …" and the reason:
|
||||
10-bit on a GPU without it (HDR), interlaced output, two-pass (`ExportSettings::validate` refuses it first), non-square pixels,
|
||||
MXF, sizes above 65535, odd sizes, sizes outside the GPU's limits (129×33 to 8192×8192 on the RTX
|
||||
5060). On a machine without an HEVC encoder the format is not available and an export fails with
|
||||
"encoder not available yet". Frames, sessions and declines are the same `export.hardware` counters
|
||||
as for H.264.
|
||||
- **Main 10 HDR (PQ / HLG).** An HDR sequence (Rec. 2100 PQ or HLG working space) exports as HEVC
|
||||
Main 10 when `settings.sdr` is off and this GPU has a 10-bit HEVC encoder. `register()` hands
|
||||
`nvenc::hevc_hdr_available` (a Main 10 session, cached, asked on the same background thread as the
|
||||
8-bit probe and short-circuiting when HEVC itself is missing) to `filmcraft_export::register_hdr_probe`,
|
||||
and the export job makes the export HDR for `Format::Hevc` only when
|
||||
`filmcraft_export::hdr_available(Format::Hevc)` says so: on macOS (VideoToolbox is 8-bit) and on GPUs
|
||||
without 10-bit an HDR sequence is tone-mapped to 8-bit SDR HEVC exactly as before, and `settings.sdr`
|
||||
still forces that. The pixels are real: `EncoderFrame::hdr` (encoded BT.2020 R'G'B', 3 floats per
|
||||
pixel) goes through `filmcraft_export::rgbf_to_yuv420_10` (BT.2020 non-constant-luminance matrix,
|
||||
Y = 64 + 876·Y', C = 512 + 896·C, 2x2 chroma average, NaN counts as 0, values clamp to 0..1 and codes
|
||||
to 4..1019, wrong-length input is an error) and into a **P010** input buffer (`code << 6`,
|
||||
little-endian, chroma interleaved after the luma rows, pitch in bytes; a driver pitch under 2 bytes
|
||||
per pixel is refused). One buffer format (`NV_ENC_BUFFER_FORMAT_YUV420_10BIT`) is used for the
|
||||
initialisation, the input buffers and every picture; `pixelBitDepthMinus8` is 2, the profile GUID
|
||||
is Main 10, `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` is queried first and a GPU without it declines
|
||||
with "no 10-bit (HEVC Main 10) support". The VUI says BT.2020 primaries, transfer 16 (PQ) or 18
|
||||
(HLG), matrix 9, limited range. The `hvcC` is checked against what was asked (`hevc_config(…, 10)`:
|
||||
profile 2, 10-bit 4:2:0). NVENC chose the High tier for level 4 at 12 Mb/s; the record just says so.
|
||||
Pictures of the wrong depth for the encoder (an `hdr` picture to a Main encoder, an RGBA one to
|
||||
Main 10, or a float slice of the wrong length) are errors, never panics.
|
||||
- **HDR10 static metadata is SEI, not an API.** NVENC 12.1 has no mastering-display field, so PQ
|
||||
streams get the mastering display colour volume (payload 137) and content light level (144)
|
||||
messages through `NV_ENC_PIC_PARAMS_HEVC::seiPayloadArray` on every IDR picture, with the values
|
||||
of `ColorSignal::static_metadata` (BT.2020/D65, 1000 / 0.0001 cd/m², MaxCLL/MaxFALL 0), the same
|
||||
that the software H.264 encoder's SEI and the `mdcv` / `clli` boxes carry. NVENC writes them as
|
||||
two prefix SEI NAL units (type 39) in front of the IDR slice, adding the emulation-prevention
|
||||
byte its `00 00 00 01` needs; nothing is attached to other pictures; HLG has no static metadata and
|
||||
gets none. The payload bytes and the descriptor array are heap blocks owned by the `Session` for
|
||||
its whole life (it is moved by value, and the driver may queue a picture), and IDR pictures are
|
||||
known from a submission counter (`n % gop == 0`). The sample entry carries `colr` (nclx BT.2020,
|
||||
PQ / HLG, limited), and for PQ `mdcv` and `clli`; 8-bit SDR HEVC is unchanged and has no `colr`
|
||||
(the VUI carries its description, as in the software H.264 path).
|
||||
|
||||
## Guarantees
|
||||
|
||||
- **Never undecodable:** the factory declines (returns `None`, so the software decoder is used)
|
||||
when Settings ▸ Playback ▸ Hardware decoding is Off, for formats it does not take (field-coded
|
||||
H.264, bit depths other than 8 / 10, 4:4:4 or monochrome, luma / chroma depth mismatch, larger
|
||||
than 8192×8192; on Windows also 4:2:2 and the profiles listed above) and when the OS cannot
|
||||
create a hardware session (VideoToolbox) or a GPU-backed decoder (Media Foundation).
|
||||
than 8192×8192; on Windows also 4:2:2 and the profiles listed above; on Linux everything but
|
||||
8-bit 4:2:0 progressive H.264 and 4:2:0 HEVC Main / Main 10) and when the OS cannot create a hardware session (VideoToolbox), a
|
||||
GPU-backed decoder (Media Foundation) or a VA-API configuration and context (Linux).
|
||||
- **Interchangeable:** colour, pixel aspect, pts, presentation order, `is_random_access` and
|
||||
`is_disposable` come from the software decoders' own helpers (`filmcraft_codecs::hw`,
|
||||
`video::vui_color`, `sar_par`).
|
||||
- **Never crash:** no `unwrap` / `expect` / `panic!` outside tests; the output callback runs under
|
||||
`catch_unwind`; every `unsafe` block has a `// SAFETY:` comment; the public API is safe.
|
||||
- **Never crash:** no `unwrap` / `expect` / `panic!` outside tests; the output callback (and
|
||||
libva's error-message callback) runs under `catch_unwind`; every `unsafe` block has a `// SAFETY:` comment; the public API is safe.
|
||||
- **Counted:** `perf.stats` `decode.hardware` (frames, software frames, sessions, declined,
|
||||
fallbacks; `filmcraft_codecs::hw::hw_stats`) and `backend` (the registered backend's name,
|
||||
`filmcraft_codecs::hw::hw_backend`). `export.hardware` counts the NVENC encoder's frames, sessions and declines.
|
||||
@@ -161,7 +277,18 @@ level and flags are the encoder's own. If it is missing the export stops with th
|
||||
| `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 |
|
||||
| `tests/nvenc.rs` (Windows, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right |
|
||||
| `tests/nvenc_export.rs` (Windows, NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters |
|
||||
| `src/nvenc/abi_tests.rs` (Windows) | FFI structs' sizes, alignments, field offsets, constants and GUIDs against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) |
|
||||
| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames |
|
||||
| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC |
|
||||
| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) |
|
||||
| `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe |
|
||||
| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight |
|
||||
| `tests/nvenc_hevc_hdr_export.rs` (Windows, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity |
|
||||
| `src/nvenc/abi_tests.rs` (Windows) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) |
|
||||
| `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages |
|
||||
| `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` |
|
||||
| `tests/vaapi.rs` (Linux, VA-API) | HEVC Main (open GOP: CRA + RASL), Main 10, scaling lists, 3 slices with WPP (10-bit), weighted prediction with 4 references, transform skip + AMP + B-pyramid (10-bit, open GOP), Main / Main 10 at 1080p and 2160p (compared picture by picture, little memory); H.264 High (B-pyramid), Constrained Baseline with 3 slices per picture, Main with explicit weighted prediction (P and B) and 4 references, temporal direct, custom scaling matrices (JVT) with the 8x8 transform and 2 slices, an open-GOP stream, and 1080p / 2160p, 640×360 (coded 368: cropping): every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample (after an open-GOP I picture, from the seek point on), a mid-stream `flush` with decoding carrying on, and a full pass after resets; forced mid-stream failures (`VaDecoder::fail_after`) at five points continue with the software decoder's exact output; interlaced and 10-bit H.264, 4:2:2 HEVC and the Off setting are declined; an HEVC mid-GOP flush is not checked (it ends the stream, as in software); seeded mutation of samples (through the hybrid and straight into the hardware decoder) and of `avcC` records never panics or hangs; 40 decoders created and dropped; concurrent decoders; a decoder moving between threads |
|
||||
| `src/vaapi/tests.rs` (every OS) | The H.264 and HEVC front ends with a recording stand-in for the hardware, on x264 streams (B-pyramid, weighted prediction, 4 slices, open GOP with temporal direct) and x265 streams (open GOP with RASL, B-pyramid, Main 10 with 2 slices and weighted prediction): output order and pts are the software decoder's, also after seeks and a mid-stream flush; every buffer is consistent (current surface free, references decoded and listed once, reference lists of the active length, slice data offsets inside the slice); damaged samples are errors |
|
||||
| `src/vaapi/abi_tests.rs` | FFI structs' sizes, alignments, field offsets, constants and bit-field positions against a C compiler's view of libva's `va.h` and `va_dec_hevc.h` (VA-API 1.23) |
|
||||
|
||||
Fixtures are made with ffmpeg into `target/fixtures/platform/` (generator only, never linked);
|
||||
tests skip without ffmpeg or without a hardware decoder.
|
||||
@@ -185,9 +312,16 @@ M4 Pro, load 150–190 (`cargo xtask bench --hw off|auto`): CPU per decoded fram
|
||||
playback with no dropped frames at Full, 1/2 and 1/4. Details in
|
||||
[docs/performance.md](../../docs/performance.md).
|
||||
|
||||
Hardware decoding, Linux (Intel Iris Xe, iHD driver; `cargo xtask bench --hw off|auto`): CPU per
|
||||
decoded frame H.264 1080p 104 → 2.3 ms, 2160p 417 → 11.4 ms; HEVC 1080p 46 → 2.3 ms, 2160p
|
||||
203 → 10.1 ms, Main 10 2160p 212 → 18.4 ms.
|
||||
Details in [docs/performance.md](../../docs/performance.md).
|
||||
|
||||
## Not yet
|
||||
|
||||
Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on macOS, Direct3D 11
|
||||
textures on Windows); B-frames and 10-bit / HDR HEVC (Main 10) in hardware encoding; VA-API (Linux)
|
||||
decoders; field-coded H.264; HEVC, 10-bit and HDR encoding with NVENC, and encoders from other
|
||||
vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows.
|
||||
textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in
|
||||
VideoToolbox; VP9 and AV1 through VA-API (Linux), and VA-API encoding; field-coded H.264; 4:4:4 and 4:2:2 encoding with NVENC, HDR
|
||||
H.264 with NVENC (HDR H.264 stays with the software encoder), HDR10 MaxCLL / MaxFALL measured from
|
||||
the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders
|
||||
from other vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! configurations are declined, and a declined export fails with "encoder not available" instead of
|
||||
//! falling back; [`hevc_available`] tells the format list whether this machine can do it at all.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{Once, OnceLock};
|
||||
|
||||
use filmcraft_export::{BitrateMode, EncodedPacket, EncoderFrame, ExportError, ExportSettings, Format, H264Pass, H264Profile, HardwareEncoding, VideoEncoder};
|
||||
use filmcraft_isobmff::{AvcConfig, HevcConfig, SampleEntry};
|
||||
@@ -66,11 +66,13 @@ pub fn config_for(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Result
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this machine has a hardware HEVC encoder (asked once, by creating a small session): what
|
||||
/// makes `Format::Hevc` available.
|
||||
static HEVC_PROBE: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Whether this machine has a hardware HEVC encoder (asked once, by creating a small session, which
|
||||
/// takes about 0.1 s): what makes `Format::Hevc` available. [`warm_hevc_probe`] asks it ahead of the
|
||||
/// first caller.
|
||||
pub fn hevc_available() -> bool {
|
||||
static AVAILABLE: OnceLock<bool> = OnceLock::new();
|
||||
*AVAILABLE.get_or_init(|| {
|
||||
*HEVC_PROBE.get_or_init(|| {
|
||||
let probe = VtConfig {
|
||||
width: 640,
|
||||
height: 360,
|
||||
@@ -88,6 +90,27 @@ pub fn hevc_available() -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether [`hevc_available`] has its answer yet (diagnostics and tests).
|
||||
pub fn hevc_probed() -> bool {
|
||||
HEVC_PROBE.get().is_some()
|
||||
}
|
||||
|
||||
/// Ask [`hevc_available`] now, on a thread of its own, so that the first draw of the format list does
|
||||
/// not spend its 0.1 s creating a hardware session on the UI thread. Whoever asks while the thread
|
||||
/// runs waits for the same answer (no longer than asking first); if the thread cannot start, or the
|
||||
/// probe panics, the first caller asks, as before. Only the first call starts a thread.
|
||||
pub fn warm_hevc_probe() {
|
||||
static STARTED: Once = Once::new();
|
||||
STARTED.call_once(|| {
|
||||
let spawned = std::thread::Builder::new().name("filmcraft-hevc-probe".into()).spawn(|| {
|
||||
let _ = std::panic::catch_unwind(hevc_available);
|
||||
});
|
||||
if let Err(why) = spawned {
|
||||
log::info!("HEVC probe not started ahead of time: {why}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The encoder factory `register` puts in front of the built-in ones: a hardware encoder when the
|
||||
/// export asks for one (H.264 with `hardware_encoding`; HEVC always) and the OS takes the
|
||||
/// configuration, `None` (the built-in encoder, if there is one) otherwise.
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
//!
|
||||
//! [`register`] puts the platform's hardware decoder factory in front of FilmCraft's own decoders
|
||||
//! (`filmcraft_codecs::register_video_decoder`). Today that is VideoToolbox on macOS for H.264
|
||||
//! (`avcC`) and HEVC (`hvcC`) streams, 8- and 10-bit, 4:2:0 and 4:2:2; on other systems
|
||||
//! registration does nothing and reports [`Availability::Unavailable`]. It also registers a
|
||||
//! (`avcC`) and HEVC (`hvcC`) streams, 8- and 10-bit, 4:2:0 and 4:2:2; Media Foundation / DXVA on
|
||||
//! Windows; VA-API on Linux for H.264 ([`vaapi`]); on other systems (and on Linux without a VA-API
|
||||
//! driver) registration does nothing and reports [`Availability::Unavailable`]. It also registers a
|
||||
//! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an
|
||||
//! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`].
|
||||
//! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on
|
||||
//! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows
|
||||
//! (Main 10) lets HDR sequences export as HDR H.265.
|
||||
//!
|
||||
//! Hardware decoding never makes a file undecodable:
|
||||
//!
|
||||
@@ -25,10 +28,11 @@
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
// Used by the Windows decoder only; compiled everywhere so their tests run on every system.
|
||||
// Used by the Windows (and, for `biplanar`, Linux) decoders; compiled everywhere so their tests run
|
||||
// on every system.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
mod annexb;
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
#[cfg_attr(not(any(target_os = "windows", target_os = "linux")), allow(dead_code))]
|
||||
mod biplanar;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod hardware_encode;
|
||||
@@ -37,6 +41,8 @@ pub mod hybrid;
|
||||
pub mod media_foundation;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod nvenc;
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub mod vaapi;
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unsafe_code)]
|
||||
pub mod videotoolbox;
|
||||
@@ -64,9 +70,23 @@ pub fn register() -> Availability {
|
||||
filmcraft_codecs::register_video_decoder(videotoolbox_factory);
|
||||
filmcraft_export::register_encoder(hardware_encode::videotoolbox_encoder_factory);
|
||||
filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, hardware_encode::hevc_available);
|
||||
// the probe creates a hardware session (about 0.1 s): answer it now, off the UI thread,
|
||||
// before the first draw of the format list asks
|
||||
hardware_encode::warm_hevc_probe();
|
||||
filmcraft_codecs::hw::set_hw_backend("VideoToolbox");
|
||||
Availability::Available("VideoToolbox")
|
||||
}
|
||||
// Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows, so the format is available
|
||||
// when a small HEVC session opens. That takes about half a second, so it is asked on a thread of
|
||||
// its own now rather than by the first draw of the format list.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available);
|
||||
// HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere
|
||||
// (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before
|
||||
filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available);
|
||||
nvenc::warm_hevc_probe();
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
static ENCODERS: std::sync::Once = std::sync::Once::new();
|
||||
@@ -77,7 +97,23 @@ pub fn register() -> Availability {
|
||||
filmcraft_codecs::hw::set_hw_backend("Media Foundation");
|
||||
Availability::Available("Media Foundation")
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
match vaapi::va::probe() {
|
||||
Ok(driver) => {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| log::info!("hardware decoding through VA-API: {driver}"));
|
||||
filmcraft_codecs::register_video_decoder(vaapi_factory);
|
||||
filmcraft_codecs::hw::set_hw_backend("VA-API");
|
||||
Availability::Available("VA-API")
|
||||
}
|
||||
Err(why) => {
|
||||
log::info!("no VA-API hardware decoding: {why}");
|
||||
Availability::Unavailable("no VA-API driver (libva and a DRM render node)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
Availability::Unavailable("no hardware video decoder for this system yet")
|
||||
}
|
||||
@@ -93,7 +129,11 @@ pub fn registered() -> bool {
|
||||
{
|
||||
filmcraft_codecs::video_decoder_registered(media_foundation_factory)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
filmcraft_codecs::video_decoder_registered(vaapi_factory)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
@@ -110,7 +150,11 @@ pub fn hardware_decoder_for(entry: &filmcraft_isobmff::SampleEntry) -> bool {
|
||||
{
|
||||
media_foundation::stream_info(entry).is_some_and(|info| media_foundation::MfDecoder::new(info).is_ok())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
filmcraft_codecs::hw::NalStreamInfo::from_entry(entry).and_then(|r| r.ok()).is_some_and(|info| vaapi::VaDecoder::new(info).is_ok())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
let _ = entry;
|
||||
false
|
||||
@@ -153,3 +197,22 @@ pub fn media_foundation_factory(entry: &filmcraft_isobmff::SampleEntry) -> Optio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The VA-API factory: a [`HybridDecoder`] around [`vaapi::VaDecoder`] for H.264 and HEVC
|
||||
/// streams this system's VA-API driver decodes, `None` otherwise (other codecs keep the software
|
||||
/// decoders).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn vaapi_factory(entry: &filmcraft_isobmff::SampleEntry) -> Option<filmcraft_codecs::Result<Box<dyn filmcraft_codecs::VideoDecoder>>> {
|
||||
if !filmcraft_codecs::hw::hardware_decoding() {
|
||||
return None;
|
||||
}
|
||||
let info = filmcraft_codecs::hw::NalStreamInfo::from_entry(entry)?.ok()?;
|
||||
match vaapi::VaDecoder::new(info.clone()) {
|
||||
Ok(va) => Some(Ok(Box::new(HybridDecoder::new(Box::new(va), entry.clone(), info)))),
|
||||
Err(why) => {
|
||||
log::info!("hardware decoding declined for {} video: {why}", entry.codec.name());
|
||||
filmcraft_codecs::hw::note_hw_declined();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! alignments, field offsets, constants and GUIDs. Generated by running a C program (built with
|
||||
//! MSVC from the NVIDIA header) that prints them; the header itself is not in this repository.
|
||||
//! To regenerate, put NVIDIA's MIT-licensed `nvEncodeAPI.h` 12.1 in `ffnvcodec/` next to a program
|
||||
//! printing `sizeof` / `offsetof` of the structures of `src/nvenc/ffi.rs` and the constants it uses.
|
||||
//! printing `sizeof` / `alignof` / `offsetof` of the structures of `src/nvenc/ffi.rs`, the constants
|
||||
//! and GUIDs it uses and, for the bit-fields of the `flags` words (`offsetof` cannot take those), the
|
||||
//! word with that one field set (see docs/testing.md). H.264 and HEVC are both covered.
|
||||
#![allow(clippy::unreadable_literal)]
|
||||
|
||||
use super::ffi::*;
|
||||
@@ -111,9 +113,38 @@ fn layouts_match_the_header() {
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_H264, numRefL1), 208, "NV_ENC_CONFIG_H264.numRefL1");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_H264, reserved1), 212, "NV_ENC_CONFIG_H264.reserved1");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_H264, reserved2), 1280, "NV_ENC_CONFIG_H264.reserved2");
|
||||
assert_eq!(
|
||||
(size_of::<NV_ENC_CONFIG_HEVC_VUI_PARAMETERS>(), align_of::<NV_ENC_CONFIG_HEVC_VUI_PARAMETERS>()),
|
||||
(112, 4),
|
||||
"NV_ENC_CONFIG_HEVC_VUI_PARAMETERS"
|
||||
);
|
||||
assert_eq!((size_of::<NV_ENC_CONFIG_HEVC>(), align_of::<NV_ENC_CONFIG_HEVC>()), (1560, 8), "NV_ENC_CONFIG_HEVC");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, level), 0, "NV_ENC_CONFIG_HEVC.level");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, tier), 4, "NV_ENC_CONFIG_HEVC.tier");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, minCUSize), 8, "NV_ENC_CONFIG_HEVC.minCUSize");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, maxCUSize), 12, "NV_ENC_CONFIG_HEVC.maxCUSize");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, idrPeriod), 20, "NV_ENC_CONFIG_HEVC.idrPeriod");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, intraRefreshPeriod), 24, "NV_ENC_CONFIG_HEVC.intraRefreshPeriod");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, intraRefreshCnt), 28, "NV_ENC_CONFIG_HEVC.intraRefreshCnt");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, maxNumRefFramesInDPB), 32, "NV_ENC_CONFIG_HEVC.maxNumRefFramesInDPB");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, ltrNumFrames), 36, "NV_ENC_CONFIG_HEVC.ltrNumFrames");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, vpsId), 40, "NV_ENC_CONFIG_HEVC.vpsId");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, spsId), 44, "NV_ENC_CONFIG_HEVC.spsId");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, ppsId), 48, "NV_ENC_CONFIG_HEVC.ppsId");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, sliceMode), 52, "NV_ENC_CONFIG_HEVC.sliceMode");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, sliceModeData), 56, "NV_ENC_CONFIG_HEVC.sliceModeData");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, maxTemporalLayersMinus1), 60, "NV_ENC_CONFIG_HEVC.maxTemporalLayersMinus1");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, hevcVUIParameters), 64, "NV_ENC_CONFIG_HEVC.hevcVUIParameters");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, ltrTrustMode), 176, "NV_ENC_CONFIG_HEVC.ltrTrustMode");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, useBFramesAsRef), 180, "NV_ENC_CONFIG_HEVC.useBFramesAsRef");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, numRefL0), 184, "NV_ENC_CONFIG_HEVC.numRefL0");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, numRefL1), 188, "NV_ENC_CONFIG_HEVC.numRefL1");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, reserved1), 192, "NV_ENC_CONFIG_HEVC.reserved1");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, reserved2), 1048, "NV_ENC_CONFIG_HEVC.reserved2");
|
||||
assert_eq!((size_of::<NV_ENC_CODEC_CONFIG>(), align_of::<NV_ENC_CODEC_CONFIG>()), (1792, 8), "NV_ENC_CODEC_CONFIG");
|
||||
assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, h264Config), 0, "NV_ENC_CODEC_CONFIG.h264Config");
|
||||
assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, reserved), 0, "NV_ENC_CODEC_CONFIG.reserved");
|
||||
assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, hevcConfig), 0, "NV_ENC_CODEC_CONFIG.hevcConfig");
|
||||
assert_eq!((size_of::<NV_ENC_CONFIG>(), align_of::<NV_ENC_CONFIG>()), (3584, 8), "NV_ENC_CONFIG");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG, version), 0, "NV_ENC_CONFIG.version");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG, profileGUID), 4, "NV_ENC_CONFIG.profileGUID");
|
||||
@@ -305,6 +336,41 @@ fn layouts_match_the_header() {
|
||||
assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, nvEncRestoreEncoderState), 336, "NV_ENCODE_API_FUNCTION_LIST.nvEncRestoreEncoderState");
|
||||
assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, nvEncLookaheadPicture), 344, "NV_ENCODE_API_FUNCTION_LIST.nvEncLookaheadPicture");
|
||||
assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, reserved2), 352, "NV_ENCODE_API_FUNCTION_LIST.reserved2");
|
||||
assert_eq!((size_of::<NV_ENC_SEI_PAYLOAD>(), align_of::<NV_ENC_SEI_PAYLOAD>()), (16, 8), "NV_ENC_SEI_PAYLOAD");
|
||||
assert_eq!(offset_of!(NV_ENC_SEI_PAYLOAD, payloadSize), 0, "NV_ENC_SEI_PAYLOAD.payloadSize");
|
||||
assert_eq!(offset_of!(NV_ENC_SEI_PAYLOAD, payloadType), 4, "NV_ENC_SEI_PAYLOAD.payloadType");
|
||||
assert_eq!(offset_of!(NV_ENC_SEI_PAYLOAD, payload), 8, "NV_ENC_SEI_PAYLOAD.payload");
|
||||
assert_eq!((size_of::<NV_ENC_CLOCK_TIMESTAMP_SET>(), align_of::<NV_ENC_CLOCK_TIMESTAMP_SET>()), (8, 4), "NV_ENC_CLOCK_TIMESTAMP_SET");
|
||||
assert_eq!(offset_of!(NV_ENC_CLOCK_TIMESTAMP_SET, timeOffset), 4, "NV_ENC_CLOCK_TIMESTAMP_SET.timeOffset");
|
||||
assert_eq!((size_of::<NV_ENC_TIME_CODE>(), align_of::<NV_ENC_TIME_CODE>()), (28, 4), "NV_ENC_TIME_CODE");
|
||||
assert_eq!(offset_of!(NV_ENC_TIME_CODE, displayPicStruct), 0, "NV_ENC_TIME_CODE.displayPicStruct");
|
||||
assert_eq!(offset_of!(NV_ENC_TIME_CODE, clockTimestamp), 4, "NV_ENC_TIME_CODE.clockTimestamp");
|
||||
assert_eq!((size_of::<NV_ENC_PIC_PARAMS_HEVC>(), align_of::<NV_ENC_PIC_PARAMS_HEVC>()), (1536, 8), "NV_ENC_PIC_PARAMS_HEVC");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, displayPOCSyntax), 0, "NV_ENC_PIC_PARAMS_HEVC.displayPOCSyntax");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, refPicFlag), 4, "NV_ENC_PIC_PARAMS_HEVC.refPicFlag");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, temporalId), 8, "NV_ENC_PIC_PARAMS_HEVC.temporalId");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, forceIntraRefreshWithFrameCnt), 12, "NV_ENC_PIC_PARAMS_HEVC.forceIntraRefreshWithFrameCnt");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceTypeData), 24, "NV_ENC_PIC_PARAMS_HEVC.sliceTypeData");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceTypeArrayCnt), 32, "NV_ENC_PIC_PARAMS_HEVC.sliceTypeArrayCnt");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceMode), 36, "NV_ENC_PIC_PARAMS_HEVC.sliceMode");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceModeData), 40, "NV_ENC_PIC_PARAMS_HEVC.sliceModeData");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, ltrMarkFrameIdx), 44, "NV_ENC_PIC_PARAMS_HEVC.ltrMarkFrameIdx");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, ltrUseFrameBitmap), 48, "NV_ENC_PIC_PARAMS_HEVC.ltrUseFrameBitmap");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, ltrUsageMode), 52, "NV_ENC_PIC_PARAMS_HEVC.ltrUsageMode");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, seiPayloadArrayCnt), 56, "NV_ENC_PIC_PARAMS_HEVC.seiPayloadArrayCnt");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, reserved), 60, "NV_ENC_PIC_PARAMS_HEVC.reserved");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, seiPayloadArray), 64, "NV_ENC_PIC_PARAMS_HEVC.seiPayloadArray");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, timeCode), 72, "NV_ENC_PIC_PARAMS_HEVC.timeCode");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, reserved2), 100, "NV_ENC_PIC_PARAMS_HEVC.reserved2");
|
||||
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, reserved3), 1048, "NV_ENC_PIC_PARAMS_HEVC.reserved3");
|
||||
assert_eq!(offset_of!(NV_ENC_CLOCK_TIMESTAMP_SET, bits), 0, "NV_ENC_CLOCK_TIMESTAMP_SET.bits");
|
||||
assert_eq!(offset_of!(NV_ENC_CODEC_PIC_PARAMS, hevcPicParams), 0, "NV_ENC_CODEC_PIC_PARAMS.hevcPicParams");
|
||||
assert_eq!(offset_of!(NV_ENC_CODEC_PIC_PARAMS, reserved), 0, "NV_ENC_CODEC_PIC_PARAMS.reserved");
|
||||
assert_eq!(
|
||||
offset_of!(NV_ENC_PIC_PARAMS_HEVC, flags),
|
||||
16,
|
||||
"NV_ENC_PIC_PARAMS_HEVC.flags (the C program sets constrainedFrame / ltrUseFrames and reads the word at 16: 0x1 / 0x8)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -353,7 +419,28 @@ fn constants_match_the_header() {
|
||||
assert_eq!(NV_ENC_LOCK_BITSTREAM_VER as i64, 4043374604, "NV_ENC_LOCK_BITSTREAM_VER");
|
||||
assert_eq!(NV_ENC_LOCK_INPUT_BUFFER_VER as i64, 1895890956, "NV_ENC_LOCK_INPUT_BUFFER_VER");
|
||||
assert_eq!(NV_ENC_SEQUENCE_PARAM_PAYLOAD_VER as i64, 1895890956, "NV_ENC_SEQUENCE_PARAM_PAYLOAD_VER");
|
||||
assert_eq!(NV_ENC_TIER_HEVC_MAIN as i64, 0, "NV_ENC_TIER_HEVC_MAIN");
|
||||
assert_eq!(NV_ENC_HEVC_CUSIZE_AUTOSELECT as i64, 0, "NV_ENC_HEVC_CUSIZE_AUTOSELECT");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_1 as i64, 30, "NV_ENC_LEVEL_HEVC_1");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_2 as i64, 60, "NV_ENC_LEVEL_HEVC_2");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_21 as i64, 63, "NV_ENC_LEVEL_HEVC_21");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_3 as i64, 90, "NV_ENC_LEVEL_HEVC_3");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_31 as i64, 93, "NV_ENC_LEVEL_HEVC_31");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_4 as i64, 120, "NV_ENC_LEVEL_HEVC_4");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_41 as i64, 123, "NV_ENC_LEVEL_HEVC_41");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_5 as i64, 150, "NV_ENC_LEVEL_HEVC_5");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_51 as i64, 153, "NV_ENC_LEVEL_HEVC_51");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_52 as i64, 156, "NV_ENC_LEVEL_HEVC_52");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_6 as i64, 180, "NV_ENC_LEVEL_HEVC_6");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_61 as i64, 183, "NV_ENC_LEVEL_HEVC_61");
|
||||
assert_eq!(NV_ENC_LEVEL_HEVC_62 as i64, 186, "NV_ENC_LEVEL_HEVC_62");
|
||||
assert_eq!(NVENCAPI_VERSION as i64, 16777228, "NVENCAPI_VERSION");
|
||||
assert_eq!(NV_ENC_BUFFER_FORMAT_YUV420_10BIT as i64, 65536, "NV_ENC_BUFFER_FORMAT_YUV420_10BIT");
|
||||
assert_eq!(NV_ENC_CAPS_SUPPORT_10BIT_ENCODE as i64, 39, "NV_ENC_CAPS_SUPPORT_10BIT_ENCODE");
|
||||
assert_eq!(NV_ENC_VUI_COLOR_PRIMARIES_BT2020 as i64, 9, "NV_ENC_VUI_COLOR_PRIMARIES_BT2020");
|
||||
assert_eq!(NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084 as i64, 16, "NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084");
|
||||
assert_eq!(NV_ENC_VUI_TRANSFER_CHARACTERISTIC_ARIB_STD_B67 as i64, 18, "NV_ENC_VUI_TRANSFER_CHARACTERISTIC_ARIB_STD_B67");
|
||||
assert_eq!(NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL as i64, 9, "NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -364,4 +451,23 @@ fn guids_match_the_header() {
|
||||
assert_eq!(NV_ENC_H264_PROFILE_HIGH_GUID.to_u128(), 0xe7cbc3094f7a4b89af2ad537c92be310u128, "NV_ENC_H264_PROFILE_HIGH_GUID");
|
||||
assert_eq!(NV_ENC_PRESET_P4_GUID.to_u128(), 0x90a7b826df064862b9d2cd6d73a08681u128, "NV_ENC_PRESET_P4_GUID");
|
||||
assert_eq!(NV_ENC_PRESET_P5_GUID.to_u128(), 0x21c6e6b4297a4cba998fb6cbde72ade3u128, "NV_ENC_PRESET_P5_GUID");
|
||||
assert_eq!(NV_ENC_CODEC_HEVC_GUID.to_u128(), 0x790cdc8845224d7b9425bda9975f7603u128, "NV_ENC_CODEC_HEVC_GUID");
|
||||
assert_eq!(NV_ENC_HEVC_PROFILE_MAIN_GUID.to_u128(), 0xb514c39ab55b40fa878ff1253b4dfdecu128, "NV_ENC_HEVC_PROFILE_MAIN_GUID");
|
||||
assert_eq!(NV_ENC_HEVC_PROFILE_MAIN10_GUID.to_u128(), 0xfa4d2b6c3a5b411a80180a3f5e3c9be5u128, "NV_ENC_HEVC_PROFILE_MAIN10_GUID");
|
||||
}
|
||||
|
||||
/// The bit-field masks of the `flags` words (the C compiler's layout of each bit-field set alone).
|
||||
#[test]
|
||||
fn bit_field_masks_match_the_header() {
|
||||
assert_eq!(H264_OUTPUT_AUD, 0x40, "NV_ENC_CONFIG_H264.outputAUD");
|
||||
assert_eq!(H264_REPEAT_SPSPPS, 0x1000, "NV_ENC_CONFIG_H264.repeatSPSPPS");
|
||||
assert_eq!(HEVC_OUTPUT_AUD, 0x10, "NV_ENC_CONFIG_HEVC.outputAUD");
|
||||
assert_eq!(HEVC_DISABLE_SPSPPS, 0x40, "NV_ENC_CONFIG_HEVC.disableSPSPPS");
|
||||
assert_eq!(HEVC_REPEAT_SPSPPS, 0x80, "NV_ENC_CONFIG_HEVC.repeatSPSPPS");
|
||||
assert_eq!(HEVC_ENABLE_INTRA_REFRESH, 0x100, "NV_ENC_CONFIG_HEVC.enableIntraRefresh");
|
||||
assert_eq!(HEVC_CHROMA_FORMAT_MASK, 0x600, "NV_ENC_CONFIG_HEVC.chromaFormatIDC");
|
||||
assert_eq!(HEVC_PIXEL_BIT_DEPTH_MASK, 0x3800, "NV_ENC_CONFIG_HEVC.pixelBitDepthMinus8");
|
||||
assert_eq!(HEVC_OUTPUT_RECOVERY_POINT_SEI, 0x40000, "NV_ENC_CONFIG_HEVC.outputRecoveryPointSEI");
|
||||
assert_eq!(HEVC_CHROMA_FORMAT_MASK >> HEVC_CHROMA_FORMAT_SHIFT, 3, "chromaFormatIDC is 2 bits");
|
||||
assert_eq!(HEVC_PIXEL_BIT_DEPTH_MASK >> HEVC_PIXEL_BIT_DEPTH_SHIFT, 7, "pixelBitDepthMinus8 is 3 bits");
|
||||
}
|
||||
|
||||
@@ -1,29 +1,69 @@
|
||||
//! NVENC as an Export encoder: [`factory`] is registered in front of the software H.264 encoder
|
||||
//! (`filmcraft_export::register_encoder`) and takes an export only when Export ▸ Hardware encoding
|
||||
//! is Auto and NVENC can do what the settings ask; otherwise it returns `None` and the software
|
||||
//! encoder runs, exactly as before.
|
||||
//! (`filmcraft_export::register_encoder`).
|
||||
//!
|
||||
//! - H.264: it takes an export only when Export ▸ Hardware encoding is Auto and NVENC can do what
|
||||
//! the settings ask; otherwise it returns `None` and the software encoder runs, exactly as before.
|
||||
//! - H.265 (HEVC Main 8-bit 4:2:0 SDR BT.709, or Main 10 HDR: 10-bit 4:2:0, BT.2020, PQ or HLG, limited
|
||||
//! range; progressive): there is no software encoder, so the Hardware encoding toggle does not apply
|
||||
//! (as with VideoToolbox) and choosing the format is the opt-in. The export crate hands HDR pictures
|
||||
//! over (`EncoderFrame::hdr`) only where [`super::hevc_hdr_available`] said this GPU has a 10-bit
|
||||
//! encoder, otherwise an HDR sequence is tone-mapped to SDR as before. What NVENC cannot do
|
||||
//! (interlaced, two-pass, non-square pixels, 10-bit on a GPU without it, sizes outside its
|
||||
//! limits...) is counted as declined and ends the export with an error that says why; when this
|
||||
//! machine has no HEVC encoder at all, [`filmcraft_export::available`] says so and the factory
|
||||
//! returns `None` (the export's own "encoder not available" error).
|
||||
//!
|
||||
//! A hardware encoder that fails in the middle of an export (a lost device) ends the export with an
|
||||
//! error naming it; unlike a decoder it cannot be replayed into the software encoder, because the
|
||||
//! two write different streams.
|
||||
|
||||
use filmcraft_export::{
|
||||
BitrateMode, EncodedPacket, EncoderFrame, ExportError, ExportSettings, FieldOrder, Format, H264Pass, H264Profile, HardwareEncoding, Result, VideoEncoder,
|
||||
BitrateMode, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, FieldOrder, Format, H264Pass, H264Profile, HardwareEncoding, Result,
|
||||
VideoEncoder,
|
||||
};
|
||||
use filmcraft_isobmff::{AvcConfig, SampleEntry};
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
use super::{Config, NvencH264, Profile};
|
||||
use super::{Codec, Config, Nvenc, Profile, Sei, Signal};
|
||||
|
||||
/// The NVENC export encoder.
|
||||
struct NvencEncoder {
|
||||
enc: NvencH264,
|
||||
enc: Nvenc,
|
||||
w: u32,
|
||||
h: u32,
|
||||
rate: FrameRate,
|
||||
/// The stream's colour signal (`colr` / `mdcv` / `clli` on the sample entry for HDR).
|
||||
signal: ColorSignal,
|
||||
y: Vec<u8>,
|
||||
u: Vec<u8>,
|
||||
v: Vec<u8>,
|
||||
/// Main 10: the 10-bit planes of the current picture.
|
||||
y16: Vec<u16>,
|
||||
u16: Vec<u16>,
|
||||
v16: Vec<u16>,
|
||||
}
|
||||
|
||||
/// SEI payload types of the HDR10 static metadata (H.265 Annex D): mastering display colour volume
|
||||
/// and content light level information.
|
||||
const SEI_MASTERING_DISPLAY: u32 = 137;
|
||||
const SEI_CONTENT_LIGHT_LEVEL: u32 = 144;
|
||||
|
||||
/// The encoder's signal for an export's colour signal: the VUI code points and, for PQ, the HDR10
|
||||
/// static metadata as SEI messages (the same values and bytes the `mdcv` / `clli` boxes and the software
|
||||
/// H.264 encoder's SEI carry: [`ColorSignal::static_metadata`]). HLG has no static metadata.
|
||||
pub fn nvenc_signal(signal: &ColorSignal) -> Signal {
|
||||
let mut sei = Vec::new();
|
||||
if let Some((md, (max_cll, max_fall))) = signal.static_metadata() {
|
||||
sei.push(Sei { payload_type: SEI_MASTERING_DISPLAY, payload: md.to_bytes() });
|
||||
let mut cll = max_cll.to_be_bytes().to_vec();
|
||||
cll.extend_from_slice(&max_fall.to_be_bytes());
|
||||
sei.push(Sei { payload_type: SEI_CONTENT_LIGHT_LEVEL, payload: cll });
|
||||
}
|
||||
Signal { primaries: signal.primaries, transfer: signal.transfer, matrix: signal.matrix, sei }
|
||||
}
|
||||
|
||||
fn new_encoder(enc: Nvenc, w: u32, h: u32, rate: FrameRate, signal: ColorSignal) -> NvencEncoder {
|
||||
NvencEncoder { enc, w, h, rate, signal, y: Vec::new(), u: Vec::new(), v: Vec::new(), y16: Vec::new(), u16: Vec::new(), v16: Vec::new() }
|
||||
}
|
||||
|
||||
impl NvencEncoder {
|
||||
@@ -46,7 +86,17 @@ impl VideoEncoder for NvencEncoder {
|
||||
let (sps, pps) = self.enc.parameter_sets();
|
||||
// `config` declines sizes above u16::MAX
|
||||
let (w, h) = (u16::try_from(self.w).unwrap_or(u16::MAX), u16::try_from(self.h).unwrap_or(u16::MAX));
|
||||
SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w, h)
|
||||
match self.enc.codec() {
|
||||
Codec::H264 => SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w, h),
|
||||
Codec::Hevc => {
|
||||
// `Nvenc::new` built and validated the record, so there is always one for HEVC
|
||||
let mut e = SampleEntry::hevc(self.enc.hevc_config().cloned().unwrap_or_default(), w, h);
|
||||
// HDR: `colr` (BT.2020 + PQ / HLG) and, for PQ, `mdcv` / `clli`, as the software H.264 encoder does
|
||||
// (nclx, whatever the container: readers take it from either). SDR stays as it was, with the VUI only.
|
||||
self.signal.apply_to(&mut e, false);
|
||||
e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn timescale(&self) -> u32 {
|
||||
@@ -57,11 +107,22 @@ impl VideoEncoder for NvencEncoder {
|
||||
if f.width != self.w || f.height != self.h {
|
||||
return Err(ExportError::Encode(format!("NVENC: a {}x{} picture for a {}x{} encoder", f.width, f.height, self.w, self.h)));
|
||||
}
|
||||
filmcraft_export::rgba_to_yuv420_8(f.rgba, self.w as usize, self.h as usize, &mut self.y, &mut self.u, &mut self.v);
|
||||
let ps = self
|
||||
.enc
|
||||
.encode(&self.y, &self.u, &self.v, f.index)
|
||||
.map_err(|e| ExportError::Encode(format!("NVENC: {e} (turn Export ▸ Hardware encoding off to use the software encoder)")))?;
|
||||
let (w, h) = (self.w as usize, self.h as usize);
|
||||
let hint = if self.enc.codec() == Codec::H264 { " (turn Export ▸ Hardware encoding off to use the software encoder)" } else { "" };
|
||||
let ps = match (self.enc.is_ten_bit(), f.hdr) {
|
||||
// Main 10 takes the encoded R'G'B' floats: 10-bit limited-range BT.2020 NCL (or the signal's matrix)
|
||||
(true, Some(rgb)) => {
|
||||
let (kr, kb) = self.signal.kr_kb();
|
||||
filmcraft_export::rgbf_to_yuv420_10(rgb, w, h, kr, kb, &mut self.y16, &mut self.u16, &mut self.v16)?;
|
||||
self.enc.encode_10(&self.y16, &self.u16, &self.v16, f.index).map_err(|e| ExportError::Encode(format!("NVENC: {e}{hint}")))?
|
||||
}
|
||||
(true, None) => return Err(ExportError::Unsupported("NVENC Main 10 needs the HDR picture (EncoderFrame::hdr)".into())),
|
||||
(false, Some(_)) => return Err(ExportError::Unsupported("NVENC Main (8-bit) does not take HDR pictures".into())),
|
||||
(false, None) => {
|
||||
filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v);
|
||||
self.enc.encode(&self.y, &self.u, &self.v, f.index).map_err(|e| ExportError::Encode(format!("NVENC: {e}{hint}")))?
|
||||
}
|
||||
};
|
||||
filmcraft_export::note_hw_encode_frame();
|
||||
Ok(self.packets(ps))
|
||||
}
|
||||
@@ -77,17 +138,28 @@ impl VideoEncoder for NvencEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why NVENC does not take this export (the software encoder does), or the encoder's configuration.
|
||||
/// Why NVENC does not take this export (the software encoder does, for H.264), or the encoder's
|
||||
/// configuration.
|
||||
fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> std::result::Result<Config, String> {
|
||||
if format != Format::H264 || s.format.is_mxf() {
|
||||
return Err("only MP4 / MOV H.264 exports".into());
|
||||
let hevc = format == Format::Hevc;
|
||||
if !(format == Format::H264 || hevc) || s.format.is_mxf() {
|
||||
return Err("only MP4 / MOV H.264 and H.265 exports".into());
|
||||
}
|
||||
if s.signal.is_hdr() {
|
||||
let hdr = s.signal.is_hdr();
|
||||
if hdr && !hevc {
|
||||
return Err("HDR (8-bit H.264 here carries it with software signalling)".into());
|
||||
}
|
||||
if !matches!(s.h264_pass, H264Pass::Single) {
|
||||
// HEVC: BT.709 SDR (Main) or BT.2020 NCL with PQ / HLG (Main 10); any other description is not written
|
||||
if hevc && (hdr && (s.signal.primaries != 9 || s.signal.matrix != 9) || !hdr && s.signal != ColorSignal::default()) {
|
||||
return Err(format!("the colour signal {:?} (H.265 here is BT.709, or BT.2020 with PQ / HLG)", s.signal));
|
||||
}
|
||||
if !matches!(s.h264_pass, H264Pass::Single) || (hevc && s.bitrate_mode == BitrateMode::Vbr2Pass) {
|
||||
return Err("two-pass VBR".into());
|
||||
}
|
||||
// the hardware path writes the aspect ratio of H.264 only
|
||||
if hevc && s.pixel_aspect.is_some_and(|(n, d)| n != d) {
|
||||
return Err("non-square pixels".into());
|
||||
}
|
||||
if s.field_order != FieldOrder::Progressive {
|
||||
return Err("interlaced output".into());
|
||||
}
|
||||
@@ -110,12 +182,15 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
|
||||
max_bitrate_kbps: s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps / 2 * 3),
|
||||
cbr: s.bitrate_mode == BitrateMode::Cbr,
|
||||
keyint: s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (f64::from(fps.0) / f64::from(fps.1) * 2.0).round().max(1.0) as u32),
|
||||
profile: match s.h264_profile {
|
||||
H264Profile::Baseline => Profile::Baseline,
|
||||
H264Profile::Main => Profile::Main,
|
||||
H264Profile::High => Profile::High,
|
||||
profile: match (hevc, s.h264_profile) {
|
||||
(true, _) if hdr => Profile::HevcMain10,
|
||||
(true, _) => Profile::HevcMain,
|
||||
(false, H264Profile::Baseline) => Profile::Baseline,
|
||||
(false, H264Profile::Main) => Profile::Main,
|
||||
(false, H264Profile::High) => Profile::High,
|
||||
},
|
||||
level: s.h264_level,
|
||||
// the H.264 level setting means nothing for H.265: the encoder picks the level
|
||||
level: if hevc { None } else { s.h264_level },
|
||||
sar: s.pixel_aspect,
|
||||
bframes: true,
|
||||
})
|
||||
@@ -123,6 +198,9 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
|
||||
|
||||
/// The Export encoder factory (see the module documentation).
|
||||
pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
|
||||
if format == Format::Hevc {
|
||||
return hevc_factory(w, h, rate, s);
|
||||
}
|
||||
if s.hardware_encoding != HardwareEncoding::Auto || format != Format::H264 {
|
||||
return None;
|
||||
}
|
||||
@@ -135,10 +213,36 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
|
||||
Ok(c) => c,
|
||||
Err(why) => return declined(&why),
|
||||
};
|
||||
match NvencH264::new(&cfg) {
|
||||
match Nvenc::new(&cfg) {
|
||||
Ok(enc) => {
|
||||
filmcraft_export::note_hw_encode_session();
|
||||
Some(Ok(Box::new(NvencEncoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new() })))
|
||||
Some(Ok(Box::new(new_encoder(enc, w, h, rate, s.signal))))
|
||||
}
|
||||
Err(why) => declined(&why),
|
||||
}
|
||||
}
|
||||
|
||||
/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows, so the Hardware encoding toggle
|
||||
/// does not apply and a request NVENC cannot take is an error, not a fall-through.
|
||||
fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
|
||||
let declined = |why: &str| {
|
||||
log::info!("hardware H.265 encoding declined: {why}");
|
||||
filmcraft_export::note_hw_encode_declined();
|
||||
Some(Err(ExportError::Unsupported(format!("H.265 export with NVENC: {why}"))))
|
||||
};
|
||||
let cfg = match config(Format::Hevc, w, h, rate, s) {
|
||||
Ok(c) => c,
|
||||
Err(why) => return declined(&why),
|
||||
};
|
||||
match Nvenc::with_signal(&cfg, &nvenc_signal(&s.signal)) {
|
||||
Ok(enc) => {
|
||||
filmcraft_export::note_hw_encode_session();
|
||||
Some(Ok(Box::new(new_encoder(enc, w, h, rate, s.signal))))
|
||||
}
|
||||
// no HEVC encoder here at all: not a hardware attempt, the export's own "encoder not available" error
|
||||
Err(why) if !super::hevc_available() => {
|
||||
log::info!("no hardware H.265 encoder: {why}");
|
||||
None
|
||||
}
|
||||
Err(why) => declined(&why),
|
||||
}
|
||||
@@ -157,6 +261,72 @@ mod tests {
|
||||
assert_eq!(composition_offset(1 << 40, 0, 1), i32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_takes_what_nvenc_can_write_and_declines_the_rest() {
|
||||
let ok = ExportSettings { format: Format::Hevc, bitrate_kbps: 4000, keyframe_distance: Some(48), ..Default::default() };
|
||||
let c = config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &ok).unwrap();
|
||||
assert_eq!((c.profile, c.level, c.keyint, c.fps), (Profile::HevcMain, None, 48, (24, 1)));
|
||||
// the H.264 level and profile settings mean nothing to HEVC
|
||||
let h = ExportSettings { h264_level: Some(41), h264_profile: H264Profile::Baseline, ..ok.clone() };
|
||||
assert_eq!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &h).unwrap().level, None);
|
||||
// HDR (PQ / HLG, BT.2020) is Main 10, SDR stays Main; H.264 still declines HDR
|
||||
for signal in [ColorSignal::PQ, ColorSignal::HLG] {
|
||||
let hdr = ExportSettings { signal, ..ok.clone() };
|
||||
assert_eq!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &hdr).unwrap().profile, Profile::HevcMain10);
|
||||
assert!(config(Format::H264, 1280, 720, FrameRate::FPS_24, &ExportSettings { format: Format::H264, ..hdr }).is_err());
|
||||
}
|
||||
// the Hardware encoding toggle does not matter for HEVC (it has no software encoder)
|
||||
let off = ExportSettings { hardware_encoding: HardwareEncoding::Off, ..ok.clone() };
|
||||
assert!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &off).is_ok());
|
||||
for (what, s) in [
|
||||
("an unwritable colour signal", ExportSettings { signal: ColorSignal { primaries: 9, transfer: 1, matrix: 1 }, ..ok.clone() }),
|
||||
("HDR with BT.709 primaries", ExportSettings { signal: ColorSignal { primaries: 1, transfer: 16, matrix: 9 }, ..ok.clone() }),
|
||||
("two-pass", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }),
|
||||
("analysis pass", ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }),
|
||||
("interlaced", ExportSettings { field_order: FieldOrder::UpperFirst, ..ok.clone() }),
|
||||
("non-square pixels", ExportSettings { pixel_aspect: Some((4, 3)), ..ok.clone() }),
|
||||
("MXF", ExportSettings { format: Format::MxfOp1a, ..ok.clone() }),
|
||||
] {
|
||||
assert!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &s).is_err(), "{what}");
|
||||
}
|
||||
// hostile sizes and frame rates
|
||||
assert!(config(Format::Hevc, 70_000, 64, FrameRate::FPS_24, &ok).is_err());
|
||||
assert!(config(Format::Hevc, 64, 70_000, FrameRate::FPS_24, &ok).is_err());
|
||||
for rate in [FrameRate { num: 0, den: 1 }, FrameRate { num: 24, den: 0 }, FrameRate { num: -24, den: 1 }, FrameRate { num: i64::MAX, den: 1 }] {
|
||||
assert!(config(Format::Hevc, 1280, 720, rate, &ok).is_err(), "{rate:?}");
|
||||
}
|
||||
// other formats are not NVENC's
|
||||
assert!(config(Format::ProRes, 1280, 720, FrameRate::FPS_24, &ok).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sei_messages_are_the_hdr10_static_metadata_the_software_h264_path_writes() {
|
||||
let pq = nvenc_signal(&ColorSignal::PQ);
|
||||
assert_eq!((pq.primaries, pq.transfer, pq.matrix), (9, 16, 9));
|
||||
assert_eq!(pq.sei.len(), 2);
|
||||
// mastering display colour volume (137): the 24-byte payload of ST 2086, the `mdcv` box's bytes
|
||||
let (md, _) = ColorSignal::PQ.static_metadata().unwrap();
|
||||
assert_eq!((pq.sei[0].payload_type, pq.sei[0].payload.as_slice()), (137, md.to_bytes().as_slice()));
|
||||
assert_eq!(pq.sei[0].payload.len(), 24);
|
||||
// BT.2020 primaries (G, B, R order), D65, 1000 / 0.0001 cd/m2 in 0.0001 units
|
||||
assert_eq!(
|
||||
pq.sei[0].payload,
|
||||
[0x21, 0x34, 0x9B, 0xAA, 0x19, 0x96, 0x08, 0xFC, 0x8A, 0x48, 0x39, 0x08, 0x3D, 0x13, 0x40, 0x42, 0x00, 0x98, 0x96, 0x80, 0x00, 0x00, 0x00, 0x01]
|
||||
);
|
||||
// content light level (144): MaxCLL 0, MaxFALL 0 (unknown)
|
||||
assert_eq!((pq.sei[1].payload_type, pq.sei[1].payload.as_slice()), (144, &[0u8, 0, 0, 0][..]));
|
||||
// laid out as one SEI RBSP (type, size, payload, ...) these are the bytes the software H.264 encoder
|
||||
// writes for the same metadata (checked against its real output in the export crate's hdr_tests)
|
||||
let rbsp: Vec<u8> =
|
||||
pq.sei.iter().flat_map(|m| [m.payload_type as u8, m.payload.len() as u8].into_iter().chain(m.payload.iter().copied())).chain([0x80]).collect();
|
||||
assert_eq!(&rbsp[..2], &[137, 24]);
|
||||
assert_eq!(&rbsp[26..], &[144, 4, 0, 0, 0, 0, 0x80]);
|
||||
// HLG: the colour description only; SDR: BT.709, no messages
|
||||
let hlg = nvenc_signal(&ColorSignal::HLG);
|
||||
assert_eq!((hlg.primaries, hlg.transfer, hlg.matrix, hlg.sei.len()), (9, 18, 9, 0));
|
||||
assert_eq!(nvenc_signal(&ColorSignal::default()), Signal::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_pictures_are_declined() {
|
||||
let s = ExportSettings { format: Format::H264, hardware_encoding: HardwareEncoding::Auto, ..Default::default() };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user