mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 20:41:25 +00:00
Merge remote-tracking branch 'origin/main' into review-2162
This commit is contained in:
commit
7b5786fdb6
190 files changed
+14220
-996
No files matched your search
Generated
+89
-39
@@ -1094,6 +1094,16 @@ name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ecproj-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca1b5e2edb8568335c6c14f390307bd5beae8238#ca1b5e2edb8568335c6c14f390307bd5beae8238"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
@@ -1402,7 +1412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"craft-update",
|
||||
@@ -1428,7 +1438,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
@@ -1441,14 +1451,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
@@ -1467,7 +1477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1475,7 +1485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"effectcraft-color",
|
||||
@@ -1493,7 +1503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -1531,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
@@ -1545,6 +1555,7 @@ dependencies = [
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"effectcraft-vp9enc",
|
||||
"exr",
|
||||
"filmcraft-aac",
|
||||
"filmcraft-h264enc",
|
||||
"filmcraft-isobmff",
|
||||
@@ -1561,7 +1572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
@@ -1578,7 +1589,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1586,7 +1597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
@@ -1601,6 +1612,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
"rayon",
|
||||
"wasm-bindgen",
|
||||
"web-time",
|
||||
"wgpu",
|
||||
@@ -1608,14 +1620,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -1640,7 +1652,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1659,7 +1671,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1671,7 +1683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
@@ -1689,7 +1701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
@@ -1721,7 +1733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
@@ -1734,26 +1746,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-raster",
|
||||
"kurbo",
|
||||
"linesweeper",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"effectcraft-raster",
|
||||
@@ -1769,7 +1781,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1781,8 +1793,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-ecproj-format",
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1794,14 +1807,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1810,7 +1823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1832,7 +1845,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-sam2"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"candle-core",
|
||||
@@ -1847,7 +1860,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
@@ -1865,7 +1878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.9.1",
|
||||
"ndarray",
|
||||
@@ -1874,7 +1887,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
@@ -1886,7 +1899,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
@@ -1898,7 +1911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1906,7 +1919,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
@@ -1918,7 +1931,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
@@ -1954,11 +1967,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -5369,6 +5382,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -6098,6 +6122,20 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
@@ -6109,7 +6147,18 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7418,6 +7467,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -7575,7 +7625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"pkg-config",
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.0"
|
||||
version = "0.21.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/effectcraft"
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
# internal
|
||||
|
||||
@@ -27,6 +27,37 @@ The shared patched syntax source is centralized in storytold/craft-libs, transfe
|
||||
PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no
|
||||
Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
SIL Open Font License 1.1
|
||||
|
||||
Applies to Inter (assets/fonts/Inter-*.ttf), Noto Serif (assets/fonts/NotoSerif-Regular.ttf),
|
||||
|
||||
@@ -344,8 +344,17 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | **Motion graphics and visual effects · you are here** | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
mod project_lock;
|
||||
mod template;
|
||||
|
||||
use effectcraft_automation::tools::{self, Reply};
|
||||
@@ -508,6 +509,7 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
(None, Some(f)) => (std::fs::read_to_string(f).map_err(|e| Failure::Error(format!("cannot read {f}: {e}")))?, f.clone()),
|
||||
(None, None) => return usage_err("script needs a .jsx/.js file or --eval CODE"),
|
||||
};
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, false)?;
|
||||
let r = b.exec("script.run", json!({"code": code, "name": name}))?;
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
@@ -611,6 +613,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
(Some(p), _) | (None, Some(p)) => serde_json::from_str(p).map_err(|e| Failure::Usage(format!("params are not valid JSON: {e}")))?,
|
||||
(None, None) => json!({}),
|
||||
};
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, true)?;
|
||||
let r = b.exec(&id, params)?;
|
||||
if let Some(e) = failed_render(&id, &r) {
|
||||
@@ -654,6 +657,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
if steps.is_empty() {
|
||||
return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", "))));
|
||||
}
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, true)?;
|
||||
let mut results = vec![];
|
||||
// The step's index closes the message: a generated list repeats ids, and callers match
|
||||
@@ -690,7 +694,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
if let Some(e) = p.get("expression").and_then(Value::as_str) {
|
||||
extra += &format!(" expr: {e}");
|
||||
}
|
||||
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
|
||||
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), value_text(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -708,6 +712,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
let (comp, layer, path) = (args.pos[0].clone(), args.pos[1].clone(), args.pos[2].clone());
|
||||
let value = args.pos.get(3).map(|v| value_arg(v));
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, true)?;
|
||||
let a =
|
||||
json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "value": value, "time": args.num("--time")?, "expression": expr});
|
||||
@@ -1539,6 +1544,16 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A flat property's value for the text listing: an enum's number with the label `options` gives it
|
||||
/// (#2928), since the numbers are 0-based where After Effects scripting counts from 1.
|
||||
fn value_text(p: &Value) -> String {
|
||||
let label = p["options"].as_array().and_then(|o| o.iter().find(|o| o["value"] == p["value"])).and_then(|o| o["label"].as_str());
|
||||
match label {
|
||||
Some(l) => format!("{} ({l})", p["value"]),
|
||||
None => p["value"].to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! One saving run at a time per project file.
|
||||
//!
|
||||
//! A `--save` run reads the project, edits it and writes the whole file back, so two at once would
|
||||
//! silently drop the edits of whichever saved first (#2851). Each run holds an OS lock from before
|
||||
//! its read until it exits; the next one waits, then reads what the first one saved. This is how
|
||||
//! filmcraft-cli does it (storytold/filmcraft#3926).
|
||||
//!
|
||||
//! The lock is on a file next to the project, not on the project: a save replaces the project's
|
||||
//! inode, and on Windows a locked file can't be read by other processes. The OS drops the lock when
|
||||
//! the process ends, however it ends, so a crash leaves nothing stale. The file itself stays, since
|
||||
//! removing it would let one run wait on the old file while another locks a new one.
|
||||
|
||||
use crate::{Args, Failure};
|
||||
use std::fs::{File, TryLockError};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Most bytes of the project's file name kept in the lock's name; the rest of the 255-byte limit
|
||||
/// is left for the leading dot and `.lock`.
|
||||
const MAX_NAME_BYTES: usize = 200;
|
||||
|
||||
fn lock_path(project: &Path) -> PathBuf {
|
||||
let name = project.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
project.with_file_name(format!(".{}.lock", &name[..name.floor_char_boundary(MAX_NAME_BYTES)]))
|
||||
}
|
||||
|
||||
/// Do two paths name one existing file?
|
||||
fn same_file(a: &str, b: &str) -> bool {
|
||||
a == b || matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
|
||||
}
|
||||
|
||||
/// The path this run saves over, when that is the file it opens. `--save-as` somewhere else replaces
|
||||
/// that file whatever it held, so there is no edit of its to lose.
|
||||
///
|
||||
/// The lock goes by this path as written, not by where a symlink points: runs that name the
|
||||
/// project the same way share one lock.
|
||||
///
|
||||
/// A missing project is an error here, before anything else, the same one opening it would give.
|
||||
/// If the run went on, another one could create the project before it is read, and this run would
|
||||
/// edit and save it with no lock. Checking first also means the paths compared below exist, so
|
||||
/// `canonicalize` tells exactly whether they are one file, however they are spelt. A project that
|
||||
/// exists but isn't a file gets no lock file beside it, and opening it reports why.
|
||||
fn saved_in_place(a: &Args) -> Result<Option<&Path>, String> {
|
||||
// `--bridge` with a project is a usage error, which `backend` reports
|
||||
let Some(project) = a.project.as_deref().filter(|_| a.opt("--bridge").is_none()) else { return Ok(None) };
|
||||
match Path::new(project).metadata() {
|
||||
Ok(meta) if meta.is_file() => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("cannot read {project}: {e}")),
|
||||
_ => return Ok(None),
|
||||
}
|
||||
match a.opt("--save-as") {
|
||||
Some(target) if same_file(target, project) => Ok(Some(Path::new(target))),
|
||||
None if a.flag("--save") => Ok(Some(Path::new(project))),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock `project`, calling `waiting` first if another run holds the lock.
|
||||
fn lock(project: &Path, waiting: impl FnOnce()) -> std::io::Result<File> {
|
||||
let file = File::options().create(true).truncate(false).read(true).write(true).open(lock_path(project))?;
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
waiting();
|
||||
file.lock()?;
|
||||
}
|
||||
Err(TryLockError::Error(e)) => return Err(e),
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Take the project's lock if this run saves over the file it opens, or fail if the project is
|
||||
/// missing. Call it before opening the project and keep the result until the save is done. Blocks
|
||||
/// while another run holds the lock, which is fine here: nothing else is running yet. A lock that
|
||||
/// can't be had (a read-only folder, a file system without locks) is a warning and not an error, so
|
||||
/// that saving works where it did.
|
||||
pub(crate) fn for_save(a: &Args) -> Result<Option<File>, Failure> {
|
||||
let Some(project) = saved_in_place(a).map_err(Failure::Error)? else { return Ok(None) };
|
||||
let shown = project.display();
|
||||
let waiting = || {
|
||||
let _ = writeln!(std::io::stderr(), "effectcraft-cli: waiting for another run saving {shown}");
|
||||
};
|
||||
match lock(project, waiting) {
|
||||
Ok(file) => Ok(Some(file)),
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "effectcraft-cli: warning: can't lock {shown} ({e}); another run saving it now could overwrite these edits");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(list: &[&str]) -> Args {
|
||||
Args::parse(list.iter().map(|s| s.to_string()).collect()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lock_sits_next_to_the_project() {
|
||||
assert_eq!(lock_path(Path::new("/a/b/p.ecproj")), Path::new("/a/b/.p.ecproj.lock"));
|
||||
assert_eq!(lock_path(Path::new("p.ecproj")), Path::new(".p.ecproj.lock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_project_name_still_gets_a_lock_name_the_file_system_takes() {
|
||||
for name in ["a".repeat(255), "é".repeat(127)] {
|
||||
let lock = lock_path(Path::new(&name));
|
||||
let lock = lock.to_str().unwrap();
|
||||
assert!(lock.len() <= 255, "{} bytes", lock.len());
|
||||
assert!(lock.ends_with(".lock"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The moment between this check and opening the project can't be hit on demand, so this pins
|
||||
/// the decision that closes it: a run never goes on to open a project that wasn't there, however
|
||||
/// its paths are spelt and whatever it saves.
|
||||
#[test]
|
||||
fn a_missing_project_stops_the_run_before_anything_opens_it() {
|
||||
let name = format!("ec-cli-gone-{}.ecproj", std::process::id());
|
||||
let gone = std::env::temp_dir().join(&name);
|
||||
let gone = gone.to_str().unwrap();
|
||||
let alias = std::env::temp_dir().join(".").join(&name);
|
||||
let alias = alias.to_str().unwrap();
|
||||
let dotted = format!("./{name}");
|
||||
// in a folder that doesn't exist either, so no part of either spelling can be resolved
|
||||
let new_dir = std::env::temp_dir().join(format!("ec-cli-new-dir-{}", std::process::id()));
|
||||
let in_new_dir = new_dir.join("p.ecproj");
|
||||
let in_new_dir = in_new_dir.to_str().unwrap();
|
||||
let in_new_dir_alias = new_dir.join(".").join("p.ecproj");
|
||||
let in_new_dir_alias = in_new_dir_alias.to_str().unwrap();
|
||||
for (project, list) in [
|
||||
(gone, &["exec", "--project", gone, "--save"][..]),
|
||||
(gone, &["exec", gone, "--save"]),
|
||||
(gone, &["exec", "--project", gone, "--save-as", gone]),
|
||||
(gone, &["exec", "--project", gone, "--save-as", alias]),
|
||||
(name.as_str(), &["exec", "--project", &name, "--save-as", &dotted]),
|
||||
(in_new_dir, &["exec", "--project", in_new_dir, "--save-as", in_new_dir_alias]),
|
||||
(gone, &["exec", "--project", gone, "--save-as", "other.ecproj"]),
|
||||
(gone, &["exec", "--project", gone]),
|
||||
] {
|
||||
let error = saved_in_place(&args(list)).unwrap_err();
|
||||
assert_eq!(error.split_once(": ").map(|(head, _)| head), Some(format!("cannot read {project}").as_str()), "{list:?}: {error}");
|
||||
}
|
||||
// a project with --bridge is a usage error, which `backend` reports; no project, no lock
|
||||
assert_eq!(saved_in_place(&args(&["exec", "--bridge", "9877", "--project", gone, "--save"])), Ok(None));
|
||||
assert_eq!(saved_in_place(&args(&["exec", "--demo", "--save-as", gone])), Ok(None));
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,197 @@ fn exec_set_get_with_saved_project() {
|
||||
assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}");
|
||||
}
|
||||
|
||||
/// #2928: the text listing gives an enum's label next to its number (0-based, unlike After
|
||||
/// Effects scripting), the way `props --json` lists its `options`.
|
||||
#[test]
|
||||
fn props_lists_enum_labels() {
|
||||
let proj = tmp("enum-labels.ecproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
let anim = r#"{"layer":"t","properties":["opacity"]}"#;
|
||||
ok_json(&[
|
||||
"run",
|
||||
"comp.new",
|
||||
r#"{"name":"T","width":64,"height":36}"#,
|
||||
"layer.newText",
|
||||
r#"{"text":"AB","name":"t"}"#,
|
||||
"layer.addTextAnimator",
|
||||
anim,
|
||||
"--empty",
|
||||
"--save-as",
|
||||
p,
|
||||
]);
|
||||
ok_json(&["set", "T", "t", "text/animators/animator/selectors/rangeSelector/advanced/shape", "1", p, "--save"]);
|
||||
let out = bin().args(["props", "T", "t", p]).output().unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let shape = text.lines().find(|l| l.contains("advanced/shape")).unwrap_or_else(|| panic!("no shape line in:\n{text}"));
|
||||
assert!(shape.trim_end().ends_with("enum 1 (Ramp Up)"), "{shape}");
|
||||
let units = text.lines().find(|l| l.contains("advanced/units")).unwrap_or_default();
|
||||
assert!(units.trim_end().ends_with("0 (Percentage)"), "{units}");
|
||||
let size = text.lines().find(|l| l.contains("transform/opacity")).unwrap_or_default();
|
||||
assert!(size.trim_end().ends_with("100.0"), "a number without options stays bare: {size}");
|
||||
}
|
||||
|
||||
/// A project in a new folder of its own, for the save lock tests (#2851).
|
||||
fn lock_test_project(tag: &str) -> (PathBuf, String) {
|
||||
let dir = tmp(&format!("lock-{tag}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let proj = dir.join("p.ecproj").to_str().unwrap().to_string();
|
||||
ok_json(&["exec", "project.newFolder", r#"{"name":"start"}"#, "--empty", "--save-as", &proj]);
|
||||
(dir, proj)
|
||||
}
|
||||
|
||||
/// The lock of the project file `name`, held by this process.
|
||||
fn hold_lock(dir: &std::path::Path, name: &str) -> std::fs::File {
|
||||
let lock = std::fs::File::options().create(true).truncate(false).read(true).write(true).open(dir.join(format!(".{name}.lock"))).unwrap();
|
||||
lock.lock().unwrap();
|
||||
lock
|
||||
}
|
||||
|
||||
/// Start a run that has to wait for a lock this process holds; returns once it says it is waiting.
|
||||
fn start_waiting_run(args: &[&str]) -> std::process::Child {
|
||||
let mut child = bin().args(args).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut stderr = BufReader::new(child.stderr.take().unwrap());
|
||||
let mut line = String::new();
|
||||
while !line.contains("waiting for another run saving") {
|
||||
line.clear();
|
||||
assert_ne!(stderr.read_line(&mut line).unwrap(), 0, "{args:?} ended without waiting");
|
||||
}
|
||||
assert!(child.try_wait().unwrap().is_none(), "{args:?} did not wait");
|
||||
child
|
||||
}
|
||||
|
||||
/// Folder names in a saved project.
|
||||
fn has_folder(proj: &str, name: &str) -> bool {
|
||||
std::fs::read_to_string(proj).unwrap().contains(&format!("\"{name}\""))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_saves_keep_every_edit() {
|
||||
let (dir, proj) = lock_test_project("parallel");
|
||||
let names = |writer: usize| (0..300).map(move |i| format!("edit-{writer}-{i}"));
|
||||
let mut writers: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
bin()
|
||||
.args(["run", "--project", &proj, "--commands", "-", "--save"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
// Each writer reads its whole script before it opens the project, so closing the stdins together
|
||||
// starts all the edits at once, and long scripts keep each read-to-save span over the others.
|
||||
for (n, writer) in writers.iter_mut().enumerate() {
|
||||
let script: String = names(n).map(|name| format!("{}\n", json!({"command": "project.newFolder", "params": {"name": name}}))).collect();
|
||||
writer.stdin.take().unwrap().write_all(script.as_bytes()).unwrap();
|
||||
}
|
||||
for mut writer in writers {
|
||||
assert!(writer.wait().unwrap().success());
|
||||
}
|
||||
let lost: Vec<String> = (0..4).flat_map(names).filter(|name| !has_folder(&proj, name)).collect();
|
||||
assert!(lost.is_empty(), "lost {} edits: {lost:?}", lost.len());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_in_place_waits_for_the_lock_then_edits_the_latest_project() {
|
||||
let (dir, proj) = lock_test_project("wait");
|
||||
for save in [&["--save"][..], &["--save-as", proj.as_str()]] {
|
||||
let held = hold_lock(&dir, "p.ecproj");
|
||||
let before = std::fs::read(&proj).unwrap();
|
||||
let params = json!({"name": format!("waited-{}", save.len())}).to_string();
|
||||
let args = [&["exec", "project.newFolder", ¶ms, "--project", &proj][..], save].concat();
|
||||
let mut child = start_waiting_run(&args);
|
||||
assert_eq!(std::fs::read(&proj).unwrap(), before, "{save:?} wrote while another run held the lock");
|
||||
// an edit made meanwhile by the holder must survive: the waiter reads only after the lock is free
|
||||
let held_params = json!({"name": format!("held-{}", save.len())}).to_string();
|
||||
ok_json(&["exec", "project.newFolder", &held_params, "--project", &proj, "--save-as", &format!("{proj}.tmp")]);
|
||||
std::fs::rename(format!("{proj}.tmp"), &proj).unwrap();
|
||||
drop(held);
|
||||
assert!(child.wait().unwrap().success(), "{save:?}");
|
||||
for want in [format!("waited-{}", save.len()), format!("held-{}", save.len())] {
|
||||
assert!(has_folder(&proj, &want), "{save:?} lost {want}");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Runs through a symlink take the same lock before and after a save, or a run still waiting on
|
||||
/// the old one could overwrite a later run's save.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlinked_project_keeps_its_lock_after_a_save() {
|
||||
let (dir, proj) = lock_test_project("symlink");
|
||||
let link = dir.join("link.ecproj");
|
||||
std::os::unix::fs::symlink(&proj, &link).unwrap();
|
||||
for name in [r#"{"name":"first"}"#, r#"{"name":"second"}"#] {
|
||||
let held = hold_lock(&dir, "link.ecproj");
|
||||
let mut child = start_waiting_run(&["exec", "project.newFolder", name, "--project", link.to_str().unwrap(), "--save"]);
|
||||
drop(held);
|
||||
assert!(child.wait().unwrap().success(), "{name}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_that_do_not_save_in_place_ignore_the_lock() {
|
||||
let (dir, proj) = lock_test_project("ignore");
|
||||
let other = dir.join("other.ecproj").to_str().unwrap().to_string();
|
||||
let _held = hold_lock(&dir, "p.ecproj");
|
||||
let params = r#"{"name":"copy-only"}"#;
|
||||
let cases: [&[&str]; 3] = [
|
||||
&["info", "--project", &proj],
|
||||
&["exec", "project.newFolder", params, "--project", &proj],
|
||||
&["exec", "project.newFolder", params, "--project", &proj, "--save-as", &other],
|
||||
];
|
||||
for args in cases {
|
||||
let mut child = bin().args(args).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("{args:?} is waiting for a lock it doesn't need");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
};
|
||||
assert!(status.success(), "{args:?}");
|
||||
}
|
||||
assert!(!has_folder(&proj, "copy-only"));
|
||||
assert!(has_folder(&other, "copy-only"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_project_gets_no_lock_file_and_keeps_its_error() {
|
||||
let (dir, _) = lock_test_project("missing");
|
||||
let gone = dir.join("gone.ecproj").to_str().unwrap().to_string();
|
||||
let other = dir.join("other.ecproj").to_str().unwrap().to_string();
|
||||
// what opening it says, after the `effectcraft-cli <subcommand>: ` prefix
|
||||
let error = |args: &[&str]| {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{args:?}");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
stderr.split_once(": ").map(|(_, e)| e.to_string()).unwrap_or(stderr)
|
||||
};
|
||||
let plain = error(&["info", "--project", &gone]);
|
||||
assert!(plain.contains("gone.ecproj"), "{plain}");
|
||||
// saving in place, saving elsewhere, or not saving: the error is the one opening gives
|
||||
for save in [&["--save"][..], &["--save-as", gone.as_str()], &["--save-as", other.as_str()], &[]] {
|
||||
let args = [&["exec", "project.newFolder", r#"{"name":"copy-only"}"#, "--project", &gone][..], save].concat();
|
||||
assert_eq!(error(&args), plain, "{save:?}");
|
||||
}
|
||||
assert!(!dir.join(".gone.ecproj.lock").exists());
|
||||
assert!(!dir.join("other.ecproj").exists());
|
||||
// a project with --bridge stays a usage error
|
||||
assert_eq!(bin().args(["exec", "project.newFolder", "--bridge", "9877", "--project", &gone, "--save"]).output().unwrap().status.code(), Some(2));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer
|
||||
/// than a Windows command line (32 767 characters, os error 206) still runs.
|
||||
#[test]
|
||||
@@ -136,7 +327,14 @@ fn run_reads_arguments_from_a_file() {
|
||||
/// Run with `stdin` piped in; returns (exit code, stdout, stderr).
|
||||
fn run_with_stdin(args: &[&str], stdin: &str) -> (i32, String, String) {
|
||||
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap();
|
||||
// The child may exit before it has read all of standard input (the usage-error cases below
|
||||
// exit with code 2 without draining stdin), so a broken pipe while writing is that early
|
||||
// exit, not a failure of this helper; the assertions on the returned output still hold.
|
||||
let mut stdin_pipe = child.stdin.take().unwrap();
|
||||
if let Err(err) = stdin_pipe.write_all(stdin.as_bytes()) {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe, "writing the child's standard input: {err}");
|
||||
}
|
||||
drop(stdin_pipe);
|
||||
let out = child.wait_with_output().unwrap();
|
||||
(out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned())
|
||||
}
|
||||
|
||||
@@ -213,8 +213,24 @@ fn install_hooks(app: &mut EffectcraftApp) {
|
||||
}
|
||||
None
|
||||
}));
|
||||
// Saving downloads the file: the chosen name is all a "save dialog" needs.
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name }))));
|
||||
// Saving downloads the file: the chosen name is all a "save dialog" needs. A project (Save As,
|
||||
// Save a Copy) asks for the name, starting from the open project's (#2788): without asking,
|
||||
// Save As would replace the open project's file; Cancel cancels. A name that is already in
|
||||
// browser storage asks before replacing it, as the desktop's save dialogs do.
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| {
|
||||
let name = if name.is_empty() { "Untitled Project.ecproj" } else { name };
|
||||
if !std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("ecproj") || e.eq_ignore_ascii_case("ecprojx")) {
|
||||
return Some(files::path_for(name));
|
||||
}
|
||||
let window = web_sys::window()?;
|
||||
let typed = window.prompt_with_message_and_default("Save project as:", name).ok().flatten();
|
||||
let typed = crate::store::typed_save_name(name, typed)?;
|
||||
let path = files::path_for(&typed);
|
||||
if files::get(&path).is_some() && !window.confirm_with_message(&format!("{typed} already exists. Replace it?")).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
Some(path)
|
||||
}));
|
||||
app.hooks.audio_device = Some(Box::new(audio::open));
|
||||
app.hooks.audio_devices = Some(Box::new(|| vec!["Browser default output".into()]));
|
||||
}
|
||||
|
||||
@@ -152,6 +152,24 @@ pub fn key_path(key: &str) -> Option<&str> {
|
||||
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
|
||||
}
|
||||
|
||||
/// The project file name typed in the web's Save As prompt (#2788): `None` when it was cancelled or
|
||||
/// left empty; folders are dropped (the file table is flat) and a name without the default's
|
||||
/// extension gets it (`Intro copy` → `Intro copy.ecproj`, `Intro.v2` → `Intro.v2.ecproj`), so the
|
||||
/// browser still opens and keeps it as a project.
|
||||
pub fn typed_save_name(default: &str, typed: Option<String>) -> Option<String> {
|
||||
let typed = typed?;
|
||||
let name = typed.trim().rsplit(['/', '\\']).next().unwrap_or("").trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ext = Path::new(default).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if ext.is_empty() || Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case(&ext)) {
|
||||
Some(name.to_string())
|
||||
} else {
|
||||
Some(format!("{name}.{ext}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write.
|
||||
fn content_hash(data: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
@@ -436,6 +454,20 @@ pub struct SnapshotMeta {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn typed_save_names() {
|
||||
let t = |s: &str| Some(s.to_string());
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t("Intro copy")).as_deref(), Some("Intro copy.ecproj"));
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t(" Other.ecproj ")).as_deref(), Some("Other.ecproj"));
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t("a/b\\Shot.ecproj")).as_deref(), Some("Shot.ecproj"));
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t("Intro.v2")).as_deref(), Some("Intro.v2.ecproj"));
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t("Intro.ECPROJ")).as_deref(), Some("Intro.ECPROJ"));
|
||||
assert_eq!(typed_save_name("Intro.ecprojx", t("Intro")).as_deref(), Some("Intro.ecprojx"));
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t(" ")), None);
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t("folder/")), None);
|
||||
assert_eq!(typed_save_name("Intro.ecproj", None), None);
|
||||
}
|
||||
|
||||
fn store() -> Arc<Store> {
|
||||
let s = Arc::new(Store::default());
|
||||
let _ = s.clock.set(|| 1_000.0);
|
||||
|
||||
@@ -27,6 +27,9 @@ cpal = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Native GNOME/Wayland controls without a bundled title font.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
|
||||
# Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in
|
||||
# Cargo.lock (accesskit, through eframe), so it adds no new crates.
|
||||
zbus = "5.19"
|
||||
|
||||
@@ -123,3 +123,7 @@ sync_github_name = true
|
||||
real_name = "Arshiya Rahgozar"
|
||||
display_name = "Arshiya"
|
||||
sync_github_name = false
|
||||
|
||||
[people.JonRC]
|
||||
real_name = "Jonathan Cardoso"
|
||||
display_name = "Jonathan Cardoso"
|
||||
@@ -52,11 +52,28 @@ pub struct EncoderConfig {
|
||||
pub keyint: u32,
|
||||
/// BT.709 limited range is signalled unless this is set.
|
||||
pub full_range: bool,
|
||||
/// The colour description of `color_config` (ITU-T H.273 code points); BT.709 by default.
|
||||
pub color_primaries: u8,
|
||||
pub transfer_characteristics: u8,
|
||||
pub matrix_coefficients: u8,
|
||||
}
|
||||
|
||||
impl EncoderConfig {
|
||||
pub fn new(width: u32, height: u32, fps_num: u32, fps_den: u32) -> Self {
|
||||
EncoderConfig { width, height, fps_num, fps_den, bit_depth: 8, level_idx: None, rate: RateControl::ConstantQ(100), keyint: 60, full_range: false }
|
||||
EncoderConfig {
|
||||
width,
|
||||
height,
|
||||
fps_num,
|
||||
fps_den,
|
||||
bit_depth: 8,
|
||||
level_idx: None,
|
||||
rate: RateControl::ConstantQ(100),
|
||||
keyint: 60,
|
||||
full_range: false,
|
||||
color_primaries: 1,
|
||||
transfer_characteristics: 1,
|
||||
matrix_coefficients: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,9 +381,9 @@ impl Encoder {
|
||||
w.f(1, (c.bit_depth > 8) as u32); // high_bitdepth
|
||||
w.f(1, 0); // mono_chrome
|
||||
w.f(1, 1); // color_description_present_flag
|
||||
w.f(8, 1); // color_primaries: BT.709
|
||||
w.f(8, 1); // transfer_characteristics: BT.709
|
||||
w.f(8, 1); // matrix_coefficients: BT.709
|
||||
w.f(8, c.color_primaries as u32); // color_primaries
|
||||
w.f(8, c.transfer_characteristics as u32); // transfer_characteristics
|
||||
w.f(8, c.matrix_coefficients as u32); // matrix_coefficients
|
||||
w.f(1, c.full_range as u32); // color_range
|
||||
w.f(2, 0); // chroma_sample_position: unknown
|
||||
w.f(1, 0); // separate_uv_delta_q
|
||||
|
||||
@@ -104,12 +104,12 @@ pub use generate3::{
|
||||
};
|
||||
pub use noise::GrainLook;
|
||||
pub use textfx::{BoltPlan, TextLook, TextPass, bolt_plan, text_passes};
|
||||
pub use transition::place_layer;
|
||||
pub use transition::{GridWipe, place_layer};
|
||||
// effectcraft-gpu fx_noise.
|
||||
pub use keying2::{InnerOuterPlan, inner_outer_plan};
|
||||
pub use noise::remove_grain_level;
|
||||
pub use noise3::{FractalGpu, NOISE_CELL, fractal_gpu};
|
||||
pub use stylize3::{BumpLight, bump_layer_id};
|
||||
pub use stylize3::{BumpLight, HexTile, bump_layer_id};
|
||||
pub use textfx::{TextLayer, text_layer};
|
||||
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan};
|
||||
// effectcraft-gpu fx_tone.
|
||||
|
||||
@@ -254,8 +254,14 @@ pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) {
|
||||
});
|
||||
}
|
||||
|
||||
/// How far the grain statistics read around a sample box: their high-pass subtracts a Gaussian
|
||||
/// of sigma 1.5, three box passes of radius 1 (`noise2::grain_stats_boxes`).
|
||||
const SAMPLE_REACH: usize = 3;
|
||||
|
||||
/// Boxes (x, y, size in buffer px) where grain is sampled: the flattest of a grid of
|
||||
/// candidate boxes (real grain shows best where the picture has no detail).
|
||||
/// candidate boxes (real grain shows best where the picture has no detail). A box and the
|
||||
/// [`SAMPLE_REACH`] px around it are opaque: a transparent pixel there is black once
|
||||
/// unpremultiplied, and the high-pass would measure that edge as grain.
|
||||
pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize, usize, usize)> {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let size = size.clamp(3, w.min(h).max(3));
|
||||
@@ -264,6 +270,19 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize
|
||||
}
|
||||
let low = Plane::luma(img);
|
||||
let step = (size / 2).max(1);
|
||||
// Transparent pixels (alpha 0.5 or less) in a summed-area table: `sat[y * (w + 1) + x]`
|
||||
// counts them above and left of (x, y).
|
||||
let sw = w + 1;
|
||||
let mut sat = vec![0usize; sw * (h + 1)];
|
||||
for y in 0..h {
|
||||
let mut run = 0;
|
||||
for x in 0..w {
|
||||
let opaque = img.data[y * w + x][3] > 0.5;
|
||||
run += usize::from(!opaque);
|
||||
sat[(y + 1) * sw + x + 1] = sat[y * sw + x + 1] + run;
|
||||
}
|
||||
}
|
||||
let sat = sat.as_slice();
|
||||
// Candidate rows in parallel; the sort below is a total order (variance, y, x), so the
|
||||
// result does not depend on the collection order.
|
||||
let rows: Vec<usize> = (0..=h - size).step_by(step).collect();
|
||||
@@ -271,20 +290,23 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize
|
||||
.par_iter()
|
||||
.flat_map_iter(|&y| {
|
||||
let data = low.data.as_slice();
|
||||
let alpha = img.data.as_slice();
|
||||
(0..=w - size).step_by(step).filter_map(move |x| {
|
||||
let (x0, y0) = (x.saturating_sub(SAMPLE_REACH), y.saturating_sub(SAMPLE_REACH));
|
||||
let (x1, y1) = ((x + size + SAMPLE_REACH).min(w), (y + size + SAMPLE_REACH).min(h));
|
||||
if sat[y1 * sw + x1] + sat[y0 * sw + x0] != sat[y0 * sw + x1] + sat[y1 * sw + x0] {
|
||||
return None;
|
||||
}
|
||||
// Variance of the picture inside the box (the flattest boxes hold the least detail).
|
||||
let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true);
|
||||
let (mut s, mut sq, mut n) = (0.0f32, 0.0f32, 0.0f32);
|
||||
for yy in y..y + size {
|
||||
for xx in x..x + size {
|
||||
let v = data[yy * w + xx];
|
||||
s += v;
|
||||
sq += v * v;
|
||||
n += 1.0;
|
||||
opaque &= alpha[yy * w + xx][3] > 0.5;
|
||||
}
|
||||
}
|
||||
opaque.then(|| (sq / n - (s / n).powi(2), x, y))
|
||||
Some((sq / n - (s / n).powi(2), x, y))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -190,15 +190,14 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b.img = img;
|
||||
return b;
|
||||
}
|
||||
let src_stats = grain_stats_boxes(&source, &src_boxes);
|
||||
// No box with an opaque surround (a small layer inside transparent pixels) means no clean
|
||||
// sample: no grain to match, as After Effects applies none when its samples hold transparent
|
||||
// areas. The whole-image fallback of `grain_stats_boxes` would measure the edge again.
|
||||
let clean_stats = |img: &Image, boxes: &[(usize, usize, usize)]| if boxes.is_empty() { GrainStats::default() } else { grain_stats_boxes(img, boxes) };
|
||||
let src_stats = clean_stats(&source, &src_boxes);
|
||||
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
|
||||
// The own-layer boxes only matter for the compensation term; skip them when it is off.
|
||||
let own = if comp > 0.0 {
|
||||
let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
|
||||
grain_stats_boxes(&b.img, &own_boxes)
|
||||
} else {
|
||||
GrainStats::default()
|
||||
};
|
||||
let own = if comp > 0.0 { clean_stats(&b.img, &sampling_boxes(ctx, &b.img, b.scale)) } else { GrainStats::default() };
|
||||
let orig = (view == 0).then(|| b.img.clone());
|
||||
let look = GrainLook::from_params(ctx);
|
||||
let amp: [f32; 3] = [0, 1, 2].map(|k| {
|
||||
@@ -464,6 +463,54 @@ mod tests {
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
}
|
||||
|
||||
/// #2919: a sample box beside transparent pixels measured the edge as grain. Its own
|
||||
/// pixels are opaque, but the high-pass blur reads 3 px around it, where transparent pixels
|
||||
/// are black once unpremultiplied. An earlier blur pads the layer with transparent pixels,
|
||||
/// so a clean plate got blocky colour grain on the frames whose flattest boxes sat at the
|
||||
/// edge, and fine grain on the others.
|
||||
#[test]
|
||||
fn match_grain_ignores_the_edge_of_a_transparent_border() {
|
||||
// A flat colour inside the 2 px transparent frame of a padded buffer.
|
||||
let framed = |w: u32, h: u32, c: [f32; 4]| {
|
||||
let mut img = Image::filled(w, h, c);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 {
|
||||
img.set(x, y, [0.0; 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
img
|
||||
};
|
||||
let most_change = |a: &Image, b: &Image| a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] - q[k]).abs())).fold(0.0f32, f32::max);
|
||||
// A noiseless sky. At 16 px no 9 px box has an opaque 3 px surround, so there is no
|
||||
// clean sample at all (the whole-image fallback would measure the edge).
|
||||
for (w, h, clean_boxes) in [(64u32, 48u32, true), (16, 16, false)] {
|
||||
let sky = framed(w, h, [0.4, 0.6, 0.9, 1.0]);
|
||||
let boxes = crate::noise::sample_boxes(&sky, 80, 9);
|
||||
assert_eq!(!boxes.is_empty(), clean_boxes, "{w}x{h}: {boxes:?}");
|
||||
if clean_boxes {
|
||||
let st = grain_stats_boxes(&sky, &boxes);
|
||||
assert!(st.std.iter().all(|s| *s < 1e-5), "{w}x{h}: {st:?} from {boxes:?}");
|
||||
}
|
||||
// So Match Grain finds no grain to add.
|
||||
let host = FakeHost(sky);
|
||||
let env = EffectEnv { host: Some(&host), ..Default::default() };
|
||||
let target = Image::filled(w, h, [0.2, 0.3, 0.5, 1.0]);
|
||||
let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], target.clone(), 0.5, env);
|
||||
let most = most_change(&out.img, &target);
|
||||
assert!(most < 1e-5, "{w}x{h}: Match Grain added {most}");
|
||||
}
|
||||
// Compensate for Existing Noise has no clean sample of such a layer either, so its edge
|
||||
// doesn't cancel the source's grain.
|
||||
let host = FakeHost(noisy(16, 16, 0.1, 3));
|
||||
let env = EffectEnv { host: Some(&host), ..Default::default() };
|
||||
let target = framed(16, 16, [0.9, 0.9, 0.9, 1.0]);
|
||||
let params = [("noiseSourceLayer", Value::Layer(Some(7))), ("compensateForExistingNoise", crate::num(100.0))];
|
||||
let out = run_fx("ec.noise.matchgrain", ¶ms, target.clone(), 0.5, env);
|
||||
assert!(most_change(&out.img, &target) > 0.01, "the layer's own edge cancelled the grain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_legacy_radius_zero_identity_and_removes_speck() {
|
||||
let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]);
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn parse_spi1d(text: &str) -> Option<Lut> {
|
||||
}
|
||||
}
|
||||
// A 1D table is interpolated by index, so cap it like the lattices (2²⁴ entries).
|
||||
if !(2..=1 << 24).contains(&len)
|
||||
if !(2..=MAX_1D).contains(&len)
|
||||
|| !matches!(comps, 1 | 3)
|
||||
|| values.len() != len.checked_mul(comps)?
|
||||
|| from[1] <= from[0]
|
||||
@@ -79,7 +79,7 @@ pub fn parse_spi3d(text: &str) -> Option<Lut> {
|
||||
lines.next()?;
|
||||
let size: Vec<usize> = lines.next()?.split_whitespace().map(|t| t.parse().ok()).collect::<Option<_>>()?;
|
||||
let n = *size.first()?;
|
||||
if size.len() != 3 || size.iter().any(|s| *s != n) || !(2..=129).contains(&n) {
|
||||
if size.len() != 3 || size.iter().any(|s| *s != n) || !(2..=MAX_LATTICE).contains(&n) {
|
||||
return None;
|
||||
}
|
||||
let mut d3 = vec![[0.0f32; 3]; n * n * n];
|
||||
@@ -154,6 +154,11 @@ pub fn parse_3dl(text: &str) -> Option<Lut> {
|
||||
Some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] })
|
||||
}
|
||||
|
||||
/// Largest lattice side the lattice readers accept (`.spi3d`, `.csp`).
|
||||
const MAX_LATTICE: usize = 129;
|
||||
/// Most entries of a 1D table (interpolated by index, so capped like the lattices: 2²⁴).
|
||||
const MAX_1D: usize = 1 << 24;
|
||||
|
||||
/// Parse a cineSpace `.csp` LUT (1D or 3D, with its three pre-LUT shapers).
|
||||
pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> {
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
@@ -185,8 +190,15 @@ pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> {
|
||||
if sz.len() != 3 || sz[0] != sz[1] || sz[1] != sz[2] {
|
||||
return None;
|
||||
}
|
||||
let n = sz[0] as usize;
|
||||
let mut d3 = Vec::with_capacity(n * n * n);
|
||||
// The size comes from the file: an integer lattice size the other lattice readers accept
|
||||
// (2..=129), checked before anything is allocated. Rows are read before they are stored,
|
||||
// so a short file never reserves the table it claims.
|
||||
let n = sz[0];
|
||||
if n.fract() != 0.0 || !(2.0..=MAX_LATTICE as f32).contains(&n) {
|
||||
return None;
|
||||
}
|
||||
let n = n as usize;
|
||||
let mut d3 = Vec::new();
|
||||
for _ in 0..n * n * n {
|
||||
let v = nums(lines.next()?)?;
|
||||
if v.len() != 3 {
|
||||
@@ -194,10 +206,13 @@ pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> {
|
||||
}
|
||||
d3.push([v[0], v[1], v[2]]);
|
||||
}
|
||||
(n >= 2).then_some((Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
|
||||
Some((Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
|
||||
} else {
|
||||
let n: usize = lines.next()?.parse().ok()?;
|
||||
let mut d1 = Vec::with_capacity(n);
|
||||
if !(2..=MAX_1D).contains(&n) {
|
||||
return None;
|
||||
}
|
||||
let mut d1 = Vec::new();
|
||||
for _ in 0..n {
|
||||
let v = nums(lines.next()?)?;
|
||||
if v.len() != 3 {
|
||||
@@ -205,7 +220,7 @@ pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> {
|
||||
}
|
||||
d1.push([v[0], v[1], v[2]]);
|
||||
}
|
||||
(n >= 2).then_some((Lut { n1: n, d1, n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
|
||||
Some((Lut { n1: n, d1, n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,14 @@ fn lut_files_parse_and_interpolate() {
|
||||
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.4).abs() < 1e-6, "{o:?}");
|
||||
let inv = x.apply(o, 2, true, "");
|
||||
assert!((inv[0] - 0.5).abs() < 1e-4, "{inv:?}");
|
||||
// A .csp whose header claims a huge or fractional table is refused before anything is reserved.
|
||||
let head = "CSPLUTV100\n{k}\n\n2\n0 1\n0 1\n2\n0 1\n0 1\n2\n0 1\n0 1\n\n";
|
||||
for (k, size) in [("3D", "100000 100000 100000"), ("3D", "130 130 130"), ("3D", "2.5 2.5 2.5"), ("1D", "4000000000000")] {
|
||||
let text = head.replace("{k}", k) + size + "\n0 0 0\n";
|
||||
assert!(parse_file(&text, "csp").is_none(), "{k} {size}");
|
||||
}
|
||||
// A short file that claims a valid size is refused once its rows run out.
|
||||
assert!(parse_file(&(head.replace("{k}", "3D") + "129 129 129\n0 0 0\n"), "csp").is_none());
|
||||
// CDL collection: pick by id.
|
||||
let ccc = r#"<ColorCorrectionCollection><ColorCorrection id="a"><SOPNode><Slope>2 2 2</Slope><Offset>0 0 0</Offset><Power>1 1 1</Power></SOPNode></ColorCorrection>
|
||||
<ColorCorrection id='b'><SOPNode><Slope>0.5 0.5 0.5</Slope></SOPNode><SatNode><Saturation>1</Saturation></SatNode></ColorCorrection></ColorCorrectionCollection>"#;
|
||||
|
||||
@@ -168,19 +168,29 @@ fn split_key(s: &str) -> Option<(String, &str)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Bracket balance of a flow fragment (outside quotes).
|
||||
fn balance(s: &str) -> i32 {
|
||||
let (mut sq, mut dq, mut d) = (false, false, 0i32);
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\'' if !dq => sq = !sq,
|
||||
'"' if !sq => dq = !dq,
|
||||
'{' | '[' if !sq && !dq => d = d.saturating_add(1),
|
||||
'}' | ']' if !sq && !dq => d = d.saturating_sub(1),
|
||||
_ => {}
|
||||
/// Bracket balance of a flow fragment (outside quotes), kept as lines are appended so each
|
||||
/// character is looked at once however many lines a collection spans.
|
||||
#[derive(Default)]
|
||||
struct Balance {
|
||||
sq: bool,
|
||||
dq: bool,
|
||||
d: i32,
|
||||
}
|
||||
|
||||
impl Balance {
|
||||
/// Takes in the next piece of the fragment and returns the balance so far.
|
||||
fn feed(&mut self, s: &str) -> i32 {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\'' if !self.dq => self.sq = !self.sq,
|
||||
'"' if !self.sq => self.dq = !self.dq,
|
||||
'{' | '[' if !self.sq && !self.dq => self.d = self.d.saturating_add(1),
|
||||
'}' | ']' if !self.sq && !self.dq => self.d = self.d.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.d
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
struct Flow<'a> {
|
||||
@@ -223,12 +233,19 @@ impl Flow<'_> {
|
||||
self.i += 1;
|
||||
break;
|
||||
}
|
||||
// A `]` cannot close a mapping: the text is malformed, so the rest is
|
||||
// skipped (as past the depth limit) instead of read again and again.
|
||||
Some(b']') => {
|
||||
self.i = self.s.len();
|
||||
break;
|
||||
}
|
||||
Some(b',') => {
|
||||
self.i += 1;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let start = self.i;
|
||||
let k = self.scalar(true);
|
||||
self.ws();
|
||||
if self.peek() == Some(b':') {
|
||||
@@ -236,6 +253,11 @@ impl Flow<'_> {
|
||||
}
|
||||
let v = self.nested();
|
||||
m.push((unquote(&k), v));
|
||||
// Every entry consumes text; one that did not would repeat forever.
|
||||
if self.i == start {
|
||||
self.i = self.s.len();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Yaml::Map(m)
|
||||
}
|
||||
@@ -250,11 +272,23 @@ impl Flow<'_> {
|
||||
self.i += 1;
|
||||
break;
|
||||
}
|
||||
// A `}` cannot close a sequence: skip the malformed rest (see above).
|
||||
Some(b'}') => {
|
||||
self.i = self.s.len();
|
||||
break;
|
||||
}
|
||||
Some(b',') => {
|
||||
self.i += 1;
|
||||
continue;
|
||||
}
|
||||
_ => v.push(self.nested()),
|
||||
_ => {
|
||||
let start = self.i;
|
||||
v.push(self.nested());
|
||||
if self.i == start {
|
||||
self.i = self.s.len();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Yaml::Seq(v)
|
||||
@@ -364,11 +398,14 @@ impl Lines {
|
||||
if rest.starts_with('{') || rest.starts_with('[') || (rest.starts_with('!') && (rest.contains('{') || rest.contains('['))) {
|
||||
// Flow collections may continue on the next lines.
|
||||
let mut text = rest.to_string();
|
||||
while balance(&text) > 0
|
||||
let mut bal = Balance::default();
|
||||
let mut open = bal.feed(&text);
|
||||
while open > 0
|
||||
&& let Some((_, next)) = self.v.get(*i)
|
||||
{
|
||||
text.push(' ');
|
||||
text.push_str(next);
|
||||
open = bal.feed(next);
|
||||
*i += 1;
|
||||
}
|
||||
return flow(&text, depth);
|
||||
|
||||
@@ -94,6 +94,24 @@ fn yaml_nesting_is_bounded() {
|
||||
assert!(Config::parse(&flow, Path::new(".")).is_err());
|
||||
}
|
||||
|
||||
/// A closing bracket of the wrong kind ends the collection instead of being read forever, and a
|
||||
/// flow collection spread over many lines is assembled in linear time.
|
||||
#[test]
|
||||
fn yaml_malformed_flow_is_bounded() {
|
||||
for text in ["a: {x: ]\nb: 1\n", "a: [x, }\nb: 1\n", "a: [{k: ], 1]\n", "a: {[}: ]}\n"] {
|
||||
let _ = parse_yaml(text);
|
||||
}
|
||||
assert!(matches!(parse_yaml("a: {x: ]\n"), Yaml::Map(_)));
|
||||
let mut many = String::from("a: [\n");
|
||||
for _ in 0..200_000 {
|
||||
many.push_str(" 1,\n");
|
||||
}
|
||||
many.push_str("]\nb: 2\n");
|
||||
let y = parse_yaml(&many);
|
||||
assert_eq!(y.get("a").map(|a| a.seq().len()), Some(200_000));
|
||||
assert_eq!(y.get("b").and_then(Yaml::str), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_spaces_roles_displays_and_converts() {
|
||||
let dir = temp_dir("basic");
|
||||
|
||||
+392
-26
@@ -9,7 +9,8 @@
|
||||
//! neighbouring frames with [`crate::EffectHost::self_at`]). Undo, redo and reopening a project
|
||||
//! therefore never see stale mattes. Behind the memory cache an optional [`SegStore`] (the
|
||||
//! persistent disk cache, set by the engine) keeps computed segmentations across restarts. **Freeze** stores the final mattes in the hidden **Frozen**
|
||||
//! parameter ([`FrozenCache`]); they are used while their key still matches.
|
||||
//! parameter ([`FrozenCache`]); they are used while their key still matches, whichever model the
|
||||
//! session has (a session without the model that froze them uses them as they are).
|
||||
//!
|
||||
//! Rendering: binary segmentation → Roto Brush Matte (Shift Edge, Reduce Chatter, Feather,
|
||||
//! Contrast) → Refine Edge matting inside the Refine Edge band (guided filter + closed-form
|
||||
@@ -242,6 +243,11 @@ fn seed_with(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u6
|
||||
h
|
||||
}
|
||||
|
||||
/// Show Object: 0 shows every object, N object N alone.
|
||||
fn show_object_of(params: &Params) -> u32 {
|
||||
f(params, "rotoBrushMatte/object").round().clamp(0.0, 32.0) as u32
|
||||
}
|
||||
|
||||
/// The layer frame shown at layer time `t` seconds.
|
||||
pub fn frame_of(t: f64, fps: f64) -> i64 {
|
||||
(t * fps + 1e-6).floor() as i64
|
||||
@@ -415,7 +421,7 @@ impl Chain {
|
||||
let data = data(params);
|
||||
let keys = data.chain_keys(seed(params, layer_size, fps));
|
||||
let saved = saved_for(params, &data, layer_size, fps);
|
||||
let show_object = f(params, "rotoBrushMatte/object").round().clamp(0.0, 32.0) as u32;
|
||||
let show_object = show_object_of(params);
|
||||
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params), saved, show_object }
|
||||
}
|
||||
|
||||
@@ -543,25 +549,74 @@ impl Chain {
|
||||
}
|
||||
None
|
||||
};
|
||||
self.carry(f, None, frame, cancel, &lookup, &mut |g, seg| {
|
||||
let k = *self.keys.get(&g)?;
|
||||
store(k, sc, seg.clone());
|
||||
if let Some(st) = seg_store() {
|
||||
st.put_seg(k, sc, seg);
|
||||
}
|
||||
Some(())
|
||||
})
|
||||
}
|
||||
|
||||
/// [`Chain::ensure`]'s walk: the matte carried to `f` from the nearest frame towards the base that `lookup` has,
|
||||
/// else from the root cut afresh from its strokes. `object: None` carries every stroke from the base frame (the shown
|
||||
/// matte); `Some(o)` carries only `o`'s, from its foreground-stroked frame nearest the base on the way. `keep`
|
||||
/// gets every frame made on the way.
|
||||
fn carry(
|
||||
&self,
|
||||
f: i64,
|
||||
object: Option<u32>,
|
||||
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
lookup: &dyn Fn(i64) -> Option<Arc<FrameSeg>>,
|
||||
keep: &mut dyn FnMut(i64, &Arc<FrameSeg>) -> Option<()>,
|
||||
) -> Option<Arc<FrameSeg>> {
|
||||
let strokes = |g: i64| match object {
|
||||
Some(o) => self.data.strokes_of(g, o),
|
||||
None => self.data.strokes_at(g),
|
||||
};
|
||||
if let Some(s) = lookup(f) {
|
||||
return Some(s);
|
||||
}
|
||||
let base = self.data.base?;
|
||||
let root = match object {
|
||||
Some(o) => {
|
||||
// one pass over the strokes, not one per frame on the way
|
||||
let on_the_way = |g: i64| (g == base || g.cmp(&base) == f.cmp(&base)) && g.abs_diff(base) <= f.abs_diff(base);
|
||||
let fg = self.data.strokes.iter().filter(|s| s.object == o && s.kind == StrokeKind::Fg && on_the_way(s.frame));
|
||||
fg.map(|s| s.frame).min_by_key(|g| g.abs_diff(base)).unwrap_or(f)
|
||||
}
|
||||
None => base,
|
||||
};
|
||||
if !self.data.in_span(f) {
|
||||
return None;
|
||||
}
|
||||
// Walk towards the base to the nearest available frame.
|
||||
// A new first stroke can still have a valid tracked matte nearer the base. Look for it
|
||||
// without reading those frames; without one, start afresh at the object's first stroke.
|
||||
let mut path = vec![f];
|
||||
let mut untracked_len = 1;
|
||||
let mut g = f;
|
||||
let mut start: Option<Arc<FrameSeg>> = None;
|
||||
while let Some(src) = self.data.source_of(g) {
|
||||
while g != base
|
||||
&& let Some(src) = self.data.source_of(g)
|
||||
{
|
||||
if cancel() {
|
||||
return None;
|
||||
}
|
||||
if let Some(s) = lookup(src) {
|
||||
start = Some(s);
|
||||
break;
|
||||
}
|
||||
path.push(src);
|
||||
if src == root {
|
||||
untracked_len = path.len();
|
||||
}
|
||||
g = src;
|
||||
}
|
||||
if start.is_none() {
|
||||
path.truncate(untracked_len);
|
||||
}
|
||||
path.reverse();
|
||||
let mut prev_img: Option<Arc<Image>> = None;
|
||||
let mut cur = start;
|
||||
@@ -570,8 +625,8 @@ impl Chain {
|
||||
return None;
|
||||
}
|
||||
let img = frame(g)?;
|
||||
let strokes = self.data.strokes_at(g);
|
||||
let seg = match (&cur, g == base) {
|
||||
let strokes = strokes(g);
|
||||
let seg = match (&cur, object.is_none() && g == root) {
|
||||
(_, true) | (None, _) => rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref()),
|
||||
(Some(p), false) => {
|
||||
let src = self.data.source_of(g)?;
|
||||
@@ -587,11 +642,7 @@ impl Chain {
|
||||
}
|
||||
};
|
||||
let seg = Arc::new(seg);
|
||||
let k = *self.keys.get(&g)?;
|
||||
store(k, sc, seg.clone());
|
||||
if let Some(st) = seg_store() {
|
||||
st.put_seg(k, sc, &seg);
|
||||
}
|
||||
keep(g, &seg)?;
|
||||
cur = Some(seg);
|
||||
prev_img = Some(img);
|
||||
}
|
||||
@@ -674,7 +725,7 @@ impl Chain {
|
||||
max_pending_bytes: usize,
|
||||
) -> Result<Option<TrackSummary>, String> {
|
||||
let Some(tracker) = self.model.as_ref().and_then(|m| m.tracker()) else { return Ok(None) };
|
||||
let (Some(_), true) = (self.data.base, self.data.span[1] >= self.data.span[0]) else { return Err("no base frame".into()) };
|
||||
let (Some(base), true) = (self.data.base, self.data.span[1] >= self.data.span[0]) else { return Err("no base frame".into()) };
|
||||
// Frames of the span that already have a tracked result (this session's, or the saved
|
||||
// tracking) are kept: only the rest is tracked. A correction stroke changes the keys of the
|
||||
// frames from it on (both directions), so only those come back here.
|
||||
@@ -686,26 +737,48 @@ impl Chain {
|
||||
let (Some(&need_lo), Some(&need_hi)) = (needed.first(), needed.last()) else {
|
||||
return Ok(Some(TrackSummary::default()));
|
||||
};
|
||||
// Each object has its own prompts: a few clicks from its strokes and a box around what the classic engine
|
||||
// makes of them on that frame (clicks alone pick a part of the object, not all of it).
|
||||
// Each object has its own prompts: a few clicks from its strokes and a box saying how far it reaches (clicks
|
||||
// alone pick a part of the object). SAM 2 fills the box it is given, so the box surrounds the object's matte
|
||||
// on that frame: the one shown for a lone object; with several, each object's own, carried with its own strokes
|
||||
// as the shown one is (from its first prompted frame, or the nearest tracked or prompted frame), so a correction stroke on
|
||||
// part of an object still boxes all of it. Those per-object mattes are not cached: their keys mean tracked results.
|
||||
let objects = self.data.objects();
|
||||
let several = objects.len() > 1;
|
||||
let mut by_object: Vec<Vec<(i64, effectcraft_segment::Prompt)>> = vec![];
|
||||
for &obj in &objects {
|
||||
let mut prompts = vec![];
|
||||
for f in self.data.stroke_frames().into_iter().filter(|f| self.data.in_span(*f)) {
|
||||
let mut carried: Option<(i64, Arc<FrameSeg>)> = None;
|
||||
// from the base outwards on each side, so each carry goes on from the one before
|
||||
let mut frames: Vec<i64> = self.data.stroke_frames().into_iter().filter(|f| self.data.in_span(*f)).collect();
|
||||
frames.sort_by_key(|f| (*f < base, f.abs_diff(base)));
|
||||
for f in frames {
|
||||
let strokes = self.data.strokes_of(f, obj);
|
||||
if !strokes.iter().any(|s| s.kind == StrokeKind::Fg) {
|
||||
continue;
|
||||
}
|
||||
let p = match frame(f) {
|
||||
Some(img) => rb::tracker_prompt_boxed(&strokes, self.opts.scale, &rb::segment(&img, &strokes, None, &self.opts, self.refine_radius)),
|
||||
None => return Err(format!("cannot read Roto Brush prompt frame {f}")),
|
||||
let own = if several {
|
||||
// a later correction frame carries on from the one before it (only the last can be on its way)
|
||||
let lookup = |g| self.object_seg(g, obj).or_else(|| carried.as_ref().filter(|(k, _)| *k == g).map(|(_, s)| s.clone()));
|
||||
let own = self.carry(f, Some(obj), &mut *frame, cancel, &lookup, &mut |_, _| Some(()));
|
||||
carried = own.clone().map(|s| (f, s));
|
||||
own
|
||||
} else {
|
||||
self.ensure(f, &mut *frame, cancel)
|
||||
};
|
||||
let matte = match own {
|
||||
Some(own) => own,
|
||||
None if cancel() => return Err("tracking cancelled".into()),
|
||||
None => match frame(f) {
|
||||
Some(img) => Arc::new(rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref())),
|
||||
None => return Err(format!("cannot read Roto Brush prompt frame {f}")),
|
||||
},
|
||||
};
|
||||
let p = rb::tracker_prompt_boxed(&strokes, self.opts.scale, &matte);
|
||||
if p.points.iter().any(|(_, fg)| *fg) {
|
||||
prompts.push((f, p));
|
||||
}
|
||||
}
|
||||
prompts.sort_by_key(|q| q.0);
|
||||
by_object.push(prompts);
|
||||
}
|
||||
// objects without a foreground prompt cannot be followed
|
||||
@@ -862,7 +935,8 @@ fn needs_neighbours(mp: &MatteParams) -> bool {
|
||||
// ------------------------------------------------------------------ Freeze
|
||||
|
||||
/// Frozen mattes (Freeze): the final 8-bit alpha and the Refine Edge band of every frame of
|
||||
/// the span at full resolution, valid while `key` matches [`frozen_key`].
|
||||
/// the span at full resolution, valid while `key` matches [`frozen_key`] under the session's model or
|
||||
/// any other Roto Brush model.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FrozenCache {
|
||||
@@ -892,21 +966,35 @@ impl FrozenCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// What frozen mattes depend on: every chain key and the matte settings.
|
||||
/// What frozen mattes depend on: every chain key (the session's model is in their seed) and the matte settings.
|
||||
pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64) -> String {
|
||||
let ch = Chain::new(params, layer_size, fps, 1.0);
|
||||
frozen_key_with(params, layer_size, fps, model_for(params).map_or(effectcraft_segment::CLASSICAL, |m| m.info().id))
|
||||
}
|
||||
|
||||
/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: none).
|
||||
fn frozen_key_with(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||||
for (f, k) in &ch.keys {
|
||||
for (f, k) in &data(params).chain_keys(seed_with(params, layer_size, fps, model)) {
|
||||
fnv(&mut h, &f.to_le_bytes());
|
||||
fnv(&mut h, &k.to_le_bytes());
|
||||
}
|
||||
fnv(&mut h, format!("{:?}", matte_params(params)).as_bytes());
|
||||
if ch.show_object != 0 {
|
||||
fnv(&mut h, &ch.show_object.to_le_bytes());
|
||||
let show_object = show_object_of(params);
|
||||
if show_object != 0 {
|
||||
fnv(&mut h, &show_object.to_le_bytes());
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
/// Whether frozen mattes still apply: their key matches under the session's model, or under the model that froze
|
||||
/// them, which this session may not have loaded (a fresh headless render). Frozen mattes need no segmentation.
|
||||
fn frozen_applies(fc: &FrozenCache, params: &Params, layer_size: [f64; 2], fps: f64) -> bool {
|
||||
fc.key == frozen_key(params, layer_size, fps)
|
||||
|| std::iter::once(effectcraft_segment::CLASSICAL)
|
||||
.chain(effectcraft_segment::MODELS.iter().filter(|m| m.task == effectcraft_segment::Task::Mask).map(|m| m.id))
|
||||
.any(|id| fc.key == frozen_key_with(params, layer_size, fps, id))
|
||||
}
|
||||
|
||||
/// Whether the instance is frozen (has frozen mattes).
|
||||
pub fn is_frozen(params: &Params) -> bool {
|
||||
!s(params, FROZEN).is_empty()
|
||||
@@ -1125,7 +1213,7 @@ fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc<Image>, scale: f64) -> Option<(V
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
// Frozen mattes.
|
||||
if let Some(fc) = frozen(ctx.params)
|
||||
&& fc.key == frozen_key(ctx.params, ctx.layer_size, ctx.fps())
|
||||
&& frozen_applies(&fc, ctx.params, ctx.layer_size, ctx.fps())
|
||||
&& let Some(fr) = fc.frames.get(&f)
|
||||
{
|
||||
let [fw, fh] = fc.size;
|
||||
@@ -1228,17 +1316,23 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
|
||||
|
||||
/// The final matte of the effect at layer frame `f` on the full-resolution layer grid of
|
||||
/// `input` (the effect's input buffer at scale 1), computing what is missing. For Freeze and
|
||||
/// the Layer panel overlays.
|
||||
/// the Layer panel overlays. A frame of `frozen` (the instance's frozen mattes, from
|
||||
/// [`applying_frozen`]) keeps its frozen matte, so freezing again in a session without the model
|
||||
/// that froze it does not replace that model's matte.
|
||||
pub fn frame_matte(
|
||||
params: &Params,
|
||||
layer_size: [f64; 2],
|
||||
fps: f64,
|
||||
f: i64,
|
||||
frozen: Option<&FrozenCache>,
|
||||
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
) -> Option<(Vec<f32>, Vec<u8>)> {
|
||||
let chain = Chain::new(params, layer_size, fps, 1.0);
|
||||
let img = frame(f)?;
|
||||
if let Some(kept) = frozen.and_then(|fc| frozen_frame_matte(fc, f, img.width as usize, img.height as usize)) {
|
||||
return Some(kept);
|
||||
}
|
||||
let seg = chain.ensure(f, frame, cancel)?;
|
||||
let mp = matte_params(params);
|
||||
let (mut prev, mut next) = (None, None);
|
||||
@@ -1254,6 +1348,25 @@ pub fn frame_matte(
|
||||
Some((alpha, seg.refine.clone()))
|
||||
}
|
||||
|
||||
/// The instance's frozen mattes while they apply ([`frozen_applies`]): the frames a second Freeze keeps. A Freeze job
|
||||
/// resolves them once, since this hashes the whole frozen cache.
|
||||
pub fn applying_frozen(params: &Params, layer_size: [f64; 2], fps: f64) -> Option<Arc<FrozenCache>> {
|
||||
let fc = frozen(params)?;
|
||||
frozen_applies(&fc, params, layer_size, fps).then_some(fc)
|
||||
}
|
||||
|
||||
/// Frame `f`'s alpha and band in `fc` on a `w`×`h` grid, when it was frozen at that size.
|
||||
fn frozen_frame_matte(fc: &FrozenCache, f: i64, w: usize, h: usize) -> Option<(Vec<f32>, Vec<u8>)> {
|
||||
let fr = fc.frames.get(&f)?;
|
||||
if fc.size != [w, h] {
|
||||
return None;
|
||||
}
|
||||
let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= rle::MAX_PLANE_BYTES)?;
|
||||
let alpha = rle::decode_bounded(&fr.a, n).filter(|a| a.len() == n)?;
|
||||
let band = rle::decode_bounded(&fr.b, n).filter(|b| b.len() == n)?;
|
||||
Some((alpha.iter().map(|v| f32::from(*v) / 255.0).collect(), band))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1344,6 +1457,50 @@ mod tests {
|
||||
assert!(o.data.iter().all(|p| p[3] == 0.0));
|
||||
}
|
||||
|
||||
/// Mattes frozen with a model render as frozen in a session that has another model or none (a fresh headless
|
||||
/// render), instead of being segmented again with the classic engine.
|
||||
#[test]
|
||||
fn frozen_mattes_survive_a_session_without_their_model() {
|
||||
let mut d = RotoData::default();
|
||||
d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0], [45.0, 40.0]], object: 0 }, 2, [0, 5]);
|
||||
let mut params = params_with(&d);
|
||||
params.values.insert(INPUT_KEY.into(), Value::Str("frozen-without-the-model".into()));
|
||||
let size = [100.0, 80.0];
|
||||
assert!(model_for(¶ms).is_none(), "this session has no model");
|
||||
// the frozen matte is the left half of the layer: no segmentation of the disk gives it
|
||||
let alpha: Vec<f32> = (0..100 * 80).map(|i| f32::from(u8::from(i % 100 < 50))).collect();
|
||||
let mut fc = FrozenCache { key: frozen_key_with(¶ms, size, 25.0, effectcraft_segment::SAM2_TINY.id), size: [100, 80], ..Default::default() };
|
||||
fc.frames.insert(0, freeze_frame(&alpha, &[0; 100 * 80]));
|
||||
assert_ne!(fc.key, frozen_key(¶ms, size, 25.0));
|
||||
params.values.insert(FROZEN.into(), Value::Str(fc.to_json()));
|
||||
let host = Seq;
|
||||
let ctx = EffectCtx {
|
||||
params: ¶ms,
|
||||
time: 0.001,
|
||||
layer_size: size,
|
||||
seed: 1,
|
||||
adjustment: false,
|
||||
env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() },
|
||||
};
|
||||
let out = crate::apply(&specs()[0], &ctx, Buf { img: frame(0.0), offset: [0.0; 2], scale: 1.0 }).img;
|
||||
let a = |x: usize, y: usize| out.data[y * 100 + x][3];
|
||||
assert!(a(10, 70) > 0.99 && a(90, 10) < 0.01, "the frozen matte is used: {} at (10, 70), {} at (90, 10)", a(10, 70), a(90, 10));
|
||||
// freezing again in this session keeps the frozen matte instead of segmenting with the classic engine
|
||||
let mut get = |g: i64| Some(Arc::new(frame(g as f64 / 25.0)));
|
||||
let kept = applying_frozen(¶ms, size, 25.0).expect("the frozen mattes apply");
|
||||
let (matte, band) = frame_matte(¶ms, size, 25.0, 0, Some(&kept), &mut get, &|| false).expect("frame 0's matte");
|
||||
assert_eq!((matte, band.len()), (alpha.clone(), 100 * 80), "a second Freeze keeps the frozen frame");
|
||||
let segmented = frame_matte(¶ms, size, 25.0, 1, Some(&kept), &mut get, &|| false);
|
||||
assert!(segmented.is_some_and(|(a, _)| a[40 * 100 + 10] < 0.01), "a frame with none is segmented");
|
||||
// a stroke edited after the freeze still makes it stale, whatever the model
|
||||
let mut moved = d.clone();
|
||||
moved.strokes[0].points[0] = [36.0, 40.0];
|
||||
let mut edited = params.clone();
|
||||
edited.values.insert(STROKES.into(), Value::Str(moved.to_json()));
|
||||
assert!(!frozen_applies(&fc, &edited, size, 25.0));
|
||||
assert!(applying_frozen(&edited, size, 25.0).is_none(), "a second Freeze then segments every frame");
|
||||
}
|
||||
|
||||
/// A stand-in tracker for two objects: object 0 is the moving disk, object 1 a fixed square. It records how many
|
||||
/// objects (and prompts per object) it was asked for.
|
||||
struct TwoObjects(std::sync::Mutex<Vec<Vec<usize>>>);
|
||||
@@ -1470,6 +1627,215 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A model that sees only the left part of the disk, and records the tracker's box prompts.
|
||||
#[derive(Default)]
|
||||
struct LeftPart(std::sync::Mutex<Vec<(i64, Option<[f32; 4]>)>>);
|
||||
impl effectcraft_segment::MaskModel for LeftPart {
|
||||
fn info(&self) -> &'static effectcraft_segment::ModelInfo {
|
||||
&effectcraft_segment::SAM2_TINY
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, _: &effectcraft_segment::Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
Ok((0..w * h).map(|i| f32::from((26..36).contains(&(i % w)) && (30..50).contains(&(i / w)))).collect())
|
||||
}
|
||||
fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
impl effectcraft_segment::ObjectTracker for LeftPart {
|
||||
fn track(
|
||||
&self,
|
||||
req: &effectcraft_segment::TrackRequest,
|
||||
_: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
|
||||
_: &dyn Fn() -> bool,
|
||||
_: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame),
|
||||
) -> effectcraft_segment::Result<()> {
|
||||
*self.0.lock().unwrap() = req.objects.iter().flatten().map(|(f, p)| (*f, p.bbox)).collect();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tracker_box_surrounds_the_matte_shown_on_each_stroked_frame() {
|
||||
let mut data = RotoData::default();
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[30.0, 40.0]], object: 0 }, 3, [0, 3]);
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 3.0, points: vec![[50.0, 40.0]], object: 0 }, 3, [0, 3]);
|
||||
data.span = [0, 3];
|
||||
let mut params = params_with(&data);
|
||||
params.values.insert(INPUT_KEY.into(), Value::Str("tracker-box-from-shown-matte".into()));
|
||||
let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0);
|
||||
let fake = Arc::new(LeftPart::default());
|
||||
chain.model = Some(fake.clone());
|
||||
chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id));
|
||||
let mut get = |f: i64| Some(Arc::new(frame(f as f64 / 25.0)));
|
||||
let expected: Vec<(i64, Option<[f32; 4]>)> = [0, 2]
|
||||
.into_iter()
|
||||
.map(|f| (f, rb::tracker_prompt_boxed(&data.strokes_of(f, 0), 1.0, &chain.ensure(f, &mut get, &|| false).expect("a shown matte")).bbox))
|
||||
.collect();
|
||||
assert!(expected.iter().all(|(_, b)| b.is_some()));
|
||||
assert!(expected[0].1.unwrap()[2] < 44.0, "the base frame shows the model's part of the disk: {expected:?}");
|
||||
chain.track(&mut get, &|| false, &mut |_| {}).unwrap();
|
||||
assert_eq!(*fake.0.lock().unwrap(), expected, "the base frame and the correction frame are boxed around what they show");
|
||||
}
|
||||
|
||||
/// A model that cuts only a small square around each foreground click, and records every object's box prompts.
|
||||
#[derive(Default)]
|
||||
struct ClickSquares(std::sync::Mutex<Vec<Vec<(i64, Option<[f32; 4]>)>>>);
|
||||
impl effectcraft_segment::MaskModel for ClickSquares {
|
||||
fn info(&self) -> &'static effectcraft_segment::ModelInfo {
|
||||
&effectcraft_segment::SAM2_TINY
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, p: &effectcraft_segment::Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
let near = |x: f32, y: f32| p.points.iter().any(|(c, fg)| *fg && (c[0] - x).abs() <= 4.0 && (c[1] - y).abs() <= 4.0);
|
||||
Ok((0..w * h).map(|i| f32::from(near((i % w) as f32 + 0.5, (i / w) as f32 + 0.5))).collect())
|
||||
}
|
||||
fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
impl effectcraft_segment::ObjectTracker for ClickSquares {
|
||||
fn track(
|
||||
&self,
|
||||
req: &effectcraft_segment::TrackRequest,
|
||||
_: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
|
||||
_: &dyn Fn() -> bool,
|
||||
_: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame),
|
||||
) -> effectcraft_segment::Result<()> {
|
||||
*self.0.lock().unwrap() = req.objects.iter().map(|o| o.iter().map(|(f, p)| (*f, p.bbox)).collect()).collect();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_correction_stroke_on_part_of_one_of_several_objects_boxes_the_whole_object() {
|
||||
let mut data = RotoData::default();
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[30.0, 40.0], [50.0, 40.0]], object: 0 }, 3, [0, 3]);
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[88.0, 72.0]], object: 1 }, 3, [0, 3]);
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 2.0, points: vec![[54.0, 40.0]], object: 0 }, 3, [0, 3]);
|
||||
data.span = [0, 3];
|
||||
let mut params = params_with(&data);
|
||||
params.values.insert(INPUT_KEY.into(), Value::Str("object-correction-box".into()));
|
||||
let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0);
|
||||
let fake = Arc::new(ClickSquares::default());
|
||||
chain.model = Some(fake.clone());
|
||||
chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id));
|
||||
chain.track(&mut |f| Some(Arc::new(frame(f as f64 / 25.0))), &|| false, &mut |_| {}).unwrap();
|
||||
let boxes = fake.0.lock().unwrap().clone();
|
||||
let box_of = |o: usize, f: i64| boxes[o].iter().find(|(g, _)| *g == f).and_then(|(_, b)| *b).expect("a box prompt");
|
||||
let (base, correction, other) = (box_of(0, 0), box_of(0, 2), box_of(1, 0));
|
||||
assert!(correction[0] <= base[0] + 6.0, "the correction frame boxes the object carried from frame 0 {base:?}, not only the stroke: {correction:?}");
|
||||
assert!(correction[2] >= 54.0 && correction[2] - correction[0] >= base[2] - base[0], "the box holds the whole object and the stroke: {correction:?}");
|
||||
assert!(correction[2] < other[0], "object 1 {other:?} stays out of object 0's box {correction:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_correction_after_propagate_carries_each_object_on_from_its_tracked_matte() {
|
||||
let mut data = RotoData::default();
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[30.0, 40.0]], object: 0 }, 3, [0, 3]);
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[15.0, 15.0]], object: 1 }, 3, [0, 3]);
|
||||
data.span = [0, 3];
|
||||
let mut params = params_with(&data);
|
||||
params.values.insert(INPUT_KEY.into(), Value::Str("object-correction-after-propagate".into()));
|
||||
let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0);
|
||||
chain.model = Some(Arc::new(TwoObjects(Default::default())));
|
||||
chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id));
|
||||
assert_eq!(chain.track(&mut |f| Some(Arc::new(frame(f as f64 / 25.0))), &|| false, &mut |_| {}).unwrap().unwrap().frames, 4);
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 2.0, points: vec![[56.0, 40.0]], object: 0 }, 3, [0, 3]);
|
||||
chain.data = Arc::new(data.clone());
|
||||
chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id));
|
||||
let fake = Arc::new(ClickSquares::default());
|
||||
chain.model = Some(fake.clone());
|
||||
let mut prompt_reads = vec![];
|
||||
let mut get = |f: i64| {
|
||||
if fake.0.lock().unwrap().is_empty() {
|
||||
prompt_reads.push(f);
|
||||
}
|
||||
Some(Arc::new(frame(f as f64 / 25.0)))
|
||||
};
|
||||
chain.track(&mut get, &|| false, &mut |_| {}).unwrap();
|
||||
assert_eq!(prompt_reads, [2, 1], "frame 2 carries on from the tracked frame 1; tracked frame 0 boxes both objects as it is");
|
||||
let boxes = fake.0.lock().unwrap().clone();
|
||||
let correction = boxes[0].iter().find(|(g, _)| *g == 2).and_then(|(_, b)| *b).expect("a box prompt");
|
||||
// the tracked disk carried to frame 2 spans x 29..59
|
||||
assert!(correction[0] <= 29.0 && correction[2] >= 59.0, "the box holds the whole disk: {correction:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_correction_before_an_objects_first_stroke_keeps_its_tracked_matte() {
|
||||
for base in [0, 4] {
|
||||
let first = if base == 0 { 3 } else { 1 };
|
||||
let prior = if base == 0 { 1 } else { 3 };
|
||||
let mut data = RotoData::default();
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: base, radius: 3.0, points: vec![[40.0, 40.0]], object: 0 }, 4, [0, 4]);
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: first, radius: 3.0, points: vec![[15.0, 15.0]], object: 1 }, 4, [0, 4]);
|
||||
data.span = [0, 4];
|
||||
let mut params = params_with(&data);
|
||||
params.values.insert(INPUT_KEY.into(), Value::Str(format!("earlier-object-correction-{base}")));
|
||||
let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0);
|
||||
chain.model = Some(Arc::new(TwoObjects(Default::default())));
|
||||
let seed = seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id);
|
||||
chain.keys = data.chain_keys(seed);
|
||||
let image = |f: i64| {
|
||||
let mut img = frame(f as f64 / 25.0);
|
||||
// Match the second object's tracked square, so the carry has a real image edge.
|
||||
for y in 8..22 {
|
||||
for x in 8..22 {
|
||||
img.set(x, y, [0.95, 0.9, 0.1, 1.0]);
|
||||
}
|
||||
}
|
||||
Arc::new(img)
|
||||
};
|
||||
assert_eq!(chain.track(&mut |f| Some(image(f)), &|| false, &mut |_| {}).unwrap().unwrap().frames, 5);
|
||||
let prior_key = chain.keys[&prior];
|
||||
assert!(chain.object_seg(prior, 1).is_some());
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 1.0, points: vec![[19.0, 15.0]], object: 1 }, 4, [0, 4]);
|
||||
chain.data = Arc::new(data.clone());
|
||||
chain.keys = data.chain_keys(seed);
|
||||
assert_eq!(chain.keys[&prior], prior_key, "the nearer tracked frame is still valid");
|
||||
let fake = Arc::new(ClickSquares::default());
|
||||
chain.model = Some(fake.clone());
|
||||
let mut prompt_reads = vec![];
|
||||
let mut get = |f: i64| {
|
||||
if fake.0.lock().unwrap().is_empty() {
|
||||
prompt_reads.push(f);
|
||||
}
|
||||
Some(image(f))
|
||||
};
|
||||
chain.track(&mut get, &|| false, &mut |_| {}).unwrap();
|
||||
assert_eq!(prompt_reads, [2, prior, first, 2], "corrections carry from the valid tracked matte in either direction");
|
||||
let boxes = fake.0.lock().unwrap().clone();
|
||||
let correction = boxes[1].iter().find(|(g, _)| *g == 2).and_then(|(_, b)| *b).expect("a correction box");
|
||||
assert!(correction[0] <= 10.0 && correction[2] >= 22.0, "the box keeps the whole tracked square, not only the correction: {correction:?}");
|
||||
assert!(correction[2] < 30.0, "the other object's disk stays outside this box: {correction:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrections_before_the_base_carry_on_from_each_other() {
|
||||
let mut data = RotoData::default();
|
||||
for (frame, object) in [(3, 0), (3, 1), (2, 0), (1, 0)] {
|
||||
data.add(Stroke { kind: StrokeKind::Fg, frame, radius: 3.0, points: vec![[35.0, 40.0]], object }, 3, [0, 3]);
|
||||
}
|
||||
data.span = [0, 3];
|
||||
assert_eq!(data.base, Some(3));
|
||||
let mut params = params_with(&data);
|
||||
params.values.insert(INPUT_KEY.into(), Value::Str("corrections-before-the-base".into()));
|
||||
let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0);
|
||||
let fake = Arc::new(ClickSquares::default());
|
||||
chain.model = Some(fake.clone());
|
||||
chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id));
|
||||
let mut prompt_reads = vec![];
|
||||
let mut get = |f: i64| {
|
||||
if fake.0.lock().unwrap().is_empty() {
|
||||
prompt_reads.push(f);
|
||||
}
|
||||
Some(Arc::new(frame(f as f64 / 25.0)))
|
||||
};
|
||||
chain.track(&mut get, &|| false, &mut |_| {}).unwrap();
|
||||
assert_eq!(prompt_reads, [3, 2, 3, 1, 2, 3], "object 0: the base, then 2 from 3, then 1 from 2; object 1: the base");
|
||||
let frames: Vec<Vec<i64>> = fake.0.lock().unwrap().iter().map(|o| o.iter().map(|q| q.0).collect()).collect();
|
||||
assert_eq!(frames, [vec![1, 2, 3], vec![3]], "prompts reach the tracker in frame order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_segmentation_reads_only_frames_with_that_objects_foreground() {
|
||||
let mut data = RotoData::default();
|
||||
|
||||
@@ -457,7 +457,11 @@ fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
fy = 1.0 - fy;
|
||||
}
|
||||
}
|
||||
*px = src.sample_bilinear(fx * lw * s + b.offset[0], fy * lh * s + b.offset[1]);
|
||||
// Clamped: a tile edge lands exactly on a pixel centre whenever the boundary does
|
||||
// (#2820's animated Tile Center walks it across the grid), and the plain sampler
|
||||
// would mix in the transparent outside at the source's geometric edge, leaving a
|
||||
// half-faded seam pixel along the fold.
|
||||
*px = src.sample_bilinear_clamped(fx * lw * s + b.offset[0], fy * lh * s + b.offset[1]);
|
||||
}
|
||||
});
|
||||
Buf { img: out, offset: noff, scale: s }
|
||||
@@ -808,6 +812,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_tile_mirror_folds_stay_opaque() {
|
||||
// A fold that lands exactly on a pixel centre samples the source's geometric edge, where
|
||||
// the plain bilinear tap reaches half a texel into the transparent outside — a faint
|
||||
// seam line on the mirrored boundary that sweeps the tile while Tile Center animates
|
||||
// (#2820). The fold must repeat the edge texel instead: opaque, and exactly the pixel
|
||||
// it mirrors.
|
||||
let img = ramp(10, 8);
|
||||
let out = run_at(
|
||||
"ec.stylize.motiontile",
|
||||
&[("tileCenter", Value::Vec2([5.0, 4.0])), ("tileWidth", num(50.0)), ("mirrorEdges", Value::Bool(true))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
);
|
||||
assert_eq!((out.img.width, out.img.height), (10, 8));
|
||||
for (x, mirrored) in [(2i64, 0i64), (7, 9)] {
|
||||
for y in 0..i64::from(out.img.height) {
|
||||
let a = out.img.get(x, y);
|
||||
assert!((a[3] - 1.0).abs() < 1e-5, "fold x={x},y={y} faded to alpha {}", a[3]);
|
||||
let b = img.get(mirrored, y);
|
||||
for c in 0..4 {
|
||||
assert!((a[c] - b[c]).abs() < 1e-5, "fold x={x},y={y} channel {c}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// And no pixel anywhere in the tile faded either.
|
||||
for px in out.img.data.iter() {
|
||||
assert!((px[3] - 1.0).abs() < 1e-5, "tile pixel faded to alpha {}", px[3]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strobe_by_time() {
|
||||
assert!(strobe_on(0.02, 0.05, 1.0, 0.0, 1));
|
||||
|
||||
@@ -364,32 +364,54 @@ fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) {
|
||||
((px, py), (rx as i64, rz as i64))
|
||||
}
|
||||
|
||||
/// Stable f64 hex-cell selection and source geometry for CPU and GPU renders.
|
||||
pub struct HexTile {
|
||||
r: f64,
|
||||
c: (f64, f64),
|
||||
sn: f64,
|
||||
cs: f64,
|
||||
smear: f64,
|
||||
mode: u32,
|
||||
}
|
||||
impl HexTile {
|
||||
/// No image pixels are read; only buffer geometry and parameters are used.
|
||||
pub fn new(ctx: &EffectCtx, b: &Buf) -> Self {
|
||||
let r = (ctx.params.f("radius") * b.scale).max(0.5);
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let rot = ctx.params.f("rotate").to_radians();
|
||||
let (sn, cs) = rot.sin_cos();
|
||||
let smear = (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0);
|
||||
let mode = ctx.params.e("render");
|
||||
Self { r, c, sn, cs, smear, mode }
|
||||
}
|
||||
/// Bilinear source position at a buffer pixel centre.
|
||||
pub fn source_at(&self, x: u32, y: u32) -> (f64, f64) {
|
||||
let Self { r, c: (cx, cy), sn, cs, smear, mode } = *self;
|
||||
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
|
||||
// Into the rotated tiling frame.
|
||||
let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs);
|
||||
let ((hx, hy), (qi, ri)) = hex_cell(u, v, r);
|
||||
let (mut lu, lv) = (u - hx, v - hy);
|
||||
let odd = (qi + ri).rem_euclid(2) == 1;
|
||||
match mode {
|
||||
1 if odd => lu = -lu,
|
||||
2 if odd => lu = -lu,
|
||||
_ => {}
|
||||
}
|
||||
let lv = if mode == 2 && odd { -lv } else { lv };
|
||||
// Every tile shows the hexagon around the centre point.
|
||||
let (ox, oy) = (lu * cs - lv * sn, lu * sn + lv * cs);
|
||||
let (tx, ty) = (cx + ox, cy + oy);
|
||||
let (sx, sy) = (tx + (x as f64 + 0.5 - tx) * smear, ty + (y as f64 + 0.5 - ty) * smear);
|
||||
(sx, sy)
|
||||
}
|
||||
}
|
||||
fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let r = (ctx.params.f("radius") * b.scale).max(0.5);
|
||||
let (cx, cy) = b.to_px(ctx.params.v2("center"));
|
||||
let rot = ctx.params.f("rotate").to_radians();
|
||||
let (sn, cs) = rot.sin_cos();
|
||||
let smear = (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0);
|
||||
let mode = ctx.params.e("render");
|
||||
let geometry = HexTile::new(ctx, &b);
|
||||
let src = b.img.clone();
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
|
||||
// Into the rotated tiling frame.
|
||||
let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs);
|
||||
let ((hx, hy), (qi, ri)) = hex_cell(u, v, r);
|
||||
let (mut lu, lv) = (u - hx, v - hy);
|
||||
let odd = (qi + ri).rem_euclid(2) == 1;
|
||||
match mode {
|
||||
1 if odd => lu = -lu,
|
||||
2 if odd => lu = -lu,
|
||||
_ => {}
|
||||
}
|
||||
let lv = if mode == 2 && odd { -lv } else { lv };
|
||||
// Every tile shows the hexagon around the centre point.
|
||||
let (ox, oy) = (lu * cs - lv * sn, lu * sn + lv * cs);
|
||||
let (tx, ty) = (cx + ox, cy + oy);
|
||||
let (sx, sy) = (tx + (x as f64 + 0.5 - tx) * smear, ty + (y as f64 + 0.5 - ty) * smear);
|
||||
let (sx, sy) = geometry.source_at(x as u32, y as u32);
|
||||
*px = src.sample_bilinear_clamped(sx, sy);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -451,11 +451,76 @@ fn time_displacement(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
|
||||
// ---------------------------------------------------------------- Timewarp
|
||||
|
||||
/// Source layer time Timewarp shows at layer time `t`.
|
||||
/// Source layer time Timewarp shows at layer time `t` when Speed is the same at every time:
|
||||
/// Source Frame maps directly, Speed (a rate in %) gives `t × speed / 100`. An animated Speed
|
||||
/// is integrated instead (see [`timewarp_integrate_speed`]).
|
||||
pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f64, fps: f64) -> f64 {
|
||||
if by_frame { source_frame / fps } else { t * speed / 100.0 }
|
||||
}
|
||||
|
||||
/// Speed samples per composition frame when integrating an animated Speed.
|
||||
const TW_SPEED_SAMPLES_PER_FRAME: f64 = 2.0;
|
||||
/// Most Speed samples one integration takes (longer spans widen the step instead).
|
||||
const TW_SPEED_MAX_SAMPLES: f64 = 50_000.0;
|
||||
|
||||
/// Source layer time of Adjust Time By = Speed at layer time `t` with an animated Speed: Speed
|
||||
/// is a rate (100 % = real time), so the source time is the integral of `speed / 100` over layer
|
||||
/// time from 0 to `t` (toward 0 with the sign for negative `t`). `speed_at(lt)` is Speed at layer
|
||||
/// time `lt`; a missing or non-finite sample counts as `now` (the current Speed), or 0 when that
|
||||
/// is not finite either. Midpoint rule on a fixed grid from 0, [`TW_SPEED_SAMPLES_PER_FRAME`]
|
||||
/// samples per frame at `fps`, at most [`TW_SPEED_MAX_SAMPLES`] samples. A non-finite result falls back to
|
||||
/// `t × now / 100`, then to 0.
|
||||
pub fn timewarp_integrate_speed(t: f64, fps: f64, now: f64, speed_at: impl Fn(f64) -> Option<f64>) -> f64 {
|
||||
let flat = t * now / 100.0;
|
||||
let fallback = |v: f64| {
|
||||
if v.is_finite() {
|
||||
v
|
||||
} else if flat.is_finite() {
|
||||
flat
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
if !t.is_finite() || t == 0.0 {
|
||||
return fallback(flat);
|
||||
}
|
||||
let fps = if fps.is_finite() && fps > 0.0 { fps } else { 30.0 };
|
||||
let span = t.abs();
|
||||
// Cells of a fixed step anchored at 0 (whole cells plus a partial last one), so successive
|
||||
// frames share their samples and a Hold key's jump lands the same way on every frame; only a
|
||||
// span past the sample budget widens the step.
|
||||
let mut h = 1.0 / (fps * TW_SPEED_SAMPLES_PER_FRAME);
|
||||
if !(span / h).is_finite() || span / h >= TW_SPEED_MAX_SAMPLES {
|
||||
h = span / TW_SPEED_MAX_SAMPLES;
|
||||
}
|
||||
if !h.is_finite() || h <= 0.0 {
|
||||
return fallback(flat);
|
||||
}
|
||||
let whole = (span / h).floor().clamp(0.0, TW_SPEED_MAX_SAMPLES);
|
||||
let rest = (span - whole * h).max(0.0);
|
||||
let sign = t.signum();
|
||||
let held = if now.is_finite() { now } else { 0.0 };
|
||||
let sample = |lt: f64| speed_at(sign * lt).filter(|v| v.is_finite()).unwrap_or(held);
|
||||
// `whole` is a whole number in 0..=TW_SPEED_MAX_SAMPLES, so the count is exact.
|
||||
let full: f64 = (0..whole as u64).map(|i| sample((i as f64 + 0.5) * h)).sum::<f64>() * h;
|
||||
let part = if rest > 0.0 { sample(whole * h + rest * 0.5) * rest } else { 0.0 };
|
||||
fallback(sign * (full + part) / 100.0)
|
||||
}
|
||||
|
||||
/// Source layer time Timewarp shows at the frame: [`timewarp_source_time`], except that an
|
||||
/// animated Speed (keyframes or an expression, read through the effect host) is integrated
|
||||
/// from layer time 0 ([`timewarp_integrate_speed`]), so a speed ramp plays on from where the
|
||||
/// footage is instead of jumping to another segment.
|
||||
fn timewarp_source(ctx: &EffectCtx, by_frame: bool, speed: f64, fps: f64) -> f64 {
|
||||
let direct = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps);
|
||||
if by_frame {
|
||||
return direct;
|
||||
}
|
||||
let idx = ctx.env.effect_index;
|
||||
let Some(host) = ctx.env.host.filter(|h| !h.param_is_static(idx, "speed")) else { return direct };
|
||||
timewarp_integrate_speed(ctx.time, fps, speed, |lt| host.param_at(idx, "speed", lt).map(|v| v.as_f64()))
|
||||
}
|
||||
|
||||
/// Timewarp's Pixel Motion settings (the Tuning group and Source Crops).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PixelMotion {
|
||||
@@ -747,7 +812,7 @@ fn timewarp_instants(ctx: &EffectCtx) -> (Vec<(f64, f32)>, Vec<f64>, f64) {
|
||||
let fps = ctx.fps();
|
||||
let by_frame = ctx.params.e("adjustTimeBy") == 1;
|
||||
let speed = ctx.params.f("speed");
|
||||
let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps);
|
||||
let src = timewarp_source(ctx, by_frame, speed, fps);
|
||||
let mut instants: Vec<(f64, f32)> = vec![];
|
||||
if ctx.params.b("motionBlur/enableMotionBlur") {
|
||||
let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 };
|
||||
@@ -879,7 +944,8 @@ pub fn timewarp_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
|
||||
/// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning
|
||||
/// group. A Matte Layer splits foreground and background so each moves on its own vectors; a
|
||||
/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter
|
||||
/// Samples instants across the shutter.
|
||||
/// Samples instants across the shutter. Adjust Time By = Speed treats Speed as a rate
|
||||
/// integrated from layer time 0 (keyframing it ramps the playback speed).
|
||||
fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
match timewarp_plan(ctx) {
|
||||
Some(plan) => plan.render_cpu(),
|
||||
@@ -1188,6 +1254,88 @@ mod tests {
|
||||
assert!(out.img.data[0][0] > 1.0);
|
||||
}
|
||||
|
||||
/// [`Clock`] whose Timewarp (effect 2) Speed is animated: `speed(lt)` at layer time `lt`.
|
||||
struct Ramp {
|
||||
speed: fn(f64) -> f64,
|
||||
}
|
||||
impl EffectHost for Ramp {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||||
let v = t as f32;
|
||||
Some(Buf { img: Image::filled(6, 4, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
/// Timewarp's (effect 2) Speed follows `speed`; every other parameter is unknown.
|
||||
fn param_at(&self, effect_index: usize, id: &str, lt: f64) -> Option<Value> {
|
||||
(effect_index == 2 && id == "speed").then(|| num((self.speed)(lt)))
|
||||
}
|
||||
/// Only Timewarp's (effect 2) Speed is animated.
|
||||
fn param_is_static(&self, effect_index: usize, id: &str) -> bool {
|
||||
!(effect_index == 2 && id == "speed")
|
||||
}
|
||||
}
|
||||
|
||||
fn run_ramp(vals: &[(&str, Value)], t: f64, host: &Ramp) -> Buf {
|
||||
let env = EffectEnv { host: Some(host), frame_rate: 10.0, comp_time: t, effect_index: 2, ..Default::default() };
|
||||
run_fx("ec.time.timewarp", vals, Image::filled(6, 4, [0.9, 0.0, 0.0, 1.0]), t, env)
|
||||
}
|
||||
|
||||
/// #2803: an animated Speed is a rate integrated from layer time 0, so a speed ramp plays on
|
||||
/// from where the footage is instead of jumping to another segment.
|
||||
#[test]
|
||||
fn timewarp_integrates_animated_speed() {
|
||||
// Speed 100 % for the first second, then 50 %: at t = 2 s the source is 1 + 0.5 = 1.5 s
|
||||
// (`t × speed` would jump back to 1.0 s).
|
||||
let h = Ramp { speed: |lt| if lt < 1.0 { 100.0 } else { 50.0 } };
|
||||
let vals = [("method", Value::Enum(0)), ("speed", num(50.0))];
|
||||
let out = run_ramp(&vals, 2.0, &h);
|
||||
assert!((out.img.data[0][0] - 1.5).abs() < 1e-4, "{}", out.img.data[0][0]);
|
||||
// Continuous across the change: just past 1 s the source is just past 1 s.
|
||||
let out = run_ramp(&vals, 1.2, &h);
|
||||
assert!((out.img.data[0][0] - 1.1).abs() < 1e-4, "{}", out.img.data[0][0]);
|
||||
// Negative layer time integrates toward 0.
|
||||
let src = timewarp_integrate_speed(-2.0, 10.0, 50.0, |lt| Some(if lt > -1.0 { 100.0 } else { 50.0 }));
|
||||
assert!((src + 1.5).abs() < 1e-9, "{src}");
|
||||
// A Hold key between samples (1.03 s) lands the same way on every frame: past it, each
|
||||
// 0.1 s frame advances the source by exactly 50 % of a frame.
|
||||
let hold = |lt: f64| Some(if lt < 1.03 { 100.0 } else { 50.0 });
|
||||
let at = |t: f64| timewarp_integrate_speed(t, 10.0, 50.0, hold);
|
||||
for k in 12..40 {
|
||||
let (a, b) = (at((k - 1) as f64 / 10.0), at(k as f64 / 10.0));
|
||||
assert!((b - a - 0.05).abs() < 1e-9, "k={k}: {}", b - a);
|
||||
}
|
||||
// A static Speed keeps `t × speed / 100` exactly.
|
||||
assert_eq!(timewarp_source_time(2.0, false, 37.0, 0.0, 10.0), 2.0 * 37.0 / 100.0);
|
||||
// Source Frame mode ignores Speed.
|
||||
let out = run_ramp(&[("method", Value::Enum(0)), ("adjustTimeBy", Value::Enum(1)), ("sourceFrame", num(3.0))], 2.0, &h);
|
||||
assert!((out.img.data[0][0] - 0.3).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// Never crash: non-finite or huge Speeds, times and frame rates give finite source times
|
||||
/// and pixels.
|
||||
#[test]
|
||||
fn timewarp_animated_speed_survives_bad_values() {
|
||||
// Non-finite samples count as the current Speed (or 0); the result is always finite.
|
||||
for t in [0.0, 1.0, -3.0, 1e9, f64::MAX, f64::INFINITY, f64::NAN] {
|
||||
for now in [50.0, f64::NAN, f64::INFINITY, 1e308] {
|
||||
let src = timewarp_integrate_speed(t, 10.0, now, |lt| Some(if lt < 0.5 { f64::NAN } else { 1e308 }));
|
||||
assert!(src.is_finite(), "t={t} now={now}: {src}");
|
||||
let src = timewarp_integrate_speed(t, f64::NAN, now, |_| None);
|
||||
assert!(src.is_finite(), "t={t} now={now}: {src}");
|
||||
}
|
||||
}
|
||||
let h = Ramp { speed: |lt| if lt < 1.0 { f64::NAN } else { f64::INFINITY } };
|
||||
let out = run_ramp(&[("method", Value::Enum(2)), ("speed", num(50.0)), ("motionBlur/enableMotionBlur", Value::Bool(true))], 2.0, &h);
|
||||
assert!(out.img.data.iter().all(|p| p.iter().all(|c| c.is_finite())));
|
||||
let h = Ramp { speed: |_| 1e308 };
|
||||
let out = run_ramp(&[("method", Value::Enum(1)), ("speed", num(1e308))], 2.0, &h);
|
||||
assert!(out.img.data.iter().all(|p| p.iter().all(|c| c.is_finite())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_displacement_uses_luma_as_offset() {
|
||||
// Static map from a bright self frame: luminance 1.0 → +max seconds.
|
||||
|
||||
@@ -314,53 +314,86 @@ pub(crate) fn card_wipe_plan(ctx: &EffectCtx, b: &Buf) -> Option<PiecePlan> {
|
||||
Some(PiecePlan { pieces, front: PieceTex::Layer, back })
|
||||
}
|
||||
|
||||
/// Double-precision grid geometry, shared with the GPU mask upload. A tiny
|
||||
/// rounding error at a tile boundary can otherwise flip a fully opaque pixel.
|
||||
pub struct GridWipe {
|
||||
c: (f64, f64),
|
||||
s: f64,
|
||||
co: f64,
|
||||
cs: f64,
|
||||
maxd: f64,
|
||||
shape: u32,
|
||||
reverse: bool,
|
||||
done: f64,
|
||||
border: f64,
|
||||
}
|
||||
|
||||
impl GridWipe {
|
||||
/// Construct from buffer geometry; no pixel data is accessed.
|
||||
pub fn new(ctx: &EffectCtx, b: &Buf) -> Self {
|
||||
let done = completion(ctx);
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let rot = ctx.params.f("rotation").to_radians();
|
||||
let border = ctx.params.f("border").max(0.0) * b.scale;
|
||||
let tiles = ctx.params.f("tiles").round().clamp(1.0, 500.0);
|
||||
let shape = ctx.params.e("shape");
|
||||
let reverse = ctx.params.b("reverse");
|
||||
let (_, _, w, _) = layer_rect(ctx, b);
|
||||
let cs = (w / tiles).max(1.0);
|
||||
let (s, co) = rot.sin_cos();
|
||||
let metric = |dx: f64, dy: f64| match shape {
|
||||
0 => dx.abs(),
|
||||
2 => dx.abs().max(dy.abs()),
|
||||
_ => dx.hypot(dy),
|
||||
};
|
||||
let maxd = max_corner_dist(b, c, |dx, dy| {
|
||||
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
||||
metric(qx, qy)
|
||||
});
|
||||
GridWipe { c, s, co, cs, maxd, shape, reverse, done, border }
|
||||
}
|
||||
|
||||
/// Alpha multiplier at a buffer pixel centre.
|
||||
pub fn coverage(&self, x: u32, y: u32) -> f32 {
|
||||
let Self { c, s, co, cs, maxd, shape, reverse, done, border } = *self;
|
||||
let metric = |dx: f64, dy: f64| match shape {
|
||||
0 => dx.abs(),
|
||||
2 => dx.abs().max(dy.abs()),
|
||||
_ => dx.hypot(dy),
|
||||
};
|
||||
let spread = 0.25;
|
||||
let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
|
||||
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
||||
let (cx, cy) = (((qx / cs).floor() + 0.5) * cs, ((qy / cs).floor() + 0.5) * cs);
|
||||
let mut o = (metric(cx, cy) / maxd).clamp(0.0, 1.0);
|
||||
if reverse {
|
||||
o = 1.0 - o;
|
||||
}
|
||||
let p = ((done * (1.0 + spread) - o) / spread).clamp(0.0, 1.0);
|
||||
if p <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let half = cs * 0.5 * (1.0 - p);
|
||||
let m = (qx - cx).abs().max((qy - cy).abs());
|
||||
let k = if border > 0.0 {
|
||||
((half - m) / border + 0.5).clamp(0.0, 1.0)
|
||||
} else if m < half {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
k as f32
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let done = completion(ctx);
|
||||
if done <= 0.0 {
|
||||
if completion(ctx) <= 0.0 {
|
||||
return b;
|
||||
}
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let rot = ctx.params.f("rotation").to_radians();
|
||||
let border = ctx.params.f("border").max(0.0) * b.scale;
|
||||
let tiles = ctx.params.f("tiles").round().clamp(1.0, 500.0);
|
||||
let shape = ctx.params.e("shape");
|
||||
let reverse = ctx.params.b("reverse");
|
||||
let (_, _, w, _) = layer_rect(ctx, &b);
|
||||
let cs = (w / tiles).max(1.0);
|
||||
let (s, co) = rot.sin_cos();
|
||||
let metric = |dx: f64, dy: f64| match shape {
|
||||
0 => dx.abs(),
|
||||
2 => dx.abs().max(dy.abs()),
|
||||
_ => dx.hypot(dy),
|
||||
};
|
||||
let maxd = max_corner_dist(&b, c, |dx, dy| {
|
||||
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
||||
metric(qx, qy)
|
||||
});
|
||||
let spread = 0.25;
|
||||
let grid = GridWipe::new(ctx, &b);
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
|
||||
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
||||
let (cx, cy) = (((qx / cs).floor() + 0.5) * cs, ((qy / cs).floor() + 0.5) * cs);
|
||||
let mut o = (metric(cx, cy) / maxd).clamp(0.0, 1.0);
|
||||
if reverse {
|
||||
o = 1.0 - o;
|
||||
}
|
||||
let p = ((done * (1.0 + spread) - o) / spread).clamp(0.0, 1.0);
|
||||
if p <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let half = cs * 0.5 * (1.0 - p);
|
||||
let m = (qx - cx).abs().max((qy - cy).abs());
|
||||
let k = if border > 0.0 {
|
||||
((half - m) / border + 0.5).clamp(0.0, 1.0)
|
||||
} else if m < half {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
scale_px(px, k as f32);
|
||||
scale_px(px, grid.coverage(x as u32, y as u32));
|
||||
}
|
||||
});
|
||||
b
|
||||
|
||||
@@ -489,6 +489,8 @@ async fn run_work(w: Work, shared: &RotoShared) {
|
||||
// With the classic engine, Propagate leaves no saved tracking behind.
|
||||
*lock(&shared.saved) = Some(String::new());
|
||||
}
|
||||
// Freezing again keeps the frames frozen already (resolved once: it reads the whole frozen cache).
|
||||
let kept = if w.task == RotoTask::Freeze && !w.object_matte { fx::applying_frozen(&w.params, w.size, w.fps) } else { None };
|
||||
for (k, f) in w.frames.iter().enumerate() {
|
||||
if cancel() {
|
||||
let mut s = lock(&shared.state);
|
||||
@@ -524,7 +526,7 @@ async fn run_work(w: Work, shared: &RotoShared) {
|
||||
}
|
||||
}
|
||||
(RotoTask::Propagate, false) => chain.ensure(*f, &mut get, &cancel).is_some(),
|
||||
(RotoTask::Freeze, false) => match fx::frame_matte(&w.params, w.size, w.fps, *f, &mut get, &cancel) {
|
||||
(RotoTask::Freeze, false) => match fx::frame_matte(&w.params, w.size, w.fps, *f, kept.as_deref(), &mut get, &cancel) {
|
||||
Some((a, b)) => {
|
||||
if let Some(img) = src.get(*f) {
|
||||
frozen.size = [img.width as usize, img.height as usize];
|
||||
|
||||
@@ -558,6 +558,7 @@ async fn fill(s: &mut Session, plan: crate::commands::content_fill::FillPlan, po
|
||||
true
|
||||
};
|
||||
let accel = job_accel();
|
||||
let fill = accel.as_deref().and_then(|a| a.inpaint());
|
||||
let files = crate::commands::content_fill::run_plan(
|
||||
&plan,
|
||||
&s.project,
|
||||
@@ -566,6 +567,7 @@ async fn fill(s: &mut Session, plan: crate::commands::content_fill::FillPlan, po
|
||||
&s.layer_cache,
|
||||
s.services.as_ref(),
|
||||
accel.as_deref(),
|
||||
fill,
|
||||
&mut progress,
|
||||
&mut |_| {},
|
||||
)
|
||||
|
||||
@@ -417,6 +417,10 @@ impl FrameServer {
|
||||
auto = Some((key, gpu));
|
||||
}
|
||||
}
|
||||
// A frame still waiting for readbacks is replaced: end it, so every `frame_begin` has its `frame_end`.
|
||||
if self.pending.take().is_some() {
|
||||
a.frame_end();
|
||||
}
|
||||
a.frame_begin();
|
||||
}
|
||||
self.pending = Some(Pending { id, project: p, comp, time, opts, t0: web_time::Instant::now(), passes: 0, auto });
|
||||
@@ -457,6 +461,10 @@ impl FrameServer {
|
||||
{
|
||||
pick.record(key, chose, ms);
|
||||
}
|
||||
// The frame is done (its `frame_begin` ran in `handle`): the GPU pool ages its sizes.
|
||||
if let Some(a) = &self.accel {
|
||||
a.frame_end();
|
||||
}
|
||||
vec![(
|
||||
FrameReply::Frame { id: pr.id, width: img.width, height: img.height, ms, passes: pr.passes, gpu, layer_misses, layer_hits },
|
||||
Some(rgba8_premultiplied(&img)),
|
||||
|
||||
@@ -192,6 +192,8 @@ struct Deferred {
|
||||
asked: std::sync::Mutex<std::collections::HashSet<u64>>,
|
||||
gate: Arc<std::sync::atomic::AtomicBool>,
|
||||
chains: std::sync::atomic::AtomicUsize,
|
||||
begins: std::sync::atomic::AtomicUsize,
|
||||
ends: std::sync::atomic::AtomicUsize,
|
||||
auto: effectcraft_render::AutoPick,
|
||||
}
|
||||
|
||||
@@ -245,8 +247,12 @@ impl Accelerator for Deferred {
|
||||
Some(&self.auto)
|
||||
}
|
||||
fn frame_begin(&self) {
|
||||
self.begins.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.ready.lock().unwrap().clear();
|
||||
}
|
||||
fn frame_end(&self) {
|
||||
self.ends.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
fn pass_begin(&self) {
|
||||
self.gate.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
@@ -316,3 +322,56 @@ fn deferred_readbacks_render_in_passes() {
|
||||
let st = acc.auto.stats(effectcraft_render::AutoKey::new(comp, opts.scale, false)).unwrap();
|
||||
assert!(st.cpu_frames > 0 && st.gpu_frames > 0, "{st:?}");
|
||||
}
|
||||
|
||||
/// `frame_begin` and `frame_end` stay paired: early fails (before `frame_begin`), a pending frame replaced by a new render, a finished multi-pass frame, and a CPU frame.
|
||||
#[test]
|
||||
fn every_frame_begin_has_a_frame_end() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#3388ff", "name": "Blue", "width": 200, "height": 120})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "ec.stylize.glow"})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ffcc00", "name": "Yellow", "width": 80, "height": 80})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "ec.color.levels"})).unwrap();
|
||||
let opts = RenderOpts { scale: 0.25, backend: Backend::Gpu, ..Default::default() };
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let mut w = server(&s);
|
||||
let acc = Arc::new(Deferred::default());
|
||||
w.set_accel(Some(acc.clone()));
|
||||
let mut m = Mirror::default();
|
||||
w.handle(round(&m.sync(s.revision, &s.project).unwrap()));
|
||||
let req = |id, revision, comp, opts: RenderOpts| {
|
||||
round(&FrameMsg::Render { id, revision, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] })
|
||||
};
|
||||
let load = || (acc.begins.load(Relaxed), acc.ends.load(Relaxed));
|
||||
// Early fails never touch the frame clock.
|
||||
assert!(matches!(w.handle(req(1, s.revision + 1, comp, opts))[0].0, FrameReply::Failed { .. }));
|
||||
assert!(matches!(w.handle(req(2, s.revision, ItemId(999_999), opts))[0].0, FrameReply::Failed { .. }));
|
||||
assert_eq!(load(), (0, 0));
|
||||
// First GPU pass misses and leaves the frame pending.
|
||||
assert!(w.handle(req(3, s.revision, comp, opts)).is_empty() && w.waiting());
|
||||
assert_eq!(load(), (1, 0));
|
||||
// A fail while pending leaves the frame and the counters alone.
|
||||
assert!(matches!(w.handle(req(4, s.revision + 1, comp, opts))[0].0, FrameReply::Failed { .. }));
|
||||
assert!(w.waiting());
|
||||
assert_eq!(load(), (1, 0));
|
||||
// A new valid render replaces the pending frame (ends the old one).
|
||||
assert!(w.handle(req(5, s.revision, comp, opts)).is_empty() && w.waiting());
|
||||
assert_eq!(load(), (2, 1));
|
||||
let mut out = vec![];
|
||||
for _ in 0..10 {
|
||||
acc.settle();
|
||||
out = w.resume();
|
||||
if !w.waiting() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(matches!(out[0].0, FrameReply::Frame { id: 5, .. }));
|
||||
assert_eq!(load(), (2, 2));
|
||||
// CPU still pairs begin/end on the accelerator.
|
||||
let cpu = RenderOpts { backend: Backend::Cpu, ..opts };
|
||||
assert!(matches!(w.handle(req(6, s.revision, comp, cpu))[0].0, FrameReply::Frame { id: 6, .. }));
|
||||
assert_eq!(load(), (3, 3));
|
||||
}
|
||||
@@ -18,8 +18,8 @@ pub mod content_fill;
|
||||
mod create;
|
||||
mod edit;
|
||||
mod effect;
|
||||
pub use effect::CryptoProbeJob;
|
||||
pub(crate) use effect::{CryptoProbeSlot, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
|
||||
pub use effect::{CryptoCoverage, CryptoProbeJob};
|
||||
pub(crate) use effect::{CryptoProbeSlot, crypto_coverage, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
|
||||
pub mod essential;
|
||||
pub mod expr_tools;
|
||||
mod file;
|
||||
|
||||
@@ -555,7 +555,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_keys,
|
||||
exponential_scale
|
||||
),
|
||||
cmd!("text.addSelector", "Add Text Selector", [], None, "{layer?, animator?: uid|index, kind: range|wiggly|expression}", has_text_layer, add_selector),
|
||||
cmd!(
|
||||
"text.addSelector",
|
||||
"Add Text Selector",
|
||||
[],
|
||||
None,
|
||||
"{layer?, animator?: uid|index (1-based; a uid match wins over an index), kind: range|wiggly|expression}",
|
||||
has_text_layer,
|
||||
add_selector
|
||||
),
|
||||
cmd!("text.removeAllAnimators", "Remove All Text Animators", ["Animation"], None, "{layers?}", has_text_layer, remove_all_animators),
|
||||
cmd!("anim.reveal", "Reveal Properties", [], None, "{kind: keyframes|animation|modified}", has_comp, reveal),
|
||||
]
|
||||
|
||||
@@ -64,8 +64,9 @@ fn trace_frame(img: &Image, offset: [f64; 2], ch: Channel, invert: bool, blur: f
|
||||
let mut out = trace(&field, img.width as usize, img.height as usize, o);
|
||||
for t in &mut out {
|
||||
for v in &mut t.path.vertices {
|
||||
v[0] += offset[0];
|
||||
v[1] += offset[1];
|
||||
// Source buffers are layer pixels + offset: subtract it to get back to layer space.
|
||||
v[0] -= offset[0];
|
||||
v[1] -= offset[1];
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -211,3 +212,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
run
|
||||
)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trace_frame_converts_image_pixels_back_to_layer_space() {
|
||||
// A synthetic cropped source avoids any dependency on installed fonts.
|
||||
let mut img = Image::new(32, 32);
|
||||
for y in 8..24 {
|
||||
for x in 8..24 {
|
||||
img.set(x, y, [1.0; 4]);
|
||||
}
|
||||
}
|
||||
let opts = TraceOpts { corner_roundness: 0.0, ..Default::default() };
|
||||
for offset in [[0.0, 0.0], [2.0, 58.0], [-12.5, 7.25]] {
|
||||
let outlines = trace_frame(&img, offset, Channel::Alpha, false, 0.0, &opts);
|
||||
assert_eq!(outlines.len(), 1);
|
||||
let vertices = &outlines[0].path.vertices;
|
||||
for axis in 0..2 {
|
||||
// image = layer + offset, so the known image bounds map back
|
||||
// to [8 - offset, 24 - offset] in layer space.
|
||||
let lo = vertices.iter().map(|v| v[axis]).fold(f64::INFINITY, f64::min);
|
||||
let hi = vertices.iter().map(|v| v[axis]).fold(f64::NEG_INFINITY, f64::max);
|
||||
assert!((lo - (8.0 - offset[axis])).abs() < 0.01, "{offset:?}: {lo}");
|
||||
assert!((hi - (24.0 - offset[axis])).abs() < 0.01, "{offset:?}: {hi}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,13 +246,18 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out["deleted"] = json!(settings.deleted.len());
|
||||
out["lensDistortionSolved"] = json!(settings.lens_distortion);
|
||||
let solve = ct::solve(¶ms);
|
||||
out["solved"] = json!(solve.is_some());
|
||||
// The solver interpolates the frames it could not solve: only a solve of every frame is
|
||||
// `solved` (what `camera.exportBlender` needs); one with gaps is `partial`.
|
||||
let solved_frames = solve.as_ref().map_or(0, |sv| sv.frames.iter().filter(|c| c.solved).count());
|
||||
let complete = solve.as_ref().is_some_and(|sv| solved_frames == sv.frames.len());
|
||||
out["solved"] = json!(complete);
|
||||
out["partial"] = json!(solve.is_some() && !complete);
|
||||
if let Some(sv) = &solve {
|
||||
let f = sv.focal();
|
||||
out["methodUsed"] = json!(sv.method_used.label());
|
||||
out["averageError"] = json!(sv.average_error);
|
||||
out["points"] = json!(sv.points.len());
|
||||
out["solvedFrames"] = json!(sv.frames.iter().filter(|c| c.solved).count());
|
||||
out["solvedFrames"] = json!(solved_frames);
|
||||
out["focalLength"] = json!(f);
|
||||
out["horizontalAngleOfView"] = json!(sv.hfov(f));
|
||||
out["groundPlane"] = json!(sv.ground.is_some());
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerId, LayerSource, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, dilate, fill_sequence};
|
||||
use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, dilate};
|
||||
use effectcraft_render::{Accelerator, EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -185,8 +185,21 @@ fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option<Str
|
||||
})
|
||||
}
|
||||
|
||||
/// The status line shown while the frames are filled. Edge Blend calls only the membrane, which
|
||||
/// the device backend leaves to the CPU reference, so it is not a device fill even when a `fill`
|
||||
/// backend is present; Object and Surface run the flow and PatchMatch on the device.
|
||||
fn filling_status(on_device: bool, method: FillMethod) -> &'static str {
|
||||
if on_device && method != FillMethod::EdgeBlend { "Filling (GPU)" } else { "Filling" }
|
||||
}
|
||||
|
||||
/// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the
|
||||
/// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files.
|
||||
///
|
||||
/// `accel` renders the frames (a job worker's GPU, as for every analysis: `None` on the
|
||||
/// desktop, whose analysis threads render on the CPU by design); `fill` is the fill's own
|
||||
/// backend ([`effectcraft_raster::inpaint::FillBackend`]), which is what makes Content-Aware
|
||||
/// Fill itself run on the GPU. They are separate on purpose: a desktop fill can run on the GPU
|
||||
/// while its input frames are composited on the CPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn run_plan(
|
||||
plan: &FillPlan,
|
||||
@@ -196,6 +209,7 @@ pub(crate) async fn run_plan(
|
||||
cache: &LayerCache,
|
||||
services: &dyn crate::Services,
|
||||
accel: Option<&dyn Accelerator>,
|
||||
fill: Option<&dyn effectcraft_raster::inpaint::FillBackend>,
|
||||
progress: &mut dyn FnMut(u64, u64) -> bool,
|
||||
message: &mut dyn FnMut(&str),
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
@@ -233,10 +247,13 @@ pub(crate) async fn run_plan(
|
||||
references.push((k, img));
|
||||
}
|
||||
}
|
||||
message("Filling");
|
||||
let opts = FillOpts { method: FillMethod::from_name(&plan.method).unwrap_or_default(), lighting: plan.lighting, ..Default::default() };
|
||||
message(filling_status(fill.is_some(), opts.method));
|
||||
let input = FillInput { frames, holes, references };
|
||||
let out = fill_sequence(&input, &opts, &mut |d, t| progress(n + (d as u64 * n / t.max(1) as u64), n * 3)).ok_or(crate::render_queue::CANCELLED)?;
|
||||
let cpu = effectcraft_raster::inpaint::CpuFill;
|
||||
let backend: &dyn effectcraft_raster::inpaint::FillBackend = fill.unwrap_or(&cpu);
|
||||
let out = effectcraft_raster::inpaint::fill_sequence_with(&input, &opts, backend, &mut |d, t| progress(n + (d as u64 * n / t.max(1) as u64), n * 3))
|
||||
.ok_or(crate::render_queue::CANCELLED)?;
|
||||
message("Writing the fill layer");
|
||||
let dir = &plan.dir;
|
||||
let mut paths = vec![];
|
||||
@@ -324,7 +341,12 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let services = s.services.clone();
|
||||
let importer = s.importer.clone();
|
||||
let name = layer.name.clone();
|
||||
// The fill's own GPU backend (Mercury GPU Acceleration; none under Software Only or
|
||||
// without a compositor). The frames themselves still render on the CPU here, as for every
|
||||
// desktop analysis (`offload::job_accel`): only the fill's own work moves to the device.
|
||||
let accel = if s.project.settings.gpu_acceleration { s.accel.clone() } else { None };
|
||||
s.spawn_task("contentFill", format!("Content-Aware Fill: {name}"), wait, move |ctl| {
|
||||
let fill = accel.as_deref().and_then(|a| a.inpaint());
|
||||
let paths = effectcraft_render::passes::block_on(run_plan(
|
||||
&plan,
|
||||
&lf.project,
|
||||
@@ -333,6 +355,7 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&lf.cache,
|
||||
services.as_ref(),
|
||||
None,
|
||||
fill,
|
||||
&mut |d, t| ctl.progress(d, t),
|
||||
&mut |m| ctl.message(m),
|
||||
))?;
|
||||
@@ -445,7 +468,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::memory_shortfall;
|
||||
use super::{filling_status, memory_shortfall};
|
||||
|
||||
/// Edge Blend fills with the membrane, which the device backend leaves to the CPU reference:
|
||||
/// it must not claim a GPU fill just because a backend is available.
|
||||
#[test]
|
||||
fn edge_blend_is_not_reported_as_a_device_fill() {
|
||||
use effectcraft_raster::inpaint::FillMethod;
|
||||
assert_eq!(filling_status(true, FillMethod::Object), "Filling (GPU)");
|
||||
assert_eq!(filling_status(true, FillMethod::Surface), "Filling (GPU)");
|
||||
assert_eq!(filling_status(true, FillMethod::EdgeBlend), "Filling");
|
||||
assert_eq!(filling_status(false, FillMethod::Object), "Filling");
|
||||
}
|
||||
|
||||
/// #1789: 10 s of 1080p30 (300 frames) needs at least 20 GB, which 4 GB free can't hold.
|
||||
#[test]
|
||||
|
||||
@@ -500,6 +500,39 @@ pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result<Option<CryptoPr
|
||||
})))
|
||||
}
|
||||
|
||||
/// [`Session::crypto_coverage`].
|
||||
pub(crate) fn crypto_coverage(s: &Session, p: &Value, hash: u32, max_side: usize) -> Option<CryptoCoverage> {
|
||||
let (key, ..) = probe_key(s, p, "effect.cryptomatteAt").ok()?;
|
||||
let probe = cached_probe(s, key)?;
|
||||
let b = probe.grid.as_ref().ok()?;
|
||||
let (aw, ah) = (b.aux.width as usize, b.aux.height as usize);
|
||||
if aw == 0 || ah == 0 {
|
||||
return None;
|
||||
}
|
||||
let step = aw.max(ah).div_ceil(max_side.max(1)).max(1);
|
||||
let (w, h) = (aw.div_ceil(step), ah.div_ceil(step));
|
||||
let planes = b.aux.crypto_rank_planes(&b.layer);
|
||||
let sample = |j: usize| {
|
||||
planes.iter().filter(|(id, _)| id.get(j).is_some_and(|v| v.to_bits() == hash)).map(|(_, c)| c.get(j).map_or(0.0, |c| c.max(0.0))).sum::<f32>().min(1.0)
|
||||
};
|
||||
// Each sample at the centre of its step × step cell; the last cells are cut by the layer.
|
||||
let at = |x: usize, y: usize| sample((y * step + step / 2).min(ah - 1) * aw + (x * step + step / 2).min(aw - 1));
|
||||
let coverage = (0..w * h).map(|i| at(i % w, i / w)).collect();
|
||||
let (size, uv) = ([aw as f64 / b.aux.scale, ah as f64 / b.aux.scale], [aw as f64 / (w * step) as f64, ah as f64 / (h * step) as f64]);
|
||||
Some(CryptoCoverage { width: w, height: h, coverage, size, uv })
|
||||
}
|
||||
|
||||
/// An ID's coverage over a layer, for the eyedropper's hover highlight: `width` × `height`
|
||||
/// samples (row-major, 0..1). The layer, `size` effect-space pixels from its origin, shows the
|
||||
/// texture from (0, 0) to `uv`.
|
||||
pub struct CryptoCoverage {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub coverage: Vec<f32>,
|
||||
pub size: [f64; 2],
|
||||
pub uv: [f64; 2],
|
||||
}
|
||||
|
||||
/// What a pick finds at a point.
|
||||
enum Hit {
|
||||
/// `probeOnly` and the probe isn't built yet.
|
||||
|
||||
@@ -201,6 +201,11 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// "Pre-comp N" with the lowest N no item already uses.
|
||||
fn unused_precomp_name(proj: &effectcraft_project::Project) -> String {
|
||||
(1u32..).map(|n| format!("Pre-comp {n}")).find(|n| !proj.items.values().any(|i| &i.name == n)).unwrap_or_else(|| "Pre-comp".to_string())
|
||||
}
|
||||
|
||||
/// Layer ▸ Pre-compose. `mode: move` (default; "Move all attributes into the new composition")
|
||||
/// nests the selected layers as they are; `mode: leave` ("Leave all attributes in", one footage,
|
||||
/// solid or precomp layer) nests only the layer's source, sized to it, and keeps the layer's
|
||||
@@ -212,7 +217,10 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(c, "no layers"));
|
||||
}
|
||||
let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string();
|
||||
let name = match str_p(p, "name") {
|
||||
Some(n) => n.to_string(),
|
||||
None => unused_precomp_name(&s.project),
|
||||
};
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let leave = match str_p(p, "mode").unwrap_or("move") {
|
||||
"move" => false,
|
||||
@@ -280,6 +288,15 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0);
|
||||
c.layers.retain(|l| !ids.contains(&l.id));
|
||||
// Layers left behind must not keep pointing at layers that moved into the new comp.
|
||||
for l in &mut c.layers {
|
||||
if l.parent.is_some_and(|p| ids.contains(&p)) {
|
||||
l.parent = None;
|
||||
}
|
||||
if l.track_matte.as_ref().is_some_and(|m| ids.contains(&m.layer)) {
|
||||
l.track_matte = None;
|
||||
}
|
||||
}
|
||||
let lid = l.id;
|
||||
c.layers.insert(at.min(c.layers.len()), l);
|
||||
st.selected_layers = vec![lid];
|
||||
|
||||
@@ -171,9 +171,29 @@ fn kind_p(p: &Value, cmd: &str) -> Result<PrimitiveKind> {
|
||||
PrimitiveKind::parse(k).ok_or_else(|| bad(cmd, format!("kind: cube|sphere|plane|torus|cone|cylinder, not `{k}`")))
|
||||
}
|
||||
|
||||
/// Parse Material Options parameters onto a layer at comp time `t`.
|
||||
fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
/// The Material Options parameters [`apply_material`] reads. Which ones a layer has depends on
|
||||
/// its kind (see [`material_mismatch`]).
|
||||
const MATERIAL_PARAMS: [&str; 14] = [
|
||||
"castsShadows",
|
||||
"acceptsShadows",
|
||||
"acceptsLights",
|
||||
"appearsInReflections",
|
||||
"baseColor",
|
||||
"metallic",
|
||||
"roughness",
|
||||
"emissive",
|
||||
"lightTransmission",
|
||||
"ambient",
|
||||
"diffuse",
|
||||
"specularIntensity",
|
||||
"specularShininess",
|
||||
"metal",
|
||||
];
|
||||
|
||||
/// Parse Material Options parameters onto a layer at comp time `t`. The parameters the layer
|
||||
/// had a property for.
|
||||
fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<Vec<&'static str>> {
|
||||
let mut set = Vec::new();
|
||||
let tri = |v: &Value| -> Option<u32> {
|
||||
match v {
|
||||
Value::Bool(b) => Some(*b as u32),
|
||||
@@ -187,28 +207,55 @@ fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<usize>
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let mut put = |l: &mut Layer, k: &'static str, v: KV| {
|
||||
if set_at(l, &format!("materialOptions/{k}"), t, v) {
|
||||
set.push(k);
|
||||
}
|
||||
};
|
||||
for k in ["castsShadows", "acceptsShadows"] {
|
||||
if let Some(v) = p.get(k) {
|
||||
let e = tri(v).ok_or_else(|| bad(cmd, format!("{k}: off|on|only")))?;
|
||||
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Enum(e)) as usize;
|
||||
put(l, k, KV::Enum(e));
|
||||
}
|
||||
}
|
||||
// A value of the wrong type is an error, not a parameter left out.
|
||||
for k in ["acceptsLights", "appearsInReflections"] {
|
||||
if let Some(v) = b_p(p, k) {
|
||||
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Bool(v)) as usize;
|
||||
if p.get(k).is_some() {
|
||||
let v = b_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: true|false")))?;
|
||||
put(l, k, KV::Bool(v));
|
||||
}
|
||||
}
|
||||
for k in ["lightTransmission", "ambient", "diffuse", "specularIntensity", "specularShininess", "metal", "metallic", "roughness"] {
|
||||
if let Some(v) = f_p(p, k) {
|
||||
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Scalar(v)) as usize;
|
||||
if p.get(k).is_some() {
|
||||
let v = f_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: a number")))?;
|
||||
put(l, k, KV::Scalar(v));
|
||||
}
|
||||
}
|
||||
for k in ["baseColor", "emissive"] {
|
||||
if let Some(c) = color_p(p, k) {
|
||||
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])) as usize;
|
||||
if p.get(k).is_some() {
|
||||
let c = color_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: \"#rrggbb\" or [r, g, b]")))?;
|
||||
put(l, k, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0]));
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// The error for Material Options parameters `missing` that none of the layers has: what the
|
||||
/// first layer has instead, and which layers the missing ones belong to.
|
||||
fn material_mismatch(l: Option<&Layer>, missing: &[&str]) -> EngineError {
|
||||
let names = missing.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", ");
|
||||
let Some(l) = l else { return bad("material.set", format!("no layer has {names}")) };
|
||||
let Some(g) = l.props.sub("materialOptions") else { return bad("material.set", format!("`{}` has no Material Options, so it has no {names}", l.name)) };
|
||||
let has: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
|
||||
let why = if matches!(l.source, LayerSource::Model { .. }) { ": a 3D model layer keeps its model file's materials" } else { "" };
|
||||
bad(
|
||||
"material.set",
|
||||
format!(
|
||||
"`{}` has no {names}{why}. Its Material Options: {}. baseColor, metallic, roughness and emissive are for 3D primitive layers; lightTransmission, ambient, diffuse, specularIntensity, specularShininess and metal for footage, solid, text, shape and precomp layers",
|
||||
l.name,
|
||||
has.join(", ")
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -273,19 +320,22 @@ fn material_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let t = super::time_p(s, p, Some(&comp));
|
||||
let n = s.edit("Material Options", None, |proj, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
n += apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?;
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Err(bad(
|
||||
"material.set",
|
||||
"no Material Options property matched (castsShadows, acceptsShadows, acceptsLights, baseColor, metallic, roughness, …)",
|
||||
));
|
||||
if !MATERIAL_PARAMS.iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad("material.set", format!("no Material Options parameter given ({})", MATERIAL_PARAMS.join(", "))));
|
||||
}
|
||||
// A parameter none of the layers has fails the whole command (the edit is dropped), so
|
||||
// it is never skipped without a word.
|
||||
let n = s.edit("Material Options", None, |proj, _| {
|
||||
let mut set = Vec::new();
|
||||
for id in &ids {
|
||||
set.extend(apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?);
|
||||
}
|
||||
let missing: Vec<&str> = MATERIAL_PARAMS.into_iter().filter(|k| p.get(*k).is_some() && !set.contains(k)).collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(material_mismatch(ids.first().and_then(|id| proj.comp(cid)?.layer(*id)), &missing));
|
||||
}
|
||||
Ok(set.len())
|
||||
})?;
|
||||
Ok(json!({"set": n}))
|
||||
}
|
||||
|
||||
@@ -411,7 +461,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Material Options",
|
||||
[],
|
||||
None,
|
||||
"{layers?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections?, lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal?, baseColor?, metallic?, roughness?, emissive?, time?}",
|
||||
"{layers?, time?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections? (model and primitive layers), baseColor?, metallic?, roughness?, emissive? (primitive layers; a model layer keeps its file's materials), lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal? (footage, solid, text, shape and precomp layers)}; a parameter none of the layers has is an error",
|
||||
has_layers,
|
||||
material_set
|
||||
),
|
||||
|
||||
@@ -39,6 +39,18 @@ fn deselect_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The selected mask (its group, or a property inside it) of a selected layer, the first
|
||||
/// one: (layer, mask group).
|
||||
pub fn selected_mask(&self) -> Option<(effectcraft_project::LayerId, effectcraft_project::Uid)> {
|
||||
let comp = self.active_comp()?;
|
||||
self.state.selected_props.iter().find_map(|(lid, uid)| {
|
||||
let masks = comp.layer(*lid)?.masks()?;
|
||||
masks.groups().find(|g| g.uid == *uid || g.find_group(*uid).is_some() || g.find(*uid).is_some()).map(|g| (*lid, g.uid))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt+` / Alt+Shift+`: select the next (previous) mask of the layer, wrapping around; the first
|
||||
/// (last) one when none is selected.
|
||||
fn step_mask(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//!
|
||||
//! Pin positions are in effect space. A new pin is attached to the mesh under it: the click is
|
||||
//! mapped back through the current deformation to the rest mesh (its hidden rest position), and
|
||||
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects.
|
||||
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects. Off
|
||||
//! every mesh a pin starts a new mesh, unless its art already has one (#2865).
|
||||
|
||||
use effectcraft_effects::puppet::{self, MeshOpts, PinKind};
|
||||
use effectcraft_effects::{Buf, Params};
|
||||
@@ -62,6 +63,32 @@ fn layer_time(s: &Session, cid: ItemId, lid: LayerId, t: Tick) -> Result<Tick> {
|
||||
Ok(l.layer_time(t))
|
||||
}
|
||||
|
||||
/// Whether a pin at `at` (effect space) goes on this mesh as `def` deforms it: on it or beside it.
|
||||
fn on_mesh(mesh: &puppet::Mesh, def: &[[f64; 2]], at: [f64; 2]) -> bool {
|
||||
!mesh.tris.is_empty() && (puppet::contains(mesh, def, at) || def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0)
|
||||
}
|
||||
|
||||
/// Whether a new mesh at `at` (effect space) would repeat a mesh of `params`: the art it would
|
||||
/// outline is that mesh's own, even if a deformation has carried the mesh off it (#2865).
|
||||
fn repeats_mesh(buf: &Buf, params: &Params, at: [f64; 2]) -> bool {
|
||||
let (w, h) = (buf.img.width as usize, buf.img.height as usize);
|
||||
let alpha: Vec<f32> = buf.img.data.iter().map(|p| p[3]).collect();
|
||||
puppet::parse(params).iter().any(|m| {
|
||||
let mut outline = puppet::mesh::outline(&alpha, w, h, buf.scale.max(1e-9), buf.offset, at, m.opts.expansion);
|
||||
// As `triangulate` winds it.
|
||||
if puppet::mesh::signed_area(&outline) < 0.0 {
|
||||
outline.reverse();
|
||||
}
|
||||
puppet::mesh_for(buf, m).outline == outline
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `puppet.addPin` at `at` (effect space) places a pin on a layer with a Puppet effect.
|
||||
pub fn places_pin(s: &Session, cid: ItemId, lid: LayerId, at: [f64; 2], t: Tick) -> bool {
|
||||
let Some((buf, params)) = find_puppet(s, cid, lid).and_then(|fu| puppet_eval(s, cid, lid, fu, t)) else { return true };
|
||||
!repeats_mesh(&buf, ¶ms, at) || puppet::overlay(&buf, ¶ms).iter().any(|(_, mesh, def, _)| on_mesh(mesh, def, at))
|
||||
}
|
||||
|
||||
fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "puppet.addPin";
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
@@ -93,9 +120,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if want_mesh.is_some_and(|w| w != *uid) || mesh.tris.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let inside = puppet::contains(mesh, def, at);
|
||||
let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0;
|
||||
if inside || near || want_mesh.is_some() {
|
||||
if on_mesh(mesh, def, at) || want_mesh.is_some() {
|
||||
let rest = puppet::unmap(mesh, def, at);
|
||||
// A moving pin holds its nearest mesh vertex at `position + (vertex - rest)`
|
||||
// (puppet::solve). Choose the position that keeps that vertex where it is now, so
|
||||
@@ -120,6 +145,9 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(cmd, format!("no mesh {w}")));
|
||||
}
|
||||
if target.is_none() && repeats_mesh(&buf, ¶ms, at) {
|
||||
return Ok(json!({"pin": null, "changed": false}));
|
||||
}
|
||||
}
|
||||
let opts = MeshOpts {
|
||||
density: f_p(p, "density").unwrap_or(s.state.puppet.density),
|
||||
|
||||
@@ -49,8 +49,9 @@ fn per_char_on(l: &Layer) -> bool {
|
||||
l.props.prop("text/perChar3d").is_some_and(|p| p.value.as_bool())
|
||||
}
|
||||
|
||||
/// The animator a command targets: `animator` (uid or 1-based index), else the animator holding
|
||||
/// a selected property, else the last one.
|
||||
/// The animator a command targets: `animator` (uid or 1-based index; a uid match wins, so an
|
||||
/// animator whose uid equals an index is picked over the one at that position), else the animator
|
||||
/// holding a selected property, else the last one.
|
||||
fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
|
||||
let anims = l.props.group("text/animators").ok_or_else(|| bad(cmd, "not a text layer"))?;
|
||||
let list: Vec<&PropGroup> = anims.groups().collect();
|
||||
@@ -59,7 +60,11 @@ fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
|
||||
if let Some(g) = list.iter().find(|g| g.uid == n) {
|
||||
return Ok(g.uid);
|
||||
}
|
||||
return list.get((n as usize).wrapping_sub(1)).map(|g| g.uid).ok_or_else(|| bad(cmd, format!("no animator {n}")));
|
||||
let count = if list.len() == 1 { "1 animator".to_string() } else { format!("{} animators", list.len()) };
|
||||
return list
|
||||
.get((n as usize).wrapping_sub(1))
|
||||
.map(|g| g.uid)
|
||||
.ok_or_else(|| bad(cmd, format!("no animator {n}: `animator` is a uid or a 1-based index, and the layer has {count}")));
|
||||
}
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
if *lid != l.id {
|
||||
@@ -454,7 +459,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Property",
|
||||
[],
|
||||
None,
|
||||
"{layer?, animator?: uid|index, property: (see layer.addTextAnimator)}",
|
||||
"{layer?, animator?: uid|index (1-based; a uid match wins over an index), property: (see layer.addTextAnimator)}",
|
||||
has_animator,
|
||||
add_animator_prop
|
||||
),
|
||||
@@ -463,7 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Text Selector",
|
||||
[],
|
||||
None,
|
||||
"{layer?, animator?: uid|index, kind: range|wiggly|expression}",
|
||||
"{layer?, animator?: uid|index (1-based; a uid match wins over an index), kind: range|wiggly|expression}",
|
||||
has_animator,
|
||||
add_selector
|
||||
),
|
||||
@@ -482,7 +487,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Variable Font Axes",
|
||||
["Animation", "Animate Text"],
|
||||
None,
|
||||
"{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index}",
|
||||
"{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index (1-based; a uid match wins over an index)}",
|
||||
has_text_layer,
|
||||
font_axes
|
||||
),
|
||||
|
||||
@@ -36,6 +36,13 @@ impl Session {
|
||||
commands::crypto_probe_job(self, p)
|
||||
}
|
||||
|
||||
/// The coverage of Cryptomatte ID `hash` (as `effect.cryptomatteAt` reports it, parsed) over
|
||||
/// the layer of params `p`, at most `max_side` samples on the long side, from the kept probe:
|
||||
/// `None` until [`Session::crypto_probe_job`] has built it.
|
||||
pub fn crypto_coverage(&self, p: &Value, hash: u32, max_side: usize) -> Option<commands::CryptoCoverage> {
|
||||
commands::crypto_coverage(self, p, hash, max_side)
|
||||
}
|
||||
|
||||
/// Let go of the kept Cryptomatte probe (and its aux channels), and cancel a build in
|
||||
/// flight: the eyedropper was disarmed. Edits, footage changes and Open / New do it too.
|
||||
pub fn drop_crypto_probe(&self) {
|
||||
|
||||
@@ -76,7 +76,15 @@ impl Prefs {
|
||||
Err(_) => {
|
||||
let mut base = serde_json::to_value(Prefs::default()).unwrap_or_default();
|
||||
if let (Some(b), Some(o)) = (base.as_object_mut(), v.as_object()) {
|
||||
// Only the settings' own pages are tried one by one (each trial parses the
|
||||
// whole document): any other key is kept as it is, so a document of many
|
||||
// unknown keys costs one pass, not one parse per key.
|
||||
let pages: Vec<String> = b.keys().cloned().collect();
|
||||
for (k, x) in o {
|
||||
if !pages.contains(k) {
|
||||
b.insert(k.clone(), x.clone());
|
||||
continue;
|
||||
}
|
||||
let mut trial = b.clone();
|
||||
trial.insert(k.clone(), x.clone());
|
||||
if serde_json::from_value::<Prefs>(Value::Object(trial)).is_ok() {
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
//! (as After Effects asks you to "Save As" a custom set). The shortcut dispatcher and the menus
|
||||
//! read the active preset through [`ShortcutTable`].
|
||||
//!
|
||||
//! Scopes, narrowest first: a *context* ([`CONTEXT_SCOPES`]: a state of the app such as editing
|
||||
//! text; its shortcuts work only while it is active), a *panel* (`Project`, `Timeline`,
|
||||
//! `RenderQueue`…: the panel with the keyboard) and the application. One chord can be bound once
|
||||
//! per scope; the narrowest active scope runs it ([`scope_rank`]).
|
||||
//!
|
||||
//! Shortcut text is `Ctrl+Cmd+Alt+Shift+Key` (`Cmd` is ⌘ on macOS and Ctrl elsewhere).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
@@ -29,6 +34,97 @@ pub struct UiCommand {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub shortcut: Option<String>,
|
||||
/// Its scope; none: from its id (`timeline.*` → Timeline, `view.*` → Composition).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
/// Scopes that are a state of the app rather than a panel: editing text in the viewer, a paint
|
||||
/// tool active, a mask selected, a camera tool active.
|
||||
pub const CONTEXT_SCOPES: &[&str] = &["TextEditing", "Paint", "Mask", "Camera"];
|
||||
|
||||
/// Engine commands and menu entries whose shortcuts belong to one panel or context (binding key
|
||||
/// → scope). Every other one is application-wide; frontend commands state their own.
|
||||
const SCOPES: &[(&str, &str)] = &[
|
||||
("file.rememberInterpretation", "Project"),
|
||||
("file.applyInterpretation", "Project"),
|
||||
("project.deleteWithoutConfirmation", "Project"),
|
||||
("renderQueue.duplicate", "RenderQueue"),
|
||||
];
|
||||
|
||||
/// Scope of an engine command or menu entry from its binding key.
|
||||
pub fn scope_of(key: &str) -> &'static str {
|
||||
SCOPES.iter().find(|(k, _)| *k == key).map_or(APP_SCOPE, |(_, s)| s)
|
||||
}
|
||||
|
||||
pub fn is_context(scope: &str) -> bool {
|
||||
CONTEXT_SCOPES.contains(&scope)
|
||||
}
|
||||
|
||||
/// The Timeline and the Composition panel show the same composition and share their shortcuts:
|
||||
/// the layer keys work with either one focused.
|
||||
pub fn panels_share(a: &str, b: &str) -> bool {
|
||||
matches!((a, b), ("Timeline", "Composition") | ("Composition", "Timeline"))
|
||||
}
|
||||
|
||||
/// A scope as shown to people (`RenderQueue` → `Render Queue`).
|
||||
pub fn scope_label(scope: &str) -> String {
|
||||
let mut out = String::with_capacity(scope.len() + 2);
|
||||
for (i, c) in scope.chars().enumerate() {
|
||||
if i > 0 && c.is_uppercase() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How strongly a binding in `scope` claims a chord while the panel `focused` has the keyboard
|
||||
/// and `contexts` are active: lower wins, `None` doesn't run at all. An active context, then the
|
||||
/// focused panel, then the panel sharing its shortcuts, then the application. A shortcut of
|
||||
/// another panel still runs when nothing nearer claims the chord (the reveal keys work from the
|
||||
/// Effect Controls panel too); a context's shortcuts never run outside it. Two other panels (or
|
||||
/// two active contexts) can claim a chord at the same rank: [`pick`] runs neither.
|
||||
pub fn scope_rank(scope: &str, focused: &str, contexts: &[&str]) -> Option<u8> {
|
||||
if is_context(scope) {
|
||||
return contexts.contains(&scope).then_some(0);
|
||||
}
|
||||
Some(if scope == focused {
|
||||
1
|
||||
} else if panels_share(scope, focused) {
|
||||
2
|
||||
} else if scope == APP_SCOPE {
|
||||
3
|
||||
} else {
|
||||
4
|
||||
})
|
||||
}
|
||||
|
||||
/// Which of the bindings matching one key press runs: `candidates` are (scope, physical,
|
||||
/// binding), in the dispatcher's order. The narrowest scope ([`scope_rank`]) wins; at the same
|
||||
/// rank an explicit physical-key binding wins, then the first one. `Err`: the best candidates
|
||||
/// belong to different scopes of the same rank (two other panels, or two active contexts), so
|
||||
/// none of them runs; it lists those scopes.
|
||||
pub fn pick<'a, T>(candidates: impl IntoIterator<Item = (&'a str, bool, T)>, focused: &str, contexts: &[&str]) -> Result<Option<T>, Vec<String>> {
|
||||
let mut best: Option<((u8, bool), &str, T)> = None;
|
||||
let mut tied: Vec<String> = vec![];
|
||||
for (scope, physical, b) in candidates {
|
||||
let Some(rank) = scope_rank(scope, focused, contexts) else { continue };
|
||||
let at = (rank, !physical);
|
||||
match &best {
|
||||
Some((r, s, _)) if *r < at || (*r == at && *s == scope) => {}
|
||||
Some((r, _, _)) if *r == at => {
|
||||
if !tied.iter().any(|t| t == scope) {
|
||||
tied.push(scope.to_string());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tied = vec![scope.to_string()];
|
||||
best = Some((at, scope, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
if tied.len() > 1 { Err(tied) } else { Ok(best.map(|(.., b)| b)) }
|
||||
}
|
||||
|
||||
/// One thing a shortcut can run.
|
||||
@@ -95,12 +191,12 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
||||
e.shortcut.as_deref().into_iter().chain(e.alt_shortcuts.iter().map(String::as_str)).chain(alt.iter().copied()).filter_map(normalize).collect();
|
||||
push(
|
||||
Bindable {
|
||||
scope: scope_of(&key).into(),
|
||||
key,
|
||||
command: e.command.clone(),
|
||||
params: e.params.clone(),
|
||||
label: e.label.clone(),
|
||||
path: path.clone(),
|
||||
scope: APP_SCOPE.into(),
|
||||
defaults,
|
||||
},
|
||||
&mut out,
|
||||
@@ -133,22 +229,22 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
||||
params: Value::Null,
|
||||
label: c.label.to_string(),
|
||||
path: vec!["Commands".into()],
|
||||
scope: APP_SCOPE.into(),
|
||||
scope: scope_of(c.id).into(),
|
||||
defaults,
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
for c in ui {
|
||||
let scope = ui_scope(&c.id);
|
||||
let cat = if scope == APP_SCOPE { "Tools & Panels" } else { scope };
|
||||
let scope = c.scope.as_deref().unwrap_or_else(|| ui_scope(&c.id));
|
||||
let cat = if scope == APP_SCOPE { "Tools & Panels".to_string() } else { scope_label(scope) };
|
||||
push(
|
||||
Bindable {
|
||||
key: c.id.clone(),
|
||||
command: c.id.clone(),
|
||||
params: Value::Null,
|
||||
label: c.label.clone(),
|
||||
path: vec![cat.into()],
|
||||
path: vec![cat],
|
||||
scope: scope.into(),
|
||||
defaults: c.shortcut.as_deref().and_then(normalize).into_iter().collect(),
|
||||
},
|
||||
@@ -290,10 +386,16 @@ pub struct Conflict {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub scope: String,
|
||||
/// The two are in different scopes, so both keep working: the narrower one runs while its
|
||||
/// panel or context is active and the other one everywhere else. `false`: the same scope,
|
||||
/// where only one of them can run.
|
||||
pub overrides: bool,
|
||||
}
|
||||
|
||||
/// Whether shortcuts of these scopes can be active at once. Two different panels can't, except
|
||||
/// the pair sharing theirs; a context can be active in any panel.
|
||||
fn scopes_overlap(a: &str, b: &str) -> bool {
|
||||
a == b || a == APP_SCOPE || b == APP_SCOPE
|
||||
a == b || a == APP_SCOPE || b == APP_SCOPE || panels_share(a, b) || is_context(a) || is_context(b)
|
||||
}
|
||||
|
||||
impl Keymaps {
|
||||
@@ -492,11 +594,19 @@ impl ShortcutTable {
|
||||
self.bindings()
|
||||
.into_iter()
|
||||
.filter(|(k, o)| *k == keys && o.key != b.key && scopes_overlap(&o.scope, &b.scope))
|
||||
.map(|(k, o)| Conflict { keys: k.to_string(), key: o.key.clone(), label: o.label.clone(), scope: o.scope.clone() })
|
||||
.map(|(k, o)| Conflict {
|
||||
keys: k.to_string(),
|
||||
key: o.key.clone(),
|
||||
label: o.label.clone(),
|
||||
scope: o.scope.clone(),
|
||||
overrides: o.scope != b.scope && !(is_context(&o.scope) && is_context(&b.scope)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every pair of commands sharing keys in an overlapping scope.
|
||||
/// Every pair of commands sharing keys in the same scope, or in two contexts (which can be
|
||||
/// active at once): only one of the two can run, or neither ([`pick`]). (Other pairs in
|
||||
/// different scopes are intended, see [`Conflict::overrides`].)
|
||||
pub fn all_conflicts(&self) -> Vec<(String, Conflict)> {
|
||||
let mut by_keys: BTreeMap<&str, Vec<&Bindable>> = BTreeMap::new();
|
||||
for (k, b) in self.bindings() {
|
||||
@@ -508,8 +618,11 @@ impl ShortcutTable {
|
||||
for (k, bs) in by_keys {
|
||||
for (i, a) in bs.iter().enumerate() {
|
||||
for b in &bs[i + 1..] {
|
||||
if scopes_overlap(&a.scope, &b.scope) {
|
||||
out.push((a.key.clone(), Conflict { keys: k.to_string(), key: b.key.clone(), label: b.label.clone(), scope: b.scope.clone() }));
|
||||
if a.scope == b.scope || (is_context(&a.scope) && is_context(&b.scope)) {
|
||||
out.push((
|
||||
a.key.clone(),
|
||||
Conflict { keys: k.to_string(), key: b.key.clone(), label: b.label.clone(), scope: b.scope.clone(), overrides: false },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,23 @@ fn blender_export_rejects_malformed_solves_without_panicking() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A solve with unsolved (interpolated) frames is partial, not solved, so the status agrees with
|
||||
/// the export, which refuses it.
|
||||
#[test]
|
||||
fn solve_status_reports_unsolved_frames_as_partial() {
|
||||
let (mut s, lid, uid) = fixture(false);
|
||||
let st = s.execute("camera.solveStatus", json!({"layer": lid.0})).unwrap();
|
||||
assert_eq!((&st["solved"], &st["partial"], &st["solvedFrames"]), (&json!(true), &json!(false), &json!(3)), "{st}");
|
||||
let mut solve =
|
||||
ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap())).unwrap().as_ref().clone();
|
||||
solve.frames[1].solved = false;
|
||||
s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap();
|
||||
s.camera_pending.clear();
|
||||
let st = s.execute("camera.solveStatus", json!({"layer": lid.0})).unwrap();
|
||||
assert_eq!((&st["solved"], &st["partial"], &st["solvedFrames"]), (&json!(false), &json!(true), &json!(2)), "{st}");
|
||||
assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("partial.py")})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_respects_script_write_permission() {
|
||||
let (mut s, lid, _) = fixture(false);
|
||||
|
||||
@@ -431,7 +431,13 @@ fn hover_latency() {
|
||||
format!("{:.3}", t0.elapsed().as_secs_f64() * 1e3)
|
||||
})
|
||||
.collect();
|
||||
eprintln!("HOVER {name}: first {} ms, then {} ms", ms[0], ms[1..].join(" / "));
|
||||
// The highlight of the hovered ID (built once per ID, at most 1024 samples wide).
|
||||
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 500.0});
|
||||
let hash = u32::from_str_radix(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["hash"].as_str().unwrap(), 16).unwrap();
|
||||
let t0 = std::time::Instant::now();
|
||||
let c = s.crypto_coverage(&q, hash, 1024).unwrap();
|
||||
let cov = t0.elapsed().as_secs_f64() * 1e3;
|
||||
eprintln!("HOVER {name}: first {} ms, then {} ms; highlight {}x{} {cov:.2} ms", ms[0], ms[1..].join(" / "), c.width, c.height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,3 +800,99 @@ fn undoing_picks_keeps_the_probe_and_the_step_says_pick() {
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
assert!(!s.crypto_probe_ready(&q).unwrap(), "an undone opacity change needs a new probe");
|
||||
}
|
||||
|
||||
/// The hover highlight: an ID's coverage from the kept probe, one sample at the centre of each
|
||||
/// cell of aux pixels, over the layer's extent (the last cells cut by it).
|
||||
#[test]
|
||||
fn coverage_of_an_id_from_the_kept_probe() {
|
||||
let s = session();
|
||||
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
|
||||
let left = crypto_hash("left");
|
||||
assert!(s.crypto_coverage(&q, left, 64).is_none(), "no coverage before the probe");
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
let c = s.crypto_coverage(&q, left, 64).unwrap();
|
||||
assert_eq!((c.width, c.height, c.size, c.uv), (16, 8, [8.0, 4.0], [1.0, 1.0]), "the aux pixels over the layer");
|
||||
assert!(c.coverage.iter().all(|v| (0.0..=1.0).contains(v)) && c.coverage.iter().any(|v| *v > 0.5) && c.coverage.contains(&0.0), "{:?}", c.coverage);
|
||||
assert_eq!(c.coverage[0], 0.6, "left's coverage at the first aux pixel");
|
||||
assert!(s.crypto_coverage(&q, crypto_hash("nobody"), 64).unwrap().coverage.iter().all(|v| *v == 0.0));
|
||||
assert_eq!(s.crypto_coverage(&q, left, 2).map(|c| (c.width, c.height)), Some((2, 1)));
|
||||
// An odd size: 2 × 2 cells, the last ones half outside the layer; samples at cell centres.
|
||||
let mut s = cells_session(1919, 1079, false);
|
||||
let q = json!({"effect": "Cryptomatte", "x": 40.0, "y": 10.0});
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "c1_0");
|
||||
let c = s.crypto_coverage(&q, crypto_hash("c1_0"), 1024).unwrap();
|
||||
assert_eq!((c.width, c.height, c.size), (960, 540, [1919.0, 1079.0]));
|
||||
assert_eq!(c.uv, [1919.0 / 1920.0, 1079.0 / 1080.0]);
|
||||
// Sample 18 covers pixels 36-37: its centre (37) is in c1_0, its corner (36) in c0_0.
|
||||
assert_eq!((c.coverage[17], c.coverage[18], c.coverage[37]), (0.0, 1.0, 0.0));
|
||||
}
|
||||
|
||||
struct Ranked(u32, u32, std::sync::OnceLock<Arc<AuxChannels>>);
|
||||
impl FootageSource for Ranked {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
|
||||
Some(
|
||||
self.2
|
||||
.get_or_init(|| {
|
||||
let (w, h) = (self.0 as usize, self.1 as usize);
|
||||
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
for part in 0..3 {
|
||||
for (k, c) in ["R", "G", "B", "A"].iter().enumerate() {
|
||||
let rank = part * 2 + k / 2;
|
||||
let v: Vec<f32> = (0..w * h)
|
||||
.map(|i| if k % 2 == 0 { crypto_float(crypto_hash(&format!("r{rank}_{}", Cells::name(i % w, i / w)))) } else { 1.0 / 6.0 })
|
||||
.collect();
|
||||
aux.channels.push((format!("CryptoObject{part:02}.{c}"), v));
|
||||
}
|
||||
}
|
||||
Arc::new(aux)
|
||||
})
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn rss_mb() -> f64 {
|
||||
let s = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
|
||||
s.lines().find(|l| l.starts_with("VmRSS")).and_then(|l| l.split_whitespace().nth(1)).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0) / 1024.0
|
||||
}
|
||||
|
||||
/// Highlight cost with 6 ranks (Cryptomatte's default 3 parts) at 1080p, 4K and 8K, and the memory the
|
||||
/// probe pins. Run with `--ignored --nocapture`; it measures, it doesn't assert.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn highlight_cost_with_six_ranks() {
|
||||
for (w, h) in [(1920u32, 1080u32), (3840, 2160), (7680, 4320)] {
|
||||
let r0 = rss_mb();
|
||||
let mut s = Session { footage: Arc::new(Ranked(w, h, Default::default())), importer: Some(Arc::new(CellsProbe(w, h))), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/renders/ranked.exr"]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 500.0});
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = s.execute("effect.cryptomatteAt", q.clone()).unwrap();
|
||||
let build = t0.elapsed().as_secs_f64() * 1e3;
|
||||
let hash = u32::from_str_radix(r["hash"].as_str().unwrap(), 16).unwrap();
|
||||
let mut ms = vec![];
|
||||
for _ in 0..5 {
|
||||
let t0 = std::time::Instant::now();
|
||||
let c = s.crypto_coverage(&q, hash, 1024).unwrap();
|
||||
ms.push(t0.elapsed().as_secs_f64() * 1e3);
|
||||
assert!(c.coverage.iter().any(|v| *v > 0.0));
|
||||
}
|
||||
ms.sort_by(f64::total_cmp);
|
||||
let aux = s.footage.aux(ItemId(0), &Footage::default(), Tick::ZERO).unwrap();
|
||||
let bytes: usize = aux.channels.iter().map(|(_, v)| v.len() * 4).sum();
|
||||
eprintln!(
|
||||
"HIGHLIGHT 6 ranks {w}x{h}: aux {:.0} MB ({} planes), probe build {build:.1} ms, highlight median {:.2} ms (min {:.2}), RSS +{:.0} MB",
|
||||
bytes as f64 / 1e6,
|
||||
aux.channels.len(),
|
||||
ms[2],
|
||||
ms[0],
|
||||
rss_mb() - r0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2035,3 +2035,33 @@ fn pasting_a_mask_onto_its_own_layer_drops_the_source_vertex_selection() {
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).masks().unwrap().groups().count(), 1, "the pasted mask goes, the source stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precompose_unlinks_layers_left_behind_and_numbers_default_names() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let mk = |s: &mut Session, name: &str| {
|
||||
s.execute("layer.newSolid", json!({"name": name, "color": "#fff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap()
|
||||
};
|
||||
let (a, b, c, d) = (mk(&mut s, "A"), mk(&mut s, "B"), mk(&mut s, "C"), mk(&mut s, "D"));
|
||||
s.execute("layer.setParent", json!({"layers": [c], "parent": a})).unwrap();
|
||||
s.execute("layer.setTrackMatte", json!({"layers": [d], "matte": b, "kind": "alpha"})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.execute("layer.precompose", json!({"layers": [a, b]})).unwrap();
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
assert_eq!(comp.layers.len(), 3);
|
||||
for l in &comp.layers {
|
||||
assert!(l.parent.is_none_or(|p| comp.layer(p).is_some()), "{} has a dangling parent", l.name);
|
||||
assert!(l.track_matte.as_ref().is_none_or(|m| comp.layer(m.layer).is_some()), "{} has a dangling matte", l.name);
|
||||
}
|
||||
s.execute("layer.precompose", json!({"layers": [c, d]})).unwrap();
|
||||
let mut names: Vec<String> = s
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| matches!(i.kind, effectcraft_project::ItemKind::Comp(_)) && i.name.starts_with("Pre-comp"))
|
||||
.map(|i| i.name.clone())
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["Pre-comp 1", "Pre-comp 2"]);
|
||||
}
|
||||
@@ -232,6 +232,43 @@ fn material_commands_and_serde() {
|
||||
assert_eq!(&back, &*s.project);
|
||||
}
|
||||
|
||||
/// #2917: a 3D model layer keeps its file's materials, so it has no Base Color, Metallic or
|
||||
/// Roughness. material.set refused them with "no Material Options property matched (…,
|
||||
/// baseColor, metallic, roughness, …)", naming the very parameters it refused, and next to a
|
||||
/// parameter the layer does have it dropped them without a word.
|
||||
#[test]
|
||||
fn material_set_names_the_parameters_a_layer_lacks() {
|
||||
let mut s = session();
|
||||
let r = s.execute("file.import", json!({"paths": [fixture("quad.glb")]})).unwrap();
|
||||
let item = r["items"][0].as_u64().unwrap();
|
||||
let m = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
let e = s.execute_checked("material.set", json!({"layers": [m], "baseColor": "#3366ff", "roughness": 20})).unwrap_err().to_string();
|
||||
assert!(e.contains("`baseColor`, `roughness`") && e.contains("model file") && e.contains("appearsInReflections"), "{e}");
|
||||
assert!(!e.contains("`metallic`"), "{e}");
|
||||
// A parameter the layer has doesn't hide one it lacks, and nothing is set.
|
||||
assert!(s.execute_checked("material.set", json!({"layers": [m], "castsShadows": "on", "metallic": 50})).is_err());
|
||||
assert_eq!(layer(&s, m).props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(0));
|
||||
s.execute_checked("material.set", json!({"layers": [m], "castsShadows": "on"})).unwrap();
|
||||
assert_eq!(layer(&s, m).props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(1));
|
||||
// Across layers, a parameter goes to the ones that have it.
|
||||
let cube = s.execute("layer.newPrimitive", json!({"kind": "cube"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.execute_checked("material.set", json!({"layers": [m, cube], "roughness": 20})).unwrap()["set"], 1);
|
||||
assert_eq!(layer(&s, cube).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
|
||||
// A solid has the classic shading, not the primitive's material.
|
||||
let solid = s.execute("layer.newSolid", json!({"width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap();
|
||||
let e = s.execute_checked("material.set", json!({"layers": [solid], "emissive": "#ffffff"})).unwrap_err().to_string();
|
||||
assert!(e.contains("`emissive`") && e.contains("specularShininess"), "{e}");
|
||||
assert!(s.execute_checked("material.set", json!({"layers": [m]})).is_err());
|
||||
// The parameter list still names every parameter, or agents' calls are refused before they run.
|
||||
let spec = crate::command_specs().iter().find(|c| c.id == "material.set").unwrap();
|
||||
let keys = crate::commands::accepted_params(spec.params).unwrap();
|
||||
let want = "layers time castsShadows acceptsShadows acceptsLights appearsInReflections baseColor metallic roughness emissive \
|
||||
lightTransmission ambient diffuse specularIntensity specularShininess metal";
|
||||
for k in want.split_whitespace() {
|
||||
assert!(keys.iter().any(|a| a == k), "{k}: {keys:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #264: only Advanced 3D draws models and primitives, and new comps are Classic 3D, so an
|
||||
/// imported model was invisible. Adding one switches the comp to Advanced 3D (in the same undo
|
||||
/// step, with a toast), as After Effects does.
|
||||
|
||||
@@ -225,6 +225,71 @@ fn a_pin_added_to_a_bent_mesh_moves_nothing() {
|
||||
}
|
||||
}
|
||||
|
||||
fn mesh_count(s: &mut Session, id: u64) -> usize {
|
||||
s.execute("puppet.info", json!({"layer": id})).unwrap()["meshes"].as_array().unwrap().len()
|
||||
}
|
||||
|
||||
/// #2865: where the deformed layer is empty a click makes nothing: no copy of the art it has moved
|
||||
/// off, no undo step, no error.
|
||||
#[test]
|
||||
fn a_pin_where_the_deformed_layer_is_empty_creates_nothing() {
|
||||
let (mut s, id) = setup();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
|
||||
s.set_time(Tick::from_seconds_f64(1.0));
|
||||
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 1", "position": [10, 70]})).unwrap();
|
||||
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [90, 70]})).unwrap();
|
||||
let (cid, t) = (s.active_comp_id().unwrap(), Tick::from_seconds_f64(1.0));
|
||||
let before = s.render(cid, t, RenderOpts::default());
|
||||
let revision = s.revision;
|
||||
let lid = effectcraft_project::LayerId(id);
|
||||
// The solid's top, which the pins have carried 40 px down; and well off it.
|
||||
for at in [[50, 10], [300, 300]] {
|
||||
assert!(!crate::commands::puppet::places_pin(&s, cid, lid, [at[0] as f64, at[1] as f64], t), "{at:?}");
|
||||
let r = s.execute("puppet.addPin", json!({"layer": id, "position": at})).unwrap();
|
||||
assert_eq!(r["pin"], Value::Null, "{at:?}");
|
||||
assert_eq!(s.revision, revision, "{at:?} changed the project");
|
||||
}
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 2));
|
||||
assert_eq!(s.render(cid, t, RenderOpts::default()), before);
|
||||
// Whatever Expansion new meshes would now get, the art is the mesh's own.
|
||||
s.state.puppet.expansion = 9.0;
|
||||
assert_eq!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 10]})).unwrap()["pin"], Value::Null);
|
||||
// On the deformed shape a pin still goes on the mesh.
|
||||
assert!(crate::commands::puppet::places_pin(&s, cid, lid, [50.0, 70.0], t));
|
||||
assert!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 70]})).unwrap()["pin"].is_u64());
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 3));
|
||||
}
|
||||
|
||||
/// #2865: separate art still gets its own mesh, also from a near miss in the gap beside it (a click
|
||||
/// off the art finds the art nearest it): only a repeat of a mesh is refused.
|
||||
#[test]
|
||||
fn separate_art_still_gets_its_own_mesh() {
|
||||
let (mut s, id) = setup();
|
||||
for x in [[0, 30], [60, 100]] {
|
||||
s.execute("mask.new", json!({"layer": id, "vertices": [[x[0], 0], [x[1], 0], [x[1], 60], [x[0], 60]], "closed": true})).unwrap();
|
||||
}
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 1));
|
||||
// In the gap, nearer the right piece and past reach of the left piece's mesh.
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [46, 30]})).unwrap();
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the near miss meshes the right piece");
|
||||
s.undo();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [80, 30]})).unwrap();
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the right piece gets its own mesh");
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [80, 50]})).unwrap();
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 3), "and the next pin goes on it");
|
||||
}
|
||||
|
||||
/// `newMesh` asks for a new mesh wherever the click is, the art's own mesh or not.
|
||||
#[test]
|
||||
fn new_mesh_option_starts_a_mesh_on_meshed_art() {
|
||||
let (mut s, id) = setup();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30], "newMesh": true})).unwrap();
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puppet_pin_kinds_and_mesh_options() {
|
||||
let (mut s, id) = setup();
|
||||
|
||||
@@ -40,6 +40,22 @@ fn prefs_round_trip_through_json() {
|
||||
assert_eq!(Prefs::from_json(&Prefs::default().to_json()), Prefs::default());
|
||||
}
|
||||
|
||||
/// A document with one bad page and many unknown keys recovers its good pages in one pass per
|
||||
/// page, not one full parse per key.
|
||||
#[test]
|
||||
fn prefs_recovery_tries_only_the_settings_pages() {
|
||||
let mut doc = serde_json::Map::new();
|
||||
doc.insert("general".into(), json!({"undoLevels": "not a number"}));
|
||||
doc.insert("labels".into(), serde_json::to_value(Prefs::default()).unwrap()["labels"].clone());
|
||||
for i in 0..20_000 {
|
||||
doc.insert(format!("unknown{i}"), json!({"x": i}));
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let p = Prefs::from_json(&serde_json::Value::Object(doc).to_string());
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
assert_eq!(p.general, Prefs::default().general);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_migrate_old_layout_and_keep_unknown_keys() {
|
||||
let old = r#"{
|
||||
@@ -597,8 +613,8 @@ fn shortcut_presets_duplicate_rename_delete_export_import() {
|
||||
fn frontend_commands_are_bindable_with_panel_scope() {
|
||||
let mut s = Session::default();
|
||||
s.set_ui_commands(vec![
|
||||
crate::shortcuts::UiCommand { id: "timeline.zoomIn".into(), label: "Zoom In Time".into(), shortcut: Some("=".into()) },
|
||||
crate::shortcuts::UiCommand { id: "tool.hand".into(), label: "Hand Tool".into(), shortcut: Some("H".into()) },
|
||||
crate::shortcuts::UiCommand { id: "timeline.zoomIn".into(), label: "Zoom In Time".into(), shortcut: Some("=".into()), scope: None },
|
||||
crate::shortcuts::UiCommand { id: "tool.hand".into(), label: "Hand Tool".into(), shortcut: Some("H".into()), scope: None },
|
||||
]);
|
||||
let b = s.shortcuts().find("timeline.zoomIn").unwrap().clone();
|
||||
assert_eq!(b.scope, "Timeline");
|
||||
@@ -606,6 +622,49 @@ fn frontend_commands_are_bindable_with_panel_scope() {
|
||||
// A panel shortcut conflicts with an application shortcut on the same keys.
|
||||
let c = s.shortcuts().conflicts(&b, "H");
|
||||
assert!(c.iter().any(|c| c.key == "tool.hand"));
|
||||
// A registry command that only the Project panel runs is scoped to it.
|
||||
assert_eq!(s.shortcuts().find("project.deleteWithoutConfirmation").unwrap().scope, "Project");
|
||||
}
|
||||
|
||||
/// The binding that runs a key press: the narrowest scope; a physical binding at the same rank;
|
||||
/// nothing when two other panels (or two active contexts) claim it at the same rank.
|
||||
#[test]
|
||||
fn shortcut_dispatch_picks_the_narrowest_scope_deterministically() {
|
||||
use crate::shortcuts::pick;
|
||||
// Two unrelated panels, neither focused: neither runs, in either order.
|
||||
let both = [("Project", false, "delete"), ("RenderQueue", false, "duplicate")];
|
||||
assert_eq!(pick(both, "Timeline", &[]), Err(vec!["Project".to_string(), "RenderQueue".into()]));
|
||||
assert_eq!(pick([both[1], both[0]], "Timeline", &[]), Err(vec!["RenderQueue".to_string(), "Project".into()]));
|
||||
// The focused one of them wins.
|
||||
assert_eq!(pick(both, "RenderQueue", &[]), Ok(Some("duplicate")));
|
||||
// A single other panel's shortcut runs when nothing nearer claims the chord.
|
||||
assert_eq!(pick([("Project", false, "delete")], "Timeline", &[]), Ok(Some("delete")));
|
||||
// An application binding beats an unrelated panel's binding, also a physical one.
|
||||
assert_eq!(pick([("RenderQueue", true, "duplicate"), ("Application", false, "app")], "Timeline", &[]), Ok(Some("app")));
|
||||
// At the same rank a physical binding wins, in either order.
|
||||
assert_eq!(pick([("Application", false, "logical"), ("Application", true, "physical")], "Timeline", &[]), Ok(Some("physical")));
|
||||
assert_eq!(pick([("Application", true, "physical"), ("Application", false, "logical")], "Timeline", &[]), Ok(Some("physical")));
|
||||
// A Composition binding runs in the Composition panel over the application's.
|
||||
assert_eq!(pick([("Application", false, "app"), ("Composition", false, "comp")], "Composition", &[]), Ok(Some("comp")));
|
||||
// Two active contexts tie; an inactive one doesn't run.
|
||||
assert_eq!(pick([("Mask", false, "mask"), ("Paint", false, "paint")], "Composition", &["Paint", "Mask"]), Err(vec!["Mask".to_string(), "Paint".into()]));
|
||||
assert_eq!(pick([("Mask", false, "mask"), ("Paint", false, "paint")], "Composition", &["Paint"]), Ok(Some("paint")));
|
||||
// The same scope twice (a reported conflict): the first one.
|
||||
assert_eq!(pick([("Timeline", false, "a"), ("Timeline", false, "b")], "Timeline", &[]), Ok(Some("a")));
|
||||
}
|
||||
|
||||
/// Two contexts can be active at once, so their shortcuts on the same keys conflict.
|
||||
#[test]
|
||||
fn context_shortcuts_on_the_same_keys_conflict() {
|
||||
let mut s = Session::default();
|
||||
s.set_ui_commands(vec![
|
||||
crate::shortcuts::UiCommand { id: "test.mask".into(), label: "Mask".into(), shortcut: Some("F9".into()), scope: Some("Mask".into()) },
|
||||
crate::shortcuts::UiCommand { id: "test.paint".into(), label: "Paint".into(), shortcut: Some("F9".into()), scope: Some("Paint".into()) },
|
||||
]);
|
||||
let t = s.shortcuts();
|
||||
assert!(t.all_conflicts().iter().any(|(a, c)| a == "test.mask" && c.key == "test.paint" && !c.overrides), "{:?}", t.all_conflicts());
|
||||
let b = t.find("test.mask").unwrap();
|
||||
assert!(t.conflicts(b, "F9").iter().any(|c| c.key == "test.paint" && !c.overrides));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- auto-save / recovery
|
||||
|
||||
@@ -49,6 +49,38 @@ fn animate_text_adds_animators_with_companion_properties() {
|
||||
let _ = s.render(cid, s.time(), Default::default());
|
||||
}
|
||||
|
||||
/// `animator` counts from 1, as `#n` references do, and a uid match wins over an index: every
|
||||
/// command that takes it says so, and 0 fails with an error that says so (agents reading
|
||||
/// `uid|index` tried 0).
|
||||
#[test]
|
||||
fn animator_indices_count_from_1_and_say_so() {
|
||||
let (mut s, t) = session();
|
||||
let uid = s.execute("layer.addTextAnimator", json!({"layer": t, "property": "position"})).unwrap()["animator"].as_u64().unwrap();
|
||||
for id in ["layer.addTextAnimatorProperty", "layer.addTextSelector", "text.addSelector", "text.animatorFontAxes"] {
|
||||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
assert!(spec.params.contains("animator?: uid|index (1-based; a uid match wins over an index)"), "{id}: {}", spec.params);
|
||||
}
|
||||
for (id, p) in [
|
||||
("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 0, "property": "fillHue"})),
|
||||
("layer.addTextSelector", json!({"layer": t, "animator": 0, "kind": "wiggly"})),
|
||||
] {
|
||||
let e = s.execute(id, p).unwrap_err().to_string();
|
||||
assert!(e.contains("no animator 0") && e.contains("1-based"), "{id}: {e}");
|
||||
}
|
||||
let r = s.execute("layer.addTextSelector", json!({"layer": t, "animator": 1, "kind": "wiggly"})).unwrap();
|
||||
assert_eq!(r["animator"].as_u64(), Some(uid));
|
||||
// A second animator whose uid is 1: `animator: 1` is that uid, not the first animator, and
|
||||
// index 2 reaches the same animator.
|
||||
s.execute("layer.addTextAnimator", json!({"layer": t, "property": "opacity"})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let layer = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(t)).unwrap();
|
||||
layer.props.group_mut("text/animators").unwrap().children[1].as_group_mut().unwrap().uid = 1;
|
||||
for (id, animator) in [("layer.addTextSelector", 1), ("text.addSelector", 1), ("layer.addTextSelector", 2)] {
|
||||
let r = s.execute(id, json!({"layer": t, "animator": animator, "kind": "wiggly"})).unwrap();
|
||||
assert_eq!(r["animator"].as_u64(), Some(1), "{id} animator {animator}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_property_and_selectors_with_undo_redo() {
|
||||
let (mut s, t) = session();
|
||||
|
||||
@@ -60,6 +60,62 @@ fn snap_lines_snap_axes_independently_and_priority_breaks_ties() {
|
||||
assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3)));
|
||||
}
|
||||
|
||||
/// #2855: an edge midpoint lies on its edge line, so one line nearer than the point must not win
|
||||
/// and leave the other axis free. Two lines do win when together they are nearer.
|
||||
#[test]
|
||||
fn snap_point_beats_one_line_and_loses_only_to_two_nearer_lines() {
|
||||
let src = SnapSource::Layer(LayerId(1));
|
||||
let mid = SnapTarget::point([100.0, 50.0], PRI_LAYER, src);
|
||||
let top = SnapTarget::hline(50.0, PRI_LAYER, src);
|
||||
// The line alone is nearer (1 against √10), and the point still snaps both axes.
|
||||
let s = snap(&[[103.0, 51.0]], &[top, mid], 5.0).unwrap();
|
||||
assert_eq!(s.at, [100.0, 50.0]);
|
||||
assert_eq!(s.delta, [-3.0, -1.0]);
|
||||
assert_eq!(s.hits, [mid]);
|
||||
// Lines through the point itself tie with it: the point.
|
||||
let centre = SnapTarget::vline(100.0, PRI_LAYER, src);
|
||||
assert_eq!(snap(&[[103.0, 51.0]], &[top, centre, mid], 5.0).unwrap().hits, [mid]);
|
||||
// Two lines together nearer than the point: both snap.
|
||||
let far = SnapTarget::point([101.0, 55.0], PRI_LAYER, src);
|
||||
let v = SnapTarget::vline(102.0, PRI_GUIDE, SnapSource::Guide);
|
||||
let h = SnapTarget::hline(52.0, PRI_GUIDE, SnapSource::Guide);
|
||||
let s = snap(&[[103.0, 51.0]], &[v, h, far], 5.0).unwrap();
|
||||
assert_eq!(s.at, [102.0, 52.0]);
|
||||
assert_eq!(s.hits, [v, h]);
|
||||
}
|
||||
|
||||
/// #2855: an unrotated layer's centre lines follow its edges' span rule, reach the outer comp
|
||||
/// through a collapsed precomp layer, and a rotated layer keeps its points only.
|
||||
#[test]
|
||||
fn layer_centre_lines_follow_the_edges() {
|
||||
use crate::viewer::{SnapFeatures, SnapOptions, targets};
|
||||
let mut s = comp();
|
||||
// A 100×50 solid moved off the comp's centre lines: x 70–170, y 55–105, centre (120, 80).
|
||||
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [120, 80, 0]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let at = |s: &Session, f: SnapFeatures| {
|
||||
let comp = s.project.comp(cid).unwrap().clone();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
|
||||
targets(&ctx, &[], SnapOptions { layers: true, features: f, guides: false, grid: false, grid_spacing: 0.0 })
|
||||
};
|
||||
let lines = |t: &[SnapTarget], l: LayerId| {
|
||||
let of = |kind: SnapKind, i: usize, c: f64| t.iter().find(|t| t.kind == kind && t.source == SnapSource::Layer(l) && t.pos[i] == c).map(|t| t.span);
|
||||
(of(SnapKind::VLine, 0, 120.0), of(SnapKind::HLine, 1, 80.0))
|
||||
};
|
||||
assert_eq!(lines(&at(&s, SnapFeatures::default()), LayerId(a)), (Some(None), Some(None)));
|
||||
let off = SnapFeatures { edges_extended: false, ..Default::default() };
|
||||
assert_eq!(lines(&at(&s, off), LayerId(a)), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0]))));
|
||||
// Through a collapsed precomp layer (it fills the comp, unmoved, so the numbers stay).
|
||||
s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap();
|
||||
let pre = s.active_comp().unwrap().layers[0].id;
|
||||
s.execute("layer.setSwitch", json!({"layers": [pre.0], "switch": "collapse", "value": true})).unwrap();
|
||||
assert_eq!(lines(&at(&s, off), pre), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0]))));
|
||||
// Rotated: no lines at all.
|
||||
s.execute("prop.set", json!({"layer": pre.0, "path": "transform/rotation", "value": 30})).unwrap();
|
||||
assert_eq!(lines(&at(&s, off), pre), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_targets_cover_layers_comp_guides_and_grid() {
|
||||
let mut s = comp();
|
||||
|
||||
+12
-11
@@ -48,7 +48,7 @@ pub struct SnapTarget {
|
||||
pub priority: u8,
|
||||
pub source: SnapSource,
|
||||
/// A line's extent along itself (y of a vertical line, x of a horizontal one): a layer's
|
||||
/// edge without Snap Edges Extended. `None` runs across the whole comp.
|
||||
/// edge or centre line without Snap Edges Extended. `None` runs across the whole comp.
|
||||
#[serde(default)]
|
||||
pub span: Option<[f64; 2]>,
|
||||
}
|
||||
@@ -70,7 +70,7 @@ impl SnapTarget {
|
||||
}
|
||||
|
||||
/// Tools bar ▸ Snapping options, the two After Effects' Snapping menu has: Snap Edges Extended
|
||||
/// (layer edges snap along their whole line, beyond the layer's bounds) and Snap to Features in
|
||||
/// (layer edge and centre lines snap along their whole line, beyond the layer's bounds) and Snap to Features in
|
||||
/// Collapsed Compositions and Text Layers (the layers inside a precomp layer with Collapse
|
||||
/// Transformations on are targets too). Both are on by default. Every layer feature snaps
|
||||
/// (corners, edge midpoints, centre, anchor point, mask and shape path points), as in After
|
||||
@@ -126,9 +126,10 @@ fn better(a: (f64, u8), b: (f64, u8)) -> bool {
|
||||
}
|
||||
|
||||
/// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels.
|
||||
/// A point target snaps both axes and wins when it is at least as near as the best lines;
|
||||
/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to
|
||||
/// the lower priority value.
|
||||
/// A point target snaps both axes and wins unless a vertical and a horizontal line both resolve
|
||||
/// and together lie nearer than it (one line alone leaves the other axis free, so it never beats
|
||||
/// a point: a layer's edge midpoint lies on its edge line); otherwise the nearest vertical and
|
||||
/// horizontal lines snap x and y independently. Ties go to the lower priority value.
|
||||
pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Snap> {
|
||||
let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
@@ -150,9 +151,9 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Sn
|
||||
}
|
||||
}
|
||||
}
|
||||
let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min);
|
||||
let both_lines = best_x.zip(best_y).map_or(f64::INFINITY, |(x, y)| x.0.0.hypot(y.0.0));
|
||||
if let Some((k, s, t)) = best_pt
|
||||
&& k.0 <= line_best + 1e-9
|
||||
&& k.0 <= both_lines + 1e-9
|
||||
{
|
||||
return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos });
|
||||
}
|
||||
@@ -366,8 +367,8 @@ fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterato
|
||||
comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id))
|
||||
}
|
||||
|
||||
/// A 2D layer's snap targets: its box points, its edges as lines while unrotated (across the
|
||||
/// comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is
|
||||
/// A 2D layer's snap targets: its box points, its edges and centre as lines while unrotated (across
|
||||
/// the comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is
|
||||
/// false (Pan Behind drags it, the box stays put), its mask and shape path vertices and, with
|
||||
/// Snap to Features in Collapsed Compositions, the targets of the visible layers inside a
|
||||
/// collapsed precomp layer. None for cameras, lights and 3D layers.
|
||||
@@ -386,10 +387,10 @@ fn layer_targets_at(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures, dep
|
||||
let [nw, ne, _, sw] = b.corners;
|
||||
if (nw[1] - ne[1]).abs() < 1e-6 && (nw[0] - sw[0]).abs() < 1e-6 {
|
||||
let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([nw[0], ne[0]]), Some([nw[1], sw[1]])) };
|
||||
for x in [nw[0], ne[0]] {
|
||||
for x in [nw[0], b.center[0], ne[0]] {
|
||||
out.push(SnapTarget::vline(x, PRI_LAYER, src).within(ys));
|
||||
}
|
||||
for y in [nw[1], sw[1]] {
|
||||
for y in [nw[1], b.center[1], sw[1]] {
|
||||
out.push(SnapTarget::hline(y, PRI_LAYER, src).within(xs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ effectcraft-vp9enc = { workspace = true }
|
||||
effectcraft-hevcenc = { workspace = true }
|
||||
effectcraft-av1enc = { workspace = true }
|
||||
image = { workspace = true, features = ["png", "jpeg", "tiff", "exr", "tga"] }
|
||||
exr = { workspace = true }
|
||||
gif = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
+64
-17
@@ -4,8 +4,8 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use effectcraft_project::Comp;
|
||||
use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile};
|
||||
use effectcraft_project::{Comp, ProjectSettings};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::{Brand, ColorInfo, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use rayon::prelude::*;
|
||||
@@ -19,14 +19,41 @@ fn mux_err(e: impl std::fmt::Display) -> ExportError {
|
||||
ExportError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// Tag a video [`SampleEntry`] with the BT.709 limited-range `colr` (nclx) box every video
|
||||
/// encoder here outputs (`rgba_to_yuv420`, `rgba_to_yuv_10` and `rgba_to_yuv420_bits` all use
|
||||
/// the 0.2126/0.7152/0.0722 luma and a 16–235 luma range). Without it a re-import falls back to
|
||||
/// the container's resolution-based `default_matrix` — BT.601 below 1024×577 — and shifts the
|
||||
/// What a render's frames are, as ITU-T H.273 code points, for the `colr` box and the encoders'
|
||||
/// own colour description. The matrix is BT.709, the one every encoder here converts with
|
||||
/// (`rgba_to_yuv420`, `rgba_to_yuv_10` and `rgba_to_yuv420_bits` all use the 0.2126/0.7152/0.0722
|
||||
/// luma). Primaries and transfer follow the render path (`Pipe::output`): only a working space
|
||||
/// converts to the output space, so unmanaged frames pass through. sRGB output keeps transfer 1
|
||||
/// rather than 13 (#340), and an alpha matte is not in the output space.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Codes {
|
||||
pub(crate) primaries: u8,
|
||||
pub(crate) transfer: u8,
|
||||
pub(crate) matrix: u8,
|
||||
}
|
||||
|
||||
pub(crate) fn output_codes(settings: &ProjectSettings, channels: Channels) -> Codes {
|
||||
use effectcraft_project::ColorSpace as Space;
|
||||
let output = settings.working_space.and(settings.output_space).filter(|_| channels != Channels::Alpha);
|
||||
let (primaries, transfer) = match output {
|
||||
None | Some(Space::Srgb | Space::Rec709) => (1, 1),
|
||||
Some(Space::Rec2020) => (9, 1),
|
||||
Some(Space::DisplayP3) => (12, 13),
|
||||
Some(Space::Rec2100Pq) => (9, 16),
|
||||
Some(Space::Rec2100Hlg) => (9, 18),
|
||||
// H.273 has no code for the ACES primaries: unspecified, in linear light.
|
||||
Some(Space::AcesCg | Space::Aces2065) => (2, 8),
|
||||
};
|
||||
Codes { primaries, transfer, matrix: 1 }
|
||||
}
|
||||
|
||||
/// Tag a video [`SampleEntry`] with the limited-range (16–235 luma, as every encoder here
|
||||
/// writes) `colr` (nclx) box for `c` (#2879). Without it a re-import falls back to the
|
||||
/// container's resolution-based `default_matrix` — BT.601 below 1024×577 — and shifts the
|
||||
/// colours (pure red came back as [232,0,1]).
|
||||
pub(crate) fn tag_bt709_limited(entry: &mut SampleEntry) {
|
||||
pub(crate) fn tag_nclx_limited(entry: &mut SampleEntry, c: Codes) {
|
||||
if let Some(v) = entry.video.as_mut() {
|
||||
v.color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false });
|
||||
v.color = Some(ColorInfo::Nclx { primaries: c.primaries.into(), transfer: c.transfer.into(), matrix: c.matrix.into(), full_range: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,21 +121,23 @@ struct H264 {
|
||||
w: u32,
|
||||
h: u32,
|
||||
rate: FrameRate,
|
||||
codes: Codes,
|
||||
y: Vec<u8>,
|
||||
u: Vec<u8>,
|
||||
v: Vec<u8>,
|
||||
}
|
||||
|
||||
impl H264 {
|
||||
fn new(w: u32, h: u32, rate: FrameRate, kbps: u32) -> Result<H264> {
|
||||
fn new(w: u32, h: u32, rate: FrameRate, kbps: u32, codes: Codes) -> Result<H264> {
|
||||
let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32);
|
||||
cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
|
||||
cfg.aud = false;
|
||||
cfg.keyint = (rate.as_f64() * 2.0).round().max(1.0) as u32;
|
||||
let kbps = kbps.max(100);
|
||||
cfg.rate = filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 };
|
||||
cfg.color = filmcraft_h264enc::ColorConfig { primaries: codes.primaries, transfer: codes.transfer, matrix: codes.matrix, full_range: false };
|
||||
let enc = filmcraft_h264enc::Encoder::new(cfg).map_err(enc)?;
|
||||
Ok(H264 { enc, w, h, rate, y: vec![], u: vec![], v: vec![] })
|
||||
Ok(H264 { enc, w, h, rate, codes, y: vec![], u: vec![], v: vec![] })
|
||||
}
|
||||
fn packets(&self, ps: Vec<filmcraft_h264enc::Packet>) -> Vec<Packet> {
|
||||
ps.into_iter().map(|p| Packet { data: p.data, key: p.keyframe, cto: (p.pts - p.dts) as i32 }).collect()
|
||||
@@ -122,7 +151,7 @@ impl VideoEncoder for H264 {
|
||||
filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4)
|
||||
});
|
||||
let mut entry = SampleEntry::avc(cfg, self.w as u16, self.h as u16);
|
||||
tag_bt709_limited(&mut entry);
|
||||
tag_nclx_limited(&mut entry, self.codes);
|
||||
entry
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>> {
|
||||
@@ -164,6 +193,13 @@ struct ProRes {
|
||||
w: u32,
|
||||
h: u32,
|
||||
alpha: bool,
|
||||
colr: ColorInfo,
|
||||
}
|
||||
|
||||
/// The `colr` atom of a ProRes render ([`output_codes`]). MOV uses QuickTime's `nclc`, which has
|
||||
/// no range flag; ProRes frames are limited range.
|
||||
fn prores_colr(c: Codes) -> ColorInfo {
|
||||
ColorInfo::Nclc { primaries: c.primaries.into(), transfer: c.transfer.into(), matrix: c.matrix.into() }
|
||||
}
|
||||
|
||||
fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile {
|
||||
@@ -179,14 +215,20 @@ fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile {
|
||||
}
|
||||
|
||||
impl ProRes {
|
||||
fn new(w: u32, h: u32, profile: ProResProfile, channels: Channels) -> ProRes {
|
||||
fn new(w: u32, h: u32, profile: ProResProfile, channels: Channels, colr: ColorInfo) -> ProRes {
|
||||
// Alpha needs a 4444 flavour.
|
||||
let profile = if channels == Channels::Rgba && !profile.is_4444() { ProResProfile::P4444 } else { profile };
|
||||
let profile = prores_profile(profile);
|
||||
let alpha = channels == Channels::Rgba;
|
||||
let mut cfg = filmcraft_prores::EncoderConfig::new(profile, w, h);
|
||||
cfg.encode_alpha = alpha;
|
||||
ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha }
|
||||
// The frame header carries the same H.273 codes as the `colr` atom, so readers that trust
|
||||
// either one agree.
|
||||
if let ColorInfo::Nclc { primaries, transfer, matrix } = colr {
|
||||
let code = |c| u8::try_from(c).unwrap_or(2);
|
||||
cfg.color = filmcraft_prores::ColorInfo { primaries: code(primaries), transfer: code(transfer), matrix: code(matrix) };
|
||||
}
|
||||
ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha, colr }
|
||||
}
|
||||
fn is_444(&self) -> bool {
|
||||
matches!(self.profile, filmcraft_prores::Profile::P4444 | filmcraft_prores::Profile::P4444Xq)
|
||||
@@ -195,7 +237,11 @@ impl ProRes {
|
||||
|
||||
impl VideoEncoder for ProRes {
|
||||
fn sample_entry(&self) -> SampleEntry {
|
||||
SampleEntry::prores(FourCc(self.profile.fourcc()), self.w as u16, self.h as u16)
|
||||
let mut entry = SampleEntry::prores(FourCc(self.profile.fourcc()), self.w as u16, self.h as u16);
|
||||
if let Some(v) = entry.video.as_mut() {
|
||||
v.color = Some(self.colr.clone());
|
||||
}
|
||||
entry
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
|
||||
let chroma = if self.is_444() { filmcraft_prores::ChromaFormat::Yuv444 } else { filmcraft_prores::ChromaFormat::Yuv422 };
|
||||
@@ -296,12 +342,13 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
Channels::Rgba if !matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) => Channels::Rgb,
|
||||
c => c,
|
||||
};
|
||||
let codes = output_codes(&job.project.settings, channels);
|
||||
let mut venc: Box<dyn VideoEncoder> = match fmt {
|
||||
OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?),
|
||||
OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output)?),
|
||||
OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output)?),
|
||||
OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps, codes)?),
|
||||
OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output, codes)?),
|
||||
OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output, codes)?),
|
||||
OutputFormat::Uncompressed => Box::new(crate::uncompressed::Raw::new(w, h, channels == Channels::Rgba)?),
|
||||
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels })),
|
||||
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels }, prores_colr(codes))),
|
||||
};
|
||||
let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 };
|
||||
let batch = batch_size();
|
||||
|
||||
@@ -7,7 +7,7 @@ use effectcraft_time::FrameRate;
|
||||
use filmcraft_isobmff::{Av1Config, CodecConfig, FourCc, HevcConfig, SampleEntry};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::encode::{Packet, VideoEncoder};
|
||||
use crate::encode::{Codes, Packet, VideoEncoder};
|
||||
use crate::{ExportError, Result};
|
||||
|
||||
fn enc_err(e: impl std::fmt::Display) -> ExportError {
|
||||
@@ -59,6 +59,7 @@ pub(crate) struct Hevc {
|
||||
w: u32,
|
||||
h: u32,
|
||||
bits: u8,
|
||||
codes: Codes,
|
||||
planes: Planes,
|
||||
}
|
||||
|
||||
@@ -81,11 +82,12 @@ pub(crate) fn hevc_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) ->
|
||||
}
|
||||
|
||||
impl Hevc {
|
||||
pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> Result<Hevc> {
|
||||
let cfg = hevc_config(w, h, rate, om);
|
||||
pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule, codes: Codes) -> Result<Hevc> {
|
||||
let mut cfg = hevc_config(w, h, rate, om);
|
||||
(cfg.colour_primaries, cfg.transfer_characteristics, cfg.matrix_coeffs) = (codes.primaries, codes.transfer, codes.matrix);
|
||||
let bits = cfg.profile.bit_depth();
|
||||
let enc = effectcraft_hevcenc::Encoder::new(cfg).map_err(enc_err)?;
|
||||
Ok(Hevc { enc, w, h, bits, planes: Planes::default() })
|
||||
Ok(Hevc { enc, w, h, bits, codes, planes: Planes::default() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +95,7 @@ impl VideoEncoder for Hevc {
|
||||
fn sample_entry(&self) -> SampleEntry {
|
||||
let cfg = HevcConfig::parse(&self.enc.hvcc()).unwrap_or_default();
|
||||
let mut entry = SampleEntry::hevc(cfg, self.w as u16, self.h as u16);
|
||||
crate::encode::tag_bt709_limited(&mut entry);
|
||||
crate::encode::tag_nclx_limited(&mut entry, self.codes);
|
||||
entry
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
|
||||
@@ -116,6 +118,7 @@ pub(crate) struct Av1 {
|
||||
w: u32,
|
||||
h: u32,
|
||||
bits: u8,
|
||||
codes: Codes,
|
||||
planes: Planes,
|
||||
}
|
||||
|
||||
@@ -135,11 +138,12 @@ pub(crate) fn av1_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) ->
|
||||
}
|
||||
|
||||
impl Av1 {
|
||||
pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> Result<Av1> {
|
||||
let cfg = av1_config(w, h, rate, om);
|
||||
pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule, codes: Codes) -> Result<Av1> {
|
||||
let mut cfg = av1_config(w, h, rate, om);
|
||||
(cfg.color_primaries, cfg.transfer_characteristics, cfg.matrix_coefficients) = (codes.primaries, codes.transfer, codes.matrix);
|
||||
let bits = cfg.bit_depth;
|
||||
let enc = effectcraft_av1enc::Encoder::new(cfg).map_err(enc_err)?;
|
||||
Ok(Av1 { enc, w, h, bits, planes: Planes::default() })
|
||||
Ok(Av1 { enc, w, h, bits, codes, planes: Planes::default() })
|
||||
}
|
||||
/// The `av1C` record (also the WebM CodecPrivate).
|
||||
pub(crate) fn av1c(&self) -> Vec<u8> {
|
||||
@@ -151,7 +155,7 @@ impl VideoEncoder for Av1 {
|
||||
fn sample_entry(&self) -> SampleEntry {
|
||||
let cfg = Av1Config::parse(&self.enc.av1c()).unwrap_or_default();
|
||||
let mut entry = SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), self.w as u16, self.h as u16);
|
||||
crate::encode::tag_bt709_limited(&mut entry);
|
||||
crate::encode::tag_nclx_limited(&mut entry, self.codes);
|
||||
entry
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
|
||||
|
||||
@@ -175,7 +175,9 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<
|
||||
pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<Report> {
|
||||
let t0 = Instant::now();
|
||||
let cx = Cx::new(job);
|
||||
let render = RenderEnd(job.accel);
|
||||
let mut r = export_with(&cx, progress, t0).await;
|
||||
drop(render);
|
||||
if matches!(r, Err(ExportError::Cancelled)) {
|
||||
cx.remove_cancelled_outputs()?;
|
||||
return r;
|
||||
@@ -188,6 +190,18 @@ pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) ->
|
||||
r
|
||||
}
|
||||
|
||||
/// Calls [`effectcraft_render::Accelerator::render_end`] when dropped: after the export, and
|
||||
/// also when it panics or its future is dropped (a browser worker aborting it).
|
||||
struct RenderEnd<'a>(Option<&'a dyn effectcraft_render::Accelerator>);
|
||||
|
||||
impl Drop for RenderEnd<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(a) = self.0 {
|
||||
a.render_end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn export_with(cx: &Cx<'_>, progress: &mut dyn FnMut(&Progress) -> bool, t0: Instant) -> Result<Report> {
|
||||
let comp = cx.comp().ok_or(ExportError::NoComp)?;
|
||||
let total = cx.settings.frame_count(comp);
|
||||
@@ -297,7 +311,7 @@ struct StillOutput {
|
||||
|
||||
fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &Image, comp: &Comp, output: StillOutput) -> Result<()> {
|
||||
let StillOutput { format: fmt, channels, size: (w, h), quality } = output;
|
||||
use image::{ExtendedColorType, ImageEncoder, ImageFormat};
|
||||
use image::{ExtendedColorType, ImageEncoder};
|
||||
match fmt {
|
||||
OutputFormat::ExrSequence => {
|
||||
// Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background.
|
||||
@@ -326,8 +340,16 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
|
||||
data.extend_from_slice(&[c[0], c[1], c[2], a]);
|
||||
}
|
||||
}
|
||||
let buf = image::Rgba32FImage::from_raw(w, h, data).ok_or_else(|| ExportError::Encode("exr buffer".into()))?;
|
||||
image::DynamicImage::ImageRgba32F(buf).write_to(file, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
// Scan lines with ZIP like `comp.saveFrameAsExr`: `image`'s encoder writes 64×64 RLE tiles,
|
||||
// which FilmCraft and other scan-line readers refuse (#2805).
|
||||
use exr::prelude::{Encoding, Layer, LayerAttributes, SpecificChannels, Vec2, WritableImage};
|
||||
let row = w as usize * 4;
|
||||
let pixels = SpecificChannels::rgba(|p: Vec2<usize>| {
|
||||
let o = p.y() * row + p.x() * 4;
|
||||
(data[o], data[o + 1], data[o + 2], data[o + 3])
|
||||
});
|
||||
let layer = Layer::new((w as usize, h as usize), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, pixels);
|
||||
exr::image::Image::from_layer(layer).write().to_buffered(file).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
OutputFormat::JpegSequence => {
|
||||
let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h);
|
||||
|
||||
@@ -391,11 +391,14 @@ impl<'a> Cx<'a> {
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
let r = self.renderer();
|
||||
let planes = r.active_accel()?.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?;
|
||||
let a = r.active_accel()?;
|
||||
let planes = a.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?;
|
||||
let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize);
|
||||
if planes.len() != w as usize * h as usize + 2 * cw * ch {
|
||||
return None;
|
||||
}
|
||||
// A frame rendered off `passes::comp_frame` ends here; on a `None` the fallback renders and ends it.
|
||||
a.frame_end();
|
||||
if let Some(ft) = &self.frame_times {
|
||||
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ fn cluster(blocks: &mut [Block]) -> Vec<u8> {
|
||||
|
||||
pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) -> Result<Report> {
|
||||
let rate = job.settings.rate(comp);
|
||||
let mut venc = crate::hevc_av1::Av1::new(w, h, rate, job.output)?;
|
||||
let codes = crate::encode::output_codes(&job.project.settings, job.output.channels);
|
||||
let mut venc = crate::hevc_av1::Av1::new(w, h, rate, job.output, codes)?;
|
||||
let with_audio = wants_audio(job);
|
||||
let opus_channels = if job.output.audio_channels == 1 { 1usize } else { 2 };
|
||||
let mut opus = with_audio.then(|| opus_encoder(job));
|
||||
|
||||
@@ -24,11 +24,21 @@ struct Fake {
|
||||
/// Frames asking for planes right now, and the most at once after the stop.
|
||||
asking: AtomicUsize,
|
||||
most_after_stop: AtomicUsize,
|
||||
/// `Accelerator::frame_end` calls: one per rendered comp frame, planes or not.
|
||||
frame_ends: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new(yuv: bool, deferred: bool, stop_after: usize) -> Self {
|
||||
Fake { yuv, yuv_frames: AtomicUsize::new(0), deferred, stop_after, asking: AtomicUsize::new(0), most_after_stop: AtomicUsize::new(0) }
|
||||
Fake {
|
||||
yuv,
|
||||
yuv_frames: AtomicUsize::new(0),
|
||||
deferred,
|
||||
stop_after,
|
||||
asking: AtomicUsize::new(0),
|
||||
most_after_stop: AtomicUsize::new(0),
|
||||
frame_ends: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +64,9 @@ impl Accelerator for Fake {
|
||||
}
|
||||
Some(effectcraft_raster::yuv::yuv420_over(&r.comp_frame_cpu(comp, t), bg))
|
||||
}
|
||||
fn frame_end(&self) {
|
||||
self.frame_ends.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn available(&self) -> bool {
|
||||
self.yuv_frames.load(Ordering::SeqCst) < self.stop_after
|
||||
}
|
||||
@@ -122,6 +135,8 @@ fn accelerator_planes_encode_like_the_cpu_conversion() {
|
||||
let b = run(&p, cid, Some(&rgba), &s, &d.join("rgba.mp4"));
|
||||
let c = run(&p, cid, None, &s, &d.join("cpu.mp4"));
|
||||
assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 20, "every frame came from the accelerator");
|
||||
assert_eq!(yuv.frame_ends.load(Ordering::SeqCst), 20, "every planes frame ends once");
|
||||
assert_eq!(rgba.frame_ends.load(Ordering::SeqCst), 20, "every RGBA frame ends once");
|
||||
assert!(a == b && b == c, "the three files differ");
|
||||
let f = effectcraft_media::probe(&yuv_mp4).expect("probe yuv.mp4");
|
||||
assert_eq!((f.width, f.height), (64, 48));
|
||||
@@ -144,6 +159,7 @@ fn frames_the_accelerator_cannot_take_fall_back_to_rgba() {
|
||||
let a = run(&p, cid, Some(&yuv), &odd, &d.join("yuv.mp4"));
|
||||
let c = run(&p, cid, None, &odd, &d.join("cpu.mp4"));
|
||||
assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(yuv.frame_ends.load(Ordering::SeqCst), 6, "every fallback frame ends once");
|
||||
assert!(a == c);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod mov_audio_sample_positions;
|
||||
mod movie_bytes;
|
||||
mod movie_color;
|
||||
mod ms_rounded_footage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_audio_sample_positions;
|
||||
mod opus_roundtrip;
|
||||
mod prores_color;
|
||||
mod render_settings;
|
||||
mod roundtrip;
|
||||
mod targa_sequence;
|
||||
|
||||
@@ -102,5 +102,6 @@ fn h264_movie_bytes_are_pinned() {
|
||||
#[test]
|
||||
fn prores_movie_bytes_are_pinned() {
|
||||
let data = export_bytes(OutputFormat::ProRes, "/out/pinned.mov");
|
||||
assert_eq!((data.len(), fnv1a(&data)), (270_094, 8_038_709_743_204_075_800), "ProRes MOV");
|
||||
// The `nclc` colr atom (#2801) adds exactly 18 bytes; the frames don't move.
|
||||
assert_eq!((data.len(), fnv1a(&data)), (270_112, 2_693_123_490_992_200_481), "ProRes MOV");
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! H.264, HEVC and AV1 MP4s describe the frames the render path made (#2879): the `colr` box, read
|
||||
//! back with FilmCraft's demuxer, and the bitstream's own colour description (the SPS VUI, AV1's
|
||||
//! `color_config`), which ffprobe reports for the first frame of the bare elementary stream when
|
||||
//! ffmpeg can copy it out and decode it.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::{ColorInfo, TrackKind};
|
||||
|
||||
/// (name, Output color space, H.273 primaries / transfer / matrix, the same as ffprobe names them)
|
||||
const ROWS: &[(&str, Option<ColorSpace>, [u16; 3], [&str; 3])] = &[
|
||||
("default", None, [1, 1, 1], ["bt709", "bt709", "bt709"]),
|
||||
("rec2020", Some(ColorSpace::Rec2020), [9, 1, 1], ["bt2020", "bt709", "bt709"]),
|
||||
("p3", Some(ColorSpace::DisplayP3), [12, 13, 1], ["smpte432", "iec61966-2-1", "bt709"]),
|
||||
("pq", Some(ColorSpace::Rec2100Pq), [9, 16, 1], ["bt2020", "smpte2084", "bt709"]),
|
||||
("hlg", Some(ColorSpace::Rec2100Hlg), [9, 18, 1], ["bt2020", "arib-std-b67", "bt709"]),
|
||||
];
|
||||
|
||||
fn render(output: Option<ColorSpace>, format: OutputFormat, path: &Path) {
|
||||
let mut p = Project::default();
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
p.settings.output_space = output;
|
||||
let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.2));
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.75, 0.25, 0.5], width: 64, height: 48, pixel_aspect: 1.0 }));
|
||||
comp.layers.push(build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 48), None));
|
||||
let comp = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let om = OutputModule::for_format(format);
|
||||
let out = path.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp,
|
||||
settings: &settings,
|
||||
output: &om,
|
||||
path: &out,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
export(&job, &mut |_| true).expect("export");
|
||||
}
|
||||
|
||||
/// ffprobe's primaries, transfer and matrix of the first frame of the movie's video copied out as a
|
||||
/// bare elementary stream (`h264`, `hevc` or `obu`), so no container metadata can stand in for the
|
||||
/// bitstream's own; `None` without ffmpeg or when its build can't copy or decode the stream.
|
||||
fn bitstream_tags(path: &Path, format: &str) -> Option<Vec<String>> {
|
||||
let es = path.with_extension(format);
|
||||
let copied =
|
||||
Command::new("ffmpeg").args(["-v", "error", "-y", "-i"]).arg(path).args(["-map", "0:v:0", "-c", "copy", "-f", format]).arg(&es).status().ok()?;
|
||||
if !copied.success() {
|
||||
return None;
|
||||
}
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-read_intervals", "%+#1", "-show_entries", "frame=color_primaries,color_transfer,color_space", "-of", "default=nw=1"])
|
||||
.arg(&es)
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let tag = |key: &str| text.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix('=')).map(str::to_string);
|
||||
Some(vec![tag("color_primaries")?, tag("color_transfer")?, tag("color_space")?])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_tags_describe_the_frames() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/movie_color");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
for (format, codec, stream) in [(OutputFormat::H264, "h264", "h264"), (OutputFormat::Hevc, "hevc", "hevc"), (OutputFormat::Av1, "av1", "obu")] {
|
||||
for &(name, output, [primaries, transfer, matrix], names) in ROWS {
|
||||
let path = dir.join(format!("{codec}_{name}.mp4"));
|
||||
render(output, format, &path);
|
||||
|
||||
let bytes = std::fs::read(&path).expect("read movie");
|
||||
let mp4 = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
|
||||
let track = mp4.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track");
|
||||
let colr = track.entries[0].video.as_ref().and_then(|v| v.color.clone());
|
||||
assert_eq!(colr, Some(ColorInfo::Nclx { primaries, transfer, matrix, full_range: false }), "{codec} {name}: colr");
|
||||
|
||||
match bitstream_tags(&path, stream) {
|
||||
Some(tags) => assert_eq!(tags, names, "{codec} {name}: the bitstream's colour description"),
|
||||
None => eprintln!("{codec} {name}: ffprobe can't decode it here, bitstream not checked"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! ProRes `colr` atom (#2801): it describes the frames the render path made, read back with
|
||||
//! FilmCraft's demuxer and, when installed, ffprobe as an external oracle. A PQ or HLG render
|
||||
//! read back into a managed project has the colours it was rendered from (#2849).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_render::{FootageSource, NoFootage};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::{ColorInfo, TrackKind};
|
||||
|
||||
struct Row {
|
||||
name: &'static str,
|
||||
working: Option<ColorSpace>,
|
||||
output: Option<ColorSpace>,
|
||||
channels: Channels,
|
||||
/// H.273 primaries, transfer and matrix.
|
||||
codes: [u16; 3],
|
||||
/// The same as ffprobe names them.
|
||||
names: [&'static str; 3],
|
||||
}
|
||||
|
||||
const SRGB: Option<ColorSpace> = Some(ColorSpace::Srgb);
|
||||
const PQ: Option<ColorSpace> = Some(ColorSpace::Rec2100Pq);
|
||||
const BT709: [&str; 3] = ["bt709", "bt709", "bt709"];
|
||||
|
||||
const ROWS: &[Row] = &[
|
||||
Row { name: "unmanaged", working: None, output: None, channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 },
|
||||
Row { name: "unmanaged_ignores_output", working: None, output: PQ, channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 },
|
||||
Row { name: "srgb_output_stays_1_1_1", working: SRGB, output: None, channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 },
|
||||
Row { name: "rec709", working: SRGB, output: Some(ColorSpace::Rec709), channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 },
|
||||
Row { name: "rec2020", working: SRGB, output: Some(ColorSpace::Rec2020), channels: Channels::Rgb, codes: [9, 1, 1], names: ["bt2020", "bt709", "bt709"] },
|
||||
Row {
|
||||
name: "p3",
|
||||
working: SRGB,
|
||||
output: Some(ColorSpace::DisplayP3),
|
||||
channels: Channels::Rgb,
|
||||
codes: [12, 13, 1],
|
||||
names: ["smpte432", "iec61966-2-1", "bt709"],
|
||||
},
|
||||
Row { name: "pq", working: SRGB, output: PQ, channels: Channels::Rgb, codes: [9, 16, 1], names: ["bt2020", "smpte2084", "bt709"] },
|
||||
Row {
|
||||
name: "hlg",
|
||||
working: SRGB,
|
||||
output: Some(ColorSpace::Rec2100Hlg),
|
||||
channels: Channels::Rgb,
|
||||
codes: [9, 18, 1],
|
||||
names: ["bt2020", "arib-std-b67", "bt709"],
|
||||
},
|
||||
Row { name: "aces", working: SRGB, output: Some(ColorSpace::AcesCg), channels: Channels::Rgb, codes: [2, 8, 1], names: ["unknown", "linear", "bt709"] },
|
||||
Row { name: "pq_with_alpha", working: SRGB, output: PQ, channels: Channels::Rgba, codes: [9, 16, 1], names: ["bt2020", "smpte2084", "bt709"] },
|
||||
Row { name: "alpha_matte", working: SRGB, output: PQ, channels: Channels::Alpha, codes: [1, 1, 1], names: BT709 },
|
||||
];
|
||||
|
||||
/// The solid every row renders; with the default output it comes out as these values.
|
||||
const SOLID: [f32; 3] = [0.75, 0.25, 0.5];
|
||||
|
||||
fn render(row: &Row, path: &Path) {
|
||||
let mut p = Project::default();
|
||||
p.settings.working_space = row.working;
|
||||
p.settings.output_space = row.output;
|
||||
let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.2));
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: SOLID, width: 64, height: 48, pixel_aspect: 1.0 }));
|
||||
comp.layers.push(build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 48), None));
|
||||
let comp = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let output = OutputModule { channels: row.channels, ..OutputModule::for_format(OutputFormat::ProRes) };
|
||||
run(&p, comp, &NoFootage, &output, path);
|
||||
}
|
||||
|
||||
fn run(p: &Project, comp: effectcraft_project::ItemId, footage: &dyn FootageSource, output: &OutputModule, path: &Path) {
|
||||
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let out = path.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: p,
|
||||
footage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp,
|
||||
settings: &settings,
|
||||
output,
|
||||
path: &out,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
export(&job, &mut |_| true).expect("export");
|
||||
}
|
||||
|
||||
/// ffprobe's primaries, transfer and matrix for the stream, or `None` without ffprobe.
|
||||
fn ffprobe_tags(path: &Path) -> Option<Vec<String>> {
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_primaries,color_transfer,color_space", "-of", "default=nw=1"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.ok()?;
|
||||
assert!(out.status.success(), "ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let tag = |key: &str| text.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix('=')).unwrap_or("").to_string();
|
||||
Some(vec![tag("color_primaries"), tag("color_transfer"), tag("color_space")])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prores_colr_describes_the_frames() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/prores_color");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
for row in ROWS {
|
||||
let path = dir.join(format!("{}.mov", row.name));
|
||||
render(row, &path);
|
||||
|
||||
let bytes = std::fs::read(&path).expect("read movie");
|
||||
let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
|
||||
let track = mov.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track");
|
||||
let [primaries, transfer, matrix] = row.codes;
|
||||
let colr = track.entries[0].video.as_ref().and_then(|v| v.color.clone());
|
||||
assert_eq!(colr, Some(ColorInfo::Nclc { primaries, transfer, matrix }), "{}: colr", row.name);
|
||||
|
||||
if let Some(stream) = ffprobe_tags(&path) {
|
||||
assert_eq!(stream, row.names, "{}: ffprobe stream tags", row.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2849: a PQ or HLG render, imported into a project with an sRGB working space, gets the
|
||||
/// Rec. 2100 profile its `colr` atom names and shows the solid it was rendered from.
|
||||
#[test]
|
||||
fn hdr_renders_reimport_with_their_colours() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/prores_hdr_reimport");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
let want = SOLID.map(|v| (v * 255.0 + 0.5) as i32);
|
||||
for (name, space) in [("pq", ColorSpace::Rec2100Pq), ("hlg", ColorSpace::Rec2100Hlg)] {
|
||||
let row = ROWS.iter().find(|r| r.name == name).expect("row");
|
||||
let movie = dir.join(format!("{name}.mov"));
|
||||
render(row, &movie);
|
||||
|
||||
let footage = effectcraft_media::probe(&movie).expect("probe");
|
||||
let profile = footage.color_profile;
|
||||
let mut p = Project::default();
|
||||
p.settings.working_space = SRGB;
|
||||
let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.1));
|
||||
let fid = p.add_item(name, Label::SeaFoam, None, ItemKind::Footage(footage));
|
||||
comp.layers.push(build::layer(&mut p, &comp, name, LayerSource::Footage { item: fid }, (64, 48), None));
|
||||
let comp = p.add_item("Reimport", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let pattern = dir.join(format!("{name}_[#####].png")).to_string_lossy().to_string();
|
||||
run(&p, comp, &MediaPool::new(), &OutputModule::for_format(OutputFormat::PngSequence), Path::new(&pattern));
|
||||
|
||||
let png = image::open(sequence_path(&pattern, 0)).expect("png").to_rgb8();
|
||||
let got = png.get_pixel(32, 24).0.map(i32::from);
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 2), "{name}: centre {got:?}, want {want:?} (profile {profile:?})");
|
||||
assert_eq!(profile, Some(space), "{name}: profile");
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,23 @@ fn exr_sequence_is_linear() {
|
||||
assert!((c[2] - 0.1329).abs() < 0.01 && (c[3] - 1.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
/// Sequence frames are scan-line EXR, not tiled, so scan-line readers such as FilmCraft open
|
||||
/// them (#2805).
|
||||
#[test]
|
||||
fn exr_sequence_writes_scan_lines() {
|
||||
let (p, cid) = project(None);
|
||||
let d = out_dir("seq-exr-scanline");
|
||||
let om = OutputModule::for_format(OutputFormat::ExrSequence);
|
||||
run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr"));
|
||||
let bytes = std::fs::read(d.join("f_00000.exr")).expect("exr");
|
||||
// Version field (little-endian u32 at bytes 4..8): version 2, then the flags byte. Its 0x02
|
||||
// bit is the tiled bit, 0x200 of the field, so a scan-line file has 0 there.
|
||||
assert_eq!(&bytes[4..6], &[2, 0], "{:02x?}", &bytes[..8]);
|
||||
let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes), false).expect("exr header");
|
||||
assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines);
|
||||
assert_eq!(meta.headers[0].compression, exr::compression::Compression::ZIP16);
|
||||
}
|
||||
|
||||
/// A 32 bpc project whose only layer is a full-frame opaque solid of the given colour.
|
||||
fn bright_project(color: [f32; 3]) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
|
||||
@@ -1144,7 +1144,7 @@ function __global(name, get) {
|
||||
__global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); });
|
||||
__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); });
|
||||
__global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); });
|
||||
['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active',
|
||||
['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active', 'source',
|
||||
'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) {
|
||||
__global(k, function () { return thisLayer[k]; });
|
||||
});
|
||||
|
||||
@@ -1402,6 +1402,89 @@ fn layer_source_is_the_layers_source_item() {
|
||||
assert_close!(f.num("thisComp.layer('Pre').source.numLayers", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// The fixture plus a 20×12 solid "Wide", a 48×36 still "Still" (item "still.png") and a precomp
|
||||
/// layer "Pre" of the 64×32 "Other" comp.
|
||||
fn implicit_source_fx() -> (Fx, LayerId, LayerId, LayerId) {
|
||||
let mut f = fx();
|
||||
let main = f.p.comp(f.cid).unwrap().clone();
|
||||
let wide = solid(&mut f.p, &main, "Wide", [1.0, 1.0, 0.0], 20, 12);
|
||||
let item = f.p.add_item(
|
||||
"still.png",
|
||||
Label::SeaFoam,
|
||||
None,
|
||||
ItemKind::Footage(effectcraft_project::Footage {
|
||||
path: "still.png".into(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 48,
|
||||
height: 36,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let still = build::layer(&mut f.p, &main, "Still", LayerSource::Footage { item }, (48, 36), None);
|
||||
let other = f.p.items.values().find(|i| i.name == "Other").unwrap().id;
|
||||
let pre = build::layer(&mut f.p, &main, "Pre", LayerSource::Comp { item: other }, (64, 32), None);
|
||||
let ids = (wide.id, still.id, pre.id);
|
||||
let c = f.p.comp_mut(f.cid).unwrap();
|
||||
c.layers.push(wide);
|
||||
c.layers.push(still);
|
||||
c.layers.push(pre);
|
||||
(f, ids.0, ids.1, ids.2)
|
||||
}
|
||||
|
||||
fn num_on(f: &Fx, layer: LayerId, text: &str) -> f64 {
|
||||
match f.ev(layer, ROT, text, 0.0) {
|
||||
Ok(v) => v.as_f64(),
|
||||
Err(e) => panic!("{text}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_source_matches_this_layer_source() {
|
||||
let (f, wide, still, pre) = implicit_source_fx();
|
||||
let same = "['name', 'width', 'height', 'duration', 'frameDuration', 'pixelAspect']\
|
||||
.every(function (k) { return source[k] === thisLayer.source[k]; }) ? 1 : 0";
|
||||
for (layer, name, w, h) in [(wide, "Wide", 20.0, 12.0), (still, "still.png", 48.0, 36.0), (pre, "Other", 64.0, 32.0)] {
|
||||
assert_close!(num_on(&f, layer, "source.width"), w);
|
||||
assert_close!(num_on(&f, layer, "source.height"), h);
|
||||
assert_close!(num_on(&f, layer, &format!("source.name == '{name}' ? 1 : 0")), 1.0);
|
||||
assert_close!(num_on(&f, layer, same), 1.0);
|
||||
}
|
||||
assert_close!(num_on(&f, pre, "source.numLayers"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_source_is_null_without_a_source_item() {
|
||||
let f = fx();
|
||||
for layer in [f.title, f.shape] {
|
||||
assert_close!(num_on(&f, layer, "source === null && thisLayer.source === null ? 1 : 0"), 1.0);
|
||||
let bare = f.ev(layer, ROT, "source.width", 0.0).unwrap_err();
|
||||
let qualified = f.ev(layer, ROT, "thisLayer.source.width", 0.0).unwrap_err();
|
||||
assert!(qualified.contains("TypeError"), "{qualified}");
|
||||
assert_eq!(bare, qualified);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_source_can_be_shadowed_locally() {
|
||||
let (f, wide, ..) = implicit_source_fx();
|
||||
assert_close!(num_on(&f, wide, "var source = 7;\nsource * 2"), 14.0);
|
||||
assert_close!(num_on(&f, wide, "source.width"), 20.0);
|
||||
assert_close!(num_on(&f, wide, "var source = thisComp.layer('B').source;\nsource.width"), 40.0);
|
||||
assert_close!(num_on(&f, wide, "source.height"), 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_source_follows_the_current_layer() {
|
||||
let (f, wide, still, pre) = implicit_source_fx();
|
||||
let e = "source === null ? -1 : source.width * 1000 + source.height";
|
||||
for _ in 0..2 {
|
||||
for (layer, want) in [(wide, 20012.0), (still, 48036.0), (pre, 64032.0), (f.title, -1.0), (f.b, 40040.0)] {
|
||||
assert_close!(num_on(&f, layer, e), want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_control_property_stands_for_its_layer() {
|
||||
let mut f = fx();
|
||||
|
||||
@@ -21,6 +21,7 @@ effectcraft-keyframe = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
@@ -489,7 +489,7 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
||||
let zout = tex(DEPTH_OUT, rt);
|
||||
let depth = tex(DEPTH, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
let (cv, zv, dv) = (color.create_view(&Default::default()), zout.create_view(&Default::default()), depth.create_view(&Default::default()));
|
||||
let enc = e.encoder();
|
||||
let enc = e.record(&[]);
|
||||
{
|
||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("adv3d"),
|
||||
@@ -763,7 +763,7 @@ fn dispatch(e: &mut Enc, kernel: &str, u0: [u32; 4], f0: [f32; 4], b: Binds, gro
|
||||
}
|
||||
entries.push(wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::TextureView(b.out.unwrap_or(&post.dummy_out)) });
|
||||
let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some(kernel), layout: &post.bgl, entries: &entries });
|
||||
let enc = e.encoder();
|
||||
let enc = e.record(&[]);
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(kernel), timestamp_writes: None });
|
||||
pass.set_pipeline(pipe);
|
||||
pass.set_bind_group(0, &bg, &[]);
|
||||
@@ -799,7 +799,7 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option<Vec<u8>> {
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
e.encoder().copy_buffer_to_buffer(buf, 0, &rb, 0, size);
|
||||
e.record(&[]).copy_buffer_to_buffer(buf, 0, &rb, 0, size);
|
||||
e.submit();
|
||||
let out = e.g.read_buffer(&rb, row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?;
|
||||
Some(out)
|
||||
@@ -922,6 +922,7 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h):
|
||||
|
||||
/// The resolved run as an image in the canvas's encoding, composited over `canvas` when given.
|
||||
pub(crate) fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&GpuImage>) -> GpuImage {
|
||||
canvas.inspect(|c| e.uses(c));
|
||||
let out = e.image(r.width, r.height);
|
||||
let ov = out.texture.create_view(&Default::default());
|
||||
let cv = canvas.map(|c| c.texture.create_view(&Default::default()));
|
||||
@@ -1023,7 +1024,7 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc<crate::deferred::
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
e.encoder().copy_buffer_to_buffer(&r.depth, 0, &rb, 0, size);
|
||||
e.record(&[]).copy_buffer_to_buffer(&r.depth, 0, &rb, 0, size);
|
||||
e.submit();
|
||||
let done = d.start(kz, w, h, [0.0; 2], 1.0);
|
||||
g.map_readback(&rb, row, row, 1, move |r| done(r.map_err(|e| log::error!("gpu depth readback: {e}")).ok()));
|
||||
@@ -1080,6 +1081,7 @@ pub(crate) fn sky(e: &mut Enc, sky: &effectcraft_render::three_d::SkyDraw, canva
|
||||
/// Wireframe-quality layer outlines: `canvas` with `pixels` set to opaque white
|
||||
/// ([`effectcraft_render::Renderer::wireframe_pixels`]).
|
||||
pub(crate) fn wireframe(e: &mut Enc, canvas: &GpuImage, pixels: &[(u32, u32)]) -> GpuImage {
|
||||
e.uses(canvas);
|
||||
let out = e.image(canvas.width, canvas.height);
|
||||
e.copy_into(canvas, &out, 0, 0);
|
||||
if pixels.is_empty() {
|
||||
|
||||
+757
-124
File diff suppressed because it is too large.
Load diff
@@ -101,6 +101,7 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option
|
||||
e.take_missing();
|
||||
for step in chain {
|
||||
b = apply(e, step.spec.id, &step.ctx, b)?;
|
||||
e.mem_gate();
|
||||
if e.take_missing() {
|
||||
return None;
|
||||
}
|
||||
@@ -158,6 +159,7 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
|
||||
self.e.take_missing();
|
||||
for step in steps {
|
||||
let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false };
|
||||
self.e.mem_gate();
|
||||
// A kernel the device couldn't build: the effects render on the CPU (#613).
|
||||
if self.e.take_missing() {
|
||||
return false;
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
//! Layer is chosen; the colouring runs here.
|
||||
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_effects::EffectCtx;
|
||||
use effectcraft_effects::{Buf, EffectCtx};
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::context::{Enc, GpuImage, Params};
|
||||
use crate::effects::{GBuf, box_passes, gaussian_blur};
|
||||
use crate::ops::mode_id;
|
||||
@@ -380,14 +382,24 @@ fn clamp_plane(e: &mut Enc, pl: &GpuImage, lo: f32, hi: f32) -> GpuImage {
|
||||
// ---------------------------------------------------------------- CC HexTile / CC Mr. Smoothie (stylize3)
|
||||
|
||||
fn hextile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let r = (ctx.params.f("radius") * b.scale).max(0.5);
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let (sn, cs) = ctx.params.f("rotate").to_radians().sin_cos();
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = ctx.params.e("render");
|
||||
p.f[0] = [c.0 as f32, c.1 as f32, r as f32, (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0) as f32];
|
||||
p.f[1] = [sn as f32, cs as f32, 0.0, 0.0];
|
||||
run(e, "fp2_hextile", &p, b, None, None)
|
||||
let count = (b.img.width as usize).checked_mul(b.img.height as usize)?;
|
||||
if count.checked_mul(8)? > e.g.device.limits().max_storage_buffer_binding_size as usize {
|
||||
return None;
|
||||
}
|
||||
let geometry = effectcraft_effects::HexTile::new(
|
||||
ctx,
|
||||
&Buf { img: Image { width: b.img.width, height: b.img.height, data: vec![] }, offset: b.offset, scale: b.scale },
|
||||
);
|
||||
let stride = (b.img.width as usize).checked_mul(2)?.max(2);
|
||||
let mut coords = vec![0.0; count.checked_mul(2)?];
|
||||
coords.par_chunks_mut(stride).enumerate().for_each(|(y, row)| {
|
||||
for (x, xy) in row.as_chunks_mut::<2>().0.iter_mut().enumerate() {
|
||||
let (sx, sy) = geometry.source_at(x as u32, y as u32);
|
||||
*xy = [sx as f32, sy as f32];
|
||||
}
|
||||
});
|
||||
let data = e.data(&coords);
|
||||
run(e, "fp2_hextile", &Params::default(), b, None, Some(&data))
|
||||
}
|
||||
|
||||
fn smoothie(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
|
||||
@@ -174,6 +174,64 @@ fn parse_map(s: &str) -> Vec<f32> {
|
||||
vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
/// Piecewise-linear lookup in the CPU effect's f64 phase domain. Store the
|
||||
/// first representable f32 at each boundary, its value and the interval slope.
|
||||
/// Computing floor/wrap in shader f32 can select the end of the map instead of
|
||||
/// its beginning at a wrap (a full-scale colour error).
|
||||
fn arbitrary_map_table(map: &[f32], phase: f64) -> Option<Vec<f32>> {
|
||||
let n = map.len();
|
||||
if !(2..=4096).contains(&n) || !phase.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let scale = (n - 1) as f64;
|
||||
let offset = phase * scale / 255.0;
|
||||
if !offset.is_finite() || offset.abs() >= (1u64 << 52) as f64 {
|
||||
return None;
|
||||
}
|
||||
let at = |v: f32| {
|
||||
let x = (v as f64 * scale + offset).rem_euclid(n as f64);
|
||||
// `rem_euclid` can round a tiny negative phase up to `n`: wrap the index like the CPU
|
||||
// lookup (`color3::arbitrary_map`) so both endpoints come from the start of the map.
|
||||
let i = x.floor() as usize % n;
|
||||
let a = *map.get(i).unwrap_or(&0.0);
|
||||
let b = *map.get((i + 1).min(n - 1)).unwrap_or(&a);
|
||||
(a + (b - a) * (x - x.floor()) as f32, (b - a) * scale as f32)
|
||||
};
|
||||
let mut points = vec![0.0f32];
|
||||
// Search ordered positive f32 encodings using the CPU's actual f64 sum.
|
||||
// Merely rounding a mathematical boundary upward misses the half-ulp
|
||||
// region where f64 addition itself rounds across a wrap.
|
||||
let first = offset.floor() + 1.0;
|
||||
for i in 0..=n {
|
||||
let boundary = first + i as f64;
|
||||
if boundary > scale + offset {
|
||||
break;
|
||||
}
|
||||
let (mut lo, mut hi) = (0u32, 1.0f32.to_bits());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if f32::from_bits(mid) as f64 * scale + offset < boundary {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
let start = f32::from_bits(lo);
|
||||
if points.last().is_none_or(|last| start > *last) {
|
||||
points.push(start);
|
||||
}
|
||||
}
|
||||
if points.last() != Some(&1.0) {
|
||||
points.push(1.0);
|
||||
}
|
||||
let mut table = Vec::with_capacity(points.len() * 3);
|
||||
for v in points {
|
||||
let (value, slope) = at(v);
|
||||
table.extend([v, value, slope]);
|
||||
}
|
||||
Some(table)
|
||||
}
|
||||
|
||||
fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let f = |k: &str| pr.f(k) as f32;
|
||||
@@ -308,9 +366,9 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
}
|
||||
"ec.color.psarbitrarymap" => {
|
||||
let map = parse_map(pr.s("map"));
|
||||
p.u[0] = [12, pr.b("applyPhaseMapToAlpha") as u32, map.len() as u32, 0];
|
||||
p.f[0][0] = (pr.f("phase") * (map.len() - 1) as f64 / 255.0) as f32;
|
||||
data = Some(map);
|
||||
let table = arbitrary_map_table(&map, pr.f("phase"))?;
|
||||
p.u[0] = [12, pr.b("applyPhaseMapToAlpha") as u32, (table.len() / 3) as u32, 0];
|
||||
data = Some(table);
|
||||
}
|
||||
"ec.color.videolimiter" => {
|
||||
const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09];
|
||||
@@ -379,3 +437,19 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
}
|
||||
run(e, &p, b, data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod map_tests {
|
||||
use super::arbitrary_map_table;
|
||||
|
||||
/// A phase just below zero rounds `rem_euclid` up to `n` at input 0: the table must hold the
|
||||
/// map's first entry and the slope towards its second, as the CPU lookup does.
|
||||
#[test]
|
||||
fn arbitrary_map_wraps_a_phase_rounded_to_the_end() {
|
||||
let map = [0.25f32, 0.75, 1.0];
|
||||
let table = arbitrary_map_table(&map, -1e-300).unwrap();
|
||||
assert_eq!(table[0], 0.0);
|
||||
assert_eq!(table[1], 0.25, "value at input 0 is map[0]");
|
||||
assert_eq!(table[2], 0.5 * 2.0, "slope from map[0] to map[1], times n - 1");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
use effectcraft_effects::{Buf, EffectCtx};
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::context::{Enc, GpuImage, Params};
|
||||
use crate::effects::{GBuf, gaussian_blur};
|
||||
|
||||
@@ -203,25 +205,22 @@ fn grid_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
if done <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let c = b.to_px(pr.v2("center"));
|
||||
let rot = pr.f("rotation").to_radians();
|
||||
let border = pr.f("border").max(0.0) * b.scale;
|
||||
let tiles = pr.f("tiles").round().clamp(1.0, 500.0);
|
||||
let shape = pr.e("shape");
|
||||
let cs = (ctx.layer_size[0] * b.scale / tiles).max(1.0);
|
||||
let (s, co) = rot.sin_cos();
|
||||
let metric = |dx: f64, dy: f64| match shape {
|
||||
0 => dx.abs(),
|
||||
2 => dx.abs().max(dy.abs()),
|
||||
_ => dx.hypot(dy),
|
||||
};
|
||||
let maxd = max_corner_dist(&b, c, |dx, dy| metric(dx * co + dy * s, -dx * s + dy * co));
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [shape, pr.b("reverse") as u32, 0, 0];
|
||||
p.f[0] = f4([c.0, c.1, s, co]);
|
||||
p.f[1] = f4([cs, maxd, done, border]);
|
||||
run(e, "ftr_grid", &p, b, None, None)
|
||||
// Decide discontinuous tile coverage in the CPU effect's f64 geometry.
|
||||
// Only a scalar mask is uploaded; source pixels and multiplication stay resident.
|
||||
let count = (b.img.width as usize).checked_mul(b.img.height as usize)?;
|
||||
if count.checked_mul(4)? > e.g.device.limits().max_storage_buffer_binding_size as usize {
|
||||
return None;
|
||||
}
|
||||
let geometry = shape(&b);
|
||||
let grid = effectcraft_effects::GridWipe::new(ctx, &geometry);
|
||||
let mut mask = vec![0.0; count];
|
||||
mask.par_chunks_mut((b.img.width as usize).max(1)).enumerate().for_each(|(y, row)| {
|
||||
for (x, value) in row.iter_mut().enumerate() {
|
||||
*value = grid.coverage(x as u32, y as u32);
|
||||
}
|
||||
});
|
||||
let data = e.data(&mask);
|
||||
run(e, "ftr_grid", &Params::default(), b, None, Some(&data))
|
||||
}
|
||||
|
||||
fn radial_scale_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
//! Content-Aware Fill's per-frame synthesis and flow on the GPU: PatchMatch's multi-scale EM
|
||||
//! loop, propagation, random search and vote of `effectcraft_raster::inpaint`, and the block
|
||||
//! matching of `effectcraft_raster::flow::block_flow`, as compute kernels following the CPU's
|
||||
//! steps (`shaders/inpaint.wgsl`).
|
||||
//!
|
||||
//! The fill's input frames are composited on the CPU by design (analyses do not use the job
|
||||
//! worker's GPU: `effectcraft_engine::background::offload::job_accel`), so this module is about the fill
|
||||
//! itself — the part that runs for minutes on a large mask, and whose parallel search is what a
|
||||
//! device wins on. The membrane (harmonic) solve, the flow's hole completion, the orchestration
|
||||
//! ([`effectcraft_raster::inpaint::fill_sequence_with`]), the flow-guided propagation and the
|
||||
//! per-frame flow control stay on the CPU: the membrane is bandwidth-bound (see
|
||||
//! [`FillBackend::patch`]), and the propagation's coupling is sequential, needing every frame of
|
||||
//! the range resident at once.
|
||||
//!
|
||||
//! Parity with the CPU reference is a quality contract, not bit-exactness: the CPU's
|
||||
//! scan-order propagation becomes a two-pass checkerboard, and its in-place relaxation becomes
|
||||
//! Jacobi reads. Both solve the same problem to the same quality (`tests/inpaint.rs`).
|
||||
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::flow::{Flow, block_flow};
|
||||
use effectcraft_raster::inpaint::{FillBackend, complete_flow, dilate};
|
||||
|
||||
use crate::Gpu;
|
||||
use crate::context::{Enc, GpuContext, GpuImage, KernelImages, Params};
|
||||
|
||||
/// The kernels this module adds to the shared pipeline registry (`context.rs`).
|
||||
pub(crate) const KERNELS: &[&str] = &[
|
||||
"imp_down",
|
||||
"imp_up",
|
||||
"imp_jacobi",
|
||||
"imp_pm_down",
|
||||
"imp_pm_up",
|
||||
"imp_nnf_up",
|
||||
"imp_pm_init",
|
||||
"imp_pm_prop",
|
||||
"imp_pm_rand",
|
||||
"imp_nnf_store",
|
||||
"imp_pm_vote",
|
||||
"imp_hist",
|
||||
"imp_fl_half",
|
||||
"imp_fl_search",
|
||||
"imp_fl_subpix",
|
||||
"imp_fl_median",
|
||||
];
|
||||
|
||||
/// Distance histogram bins (`imp_hist`); bin 0's `.g` holds the largest finite distance the
|
||||
/// dispatch saw, for the HDR re-bin.
|
||||
const BINS: usize = 1024;
|
||||
|
||||
/// The distance range `imp_hist`'s bins cover for SDR input. An image whose channels are all ≤ 1
|
||||
/// has `Σ_c (a − b)² ≤ 4`, so no patch distance exceeds it and the bins need no rescaling.
|
||||
const SDR_DISTANCE: f32 = 4.0;
|
||||
|
||||
/// `u`/`f` blocks of a kernel's parameter buffer (see `inpaint.wgsl`'s header).
|
||||
fn params(u0: [u32; 4], u1: [u32; 4], u2: [u32; 4], f0: [f32; 4]) -> Params {
|
||||
Params { u: [u0, u1, u2, [0; 4]], f: [f0, [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4]] }
|
||||
}
|
||||
|
||||
/// `p` with this level's mask offset in `u[3].x` (`inpaint.wgsl`'s `data`).
|
||||
fn with_off(p: Params, off: u32) -> Params {
|
||||
Params { u: [p.u[0], p.u[1], p.u[2], [off, 0, 0, 0]], f: p.f }
|
||||
}
|
||||
|
||||
/// A relaxation sweep count of `membrane_core`. The CPU uses 200 for the small levels and 40 for
|
||||
/// the large ones; this uses 64 and 40. The small levels are where the difference lies: Jacobi
|
||||
/// needs O(n²) sweeps to converge on an n-wide grid, so at the levels the coarse-to-fine schedule
|
||||
/// actually reaches (n ≤ 16, where n² ≤ 256) 64 sweeps is already converged, and finer levels
|
||||
/// start from a good guess. Matching the CPU's 200 measured ≈ 6% slower on the whole fill with no
|
||||
/// change in the result to two decimals (67.0 dB either way), so the schedule is deliberately
|
||||
/// not the CPU's — Jacobi and Gauss–Seidel do not converge alike, and the parity contract is
|
||||
/// quality, not bit-exactness.
|
||||
fn sweep_count(w: usize, h: usize) -> usize {
|
||||
if w * h <= 4096 { 64 } else { 40 }
|
||||
}
|
||||
|
||||
/// PatchMatch's patch radius at a level (`pm_level`).
|
||||
fn radius(patch: usize, w: u32, h: u32) -> u32 {
|
||||
(patch / 2).min(w.min(h).saturating_sub(1) as usize / 2).max(1) as u32
|
||||
}
|
||||
|
||||
/// The bounding box of `mask`'s set pixels, `[x0, y0, x1, y1)`, or `None` when it is empty.
|
||||
fn bbox(mask: &[bool], w: usize, h: usize) -> Option<[u32; 4]> {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if mask.get(y * w + x).copied().unwrap_or(false) {
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x + 1);
|
||||
y1 = y1.max(y + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if x0 == usize::MAX { None } else { Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) }
|
||||
}
|
||||
|
||||
/// [`bbox`] grown by `n` pixels, clipped to the image.
|
||||
fn grow(b: [u32; 4], n: u32, w: u32, h: u32) -> [u32; 4] {
|
||||
[b[0].saturating_sub(n), b[1].saturating_sub(n), b[2].saturating_add(n).min(w), b[3].saturating_add(n).min(h)]
|
||||
}
|
||||
|
||||
/// The bounding box of the pixels a membrane level still has to solve (`known` false), or
|
||||
/// `None` when every pixel is known.
|
||||
fn unknown_bbox(known: &[f32], w: usize, h: usize) -> Option<[u32; 4]> {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if known.get(y * w + x).copied().unwrap_or(1.0) <= 0.5 {
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x + 1);
|
||||
y1 = y1.max(y + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if x0 == usize::MAX { None } else { Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) }
|
||||
}
|
||||
|
||||
/// One dispatch's images, its per-pixel `data` table and the region it covers. Grouped into a
|
||||
/// struct because a kernel's bindings plus its bounds exceed clippy's argument limit, and the
|
||||
/// workspace denies `too_many_arguments`.
|
||||
struct Pass<'a> {
|
||||
src: &'a GpuImage,
|
||||
aux: Option<&'a GpuImage>,
|
||||
out: &'a GpuImage,
|
||||
data: Option<&'a wgpu::Buffer>,
|
||||
bb: [u32; 4],
|
||||
}
|
||||
|
||||
/// Dispatch a kernel over `pass.bb`. The box's origin is written into `P.u[1].xy` (the kernels
|
||||
/// place their other `u[1]` fields in `.zw`): deriving it from the box the dispatch is sized from
|
||||
/// keeps a dispatch's covered region and the origin its shader reads from drifting apart, which
|
||||
/// is the subtle way an edge of the region goes unwritten.
|
||||
fn over(e: &mut Enc, entry: &str, p: &Params, pass: Pass<'_>) {
|
||||
let bb = pass.bb;
|
||||
let p = Params { u: [p.u[0], [bb[0], bb[1], p.u[1][2], p.u[1][3]], p.u[2], p.u[3]], f: p.f };
|
||||
let groups = ((bb[2] - bb[0]).div_ceil(16), (bb[3] - bb[1]).div_ceil(16));
|
||||
e.dispatch(entry, &p, KernelImages { src: pass.src, aux: pass.aux, out: pass.out }, pass.data, groups);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ membrane
|
||||
|
||||
/// Relax `fine`'s unknown pixels — the harmonic interpolation of their border — with the CPU's
|
||||
/// coarse-to-fine schedule (`membrane_core`). `spare` is a free texture of the same size; the
|
||||
/// result is returned (it may be `fine`, `spare` or one of the coarse levels).
|
||||
fn membrane_tex(e: &mut Enc, fine: &GpuImage, spare: &GpuImage, hole: &[bool], w: u32, h: u32) -> Option<GpuImage> {
|
||||
let (uw, uh) = (w as usize, h as usize);
|
||||
let n = uw * uh;
|
||||
if hole.len() < n || !hole.iter().take(n).any(|b| *b) || !hole.iter().take(n).any(|b| !*b) {
|
||||
return Some(fine.clone());
|
||||
}
|
||||
// The recursion's levels: halve while the level is wider than 8.
|
||||
let mut dims = vec![(uw, uh)];
|
||||
while let Some(&(cw, ch)) = dims.last() {
|
||||
if !(cw > 8 && ch > 8) || dims.len() >= 32 {
|
||||
break;
|
||||
}
|
||||
dims.push((cw.div_ceil(2), ch.div_ceil(2)));
|
||||
}
|
||||
// The known mask of every level: a coarse pixel is known where any of its 2×2 is.
|
||||
let mut knowns: Vec<Vec<f32>> = Vec::with_capacity(dims.len());
|
||||
knowns.push(hole.iter().take(n).map(|b| if *b { 0.0 } else { 1.0 }).collect());
|
||||
for k in 1..dims.len() {
|
||||
let (pw, ph) = dims[k - 1];
|
||||
let (cw, ch) = dims[k];
|
||||
let prev = &knowns[k - 1];
|
||||
let mut cur = vec![0.0f32; cw * ch];
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
for (dx, dy) in [(0usize, 0usize), (1, 0), (0, 1), (1, 1)] {
|
||||
let (fx, fy) = (2 * x + dx, 2 * y + dy);
|
||||
if fx < pw && fy < ph && prev[fy * pw + fx] > 0.5 {
|
||||
cur[y * cw + x] = 1.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
knowns.push(cur);
|
||||
}
|
||||
// One buffer for every level's mask (a buffer per level costs a device allocation each).
|
||||
let mut packed: Vec<f32> = Vec::with_capacity(knowns.iter().map(|k| k.len()).sum());
|
||||
let mut offs: Vec<u32> = Vec::with_capacity(knowns.len());
|
||||
for k in &knowns {
|
||||
offs.push(packed.len() as u32);
|
||||
packed.extend_from_slice(k);
|
||||
}
|
||||
let datas = e.data(&packed);
|
||||
let moff = |k: usize| offs.get(k).copied().unwrap_or(0);
|
||||
// The bounds of each level's unknown pixels: the relaxation only touches them (known pixels
|
||||
// never move), which keeps a fill's cost proportional to the hole, not the frame.
|
||||
let mut uns: Vec<Option<[u32; 4]>> = Vec::with_capacity(dims.len());
|
||||
for (k, &(cw, ch)) in dims.iter().enumerate() {
|
||||
uns.push(unknown_bbox(knowns.get(k)?, cw, ch));
|
||||
}
|
||||
// Two textures per coarse level, for the ping-pong sweeps.
|
||||
let mut lv: Vec<GpuImage> = Vec::with_capacity(2 * dims.len());
|
||||
for k in 1..dims.len() {
|
||||
let (cw, ch) = dims[k];
|
||||
lv.push(e.scratch(cw as u32, ch as u32));
|
||||
lv.push(e.scratch(cw as u32, ch as u32));
|
||||
}
|
||||
// Fine to coarse.
|
||||
for k in 1..dims.len() {
|
||||
let (pw, ph) = dims[k - 1];
|
||||
let (cw, ch) = dims[k];
|
||||
let p = with_off(params([pw as u32, ph as u32, cw as u32, ch as u32], [0; 4], [0; 4], [0.0; 4]), moff(k - 1));
|
||||
let src = if k == 1 { fine } else { lv.get(2 * (k - 2))? };
|
||||
e.pixels("imp_down", &p, src, None, lv.get(2 * (k - 1))?, Some(&datas));
|
||||
}
|
||||
// Relax, coarse to fine. `first[k]` says whether level k's image is the first of its pair
|
||||
// (level 0's pair is `fine`/`spare`).
|
||||
let top = dims.len() - 1;
|
||||
let mut first = vec![true; dims.len()];
|
||||
if top >= 1 {
|
||||
let (a, b) = (lv.get(2 * (top - 1))?, lv.get(2 * (top - 1) + 1)?);
|
||||
// Both textures must hold the level before the sweeps: they now write only the hole.
|
||||
e.copy_into(a, b, 0, 0);
|
||||
first[top] = sweeps(e, &datas, moff(top), a, b, Region { w: dims[top].0 as u32, h: dims[top].1 as u32, bb: uns.get(top).copied().flatten() });
|
||||
}
|
||||
for k in (1..top).rev() {
|
||||
let (cw, ch) = dims[k];
|
||||
let (pw, ph) = dims[k + 1];
|
||||
let coarse = lv.get(2 * k + usize::from(!first[k + 1]))?;
|
||||
let (fa, fb) = (lv.get(2 * (k - 1))?, lv.get(2 * (k - 1) + 1)?);
|
||||
e.copy_into(fa, fb, 0, 0);
|
||||
if let Some(bb) = uns.get(k).copied().flatten() {
|
||||
let p = with_off(params([cw as u32, ch as u32, pw as u32, ph as u32], [0; 4], [0; 4], [0.0; 4]), moff(k));
|
||||
over(e, "imp_up", &p, Pass { src: coarse, aux: None, out: fb, data: Some(&datas), bb });
|
||||
}
|
||||
// `imp_up` wrote into `fb`, the pair's second.
|
||||
first[k] = !sweeps(e, &datas, moff(k), fb, fa, Region { w: cw as u32, h: ch as u32, bb: uns.get(k).copied().flatten() });
|
||||
}
|
||||
if top >= 1 {
|
||||
let coarse = lv.get(usize::from(!first[1]))?;
|
||||
e.copy_into(fine, spare, 0, 0);
|
||||
let bb = uns.first().copied().flatten()?;
|
||||
let p = with_off(params([w, h, dims[1].0 as u32, dims[1].1 as u32], [0; 4], [0; 4], [0.0; 4]), moff(0));
|
||||
over(e, "imp_up", &p, Pass { src: coarse, aux: None, out: spare, data: Some(&datas), bb });
|
||||
return Some(if sweeps(e, &datas, moff(0), spare, fine, Region { w, h, bb: Some(bb) }) { spare.clone() } else { fine.clone() });
|
||||
}
|
||||
let bb = uns.first().copied().flatten()?;
|
||||
e.copy_into(fine, spare, 0, 0);
|
||||
Some(if sweeps(e, &datas, moff(0), fine, spare, Region { w, h, bb: Some(bb) }) { fine.clone() } else { spare.clone() })
|
||||
}
|
||||
|
||||
/// A texture's dimensions and the region a dispatch covers (`None`: nothing to do).
|
||||
struct Region {
|
||||
w: u32,
|
||||
h: u32,
|
||||
bb: Option<[u32; 4]>,
|
||||
}
|
||||
|
||||
/// Jacobi relaxation sweeps of a texture pair, over the unknown pixels' bounds `region.bb` (the
|
||||
/// state starts in `a`); returns whether the result is in `a`.
|
||||
fn sweeps(e: &mut Enc, data: &wgpu::Buffer, off: u32, a: &GpuImage, b: &GpuImage, region: Region) -> bool {
|
||||
let Some(bb) = region.bb else { return true };
|
||||
let p = with_off(params([region.w, region.h, 0, 0], [0; 4], [0; 4], [0.0; 4]), off);
|
||||
let mut in_a = true;
|
||||
for _ in 0..sweep_count(region.w as usize, region.h as usize) {
|
||||
let (src, out) = if in_a { (a, b) } else { (b, a) };
|
||||
over(e, "imp_jacobi", &p, Pass { src, aux: None, out, data: Some(data), bb });
|
||||
in_a = !in_a;
|
||||
}
|
||||
in_a
|
||||
}
|
||||
|
||||
/// `img` with the pixels `mask` marks inside `bb` replaced by `rect` — the tightly packed RGBA f32
|
||||
/// rows of the rectangle that was read back. Pixels outside the mask stay the input's, bit for bit.
|
||||
fn patch_back(img: &Image, mask: &[bool], bb: [u32; 4], rect: &[u8]) -> Image {
|
||||
let (w, rw) = (img.width as usize, (bb[2] - bb[0]) as usize);
|
||||
let mut out = img.clone();
|
||||
for y in bb[1]..bb[3] {
|
||||
for x in bb[0]..bb[2] {
|
||||
let i = y as usize * w + x as usize;
|
||||
if !mask.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let off = ((y - bb[1]) as usize * rw + (x - bb[0]) as usize) * 16;
|
||||
let Some(b) = rect.get(off..off + 16) else { continue };
|
||||
let mut px = [0.0f32; 4];
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = f32::from_le_bytes([b[c * 4], b[c * 4 + 1], b[c * 4 + 2], b[c * 4 + 3]]);
|
||||
}
|
||||
out.data[i] = px;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl FillBackend for Gpu {
|
||||
/// PatchMatch synthesis — the fill's dominant cost, and the stage a device wins on: two to
|
||||
/// three times faster than the CPU's 18-rayon-thread loop at 1920×1080 with a 300×200 hole
|
||||
/// on an Intel Arc iGPU, and far more on a discrete one.
|
||||
///
|
||||
/// The membrane ([`FillBackend::membrane`]) deliberately stays on the CPU: a harmonic solve
|
||||
/// over a hole is only a few tens of milliseconds, and on the same measurement it is *slower*
|
||||
/// on the device (≈ 90 ms against ≈ 55 ms). Most of that is the fill's full-frame data
|
||||
/// movement — uploading the frame, copying it and building the known pyramid — which does not
|
||||
/// shrink with the hole, while the relaxation itself does. The CPU's in-place Gauss–Seidel
|
||||
/// over many cores is already competitive there, so the device only takes the stage where its
|
||||
/// parallel search actually pays. (`membrane_tex` still runs on the device *inside* this
|
||||
/// stage, where it is the coarsest level's start and never crosses back to the CPU.)
|
||||
fn patch(&self, img: &Image, hole: &[bool], init: Option<&Image>, patch: usize, seed: u32) -> Image {
|
||||
// The error scopes make an allocation or validation failure local to this frame: the
|
||||
// CPU fills it instead, rather than an uncaptured error retiring the shared context.
|
||||
if self.ctx.can_wait()
|
||||
&& let Ok(Some(out)) = self.within_memory(|| patch_image(&self.ctx, img, hole, init, patch, seed))
|
||||
{
|
||||
return out;
|
||||
}
|
||||
effectcraft_raster::inpaint::patch_fill(img, hole, init, patch, seed)
|
||||
}
|
||||
|
||||
/// The block matching that the sequence's flow-guided propagation starts from, which is the
|
||||
/// flow's dominant cost. The field is a small `cols × rows` grid (8 px tiles, so 240 × 135 at
|
||||
/// 1080p), and the search is texture-load-bound: at 1920×1080 the CPU's `block_flow` is
|
||||
/// ≈ 65–70 ms against ≈ 55 ms here, most of it the coarsest level's window (`radius 6` over
|
||||
/// up to six seeds) and the finest level's 8×8 blocks. That is a smaller margin than
|
||||
/// [`patch`](Self::patch), but the flow runs twice per frame pair, so on a four-frame 1080p
|
||||
/// sequence it is worth ≈ 0.15× of the fill's total (1.42× against 1.27× with the CPU flow).
|
||||
///
|
||||
/// The field is exhaustive, not heuristic: it matches the CPU's to a float rounding, so the
|
||||
/// propagation sees the same motion. Both A's block per candidate (hoisted into registers)
|
||||
/// and the CPU's `Plane::luma` (computed here by [`effectcraft_raster::flow::luma_pair`] and
|
||||
/// uploaded, because a device may contract `a·b + c` into a multiply-add and round the luma
|
||||
/// differently) are why it is that close.
|
||||
///
|
||||
/// The completion — marking the tiles that touch either hole and interpolating the surrounding
|
||||
/// motion over them — stays on the CPU ([`complete_flow`]). It is a membrane solve over that
|
||||
/// same tiny grid whose cost is the mask, not the solve, so there is little for the device to
|
||||
/// take; keeping it here also keeps the completed field the CPU's to within the search's
|
||||
/// rounding.
|
||||
fn flow(&self, a: &Image, b: &Image, hole_a: &[bool], hole_b: &[bool]) -> Flow {
|
||||
// Frame-local error scopes, as for the patch: a failed GPU search falls back to the CPU.
|
||||
if self.ctx.can_wait()
|
||||
&& let Ok(Some(f)) = self.within_memory(|| flow_image(&self.ctx, a, b))
|
||||
{
|
||||
return complete_flow(f, hole_a, hole_b, a.width as usize, a.height as usize);
|
||||
}
|
||||
complete_flow(block_flow(a, b, 8, 6), hole_a, hole_b, a.width as usize, a.height as usize)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ block flow
|
||||
|
||||
/// `effectcraft_raster::flow::block_flow(a, b, 8, 6)` on the device: the two frames' luma
|
||||
/// pyramids, the coarse-to-fine search, the sub-pixel fit and the 3×3 median, returning the raw
|
||||
/// field ([`complete_flow`] does the hole completion). `None` where the device cannot run it and
|
||||
/// the caller falls back to the CPU.
|
||||
pub(crate) fn flow_image(g: &GpuContext, a: &Image, b: &Image) -> Option<Flow> {
|
||||
let (w, h) = (a.width, a.height);
|
||||
if w == 0 || h == 0 || w != b.width || h != b.height || !g.fits(w, h) {
|
||||
return None;
|
||||
}
|
||||
let (tile, win, radius) = (8u32, 8u32, 6u32);
|
||||
let cols = (w as usize).div_ceil(tile as usize).max(1);
|
||||
let rows = (h as usize).div_ceil(tile as usize).max(1);
|
||||
// block_flow_window's early out: a frame too small to hold one block and window.
|
||||
if w < tile || h < tile || w < win || h < win {
|
||||
return Some(Flow { width: w, height: h, block: tile, cols, rows, v: vec![[0.0; 2]; cols * rows] });
|
||||
}
|
||||
let mut e = Enc::new(g);
|
||||
// The luma pyramid: `Plane::luma` (packed .x/.y), halved while both sides stay worth matching
|
||||
// (`Plane::half`), at most four levels.
|
||||
let mut dims = vec![(w, h)];
|
||||
while dims.len() < 4 {
|
||||
let (cw, ch) = *dims.last()?;
|
||||
if cw / 2 < 16 || ch / 2 < 16 {
|
||||
break;
|
||||
}
|
||||
dims.push(((cw / 2).max(1), (ch / 2).max(1)));
|
||||
}
|
||||
// The luma is the CPU's (`luma_pair`), so the field matches `block_flow`'s bit for bit; the
|
||||
// halves below are exact additions and a multiply by a power of two.
|
||||
let pair = g.upload_image(&effectcraft_raster::flow::luma_pair(a, b))?;
|
||||
let mut luma: Vec<GpuImage> = Vec::with_capacity(dims.len());
|
||||
luma.push(pair);
|
||||
for k in 1..dims.len() {
|
||||
let (pw, ph) = dims[k - 1];
|
||||
let (cw, ch) = dims[k];
|
||||
let dst = e.scratch(cw, ch);
|
||||
e.pixels("imp_fl_half", ¶ms([pw, ph, cw, ch], [0; 4], [0; 4], [0.0; 4]), luma.get(k - 1)?, None, &dst, None);
|
||||
luma.push(dst);
|
||||
}
|
||||
// The coarse-to-fine search: the coarsest level from the zero vector, each finer level from
|
||||
// the doubled vectors of its tile and its four neighbours.
|
||||
let top = dims.len() - 1;
|
||||
let groups = (cols.div_ceil(16) as u32, rows.div_ceil(16) as u32);
|
||||
let mut field: Vec<GpuImage> = Vec::with_capacity(dims.len());
|
||||
for _ in 0..dims.len() {
|
||||
field.push(e.scratch(cols as u32, rows as u32));
|
||||
}
|
||||
for k in (0..dims.len()).rev() {
|
||||
let (pw, ph) = dims[k];
|
||||
let coarser = (k != top).then(|| field.get(k + 1)).flatten();
|
||||
let p = params([pw, ph, cols as u32, rows as u32], [tile, win, 1u32 << k, if k == top { radius } else { 1 }], [u32::from(k != top), 0, 0, 0], [0.0; 4]);
|
||||
e.dispatch("imp_fl_search", &p, KernelImages { src: luma.get(k)?, aux: coarser, out: field.get(k)? }, None, groups);
|
||||
}
|
||||
// The full-resolution sub-pixel fit, then the vector median.
|
||||
let fine = e.scratch(cols as u32, rows as u32);
|
||||
e.dispatch(
|
||||
"imp_fl_subpix",
|
||||
¶ms([w, h, cols as u32, rows as u32], [tile, win, 0, 0], [0; 4], [0.0; 4]),
|
||||
KernelImages { src: luma.first()?, aux: Some(field.first()?), out: &fine },
|
||||
None,
|
||||
groups,
|
||||
);
|
||||
let out = e.scratch(cols as u32, rows as u32);
|
||||
e.dispatch(
|
||||
"imp_fl_median",
|
||||
¶ms([w, h, cols as u32, rows as u32], [0; 4], [0; 4], [0.0; 4]),
|
||||
KernelImages { src: &fine, aux: None, out: &out },
|
||||
None,
|
||||
groups,
|
||||
);
|
||||
if e.take_missing() {
|
||||
return None;
|
||||
}
|
||||
// Only the field crosses back to the CPU: `cols × rows` entries.
|
||||
let rect = e.read_rect(&out.texture, 0, 0, cols as u32, rows as u32, 16)?;
|
||||
let mut v = Vec::with_capacity(cols * rows);
|
||||
for i in 0..cols * rows {
|
||||
let b = rect.get(i * 16..i * 16 + 16)?;
|
||||
let f = |o: usize| b.get(o).copied().unwrap_or(0);
|
||||
v.push([f32::from_le_bytes([f(0), f(1), f(2), f(3)]), f32::from_le_bytes([f(4), f(5), f(6), f(7)])]);
|
||||
}
|
||||
Some(Flow { width: w, height: h, block: tile, cols, rows, v })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ PatchMatch
|
||||
|
||||
/// One PatchMatch level's plan.
|
||||
struct Lv {
|
||||
w: u32,
|
||||
h: u32,
|
||||
r: u32,
|
||||
/// The target centres: pixels whose patch overlaps the hole (`near`).
|
||||
near: [u32; 4],
|
||||
/// The hole's own bounding box (the vote's dispatch).
|
||||
holes: [u32; 4],
|
||||
/// Where the nearest-neighbour field must be defined: the vote reads it `r` pixels out.
|
||||
field: [u32; 4],
|
||||
flags: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// The multi-scale PatchMatch synthesis of `patch_fill`: `init` is the starting guess (the
|
||||
/// previous frame's fill warped onto this one), which refines the finest level only.
|
||||
pub(crate) fn patch_image(g: &GpuContext, img: &Image, hole: &[bool], init: Option<&Image>, patch: usize, seed: u32) -> Option<Image> {
|
||||
let (w, h) = (img.width, img.height);
|
||||
if w == 0 || h == 0 || !g.fits(w, h) {
|
||||
return None;
|
||||
}
|
||||
let (uw, uh) = (w as usize, h as usize);
|
||||
let n = uw * uh;
|
||||
if hole.len() < n || !hole.iter().take(n).any(|b| *b) {
|
||||
return Some(img.clone());
|
||||
}
|
||||
let hole: Vec<bool> = hole.iter().take(n).copied().collect();
|
||||
let patch = patch.max(3) | 1;
|
||||
let mut e = Enc::new(g);
|
||||
|
||||
// A refinement of one frame (`patch_fill`'s `init`): the previous frame's fill seeds the
|
||||
// hole and only the finest level runs, from 2 EM rounds of 4 iterations.
|
||||
if let Some(init) = init {
|
||||
if init.width != w || init.height != h {
|
||||
return None;
|
||||
}
|
||||
let mut start = img.clone();
|
||||
for (i, v) in start.data.iter_mut().enumerate() {
|
||||
if hole.get(i).copied().unwrap_or(false) {
|
||||
*v = *init.data.get(i)?;
|
||||
}
|
||||
}
|
||||
let lv = plan_level(&mut e, &hole, w, h, patch)?;
|
||||
let image = g.upload_image(&start)?;
|
||||
let spare = e.scratch(w, h);
|
||||
let nnf = e.image(w, h);
|
||||
let (out, _) = pm_level(&mut e, &lv, PmRun { seed, em: 2, iters: 4 }, &image, &spare, &nnf)?;
|
||||
if e.take_missing() {
|
||||
return None;
|
||||
}
|
||||
let bb = bbox(&hole, uw, uh)?;
|
||||
let rect = e.read_rect(&out.texture, bb[0], bb[1], bb[2] - bb[0], bb[3] - bb[1], 16)?;
|
||||
return Some(patch_back(img, &hole, bb, &rect));
|
||||
}
|
||||
|
||||
// The pyramid: halve while the level is still a few patches across.
|
||||
let mut dims = vec![(w, h)];
|
||||
let mut holes = vec![hole.clone()];
|
||||
while dims.len() < 6 {
|
||||
let &(lw, lh) = dims.last()?;
|
||||
let hl = holes.last()?;
|
||||
if lw.min(lh) / 2 < patch as u32 * 4 || !hl.iter().any(|b| *b) {
|
||||
break;
|
||||
}
|
||||
let (cw, ch) = (lw.div_ceil(2) as usize, lh.div_ceil(2) as usize);
|
||||
let mut nh = vec![false; cw * ch];
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
for (dx, dy) in [(0usize, 0usize), (1, 0), (0, 1), (1, 1)] {
|
||||
let (fx, fy) = ((2 * x + dx).min(lw as usize - 1), (2 * y + dy).min(lh as usize - 1));
|
||||
if hl[fy * lw as usize + fx] {
|
||||
nh[y * cw + x] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !nh.iter().any(|b| !*b) {
|
||||
break;
|
||||
}
|
||||
dims.push((cw as u32, ch as u32));
|
||||
holes.push(nh);
|
||||
}
|
||||
let levels = dims.len();
|
||||
let top = levels - 1;
|
||||
|
||||
let mut plan = Vec::with_capacity(levels);
|
||||
for k in 0..levels {
|
||||
plan.push(plan_level(&mut e, holes.get(k)?, dims[k].0, dims[k].1, patch)?);
|
||||
}
|
||||
|
||||
// One image per level (the coarse-to-fine steps and the vote ping-pong in fresh textures).
|
||||
let mut ima: Vec<GpuImage> = Vec::with_capacity(levels);
|
||||
ima.push(g.upload_image(img)?);
|
||||
for k in 1..levels {
|
||||
let (cw, ch) = dims[k];
|
||||
ima.push(e.scratch(cw, ch));
|
||||
}
|
||||
for k in 1..levels {
|
||||
let (pw, ph) = dims[k - 1];
|
||||
let (cw, ch) = dims[k];
|
||||
e.pixels("imp_pm_down", ¶ms([pw, ph, cw, ch], [0; 4], [0; 4], [0.0; 4]), &ima[k - 1], None, &ima[k], Some(&plan[k - 1].flags));
|
||||
}
|
||||
// The coarsest level starts as its membrane fill.
|
||||
let (cw, ch) = dims[top];
|
||||
let spare = e.scratch(cw, ch);
|
||||
ima[top] = membrane_tex(&mut e, &ima[top], &spare, &holes[top], cw, ch)?;
|
||||
|
||||
// Coarse to fine: upsample, then the EM rounds of propagation and voting.
|
||||
let mut nnf: Option<GpuImage> = None;
|
||||
for k in (0..levels).rev() {
|
||||
let lv = &plan[k];
|
||||
nnf = Some(match nnf {
|
||||
Some(coarse) => {
|
||||
let (cw, ch) = dims[k + 1];
|
||||
let fine = e.scratch(lv.w, lv.h);
|
||||
e.pixels("imp_nnf_up", ¶ms([lv.w, lv.h, cw, ch], [0; 4], [0; 4], [0.0; 4]), &coarse, None, &fine, None);
|
||||
fine
|
||||
}
|
||||
None => e.image(lv.w, lv.h),
|
||||
});
|
||||
if k < top {
|
||||
let (cw, ch) = dims[k + 1];
|
||||
let dst = e.scratch(lv.w, lv.h);
|
||||
e.copy_into(&ima[k], &dst, 0, 0);
|
||||
e.pixels("imp_pm_up", ¶ms([lv.w, lv.h, cw, ch], [0; 4], [0; 4], [0.0; 4]), &ima[k + 1], None, &dst, Some(&lv.flags));
|
||||
ima[k] = dst;
|
||||
}
|
||||
let (em, iters) = if k == top { (8, 5) } else { (4, 5) };
|
||||
let spare = e.scratch(lv.w, lv.h);
|
||||
let run = PmRun { seed: seed.wrapping_add(k as u32 * 101), em, iters };
|
||||
let (out, out_nnf) = pm_level(&mut e, lv, run, &ima[k], &spare, nnf.as_ref()?)?;
|
||||
ima[k] = out;
|
||||
nnf = Some(out_nnf);
|
||||
}
|
||||
if e.take_missing() {
|
||||
return None;
|
||||
}
|
||||
// Only the hole changes, so only the hole's bounds cross back to the CPU.
|
||||
let bb = bbox(&hole, uw, uh)?;
|
||||
let rect = e.read_rect(&ima[0].texture, bb[0], bb[1], bb[2] - bb[0], bb[3] - bb[1], 16)?;
|
||||
Some(patch_back(img, &hole, bb, &rect))
|
||||
}
|
||||
|
||||
/// The plan of one level: its flags (`bit 0` hole, `bit 1` a target centre) and the dispatch
|
||||
/// bounds of its targets, holes and field.
|
||||
fn plan_level(e: &mut Enc, hole: &[bool], w: u32, h: u32, patch: usize) -> Option<Lv> {
|
||||
// Texture and buffer limits are independent: a level that fits as a texture can still need a
|
||||
// flags buffer over the device's storage binding limit, and that validation error would
|
||||
// retire the context. Decline the GPU path instead (the fill runs on the CPU).
|
||||
if !flags_fit(&e.g.device.limits(), w, h) {
|
||||
return None;
|
||||
}
|
||||
let (iw, ih) = (w as usize, h as usize);
|
||||
let r = radius(patch, w, h);
|
||||
let near = dilate(hole, iw, ih, r as usize);
|
||||
let near_bb = bbox(&near, iw, ih)?;
|
||||
let flags: Vec<f32> = (0..iw * ih).map(|i| (u32::from(hole[i]) + 2 * u32::from(near[i])) as f32).collect();
|
||||
Some(Lv { w, h, r, near: near_bb, holes: bbox(hole, iw, ih)?, field: grow(near_bb, r + 1, w, h), flags: e.data(&flags) })
|
||||
}
|
||||
|
||||
/// Whether a level's `w × h` f32 flags buffer (at least the 16 bytes [`Enc::data`] allocates)
|
||||
/// can be created and bound as one storage buffer on a device with `limits`.
|
||||
fn flags_fit(limits: &wgpu::Limits, w: u32, h: u32) -> bool {
|
||||
u64::from(w)
|
||||
.checked_mul(u64::from(h))
|
||||
.and_then(|n| n.checked_mul(4))
|
||||
.map(|b| b.max(16))
|
||||
.is_some_and(|b| b <= limits.max_buffer_size && b <= limits.max_storage_buffer_binding_size)
|
||||
}
|
||||
|
||||
/// The PatchMatch run at one level: the random stream's seed and the EM round and iteration
|
||||
/// counts.
|
||||
struct PmRun {
|
||||
seed: u32,
|
||||
em: usize,
|
||||
iters: usize,
|
||||
}
|
||||
|
||||
/// `pm_level`: `run.em` EM rounds of propagation and voting at one level. `img` holds the level's
|
||||
/// image and `img_spare` is a free texture of the same size; the nearest-neighbour field starts
|
||||
/// at `nnf`. Returns the level's filled image and its field.
|
||||
fn pm_level(e: &mut Enc, lv: &Lv, run: PmRun, img: &GpuImage, img_spare: &GpuImage, nnf: &GpuImage) -> Option<(GpuImage, GpuImage)> {
|
||||
let (seed, em, iters) = (run.seed, run.em, run.iters);
|
||||
let (r, w, h) = (lv.r, lv.w, lv.h);
|
||||
let field_a = e.scratch(w, h);
|
||||
let field_b = e.scratch(w, h);
|
||||
let hist = e.scratch(BINS as u32, 1);
|
||||
let (img_a, img_b) = (img.clone(), img_spare.clone());
|
||||
let (mut field_nnf, mut nnf_spare) = (nnf.clone(), e.scratch(w, h));
|
||||
// The propagation and random search write the targets and the one-pixel ring around them, so
|
||||
// every neighbour they read has a defined alpha (`grow(near, 1)`). The dispatch origin must
|
||||
// be the ring's, not the targets': `over` sizes the dispatch from the box it is given.
|
||||
let ring = grow(lv.near, 1, w, h);
|
||||
let mut in_a = true;
|
||||
for round in 0..em {
|
||||
// The round's initial field: the inherited source where it is still valid, else random.
|
||||
// Every pixel of the field's bounds is written, so the previous round's field is free.
|
||||
let img = if in_a { &img_a } else { &img_b };
|
||||
let p = params([w, h, r, 0], [0; 4], [seed, 0, 0, round as u32], [0.0; 4]);
|
||||
over(e, "imp_pm_init", &p, Pass { src: &field_nnf, aux: Some(img), out: &field_a, data: Some(&lv.flags), bb: lv.field });
|
||||
let mut f_in_a = true;
|
||||
for it in 0..iters {
|
||||
for parity in 0..2u32 {
|
||||
let (s, o) = if f_in_a { (&field_a, &field_b) } else { (&field_b, &field_a) };
|
||||
let p = params([w, h, r, parity], [0; 4], [seed, it as u32, 0, round as u32], [0.0; 4]);
|
||||
over(e, "imp_pm_prop", &p, Pass { src: s, aux: Some(img), out: o, data: Some(&lv.flags), bb: ring });
|
||||
f_in_a = !f_in_a;
|
||||
}
|
||||
let (s, o) = if f_in_a { (&field_a, &field_b) } else { (&field_b, &field_a) };
|
||||
let p = params([w, h, r, 0], [0; 4], [seed, it as u32, 0, round as u32], [0.0; 4]);
|
||||
over(e, "imp_pm_rand", &p, Pass { src: s, aux: Some(img), out: o, data: Some(&lv.flags), bb: ring });
|
||||
f_in_a = !f_in_a;
|
||||
}
|
||||
let field = if f_in_a { &field_a } else { &field_b };
|
||||
// The vote's sigma²: the 75th percentile of the targets' match distances (`pm_level`).
|
||||
let sigma2 = sigma2(e, field, lv, &hist)?;
|
||||
let next_img = if in_a { &img_b } else { &img_a };
|
||||
e.copy_into(img, next_img, 0, 0);
|
||||
let p = params([w, h, r, 0], [0; 4], [0; 4], [sigma2, 0.0, 0.0, 0.0]);
|
||||
over(e, "imp_pm_vote", &p, Pass { src: field, aux: Some(img), out: next_img, data: Some(&lv.flags), bb: lv.holes });
|
||||
// Carry the round's sources into the nearest-neighbour field (targets only).
|
||||
e.pixels("imp_nnf_store", ¶ms([w, h, 0, 0], [0; 4], [0; 4], [0.0; 4]), field, Some(&field_nnf), &nnf_spare, Some(&lv.flags));
|
||||
std::mem::swap(&mut field_nnf, &mut nnf_spare);
|
||||
in_a = !in_a;
|
||||
}
|
||||
Some((if in_a { img_a } else { img_b }, field_nnf))
|
||||
}
|
||||
|
||||
/// The 75th percentile of a 1024-bin distance histogram (`imp_hist`'s readback) covering
|
||||
/// `0..range`, and the histogram's total. The bin's centre is the estimate: within half a bin
|
||||
/// (`range/2048`) of the CPU's exact percentile.
|
||||
fn percentile(bytes: &[u8], range: f32) -> (f32, f64) {
|
||||
let bin = |i: usize| -> f64 {
|
||||
match bytes.get(i * 16..i * 16 + 4) {
|
||||
Some(b) => f32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64,
|
||||
None => 0.0,
|
||||
}
|
||||
};
|
||||
let total: f64 = (0..BINS).map(bin).sum();
|
||||
if total < 1.0 {
|
||||
return (1.0, total);
|
||||
}
|
||||
let rank = total * 3.0 / 4.0;
|
||||
let mut cum = 0.0f64;
|
||||
let mut at = BINS - 1;
|
||||
for i in 0..BINS {
|
||||
cum += bin(i);
|
||||
if cum > rank {
|
||||
at = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let centre = ((at as f32) + 0.5) * range / BINS as f32;
|
||||
(centre.max(1e-5), total)
|
||||
}
|
||||
|
||||
/// The largest finite distance `imp_hist` saw, from bin 0's `.g` channel.
|
||||
fn max_distance(bytes: &[u8]) -> f32 {
|
||||
match bytes.get(4..8) {
|
||||
Some(b) => f32::from_le_bytes([b[0], b[1], b[2], b[3]]),
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `imp_hist` over `bb`, the targets' bounds: the 75th percentile of the finite distances and the
|
||||
/// sample count. SDR input is binned over the fixed [`SDR_DISTANCE`] range; a frame with values
|
||||
/// above 1 (32-bpc HDR) has larger distances, so the second dispatch bins over the observed
|
||||
/// maximum instead. Clamping those to the SDR range would report a sigma² of ≈ 4 for a distance
|
||||
/// of 1600, and `imp_pm_vote`'s weights would round to zero and keep the prior image.
|
||||
///
|
||||
/// One 16 KB readback per EM round (≈ 28 per 1080p patch) on the SDR path: a few percent of the
|
||||
/// stage, and the obvious next thing to remove if the patch has to be faster (a device-side
|
||||
/// percentile would need a reduction instead). HDR frames pay one more dispatch and readback.
|
||||
fn histogram(e: &mut Enc, field: &GpuImage, flags: &wgpu::Buffer, w: u32, h: u32, bb: [u32; 4], hist: &GpuImage) -> Option<(f32, f64)> {
|
||||
let (bw, bh) = (bb[2] - bb[0], bb[3] - bb[1]);
|
||||
let mut p = params([w, h, 0, 0], [bb[0], bb[1], bw, bh], [0; 4], [0.0; 4]);
|
||||
e.dispatch("imp_hist", &p, KernelImages { src: field, aux: None, out: hist }, Some(flags), (1, 1));
|
||||
let bytes = e.read_texture(&hist.texture, BINS as u32, 1, 16)?;
|
||||
let max = max_distance(&bytes);
|
||||
if max <= SDR_DISTANCE {
|
||||
return Some(percentile(&bytes, SDR_DISTANCE));
|
||||
}
|
||||
// The observed maximum itself has to be in the last bin, so widen the range by a hair rather
|
||||
// than binning it to 1024.
|
||||
let range = max * (1.0 + 1e-6);
|
||||
p.f[0] = [range, 0.0, 0.0, 0.0];
|
||||
e.dispatch("imp_hist", &p, KernelImages { src: field, aux: None, out: hist }, Some(flags), (1, 1));
|
||||
let bytes = e.read_texture(&hist.texture, BINS as u32, 1, 16)?;
|
||||
Some(percentile(&bytes, range))
|
||||
}
|
||||
|
||||
/// The vote's sigma² at a level (`pm_level`): the 75th percentile of the targets' distances.
|
||||
fn sigma2(e: &mut Enc, field: &GpuImage, lv: &Lv, hist: &GpuImage) -> Option<f32> {
|
||||
Some(histogram(e, field, &lv.flags, lv.w, lv.h, lv.near, hist)?.0)
|
||||
}
|
||||
|
||||
/// [`imp_hist`] on its own: `field`'s match distance (`.b`, `.a` > 0.5 where set) over the
|
||||
/// pixels `near` marks, returning the 75th percentile and the sample count. Used by the test that
|
||||
/// checks the histogram counts the whole target population.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn distance_histogram(g: &GpuContext, field: &Image, near: &[bool], w: u32, h: u32) -> Option<(f32, f64)> {
|
||||
let flags: Vec<f32> = near.iter().map(|b| if *b { 2.0 } else { 0.0 }).collect();
|
||||
let mut e = Enc::new(g);
|
||||
let data = e.data(&flags);
|
||||
let img = g.upload_image(field)?;
|
||||
let hist = e.scratch(BINS as u32, 1);
|
||||
let out = histogram(&mut e, &img, &data, w, h, [0, 0, w, h], &hist);
|
||||
if e.take_missing() {
|
||||
return None;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod limit_tests {
|
||||
use super::flags_fit;
|
||||
|
||||
/// A level whose flags buffer is over the storage binding limit is declined before it is
|
||||
/// created (the default 128 MiB binding holds 33,554,432 flags: 8192 × 4096).
|
||||
#[test]
|
||||
fn flags_buffer_is_checked_against_the_device_limits() {
|
||||
let l = wgpu::Limits::default();
|
||||
assert!(flags_fit(&l, 1, 1));
|
||||
assert!(flags_fit(&l, 8192, 4096));
|
||||
assert!(!flags_fit(&l, 8192, 4097));
|
||||
assert!(!flags_fit(&l, 16384, 16384));
|
||||
let small = wgpu::Limits { max_buffer_size: 1024, ..wgpu::Limits::default() };
|
||||
assert!(flags_fit(&small, 16, 16));
|
||||
assert!(!flags_fit(&small, 16, 17));
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -79,6 +79,8 @@ mod fx_tone;
|
||||
mod fx_transition;
|
||||
mod fx_vr;
|
||||
mod fx_warp;
|
||||
mod inpaint;
|
||||
mod memcap;
|
||||
mod ops;
|
||||
mod particles;
|
||||
mod readback;
|
||||
@@ -397,7 +399,7 @@ impl Accelerator for Gpu {
|
||||
|
||||
fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf> {
|
||||
self.ctx.check_health().ok()?;
|
||||
effects::run_chain(&mut Enc::new(&self.ctx), chain, buf, levels)
|
||||
effects::run_chain(&mut Enc::lent(&self.ctx), chain, buf, levels)
|
||||
}
|
||||
|
||||
fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option<effectcraft_render::three_d::adv::Target> {
|
||||
@@ -415,16 +417,41 @@ impl Accelerator for Gpu {
|
||||
self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim)
|
||||
}
|
||||
|
||||
fn inpaint(&self) -> Option<&dyn effectcraft_raster::inpaint::FillBackend> {
|
||||
self.ctx.check_health().ok()?;
|
||||
// Readback is synchronous-only, and a kernel that failed to build would make every stage
|
||||
// fall back to the CPU *after* paying the upload and a failed dispatch (and the fill
|
||||
// would still report the GPU path). Neither is a backend at all.
|
||||
if !self.ctx.can_wait() || !crate::inpaint::KERNELS.iter().all(|e| self.ctx.has_kernel(e)) {
|
||||
return None;
|
||||
}
|
||||
Some(self as &dyn effectcraft_raster::inpaint::FillBackend)
|
||||
}
|
||||
|
||||
fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> {
|
||||
Some(&self.auto)
|
||||
}
|
||||
|
||||
fn render_end(&self) {
|
||||
let (hits, misses) = self.ctx.pool_stats();
|
||||
let pct = hits.saturating_mul(100) / hits.saturating_add(misses).max(1);
|
||||
log::debug!(
|
||||
target: "effectcraft_gpu",
|
||||
"gpu pool: {hits} working textures reused, {misses} allocated ({pct}% reuse) so far"
|
||||
);
|
||||
self.ctx.trim_pool();
|
||||
}
|
||||
|
||||
fn frame_begin(&self) {
|
||||
if let Some(d) = self.ctx.deferred() {
|
||||
d.frame_begin();
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_end(&self) {
|
||||
self.ctx.pool_frame_end();
|
||||
}
|
||||
|
||||
fn pass_begin(&self) {
|
||||
if let Some(d) = self.ctx.deferred() {
|
||||
d.pass_begin();
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Memory back-pressure for frames in flight.
|
||||
//!
|
||||
//! wgpu destroys a texture or buffer we drop only once the submissions that used it have
|
||||
//! completed, and pooled textures released while other frames' encoders are open stay pending
|
||||
//! until then. With several 4K frames in flight, the frames record work faster than the GPU
|
||||
//! retires it, and freed-but-not-destroyed memory runs the device out of memory (#879). Once the
|
||||
//! allocator holds more than the cap and enough of that total is reclaimable (not held by the
|
||||
//! upload cache, the texture pool, or live images), a frame submits its encoder and waits for its
|
||||
//! own submission so wgpu frees what was dropped. Frames are not held back for memory that cannot
|
||||
//! be reclaimed; a warning is logged once.
|
||||
//!
|
||||
//! wgpu has no safe query for the adapter's memory size (`max_buffer_size` is capped at `i32::MAX`
|
||||
//! on Direct3D 12 and Linux/Mesa Vulkan), so the default cap is conservative per device type,
|
||||
//! and lower on a discrete card whose memory the OS reports without `unsafe` (Linux's amdgpu
|
||||
//! driver, in sysfs: a 2–4 GB card ran out of memory and took the system down, #1487);
|
||||
//! `EFFECTCRAFT_GPU_MEMCAP_MB` overrides it (0 = off). Backends without an allocator report
|
||||
//! (Metal, WebGPU, GL) are never gated.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering::Relaxed};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Check the allocator only every this many gate calls (effect calls and layers): the report
|
||||
/// lists every allocation, and between checks a frame adds at most a few textures.
|
||||
const EVERY: u64 = 8;
|
||||
|
||||
const GIB: u64 = 1 << 30;
|
||||
const MIB: u64 = 1 << 20;
|
||||
|
||||
/// Overrides the default cap: whole MiB, 0 turns the gate off.
|
||||
const MEMCAP_ENV: &str = "EFFECTCRAFT_GPU_MEMCAP_MB";
|
||||
|
||||
pub(crate) struct MemCap {
|
||||
cap: u64,
|
||||
hysteresis: u64,
|
||||
calls: AtomicU64,
|
||||
waits: AtomicU64,
|
||||
/// Reclaimable bytes remaining after the last wait: another wait fires only once reclaimable
|
||||
/// memory grows by `hysteresis` above this floor (or drops below it first).
|
||||
floor: AtomicU64,
|
||||
/// Set after the first over-cap sample where non-reclaimable memory alone exceeds the cap.
|
||||
warned: AtomicBool,
|
||||
#[cfg(test)]
|
||||
timeout_ns: AtomicU64,
|
||||
}
|
||||
|
||||
impl MemCap {
|
||||
pub(crate) fn new(info: &wgpu::AdapterInfo) -> MemCap {
|
||||
let env = std::env::var(MEMCAP_ENV).ok();
|
||||
Self::from_env(env.as_deref(), info.device_type, vram(info))
|
||||
}
|
||||
|
||||
fn from_env(var: Option<&str>, device_type: wgpu::DeviceType, vram: Option<u64>) -> MemCap {
|
||||
let cap = cap_from_env(var, device_type, vram);
|
||||
MemCap {
|
||||
cap,
|
||||
hysteresis: default_hysteresis(cap),
|
||||
calls: AtomicU64::new(0),
|
||||
waits: AtomicU64::new(0),
|
||||
floor: AtomicU64::new(0),
|
||||
warned: AtomicBool::new(false),
|
||||
#[cfg(test)]
|
||||
timeout_ns: AtomicU64::new(crate::readback::TIMEOUT.as_nanos() as u64),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same cap with fresh counters (a test context sharing another's device).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fresh(&self) -> MemCap {
|
||||
MemCap {
|
||||
cap: self.cap,
|
||||
hysteresis: self.hysteresis,
|
||||
calls: AtomicU64::new(0),
|
||||
waits: AtomicU64::new(0),
|
||||
floor: AtomicU64::new(0),
|
||||
warned: AtomicBool::new(false),
|
||||
timeout_ns: AtomicU64::new(self.timeout_ns.load(Relaxed)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cap(&self) -> u64 {
|
||||
self.cap
|
||||
}
|
||||
|
||||
/// Whether to sample the allocator on this call.
|
||||
pub(crate) fn due(&self) -> bool {
|
||||
self.cap != 0 && self.calls.fetch_add(1, Relaxed).is_multiple_of(EVERY)
|
||||
}
|
||||
|
||||
/// Whether `allocated` bytes in the allocator with `kept` non-reclaimable bytes (upload
|
||||
/// cache, pool free list, live frames) should trigger a submit and wait.
|
||||
pub(crate) fn should_wait(&self, allocated: u64, kept: u64) -> bool {
|
||||
if self.cap == 0 || allocated <= self.cap {
|
||||
self.floor.store(0, Relaxed);
|
||||
return false;
|
||||
}
|
||||
if kept > self.cap && !self.warned.swap(true, Relaxed) {
|
||||
log::warn!(
|
||||
target: "effectcraft_gpu",
|
||||
"gpu: {} MiB held by live frames, the upload cache and the texture pool is over the {} MiB memory cap: waiting cannot free it, so frames are not held back for it ({MEMCAP_ENV} sets the cap)",
|
||||
kept >> 20,
|
||||
self.cap >> 20,
|
||||
);
|
||||
}
|
||||
let reclaimable = allocated.saturating_sub(kept);
|
||||
let floor = self.floor.load(Relaxed);
|
||||
if reclaimable < floor {
|
||||
self.floor.store(reclaimable, Relaxed);
|
||||
return false;
|
||||
}
|
||||
reclaimable >= floor.saturating_add(self.hysteresis)
|
||||
}
|
||||
|
||||
pub(crate) fn record_wait(&self, allocated_after: u64, kept_after: u64) {
|
||||
self.floor.store(allocated_after.saturating_sub(kept_after), Relaxed);
|
||||
let n = self.waits.fetch_add(1, Relaxed);
|
||||
if n == 0 {
|
||||
log::info!("gpu: allocator over the {} MiB memory cap, frames wait for the GPU to catch up", self.cap >> 20);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn wait_timeout(&self) -> Duration {
|
||||
#[cfg(test)]
|
||||
{
|
||||
Duration::from_nanos(self.timeout_ns.load(Relaxed))
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
crate::readback::TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn waits(&self) -> u64 {
|
||||
self.waits.load(Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn warned(&self) -> bool {
|
||||
self.warned.load(Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_wait_timeout(&self, timeout: Duration) {
|
||||
self.timeout_ns.store(timeout.as_nanos() as u64, Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cap from the `EFFECTCRAFT_GPU_MEMCAP_MB` value: whole MiB (0 turns the gate off), or the
|
||||
/// default for `device_type` when unset, or (with a warning) when not a number or too large.
|
||||
fn cap_from_env(var: Option<&str>, device_type: wgpu::DeviceType, vram: Option<u64>) -> u64 {
|
||||
let Some(v) = var else { return default_cap(device_type, vram) };
|
||||
match v.trim().parse::<u64>().ok().and_then(|mb| mb.checked_mul(MIB)) {
|
||||
Some(cap) => cap,
|
||||
None => {
|
||||
let cap = default_cap(device_type, vram);
|
||||
log::warn!(target: "effectcraft_gpu", "{MEMCAP_ENV}={v:?} is not a whole number of MiB that fits in 64 bits: using {} MiB", cap / MIB);
|
||||
cap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discrete cards: 3 GiB, low enough to fire on 4–8 GB cards with headroom for the driver and
|
||||
/// desktop while leaving ~1 GiB above the default 1.5 GiB upload cache + 256 MB pool (larger
|
||||
/// cards can raise it with `EFFECTCRAFT_GPU_MEMCAP_MB`), and at most three quarters of the card's
|
||||
/// memory when it is known (`vram`), so a 2 GB card gets 1.5 GiB (#1487). Integrated and software
|
||||
/// adapters share system memory: 2 GiB, whatever their carve-out.
|
||||
fn default_cap(t: wgpu::DeviceType, vram: Option<u64>) -> u64 {
|
||||
match t {
|
||||
wgpu::DeviceType::DiscreteGpu => vram.map_or(3 * GIB, |v| (v / 4).saturating_mul(3).min(3 * GIB)),
|
||||
_ => 2 * GIB,
|
||||
}
|
||||
}
|
||||
|
||||
/// The adapter's dedicated memory in bytes, where the OS reports it without `unsafe`.
|
||||
fn vram(info: &wgpu::AdapterInfo) -> Option<u64> {
|
||||
if !cfg!(target_os = "linux") || info.device_type != wgpu::DeviceType::DiscreteGpu {
|
||||
return None;
|
||||
}
|
||||
let bytes = vram_in_sysfs(Path::new("/sys/class/drm"), info.vendor, info.device, &info.device_pci_bus_id);
|
||||
if let Some(b) = bytes {
|
||||
log::info!(target: "effectcraft_gpu", "gpu {}: {} MiB of video memory (sysfs)", info.name, b >> 20);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// `mem_info_vram_total` (amdgpu) of the DRM card under `drm` whose PCI vendor and device ids are
|
||||
/// `vendor` and `device`. Connector entries (`card1-DP-1`) are skipped. Variants of one model share
|
||||
/// those ids (the RX 550 ships with 2 and 4 GB), so the card at PCI address `bus` (`uevent`'s
|
||||
/// `PCI_SLOT_NAME`, wgpu's `device_pci_bus_id`) wins; without one, the smallest matching card.
|
||||
fn vram_in_sysfs(drm: &Path, vendor: u32, device: u32, bus: &str) -> Option<u64> {
|
||||
let id = |path: &Path| std::fs::read_to_string(path).ok().and_then(|s| u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok());
|
||||
let slot = |dev: &Path| {
|
||||
std::fs::read_to_string(dev.join("uevent")).ok()?.lines().find_map(|l| l.strip_prefix("PCI_SLOT_NAME=").map(|s| s.trim().to_ascii_lowercase()))
|
||||
};
|
||||
let mut cards: Vec<_> = std::fs::read_dir(drm)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("card") && !n.contains('-')))
|
||||
.collect();
|
||||
cards.sort();
|
||||
let found: Vec<(Option<String>, u64)> = cards
|
||||
.iter()
|
||||
.filter_map(|card| {
|
||||
let dev = card.join("device");
|
||||
if id(&dev.join("vendor")) != Some(vendor) || id(&dev.join("device")) != Some(device) {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read_to_string(dev.join("mem_info_vram_total")).ok()?.trim().parse::<u64>().ok().filter(|&b| b > 0)?;
|
||||
Some((slot(&dev), bytes))
|
||||
})
|
||||
.collect();
|
||||
let bus = bus.trim().to_ascii_lowercase();
|
||||
found.iter().find(|(s, _)| !bus.is_empty() && s.as_deref() == Some(bus.as_str())).map(|&(_, b)| b).or_else(|| found.iter().map(|&(_, b)| b).min())
|
||||
}
|
||||
|
||||
/// Minimum reclaimable bytes before a wait is worth draining an encoder (1/8 of the cap,
|
||||
/// clamped between 1 MiB and 256 MiB, or 1/4 of caps smaller than 4 MiB).
|
||||
fn default_hysteresis(cap: u64) -> u64 {
|
||||
if cap == 0 { 0 } else { (cap / 8).clamp(MIB.min(cap / 4), 256 * MIB) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_and_cadence() {
|
||||
assert_eq!(default_cap(wgpu::DeviceType::DiscreteGpu, None), 3 * GIB);
|
||||
assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu, None), 2 * GIB);
|
||||
let m = MemCap::from_env(None, wgpu::DeviceType::IntegratedGpu, None);
|
||||
assert_eq!(m.cap(), 2 * GIB);
|
||||
let due = (0..2 * EVERY).filter(|_| m.due()).count();
|
||||
assert_eq!(due, 2);
|
||||
let off = MemCap::from_env(Some("0"), wgpu::DeviceType::DiscreteGpu, None);
|
||||
assert_eq!(off.cap(), 0);
|
||||
assert!((0..EVERY).all(|_| !off.due()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_is_whole_mib_or_the_default() {
|
||||
let cap = |v| MemCap::from_env(Some(v), wgpu::DeviceType::DiscreteGpu, None).cap();
|
||||
assert_eq!(cap("2048"), 2 * GIB);
|
||||
assert_eq!(cap(" 8 "), 8 * MIB);
|
||||
assert_eq!(cap("17592186044415"), 17592186044415 * MIB, "the largest value that fits");
|
||||
for bad in ["", "lots", "-1", "1.5", "17592186044416", "99999999999999999999"] {
|
||||
assert_eq!(cap(bad), 3 * GIB, "{bad:?} falls back to the default");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hysteresis_skips_non_reclaimable_and_unfreed_memory() {
|
||||
let m = MemCap::from_env(Some("2048"), wgpu::DeviceType::IntegratedGpu, None);
|
||||
// Upload cache + pool + live frames (2.15 GiB) sit over the 2 GiB cap with only 50 MiB
|
||||
// reclaimable: waiting would free almost nothing, so the gate must stay quiet.
|
||||
let kept = 2150 * MIB;
|
||||
assert!(!m.should_wait(kept + 50 * MIB, kept));
|
||||
assert!(!m.should_wait(kept + 200 * MIB, kept));
|
||||
// Once 256 MiB of dropped textures pile up, the gate fires.
|
||||
assert!(m.should_wait(kept + 256 * MIB, kept));
|
||||
// If a wait freed nothing (e.g. held by another thread's open encoder or timed out),
|
||||
// the floor prevents firing again until another 256 MiB of reclaimable memory accumulates.
|
||||
m.record_wait(kept + 256 * MIB, kept);
|
||||
assert!(!m.should_wait(kept + 256 * MIB, kept));
|
||||
assert!(!m.should_wait(kept + 400 * MIB, kept));
|
||||
assert!(m.should_wait(kept + 512 * MIB, kept));
|
||||
// Once the other thread finishes and reclaimable memory drops, the floor drops with it.
|
||||
assert!(!m.should_wait(kept + 16 * MIB, kept));
|
||||
assert!(m.should_wait(kept + (16 + 256) * MIB, kept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_memory_over_the_cap_warns_once_and_never_waits() {
|
||||
let m = MemCap::from_env(Some("8"), wgpu::DeviceType::IntegratedGpu, None);
|
||||
// Cap 8 MiB: kept alone over the cap with reclaimable under the hysteresis → no wait, warn once.
|
||||
assert!(!m.should_wait(20 * MIB, 20 * MIB));
|
||||
assert!(m.warned());
|
||||
assert!(!m.should_wait(20 * MIB, 20 * MIB));
|
||||
assert!(!m.should_wait(20 * MIB, 19 * MIB + MIB / 2));
|
||||
assert!(m.should_wait(20 * MIB, 4 * MIB), "16 MiB reclaimable is over the hysteresis");
|
||||
}
|
||||
|
||||
/// #1487: a 2–4 GB card gets three quarters of its memory instead of the 3 GiB default; a
|
||||
/// larger card keeps the default, and an integrated GPU's carve-out doesn't lower its cap.
|
||||
#[test]
|
||||
fn known_video_memory_lowers_the_discrete_cap() {
|
||||
let d = wgpu::DeviceType::DiscreteGpu;
|
||||
assert_eq!(default_cap(d, Some(2 * GIB)), 3 * GIB / 2);
|
||||
assert_eq!(default_cap(d, Some(4 * GIB)), 3 * GIB);
|
||||
assert_eq!(default_cap(d, Some(16 * GIB)), 3 * GIB, "never above the default");
|
||||
assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu, Some(GIB / 2)), 2 * GIB);
|
||||
assert_eq!(cap_from_env(None, d, Some(2 * GIB)), 3 * GIB / 2);
|
||||
assert_eq!(cap_from_env(Some("lots"), d, Some(2 * GIB)), 3 * GIB / 2, "a bad override falls back to it");
|
||||
assert_eq!(cap_from_env(Some("4096"), d, Some(2 * GIB)), 4 * GIB, "the override still wins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_memory_is_read_from_the_matching_drm_card() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-memcap-{}", std::process::id()));
|
||||
let card = |name: &str, vendor: &str, device: &str, vram: Option<&str>| {
|
||||
let dev = dir.join(name).join("device");
|
||||
std::fs::create_dir_all(&dev).unwrap();
|
||||
std::fs::write(dev.join("vendor"), vendor).unwrap();
|
||||
std::fs::write(dev.join("device"), device).unwrap();
|
||||
if let Some(v) = vram {
|
||||
std::fs::write(dev.join("mem_info_vram_total"), v).unwrap();
|
||||
}
|
||||
};
|
||||
// An integrated Intel GPU (no amdgpu file), the RX 550 and a connector entry.
|
||||
card("card0", "0x8086\n", "0x9bc5\n", None);
|
||||
card("card1", "0x1002\n", "0x699f\n", Some("4294967296\n"));
|
||||
std::fs::create_dir_all(dir.join("card1-DP-1")).unwrap();
|
||||
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, ""), Some(4 * GIB));
|
||||
assert_eq!(vram_in_sysfs(&dir, 0x8086, 0x9bc5, ""), None, "no mem_info_vram_total");
|
||||
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x73bf, ""), None, "no such card");
|
||||
assert_eq!(vram_in_sysfs(&dir.join("missing"), 0x1002, 0x699f, ""), None);
|
||||
// A 2 GB RX 550 next to the 4 GB one: same ids, told apart by PCI address.
|
||||
card("card2", "0x1002\n", "0x699f\n", Some("2147483648\n"));
|
||||
std::fs::write(dir.join("card1/device/uevent"), "DRIVER=amdgpu\nPCI_SLOT_NAME=0000:01:00.0\n").unwrap();
|
||||
std::fs::write(dir.join("card2/device/uevent"), "DRIVER=amdgpu\nPCI_SLOT_NAME=0000:03:00.0\n").unwrap();
|
||||
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, "0000:01:00.0"), Some(4 * GIB));
|
||||
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, "0000:03:00.0"), Some(2 * GIB));
|
||||
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, ""), Some(2 * GIB), "unknown address: the smaller card");
|
||||
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, "0000:09:00.0"), Some(2 * GIB), "no such address: the smaller card");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,91 @@ fn acos_p(x: f32) -> f32 {
|
||||
return 1.5707963267948966 - asin_p(x);
|
||||
}
|
||||
|
||||
// Some hardware's native trig instructions saturate cos near a full turn and
|
||||
// have errors large enough to move particles and sphere samples visibly. Reduce
|
||||
// to [-pi/4, pi/4] with a split pi/2, then evaluate the Taylor polynomials with
|
||||
// explicit fused operations. The effect angles use this bounded range; retain
|
||||
// the builtins for non-finite or very large arguments rather than cast them.
|
||||
fn sincos_p(x: f32) -> vec2<f32> {
|
||||
if (!(abs(x) <= 8192.0)) {
|
||||
return vec2<f32>(sin(x), cos(x));
|
||||
}
|
||||
let k = round(x * 0.6366197723675814);
|
||||
// Keep the coarse reduction in integers. Some drivers reassociate even
|
||||
// explicit fma chains into x - k*pi/2, losing low bits for large angles.
|
||||
// 3216/2048 is exact; the bounded scaled input and product fit in i32.
|
||||
let scaled = x * 2048.0;
|
||||
let whole = floor(scaled);
|
||||
var r = f32(i32(whole) - i32(k) * 3216) / 2048.0;
|
||||
r += (scaled - whole) / 2048.0;
|
||||
r = fma(-k, 0.0004838267893064022, r);
|
||||
r = fma(-k, 5.564431554416771e-12, r);
|
||||
let z = r * r;
|
||||
var sp = fma(z, -1.0 / 39916800.0, 1.0 / 362880.0);
|
||||
sp = fma(z, sp, -1.0 / 5040.0);
|
||||
sp = fma(z, sp, 1.0 / 120.0);
|
||||
sp = fma(z, sp, -1.0 / 6.0);
|
||||
let sn = fma(r * z, sp, r);
|
||||
var cp = fma(z, -1.0 / 3628800.0, 1.0 / 40320.0);
|
||||
cp = fma(z, cp, -1.0 / 720.0);
|
||||
cp = fma(z, cp, 1.0 / 24.0);
|
||||
cp = fma(z, cp, -0.5);
|
||||
let cs = fma(z, cp, 1.0);
|
||||
switch imod(i32(k), 4) {
|
||||
case 1: { return vec2<f32>(cs, -sn); }
|
||||
case 2: { return vec2<f32>(-sn, -cs); }
|
||||
case 3: { return vec2<f32>(-cs, sn); }
|
||||
default: { return vec2<f32>(sn, cs); }
|
||||
}
|
||||
}
|
||||
|
||||
fn sin_p(x: f32) -> f32 { return sincos_p(x).x; }
|
||||
fn cos_p(x: f32) -> f32 { return sincos_p(x).y; }
|
||||
|
||||
// Correctly-rounded positive x/a, clamped to [0,1]. Native division and fma
|
||||
// need not have IEEE precision on every shader backend. Discontinuous colour
|
||||
// effects need the exact CPU f32 value, not just an approximation within an ulp.
|
||||
// Long division uses only bounded 24-bit significands (at most 24 iterations).
|
||||
fn unit_div(x: f32, a: f32) -> f32 {
|
||||
if (x <= 0.0 || a <= 0.0) { return 0.0; }
|
||||
if (x >= a) { return 1.0; }
|
||||
if (a == 1.0) { return x; }
|
||||
let xb = bitcast<u32>(x);
|
||||
let ab = bitcast<u32>(a);
|
||||
var nx = xb & 0x7fffffu;
|
||||
var na = ab & 0x7fffffu;
|
||||
var ex = i32((xb >> 23u) & 255u) - 127;
|
||||
var ea = i32((ab >> 23u) & 255u) - 127;
|
||||
if (ex == -127) {
|
||||
let shift = 23u - firstLeadingBit(nx);
|
||||
nx <<= shift;
|
||||
ex = -126 - i32(shift);
|
||||
} else { nx |= 0x800000u; }
|
||||
if (ea == -127) {
|
||||
let shift = 23u - firstLeadingBit(na);
|
||||
na <<= shift;
|
||||
ea = -126 - i32(shift);
|
||||
} else { na |= 0x800000u; }
|
||||
var exponent = ex - ea;
|
||||
if (nx < na) { nx <<= 1u; exponent -= 1; }
|
||||
var rem = nx - na;
|
||||
if (exponent < -150) { return 0.0; }
|
||||
if (exponent == -150) { return bitcast<f32>(select(0u, 1u, rem != 0u)); }
|
||||
let fraction = u32(min(23, exponent + 149));
|
||||
var q = 1u;
|
||||
for (var i = 0u; i <= fraction; i++) {
|
||||
rem <<= 1u;
|
||||
q <<= 1u;
|
||||
if (rem >= na) { rem -= na; q |= 1u; }
|
||||
}
|
||||
let guard = q & 1u;
|
||||
q >>= 1u;
|
||||
if (guard != 0u && (rem != 0u || (q & 1u) != 0u)) { q += 1u; }
|
||||
if (exponent < -126) { return bitcast<f32>(q); }
|
||||
if (q == 0x1000000u) { q >>= 1u; exponent += 1; }
|
||||
return bitcast<f32>((u32(exponent + 127) << 23u) | (q & 0x7fffffu));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sampling (raster::Image)
|
||||
|
||||
fn tex_get(t: texture_2d<f32>, x: i32, y: i32) -> vec4<f32> {
|
||||
@@ -157,13 +242,20 @@ fn powz(x: f32, y: f32) -> f32 {
|
||||
return pow(x, y);
|
||||
}
|
||||
|
||||
// Rust's f32::round (half away from zero; WGSL's round() is half to even).
|
||||
// Rust's f32::round (half away from zero; WGSL's round() is half to even). Decided by comparing
|
||||
// `x` with `t ± 0.5` (exact for integer `t`) rather than subtracting: drivers may fuse a product
|
||||
// that feeds `x - t` into one fma (AMD does), skipping the product's rounding, so `5/6 · 3`,
|
||||
// which rounds to exactly 2.5 on the CPU, came out 0.49999994 above 2 and rounded down (#1605).
|
||||
fn round_away(x: f32) -> f32 {
|
||||
let t = trunc(x);
|
||||
if (abs(x - t) >= 0.5) {
|
||||
return t + sign(x);
|
||||
// From 2^23 up every f32 is an integer, and `t + 0.5` would round to even (8388608 → 8388609).
|
||||
if (abs(x) >= 8388608.0) {
|
||||
return x;
|
||||
}
|
||||
return t;
|
||||
let t = trunc(x);
|
||||
if (x >= 0.0) {
|
||||
return select(t, t + 1.0, x >= t + 0.5);
|
||||
}
|
||||
return select(t, t - 1.0, x <= t - 0.5);
|
||||
}
|
||||
|
||||
// f32::rem_euclid(1.0).
|
||||
|
||||
@@ -633,52 +633,9 @@ fn fp2_hextile(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (!fp2_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let cx = P.f[0].x;
|
||||
let cy = P.f[0].y;
|
||||
let r = P.f[0].z;
|
||||
let smear = P.f[0].w;
|
||||
let sn = P.f[1].x;
|
||||
let cs = P.f[1].y;
|
||||
let mode = P.u[0].x;
|
||||
let dx = f32(p.x) + 0.5 - cx;
|
||||
let dy = f32(p.y) + 0.5 - cy;
|
||||
let u = dx * cs + dy * sn;
|
||||
let v = -dx * sn + dy * cs;
|
||||
// stylize3::hex_cell (cube rounding).
|
||||
let q = (0.5773502691896257 * u - v / 3.0) / r;
|
||||
let rr = (2.0 / 3.0 * v) / r;
|
||||
// Evaluate the third cube coordinate symmetrically with q so the centerline
|
||||
// tie is not broken by subtracting separately rounded coordinates.
|
||||
let ccy = (-0.5773502691896257 * u - v / 3.0) / r;
|
||||
var rx = round_away(q);
|
||||
let ry = round_away(ccy);
|
||||
var rz = round_away(rr);
|
||||
let ddx = abs(rx - q);
|
||||
let ddy = abs(ry - ccy);
|
||||
let ddz = abs(rz - rr);
|
||||
if (ddx > ddy && ddx > ddz) {
|
||||
rx = -ry - rz;
|
||||
} else if (ddy <= ddz) {
|
||||
rz = -rx - ry;
|
||||
}
|
||||
let hx = r * 1.7320508075688772 * (rx + rz / 2.0);
|
||||
let hy = r * 1.5 * rz;
|
||||
var lu = u - hx;
|
||||
var lv = v - hy;
|
||||
let odd = imod(i32(rx) + i32(rz), 2) == 1;
|
||||
if ((mode == 1u || mode == 2u) && odd) {
|
||||
lu = -lu;
|
||||
}
|
||||
if (mode == 2u && odd) {
|
||||
lv = -lv;
|
||||
}
|
||||
let ox = lu * cs - lv * sn;
|
||||
let oy = lu * sn + lv * cs;
|
||||
let tx = cx + ox;
|
||||
let ty = cy + oy;
|
||||
let sx = tx + (f32(p.x) + 0.5 - tx) * smear;
|
||||
let sy = ty + (f32(p.y) + 0.5 - ty) * smear;
|
||||
textureStore(out, p, sample_bilinear_clamped(src, sx, sy));
|
||||
// Cell membership and reflection were decided in the CPU effect's f64 geometry.
|
||||
let i = (gid.y * u32(out_dims().x) + gid.x) * 2u;
|
||||
textureStore(out, p, sample_bilinear_clamped(src, data[i], data[i + 1u]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Mr. Smoothie (stylize3::smoothie)
|
||||
|
||||
@@ -114,8 +114,8 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
cov = min(0.12 * select(0.0, 1.0, d <= 1.0) + ring * 0.8 + hl, 1.0);
|
||||
}
|
||||
case 5u: {
|
||||
let sn = sin(data[at + 8u]);
|
||||
let cs = cos(data[at + 8u]);
|
||||
let sn = sin_p(data[at + 8u]);
|
||||
let cs = cos_p(data[at + 8u]);
|
||||
let ux = (dx * cs + dy * sn) / r;
|
||||
let uy = (-dx * sn + dy * cs) / r;
|
||||
let d = sqrt(ux * ux + uy * uy);
|
||||
@@ -125,23 +125,23 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
cov = min(core + stx + sty, 1.0);
|
||||
}
|
||||
case 6u: {
|
||||
let sn = sin(data[at + 8u]);
|
||||
let cs = cos(data[at + 8u]);
|
||||
let sn = sin_p(data[at + 8u]);
|
||||
let cs = cos_p(data[at + 8u]);
|
||||
let ux = dx * cs + dy * sn;
|
||||
let uy = -dx * sn + dy * cs;
|
||||
let e = r * 0.75;
|
||||
cov = fxm_aa(e - abs(ux)) * fxm_aa(e - abs(uy));
|
||||
}
|
||||
case 7u: {
|
||||
let sn = sin(data[at + 8u]);
|
||||
let cs = cos(data[at + 8u]);
|
||||
let sn = sin_p(data[at + 8u]);
|
||||
let cs = cos_p(data[at + 8u]);
|
||||
let ux = dx * cs + dy * sn;
|
||||
let uy = -dx * sn + dy * cs;
|
||||
let e = r * 0.5;
|
||||
var m = 3.4e38;
|
||||
for (var k = 0; k < 3; k++) {
|
||||
let a = 1.5707964 + f32(k) * 6.2831855 / 3.0;
|
||||
m = min(m, e - (ux * cos(a) + uy * sin(a)));
|
||||
m = min(m, e - (ux * cos_p(a) + uy * sin_p(a)));
|
||||
}
|
||||
cov = fxm_aa(m);
|
||||
}
|
||||
@@ -431,8 +431,8 @@ fn fxm_ripples(x: f32, y: f32) -> f32 {
|
||||
let r = data[i * 4u + 2u];
|
||||
let u = (d - r) / P.f[0].y;
|
||||
if (abs(u) < 1.0) {
|
||||
let env = 0.5 + 0.5 * cos(u * 3.1415927);
|
||||
v += data[i * 4u + 3u] * env * cos((d - r) / P.f[0].x * 6.2831855);
|
||||
let env = 0.5 + 0.5 * cos_p(u * 3.1415927);
|
||||
v += data[i * 4u + 3u] * env * cos_p((d - r) / P.f[0].x * 6.2831855);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
|
||||
@@ -42,7 +42,11 @@ fn fxs_qdiv(x: vec3<f32>, a: f32) -> vec3<f32> {
|
||||
let r = fma(-q, vec3<f32>(a), x);
|
||||
// `q + r / a`, written as an fma so drivers (NVIDIA Vulkan) can't contract it another way
|
||||
// and round differently from the CPU.
|
||||
return fma(r / a, vec3<f32>(1.0), q);
|
||||
let corrected = fma(r / a, vec3<f32>(1.0), q);
|
||||
return vec3<f32>(
|
||||
select(corrected.x, unit_div(x.x, a), x.x >= 0.0 && x.x <= a),
|
||||
select(corrected.y, unit_div(x.y, a), x.y >= 0.0 && x.y <= a),
|
||||
select(corrected.z, unit_div(x.z, a), x.z >= 0.0 && x.z <= a));
|
||||
}
|
||||
|
||||
fn fxs_rem2(i: i32) -> i32 {
|
||||
@@ -63,6 +67,32 @@ fn fxs_bin(v: f32, t: f32, inv: bool) -> f32 {
|
||||
return select(0.0, 1.0, (v >= t) != inv);
|
||||
}
|
||||
|
||||
// Preserve the CPU's separately-rounded f32 multiply before rounding to a bin.
|
||||
// A shader compiler may reassociate division and multiplication across a half
|
||||
// threshold. The 24-bit mantissa times at most 254 fits in u32 exactly.
|
||||
fn fxs_posterize_bin(v: f32, levels: u32) -> f32 {
|
||||
let bits = bitcast<u32>(clamp(v, 0.0, 1.0));
|
||||
let exponent = (bits >> 23u) & 255u;
|
||||
if (exponent == 0u) { return 0.0; }
|
||||
var product = ((bits & 0x7fffffu) | 0x800000u) * levels;
|
||||
let high = firstLeadingBit(product);
|
||||
if (high > 23u) {
|
||||
let lost = high - 23u;
|
||||
let mask = (1u << lost) - 1u;
|
||||
let remainder = product & mask;
|
||||
let half = 1u << (lost - 1u);
|
||||
var kept = product >> lost;
|
||||
if (remainder > half || (remainder == half && (kept & 1u) != 0u)) { kept += 1u; }
|
||||
product = kept << lost;
|
||||
}
|
||||
let shift = 150u - exponent;
|
||||
if (shift > 32u) { return 0.0; }
|
||||
if (shift == 32u) { return select(0.0, 1.0, product >= 0x80000000u); }
|
||||
let mask = (1u << shift) - 1u;
|
||||
let integer = (product >> shift) + select(0u, 1u, (product & mask) >= (1u << (shift - 1u)));
|
||||
return f32(integer);
|
||||
}
|
||||
|
||||
fn fxs_strobe_op(op: u32, v: f32, k: f32) -> f32 {
|
||||
switch op {
|
||||
case 1u: { return v + k; }
|
||||
@@ -96,8 +126,8 @@ fn fxs_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
switch mode {
|
||||
case 1u: {
|
||||
let lv = P.f[0].x;
|
||||
let c = clamp(fxs_qdiv(px.xyz, a), vec3<f32>(0.0), vec3<f32>(1.0)) * lv;
|
||||
let o = vec3<f32>(round_away(c.x), round_away(c.y), round_away(c.z)) / lv;
|
||||
let c = vec3<f32>(unit_div(px.x, a), unit_div(px.y, a), unit_div(px.z, a));
|
||||
let o = vec3<f32>(fxs_posterize_bin(c.x, u32(lv)), fxs_posterize_bin(c.y, u32(lv)), fxs_posterize_bin(c.z, u32(lv))) / lv;
|
||||
textureStore(out, p, vec4<f32>(o * a, a));
|
||||
}
|
||||
case 2u: {
|
||||
@@ -611,7 +641,9 @@ fn fxs_motiontile(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
fy = 1.0 - fy;
|
||||
}
|
||||
}
|
||||
textureStore(out, p, sample_bilinear(src, fx * P.f[0].y + P.f[1].x, fy * P.f[0].z + P.f[1].y));
|
||||
// Clamped for the same reason as the CPU path: a fold on a pixel centre samples the
|
||||
// source's geometric edge, where the plain sampler mixes in the transparent outside.
|
||||
textureStore(out, p, sample_bilinear_clamped(src, fx * P.f[0].y + P.f[1].x, fy * P.f[0].z + P.f[1].y));
|
||||
}
|
||||
|
||||
// CC RepeTile (stylize2::repetile) into a new output size. data: per output column (tile index,
|
||||
|
||||
@@ -129,16 +129,17 @@ fn fxt_knee(v: f32, start: f32, limit: f32) -> f32 {
|
||||
return start + r * (1.0 - exp(-(v - start) / r));
|
||||
}
|
||||
|
||||
// PS Arbitrary Map lookup (map in data, n entries; f[0].x = phase offset in entries).
|
||||
// PS Arbitrary Map: CPU-rounded interval starts, values and slopes in data.
|
||||
fn fxt_lookup(v: f32, n: u32) -> f32 {
|
||||
let nf = f32(n);
|
||||
var x = clamp(v, 0.0, 1.0) * (nf - 1.0) + P.f[0].x;
|
||||
x = x - nf * floor(x / nf);
|
||||
let fl = floor(x);
|
||||
let i0 = u32(fl) % n;
|
||||
let i1 = select(i0, i0 + 1u, i0 + 1u < n);
|
||||
let t = x - fl;
|
||||
return data[i0] + (data[i1] - data[i0]) * t;
|
||||
let x = clamp(v, 0.0, 1.0);
|
||||
var lo = 0u;
|
||||
var hi = n;
|
||||
while (lo + 1u < hi) {
|
||||
let mid = lo + (hi - lo) / 2u;
|
||||
if (x < data[mid * 3u]) { hi = mid; } else { lo = mid; }
|
||||
}
|
||||
let i = lo * 3u;
|
||||
return data[i + 1u] + (x - data[i]) * data[i + 2u];
|
||||
}
|
||||
|
||||
fn fxt_eq_look(table: u32, n: u32, v: f32) -> f32 {
|
||||
@@ -447,7 +448,11 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
case 12u: {
|
||||
let n = u0.z;
|
||||
let aa = max(a, 0.0);
|
||||
let r = vec3<f32>(fxt_lookup(c.x, n), fxt_lookup(c.y, n), fxt_lookup(c.z, n));
|
||||
var straight = vec3<f32>(0.0);
|
||||
if (a > 1e-6) {
|
||||
straight = vec3<f32>(unit_div(px.x, a), unit_div(px.y, a), unit_div(px.z, a));
|
||||
}
|
||||
let r = vec3<f32>(fxt_lookup(straight.x, n), fxt_lookup(straight.y, n), fxt_lookup(straight.z, n));
|
||||
var na = aa;
|
||||
if (u0.y != 0u) {
|
||||
na = fxt_lookup(aa, n);
|
||||
|
||||
@@ -224,28 +224,8 @@ fn ftr_grid(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
return;
|
||||
}
|
||||
let px = textureLoad(src, p, 0);
|
||||
let d = ftr_centre(p) - P.f[0].xy;
|
||||
let s = P.f[0].z;
|
||||
let co = P.f[0].w;
|
||||
let q = vec2<f32>(d.x * co + d.y * s, -d.x * s + d.y * co);
|
||||
let cs = P.f[1].x;
|
||||
let cc = (floor(q / cs) + 0.5) * cs;
|
||||
var o = clamp(ftr_metric(P.u[0].x, cc) / P.f[1].y, 0.0, 1.0);
|
||||
if (P.u[0].y != 0u) {
|
||||
o = 1.0 - o;
|
||||
}
|
||||
let spread = 0.25;
|
||||
let pr = clamp((P.f[1].z * (1.0 + spread) - o) / spread, 0.0, 1.0);
|
||||
if (pr <= 0.0) {
|
||||
textureStore(out, p, px);
|
||||
return;
|
||||
}
|
||||
let half = cs * 0.5 * (1.0 - pr);
|
||||
let m = max(abs(q.x - cc.x), abs(q.y - cc.y));
|
||||
var k = select(0.0, 1.0, m < half);
|
||||
if (P.f[1].w > 0.0) {
|
||||
k = clamp((half - m) / P.f[1].w + 0.5, 0.0, 1.0);
|
||||
}
|
||||
// CPU-generated coverage preserves exact f64 tile and hard-edge decisions.
|
||||
let k = data[gid.y * u32(out_dims().x) + gid.x];
|
||||
textureStore(out, p, px * k);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ fn fxv_unpremul(p: vec4<f32>) -> vec4<f32> {
|
||||
fn fxv_equi_dir(x: f32, y: f32, w: f32, h: f32) -> vec3<f32> {
|
||||
let lon = (x / w - 0.5) * 2.0 * FXV_PI;
|
||||
let lat = (0.5 - y / h) * FXV_PI;
|
||||
return vec3<f32>(cos(lat) * sin(lon), sin(lat), cos(lat) * cos(lon));
|
||||
return vec3<f32>(cos_p(lat) * sin_p(lon), sin_p(lat), cos_p(lat) * cos_p(lon));
|
||||
}
|
||||
|
||||
// vr::equi_px.
|
||||
@@ -168,7 +168,7 @@ fn fxv_dir(kind: u32, fov: f32, x: f32, y: f32, w: f32, h: f32) -> vec4<f32> {
|
||||
cu = u / r;
|
||||
cv = v / r;
|
||||
}
|
||||
return vec4<f32>(sin(th) * cu, cos(th), sin(th) * cv, 1.0);
|
||||
return vec4<f32>(sin_p(th) * cu, cos_p(th), sin_p(th) * cv, 1.0);
|
||||
}
|
||||
if (kind == 4u) {
|
||||
let half = radians(clamp(fov, 1.0, 360.0) / 2.0);
|
||||
@@ -186,7 +186,7 @@ fn fxv_dir(kind: u32, fov: f32, x: f32, y: f32, w: f32, h: f32) -> vec4<f32> {
|
||||
cu = u / r;
|
||||
cv = v / r;
|
||||
}
|
||||
return vec4<f32>(sin(th) * cu, sin(th) * cv, cos(th), 1.0);
|
||||
return vec4<f32>(sin_p(th) * cu, sin_p(th) * cv, cos_p(th), 1.0);
|
||||
}
|
||||
let t = tan(radians(clamp(fov, 1.0, 179.0) / 2.0));
|
||||
let u = (x / w * 2.0 - 1.0) * t;
|
||||
@@ -519,8 +519,8 @@ fn fxv_gen(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
var s = px;
|
||||
if (al >= 1e-9) {
|
||||
let new_th = th * (1.0 - P.f[0][c] * weight);
|
||||
let perp = normalize(d - center * cos(th));
|
||||
let nd = center * cos(new_th) + perp * sin(new_th);
|
||||
let perp = normalize(d - center * cos_p(th));
|
||||
let nd = center * cos_p(new_th) + perp * sin_p(new_th);
|
||||
let q = fxv_equi_px(nd, w, h);
|
||||
s = fxv_sample_equi(q.x, q.y);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
// GPU Content-Aware Fill: the membrane (harmonic) fill and PatchMatch synthesis of
|
||||
// effectcraft_raster::inpaint, operation for operation (the CPU reference is the oracle; see
|
||||
// crates/gpu/src/tests/inpaint.rs).
|
||||
//
|
||||
// Bindings are the shared ones (common.wgsl): `src` / `aux` are the input textures (an image, a
|
||||
// coarser level, or the nearest-neighbour field), `out` is the storage texture and `data` is a
|
||||
// per-pixel float table — the level's flags:
|
||||
// * membrane kernels: data[i] = 1 where the pixel is known (the hole's complement);
|
||||
// * PatchMatch kernels: data[i] = bit 0 hole, bit 1 near (a pixel whose patch touches the hole).
|
||||
//
|
||||
// Parameter block (P.u = u32, P.f = f32):
|
||||
// membrane kernels: u[0] = (w, h, coarseW, coarseH), u[1] = (x0, y0, 0, 0) (dispatch
|
||||
// origin), u[3].x = this level's offset into `data`
|
||||
// down kernels: u[0] = (srcW, srcH, dstW, dstH)
|
||||
// PatchMatch kernels: u[0] = (w, h, r, parity), u[1] = (x0, y0, 0, 0) (dispatch origin),
|
||||
// u[2] = (seed, iteration, 0, EM round), f[0] = (sigma², 0, 0, 0)
|
||||
// histogram: u[0] = (w, h, 0, 0), u[1] = (x0, y0, w, h) (target bounds),
|
||||
// f[0].x = the distance range (0: the SDR range 0…4)
|
||||
// The nearest-neighbour field texture holds one entry per pixel: r, g = source centre ("px"),
|
||||
// b = SSD distance (inf where unset), a = 1 where the entry is set.
|
||||
|
||||
const IMP_INF: f32 = 1e30;
|
||||
|
||||
fn imp_flags(i: i32) -> u32 {
|
||||
return u32(max(data[i], 0.0));
|
||||
}
|
||||
|
||||
fn imp_hole(i: i32) -> bool {
|
||||
return (imp_flags(i) & 1u) != 0u;
|
||||
}
|
||||
|
||||
fn imp_near(i: i32) -> bool {
|
||||
return (imp_flags(i) & 2u) != 0u;
|
||||
}
|
||||
|
||||
// A source centre whose patch is fully known and inside the image (Pm::valid_source).
|
||||
fn imp_valid_source(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool {
|
||||
if (x < r || y < r || x + r >= w || y + r >= h) {
|
||||
return false;
|
||||
}
|
||||
return !imp_near(y * w + x);
|
||||
}
|
||||
|
||||
// PatchMatch's hashing rnd (raster::inpaint::rnd), bit for bit: the CPU's random stream is a
|
||||
// contract of the algorithm, not an implementation detail.
|
||||
fn imp_rnd(seed: u32, a: u32, b: u32) -> u32 {
|
||||
var h = (seed * 0x9E3779B9u) ^ (a * 0x85EBCA6Bu) ^ (b * 0xC2B2AE35u);
|
||||
h = h ^ (h >> 16u);
|
||||
h = h * 0x7FEB352Du;
|
||||
h = h ^ (h >> 15u);
|
||||
h = h * 0x846CA68Bu;
|
||||
return h ^ (h >> 16u);
|
||||
}
|
||||
|
||||
// SSD of the patch at target (tx, ty) (clipped to the image) against source (sx, sy)
|
||||
// (Pm::dist). `cap` short-circuits a match that is already worse.
|
||||
fn imp_dist(tx: i32, ty: i32, sx: i32, sy: i32, w: i32, h: i32, r: i32, cap: f32) -> f32 {
|
||||
var d = 0.0;
|
||||
var n = 0.0;
|
||||
for (var dy = -r; dy <= r; dy = dy + 1) {
|
||||
let ty2 = ty + dy;
|
||||
if (ty2 < 0 || ty2 >= h) {
|
||||
continue;
|
||||
}
|
||||
let sy2 = sy + dy;
|
||||
for (var dx = -r; dx <= r; dx = dx + 1) {
|
||||
let tx2 = tx + dx;
|
||||
if (tx2 < 0 || tx2 >= w) {
|
||||
continue;
|
||||
}
|
||||
let a = textureLoad(aux, vec2<i32>(tx2, ty2), 0);
|
||||
let b = textureLoad(aux, vec2<i32>(sx + dx, sy2), 0);
|
||||
let e = a - b;
|
||||
d = d + e.x * e.x + e.y * e.y + e.z * e.z + e.w * e.w;
|
||||
n = n + 1.0;
|
||||
}
|
||||
if (d > cap * max(n, 1.0)) {
|
||||
return IMP_INF;
|
||||
}
|
||||
}
|
||||
return d / max(n, 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- membrane
|
||||
|
||||
// membrane_core's coarse level: the known-weighted 2×2 average.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_down(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let cw = i32(P.u[0].z);
|
||||
let ch = i32(P.u[0].w);
|
||||
let cx = i32(gid.x);
|
||||
let cy = i32(gid.y);
|
||||
if (cx >= cw || cy >= ch) {
|
||||
return;
|
||||
}
|
||||
var s = vec4<f32>(0.0);
|
||||
var n = 0.0;
|
||||
for (var dy = 0; dy < 2; dy = dy + 1) {
|
||||
for (var dx = 0; dx < 2; dx = dx + 1) {
|
||||
let fx = 2 * cx + dx;
|
||||
let fy = 2 * cy + dy;
|
||||
if (fx < w && fy < h && data[i32(P.u[3].x) + fy * w + fx] > 0.5) {
|
||||
s = s + textureLoad(src, vec2<i32>(fx, fy), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
var v = vec4<f32>(0.0);
|
||||
if (n > 0.0) {
|
||||
v = s / n;
|
||||
}
|
||||
textureStore(out, vec2<i32>(cx, cy), v);
|
||||
}
|
||||
|
||||
// membrane_core's coarse-to-fine step: unknown pixels take the nearest coarse value. The fine
|
||||
// image is copied into `out` first, so writing only the unknown pixels keeps the known ones.
|
||||
// Dispatched over the unknown pixels' bounds (`P.u[1]`); the coarse level is valid everywhere.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_up(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let cw = i32(P.u[0].z);
|
||||
let ch = i32(P.u[0].w);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h || data[i32(P.u[3].x) + y * w + x] > 0.5) {
|
||||
return;
|
||||
}
|
||||
let cx = min(x / 2, cw - 1);
|
||||
let cy = min(y / 2, ch - 1);
|
||||
textureStore(out, vec2<i32>(x, y), textureLoad(src, vec2<i32>(cx, cy), 0));
|
||||
}
|
||||
|
||||
// One relaxation of the unknown pixels (membrane_core's Jacobi sweep). `src` is the current
|
||||
// state and `aux` is unused. Dispatched over the unknown pixels' bounds (`P.u[1]`): known
|
||||
// pixels never move, so nothing outside them needs writing. Both `src` and `out` are valid
|
||||
// across the image (the fine level's, a copy of it, or the full downsample).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_jacobi(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
var v = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
if (data[i32(P.u[3].x) + y * w + x] <= 0.5) {
|
||||
var s = vec4<f32>(0.0);
|
||||
var n = 0.0;
|
||||
if (x > 0) {
|
||||
s = s + textureLoad(src, vec2<i32>(x - 1, y), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
if (x + 1 < w) {
|
||||
s = s + textureLoad(src, vec2<i32>(x + 1, y), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
if (y > 0) {
|
||||
s = s + textureLoad(src, vec2<i32>(x, y - 1), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
if (y + 1 < h) {
|
||||
s = s + textureLoad(src, vec2<i32>(x, y + 1), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
if (n > 0.0) {
|
||||
v = s / n;
|
||||
}
|
||||
}
|
||||
textureStore(out, vec2<i32>(x, y), v);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PatchMatch
|
||||
|
||||
// PatchMatch's pyramid downsample (downsample): the level's image, hole pixels excluded from the
|
||||
// average, over `out`'s size.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_down(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let cw = i32(P.u[0].z);
|
||||
let ch = i32(P.u[0].w);
|
||||
let cx = i32(gid.x);
|
||||
let cy = i32(gid.y);
|
||||
if (cx >= cw || cy >= ch) {
|
||||
return;
|
||||
}
|
||||
var s = vec4<f32>(0.0);
|
||||
var n = 0.0;
|
||||
for (var dy = 0; dy < 2; dy = dy + 1) {
|
||||
for (var dx = 0; dx < 2; dx = dx + 1) {
|
||||
let fx = min(2 * cx + dx, w - 1);
|
||||
let fy = min(2 * cy + dy, h - 1);
|
||||
if (!imp_hole(fy * w + fx)) {
|
||||
s = s + textureLoad(src, vec2<i32>(fx, fy), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
var v = vec4<f32>(0.0);
|
||||
if (n > 0.0) {
|
||||
v = s / n;
|
||||
}
|
||||
textureStore(out, vec2<i32>(cx, cy), v);
|
||||
}
|
||||
|
||||
// PatchMatch's coarse-to-fine step: bilinear upsample of the coarse image into this level's hole
|
||||
// (patch_fill's upsample loop). The fine image is copied into `out` first.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_up(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let cw = i32(P.u[0].z);
|
||||
let ch = i32(P.u[0].w);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h || !imp_hole(y * w + x)) {
|
||||
return;
|
||||
}
|
||||
let fx = clamp((f32(x) + 0.5) / 2.0 - 0.5, 0.0, f32(cw - 1));
|
||||
let fy = clamp((f32(y) + 0.5) / 2.0 - 0.5, 0.0, f32(ch - 1));
|
||||
let x0 = i32(floor(fx));
|
||||
let y0 = i32(floor(fy));
|
||||
let x1 = min(x0 + 1, cw - 1);
|
||||
let y1 = min(y0 + 1, ch - 1);
|
||||
let tx = fx - f32(x0);
|
||||
let ty = fy - f32(y0);
|
||||
let a = textureLoad(src, vec2<i32>(x0, y0), 0);
|
||||
let b = textureLoad(src, vec2<i32>(x1, y0), 0);
|
||||
let c = textureLoad(src, vec2<i32>(x0, y1), 0);
|
||||
let d = textureLoad(src, vec2<i32>(x1, y1), 0);
|
||||
let top = a + (b - a) * tx;
|
||||
let bot = c + (d - c) * tx;
|
||||
textureStore(out, vec2<i32>(x, y), top + (bot - top) * ty);
|
||||
}
|
||||
|
||||
// The NNF's coarse-to-fine step: ×2 (patch_fill's nnf upsample).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_nnf_up(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let cw = i32(P.u[0].z);
|
||||
let ch = i32(P.u[0].w);
|
||||
let x = i32(gid.x);
|
||||
let y = i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let cx = min(x / 2, cw - 1);
|
||||
let cy = min(y / 2, ch - 1);
|
||||
let c = textureLoad(src, vec2<i32>(cx, cy), 0);
|
||||
var o = vec4<f32>(0.0);
|
||||
if (c.a > 0.5) {
|
||||
o = vec4<f32>(c.r * 2.0 + f32(x % 2), c.g * 2.0 + f32(y % 2), 0.0, 1.0);
|
||||
}
|
||||
textureStore(out, vec2<i32>(x, y), o);
|
||||
}
|
||||
|
||||
// A random valid source centre, by rejection (the CPU draws from its compacted source list; the
|
||||
// distribution over the valid centres is the same, the stream differs from the list's order).
|
||||
fn imp_any_source(seed: u32, i: u32, k: u32, w: i32, h: i32, r: i32) -> vec2<i32> {
|
||||
for (var n = 0u; n < 32u; n = n + 1u) {
|
||||
let a = imp_rnd(seed, i, k + n);
|
||||
let x = i32(a % u32(w));
|
||||
let y = i32((a >> 16u) % u32(h));
|
||||
if (imp_valid_source(x, y, w, h, r)) {
|
||||
return vec2<i32>(x, y);
|
||||
}
|
||||
}
|
||||
return vec2<i32>(-1, -1);
|
||||
}
|
||||
|
||||
// A round's initial field: the inherited source where it is still valid, a random one otherwise
|
||||
// (pm_level's initialise/validate step).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_init(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let r = i32(P.u[0].z);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let i = y * w + x;
|
||||
var o = vec4<f32>(0.0, 0.0, IMP_INF, 0.0);
|
||||
if (imp_near(i)) {
|
||||
let prev = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
var s = vec2<i32>(i32(prev.r), i32(prev.g));
|
||||
if (prev.a <= 0.5 || !imp_valid_source(s.x, s.y, w, h, r)) {
|
||||
// rnd(seed ^ e, i, 0), the round's random source (see imp_any_source).
|
||||
s = imp_any_source(P.u[2].x ^ P.u[2].w, u32(i), 0u, w, h, r);
|
||||
}
|
||||
if (s.x >= 0) {
|
||||
let d = imp_dist(x, y, s.x, s.y, w, h, r, IMP_INF);
|
||||
o = vec4<f32>(f32(s.x), f32(s.y), d, 1.0);
|
||||
}
|
||||
}
|
||||
textureStore(out, vec2<i32>(x, y), o);
|
||||
}
|
||||
|
||||
// One propagation pass: every target pixel tries its four neighbours' sources shifted by the
|
||||
// step that would move that neighbour's match onto this pixel. The checkerboard parity in P.u[0].w
|
||||
// makes the pass race-free (a pixel of one parity only reads the other's).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_prop(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let r = i32(P.u[0].z);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let cur = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
let i = y * w + x;
|
||||
if (!imp_near(i) || ((x + y) & 1) != i32(P.u[0].w)) {
|
||||
textureStore(out, vec2<i32>(x, y), cur);
|
||||
return;
|
||||
}
|
||||
var best = vec2<i32>(i32(cur.r), i32(cur.g));
|
||||
var bd = cur.b;
|
||||
if (cur.a <= 0.5) {
|
||||
bd = IMP_INF;
|
||||
}
|
||||
for (var k = 0; k < 4; k = k + 1) {
|
||||
var nx = x;
|
||||
var ny = y;
|
||||
var sx = 0;
|
||||
var sy = 0;
|
||||
if (k == 0) {
|
||||
nx = x - 1;
|
||||
sx = 1;
|
||||
} else if (k == 1) {
|
||||
nx = x + 1;
|
||||
sx = -1;
|
||||
} else if (k == 2) {
|
||||
ny = y - 1;
|
||||
sy = 1;
|
||||
} else {
|
||||
ny = y + 1;
|
||||
sy = -1;
|
||||
}
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) {
|
||||
continue;
|
||||
}
|
||||
let n = textureLoad(src, vec2<i32>(nx, ny), 0);
|
||||
if (n.a <= 0.5) {
|
||||
continue;
|
||||
}
|
||||
let c = vec2<i32>(i32(n.r) + sx, i32(n.g) + sy);
|
||||
if (c.x == best.x && c.y == best.y) {
|
||||
continue;
|
||||
}
|
||||
if (!imp_valid_source(c.x, c.y, w, h, r)) {
|
||||
continue;
|
||||
}
|
||||
let d = imp_dist(x, y, c.x, c.y, w, h, r, bd);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
// alpha = the entry is set: `bd` stays INF only if no match was found.
|
||||
textureStore(out, vec2<i32>(x, y), vec4<f32>(f32(best.x), f32(best.y), bd, select(0.0, 1.0, bd < IMP_INF)));
|
||||
}
|
||||
|
||||
// The random search (halving radius) and one random source anywhere, after propagation.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_rand(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let r = i32(P.u[0].z);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let cur = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
let i = y * w + x;
|
||||
if (!imp_near(i) || cur.a <= 0.5) {
|
||||
textureStore(out, vec2<i32>(x, y), cur);
|
||||
return;
|
||||
}
|
||||
var best = vec2<i32>(i32(cur.r), i32(cur.g));
|
||||
var bd = cur.b;
|
||||
let seed = P.u[2].x;
|
||||
let it = P.u[2].y;
|
||||
let em = P.u[2].w;
|
||||
let iu = u32(i);
|
||||
var rad = max(w, h);
|
||||
var k = 0u;
|
||||
while (rad >= 1) {
|
||||
let a = imp_rnd(seed + it * 977u + em * 31u, iu, k);
|
||||
let ox = i32(a % u32(2 * rad + 1)) - rad;
|
||||
let oy = i32((a >> 16u) % u32(2 * rad + 1)) - rad;
|
||||
let c = vec2<i32>(best.x + ox, best.y + oy);
|
||||
if (imp_valid_source(c.x, c.y, w, h, r)) {
|
||||
let d = imp_dist(x, y, c.x, c.y, w, h, r, bd);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
rad = rad / 2;
|
||||
k = k + 1u;
|
||||
}
|
||||
let any = imp_any_source(seed ^ 0xABCDu, iu, it + em * 64u, w, h, r);
|
||||
if (any.x >= 0 && (any.x != best.x || any.y != best.y)) {
|
||||
let d = imp_dist(x, y, any.x, any.y, w, h, r, bd);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = any;
|
||||
}
|
||||
}
|
||||
textureStore(out, vec2<i32>(x, y), vec4<f32>(f32(best.x), f32(best.y), bd, select(0.0, 1.0, bd < IMP_INF)));
|
||||
}
|
||||
|
||||
// Carry a round's field back into the nearest-neighbour field (only targets, as on the CPU).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_nnf_store(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let x = i32(gid.x);
|
||||
let y = i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let i = y * w + x;
|
||||
var o = textureLoad(aux, vec2<i32>(x, y), 0);
|
||||
if (imp_near(i)) {
|
||||
let f = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
o = vec4<f32>(f.r, f.g, 0.0, 1.0);
|
||||
}
|
||||
textureStore(out, vec2<i32>(x, y), o);
|
||||
}
|
||||
|
||||
// The vote: every hole pixel becomes the distance-weighted mean of the samples its overlapping
|
||||
// patches carry (pm_level's vote step). `aux` is the current image; the image is copied into
|
||||
// `out` first, so only hole pixels are written.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_vote(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let r = i32(P.u[0].z);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let i = y * w + x;
|
||||
if (!imp_hole(i)) {
|
||||
return;
|
||||
}
|
||||
let sigma2 = P.f[0].x;
|
||||
var acc = vec4<f32>(0.0);
|
||||
var wsum = 0.0;
|
||||
for (var dy = -r; dy <= r; dy = dy + 1) {
|
||||
let py = y + dy;
|
||||
if (py < 0 || py >= h) {
|
||||
continue;
|
||||
}
|
||||
for (var dx = -r; dx <= r; dx = dx + 1) {
|
||||
let px = x + dx;
|
||||
if (px < 0 || px >= w) {
|
||||
continue;
|
||||
}
|
||||
let f = textureLoad(src, vec2<i32>(px, py), 0);
|
||||
if (f.a <= 0.5 || f.b >= IMP_INF) {
|
||||
continue;
|
||||
}
|
||||
let s = vec2<i32>(i32(f.r) - dx, i32(f.g) - dy);
|
||||
let wt = exp(-f.b / (2.0 * sigma2));
|
||||
acc = acc + textureLoad(aux, vec2<i32>(s.x, s.y), 0) * wt;
|
||||
wsum = wsum + wt;
|
||||
}
|
||||
}
|
||||
if (wsum > 0.0) {
|
||||
textureStore(out, vec2<i32>(x, y), acc / wsum);
|
||||
} else {
|
||||
textureStore(out, vec2<i32>(x, y), textureLoad(aux, vec2<i32>(x, y), 0));
|
||||
}
|
||||
}
|
||||
|
||||
// The workgroup's 1024 distance bins (`imp_hist`); the name is unique across the concatenated
|
||||
// shader sources because `var<workgroup>` is module scope.
|
||||
var<workgroup> imp_hist_bins: array<atomic<u32>, 1024>;
|
||||
// The largest finite distance this dispatch saw (as its f32 bit pattern: positive floats are
|
||||
// ordered by their bits). `sigma2` uses it to learn whether the frame needs a wider range.
|
||||
var<workgroup> imp_hist_max: atomic<u32>;
|
||||
|
||||
// One workgroup's histogram of the targets' match distances, for the vote's sigma² (the CPU takes
|
||||
// the 75th percentile of the finite distances). The single workgroup accumulates the whole
|
||||
// population through the shared atomic bins above.
|
||||
//
|
||||
// `P.f[0].x` is the range the bins cover; 0 selects the SDR range 0…4, where an image whose
|
||||
// channels are all ≤ 1 lives: each channel's squared difference is ≤ 4, so the mean SSD is too.
|
||||
// Frames with values above 1 (32-bpc HDR) have larger distances, so `sigma2` re-dispatches with
|
||||
// the observed maximum as the range instead of clamping them to bin 1023, which would report a
|
||||
// sigma² of ≈ 4 for a distance of 1600 and round the vote's weights to zero. The observed maximum
|
||||
// rides in bin 0's unused `.g` channel on every dispatch (its `.x` stays the count).
|
||||
@compute @workgroup_size(256, 1, 1)
|
||||
fn imp_hist(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let x0 = i32(P.u[1].x);
|
||||
let y0 = i32(P.u[1].y);
|
||||
let bw = i32(P.u[1].z);
|
||||
let bh = i32(P.u[1].w);
|
||||
let t = gid.x;
|
||||
let range = select(4.0, P.f[0].x, P.f[0].x > 0.0);
|
||||
let scale = 1024.0 / range;
|
||||
if (t == 0u) {
|
||||
atomicStore(&imp_hist_max, 0u);
|
||||
}
|
||||
// One workgroup, so the whole population is counted through shared atomics: a per-thread
|
||||
// strided pass would keep only the pixels whose bin selected that thread (a biased ~1/256
|
||||
// subsample, correlated with the pixel index).
|
||||
for (var i = t; i < 1024u; i = i + 256u) {
|
||||
atomicStore(&imp_hist_bins[i], 0u);
|
||||
}
|
||||
workgroupBarrier();
|
||||
let total = u32(max(bw, 0) * max(bh, 0));
|
||||
for (var idx = t; idx < total; idx = idx + 256u) {
|
||||
let x = x0 + i32(idx) % bw;
|
||||
let y = y0 + i32(idx) / bw;
|
||||
let i = y * w + x;
|
||||
if (!imp_near(i)) {
|
||||
continue;
|
||||
}
|
||||
let f = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
if (f.a <= 0.5 || f.b >= IMP_INF) {
|
||||
continue;
|
||||
}
|
||||
atomicMax(&imp_hist_max, bitcast<u32>(f.b));
|
||||
// Clamp in f32 before the conversion: a distance far outside the range (a mismatched patch
|
||||
// in an HDR frame) would overflow the i32 conversion, which is indeterminate in WGSL.
|
||||
let b = u32(clamp(f.b * scale, 0.0, 1023.0));
|
||||
atomicAdd(&imp_hist_bins[b], 1u);
|
||||
}
|
||||
workgroupBarrier();
|
||||
// 256 threads × 4 bins cover the histogram.
|
||||
for (var j = 0u; j < 4u; j = j + 1u) {
|
||||
let bin = t * 4u + j;
|
||||
textureStore(out, vec2<i32>(i32(bin), 0), vec4<f32>(f32(atomicLoad(&imp_hist_bins[bin])), 0.0, 0.0, 0.0));
|
||||
}
|
||||
// Thread 0 is bin 0's only writer, so this store follows the one above. `.g` carries the
|
||||
// observed maximum: host readbacks keep the row 1024 texels wide (a 16 KB, 256-aligned copy).
|
||||
if (t == 0u) {
|
||||
textureStore(out, vec2<i32>(0, 0), vec4<f32>(f32(atomicLoad(&imp_hist_bins[0])), bitcast<f32>(atomicLoad(&imp_hist_max)), 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- block flow (flow completion)
|
||||
|
||||
// The block matcher of effectcraft_raster::flow::block_flow(a, b, 8, 6), which the fill uses to
|
||||
// compute the motion between neighbouring membrane-filled frames before completing it inside the
|
||||
// holes (`FillBackend::flow`). `src` packs the two frames' luma planes at the current level:
|
||||
// .x is frame A's, .y is frame B's (`effectcraft_raster::flow::Plane::luma`). The kernels mirror
|
||||
// the CPU operation for operation, so the field matches the CPU's (crates/gpu/src/tests/inpaint.rs).
|
||||
|
||||
// Plane::half of the packed pair: the clamped 2×2 average of each channel.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_fl_half(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let cx = i32(gid.x);
|
||||
let cy = i32(gid.y);
|
||||
if (cx >= i32(P.u[0].z) || cy >= i32(P.u[0].w)) {
|
||||
return;
|
||||
}
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
var sx = 0.0;
|
||||
var sy = 0.0;
|
||||
for (var dy = 0; dy < 2; dy = dy + 1) {
|
||||
for (var dx = 0; dx < 2; dx = dx + 1) {
|
||||
let v = textureLoad(src, vec2<i32>(min(2 * cx + dx, w - 1), min(2 * cy + dy, h - 1)), 0);
|
||||
sx = sx + v.x;
|
||||
sy = sy + v.y;
|
||||
}
|
||||
}
|
||||
textureStore(out, vec2<i32>(cx, cy), vec4<f32>(sx * 0.25, sy * 0.25, 0.0, 0.0));
|
||||
}
|
||||
|
||||
// Plane::get: the packed luma at (x, y), clamped to the plane.
|
||||
fn imp_fl_tex(x: i32, y: i32, w: i32, h: i32) -> vec2<f32> {
|
||||
let v = textureLoad(src, clamp(vec2<i32>(x, y), vec2<i32>(0, 0), vec2<i32>(w - 1, h - 1)), 0);
|
||||
return vec2<f32>(v.x, v.y);
|
||||
}
|
||||
|
||||
// The block match's SAD with A's block already loaded (`la`, row-major): B's luma is the packed
|
||||
// .y. The sub-pixel kernel's five fits share the window's block, so it is read once; the search
|
||||
// hoists its own 4×4/8×8 block the same way. The loops' bounds are literals so the backend can
|
||||
// unroll them and keep the loads in flight — a runtime-bound loop runs an order of magnitude
|
||||
// slower.
|
||||
fn imp_fl_sad8h(bx: i32, by: i32, dx: i32, dy: i32, w: i32, h: i32, la: array<f32, 64>) -> f32 {
|
||||
var s = 0.0;
|
||||
for (var yy = 0; yy < 8; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 8; xx = xx + 1) {
|
||||
s = s + abs(la[yy * 8 + xx] - imp_fl_tex(bx + xx + dx, by + yy + dy, w, h).y);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// block_flow_window's coarse-to-fine search at one level: one integer vector per tile, seeded by
|
||||
// the previous (coarser) level's vector at the tile and its four neighbours. `aux` is that field
|
||||
// (`P.u[2].x` = 0 at the coarsest level, where the only seed is the zero vector).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_fl_search(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let cols = i32(P.u[0].z);
|
||||
let rows = i32(P.u[0].w);
|
||||
let c = i32(gid.x);
|
||||
let r = i32(gid.y);
|
||||
if (c >= cols || r >= rows) {
|
||||
return;
|
||||
}
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let bs = i32(P.u[1].x);
|
||||
let k = i32(P.u[1].z);
|
||||
let rad = i32(P.u[1].w);
|
||||
let lbs = max(i32(P.u[1].y) / k, 4);
|
||||
let bx = c * bs / k;
|
||||
let by = r * bs / k;
|
||||
// The zero vector plus up to five neighbour seeds (the tile and its four neighbours).
|
||||
var seeds: array<vec2<i32>, 6>;
|
||||
var ns = 0;
|
||||
seeds[ns] = vec2<i32>(0, 0);
|
||||
ns = ns + 1;
|
||||
if (P.u[2].x != 0u) {
|
||||
var nbr = array<vec2<i32>, 5>(vec2<i32>(0, 0), vec2<i32>(-1, 0), vec2<i32>(1, 0), vec2<i32>(0, -1), vec2<i32>(0, 1));
|
||||
for (var j = 0; j < 5; j = j + 1) {
|
||||
let nc = c + nbr[j].x;
|
||||
let nr = r + nbr[j].y;
|
||||
if (nc >= 0 && nr >= 0 && nc < cols && nr < rows) {
|
||||
let pv = textureLoad(aux, vec2<i32>(nc, nr), 0);
|
||||
seeds[ns] = vec2<i32>(i32(round(pv.x)) * 2, i32(round(pv.y)) * 2);
|
||||
ns = ns + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// sort_unstable + dedup: distinct seeds in lexicographic (x, then y) order.
|
||||
for (var i = 1; i < ns; i = i + 1) {
|
||||
let key = seeds[i];
|
||||
var j = i - 1;
|
||||
while (j >= 0 && (seeds[j].x > key.x || (seeds[j].x == key.x && seeds[j].y > key.y))) {
|
||||
seeds[j + 1] = seeds[j];
|
||||
j = j - 1;
|
||||
}
|
||||
seeds[j + 1] = key;
|
||||
}
|
||||
var m = 0;
|
||||
for (var i = 0; i < ns; i = i + 1) {
|
||||
if (m == 0 || seeds[i].x != seeds[m - 1].x || seeds[i].y != seeds[m - 1].y) {
|
||||
seeds[m] = seeds[i];
|
||||
m = m + 1;
|
||||
}
|
||||
}
|
||||
ns = m;
|
||||
// A's block is the same for every candidate, so load it once (the unrolled loops keep it in
|
||||
// registers) and the search only reads B's shifted block.
|
||||
var la4: array<f32, 16>;
|
||||
var la8: array<f32, 64>;
|
||||
if (lbs >= 8) {
|
||||
for (var yy = 0; yy < 8; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 8; xx = xx + 1) {
|
||||
la8[yy * 8 + xx] = imp_fl_tex(bx + xx, by + yy, w, h).x;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (var yy = 0; yy < 4; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 4; xx = xx + 1) {
|
||||
la4[yy * 4 + xx] = imp_fl_tex(bx + xx, by + yy, w, h).x;
|
||||
}
|
||||
}
|
||||
}
|
||||
var best = vec2<i32>(0, 0);
|
||||
var best_e = 1e30;
|
||||
for (var si = 0; si < ns; si = si + 1) {
|
||||
let s = seeds[si];
|
||||
for (var dy = -rad; dy <= rad; dy = dy + 1) {
|
||||
for (var dx = -rad; dx <= rad; dx = dx + 1) {
|
||||
let d0 = s.x + dx;
|
||||
let d1 = s.y + dy;
|
||||
var acc = 0.0;
|
||||
if (lbs >= 8) {
|
||||
for (var yy = 0; yy < 8; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 8; xx = xx + 1) {
|
||||
acc = acc + abs(la8[yy * 8 + xx] - imp_fl_tex(bx + xx + d0, by + yy + d1, w, h).y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (var yy = 0; yy < 4; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 4; xx = xx + 1) {
|
||||
acc = acc + abs(la4[yy * 4 + xx] - imp_fl_tex(bx + xx + d0, by + yy + d1, w, h).y);
|
||||
}
|
||||
}
|
||||
}
|
||||
let e = acc + 1e-4 * f32(abs(d0) + abs(d1));
|
||||
if (e < best_e) {
|
||||
best_e = e;
|
||||
best = vec2<i32>(d0, d1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
textureStore(out, vec2<i32>(c, r), vec4<f32>(f32(best.x), f32(best.y), 0.0, 0.0));
|
||||
}
|
||||
|
||||
// The parabola fit of the SAD around the best vector (block_flow_window's sub-pixel step).
|
||||
fn imp_fl_fit(em: f32, ep: f32, e0: f32) -> f32 {
|
||||
let den = em - 2.0 * e0 + ep;
|
||||
if (den > 1e-6) {
|
||||
return clamp(0.5 * (em - ep) / den, -0.5, 0.5);
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Sub-pixel refinement at full resolution: `aux` is the integer field of level 0, `src` the
|
||||
// level-0 packed luma (`P.u[1]` = (tile, window, 0, 0)).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_fl_subpix(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let cols = i32(P.u[0].z);
|
||||
let rows = i32(P.u[0].w);
|
||||
let c = i32(gid.x);
|
||||
let r = i32(gid.y);
|
||||
if (c >= cols || r >= rows) {
|
||||
return;
|
||||
}
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let bs = i32(P.u[1].x);
|
||||
let win = i32(P.u[1].y);
|
||||
let bx = c * bs;
|
||||
let by = r * bs;
|
||||
let f = textureLoad(aux, vec2<i32>(c, r), 0);
|
||||
let d = vec2<i32>(i32(round(f.x)), i32(round(f.y)));
|
||||
// The window's A block is the same for the five SADs: load it once (the loop is unrolled).
|
||||
var la: array<f32, 64>;
|
||||
for (var yy = 0; yy < 8; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 8; xx = xx + 1) {
|
||||
la[yy * 8 + xx] = imp_fl_tex(bx + xx, by + yy, w, h).x;
|
||||
}
|
||||
}
|
||||
let e0 = imp_fl_sad8h(bx, by, d.x, d.y, w, h, la);
|
||||
let sx = imp_fl_fit(imp_fl_sad8h(bx, by, d.x - 1, d.y, w, h, la), imp_fl_sad8h(bx, by, d.x + 1, d.y, w, h, la), e0);
|
||||
let sy = imp_fl_fit(imp_fl_sad8h(bx, by, d.x, d.y - 1, w, h, la), imp_fl_sad8h(bx, by, d.x, d.y + 1, w, h, la), e0);
|
||||
textureStore(out, vec2<i32>(c, r), vec4<f32>(f32(d.x) + sx, f32(d.y) + sy, 0.0, 0.0));
|
||||
}
|
||||
|
||||
// The 3×3 component-wise vector median against outliers (each component sorted on its own).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_fl_median(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let cols = i32(P.u[0].z);
|
||||
let rows = i32(P.u[0].w);
|
||||
let c = i32(gid.x);
|
||||
let r = i32(gid.y);
|
||||
if (c >= cols || r >= rows) {
|
||||
return;
|
||||
}
|
||||
var xs: array<f32, 9>;
|
||||
var ys: array<f32, 9>;
|
||||
var n = 0;
|
||||
for (var dr = -1; dr <= 1; dr = dr + 1) {
|
||||
for (var dc = -1; dc <= 1; dc = dc + 1) {
|
||||
let nc = c + dc;
|
||||
let nr = r + dr;
|
||||
if (nc >= 0 && nr >= 0 && nc < cols && nr < rows) {
|
||||
let v = textureLoad(src, vec2<i32>(nc, nr), 0);
|
||||
xs[n] = v.x;
|
||||
ys[n] = v.y;
|
||||
n = n + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var i = 1; i < n; i = i + 1) {
|
||||
let kx = xs[i];
|
||||
var j = i - 1;
|
||||
while (j >= 0 && xs[j] > kx) {
|
||||
xs[j + 1] = xs[j];
|
||||
j = j - 1;
|
||||
}
|
||||
xs[j + 1] = kx;
|
||||
let ky = ys[i];
|
||||
j = i - 1;
|
||||
while (j >= 0 && ys[j] > ky) {
|
||||
ys[j + 1] = ys[j];
|
||||
j = j - 1;
|
||||
}
|
||||
ys[j + 1] = ky;
|
||||
}
|
||||
textureStore(out, vec2<i32>(c, r), vec4<f32>(xs[n / 2], ys[n / 2], 0.0, 0.0));
|
||||
}
|
||||
@@ -53,12 +53,12 @@ fn ps_norm3(v: vec3<f32>) -> vec3<f32> {
|
||||
fn ps_rand_dir(i: u32, k: u32, flat: bool) -> vec3<f32> {
|
||||
if (flat) {
|
||||
let a = ps_h(i, k) * PS_TAU;
|
||||
return vec3<f32>(cos(a), sin(a), 0.0);
|
||||
return vec3<f32>(cos_p(a), sin_p(a), 0.0);
|
||||
}
|
||||
let z = ps_hs(i, k);
|
||||
let a = ps_h(i, k + 1u) * PS_TAU;
|
||||
let r = sqrt(max(1.0 - z * z, 0.0));
|
||||
return vec3<f32>(r * cos(a), r * sin(a), z);
|
||||
return vec3<f32>(r * cos_p(a), r * sin_p(a), z);
|
||||
}
|
||||
|
||||
struct Part {
|
||||
@@ -189,7 +189,7 @@ fn ps_spawn_cannon(id: u32) -> Part {
|
||||
let vel = S.f[1].y;
|
||||
let vel_spread = S.f[1].z;
|
||||
let a = radians(angle + ps_hs(id, 1u) * spread * 0.5);
|
||||
let d = vec2<f32>(sin(a), -cos(a));
|
||||
let d = vec2<f32>(sin_p(a), -cos_p(a));
|
||||
let speed = max(vel + ps_hs(id, 3u) * vel_spread, 0.0);
|
||||
var off: vec2<f32>;
|
||||
if (r >= 0.0) {
|
||||
@@ -197,7 +197,7 @@ fn ps_spawn_cannon(id: u32) -> Part {
|
||||
} else {
|
||||
let aa = ps_h(id, 5u) * PS_TAU;
|
||||
let rr = sqrt(ps_h(id, 6u)) * -r;
|
||||
off = vec2<f32>(cos(aa) * rr, sin(aa) * rr);
|
||||
off = vec2<f32>(cos_p(aa) * rr, sin_p(aa) * rr);
|
||||
}
|
||||
let v = d * speed;
|
||||
let dt = S.f[2].z;
|
||||
|
||||
+629
-3
@@ -52,10 +52,136 @@ mod fx_vr;
|
||||
mod fx_warp;
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod init;
|
||||
mod inpaint;
|
||||
mod particles;
|
||||
mod three_d;
|
||||
mod wgsl_grammar;
|
||||
|
||||
pub(crate) const MATH_PROBE_SHADER: &str = r#"
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn math_probe(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (p.x >= out_dims().x || p.y >= out_dims().y) { return; }
|
||||
let v = textureLoad(src, p, 0).x;
|
||||
if (P.u[0].x == 1u) {
|
||||
let a = textureLoad(src, p, 0).w;
|
||||
let q = unit_div(v, a);
|
||||
textureStore(out, p, vec4<f32>(q, fxs_posterize_bin(q, P.u[0].y), 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
if (P.u[0].x == 2u) {
|
||||
textureStore(out, p, vec4<f32>(round_away(v), 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
textureStore(out, p, vec4<f32>(sin_p(v), cos_p(v), 0.0, 1.0));
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Direction errors accumulate into visible particle offsets and spherical
|
||||
/// remapping errors. Check the numerical primitive before rendering a scene.
|
||||
#[test]
|
||||
fn angular_math_preserves_particle_and_vr_directions() {
|
||||
let Some(g) = gpu() else { return };
|
||||
for extent in [std::f32::consts::TAU, 100.0, 8192.0] {
|
||||
let mut img = Image::new(4097, 1);
|
||||
for (i, px) in img.data.iter_mut().enumerate() {
|
||||
px[0] = (i as f32 - 2048.0) * extent / 2048.0;
|
||||
}
|
||||
let input = g.ctx.upload_image(&img).unwrap();
|
||||
let mut e = crate::context::Enc::new(&g.ctx);
|
||||
let out = e.scratch(img.width, img.height);
|
||||
e.pixels("math_probe", &Default::default(), &input, None, &out, None);
|
||||
let result = e.download(&out).unwrap();
|
||||
for (i, (v, got)) in img.data.iter().zip(result.data).enumerate() {
|
||||
let want = [(v[0] as f64).sin() as f32, (v[0] as f64).cos() as f32];
|
||||
for c in 0..2 {
|
||||
assert!((want[c] - got[c]).abs() <= 2e-7, "sample {i}, angle {}: CPU {want:?}, GPU {got:?}", v[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_division_and_posterization_match_f32_rounding() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let mut values = vec![];
|
||||
for levels in [1u32, 3, 8, 254] {
|
||||
values.clear();
|
||||
for a in [1.0f32, 0.1, 0.3, 1.0 / 255.0, 6.0 / 255.0] {
|
||||
for i in 0..levels {
|
||||
let half = (i as f32 + 0.5) / levels as f32;
|
||||
for v in [0.0, 1e-6, half.next_down(), half, half.next_up(), 1.0] {
|
||||
values.push([v * a, 0.0, 0.0, a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let img = Image { width: values.len() as u32, height: 1, data: values.clone() };
|
||||
let input = g.ctx.upload_image(&img).unwrap();
|
||||
let mut e = crate::context::Enc::new(&g.ctx);
|
||||
let out = e.scratch(img.width, 1);
|
||||
let mut params = crate::context::Params::default();
|
||||
params.u[0] = [1, levels, 0, 0];
|
||||
e.pixels("math_probe", ¶ms, &input, None, &out, None);
|
||||
let result = e.download(&out).unwrap();
|
||||
for (i, (v, got)) in values.iter().zip(result.data).enumerate() {
|
||||
let q = (v[0] / v[3]).clamp(0.0, 1.0);
|
||||
assert_eq!(q.to_bits(), got[0].to_bits(), "levels {levels}, pixel {i}, input {v:?}");
|
||||
assert_eq!((q * levels as f32).round(), got[1], "levels {levels}, pixel {i}, input {v:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `round_away` is `f32::round`, also from 2^23 up where every value is already an integer
|
||||
/// (integer IDs there must not merge: 8388608 and 8388609 both rounded to 8388609).
|
||||
#[test]
|
||||
fn round_away_matches_f32_round_for_large_values() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let mut values = vec![];
|
||||
for m in [1.0f32, -1.0] {
|
||||
for v in [0.0f32, 0.5, 1.5, 2.5, 0.49999997, 4194303.5, 8388607.5, 8388608.0, 8388609.0, 8388610.0, 16777216.0, 3.0e9] {
|
||||
values.push([m * v, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let img = Image { width: values.len() as u32, height: 1, data: values.clone() };
|
||||
let input = g.ctx.upload_image(&img).unwrap();
|
||||
let mut e = crate::context::Enc::new(&g.ctx);
|
||||
let out = e.scratch(img.width, 1);
|
||||
let mut params = crate::context::Params::default();
|
||||
params.u[0] = [2, 0, 0, 0];
|
||||
e.pixels("math_probe", ¶ms, &input, None, &out, None);
|
||||
let result = e.download(&out).unwrap();
|
||||
for (v, got) in values.iter().zip(result.data) {
|
||||
assert_eq!(v[0].round(), got[0], "round_away({})", v[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prove that the numerical fixes run on the device, without per-effect image
|
||||
/// downloads or CPU fallback. Compiler-limited DX12 devices retain their tested
|
||||
/// fallback contract; these kernels must compile on the Vulkan test adapters.
|
||||
#[test]
|
||||
fn numerical_effects_remain_resident_on_vulkan() {
|
||||
let Some(g) = gpu() else { return };
|
||||
if !g.ctx.name.ends_with("(Vulkan)") {
|
||||
return;
|
||||
}
|
||||
for entry in ["fxs_point", "fxt_point", "ftr_grid", "fp2_hextile"] {
|
||||
assert!(g.ctx.has_kernel(entry), "{entry}: {:?}", g.ctx.unavailable_kernels());
|
||||
}
|
||||
for (id, vals) in [
|
||||
("ec.stylize.posterize", vec![("level", n(4.0))]),
|
||||
("ec.color.psarbitrarymap", vec![("phase", n(40.0))]),
|
||||
("ec.transition.ccgridwipe", vec![("completion", n(40.0))]),
|
||||
("ec.stylize.cchextile", vec![("radius", n(9.3))]),
|
||||
] {
|
||||
let before = g.ctx.transfer_stats();
|
||||
effect_direct(id, &vals, false);
|
||||
let after = g.ctx.transfer_stats();
|
||||
// Full- and half-resolution runs each upload once and download once.
|
||||
assert_eq!(after.uploads - before.uploads, 2, "{id}: uploads");
|
||||
assert_eq!(after.readbacks - before.readbacks, 2, "{id}: readbacks");
|
||||
}
|
||||
}
|
||||
|
||||
/// One GPU test at a time in this process: the calling test thread holds a process-wide lock
|
||||
/// until it exits (libtest runs every test on a thread of its own). wgpu's OpenGL backend (Mesa
|
||||
/// llvmpipe on FreeBSD, Linux without Vulkan) has one context lock per adapter and panics when a
|
||||
@@ -784,7 +910,7 @@ fn display_frames_are_mipmapped() {
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut e = crate::context::Enc::new(&g.ctx);
|
||||
e.encoder().copy_texture_to_texture(
|
||||
e.record(&[]).copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture: &f.texture, mip_level: 1, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::Extent3d { width: w1, height: h1, depth_or_array_layers: 1 },
|
||||
@@ -920,7 +1046,7 @@ fn a_failed_frame_returns_no_texture_to_the_pool() {
|
||||
let rejected = rejected_texture(&g);
|
||||
let earlier = g.within_memory(|| crate::context::Enc::new(&g.ctx).image(48, 16)).expect("an earlier frame");
|
||||
let mut other = crate::context::Enc::new(&g.ctx);
|
||||
other.encoder();
|
||||
other.record(&[]);
|
||||
g.within_memory(|| (crate::context::Enc::new(&g.ctx).image(64, 32), rejected.create_view(&Default::default()))).expect_err("the frame fails");
|
||||
drop(earlier);
|
||||
drop(other);
|
||||
@@ -1009,7 +1135,7 @@ fn effects_whose_kernels_failed_render_on_the_cpu() {
|
||||
ctx.drop_kernel("pointwise");
|
||||
ctx.drop_kernel("box_h");
|
||||
let names: Vec<&str> = ctx.unavailable_kernels().iter().map(|(e, _)| *e).collect();
|
||||
assert_eq!(names, ["pointwise", "box_h"]);
|
||||
assert_eq!(names, ["box_h", "pointwise"]);
|
||||
let g = Gpu::from_context(ctx);
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
let mut s = Scene::new(depth);
|
||||
@@ -1029,6 +1155,24 @@ fn effects_whose_kernels_failed_render_on_the_cpu() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1590: effect kernels are built on first use, not with the context (building all of them
|
||||
/// up front kept Chrome's GPU process busy past its watchdog). Once asked for, every one of
|
||||
/// them builds on this device: a kernel no test dispatches still can't break unnoticed.
|
||||
#[test]
|
||||
fn effect_kernels_build_on_first_use() {
|
||||
if gpu().is_none() {
|
||||
return;
|
||||
}
|
||||
let Ok(ctx) = pollster::block_on(crate::context::GpuContext::request()) else { return };
|
||||
let built = |ctx: &crate::context::GpuContext| ctx.effect_pipelines_built();
|
||||
assert_eq!(built(&ctx), 0, "no effect kernel is built with the context");
|
||||
let started = std::time::Instant::now();
|
||||
ctx.build_all_kernels();
|
||||
eprintln!("{}: built {} effect kernels in {:.2?}", ctx.name, built(&ctx), started.elapsed());
|
||||
assert_eq!(ctx.unavailable_kernels(), vec![], "every effect kernel builds on {}", ctx.name);
|
||||
assert!(ctx.has_kernel("classic3d") && ctx.has_kernel("math_probe"), "the up-front kernels build on {}", ctx.name);
|
||||
}
|
||||
|
||||
/// WGSL cannot read the CPU's constants, so Glow's bright pass repeats them as literals; this
|
||||
/// ties them to `effects::GLOW_GAIN` / `GLOW_KNEE` (runs without a GPU).
|
||||
#[test]
|
||||
@@ -1315,3 +1459,485 @@ fn owned_devices_favour_memory_usage() {
|
||||
assert_eq!(d.label, Some("effectcraft gpu"));
|
||||
assert_eq!(d.required_limits.max_texture_dimension_2d, wgpu::Limits::default().max_texture_dimension_2d);
|
||||
}
|
||||
|
||||
/// A GPU context of its own with a small pool budget, so other tests (sharing [`gpu`]) neither
|
||||
/// see the budget nor add to what the pool keeps.
|
||||
fn private_gpu(budget: usize) -> Option<Gpu> {
|
||||
gpu()?;
|
||||
let g = Gpu::headless()?;
|
||||
g.context().set_pool_budget(budget);
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Three effect calls while a frame encoder is open with recorded work reuse one set of
|
||||
/// textures: lent calls give them back at once, without waiting for that frame to submit.
|
||||
#[test]
|
||||
fn three_effect_calls_in_a_frame_reuse_one_set_of_textures() {
|
||||
let Some(g) = private_gpu(8 << 20) else { return };
|
||||
let ctx = g.context();
|
||||
let mut frame = crate::context::Enc::new(ctx);
|
||||
// Open the frame encoder with real work so the pool has an open id (an empty Enc::new does not).
|
||||
let src = frame.image(64, 64);
|
||||
let dst = frame.image(64, 64);
|
||||
frame.copy_into(&src, &dst, 0, 0);
|
||||
let (hits0, misses0) = ctx.pool_stats();
|
||||
for _ in 0..3 {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
let a = e.image(256, 256);
|
||||
let b = e.image(256, 256);
|
||||
drop((a, b));
|
||||
drop(e);
|
||||
}
|
||||
let (hits, misses) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0);
|
||||
assert_eq!(misses, 2, "only the first effect call allocates: {hits} hits, {misses} misses");
|
||||
assert_eq!(hits, 4, "calls 2 and 3 reuse both textures: {hits} hits, {misses} misses");
|
||||
frame.submit();
|
||||
drop((src, dst, frame));
|
||||
}
|
||||
|
||||
/// Review of #1523: after the first frame fills the free list, later identical frames hit
|
||||
/// instead of allocating again.
|
||||
#[test]
|
||||
fn a_repeated_frame_reuses_after_the_first() {
|
||||
let Some(g) = private_gpu(8 << 20) else { return };
|
||||
let ctx = g.context();
|
||||
let frame = || {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
drop((e.image(256, 256), e.image(128, 64)));
|
||||
drop(e);
|
||||
ctx.pool_frame_end();
|
||||
};
|
||||
let (hits0, misses0) = ctx.pool_stats();
|
||||
frame();
|
||||
let (hits1, misses1) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0);
|
||||
assert_eq!(misses1, 2, "first frame allocates both sizes: {hits1} hits, {misses1} misses");
|
||||
let (hits_a, misses_a) = ctx.pool_stats();
|
||||
for _ in 0..3 {
|
||||
frame();
|
||||
}
|
||||
let (hits, misses) = (ctx.pool_stats().0 - hits_a, ctx.pool_stats().1 - misses_a);
|
||||
assert_eq!(misses, 0, "frames 2..4 allocate nothing: {hits} hits, {misses} misses");
|
||||
assert!(hits >= 6, "frames 2..4 reuse both sizes: {hits} hits");
|
||||
}
|
||||
|
||||
/// Review of #1523: every idle effect-call owner kept up to the pool budget of its own,
|
||||
/// outside the shared accounting, and there are as many as calls ever ran at once (16 frame
|
||||
/// threads: ~12 GB after a 4K render). Finished calls share the free list's one budget, and
|
||||
/// the end of a render drops it.
|
||||
#[test]
|
||||
fn idle_effect_calls_share_one_pool_budget() {
|
||||
let budget = 8 << 20;
|
||||
let Some(g) = private_gpu(budget) else { return };
|
||||
let ctx = g.context();
|
||||
// 16 effect calls in flight at once, each with three 1 MB working textures.
|
||||
let mut encs: Vec<_> = (0..16).map(|_| crate::context::Enc::lent(ctx)).collect();
|
||||
for e in &mut encs {
|
||||
let imgs: Vec<_> = (0..3).map(|_| e.image(256, 256)).collect();
|
||||
drop(imgs);
|
||||
}
|
||||
drop(encs);
|
||||
let kept = ctx.pool_retained_bytes();
|
||||
assert!(kept <= budget, "idle effect calls keep {} MB, budget {} MB", kept >> 20, budget >> 20);
|
||||
// A later call reuses them instead of allocating.
|
||||
let (hits, _) = ctx.pool_stats();
|
||||
drop(crate::context::Enc::lent(ctx).image(256, 256));
|
||||
assert_eq!(ctx.pool_stats().0, hits + 1, "a later effect call reuses a kept texture");
|
||||
effectcraft_render::Accelerator::render_end(&g);
|
||||
assert_eq!(ctx.pool_retained_bytes(), 0, "the end of a render drops the kept textures");
|
||||
}
|
||||
|
||||
/// Review of #1523: a full free list rejected every new texture instead of evicting, so once
|
||||
/// sizes that no longer occur (per-frame blur bounds, padded layer sizes) filled it, a steady
|
||||
/// size never got a hit again: ~0.75 GB of dead weight plus an allocation per working texture.
|
||||
#[test]
|
||||
fn stale_sizes_do_not_starve_a_steady_size() {
|
||||
let budget = 8 << 20;
|
||||
let Some(g) = private_gpu(budget) else { return };
|
||||
let ctx = g.context();
|
||||
for k in 0..16 {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
drop((e.image(256 + k, 256), e.image(256 + k, 256)));
|
||||
drop(e);
|
||||
ctx.pool_frame_end();
|
||||
}
|
||||
let (hits0, misses0) = ctx.pool_stats();
|
||||
for _ in 0..10 {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
drop((e.image(300, 300), e.image(300, 300)));
|
||||
drop(e);
|
||||
ctx.pool_frame_end();
|
||||
}
|
||||
let (hits, misses) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0);
|
||||
assert!(ctx.pool_retained_bytes() <= budget);
|
||||
assert!(ctx.pool_evictions() > 0, "the stale sizes were evicted");
|
||||
assert!(hits >= 18 && misses <= 2, "steady 300x300 calls after stale sizes: {hits} hits, {misses} misses");
|
||||
}
|
||||
|
||||
/// Review of #1523: a `pool_frame_end` wait that clears none of the evicted bytes (timeout, or
|
||||
/// callbacks that never run) must not re-wait on every later frame while `dying` stays put.
|
||||
#[test]
|
||||
fn a_wait_without_progress_is_not_repeated() {
|
||||
let Some(g) = private_gpu(8 << 20) else { return };
|
||||
let ctx = g.context();
|
||||
let phantom = 1 << 20;
|
||||
ctx.pool_charge_dying(phantom as isize);
|
||||
ctx.pool_frame_end();
|
||||
assert_eq!(ctx.pool_waits(), 1, "first frame with dying bytes waits once");
|
||||
for _ in 0..3 {
|
||||
ctx.pool_frame_end();
|
||||
}
|
||||
assert_eq!(ctx.pool_waits(), 1, "no progress: later frames must not wait again");
|
||||
ctx.pool_charge_dying(-(phantom as isize));
|
||||
ctx.pool_frame_end();
|
||||
assert_eq!(ctx.pool_waits(), 1, "dying cleared: poll only, stall cleared");
|
||||
ctx.pool_charge_dying(phantom as isize);
|
||||
ctx.pool_frame_end();
|
||||
assert_eq!(ctx.pool_waits(), 2, "a new stall waits once");
|
||||
// Partial progress: more dying while stalled → still no wait; drop below stalled → wait.
|
||||
let extra = phantom / 2;
|
||||
ctx.pool_charge_dying(extra as isize);
|
||||
ctx.pool_frame_end();
|
||||
assert_eq!(ctx.pool_waits(), 2, "dying above stalled: poll only");
|
||||
ctx.pool_charge_dying(-(extra as isize + 1));
|
||||
ctx.pool_frame_end();
|
||||
assert_eq!(ctx.pool_waits(), 3, "dying dropped below stalled: wait again");
|
||||
ctx.pool_charge_dying(-((phantom - 1) as isize));
|
||||
}
|
||||
|
||||
/// RTX 3090 4K runs of #1523: evicting the least recently freed texture to fit every newcomer
|
||||
/// thrashes when a frame frees more than the budget in a repeating order (blur/glow
|
||||
/// intermediates): each free evicts the size the next call takes first, so every take
|
||||
/// missed (d_blurglow misses back at the old level, and evict + reallocate raised the peak).
|
||||
/// Sizes still in use stay; a kept subset of the cycle must keep hitting.
|
||||
#[test]
|
||||
fn repeating_sizes_over_the_budget_keep_hitting() {
|
||||
let budget = 8 << 20;
|
||||
let Some(g) = private_gpu(budget) else { return };
|
||||
let ctx = g.context();
|
||||
// Five ~2.1 MB sizes a frame (10.5 MB > 8 MB), one effect call each, same order every frame.
|
||||
let sizes: Vec<(u32, u32)> = (0..5).map(|k| (256 + 8 * k, 512)).collect();
|
||||
let frame = || {
|
||||
for &(w, h) in &sizes {
|
||||
drop(crate::context::Enc::lent(ctx).image(w, h));
|
||||
}
|
||||
ctx.pool_frame_end();
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame();
|
||||
}
|
||||
let (hits0, misses0) = ctx.pool_stats();
|
||||
let evicted0 = ctx.pool_evictions();
|
||||
for _ in 0..10 {
|
||||
frame();
|
||||
}
|
||||
let (hits, misses) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0);
|
||||
assert!(ctx.pool_retained_bytes() <= budget);
|
||||
assert!(hits >= 30, "10 frames of a 5-size cycle over the budget: {hits} hits, {misses} misses");
|
||||
assert_eq!(ctx.pool_evictions(), evicted0, "sizes in use were evicted");
|
||||
}
|
||||
|
||||
/// Review of #1523: a lent call's image read by another, still open encoder must not go back to
|
||||
/// the lent call for reuse when its lease drops first: that encoder's read is not submitted yet.
|
||||
#[test]
|
||||
fn image_read_by_an_open_encoder_is_not_reused_by_its_lent_call() {
|
||||
let Some(g) = private_gpu(8 << 20) else { return };
|
||||
let ctx = g.context();
|
||||
let mut frame = crate::context::Enc::new(ctx);
|
||||
let mut lent = crate::context::Enc::lent(ctx);
|
||||
let img = lent.image(64, 64);
|
||||
let dst = frame.image(64, 64);
|
||||
frame.copy_into(&img, &dst, 0, 0);
|
||||
drop(img);
|
||||
let (hits, _) = ctx.pool_stats();
|
||||
drop(lent.image(64, 64));
|
||||
assert_eq!(ctx.pool_stats().0, hits, "the lent call reused a texture an open encoder still reads");
|
||||
drop(lent);
|
||||
let (hits, _) = ctx.pool_stats();
|
||||
drop(crate::context::Enc::lent(ctx).image(64, 64));
|
||||
// The other 64x64 (the lent call's second image) is free; the read one still waits.
|
||||
let (hits2, _) = ctx.pool_stats();
|
||||
assert_eq!(hits2, hits + 1);
|
||||
let (h, _) = ctx.pool_stats();
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
let a = e.image(64, 64);
|
||||
let b = e.image(64, 64);
|
||||
assert_eq!(ctx.pool_stats().0, h + 1, "the read texture waits for the open encoder");
|
||||
drop((a, b, e));
|
||||
frame.submit();
|
||||
drop(dst);
|
||||
}
|
||||
|
||||
/// The same through [`Gpu::effects`] from 16 threads at once, as an export's frame threads.
|
||||
#[test]
|
||||
fn effect_calls_on_16_threads_keep_one_pool_budget() {
|
||||
if on_gl() {
|
||||
return; // one context lock per adapter on wgpu's GL backend (see hold_gpu_lock)
|
||||
}
|
||||
let budget = 8 << 20;
|
||||
let Some(g) = private_gpu(budget) else { return };
|
||||
let g = &g;
|
||||
let ctx = g.context();
|
||||
let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap();
|
||||
let size = [240.0, 240.0];
|
||||
let mut params =
|
||||
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
|
||||
params.values.insert("blurriness".into(), Value::Scalar(6.0));
|
||||
let buf = effectcraft_effects::Buf { img: pattern(3, 240, 240), offset: [0.0, 0.0], scale: 1.0 };
|
||||
let go = std::sync::Barrier::new(16);
|
||||
std::thread::scope(|s| {
|
||||
for i in 0..16 {
|
||||
let (params, buf, go) = (¶ms, &buf, &go);
|
||||
s.spawn(move || {
|
||||
go.wait();
|
||||
for _ in 0..3 {
|
||||
let fx = effectcraft_effects::EffectCtx { params, time: 0.0, layer_size: size, seed: i, adjustment: false, env: Default::default() };
|
||||
effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: fx }], buf, None).expect("the GPU runs the effect");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let kept = ctx.pool_retained_bytes();
|
||||
assert!(kept <= budget, "16 threads of effect calls keep {} MB, budget {} MB", kept >> 20, budget >> 20);
|
||||
effectcraft_render::Accelerator::render_end(g);
|
||||
assert_eq!(ctx.pool_retained_bytes(), 0, "the end of a render drops the kept textures");
|
||||
}
|
||||
|
||||
/// Sceptic r2 of #1523: only the export pipeline ended frames, so off export (preview, remote
|
||||
/// frames) the pool's clock stood still, no size ever went stale and a full free list dropped
|
||||
/// every newcomer again. Frames rendered through [`effectcraft_render::passes::comp_frame`]
|
||||
/// (no explicit `pool_frame_end`) must age the sizes they did not ask for.
|
||||
#[test]
|
||||
fn frames_outside_the_export_pipeline_age_the_pool() {
|
||||
let budget = 8 << 20;
|
||||
let Some(g) = private_gpu(budget) else { return };
|
||||
let ctx = g.context();
|
||||
// Fill the free list with sizes no frame asks for again.
|
||||
for k in 0..16 {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
drop((e.image(256 + k, 256), e.image(256 + k, 256)));
|
||||
}
|
||||
let evicted0 = ctx.pool_evictions();
|
||||
let s = blend_scene(BitDepth::Bpc8, BlendMode::Screen);
|
||||
let mut r = Renderer::new(&s.p, &Pattern, opts());
|
||||
r.accel = Some(&g);
|
||||
for _ in 0..3 {
|
||||
effectcraft_render::passes::block_on(effectcraft_render::passes::comp_frame(&r, s.cid, Tick::ZERO));
|
||||
}
|
||||
for _ in 0..4 {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
drop((e.image(300, 300), e.image(300, 300)));
|
||||
}
|
||||
assert!(ctx.pool_retained_bytes() <= budget);
|
||||
assert!(ctx.pool_evictions() > evicted0, "rendered frames did not age the pool: stale sizes were never evicted");
|
||||
}
|
||||
|
||||
/// #1550: with a small `EFFECTCRAFT_GPU_MEMCAP_MB` on a backend that reports allocator usage
|
||||
/// (Vulkan, DX12), `Enc::mem_gate` stays quiet when the bytes over the cap are non-reclaimable
|
||||
/// (live uploads), submits and waits when dropped working textures push reclaimable bytes over
|
||||
/// the cap, carries on without retiring the context if a wait times out, warns once when a
|
||||
/// frame's kept memory alone exceeds the cap, waits between two frames that fit only one at a
|
||||
/// time, and recovers budget when a frame is dropped unfinished.
|
||||
#[test]
|
||||
fn mem_gate_reclaims_over_cap_and_tolerates_timeout() {
|
||||
const ENV: &str = "EFFECTCRAFT_GPU_MEMCAP_TEST_CHILD";
|
||||
const SKIP: &str = "mem_gate_reclaims_over_cap_and_tolerates_timeout: skipped";
|
||||
if std::env::var_os(ENV).is_none() {
|
||||
let exe = std::env::current_exe().expect("test binary path");
|
||||
let out = std::process::Command::new(exe)
|
||||
.env(ENV, "1")
|
||||
.env("EFFECTCRAFT_GPU_MEMCAP_MB", "8")
|
||||
.args(["--exact", "tests::mem_gate_reclaims_over_cap_and_tolerates_timeout", "--nocapture"])
|
||||
.output()
|
||||
.expect("spawn child test process");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"child test failed (status {:?}):\nstdout:\n{}\nstderr:\n{}",
|
||||
out.status.code(),
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
);
|
||||
// The child's output is captured: pass its skip line on.
|
||||
String::from_utf8_lossy(&out.stderr).lines().filter(|l| l.starts_with(SKIP)).for_each(|l| eprintln!("{l}"));
|
||||
return;
|
||||
}
|
||||
|
||||
// EFFECTCRAFT_REQUIRE_GPU=1 (inherited from the parent) fails on a GPU that didn't start.
|
||||
let g = Gpu::try_headless();
|
||||
let Some(g) = require_gpu("mem_gate child", g.as_ref()) else {
|
||||
eprintln!("{SKIP}: no GPU ({})", g.as_ref().err().map_or("", String::as_str));
|
||||
return;
|
||||
};
|
||||
let ctx = g.context();
|
||||
if ctx.allocated_bytes() == 0 {
|
||||
// The backend has no allocator report (Metal, GL, WebGPU): the gate never engages.
|
||||
eprintln!("{SKIP}: adapter {} reports no allocator usage", ctx.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Non-reclaimable memory (upload cache) over the 8 MiB cap must not trigger waits.
|
||||
let img = pattern(7, 512, 512); // 4 MiB in RGBA f32
|
||||
let held: Vec<_> = (0..3)
|
||||
.map(|i| {
|
||||
let b = std::sync::Arc::new(effectcraft_effects::Buf { img: img.clone(), offset: [i as f64, 0.0], scale: 1.0 });
|
||||
ctx.upload_buf(&b).expect("upload");
|
||||
b
|
||||
})
|
||||
.collect();
|
||||
let idx = ctx.queue.submit([]);
|
||||
let _ = ctx.poll_readbacks(wgpu::PollType::Wait { submission_index: Some(idx), timeout: Some(std::time::Duration::from_secs(1)) });
|
||||
assert!(ctx.allocated_bytes() > (8 << 20), "uploads should put the allocator over the 8 MiB cap (got {} B)", ctx.allocated_bytes());
|
||||
{
|
||||
let mut e = crate::context::Enc::new(ctx);
|
||||
for _ in 0..16 {
|
||||
e.mem_gate();
|
||||
}
|
||||
}
|
||||
assert_eq!(ctx.memcap_waits(), 0, "mem_gate must not wait when over-cap memory is held by live uploads");
|
||||
assert!(ctx.memcap_warned(), "uploads alone over the cap log the one-time warning");
|
||||
drop(held);
|
||||
ctx.clear_uploads();
|
||||
ctx.pool_frame_end();
|
||||
|
||||
// 2. Dropped working textures over the 2 MiB pool budget and 8 MiB cap trigger a wait and
|
||||
// free the dropped textures in wgpu's allocator.
|
||||
let mut e = crate::context::Enc::new(ctx);
|
||||
let mut peak_before_wait = 0u64;
|
||||
for k in 0..16u32 {
|
||||
let a = e.image(512 + k * 16, 256); // ~2 MiB each, distinct sizes
|
||||
let b = e.image(512 + k * 16, 256);
|
||||
e.copy_into(&a, &b, 0, 0);
|
||||
drop((a, b));
|
||||
peak_before_wait = peak_before_wait.max(ctx.allocated_bytes());
|
||||
e.mem_gate();
|
||||
}
|
||||
drop(e);
|
||||
assert!(peak_before_wait > (8 << 20), "working textures should push allocator over 8 MiB before a wait (peak {} B)", peak_before_wait);
|
||||
let waits = ctx.memcap_waits();
|
||||
assert!(waits > 0, "mem_gate should have waited once reclaimable bytes exceeded the cap");
|
||||
assert!(
|
||||
ctx.allocated_bytes() < peak_before_wait,
|
||||
"waiting should reclaim dropped textures (peak {} B, after {} B)",
|
||||
peak_before_wait,
|
||||
ctx.allocated_bytes()
|
||||
);
|
||||
assert!(ctx.check_health().is_ok());
|
||||
ctx.pool_frame_end();
|
||||
|
||||
// 3. A frame bigger than the cap: live GpuImages count in Pool::leased (kept for every owner),
|
||||
// so three live 512×512 RGBA f32 images (12 MiB) make kept alone exceed the 8 MiB cap.
|
||||
// Waiting cannot free them; mem_gate must not add waits (warning already asserted in section 1).
|
||||
let waits_before_big = ctx.memcap_waits();
|
||||
{
|
||||
let mut e = crate::context::Enc::new(ctx);
|
||||
let live = [e.image(512, 512), e.image(512, 512), e.image(512, 512)];
|
||||
for _ in 0..16 {
|
||||
e.mem_gate();
|
||||
}
|
||||
assert_eq!(ctx.memcap_waits(), waits_before_big, "a frame whose live images alone exceed the cap must not wait");
|
||||
assert!(ctx.check_health().is_ok());
|
||||
drop(live);
|
||||
}
|
||||
ctx.pool_frame_end();
|
||||
|
||||
// 4. Two frames that fit only one at a time: frame 1 creates ~6 MiB, copies, submits its
|
||||
// work and drops its images, so wgpu holds them until the submission is done and polled.
|
||||
// Frame 2 keeps ~6 MiB of new live images and waits for frame 1's reclaimable memory.
|
||||
{
|
||||
let mut e1 = crate::context::Enc::new(ctx);
|
||||
let hold = [e1.image(512, 384), e1.image(512, 384)]; // 6 MiB
|
||||
e1.copy_into(&hold[0], &hold[1], 0, 0);
|
||||
e1.submit();
|
||||
drop((hold, e1));
|
||||
}
|
||||
let allocated_after_f1 = ctx.allocated_bytes();
|
||||
let waits_before_pair = ctx.memcap_waits();
|
||||
let allocated_before_gate;
|
||||
let downloaded;
|
||||
{
|
||||
let mut e2 = crate::context::Enc::new(ctx);
|
||||
let a = e2.image(520, 384);
|
||||
let b = e2.image(504, 384); // ~6 MiB together, sizes distinct from each other and frame 1
|
||||
e2.copy_into(&b, &a, 0, 0);
|
||||
allocated_before_gate = ctx.allocated_bytes();
|
||||
assert!(
|
||||
allocated_before_gate > (8 << 20),
|
||||
"frame 1's unreclaimed memory ({allocated_after_f1} B) plus frame 2's live images should exceed the 8 MiB cap (got {allocated_before_gate} B)"
|
||||
);
|
||||
for _ in 0..16 {
|
||||
e2.mem_gate();
|
||||
}
|
||||
assert!(ctx.memcap_waits() > waits_before_pair, "frame 2 should wait for frame 1's reclaimable memory (its own images stay live)");
|
||||
assert!(
|
||||
ctx.allocated_bytes() < allocated_before_gate,
|
||||
"waiting should reclaim frame 1's dropped textures (before {} B, after {} B)",
|
||||
allocated_before_gate,
|
||||
ctx.allocated_bytes()
|
||||
);
|
||||
downloaded = e2.download(&a);
|
||||
}
|
||||
assert!(downloaded.is_some(), "frame 2 must finish with a correct download after waiting");
|
||||
ctx.pool_frame_end();
|
||||
|
||||
// 5. A failed / unfinished frame releases its budget: drop an Enc mid-flight, then a
|
||||
// following frame must not wait once the pool has drained.
|
||||
let baseline = ctx.allocated_bytes();
|
||||
{
|
||||
let mut e = crate::context::Enc::new(ctx);
|
||||
let _hold = [e.image(512, 512), e.image(512, 512), e.image(480, 480)];
|
||||
// Drop without finishing: same path as a frame that errors before submit.
|
||||
}
|
||||
ctx.pool_frame_end();
|
||||
let _ = ctx.poll_readbacks(wgpu::PollType::Poll);
|
||||
assert!(
|
||||
ctx.allocated_bytes() <= baseline + (2 << 20) + (1 << 20),
|
||||
"dropped frame must release to baseline + pool budget (2 MiB) with slack (got {} B, baseline {} B)",
|
||||
ctx.allocated_bytes(),
|
||||
baseline
|
||||
);
|
||||
let waits_after_drop = ctx.memcap_waits();
|
||||
{
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
for _ in 0..16 {
|
||||
e.mem_gate();
|
||||
}
|
||||
}
|
||||
assert_eq!(ctx.memcap_waits(), waits_after_drop, "a following frame must not wait after a dropped frame released its memory");
|
||||
|
||||
// 6. A zero-timeout wait must not retire the context or break subsequent renders.
|
||||
ctx.set_memcap_wait_timeout(std::time::Duration::ZERO);
|
||||
{
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
for k in 0..16u32 {
|
||||
let a = e.image(384 + k * 16, 256);
|
||||
let b = e.image(384 + k * 16, 256);
|
||||
e.copy_into(&a, &b, 0, 0);
|
||||
drop((a, b));
|
||||
e.mem_gate();
|
||||
}
|
||||
}
|
||||
assert!(ctx.memcap_waits() > waits_after_drop, "mem_gate should have fired again under zero timeout");
|
||||
assert!(ctx.check_health().is_ok(), "a mem_gate wait timeout must carry on without retiring the context");
|
||||
let s = blend_scene(BitDepth::Bpc8, BlendMode::Screen);
|
||||
let r = Renderer::new(&s.p, &Pattern, opts());
|
||||
assert!(g.render(&r, s.cid, Tick::ZERO).is_some(), "context renders normally after a zero-timeout mem_gate wait");
|
||||
|
||||
// 7. Non-blocking polls (`Gpu::poll`, `pool_frame_end`) skip when `poll_lock` is held by
|
||||
// another thread, and `read_buffer` waits for `poll_lock` without consuming its deadline.
|
||||
// Drain any dying pool textures first so the timed path takes Poll (try_lock) rather than Wait.
|
||||
ctx.set_memcap_wait_timeout(crate::readback::TIMEOUT);
|
||||
ctx.pool_frame_end();
|
||||
let (locked_tx, locked_rx) = std::sync::mpsc::channel();
|
||||
std::thread::scope(|sc| {
|
||||
sc.spawn(|| {
|
||||
let _guard = ctx.lock_poll_for_test();
|
||||
locked_tx.send(()).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(60));
|
||||
});
|
||||
locked_rx.recv().unwrap();
|
||||
let t0 = std::time::Instant::now();
|
||||
g.poll();
|
||||
ctx.pool_frame_end();
|
||||
assert!(t0.elapsed() < std::time::Duration::from_millis(30), "non-blocking polls must not wait on a contended poll_lock (took {:?})", t0.elapsed());
|
||||
assert!(g.render(&r, s.cid, Tick::ZERO).is_some(), "readback waits for poll_lock and succeeds without retiring the context");
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,66 @@ fn posterize_thresholds_strobe_vignette() {
|
||||
effect_case("ec.stylize.ccvignette", &[("amount", n(-60.0)), ("pinHighlights", n(50.0)), ("angleOfView", n(120.0)), ("center", pt(20.0, 15.0))]);
|
||||
}
|
||||
|
||||
/// Premultiplied, quantized footage often lands exactly on a posterization
|
||||
/// threshold after division by alpha. Exercise that input before any placement
|
||||
/// or compositing can hide a wrong bin behind the normal image tolerance.
|
||||
#[test]
|
||||
fn posterize_quantized_premultiplied_boundaries() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.stylize.posterize").unwrap();
|
||||
for level in [4.0, 9.0] {
|
||||
let params = effectcraft_effects::Params { values: [("level".into(), n(level))].into() };
|
||||
let ctx =
|
||||
|| effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: [70.0, 44.0], seed: 11, adjustment: false, env: Default::default() };
|
||||
for seed in 1..=7 {
|
||||
let mut img = crate::tests::pattern(seed, 70, 44);
|
||||
effectcraft_render::color::quantize(&mut img, 255.0);
|
||||
let input = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
|
||||
let cpu = (spec.render)(&ctx(), input.clone());
|
||||
let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &input, None).unwrap();
|
||||
for (i, (want, got)) in cpu.img.data.iter().zip(&gpu.img.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((want[c] - got[c]).abs() <= 1e-6, "level {level} seed {seed} pixel {i}: input {:?}, CPU {want:?}, GPU {got:?}", input.img.data[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapped ramps have a large discontinuity at their end. Check signed and
|
||||
/// fractional phases, including a wrap a few double-precision ulps above zero.
|
||||
#[test]
|
||||
fn arbitrary_map_signed_fractional_and_near_zero_wraps() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.color.psarbitrarymap").unwrap();
|
||||
for phase in [-1000.0, -20.5, 0.0, 40.0, 1000.0, 255.99999999999997] {
|
||||
let params = effectcraft_effects::Params {
|
||||
values: [
|
||||
("map".into(), Value::Str((0..256).map(|n| n.to_string()).collect::<Vec<_>>().join(","))),
|
||||
("phase".into(), n(phase)),
|
||||
("applyPhaseMapToAlpha".into(), on()),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let ctx =
|
||||
|| effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: [1024.0, 1.0], seed: 7, adjustment: false, env: Default::default() };
|
||||
let mut img = effectcraft_raster::Image::new(1024, 1);
|
||||
for (i, px) in img.data.iter_mut().enumerate() {
|
||||
let v = if i < 512 { i as f32 * 1e-18 } else { (i - 512) as f32 / 511.0 };
|
||||
let a = [1.0f32, 0.1, 0.3, 6.0 / 255.0][i % 4];
|
||||
*px = [v * a, v * a, v * a, a];
|
||||
}
|
||||
let input = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
|
||||
let cpu = (spec.render)(&ctx(), input.clone());
|
||||
let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &input, None).unwrap();
|
||||
for (i, (want, got)) in cpu.img.data.iter().zip(&gpu.img.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((want[c] - got[c]).abs() <= 1e-6, "phase {phase}, pixel {i}: CPU {want:?}, GPU {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scatter_brush_strokes() {
|
||||
effect_case("ec.stylize.scatter", &[("amount", n(5.0))]);
|
||||
@@ -228,6 +288,36 @@ fn transform_motion_blur() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1605: Posterize matches the CPU exactly on every 8 bpc premultiplied colour and alpha. A
|
||||
/// straight value of 5/6 at level 4 rounds to exactly 2.5 on the CPU; drivers that fused the
|
||||
/// product into `round_away`'s subtraction (AMD) rounded it down a level.
|
||||
#[test]
|
||||
fn posterize_matches_the_cpu_on_every_8bpc_pair() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.stylize.posterize").unwrap();
|
||||
let size = [256.0, 255.0];
|
||||
let mut params =
|
||||
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
|
||||
for level in [4.0, 7.0, 9.0] {
|
||||
params.values.insert("level".into(), n(level));
|
||||
// Pixel (x, a - 1): premultiplied colour min(x, a)/255 on alpha a/255.
|
||||
let mut img = effectcraft_raster::Image::new(256, 255);
|
||||
for a in 1..=255u32 {
|
||||
for x in 0..256u32 {
|
||||
let v = x.min(a) as f32 / 255.0;
|
||||
img.data[((a - 1) * 256 + x) as usize] = [v, v, v, a as f32 / 255.0];
|
||||
}
|
||||
}
|
||||
let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale: 1.0 };
|
||||
let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: size, seed: 1, adjustment: false, env: Default::default() };
|
||||
let cpu = (spec.render)(&ctx(), buf.clone());
|
||||
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
|
||||
let bad: Vec<(usize, usize)> =
|
||||
cpu.img.data.iter().zip(&out.img.data).enumerate().filter(|(_, (c, o))| c != o).map(|(i, _)| ((i % 256).min(i / 256 + 1), i / 256 + 1)).collect();
|
||||
assert!(bad.is_empty(), "level {level}: {} (x, a) pairs differ from the CPU, first {:?}", bad.len(), &bad[..bad.len().min(8)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Motion Tile's Output size on an adjustment layer with a Transform after it that scales the
|
||||
/// tiles into view, and cropping (Output under 100 %): the stack's grown or cropped frame goes
|
||||
/// back onto the canvas as on the CPU (#1727).
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! GPU Content-Aware Fill vs the CPU reference: the CPU's fill is the oracle.
|
||||
//!
|
||||
//! Parity here is a quality contract, not bit-exactness (see `inpaint.rs`): PatchMatch's
|
||||
//! scan-order propagation becomes a two-pass checkerboard. So the tests compare both
|
||||
//! syntheses against the ground truth they are reconstructing, and check the GPU is not the
|
||||
//! worse of the two.
|
||||
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::inpaint::{FillBackend, FillInput, FillOpts, fill_sequence_with, membrane_fill, patch_fill, psnr};
|
||||
|
||||
use crate::tests::gpu;
|
||||
|
||||
/// A textured background (periodic stripes + blocky noise), shifted by `dx` (the CPU tests'
|
||||
/// scene, so the numbers here are comparable with theirs).
|
||||
fn background(w: u32, h: u32, dx: f32) -> Image {
|
||||
let mut img = Image::new(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let fx = x as f32 + dx;
|
||||
let s = 0.5 + 0.3 * (fx * std::f32::consts::TAU / 10.0).sin();
|
||||
let n = effectcraft_raster::hash_noise(((fx.floor() as i64).rem_euclid(20) / 4) as u32, (y % 20) / 4, 3);
|
||||
img.set(x, y, [s, 0.3 + 0.4 * n, 0.6 - 0.3 * s * n, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn cut(img: &Image, hole: &[bool]) -> Image {
|
||||
let mut o = img.clone();
|
||||
for (p, h) in o.data.iter_mut().zip(hole) {
|
||||
if *h {
|
||||
*p = [0.0; 4];
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn rect_hole(w: u32, h: u32, x0: u32, y0: u32, s: u32) -> Vec<bool> {
|
||||
(0..w * h).map(|i| (x0..x0 + s).contains(&(i % w)) && (y0..y0 + s).contains(&(i / w))).collect()
|
||||
}
|
||||
|
||||
/// The kernels must exist: a shader that fails to build falls back to the CPU silently, which
|
||||
/// would make every quality test below pass while testing nothing.
|
||||
#[test]
|
||||
fn the_inpaint_kernels_are_available() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let bad: Vec<&str> = crate::inpaint::KERNELS.iter().copied().filter(|e| !g.ctx.has_kernel(e)).collect();
|
||||
assert!(bad.is_empty(), "inpaint kernels did not build on {}: {bad:?}", g.ctx.name);
|
||||
}
|
||||
|
||||
/// The device path (not the CPU fallback) is what the quality tests measure.
|
||||
#[test]
|
||||
fn the_gpu_runs_the_fill_itself() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 72u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 40, 28, 16);
|
||||
let src = cut(>, &hole);
|
||||
assert!(crate::inpaint::patch_image(&g.ctx, &src, &hole, None, 7, 1).is_some(), "PatchMatch fell back to the CPU");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patchmatch_beats_the_membrane_on_texture() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 72u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 40, 28, 16);
|
||||
let src = cut(>, &hole);
|
||||
let mem = membrane_fill(&src, &hole);
|
||||
let pm = g.patch(&src, &hole, None, 7, 1);
|
||||
let (pm_db, mem_db) = (psnr(&pm, >, Some(&hole)), psnr(&mem, >, Some(&hole)));
|
||||
assert!(pm_db > mem_db + 3.0 && pm_db > 20.0, "PatchMatch {pm_db:.1} dB vs membrane {mem_db:.1} dB");
|
||||
for i in 0..hole.len() {
|
||||
if !hole[i] {
|
||||
assert_eq!(pm.data[i], gt.data[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patchmatch_agrees_with_the_cpu() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 72u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 40, 28, 16);
|
||||
let src = cut(>, &hole);
|
||||
let (cpu, gpu_pm) = (patch_fill(&src, &hole, None, 7, 1), g.patch(&src, &hole, None, 7, 1));
|
||||
// Both are syntheses of the same hole: neither is the other's ground truth, so they are
|
||||
// compared as two reconstructions of the same thing. On this periodic background both are
|
||||
// near-exact, so a relative dB check at machine precision says nothing; the device must be a
|
||||
// good reconstruction *and* agree with the CPU (a wrong fill that still looks plausible would
|
||||
// pass the first and fail the second).
|
||||
let gpu_db = psnr(&gpu_pm, >, Some(&hole));
|
||||
assert!(gpu_db > 25.0, "GPU {gpu_db:.1} dB");
|
||||
assert!(psnr(&gpu_pm, &cpu, Some(&hole)) > 30.0, "the two syntheses disagree");
|
||||
}
|
||||
|
||||
/// The `init` path (a frame refined from the previous frame's warped fill) is the finest level
|
||||
/// only, from two rounds of four iterations (`patch_fill`).
|
||||
#[test]
|
||||
fn patchmatch_refines_an_init_frame() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 72u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 40, 28, 16);
|
||||
let src = cut(>, &hole);
|
||||
let init = gt.clone();
|
||||
let (cpu, gpu_pm) = (patch_fill(&src, &hole, Some(&init), 7, 1), g.patch(&src, &hole, Some(&init), 7, 1));
|
||||
assert!(psnr(&gpu_pm, >, Some(&hole)) > 25.0, "{:.1} dB", psnr(&gpu_pm, >, Some(&hole)));
|
||||
assert!(psnr(&gpu_pm, &cpu, Some(&hole)) > 20.0, "the two syntheses disagree");
|
||||
}
|
||||
|
||||
/// A hole across the whole multi-scale pyramid (the coarse levels' membrane start included):
|
||||
/// every level must come back finite and the pyramid must beat a single-level solve.
|
||||
#[test]
|
||||
fn patchmatch_survives_a_large_hole_across_levels() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (256u32, 192u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 90, 70, 80);
|
||||
let src = cut(>, &hole);
|
||||
let pm = g.patch(&src, &hole, None, 7, 1);
|
||||
assert!(pm.data.iter().flatten().all(|v| v.is_finite()), "the synthesis left a non-finite value");
|
||||
let db = psnr(&pm, >, Some(&hole));
|
||||
assert!(db > 20.0, "{db:.1} dB");
|
||||
for i in 0..hole.len() {
|
||||
if !hole[i] {
|
||||
assert_eq!(pm.data[i], gt.data[i], "known pixel {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A moving object removed from a (static or panning) textured background, filled frame by
|
||||
/// frame through the device backend the sequence driver uses.
|
||||
#[test]
|
||||
fn object_fill_recovers_background_and_is_stable() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 64u32);
|
||||
for pan in [0.0f32, 1.0] {
|
||||
let mut frames = vec![];
|
||||
let mut holes = vec![];
|
||||
let mut gts = vec![];
|
||||
for t in 0..8u32 {
|
||||
let gt = background(w, h, t as f32 * pan);
|
||||
let hole = rect_hole(w, h, 10 + 8 * t, 22, 18);
|
||||
frames.push(cut(>, &hole));
|
||||
holes.push(hole);
|
||||
gts.push(gt);
|
||||
}
|
||||
let input = FillInput { frames, holes, references: vec![] };
|
||||
let out = fill_sequence_with(&input, &FillOpts::default(), g, &mut |_, _| true).expect("fill");
|
||||
for t in 0..out.len() {
|
||||
let db = psnr(&out[t], >s[t], Some(&input.holes[t]));
|
||||
assert!(db > 26.0, "frame {t} pan {pan}: {db:.1} dB");
|
||||
assert_eq!(out[t].data[0][3], 1.0);
|
||||
}
|
||||
if pan == 0.0 {
|
||||
for t in 1..out.len() {
|
||||
let both: Vec<bool> = input.holes[t].iter().zip(&input.holes[t - 1]).map(|(a, b)| *a || *b).collect();
|
||||
let db = psnr(&out[t], &out[t - 1], Some(&both));
|
||||
assert!(db > 30.0, "frames {}–{t} differ: {db:.1} dB", t - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The membrane stays on the CPU (see [`crate::inpaint`]): Edge Blend must still get the CPU's
|
||||
/// result through the backend, not a device one.
|
||||
#[test]
|
||||
fn edge_blend_takes_the_cpu_membrane() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (80u32, 60u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 30, 20, 14);
|
||||
let input = FillInput { frames: vec![cut(>, &hole); 4], holes: vec![hole.clone(); 4], references: vec![] };
|
||||
let opts = FillOpts { method: effectcraft_raster::inpaint::FillMethod::EdgeBlend, ..Default::default() };
|
||||
let edge = fill_sequence_with(&input, &opts, g, &mut |_, _| true).expect("fill");
|
||||
for t in 1..4 {
|
||||
assert!(psnr(&edge[t], &edge[0], Some(&hole)) > 40.0, "frame {t}");
|
||||
}
|
||||
assert!(psnr(&edge[0], &membrane_fill(&input.frames[0], &hole), Some(&hole)) > 60.0);
|
||||
}
|
||||
|
||||
/// The dilation the level plans are built from (kept honest: the flags' target bit is `near`).
|
||||
#[test]
|
||||
fn the_target_band_is_the_dilated_hole() {
|
||||
let (w, h) = (24u32, 18u32);
|
||||
let hole = rect_hole(w, h, 8, 6, 4);
|
||||
let near = effectcraft_raster::inpaint::dilate(&hole, w as usize, h as usize, 3);
|
||||
assert!(near.iter().filter(|b| **b).count() > hole.iter().filter(|b| **b).count());
|
||||
assert!(near.iter().zip(&hole).all(|(n, h)| !*h || *n));
|
||||
}
|
||||
|
||||
/// The flow's block matching runs on the device and finds the same motion the CPU does: the two
|
||||
/// frames are the membrane-filled neighbours the sequence driver builds, with a hole in each.
|
||||
#[test]
|
||||
fn flow_agrees_with_the_cpu() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (192u32, 128u32);
|
||||
let a_gt = background(w, h, 0.0);
|
||||
let b_gt = background(w, h, 5.0);
|
||||
let hole_a = rect_hole(w, h, 30, 40, 40);
|
||||
let mut hole_b = vec![false; (w * h) as usize];
|
||||
for y in 50..90 {
|
||||
for x in 120..160 {
|
||||
hole_b[(y * w + x) as usize] = true;
|
||||
}
|
||||
}
|
||||
let a = membrane_fill(&cut(&a_gt, &hole_a), &hole_a);
|
||||
let b = membrane_fill(&cut(&b_gt, &hole_b), &hole_b);
|
||||
assert!(crate::inpaint::flow_image(&g.ctx, &a, &b).is_some(), "the flow fell back to the CPU");
|
||||
let cpu = effectcraft_raster::inpaint::CpuFill.flow(&a, &b, &hole_a, &hole_b);
|
||||
let dev = g.flow(&a, &b, &hole_a, &hole_b);
|
||||
assert_eq!((dev.cols, dev.rows, dev.block), (cpu.cols, cpu.rows, cpu.block));
|
||||
// The field is the CPU's to a float rounding: the luma is the CPU's (`luma_pair`), the
|
||||
// pyramid is exact additions, and the search enumerates candidates in the CPU's order.
|
||||
let diff = cpu.v.iter().zip(&dev.v).map(|(c, d)| (c[0] - d[0]).abs().max((c[1] - d[1]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(diff <= 1e-6, "the flow fields differ by {diff}");
|
||||
// The motion is real: the panning background's 5 px shift survives the completion. `b`'s
|
||||
// content sits 5 px left of `a`'s (`background` adds `dx` inside the sample), so a → b is −5.
|
||||
let m = dev.at(96.0, 64.0);
|
||||
assert!((m[0] + 5.0).abs() < 0.5 && m[1].abs() < 0.5, "flow at centre: {m:?}");
|
||||
}
|
||||
|
||||
/// The vote's σ² comes from a histogram that must count *every* target: the old per-thread
|
||||
/// strided pass kept only the pixels whose bin selected that thread (a biased ~1/256 subsample),
|
||||
/// which left small holes with a `total < 1` fallback and near-uniform vote weights.
|
||||
#[test]
|
||||
fn the_distance_histogram_counts_every_target() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (48u32, 32u32);
|
||||
// Every target has the same distance, so the histogram is one spike. 0.5 is the SDR range
|
||||
// (bin 128); 1600 is above it, which is reachable from a 32-bpc frame (the reviewer's example:
|
||||
// clamping it to bin 1023 reported σ² ≈ 4, and the vote's weights then rounded to zero).
|
||||
for (d, want) in [(0.5f32, 128.5f32 / 256.0), (1600.0, 1600.0)] {
|
||||
let (mut field, near) = (Image::new(w, h), vec![true; (w * h) as usize]);
|
||||
for px in field.data.iter_mut() {
|
||||
*px = [0.0, 0.0, d, 1.0];
|
||||
}
|
||||
let (sigma2, total) = crate::inpaint::distance_histogram(&g.ctx, &field, &near, w, h).expect("histogram");
|
||||
assert_eq!(total as usize, (w * h) as usize, "distance {d}: the histogram kept {total} of {} samples", w * h);
|
||||
let tol = (want * 1e-3).max(1e-3);
|
||||
assert!((sigma2 - want).abs() <= tol, "distance {d}: sigma2 {sigma2}, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// HDR frames (channels above 1, as a 32-bpc image has) must still fill like the CPU. Their patch
|
||||
/// distances are far past the SDR 0…4 range the histogram used to clamp at, so this covers the
|
||||
/// rescaled binning end to end; `the_distance_histogram_counts_every_target` pins the estimator
|
||||
/// itself (the old clamp read σ² ≈ 4 for a distance of 1600).
|
||||
#[test]
|
||||
fn hdr_frames_fill_like_the_cpu() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 72u32);
|
||||
let mut gt = background(w, h, 0.0);
|
||||
for px in &mut gt.data {
|
||||
// A 32-bpc frame's values are unbounded: at 24× the patch distances run into the thousands.
|
||||
// The old fixed 0…4 histogram reported σ² ≈ 4 for those, `exp(-d/(2σ²))` rounded to zero,
|
||||
// and the vote kept the prior image instead of synthesising.
|
||||
for c in 0..3 {
|
||||
px[c] *= 24.0;
|
||||
}
|
||||
}
|
||||
let hole = rect_hole(w, h, 40, 28, 16);
|
||||
let src = cut(>, &hole);
|
||||
let (cpu, gpu_pm) = (patch_fill(&src, &hole, None, 7, 1), g.patch(&src, &hole, None, 7, 1));
|
||||
let gpu_db = psnr(&gpu_pm, >, Some(&hole));
|
||||
assert!(gpu_db > 25.0, "GPU {gpu_db:.1} dB");
|
||||
assert!(psnr(&gpu_pm, &cpu, Some(&hole)) > 30.0, "the two syntheses disagree");
|
||||
}
|
||||
|
||||
/// A frame too small for one block is the CPU's zero field, on the device path too.
|
||||
#[test]
|
||||
fn small_frames_get_a_zero_flow() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (6u32, 6u32);
|
||||
let a = background(w, h, 0.0);
|
||||
let b = background(w, h, 2.0);
|
||||
let none = vec![false; (w * h) as usize];
|
||||
let f = g.flow(&a, &b, &none, &none);
|
||||
assert_eq!((f.cols, f.rows), (1, 1));
|
||||
assert_eq!(f.v, vec![[0.0, 0.0]]);
|
||||
}
|
||||
|
||||
/// The flow the sequence driver uses (through the `Gpu` backend) keeps the fill stable: the same
|
||||
/// frames filled with the CPU backend and the device backend agree, so the propagation sees the
|
||||
/// same motion.
|
||||
#[test]
|
||||
fn sequence_fill_with_the_device_flow_matches_the_cpu() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 64u32);
|
||||
let cpu = effectcraft_raster::inpaint::CpuFill;
|
||||
for pan in [0.0f32, 2.0] {
|
||||
let mut frames = vec![];
|
||||
let mut holes = vec![];
|
||||
for t in 0..6u32 {
|
||||
let gt = background(w, h, t as f32 * pan);
|
||||
let hole = rect_hole(w, h, 12 + 6 * t, 22, 18);
|
||||
frames.push(cut(>, &hole));
|
||||
holes.push(hole);
|
||||
}
|
||||
let input = FillInput { frames, holes, references: vec![] };
|
||||
let dev = fill_sequence_with(&input, &FillOpts::default(), g, &mut |_, _| true).expect("gpu fill");
|
||||
let ref_cpu = fill_sequence_with(&input, &FillOpts::default(), &cpu, &mut |_, _| true).expect("cpu fill");
|
||||
for t in 0..dev.len() {
|
||||
let db = psnr(&dev[t], &ref_cpu[t], Some(&input.holes[t]));
|
||||
assert!(db > 40.0, "frame {t} pan {pan}: device vs CPU {db:.1} dB");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,12 @@ fn draw_collapsed<'a>(
|
||||
}
|
||||
|
||||
fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> {
|
||||
let out = draw_layer_on(e, r, ctx, layer, canvas);
|
||||
e.mem_gate();
|
||||
out
|
||||
}
|
||||
|
||||
fn draw_layer_on(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> {
|
||||
if layer.switches.adjustment {
|
||||
if draw_adjustment(e, r, ctx, layer, canvas).is_some() {
|
||||
return Some(());
|
||||
|
||||
@@ -130,7 +130,7 @@ pub(crate) fn record(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) ->
|
||||
],
|
||||
});
|
||||
{
|
||||
let enc = e.encoder();
|
||||
let enc = e.record(&[img]);
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("yuv420"), timestamp_writes: None });
|
||||
pass.set_pipeline(&yp.pipe);
|
||||
|
||||
@@ -83,6 +83,10 @@ pub struct EncoderConfig {
|
||||
pub keyint: u32,
|
||||
/// BT.709 limited range is signalled in the VUI unless this is set.
|
||||
pub full_range: bool,
|
||||
/// The VUI colour description (ITU-T H.273 code points); BT.709 by default.
|
||||
pub colour_primaries: u8,
|
||||
pub transfer_characteristics: u8,
|
||||
pub matrix_coeffs: u8,
|
||||
}
|
||||
|
||||
impl EncoderConfig {
|
||||
@@ -97,6 +101,9 @@ impl EncoderConfig {
|
||||
rate: RateControl::ConstantQp(28),
|
||||
keyint: 60,
|
||||
full_range: false,
|
||||
colour_primaries: 1,
|
||||
transfer_characteristics: 1,
|
||||
matrix_coeffs: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,6 +241,9 @@ impl Encoder {
|
||||
profile_idc: cfg.profile.idc(),
|
||||
level_idc,
|
||||
full_range: cfg.full_range,
|
||||
colour_primaries: cfg.colour_primaries,
|
||||
transfer_characteristics: cfg.transfer_characteristics,
|
||||
matrix_coeffs: cfg.matrix_coeffs,
|
||||
fps_num: cfg.fps_num,
|
||||
fps_den: cfg.fps_den,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@ pub struct SeqParams {
|
||||
pub profile_idc: u8,
|
||||
pub level_idc: u8,
|
||||
pub full_range: bool,
|
||||
pub colour_primaries: u8,
|
||||
pub transfer_characteristics: u8,
|
||||
pub matrix_coeffs: u8,
|
||||
pub fps_num: u32,
|
||||
pub fps_den: u32,
|
||||
}
|
||||
@@ -166,9 +169,9 @@ fn vui(w: &mut BitWriter, p: &SeqParams) {
|
||||
w.put(5, 3); // video_format: unspecified
|
||||
w.flag(p.full_range);
|
||||
w.put(1, 1); // colour_description_present_flag
|
||||
w.put(1, 8); // colour_primaries BT.709
|
||||
w.put(1, 8); // transfer_characteristics BT.709
|
||||
w.put(1, 8); // matrix_coeffs BT.709
|
||||
w.put(p.colour_primaries as u32, 8); // colour_primaries
|
||||
w.put(p.transfer_characteristics as u32, 8); // transfer_characteristics
|
||||
w.put(p.matrix_coeffs as u32, 8); // matrix_coeffs
|
||||
w.put(0, 1); // chroma_loc_info_present_flag
|
||||
w.put(0, 1); // neutral_chroma_indication_flag
|
||||
w.put(0, 1); // field_seq_flag
|
||||
|
||||
@@ -69,14 +69,18 @@ fn rust_plugins_apply_by_id() {
|
||||
/// Build the example plug-in (skipped when the wasm32 target isn't installed).
|
||||
fn build_example() -> Option<std::path::PathBuf> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/plugins/posterize-bands");
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("../../../target")).join("plugin-example");
|
||||
let target = if target.is_absolute() { target } else { root.join("../../..").join(target) };
|
||||
let st = std::process::Command::new(env!("CARGO"))
|
||||
.current_dir(&root)
|
||||
.args(["build", "--release", "--target", "wasm32-unknown-unknown", "--target-dir"])
|
||||
.arg(&target)
|
||||
.status()
|
||||
.ok()?;
|
||||
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let assigned = std::env::var_os("CARGO_TARGET_DIR");
|
||||
let target = assigned.as_ref().map(std::path::PathBuf::from).unwrap_or_else(|| workspace.join("target"));
|
||||
let target = if target.is_absolute() { target } else { workspace.join(target) };
|
||||
// The plug-in is part of this test's build. Inherit the assigned target and
|
||||
// compiler guards rather than creating a second target at its root.
|
||||
let mut command = std::process::Command::new(env!("CARGO"));
|
||||
command.current_dir(&workspace).args(["build", "--release", "--target", "wasm32-unknown-unknown", "--manifest-path"]).arg(root.join("Cargo.toml"));
|
||||
if assigned.is_none() {
|
||||
command.env("CARGO_TARGET_DIR", &target);
|
||||
}
|
||||
let st = command.status().ok()?;
|
||||
if !st.success() {
|
||||
eprintln!("skipping: the example plug-in didn't build (rustup target add wasm32-unknown-unknown)");
|
||||
return None;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Timeline documents → compositions (File ▸ Import ▸ Adobe Premiere Pro Project / Timeline
|
||||
//! Interchange…).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
@@ -156,18 +156,79 @@ pub fn import(
|
||||
}),
|
||||
name: Some(opts.name.trim_end_matches(|c| c != '.').trim_end_matches('.').to_string()).filter(|s| !s.is_empty()),
|
||||
};
|
||||
let (imported, report) = filmcraft_interchange::import_with(bytes, format.fc(), &fc_opts)?;
|
||||
let (mut imported, report) = filmcraft_interchange::import_with(bytes, format.fc(), &fc_opts)?;
|
||||
// Each file is probed once, though an EDL's media are probed before the layers are made.
|
||||
let mut probed: HashMap<String, Option<Footage>> = HashMap::new();
|
||||
let mut probe_once = |path: &str| probed.entry(path.to_string()).or_insert_with(|| probe(path)).clone();
|
||||
let left_on_timecode = if format == TimelineFormat::Edl { rebase_to_file_timecode(&mut imported.project, &mut probe_once) } else { Vec::new() };
|
||||
let mut res = ImportResult { format: Some(format), ..Default::default() };
|
||||
for e in &report.entries {
|
||||
// The reader's "rebase after probing" note is done above: name only the media it wasn't.
|
||||
if format == TimelineFormat::Edl && e.message.starts_with(EDL_MEDIA_TIME_NOTE) {
|
||||
if !left_on_timecode.is_empty() {
|
||||
res.warnings
|
||||
.push(format!("EDL source timecodes stay media time for {}: the file is missing or has no start timecode", left_on_timecode.join(", ")));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if e.count > 1 { res.warnings.push(format!("{} (×{})", e.message, e.count)) } else { res.warnings.push(e.message.clone()) }
|
||||
}
|
||||
let folder_name = if opts.name.is_empty() { "Imported Timeline".to_string() } else { opts.name.clone() };
|
||||
res.folder = project.add_item(&folder_name, Label::None, None, ItemKind::Folder);
|
||||
let mut im = Importer { src: &imported.project, project, res, items: HashMap::new(), solids: HashMap::new(), solids_folder: None, bins: HashMap::new() };
|
||||
im.run(&imported.sequences, probe);
|
||||
im.run(&imported.sequences, &mut probe_once);
|
||||
Ok(im.res)
|
||||
}
|
||||
|
||||
/// The start of the note FilmCraft's EDL reader adds when source timecodes look like real
|
||||
/// timecode (an hour or more) and it has kept them as media time.
|
||||
const EDL_MEDIA_TIME_NOTE: &str = "EDL source timecodes were imported as media time";
|
||||
|
||||
/// An EDL's source times are its source timecodes, which FilmCraft's reader keeps as media time.
|
||||
/// The largest start timecode, in frames, a file may have: a day at 1000 fps.
|
||||
const MAX_FILE_TIMECODE: i64 = 24 * 3600 * 1000;
|
||||
|
||||
/// Put the clips of each file that has a start timecode on the file's own time, as FilmCraft does
|
||||
/// when it links the media of an imported EDL. Returns the names of the media that stay on
|
||||
/// timecode though their clips use an hour or more of it (the file is missing or has none).
|
||||
fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str) -> Option<Footage>) -> Vec<String> {
|
||||
let mut on_timecode: HashSet<fp::ItemId> = HashSet::new();
|
||||
for it in project.items.values() {
|
||||
if let fp::ItemKind::Sequence(seq) = &it.kind {
|
||||
// 01:00:00:00 as the list counts it: 01:00:00;00 drop-frame at 29.97 fps is 3599.996 s
|
||||
let (rate, df) = (seq.settings.frame_rate, seq.settings.drop_frame);
|
||||
let hour = rate.tick_of(filmcraft_time::fields_to_frames(1, 0, 0, 0, rate, df));
|
||||
on_timecode.extend(seq.all_tracks().flat_map(|t| &t.items).filter(|ti| ti.source_in >= hour).map(|ti| ti.item));
|
||||
}
|
||||
}
|
||||
let files: Vec<(fp::ItemId, String, String, bool)> = project
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|it| match &it.kind {
|
||||
fp::ItemKind::Media(m) => match &m.media {
|
||||
fp::MediaRef::File { path } => Some((it.id, it.name.clone(), path.clone(), m.offline)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut left = Vec::new();
|
||||
for (id, name, path, offline) in files {
|
||||
match if offline { None } else { probe(&path) } {
|
||||
// A corrupt timecode past a day of frames (at up to 1000 fps) would overflow the tick
|
||||
// arithmetic; such a file stays on timecode.
|
||||
Some(Footage { file_timecode: Some(tc), native_rate, frame_rate, .. }) if (0..=MAX_FILE_TIMECODE).contains(&tc) => {
|
||||
filmcraft_interchange::rebase_source_timecode(project, id, tc, crate::rate_out(native_rate.unwrap_or(frame_rate)));
|
||||
}
|
||||
_ if on_timecode.contains(&id) => left.push(name),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
left.sort();
|
||||
left.dedup();
|
||||
left
|
||||
}
|
||||
|
||||
struct Importer<'a> {
|
||||
src: &'a fp::Project,
|
||||
project: &'a mut Project,
|
||||
|
||||
@@ -365,6 +365,61 @@ fn edl_imports_one_track() {
|
||||
assert!(c.layers.len() >= 2);
|
||||
}
|
||||
|
||||
/// An EDL's source timecodes are footage time once the file's own start timecode is known:
|
||||
/// 01:00:01:00 in a file that starts at 01:00:00:00 is one second in, not 3601 s.
|
||||
#[test]
|
||||
fn edl_source_timecode_lands_on_the_files_own_time() {
|
||||
let edl = "TITLE: CUT\nFCM: NON-DROP FRAME\n\n001 A001 V C 01:00:01:00 01:00:02:00 01:00:00:00 01:00:01:00\n* FROM CLIP NAME: a.mov\n* SOURCE FILE: /media/a.mov\n";
|
||||
let mut probe_tc = |path: &str| probe(path).map(|f| Footage { file_timecode: Some(3600 * 25), ..f });
|
||||
let mut p = Project::default();
|
||||
let opts = ImportOptions { name: "cut.edl".into(), edl_frame_rate: Some(25.0), ..Default::default() };
|
||||
let r = import(&mut p, edl.as_bytes(), None, &opts, &mut probe_tc).unwrap();
|
||||
let c = p.comp(r.comps[0]).unwrap();
|
||||
let l = layer(c, "a.mov");
|
||||
assert!(near(l.layer_time(l.in_point), t(1.0)), "{}", l.layer_time(l.in_point).seconds());
|
||||
assert!(near(l.start_time, t(-1.0)), "{}", l.start_time.seconds());
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("media time")), "the rebase is done: {:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// The reader's note to rebase after probing is replaced by the media that couldn't be rebased:
|
||||
/// a file with no start timecode and a missing file. Media cut from 00:00:00:00 needs no rebase.
|
||||
#[test]
|
||||
fn edl_media_left_on_timecode_is_named() {
|
||||
let event = |n: u32, reel: &str, ch: &str, src: (&str, &str), rec: (&str, &str), file: &str| {
|
||||
format!("{n:03} {reel:<8} {ch:<5} C {} {} {} {}\n* FROM CLIP NAME: {file}\n* SOURCE FILE: /media/{file}\n\n", src.0, src.1, rec.0, rec.1)
|
||||
};
|
||||
let edl = [
|
||||
"TITLE: CUT\nFCM: NON-DROP FRAME\n\n".to_string(),
|
||||
event(1, "A", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:00:00", "01:00:01:00"), "a.mov"),
|
||||
event(2, "B", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:01:00", "01:00:02:00"), "b.mov"),
|
||||
event(3, "C", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:02:00", "01:00:03:00"), "c.mov"),
|
||||
event(4, "D", "A", ("00:00:00:00", "00:00:03:00"), ("01:00:00:00", "01:00:03:00"), "d.wav"),
|
||||
]
|
||||
.concat();
|
||||
let mut probe_some = |path: &str| match path {
|
||||
"/media/a.mov" => probe(path).map(|f| Footage { file_timecode: Some(3600 * 25), ..f }),
|
||||
"/media/c.mov" => None,
|
||||
_ => probe(path),
|
||||
};
|
||||
let mut p = Project::default();
|
||||
let opts = ImportOptions { name: "cut.edl".into(), edl_frame_rate: Some(25.0), ..Default::default() };
|
||||
let r = import(&mut p, edl.as_bytes(), None, &opts, &mut probe_some).unwrap();
|
||||
let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("media time")).collect();
|
||||
assert_eq!(notes, ["EDL source timecodes stay media time for b.mov, c.mov: the file is missing or has no start timecode"], "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A drop-frame list counts its hour in timecode: a clip cut at 01:00:00;00 (3599.996 s at 29.97
|
||||
/// fps) from a missing file is still named.
|
||||
#[test]
|
||||
fn edl_drop_frame_media_left_on_timecode_is_named() {
|
||||
let edl = "TITLE: CUT\nFCM: DROP FRAME\n\n001 C V C 01:00:00;00 01:00:01;00 01:00:00;00 01:00:01;00\n* FROM CLIP NAME: c.mov\n* SOURCE FILE: /media/c.mov\n";
|
||||
let mut p = Project::default();
|
||||
let opts = ImportOptions { name: "cut.edl".into(), edl_frame_rate: Some(29.97), ..Default::default() };
|
||||
let r = import(&mut p, edl.as_bytes(), None, &opts, &mut |_: &str| None).unwrap();
|
||||
let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("media time")).collect();
|
||||
assert_eq!(notes, ["EDL source timecodes stay media time for c.mov: the file is missing or has no start timecode"], "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_documents() {
|
||||
let mut p = Project::default();
|
||||
|
||||
@@ -135,6 +135,17 @@ pub fn import(
|
||||
cx.fonts.push((n, fam, st));
|
||||
}
|
||||
}
|
||||
// Glyph outlines embedded for players without the fonts. Text is set in installed fonts.
|
||||
if let Some(chars) = doc.get("chars").and_then(Json::as_array).filter(|c| !c.is_empty()) {
|
||||
let mut fams: Vec<&str> = vec![];
|
||||
for fam in chars.iter().filter_map(|c| s(c, "fFamily")).filter(|f| !f.trim().is_empty()) {
|
||||
if !fams.contains(&fam) {
|
||||
fams.push(fam);
|
||||
}
|
||||
}
|
||||
let of = if fams.is_empty() { String::new() } else { format!(" of {}", fams.join(", ")) };
|
||||
cx.im.warn(format!("embedded glyphs (`chars`){of} are not imported; text uses the installed fonts"));
|
||||
}
|
||||
let (w, h) = (w.round().max(1.0) as u32, h.round().max(1.0) as u32);
|
||||
let duration = tb.tick(op);
|
||||
// Pass 1: asset items (precomps empty for now, images stored).
|
||||
@@ -305,7 +316,8 @@ fn masks(im: &Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup) {
|
||||
import_prop(im, j, p, conv);
|
||||
}
|
||||
};
|
||||
set("pt", "path", &shapes::to_path);
|
||||
let closed = m.get("cl").and_then(Json::as_bool).unwrap_or(false);
|
||||
set("pt", "path", &|v| shapes::to_path(v, closed));
|
||||
set("o", "opacity", &anim::to_scalar);
|
||||
set("x", "expansion", &anim::to_scalar);
|
||||
set("f", "feather", &anim::to_vec2);
|
||||
|
||||
@@ -27,7 +27,9 @@ pub(crate) fn path_value(v: &Value) -> Json {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_path(j: &Json) -> Option<Value> {
|
||||
/// A Lottie path. `closed` is used when the path has no `c`: Bodymovin 3.x and early 4.x keep the
|
||||
/// flag outside the path, as `cl` on a mask and `closed` on an `sh` item.
|
||||
pub(crate) fn to_path(j: &Json, closed: bool) -> Option<Value> {
|
||||
let o = match j {
|
||||
Json::Array(a) => a.first()?,
|
||||
o => o,
|
||||
@@ -48,7 +50,7 @@ pub(crate) fn to_path(j: &Json) -> Option<Value> {
|
||||
in_tangents: fit(pts("i")),
|
||||
out_tangents: fit(pts("o")),
|
||||
vertices,
|
||||
closed: o.get("c").and_then(Json::as_bool).unwrap_or(false),
|
||||
closed: o.get("c").and_then(Json::as_bool).unwrap_or(closed),
|
||||
feather: Vec::new(),
|
||||
}))
|
||||
}
|
||||
@@ -493,7 +495,8 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
|
||||
"sh" => {
|
||||
let mut g = build::shape_path(ids, ShapePath::default());
|
||||
dir(&mut g);
|
||||
set(im, &mut g, "path", j.get("ks"), &to_path);
|
||||
let closed = j.get("closed").and_then(Json::as_bool).unwrap_or(false);
|
||||
set(im, &mut g, "path", j.get("ks"), &|v| to_path(v, closed));
|
||||
g
|
||||
}
|
||||
"fl" => {
|
||||
|
||||
@@ -520,6 +520,44 @@ fn zero_time_stretch_is_not_imported() {
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_glyphs_are_reported_when_dropped() {
|
||||
let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}});
|
||||
let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], "layers": [],
|
||||
"fonts": {"list": [{"fName": "Sans-Regular", "fFamily": "Sans", "fStyle": "Regular"}]},
|
||||
"chars": [glyph("A", "Sans"), glyph("B", ""), glyph("C", "Serif")]});
|
||||
let warnings = |doc: &Json| import(&mut Project::default(), doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().warnings;
|
||||
// A blank family name is left out, not listed as "of , …".
|
||||
assert_eq!(warnings(&doc), ["embedded glyphs (`chars`) of Sans, Serif are not imported; text uses the installed fonts"]);
|
||||
let blank = json!({"w": 20, "h": 20, "fr": 30, "layers": [], "chars": [glyph("A", " ")]});
|
||||
assert_eq!(warnings(&blank), ["embedded glyphs (`chars`) are not imported; text uses the installed fonts"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_closed_flags_close_masks_and_paths() {
|
||||
// Bodymovin 3.x and early 4.x put the closed flag on the mask (`cl`) and on the `sh` item
|
||||
// (`closed`), with no `c` in the path. An open mask would leave the solid whole.
|
||||
let square = json!({"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[5, 5], [15, 5], [15, 15], [5, 15]]});
|
||||
let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], "layers": [
|
||||
{"ty": 4, "ind": 1, "ip": 0, "op": 30, "st": 0, "ks": {},
|
||||
"shapes": [{"ty": "sh", "closed": true, "ks": {"k": [{"t": 0, "s": [square], "e": [square]}, {"t": 10}]}}]},
|
||||
{"ty": 1, "ind": 2, "sc": "#ff0000", "sw": 20, "sh": 20, "ip": 0, "op": 30, "st": 0, "ks": {},
|
||||
"masksProperties": [{"mode": "a", "cl": true, "pt": {"k": square}}]}]});
|
||||
let mut q = Project::default();
|
||||
let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp;
|
||||
let comp = q.comp(nc).unwrap();
|
||||
let closed = |v: &Value| matches!(v, Value::Path(sp) if sp.closed);
|
||||
let shape = comp.layers[0].props.sub("contents").unwrap().groups().next().unwrap();
|
||||
let keys = &shape.get("path").unwrap().keys;
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert!(keys.iter().all(|k| closed(&k.value)));
|
||||
let mask = comp.layers[1].masks().unwrap().groups().next().unwrap();
|
||||
assert!(closed(&mask.get("path").unwrap().value));
|
||||
let img = render_frame(&q, nc, s(0.0), 1.0);
|
||||
assert_eq!(img.data[0][3], 0.0, "the mask cuts the corner away");
|
||||
assert!(img.data[10 * 20 + 10][3] > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precomp_with_time_remap() {
|
||||
let (mut p, cid) = setup();
|
||||
|
||||
@@ -198,16 +198,21 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -
|
||||
missing: false,
|
||||
sequence: Vec::new(),
|
||||
color_profile: v.and_then(|v| profile_of(&v.color)),
|
||||
// counted in video frames, so only with a video stream
|
||||
file_timecode: v.and(info.start_timecode),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour profile a video stream declares (H.273 primaries / transfer from the bitstream or
|
||||
/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve.
|
||||
/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve. BT.2020
|
||||
/// with a PQ or HLG curve is Rec. 2100 (HDR10, HLG), not Rec. 2020's 2.4 gamma (#2849).
|
||||
fn profile_of(c: &filmcraft_color::ColorInfo) -> Option<effectcraft_project::ColorSpace> {
|
||||
use effectcraft_project::ColorSpace;
|
||||
use filmcraft_color::{Primaries, Transfer};
|
||||
match (c.primaries, c.transfer) {
|
||||
(Primaries::Bt2020, Transfer::Pq) => Some(ColorSpace::Rec2100Pq),
|
||||
(Primaries::Bt2020, Transfer::Hlg) => Some(ColorSpace::Rec2100Hlg),
|
||||
(Primaries::Bt2020, _) => Some(ColorSpace::Rec2020),
|
||||
(Primaries::P3D65, _) => Some(ColorSpace::DisplayP3),
|
||||
(_, Transfer::Bt709) => Some(ColorSpace::Rec709),
|
||||
@@ -383,6 +388,25 @@ mod tests {
|
||||
(0..n).map(|k| (at(k + 1) - at(k)) as u32).collect()
|
||||
}
|
||||
|
||||
/// #2849: a stream's primaries and curve pick its profile; BT.2020 with PQ or HLG is Rec. 2100.
|
||||
#[test]
|
||||
fn stream_colour_picks_the_profile() {
|
||||
use effectcraft_project::ColorSpace;
|
||||
use filmcraft_color::{Primaries, Transfer};
|
||||
let rows = [
|
||||
(Primaries::Bt2020, Transfer::Pq, Some(ColorSpace::Rec2100Pq)),
|
||||
(Primaries::Bt2020, Transfer::Hlg, Some(ColorSpace::Rec2100Hlg)),
|
||||
(Primaries::Bt2020, Transfer::Bt709, Some(ColorSpace::Rec2020)),
|
||||
(Primaries::P3D65, Transfer::Srgb, Some(ColorSpace::DisplayP3)),
|
||||
(Primaries::Bt709, Transfer::Bt709, Some(ColorSpace::Rec709)),
|
||||
(Primaries::Bt709, Transfer::Srgb, None),
|
||||
];
|
||||
for (primaries, transfer, want) in rows {
|
||||
let c = filmcraft_color::ColorInfo { primaries, transfer, ..filmcraft_color::ColorInfo::REC709 };
|
||||
assert_eq!(profile_of(&c), want, "{primaries:?} + {transfer:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A model's side files are read from beside it; a URI to another computer is not followed.
|
||||
#[test]
|
||||
fn model_side_files_are_not_read_from_other_computers() {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The start timecode a movie records is read into its footage (`Footage::file_timecode`).
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use effectcraft_project::FootageKind;
|
||||
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TimecodeConfig, TrackConfig, WriteSample, WriterOptions};
|
||||
|
||||
/// A two-frame 24 fps Motion JPEG MOV, with a timecode track from `start_frame` when given.
|
||||
fn mov(start_frame: Option<u32>) -> Vec<u8> {
|
||||
let mut jpeg = Vec::new();
|
||||
image::RgbImage::from_pixel(16, 16, image::Rgb([200, 40, 90])).write_to(&mut Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).expect("jpeg");
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer");
|
||||
let v = w.add_track(TrackConfig::new(SampleEntry::jpeg(16, 16), 24)).expect("video track");
|
||||
if let Some(start) = start_frame {
|
||||
let tc = TimecodeConfig { timescale: 24, frame_duration: 1, frames_per_second: 24, ..Default::default() };
|
||||
w.add_timecode_track(v, tc, start).expect("timecode track");
|
||||
}
|
||||
for _ in 0..2 {
|
||||
w.write_sample(v, WriteSample { data: &jpeg, duration: 1, composition_offset: 0, is_sync: true }).expect("frame");
|
||||
}
|
||||
w.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_movies_start_timecode_is_read() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-file-timecode-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
// 10:00:00:00 at 24 fps, and a movie with no timecode
|
||||
for (name, start) in [("tc.mov", Some(10 * 3600 * 24)), ("none.mov", None)] {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, mov(start)).expect("write");
|
||||
let f = effectcraft_media::probe(&p).expect("probe");
|
||||
assert_eq!(f.kind, FootageKind::Video, "{name}");
|
||||
assert_eq!(f.file_timecode, start.map(i64::from), "{name}");
|
||||
assert_eq!(f.start_timecode, None, "{name}: Interpret Footage still uses the source file timecode");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod aac_layout;
|
||||
mod downmix;
|
||||
mod exif_orientation;
|
||||
mod file_timecode;
|
||||
mod footage;
|
||||
mod open_in_place;
|
||||
mod tga;
|
||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user