mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge branch 'feat/2100-instance3d-layer-create' (synced with main)
This commit is contained in:
Generated
+191
-6
@@ -860,6 +860,15 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -1038,12 +1047,40 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_plurals",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"icu_provider_adapters",
|
||||
"icu_provider_blob",
|
||||
"log",
|
||||
"objc2-foundation 0.3.2",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
"windows-registry",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1052,20 +1089,28 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
"skrifa",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -2446,6 +2491,17 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -2870,7 +2926,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
]
|
||||
@@ -2884,7 +2940,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
@@ -2942,6 +2998,23 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
@@ -2950,11 +3023,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
@@ -2967,6 +3061,7 @@ dependencies = [
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
@@ -2977,6 +3072,32 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
@@ -3006,6 +3127,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -3013,6 +3137,30 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_adapters"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||
dependencies = [
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_blob"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||
dependencies = [
|
||||
"icu_provider",
|
||||
"postcard",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -3328,6 +3476,9 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -4320,12 +4471,24 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5406,7 +5569,10 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5578,6 +5744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -6623,6 +6790,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
@@ -6936,6 +7114,9 @@ name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
@@ -7192,8 +7373,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7202,6 +7386,7 @@ version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+6
-6
@@ -11,9 +11,9 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/effectcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", version = "=0.1.0", default-features = false, features = ["read"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", features = ["egui"] }
|
||||
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-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# internal
|
||||
effectcraft-time = { path = "crates/time" }
|
||||
effectcraft-geom = { path = "crates/geom" }
|
||||
@@ -108,7 +108,7 @@ pollster = "1"
|
||||
# ui
|
||||
# Shared frontend components; keep this dependency above the engine boundary.
|
||||
# Shared UI and the complete egui family select reviewed immutable commits below.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
# `default_fonts` and `monochrome_emoji_fonts` are explicit: the app's `FontDefinitions::default()`
|
||||
# and its `✔`/`▣` chrome glyphs need both, and kittest's own dev-dependencies do not propagate.
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
@@ -220,8 +220,8 @@ lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", version = "=0.7.2" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", version = "=0.3.0" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -2,3 +2,6 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
|
||||
+169
-24
@@ -26,9 +26,38 @@ pub(crate) struct Packet {
|
||||
pub(crate) cto: i32,
|
||||
}
|
||||
|
||||
pub(crate) trait VideoEncoder {
|
||||
/// A frame for the movie encoder: 8-bit RGBA, or 8-bit 4:2:0 planes already converted on the
|
||||
/// render threads (for encoders whose [`VideoEncoder::takes_yuv420`] is true).
|
||||
pub(crate) enum Pixels {
|
||||
Rgba(Vec<u8>),
|
||||
Yuv420(Yuv420),
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 8-bit 4:2:0 planes ([`rgba_to_yuv420`]).
|
||||
pub(crate) struct Yuv420 {
|
||||
y: Vec<u8>,
|
||||
u: Vec<u8>,
|
||||
v: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Yuv420 {
|
||||
fn from_rgba(rgba: &[u8], w: usize, h: usize) -> Yuv420 {
|
||||
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
|
||||
rgba_to_yuv420(rgba, w, h, &mut y, &mut u, &mut v);
|
||||
Yuv420 { y, u, v }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait VideoEncoder: Send {
|
||||
fn sample_entry(&self) -> SampleEntry;
|
||||
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>>;
|
||||
/// Whether frames should come as [`Pixels::Yuv420`] (converted while other frames render).
|
||||
fn takes_yuv420(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn encode_yuv420(&mut self, _frame: &Yuv420, _index: u64) -> Result<Vec<Packet>> {
|
||||
Err(ExportError::Unsupported("this encoder takes RGBA frames".into()))
|
||||
}
|
||||
fn flush(&mut self) -> Result<Vec<Packet>>;
|
||||
/// Edit-list media start (B-frame delay) in the track timescale.
|
||||
fn media_start(&self) -> Option<i64> {
|
||||
@@ -73,9 +102,18 @@ impl VideoEncoder for H264 {
|
||||
SampleEntry::avc(cfg, self.w as u16, self.h as u16)
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>> {
|
||||
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut self.y, &mut self.u, &mut self.v);
|
||||
let mut planes = Yuv420 { y: std::mem::take(&mut self.y), u: std::mem::take(&mut self.u), v: std::mem::take(&mut self.v) };
|
||||
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut planes.y, &mut planes.u, &mut planes.v);
|
||||
let r = self.encode_yuv420(&planes, index);
|
||||
(self.y, self.u, self.v) = (planes.y, planes.u, planes.v);
|
||||
r
|
||||
}
|
||||
fn takes_yuv420(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn encode_yuv420(&mut self, p: &Yuv420, index: u64) -> Result<Vec<Packet>> {
|
||||
let cw = (self.w as usize).div_ceil(2);
|
||||
let frame = filmcraft_h264enc::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: self.w as usize, uv_stride: cw };
|
||||
let frame = filmcraft_h264enc::YuvFrame { y: &p.y, u: &p.u, v: &p.v, y_stride: self.w as usize, uv_stride: cw };
|
||||
let ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?;
|
||||
Ok(self.packets(ps))
|
||||
}
|
||||
@@ -88,6 +126,25 @@ impl VideoEncoder for H264 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v / 255` for every 8-bit `v` (the same values as dividing).
|
||||
pub(crate) const UNIT8: [f32; 256] = {
|
||||
let mut t = [0.0; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
t[i] = i as f32 / 255.0;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// `v.round()` for `0 <= v < 2^23`, the range the colour conversions round in: truncation and
|
||||
/// the fraction are exact there, so this is `f32::round` without its libm call.
|
||||
#[inline(always)]
|
||||
pub(crate) fn round_pos(v: f32) -> f32 {
|
||||
let t = v as u32 as f32;
|
||||
if v - t >= 0.5 { t + 1.0 } else { t }
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored).
|
||||
pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
@@ -95,14 +152,13 @@ pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u
|
||||
u.resize(cw * ch, 0);
|
||||
v.resize(cw * ch, 0);
|
||||
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
|
||||
let rows = yr.len() / w;
|
||||
let mut acc = vec![(0f32, 0f32, 0f32); cw];
|
||||
for dy in 0..rows {
|
||||
for (dy, yrow) in yr.chunks_exact_mut(w).enumerate() {
|
||||
let src = &rgba[(cy * 2 + dy) * w * 4..][..w * 4];
|
||||
for x in 0..w {
|
||||
let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0);
|
||||
for (x, (s, yo)) in src.as_chunks::<4>().0.iter().zip(yrow.iter_mut()).enumerate() {
|
||||
let (r, g, b) = (UNIT8[s[0] as usize], UNIT8[s[1] as usize], UNIT8[s[2] as usize]);
|
||||
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
yr[dy * w + x] = (16.0 + 219.0 * l).round().clamp(1.0, 254.0) as u8;
|
||||
*yo = round_pos(16.0 + 219.0 * l).clamp(1.0, 254.0) as u8;
|
||||
let a = &mut acc[x / 2];
|
||||
a.0 += (b - l) / 1.8556;
|
||||
a.1 += (r - l) / 1.5748;
|
||||
@@ -110,8 +166,8 @@ pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u
|
||||
}
|
||||
}
|
||||
for (cx, a) in acc.iter().enumerate() {
|
||||
ur[cx] = (128.0 + 224.0 * a.0 / a.2).round().clamp(1.0, 254.0) as u8;
|
||||
vr[cx] = (128.0 + 224.0 * a.1 / a.2).round().clamp(1.0, 254.0) as u8;
|
||||
ur[cx] = round_pos(128.0 + 224.0 * a.0 / a.2).clamp(1.0, 254.0) as u8;
|
||||
vr[cx] = round_pos(128.0 + 224.0 * a.1 / a.2).clamp(1.0, 254.0) as u8;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -265,15 +321,34 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
};
|
||||
let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 };
|
||||
let batch = batch_size();
|
||||
let render = |_: u64, img: effectcraft_raster::Image| -> Vec<u8> { job.pixels(&img, comp, channels, w, h) };
|
||||
let yuv = venc.takes_yuv420();
|
||||
let render = |_: u64, img: effectcraft_raster::Image| -> Pixels {
|
||||
let px = job.pixels(&img, comp, channels, w, h);
|
||||
if yuv { Pixels::Yuv420(Yuv420::from_rgba(&px, w as usize, h as usize)) } else { Pixels::Rgba(px) }
|
||||
};
|
||||
let encode = |venc: &mut Box<dyn VideoEncoder>, px: &Pixels, index: u64| match px {
|
||||
Pixels::Rgba(rgba) => venc.encode(rgba, index),
|
||||
Pixels::Yuv420(planes) => venc.encode_yuv420(planes, index),
|
||||
};
|
||||
|
||||
let beside = crate::pipeline::Beside::shared();
|
||||
let total = st.total;
|
||||
|
||||
// Encode the first batch before creating tracks: encoders finalise their config on frame 1.
|
||||
let first_end = batch.min(st.total);
|
||||
let first: Vec<Vec<u8>> = job.frames(comp, (0..first_end).collect(), render).await;
|
||||
let mut pending = Vec::new();
|
||||
for (k, px) in first.iter().enumerate() {
|
||||
pending.extend(venc.encode(px, k as u64)?);
|
||||
}
|
||||
// Each batch encodes while the next one renders.
|
||||
let first_end = batch.min(total);
|
||||
let first: Vec<Pixels> = job.frames(comp, (0..first_end).collect(), render).await;
|
||||
let next_end = (first_end + batch).min(total);
|
||||
let (pending, mut ready) = job
|
||||
.frames_beside(beside, comp, (first_end..next_end).collect(), render, || -> Result<Vec<Packet>> {
|
||||
let mut pending = Vec::new();
|
||||
for (k, px) in first.iter().enumerate() {
|
||||
pending.extend(encode(&mut venc, px, k as u64)?);
|
||||
}
|
||||
Ok(pending)
|
||||
})
|
||||
.await?;
|
||||
let pending = pending?;
|
||||
drop(first);
|
||||
st.advance(first_end)?;
|
||||
|
||||
@@ -351,19 +426,25 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let total = st.total;
|
||||
let frame_end_time = |k: u64| -> Tick { if k >= total { span_end } else { job.settings.frame_time(comp, k) } };
|
||||
write_video(&mut mux, pending)?;
|
||||
write_audio_until(&mut mux, frame_end_time(first_end), &mut cursor)?;
|
||||
let mut i = first_end;
|
||||
while i < total {
|
||||
let end = (i + batch).min(total);
|
||||
let frames: Vec<Vec<u8>> = job.frames(comp, (i..end).collect(), render).await;
|
||||
for (k, px) in frames.iter().enumerate() {
|
||||
let ps = venc.encode(px, i + k as u64)?;
|
||||
write_video(&mut mux, ps)?;
|
||||
}
|
||||
write_audio_until(&mut mux, frame_end_time(end), &mut cursor)?;
|
||||
let next_end = (end + batch).min(total);
|
||||
let frames = std::mem::take(&mut ready);
|
||||
let (done, next) = job
|
||||
.frames_beside(beside, comp, (end..next_end).collect(), render, || -> Result<()> {
|
||||
for (k, px) in frames.iter().enumerate() {
|
||||
let ps = encode(&mut venc, px, i + k as u64)?;
|
||||
write_video(&mut mux, ps)?;
|
||||
}
|
||||
write_audio_until(&mut mux, frame_end_time(end), &mut cursor)
|
||||
})
|
||||
.await?;
|
||||
done?;
|
||||
ready = next;
|
||||
st.advance(end - i)?;
|
||||
i = end;
|
||||
}
|
||||
@@ -384,6 +465,70 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The conversion as first written (per-pixel division and `f32::round`).
|
||||
fn yuv420_reference(rgba: &[u8], w: usize, h: usize) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let (mut y, mut u, mut v) = (vec![0u8; w * h], vec![0u8; cw * ch], vec![0u8; cw * ch]);
|
||||
for cy in 0..ch {
|
||||
let mut acc = vec![(0f32, 0f32, 0f32); cw];
|
||||
for row in cy * 2..(cy * 2 + 2).min(h) {
|
||||
for x in 0..w {
|
||||
let s = &rgba[(row * w + x) * 4..];
|
||||
let (r, g, b) = (s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0);
|
||||
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
y[row * w + x] = (16.0 + 219.0 * l).round().clamp(1.0, 254.0) as u8;
|
||||
let a = &mut acc[x / 2];
|
||||
a.0 += (b - l) / 1.8556;
|
||||
a.1 += (r - l) / 1.5748;
|
||||
a.2 += 1.0;
|
||||
}
|
||||
}
|
||||
for (cx, a) in acc.iter().enumerate() {
|
||||
u[cy * cw + cx] = (128.0 + 224.0 * a.0 / a.2).round().clamp(1.0, 254.0) as u8;
|
||||
v[cy * cw + cx] = (128.0 + 224.0 * a.1 / a.2).round().clamp(1.0, 254.0) as u8;
|
||||
}
|
||||
}
|
||||
(y, u, v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_pos_is_round() {
|
||||
for i in 0..2_000_000u32 {
|
||||
let v = i as f32 * 0.000_25;
|
||||
assert_eq!(round_pos(v).to_bits(), v.round().to_bits(), "{v}");
|
||||
let n = f32::from_bits(v.to_bits().wrapping_sub(1)).max(0.0);
|
||||
assert_eq!(round_pos(n).to_bits(), n.round().to_bits(), "{n}");
|
||||
}
|
||||
for k in 0..1024u32 {
|
||||
for v in [k as f32 + 0.5, f32::from_bits((k as f32 + 0.5).to_bits() - 1), f32::from_bits((k as f32 + 0.5).to_bits() + 1)] {
|
||||
assert_eq!(round_pos(v), v.round(), "{v}");
|
||||
}
|
||||
}
|
||||
for (i, u) in UNIT8.iter().enumerate() {
|
||||
assert_eq!(u.to_bits(), (i as f32 / 255.0).to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuv420_matches_the_reference() {
|
||||
// Every 8-bit level in every channel, odd sizes, and pseudo-random pixels.
|
||||
for (w, h) in [(1, 1), (2, 2), (3, 5), (17, 9), (64, 33)] {
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
let mut seed = (w * 31 + h) as u32;
|
||||
for (i, b) in rgba.iter_mut().enumerate() {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
*b = if i % 7 == 0 { (i / 4 % 256) as u8 } else { (seed >> 24) as u8 };
|
||||
}
|
||||
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
|
||||
rgba_to_yuv420(&rgba, w, h, &mut y, &mut u, &mut v);
|
||||
assert_eq!((y, u, v), yuv420_reference(&rgba, w, h), "{w}x{h}");
|
||||
}
|
||||
let grey: Vec<u8> = (0..=255u8).flat_map(|g| [g, g, g, 255, g, 255 - g, g / 2, 255]).collect();
|
||||
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
|
||||
rgba_to_yuv420(&grey, 512, 1, &mut y, &mut u, &mut v);
|
||||
assert_eq!((y, u, v), yuv420_reference(&grey, 512, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuv_of_primaries() {
|
||||
let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255];
|
||||
|
||||
@@ -91,6 +91,24 @@ pub(crate) fn map_frames_while<I: Send, T: Send>(
|
||||
out
|
||||
}
|
||||
|
||||
/// Threads that encode one batch of frames while the next renders ([`Cx::frames_beside`]).
|
||||
/// Their own pool: the encoder's parallel steps (colour conversion) would otherwise queue behind
|
||||
/// the render's work in the global pool. `None` where threads can't be started (a browser
|
||||
/// without them): encoding and rendering then take turns.
|
||||
pub(crate) struct Beside(Option<rayon::ThreadPool>);
|
||||
|
||||
impl Beside {
|
||||
fn new() -> Beside {
|
||||
Beside(rayon::ThreadPoolBuilder::new().thread_name(|i| format!("export-encode-{i}")).build().ok())
|
||||
}
|
||||
|
||||
/// The process's encode threads, started by the first movie export and shared by all.
|
||||
pub fn shared() -> &'static Beside {
|
||||
static SHARED: std::sync::OnceLock<Beside> = std::sync::OnceLock::new();
|
||||
SHARED.get_or_init(Beside::new)
|
||||
}
|
||||
}
|
||||
|
||||
/// A job being exported: the job, the project with the Render Settings overrides applied, the
|
||||
/// job's layer cache and per-frame timing for the render log.
|
||||
pub(crate) struct Cx<'a> {
|
||||
@@ -214,6 +232,40 @@ impl<'a> Cx<'a> {
|
||||
map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f)
|
||||
}
|
||||
|
||||
/// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the
|
||||
/// render threads don't wait for the (largely sequential) encoder of the previous batch.
|
||||
/// With deferred readbacks, or where no encode thread could be started, `encode` runs first.
|
||||
pub async fn frames_beside<T: Send, A: Send>(
|
||||
&self,
|
||||
beside: &Beside,
|
||||
comp: &Comp,
|
||||
ks: Vec<u64>,
|
||||
f: impl Fn(u64, Image) -> T + Sync + Send,
|
||||
encode: impl FnOnce() -> A + Send,
|
||||
) -> crate::Result<(A, Vec<T>)> {
|
||||
match &beside.0 {
|
||||
Some(pool) if !self.deferred() => {
|
||||
let mut done = None;
|
||||
let frames = pool.in_place_scope(|s| {
|
||||
s.spawn(|_| done = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(encode))));
|
||||
effectcraft_render::passes::block_on(self.frames(comp, ks, f))
|
||||
});
|
||||
match done {
|
||||
Some(Ok(a)) => Ok((a, frames)),
|
||||
Some(Err(panic)) => {
|
||||
let why = panic.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| panic.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
Err(crate::ExportError::Encode(format!("the encoder stopped unexpectedly: {why}")))
|
||||
}
|
||||
None => Err(crate::ExportError::Encode("the encoder stopped unexpectedly".into())),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let a = encode();
|
||||
Ok((a, self.frames(comp, ks, f).await))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every
|
||||
/// frame in flight holds its own working textures, so a whole batch rendered at once can
|
||||
/// exhaust VRAM (or shared memory, which crashes instead of falling back). `None` when the
|
||||
@@ -706,6 +758,45 @@ mod tests {
|
||||
assert!(stats.hits >= 9, "{stats:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_render_beside_the_encoder() {
|
||||
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::FrameRate;
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(32, 16, FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into()));
|
||||
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::H264));
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &output,
|
||||
path: "/c/out.mp4",
|
||||
sink: None,
|
||||
nested_switches: true,
|
||||
options: Default::default(),
|
||||
};
|
||||
let cx = Cx::new(&job);
|
||||
let comp = cx.comp().unwrap();
|
||||
use effectcraft_render::passes::block_on;
|
||||
let want = block_on(cx.frames(comp, (0..6).collect(), |k, img| (k, img.width, img.height)));
|
||||
// On threads of their own, and taking turns (no threads): the same frames, in order.
|
||||
for beside in [Beside::shared(), &Beside(None)] {
|
||||
let (a, frames) = block_on(cx.frames_beside(beside, comp, (0..6).collect(), |k, img| (k, img.width, img.height), || 7)).unwrap();
|
||||
assert_eq!((a, &frames), (7, &want));
|
||||
}
|
||||
// A panicking encoder fails the export instead of taking the render thread down.
|
||||
let r = block_on(cx.frames_beside(Beside::shared(), comp, vec![0], |k, _| k, || -> u32 { panic!("encoder bug") }));
|
||||
match r {
|
||||
Err(crate::ExportError::Encode(e)) => assert!(e.contains("encoder bug"), "{e}"),
|
||||
other => panic!("{:?}", other.map(|(a, f)| (a, f.len()))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleave_takes_alternate_lines() {
|
||||
let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
@@ -4,6 +4,7 @@ mod audio_sample_positions;
|
||||
mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod mov_audio_sample_positions;
|
||||
mod movie_bytes;
|
||||
mod ms_rounded_footage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_audio_sample_positions;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Movie exports pinned byte for byte: a comp longer than one render batch, with a rotating
|
||||
//! stroked ring (mostly transparent, bilinear), a fading half-transparent solid at a fractional
|
||||
//! position and a Screen-mode shape, in 8 bpc. The comp is at most four macroblock rows high,
|
||||
//! so the H.264 encoder writes one slice per frame on any number of cores. The hashes are those of the files written before
|
||||
//! frames rendered while the previous batch encoded, before transparent source tiles were
|
||||
//! skipped and before blended pixels were quantised as they were drawn: those changes must not
|
||||
//! move a single byte.
|
||||
|
||||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::{Job, Sink, export};
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
fn fnv1a(data: &[u8]) -> u64 {
|
||||
data.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3))
|
||||
}
|
||||
|
||||
fn project() -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(160, 64, FrameRate::new(30, 1), Tick::from_seconds_f64(2.0));
|
||||
comp.background = [0.05, 0.1, 0.3];
|
||||
let t = Tick::from_seconds_f64;
|
||||
|
||||
let sid = p.add_item("Half", Label::Red, None, ItemKind::Solid(Solid { color: [0.9, 0.5, 0.1], width: 70, height: 40, pixel_aspect: 1.0 }));
|
||||
let mut solid = build::layer(&mut p, &comp, "Half", LayerSource::Solid { item: sid }, (70, 40), None);
|
||||
if let Some(pr) = solid.props.prop_mut("transform/position") {
|
||||
pr.value = Value::Vec3([60.25, 36.5, 0.0]);
|
||||
}
|
||||
if let Some(pr) = solid.props.prop_mut("transform/opacity") {
|
||||
pr.keys = vec![Keyframe::new(t(0.0), Value::Scalar(20.0)), Keyframe::new(t(2.0), Value::Scalar(70.0))];
|
||||
}
|
||||
|
||||
let mut ring = build::layer(&mut p, &comp, "Ring", LayerSource::Shape, (160, 64), None);
|
||||
let mut box_ = build::layer(&mut p, &comp, "Box", LayerSource::Shape, (160, 64), None);
|
||||
let mut next = p.next_id;
|
||||
{
|
||||
let mut ids = Ids(&mut next);
|
||||
let e = build::shape_ellipse(&mut ids, [56.0, 56.0], [0.0, 0.0]);
|
||||
let s = build::shape_stroke(&mut ids, [0.3, 0.8, 1.0, 1.0], 3.0);
|
||||
let g = build::shape_group(&mut ids, "Ring", vec![e, s]);
|
||||
if let Some(c) = ring.props.sub_mut("contents") {
|
||||
c.children.push(g.into());
|
||||
}
|
||||
let r = build::shape_rect(&mut ids, [30.0, 20.0], [25.0, -10.0], 4.0);
|
||||
let f = build::shape_fill(&mut ids, [0.2, 1.0, 0.4, 0.8]);
|
||||
let g = build::shape_group(&mut ids, "Box", vec![r, f]);
|
||||
if let Some(c) = box_.props.sub_mut("contents") {
|
||||
c.children.push(g.into());
|
||||
}
|
||||
}
|
||||
p.next_id = next;
|
||||
if let Some(pr) = ring.props.prop_mut("transform/rotation") {
|
||||
pr.keys = vec![Keyframe::new(t(0.0), Value::Scalar(0.0)), Keyframe::new(t(2.0), Value::Scalar(130.0))];
|
||||
}
|
||||
if let Some(pr) = box_.props.prop_mut("transform/position") {
|
||||
pr.keys = vec![Keyframe::new(t(0.0), Value::Vec3([70.0, 30.0, 0.0])), Keyframe::new(t(2.0), Value::Vec3([95.5, 38.25, 0.0]))];
|
||||
}
|
||||
box_.blend_mode = BlendMode::Screen;
|
||||
|
||||
comp.layers = vec![box_, ring, solid];
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn export_bytes(format: OutputFormat, name: &str) -> Vec<u8> {
|
||||
let (p, cid) = project();
|
||||
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let om = OutputModule::for_format(format);
|
||||
let out = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let file = out.clone();
|
||||
let sink: &Sink = &move |_: &str, data: Vec<u8>| *file.lock().unwrap() = data;
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &om,
|
||||
path: name,
|
||||
sink: Some(sink),
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
let r = export(&job, &mut |_| true).expect("export");
|
||||
assert_eq!(r.frames, 60);
|
||||
std::mem::take(&mut *out.lock().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_movie_bytes_are_pinned() {
|
||||
let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4");
|
||||
assert_eq!((data.len(), fnv1a(&data)), (104_161, 10_466_446_817_305_548_344), "H.264 MP4");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
@@ -138,7 +138,7 @@ ffmpeg elsewhere) to check the code tables against an independent implementation
|
||||
## Shared parser setup and bounds
|
||||
|
||||
The workspace pins `craft-pdf` to `craft-libs` Git revision
|
||||
`512b54355cd1418f4e8c56a3fbd7483c3c274bf3`, with `default-features = false` and only the explicit `read`
|
||||
`92d392cff132eaf53e13afff77f38b5c11b73aa0`, with `default-features = false` and only the explicit `read`
|
||||
feature. This excludes the backend's bundled fonts and Adobe CMaps and preserves EffectCraft's
|
||||
font and asset policies. The shared parser does not change the supported drawing operators,
|
||||
image codecs, continuously rasterized SVG tree, editable shapes, or EPS interpreter.
|
||||
|
||||
@@ -18,10 +18,32 @@ use rayon::prelude::*;
|
||||
|
||||
pub use blur::{box_blur, directional_blur, gaussian_blur, gaussian_blur_exact, gaussian_is_exact, radial_blur};
|
||||
pub use channels3d::AuxChannels;
|
||||
pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample};
|
||||
pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_changed, composite_warp_reference, resample};
|
||||
|
||||
pub type Px = [f32; 4];
|
||||
|
||||
/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels):
|
||||
/// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer
|
||||
/// conversion rounds like `round()` and vectorises.
|
||||
#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN
|
||||
#[inline(always)]
|
||||
pub fn quantize_px(p: &mut Px, levels: f32, inv: f32) {
|
||||
let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv;
|
||||
let hi = [a, a, a, a];
|
||||
for c in 0..3 {
|
||||
p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv;
|
||||
}
|
||||
p[3] = a;
|
||||
}
|
||||
|
||||
/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's
|
||||
/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm
|
||||
/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there).
|
||||
#[inline(always)]
|
||||
fn round_half_up(v: f32) -> f32 {
|
||||
if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
@@ -199,26 +221,33 @@ impl Image {
|
||||
}
|
||||
/// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored).
|
||||
pub fn to_rgba8(&self) -> Vec<u8> {
|
||||
let mut out = vec![0u8; self.data.len() * 4];
|
||||
out.par_chunks_mut(4).zip(self.data.par_iter()).for_each(|(o, p)| {
|
||||
self.map_rgba8(|o, p| {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 };
|
||||
for c in 0..3 {
|
||||
o[c] = ((p[c] * inv).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
o[3] = (a * 255.0 + 0.5) as u8;
|
||||
});
|
||||
out
|
||||
})
|
||||
}
|
||||
/// Composite over an opaque background colour and return opaque 8-bit RGBA.
|
||||
pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec<u8> {
|
||||
let mut out = vec![0u8; self.data.len() * 4];
|
||||
out.par_chunks_mut(4).zip(self.data.par_iter()).for_each(|(o, p)| {
|
||||
self.map_rgba8(|o, p| {
|
||||
let k = 1.0 - p[3].clamp(0.0, 1.0);
|
||||
for c in 0..3 {
|
||||
o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
o[3] = 255;
|
||||
})
|
||||
}
|
||||
/// 8-bit RGBA with each pixel written by `f`, row-parallel.
|
||||
fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec<u8> {
|
||||
let mut out = vec![0u8; self.data.len() * 4];
|
||||
let w = self.width.max(1) as usize;
|
||||
out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| {
|
||||
for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) {
|
||||
f(o, p);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
+299
-73
@@ -57,7 +57,15 @@ fn half(src: &Image) -> Image {
|
||||
/// bilinear without bounds checks, integer-translation copies, inlined Normal blending); it
|
||||
/// matches [`composite_warp_reference`] to float rounding.
|
||||
pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) {
|
||||
composite_warp_impl(dst, src, m, opts, true, None);
|
||||
composite_warp_impl(dst, src, m, opts, true, None, None);
|
||||
}
|
||||
|
||||
/// [`composite_warp`], also returning the destination pixels it changed when it knows them:
|
||||
/// `Some([x0, y0, x1, y1])` (half-open, empty when nothing was drawn) on the fast affine path,
|
||||
/// `None` otherwise. With `quantize` (an 8/16 bpc destination's levels), the fast path also
|
||||
/// quantises each pixel it blends ([`crate::quantize_px`]): exactly when this returns `Some`.
|
||||
pub fn composite_warp_changed(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, quantize: Option<f32>) -> Option<[u32; 4]> {
|
||||
composite_warp_impl(dst, src, m, opts, true, None, quantize)
|
||||
}
|
||||
|
||||
/// `dst += warp(src) · weight` (plain sum, no blending): motion-blur sub-samples accumulate
|
||||
@@ -65,7 +73,7 @@ pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) {
|
||||
pub fn accumulate_warp(dst: &mut Image, src: &Image, m: &Mat3, sampling: Sampling, weight: f32) {
|
||||
let opts = WarpOpts { sampling, ..Default::default() };
|
||||
if m.is_affine() && m.inverse().is_some_and(|i| i.is_affine()) {
|
||||
composite_warp_impl(dst, src, m, &opts, true, Some(weight));
|
||||
composite_warp_impl(dst, src, m, &opts, true, Some(weight), None);
|
||||
return;
|
||||
}
|
||||
let mut tmp = Image::new(dst.width, dst.height);
|
||||
@@ -79,7 +87,7 @@ pub fn accumulate_warp(dst: &mut Image, src: &Image, m: &Mat3, sampling: Samplin
|
||||
|
||||
/// The straightforward per-pixel implementation (reference for the fast path's tests).
|
||||
pub fn composite_warp_reference(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) {
|
||||
composite_warp_impl(dst, src, m, opts, false, None);
|
||||
composite_warp_impl(dst, src, m, opts, false, None, None);
|
||||
}
|
||||
|
||||
/// Bilinear blend of four taps (same operation order as [`Image::sample_bilinear`]).
|
||||
@@ -94,9 +102,49 @@ fn bilerp(a: Px, b: Px, c: Px, d: Px, tx: f32, ty: f32) -> Px {
|
||||
o
|
||||
}
|
||||
|
||||
/// Bilinear sample with an unchecked interior fast path.
|
||||
/// Side of the square source tiles of [`ClearTiles`].
|
||||
const TILE: usize = 16;
|
||||
|
||||
/// Which `TILE`×`TILE` source tiles are fully transparent, the one-pixel apron to their right
|
||||
/// and below included: the four taps of a bilinear sample whose top-left tap lies in such a
|
||||
/// tile all have zero alpha, so the sample is transparent and draws nothing.
|
||||
struct ClearTiles {
|
||||
cols: usize,
|
||||
clear: Vec<bool>,
|
||||
}
|
||||
|
||||
impl ClearTiles {
|
||||
fn new(img: &Image) -> ClearTiles {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let (cols, rows) = (w.div_ceil(TILE), h.div_ceil(TILE));
|
||||
let clear = (0..rows)
|
||||
.into_par_iter()
|
||||
.flat_map_iter(|ty| {
|
||||
let mut row_clear = vec![true; cols];
|
||||
for y in ty * TILE..(ty * TILE + TILE + 1).min(h) {
|
||||
let row = &img.data[y * w..(y + 1) * w];
|
||||
for (tx, c) in row_clear.iter_mut().enumerate() {
|
||||
if *c {
|
||||
*c = row[tx * TILE..(tx * TILE + TILE + 1).min(w)].iter().all(|p| p[3] == 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
row_clear
|
||||
})
|
||||
.collect();
|
||||
ClearTiles { cols, clear }
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn is_clear(&self, x: usize, y: usize) -> bool {
|
||||
self.clear.get((y / TILE) * self.cols + x / TILE).copied().unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bilinear sample with an unchecked interior fast path; `None` when every interior tap is in a
|
||||
/// [`ClearTiles`] tile (the sample would have zero alpha).
|
||||
#[inline(always)]
|
||||
fn sample_bilinear_fast(img: &Image, x: f64, y: f64) -> Px {
|
||||
fn sample_bilinear_fast(img: &Image, clear: &ClearTiles, x: f64, y: f64) -> Option<Px> {
|
||||
let fx = x - 0.5;
|
||||
let fy = y - 0.5;
|
||||
let x0 = fx.floor();
|
||||
@@ -104,14 +152,17 @@ fn sample_bilinear_fast(img: &Image, x: f64, y: f64) -> Px {
|
||||
let (xi, yi) = (x0 as i64, y0 as i64);
|
||||
let (w, h) = (img.width as i64, img.height as i64);
|
||||
if xi >= 0 && yi >= 0 && xi + 1 < w && yi + 1 < h {
|
||||
if clear.is_clear(xi as usize, yi as usize) {
|
||||
return None;
|
||||
}
|
||||
let tx = (fx - x0) as f32;
|
||||
let ty = (fy - y0) as f32;
|
||||
let i = yi as usize * w as usize + xi as usize;
|
||||
let row0 = &img.data[i..i + 2];
|
||||
let row1 = &img.data[i + w as usize..i + w as usize + 2];
|
||||
return bilerp(row0[0], row0[1], row1[0], row1[1], tx, ty);
|
||||
return Some(bilerp(row0[0], row0[1], row1[0], row1[1], tx, ty));
|
||||
}
|
||||
img.sample_bilinear(x, y)
|
||||
Some(img.sample_bilinear(x, y))
|
||||
}
|
||||
|
||||
/// Index range `[lo, hi)` of `i` in `0..n` for which `p0 + i·s` may lie in `[min, max]`
|
||||
@@ -127,7 +178,33 @@ fn span(p0: f64, s: f64, min: f64, max: f64, n: usize) -> (usize, usize) {
|
||||
if hi <= lo { (0, 0) } else { (lo as usize, hi as usize) }
|
||||
}
|
||||
|
||||
/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels.
|
||||
/// Columns `[a, b)` of one destination row that a warp changed (`a == b`: none).
|
||||
#[derive(Clone, Copy)]
|
||||
struct RowSpan {
|
||||
a: usize,
|
||||
b: usize,
|
||||
}
|
||||
|
||||
impl RowSpan {
|
||||
const NONE: RowSpan = RowSpan { a: usize::MAX, b: 0 };
|
||||
#[inline(always)]
|
||||
fn add(&mut self, x: usize) {
|
||||
self.a = self.a.min(x);
|
||||
self.b = x + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Union of changed pixels as `[x0, y0, x1, y1]` (half-open; `x0 >= x1` when empty).
|
||||
fn union(r: [usize; 4], q: [usize; 4]) -> [usize; 4] {
|
||||
match (r[0] < r[2], q[0] < q[2]) {
|
||||
(false, _) => q,
|
||||
(_, false) => r,
|
||||
_ => [r[0].min(q[0]), r[1].min(q[1]), r[2].max(q[2]), r[3].max(q[3])],
|
||||
}
|
||||
}
|
||||
|
||||
/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels. Returns
|
||||
/// the rectangle of the pixels it blended.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn affine_rows(
|
||||
dst: &mut Image,
|
||||
@@ -135,12 +212,14 @@ fn affine_rows(
|
||||
inv: &Mat3,
|
||||
sampling: Sampling,
|
||||
op: f32,
|
||||
quantize: Option<f32>,
|
||||
x0: usize,
|
||||
x1: usize,
|
||||
y0: usize,
|
||||
y1: usize,
|
||||
blend: impl Fn(Px, Px, u32, u32) -> Px + Sync,
|
||||
) {
|
||||
) -> [usize; 4] {
|
||||
let levels = quantize.map(|l| (l, 1.0 / l));
|
||||
let w = dst.width as usize;
|
||||
let m = inv.0;
|
||||
let (iw, ih) = (img.width as f64, img.height as f64);
|
||||
@@ -148,71 +227,104 @@ fn affine_rows(
|
||||
// Pure integer translation: every sample lands on a pixel centre, so all filters return the
|
||||
// source pixel itself (bicubic still clamps negatives, as in the reference).
|
||||
let int_tx = m[0][0] == 1.0 && m[1][1] == 1.0 && m[0][1] == 0.0 && m[1][0] == 0.0 && m[0][2].fract() == 0.0 && m[1][2].fract() == 0.0;
|
||||
dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| {
|
||||
let fy = y as f64 + 0.5;
|
||||
let p0 = vec2(m[0][0] * (x0 as f64 + 0.5) + m[0][1] * fy + m[0][2], m[1][0] * (x0 as f64 + 0.5) + m[1][1] * fy + m[1][2]);
|
||||
let n = x1 - x0;
|
||||
let (ax, bx) = span(p0.x, step.x, -1.0, iw + 1.0, n);
|
||||
let (ay, by) = span(p0.y, step.y, -1.0, ih + 1.0, n);
|
||||
let (lo, hi) = (ax.max(ay), bx.min(by));
|
||||
if lo >= hi {
|
||||
return;
|
||||
}
|
||||
if int_tx {
|
||||
let sy = y as i64 + m[1][2] as i64;
|
||||
if sy < 0 || sy >= img.height as i64 {
|
||||
return;
|
||||
// Bilinear samples skip the transparent parts of the source (most of a thin shape's or a
|
||||
// glow-padded layer's buffer) without reading them.
|
||||
let clear = (sampling == Sampling::Bilinear && !int_tx).then(|| ClearTiles::new(img));
|
||||
let empty = [usize::MAX, usize::MAX, 0, 0];
|
||||
dst.data
|
||||
.par_chunks_mut(w)
|
||||
.enumerate()
|
||||
.skip(y0)
|
||||
.take(y1 - y0)
|
||||
.map(|(y, row)| {
|
||||
let mut changed = RowSpan::NONE;
|
||||
let fy = y as f64 + 0.5;
|
||||
let p0 = vec2(m[0][0] * (x0 as f64 + 0.5) + m[0][1] * fy + m[0][2], m[1][0] * (x0 as f64 + 0.5) + m[1][1] * fy + m[1][2]);
|
||||
let n = x1 - x0;
|
||||
let (ax, bx) = span(p0.x, step.x, -1.0, iw + 1.0, n);
|
||||
let (ay, by) = span(p0.y, step.y, -1.0, ih + 1.0, n);
|
||||
let (lo, hi) = (ax.max(ay), bx.min(by));
|
||||
if lo >= hi {
|
||||
return empty;
|
||||
}
|
||||
let dx = m[0][2] as i64;
|
||||
let srow = &img.data[sy as usize * img.width as usize..(sy as usize + 1) * img.width as usize];
|
||||
let xa = (x0 as i64 + lo as i64).max(-dx).max(0) as usize;
|
||||
let xb = ((x0 + hi) as i64).min(img.width as i64 - dx).max(0) as usize;
|
||||
for x in xa..xb.max(xa) {
|
||||
let mut s = srow[(x as i64 + dx) as usize];
|
||||
if sampling == Sampling::Bicubic {
|
||||
s = [s[0].max(0.0), s[1].max(0.0), s[2].max(0.0), s[3].max(0.0)];
|
||||
if int_tx {
|
||||
let sy = y as i64 + m[1][2] as i64;
|
||||
if sy < 0 || sy >= img.height as i64 {
|
||||
return empty;
|
||||
}
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if op < 1.0 {
|
||||
for c in s.iter_mut() {
|
||||
*c *= op;
|
||||
let dx = m[0][2] as i64;
|
||||
let srow = &img.data[sy as usize * img.width as usize..(sy as usize + 1) * img.width as usize];
|
||||
let xa = (x0 as i64 + lo as i64).max(-dx).max(0) as usize;
|
||||
let xb = ((x0 + hi) as i64).min(img.width as i64 - dx).max(0) as usize;
|
||||
for x in xa..xb.max(xa) {
|
||||
let mut s = srow[(x as i64 + dx) as usize];
|
||||
if sampling == Sampling::Bicubic {
|
||||
s = [s[0].max(0.0), s[1].max(0.0), s[2].max(0.0), s[3].max(0.0)];
|
||||
}
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if op < 1.0 {
|
||||
for c in s.iter_mut() {
|
||||
*c *= op;
|
||||
}
|
||||
}
|
||||
row[x] = blend(row[x], s, x as u32, y as u32);
|
||||
if let Some((l, inv)) = levels {
|
||||
crate::quantize_px(&mut row[x], l, inv);
|
||||
}
|
||||
changed.add(x);
|
||||
}
|
||||
row[x] = blend(row[x], s, x as u32, y as u32);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in lo..hi {
|
||||
let x = x0 + i;
|
||||
let p = vec2(p0.x + step.x * i as f64, p0.y + step.y * i as f64);
|
||||
if p.x < -1.0 || p.y < -1.0 || p.x > iw + 1.0 || p.y > ih + 1.0 {
|
||||
continue;
|
||||
}
|
||||
let mut s = match sampling {
|
||||
Sampling::Nearest => img.get(p.x.floor() as i64, p.y.floor() as i64),
|
||||
Sampling::Bilinear => sample_bilinear_fast(img, p.x, p.y),
|
||||
Sampling::Bicubic => img.sample_bicubic(p.x, p.y),
|
||||
};
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if op < 1.0 {
|
||||
for c in s.iter_mut() {
|
||||
*c *= op;
|
||||
} else {
|
||||
for i in lo..hi {
|
||||
let x = x0 + i;
|
||||
let p = vec2(p0.x + step.x * i as f64, p0.y + step.y * i as f64);
|
||||
if p.x < -1.0 || p.y < -1.0 || p.x > iw + 1.0 || p.y > ih + 1.0 {
|
||||
continue;
|
||||
}
|
||||
let mut s = match (sampling, &clear) {
|
||||
(Sampling::Nearest, _) => img.get(p.x.floor() as i64, p.y.floor() as i64),
|
||||
(Sampling::Bilinear, Some(clear)) => match sample_bilinear_fast(img, clear, p.x, p.y) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
},
|
||||
(Sampling::Bilinear, None) => img.sample_bilinear(p.x, p.y),
|
||||
(Sampling::Bicubic, _) => img.sample_bicubic(p.x, p.y),
|
||||
};
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if op < 1.0 {
|
||||
for c in s.iter_mut() {
|
||||
*c *= op;
|
||||
}
|
||||
}
|
||||
row[x] = blend(row[x], s, x as u32, y as u32);
|
||||
if let Some((l, inv)) = levels {
|
||||
crate::quantize_px(&mut row[x], l, inv);
|
||||
}
|
||||
changed.add(x);
|
||||
}
|
||||
}
|
||||
row[x] = blend(row[x], s, x as u32, y as u32);
|
||||
}
|
||||
});
|
||||
if changed.a < changed.b { [changed.a, y, changed.b, y + 1] } else { empty }
|
||||
})
|
||||
.reduce(|| empty, union)
|
||||
}
|
||||
|
||||
fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, allow_fast: bool, accumulate: Option<f32>) {
|
||||
fn composite_warp_impl(
|
||||
dst: &mut Image,
|
||||
src: &Image,
|
||||
m: &Mat3,
|
||||
opts: &WarpOpts,
|
||||
allow_fast: bool,
|
||||
accumulate: Option<f32>,
|
||||
quantize: Option<f32>,
|
||||
) -> Option<[u32; 4]> {
|
||||
const NOTHING: Option<[u32; 4]> = Some([0; 4]);
|
||||
if src.is_empty() || dst.is_empty() || opts.opacity <= 0.0 {
|
||||
return;
|
||||
return NOTHING;
|
||||
}
|
||||
let Some(inv) = m.inverse() else { return };
|
||||
let Some(inv) = m.inverse() else { return NOTHING };
|
||||
// Minification: pick a pre-filtered level so sampling doesn't alias.
|
||||
let scale = m.mean_scale();
|
||||
let mut level_img;
|
||||
@@ -239,7 +351,7 @@ fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts,
|
||||
area = area.intersect(&c);
|
||||
}
|
||||
if area.is_empty() {
|
||||
return;
|
||||
return NOTHING;
|
||||
}
|
||||
let (x0, x1) = (area.x0.max(0.0) as usize, area.x1.min(dst.width as f64) as usize);
|
||||
let (y0, y1) = (area.y0.max(0.0) as usize, area.y1.min(dst.height as f64) as usize);
|
||||
@@ -247,25 +359,28 @@ fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts,
|
||||
let affine = inv.is_affine();
|
||||
let mode = opts.mode;
|
||||
let op = opts.opacity;
|
||||
let changed = |r: [usize; 4]| Some(r.map(|v| v.min(u32::MAX as usize) as u32));
|
||||
if let Some(k) = accumulate
|
||||
&& affine
|
||||
{
|
||||
affine_rows(dst, img, &inv, opts.sampling, 1.0, x0, x1, y0, y1, |d, s, _, _| [d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k]);
|
||||
return;
|
||||
let r = affine_rows(dst, img, &inv, opts.sampling, 1.0, None, x0, x1, y0, y1, |d, s, _, _| {
|
||||
[d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k]
|
||||
});
|
||||
return changed(r);
|
||||
}
|
||||
if allow_fast && affine && !mode.is_stencil() {
|
||||
match mode {
|
||||
BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| {
|
||||
let r = match mode {
|
||||
BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| {
|
||||
let k = 1.0 - s[3];
|
||||
[s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k]
|
||||
}),
|
||||
BlendMode::Dissolve | BlendMode::DancingDissolve => {
|
||||
let seed = opts.seed;
|
||||
affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed)))
|
||||
affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed)))
|
||||
}
|
||||
_ => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)),
|
||||
}
|
||||
return;
|
||||
_ => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)),
|
||||
};
|
||||
return changed(r);
|
||||
}
|
||||
dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| {
|
||||
let fy = y as f64 + 0.5;
|
||||
@@ -312,6 +427,7 @@ fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts,
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resize with bilinear filtering (box pre-filter when shrinking).
|
||||
@@ -394,6 +510,116 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mostly transparent source: a thin ring with a few stray pixels (one with negative zero alpha).
|
||||
fn sparse_image(w: u32, h: u32) -> Image {
|
||||
let mut img = Image::new(w, h);
|
||||
let (cx, cy, r) = (w as f32 / 2.0, h as f32 / 2.0, w.min(h) as f32 * 0.4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let d = ((x as f32 + 0.5 - cx).hypot(y as f32 + 0.5 - cy) - r).abs();
|
||||
if d < 1.5 {
|
||||
let a = 1.0 - d / 1.5;
|
||||
img.set(x, y, [0.2 * a, 0.6 * a, a, a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
img.set(3, 2, [0.5, 0.5, 0.5, 0.5]);
|
||||
img.set(w - 1, h - 1, [1.0; 4]);
|
||||
img.set(w / 2, 1, [0.0, 0.0, 0.0, -0.0]);
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_tiles_hold_only_transparent_taps() {
|
||||
let img = sparse_image(150, 97);
|
||||
let clear = ClearTiles::new(&img);
|
||||
let mut skipped = 0;
|
||||
for y in 0..img.height as usize - 1 {
|
||||
for x in 0..img.width as usize - 1 {
|
||||
if clear.is_clear(x, y) {
|
||||
skipped += 1;
|
||||
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||
assert_eq!(img.get(x as i64 + dx, y as i64 + dy)[3], 0.0, "tap ({}, {}) of a clear tile", x as i64 + dx, y as i64 + dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(skipped > 100 * 70, "the ring's empty middle is skipped: {skipped}");
|
||||
}
|
||||
|
||||
/// The changed rectangle holds every pixel a warp changed, and the skipped transparent
|
||||
/// samples leave the result as the reference path draws it.
|
||||
#[test]
|
||||
fn changed_rect_covers_the_changed_pixels() {
|
||||
let src = sparse_image(90, 70);
|
||||
let mats = [
|
||||
Mat3::translate(vec2(7.0, 3.0)),
|
||||
Mat3::translate(vec2(10.25, 4.5)),
|
||||
Mat3::translate(vec2(60.0, 50.0)) * Mat3::rotate_deg(33.0) * Mat3::scale(vec2(0.9, 1.2)) * Mat3::translate(vec2(-45.0, -35.0)),
|
||||
Mat3::translate(vec2(-30.0, -20.0)),
|
||||
Mat3::scale(vec2(0.3, 0.3)),
|
||||
];
|
||||
for (mi, m) in mats.iter().enumerate() {
|
||||
for mode in [BlendMode::Normal, BlendMode::Add, BlendMode::Dissolve] {
|
||||
let opts = WarpOpts { mode, seed: 3, ..Default::default() };
|
||||
let before = test_image(120, 100, 4);
|
||||
let mut a = before.clone();
|
||||
let r = composite_warp_changed(&mut a, &src, m, &opts, None);
|
||||
let Some([x0, y0, x1, y1]) = r else { panic!("mat {mi}: the fast path tracks what it changed") };
|
||||
for (i, (p, q)) in a.data.iter().zip(&before.data).enumerate() {
|
||||
let (x, y) = (i as u32 % a.width, i as u32 / a.width);
|
||||
if p != q {
|
||||
assert!(x >= x0 && x < x1 && y >= y0 && y < y1, "mat {mi} {mode:?}: ({x}, {y}) changed outside {r:?}");
|
||||
}
|
||||
}
|
||||
let mut b = before.clone();
|
||||
composite_warp_reference(&mut b, &src, m, &opts);
|
||||
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((p[c] - q[c]).abs() < 1e-4, "mat {mi} {mode:?} px {i}: {p:?} vs {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut dst = Image::new(10, 10);
|
||||
assert_eq!(composite_warp_changed(&mut dst, &Image::new(4, 4), &Mat3::IDENTITY, &WarpOpts::default(), None), Some([u32::MAX, u32::MAX, 0, 0]));
|
||||
let stencil = WarpOpts { mode: BlendMode::StencilAlpha, ..Default::default() };
|
||||
assert_eq!(composite_warp_changed(&mut dst, &src, &Mat3::IDENTITY, &stencil, Some(255.0)), None, "stencils change the whole destination");
|
||||
}
|
||||
|
||||
/// Quantising while blending equals blending, then quantising the changed rectangle of an
|
||||
/// already quantised destination (what an 8/16 bpc comp did after every layer), bit for bit.
|
||||
#[test]
|
||||
fn quantize_while_blending_matches_quantizing_after() {
|
||||
let src = sparse_image(90, 70);
|
||||
let mats = [Mat3::translate(vec2(7.0, 3.0)), Mat3::translate(vec2(60.0, 50.0)) * Mat3::rotate_deg(33.0) * Mat3::translate(vec2(-45.0, -35.0))];
|
||||
for m in &mats {
|
||||
for mode in [BlendMode::Normal, BlendMode::Screen, BlendMode::Dissolve] {
|
||||
for (levels, opacity) in [(255.0f32, 1.0), (32_768.0, 0.7)] {
|
||||
let mut before = test_image(120, 100, 8);
|
||||
for p in &mut before.data {
|
||||
crate::quantize_px(p, levels, 1.0 / levels);
|
||||
}
|
||||
let opts = WarpOpts { mode, opacity, seed: 5, ..Default::default() };
|
||||
let mut after = before.clone();
|
||||
let Some([x0, y0, x1, y1]) = composite_warp_changed(&mut after, &src, m, &opts, None) else { panic!("fast path") };
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let i = after.idx(x, y);
|
||||
crate::quantize_px(&mut after.data[i], levels, 1.0 / levels);
|
||||
}
|
||||
}
|
||||
let mut fused = before.clone();
|
||||
let r = composite_warp_changed(&mut fused, &src, m, &opts, Some(levels));
|
||||
assert_eq!(r, Some([x0, y0, x1, y1]));
|
||||
for (i, (p, q)) in fused.data.iter().zip(&after.data).enumerate() {
|
||||
assert_eq!(p.map(f32::to_bits), q.map(f32::to_bits), "{mode:?} {levels} px {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulating sub-samples equals warping each into its own image and summing.
|
||||
#[test]
|
||||
fn accumulate_matches_per_sample_images() {
|
||||
|
||||
+45
-23
@@ -21,7 +21,7 @@
|
||||
|
||||
use effectcraft_color::{ColorSpace, Conversion};
|
||||
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::{Image, quantize_px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// The colour pipeline of a project (see the module docs).
|
||||
@@ -218,27 +218,6 @@ pub fn quantize(img: &mut Image, levels: f32) {
|
||||
quantize_rect(img, PxRect { x0: 0, y0: 0, x1: img.width, y1: img.height }, levels);
|
||||
}
|
||||
|
||||
/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up; values are non-negative after the
|
||||
/// clamp, so the integer conversion rounds like `round()` and vectorises.
|
||||
#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN
|
||||
#[inline(always)]
|
||||
fn quantize_px(p: &mut [f32; 4], levels: f32, inv: f32) {
|
||||
let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv;
|
||||
let hi = [a, a, a, a];
|
||||
for c in 0..3 {
|
||||
p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv;
|
||||
}
|
||||
p[3] = a;
|
||||
}
|
||||
|
||||
/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's
|
||||
/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm
|
||||
/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there).
|
||||
#[inline(always)]
|
||||
fn round_half_up(v: f32) -> f32 {
|
||||
if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 }
|
||||
}
|
||||
|
||||
/// [`quantize`] restricted to a rectangle (already clamped to the image), row-parallel.
|
||||
fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) {
|
||||
if r.x0 >= r.x1 || r.y0 >= r.y1 {
|
||||
@@ -248,7 +227,17 @@ fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) {
|
||||
let w = img.width as usize;
|
||||
let (x0, x1) = (r.x0 as usize, r.x1 as usize);
|
||||
img.data[r.y0 as usize * w..r.y1 as usize * w].par_chunks_mut(w).for_each(|row| {
|
||||
for p in &mut row[x0..x1] {
|
||||
// Runs of all-zero pixels (+0.0 in every channel) quantise to themselves: skip them a
|
||||
// cache line at a time (sparse shape, precomp and glow buffers are mostly empty).
|
||||
let (blocks, rest) = row[x0..x1].as_chunks_mut::<4>();
|
||||
for b in blocks {
|
||||
if b.as_flattened().iter().fold(0, |a, v| a | v.to_bits()) != 0 {
|
||||
for p in b {
|
||||
quantize_px(p, levels, inv);
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in rest {
|
||||
quantize_px(p, levels, inv);
|
||||
}
|
||||
});
|
||||
@@ -307,6 +296,39 @@ pub fn output_hdr(img: &mut Image, to_linear: Option<Conversion>, mode: HdrMode,
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Skipping all-zero runs leaves every pixel as quantising it one by one does, bit for bit
|
||||
/// (negative zeros and lone pixels in empty runs included).
|
||||
#[test]
|
||||
fn quantize_skips_only_zero_runs() {
|
||||
let mut img = Image::new(23, 7);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
*p = match i % 11 {
|
||||
0 => [0.3, -0.2, 0.71, 0.9],
|
||||
3 => [-0.0, 0.0, 0.0, 0.0],
|
||||
5 => [0.0, 0.0, 0.0, -0.0],
|
||||
7 => [1.7, 0.4, f32::NAN, 0.5],
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
}
|
||||
for levels in [255.0f32, 32_768.0] {
|
||||
let mut want = img.clone();
|
||||
for p in &mut want.data {
|
||||
quantize_px(p, levels, 1.0 / levels);
|
||||
}
|
||||
for r in [PxRect { x0: 0, y0: 0, x1: 23, y1: 7 }, PxRect { x0: 3, y0: 1, x1: 20, y1: 6 }] {
|
||||
let mut got = img.clone();
|
||||
quantize_rect(&mut got, r, levels);
|
||||
for (i, (g, p)) in got.data.iter().zip(&img.data).enumerate() {
|
||||
let (x, y) = (i as u32 % 23, i as u32 / 23);
|
||||
let w = if x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1 { want.data[i] } else { *p };
|
||||
assert_eq!(g.map(f32::to_bits), w.map(f32::to_bits), "{levels} {r:?} px ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Both forms `round_half_up` picks between agree on everything quantising feeds it:
|
||||
/// 0..=levels, exact halves included.
|
||||
#[test]
|
||||
|
||||
@@ -342,11 +342,26 @@ impl<'a> Renderer<'a> {
|
||||
|
||||
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
|
||||
pub(crate) fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) {
|
||||
self.place_changed(ctx, layer, buf, target, mode, opacity, None);
|
||||
}
|
||||
|
||||
/// [`Self::place`], returning the target pixels it changed when the warp tracked them (then
|
||||
/// also quantised to `quantize` levels; see [`composite_warp_changed`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn place_changed(
|
||||
&self,
|
||||
ctx: &EvalCtx,
|
||||
layer: &Layer,
|
||||
buf: &Buf,
|
||||
target: &mut Image,
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
quantize: Option<f32>,
|
||||
) -> Option<[u32; 4]> {
|
||||
let pl = self.placement(ctx, layer, buf);
|
||||
let opts = WarpOpts { sampling: pl.sampling, opacity, mode, seed: pl.seed, clip: None };
|
||||
if let [m] = pl.matrices.as_slice() {
|
||||
composite_warp(target, &buf.img, m, &opts);
|
||||
return;
|
||||
return composite_warp_changed(target, &buf.img, m, &opts, quantize);
|
||||
}
|
||||
// Sub-samples sum straight into one buffer (each warp is row-parallel).
|
||||
let mut acc = Image::new(target.width, target.height);
|
||||
@@ -355,6 +370,7 @@ impl<'a> Renderer<'a> {
|
||||
effectcraft_raster::accumulate_warp(&mut acc, &buf.img, m, opts.sampling, k);
|
||||
}
|
||||
target.blend_from(&acc, mode, opacity, pl.seed);
|
||||
None
|
||||
}
|
||||
|
||||
/// Draw a 2D layer into `canvas`; returns the region it may have changed.
|
||||
@@ -377,8 +393,14 @@ impl<'a> Renderer<'a> {
|
||||
let st = self.styled_to_blend(st);
|
||||
let t1 = web_time::Instant::now();
|
||||
let changed = if st.plain {
|
||||
self.composite_layer(ctx, layer, &st.body, canvas, opacity);
|
||||
self.footprint(ctx, layer, &st.body, opacity, blank)
|
||||
// Pixels the warp tracks are quantised as it blends them (nothing left to do); the
|
||||
// footprint is still needed when it doesn't, or when no pixel needs quantising.
|
||||
let footprint = self.footprint(ctx, layer, &st.body, opacity, blank);
|
||||
let quantize = if footprint == Region::Empty { None } else { self.pipe.levels };
|
||||
match self.composite_layer(ctx, layer, &st.body, canvas, opacity, quantize) {
|
||||
Some(_) if quantize.is_some() => Region::Empty,
|
||||
drawn => narrow(footprint, drawn),
|
||||
}
|
||||
} else {
|
||||
self.composite_styled(ctx, layer, &st, canvas, opacity);
|
||||
Region::Full
|
||||
@@ -432,16 +454,18 @@ impl<'a> Renderer<'a> {
|
||||
});
|
||||
}
|
||||
|
||||
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) {
|
||||
/// Composite a plain layer; returns the canvas pixels it changed when they are known (then
|
||||
/// also quantised to `quantize` levels).
|
||||
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32, quantize: Option<f32>) -> Option<[u32; 4]> {
|
||||
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
|
||||
if !matte && !layer.preserve_transparency {
|
||||
self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity);
|
||||
return;
|
||||
return self.place_changed(ctx, layer, buf, canvas, layer.blend_mode, opacity, quantize);
|
||||
}
|
||||
// Render in isolation, then matte / preserve transparency, then blend.
|
||||
let mut iso = Image::new(canvas.width, canvas.height);
|
||||
self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0);
|
||||
self.composite_iso(ctx, layer, iso, canvas, opacity);
|
||||
None
|
||||
}
|
||||
|
||||
/// Apply the track matte / preserve transparency to an isolated layer render, then blend.
|
||||
@@ -556,3 +580,21 @@ pub struct Placement {
|
||||
/// Dissolve noise seed.
|
||||
pub seed: u32,
|
||||
}
|
||||
|
||||
/// The part of `footprint` a warp actually changed (`drawn`, see [`composite_warp_changed`]):
|
||||
/// pixels it left alone are still quantised.
|
||||
fn narrow(footprint: Region, drawn: Option<[u32; 4]>) -> Region {
|
||||
let Some([x0, y0, x1, y1]) = drawn else { return footprint };
|
||||
if x0 >= x1 || y0 >= y1 {
|
||||
return Region::Empty;
|
||||
}
|
||||
let r = color::PxRect { x0, y0, x1, y1 };
|
||||
match footprint {
|
||||
Region::Empty => Region::Empty,
|
||||
Region::Full => Region::Rect(r),
|
||||
Region::Rect(f) => {
|
||||
let q = color::PxRect { x0: r.x0.max(f.x0), y0: r.y0.max(f.y0), x1: r.x1.min(f.x1), y1: r.y1.min(f.y1) };
|
||||
if q.x0 >= q.x1 || q.y0 >= q.y1 { Region::Empty } else { Region::Rect(q) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Pa
|
||||
use effectcraft_geom::{Mat3, Mat4, vec2};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling};
|
||||
pub use effectcraft_raster::Image;
|
||||
use effectcraft_raster::{WarpOpts, composite_warp};
|
||||
use effectcraft_raster::{WarpOpts, composite_warp_changed};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size};
|
||||
use rayon::prelude::*;
|
||||
|
||||
@@ -254,7 +254,7 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) {
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(tr("Sort"));
|
||||
let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
|
||||
let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let r = ui.selectable_label(v.key == k, k.label().0);
|
||||
auto.add(&format!("about.credits.sort.{}", k.id()), r.rect, k.label().0);
|
||||
|
||||
@@ -286,7 +286,7 @@ pub fn show(
|
||||
.or_else(|| PRESETS.iter().find(|(_, w, h, par, f)| matches(d, *w, *h, *par, *f)).map(|p| p.0))
|
||||
.unwrap_or("Custom");
|
||||
ui.horizontal(|ui| {
|
||||
let r = egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| {
|
||||
// Your own presets first, then the built-in ones.
|
||||
for q in custom {
|
||||
if ui.selectable_label(q.name == current, &q.name).clicked() {
|
||||
@@ -350,7 +350,7 @@ pub fn show(
|
||||
.find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3)
|
||||
.map(|p| p.0.to_string())
|
||||
.unwrap_or_else(|| format!("{:.2}", d.pixel_aspect));
|
||||
let r = egui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| {
|
||||
for (name, v) in PIXEL_ASPECTS {
|
||||
if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, *name).clicked() {
|
||||
d.pixel_aspect = *v;
|
||||
@@ -369,7 +369,7 @@ pub fn show(
|
||||
// Any rate can be typed; the list holds the common ones.
|
||||
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3)));
|
||||
auto.add("dialog.comp.frameRate", r.rect, "Frame Rate");
|
||||
let r = egui::ComboBox::from_id_salt("comp-fps").width(18.0).selected_text("").show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt("comp-fps").width(18.0).selected_text("").show_ui(ui, |ui| {
|
||||
for f in FRAME_RATES {
|
||||
if ui.selectable_label((d.fps - f).abs() < 1e-3, format!("{f}")).clicked() {
|
||||
d.fps = *f;
|
||||
@@ -387,7 +387,7 @@ pub fn show(
|
||||
// the frame it renders beside it.
|
||||
ui.label(tr("Resolution:"));
|
||||
ui.horizontal(|ui| {
|
||||
let r = egui::ComboBox::from_id_salt("comp-resolution").width(90.0).selected_text(tr(d.resolution.label())).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt("comp-resolution").width(90.0).selected_text(tr(d.resolution.label())).show_ui(ui, |ui| {
|
||||
for res in Resolution::ALL {
|
||||
ui.selectable_value(&mut d.resolution, res, tr(res.label()));
|
||||
}
|
||||
@@ -474,7 +474,7 @@ pub fn show(
|
||||
Tab::Renderer => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Renderer:"));
|
||||
let r = egui::ComboBox::from_id_salt("comp-renderer").selected_text(tr(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" })).show_ui(
|
||||
let r = craft_ui::ComboBox::from_id_salt("comp-renderer").selected_text(tr(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" })).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.selectable_value(&mut d.advanced_3d, false, tr("Classic 3D"));
|
||||
|
||||
@@ -536,7 +536,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
.find(|(_, v)| (v - par).abs() < 1e-3)
|
||||
.map(|p| p.0.to_string())
|
||||
.unwrap_or_else(|| format!("{par:.2}"));
|
||||
let r = egui::ComboBox::from_id_salt("solid-par").width(240.0).selected_text(cur).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt("solid-par").width(240.0).selected_text(cur).show_ui(ui, |ui| {
|
||||
for (label, v) in super::comp_settings::PIXEL_ASPECTS {
|
||||
if ui.selectable_label((v - par).abs() < 1e-3, *label).clicked() {
|
||||
par = *v;
|
||||
|
||||
@@ -172,7 +172,7 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| {
|
||||
egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| {
|
||||
ui.label(tr("Type"));
|
||||
egui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| {
|
||||
craft_ui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut d.two_node, true, tr("Two-Node Camera"));
|
||||
ui.selectable_value(&mut d.two_node, false, tr("One-Node Camera"));
|
||||
});
|
||||
@@ -184,7 +184,7 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.label(tr("Preset"));
|
||||
let focal = focal_for_zoom(w, d.zoom);
|
||||
let cur = PRESETS.iter().find(|(_, f)| (f - focal).abs() < 1e-6).map(|(n, _)| *n).unwrap_or("Custom");
|
||||
egui::ComboBox::from_id_salt("cam-preset").selected_text(cur).show_ui(ui, |ui| {
|
||||
craft_ui::ComboBox::from_id_salt("cam-preset").selected_text(cur).show_ui(ui, |ui| {
|
||||
for (n, f) in PRESETS {
|
||||
if ui.selectable_label(cur == n, n).clicked() {
|
||||
d.zoom = zoom_for_focal(w, f);
|
||||
@@ -268,7 +268,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
crate::widgets::focus_on_open(ui.ctx(), &r, &d.name);
|
||||
ui.end_row();
|
||||
ui.label(tr("Light Type"));
|
||||
let combo = egui::ComboBox::from_id_salt("light-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| {
|
||||
let combo = craft_ui::ComboBox::from_id_salt("light-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| {
|
||||
for (i, k) in LightKind::ALL.into_iter().enumerate() {
|
||||
let item = ui.selectable_value(&mut d.kind, k, tr(k.label()));
|
||||
app.auto.add(&format!("dialog.light.typeItem.{i}"), item.rect, k.label());
|
||||
@@ -292,7 +292,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
}
|
||||
if falls_off(d.kind) {
|
||||
ui.label(tr("Falloff"));
|
||||
egui::ComboBox::from_id_salt("light-falloff").selected_text(FALLOFFS[d.falloff.min(2)]).show_ui(ui, |ui| {
|
||||
craft_ui::ComboBox::from_id_salt("light-falloff").selected_text(FALLOFFS[d.falloff.min(2)]).show_ui(ui, |ui| {
|
||||
for (i, f) in FALLOFFS.iter().enumerate() {
|
||||
ui.selectable_value(&mut d.falloff, i, *f);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) {
|
||||
// ---- Primary + Solo.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(tr("Primary:")).color(t.text_dim));
|
||||
let r = egui::ComboBox::from_id_salt("eg-primary").selected_text(&comp_name).width(150.0).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt("eg-primary").selected_text(&comp_name).width(150.0).show_ui(ui, |ui| {
|
||||
for (id, _) in project.comps() {
|
||||
let name = project.item(*id).map(|i| i.name.clone()).unwrap_or_default();
|
||||
if ui.selectable_label(*id == cid, name).clicked() {
|
||||
@@ -374,7 +374,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
|
||||
_ => vec![],
|
||||
};
|
||||
let cur = v.as_enum() as usize;
|
||||
let r = egui::ComboBox::from_id_salt(("eg-dd", auto)).selected_text(opts.get(cur).cloned().unwrap_or_default()).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt(("eg-dd", auto)).selected_text(opts.get(cur).cloned().unwrap_or_default()).show_ui(ui, |ui| {
|
||||
for (i, o) in opts.iter().enumerate() {
|
||||
if ui.selectable_label(i == cur, o).clicked() {
|
||||
out = Some(json!(i));
|
||||
@@ -437,7 +437,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
|
||||
let fams = effectcraft_engine::text::fonts::families();
|
||||
let mut d = (**doc).clone();
|
||||
let mut changed = false;
|
||||
let r1 = egui::ComboBox::from_id_salt(("eg-font", auto)).selected_text(&d.font).width(110.0).show_ui(ui, |ui| {
|
||||
let r1 = craft_ui::ComboBox::from_id_salt(("eg-font", auto)).selected_text(&d.font).width(110.0).show_ui(ui, |ui| {
|
||||
for (f, _) in &fams {
|
||||
if ui.selectable_label(*f == d.font, f).clicked() {
|
||||
d.font = f.clone();
|
||||
@@ -446,7 +446,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
|
||||
}
|
||||
});
|
||||
let styles: Vec<String> = fams.iter().find(|(f, _)| *f == d.font).map(|(_, st)| st.clone()).unwrap_or_default();
|
||||
let r2 = egui::ComboBox::from_id_salt(("eg-style", auto)).selected_text(&d.style).width(70.0).show_ui(ui, |ui| {
|
||||
let r2 = craft_ui::ComboBox::from_id_salt(("eg-style", auto)).selected_text(&d.style).width(70.0).show_ui(ui, |ui| {
|
||||
for st in &styles {
|
||||
if ui.selectable_label(*st == d.style, st).clicked() {
|
||||
d.style = st.clone();
|
||||
@@ -532,7 +532,7 @@ fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions)
|
||||
.filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_video))
|
||||
.map(|i| (i.id, i.name.clone()))
|
||||
.collect();
|
||||
let r = egui::ComboBox::from_id_salt(("eg-media", layer.id.0, c.id)).selected_text(cur_name).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt(("eg-media", layer.id.0, c.id)).selected_text(cur_name).show_ui(ui, |ui| {
|
||||
for (id, name) in &items {
|
||||
if ui.selectable_label(*id == cur, name).clicked() {
|
||||
actions.push(("essential.set".into(), json!({"layer": layer.id.0, "control": c.id, "item": id.0})));
|
||||
|
||||
@@ -830,7 +830,7 @@ fn form_grid(ui: &mut egui::Ui, f: &mut Form, regs: &mut Vec<(String, egui::Rect
|
||||
let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default();
|
||||
// The column keeps its width: a long choice (a GPU adapter's name) ends in an
|
||||
// ellipsis instead of widening the form past the dialog (#978).
|
||||
egui::ComboBox::from_id_salt(("form", fl.key.as_str()))
|
||||
craft_ui::ComboBox::from_id_salt(("form", fl.key.as_str()))
|
||||
.selected_text(tr(&cur))
|
||||
.width(240.0)
|
||||
.truncate()
|
||||
|
||||
@@ -191,7 +191,7 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
fn combo(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, sel: &mut usize, opts: &[&str], enabled: bool) {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
// The options are the English source strings; the automation id and label stay English.
|
||||
let r = egui::ComboBox::from_id_salt(id).width(200.0).selected_text(tr(opts[*sel])).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt(id).width(200.0).selected_text(tr(opts[*sel])).show_ui(ui, |ui| {
|
||||
for (i, o) in opts.iter().enumerate() {
|
||||
ui.selectable_value(sel, i, tr(o));
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
|
||||
}
|
||||
(KV::Enum(i), ParamUi::Popup { options }) => {
|
||||
let mut sel = *i as usize;
|
||||
egui::ComboBox::from_id_salt(("ls-popup", p.uid)).selected_text(options.get(sel).cloned().unwrap_or_default()).show_ui(ui, |ui| {
|
||||
craft_ui::ComboBox::from_id_salt(("ls-popup", p.uid)).selected_text(options.get(sel).cloned().unwrap_or_default()).show_ui(ui, |ui| {
|
||||
for (k, o) in options.iter().enumerate() {
|
||||
ui.selectable_value(&mut sel, k, o);
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.label(tr("Label:"));
|
||||
let labels = effectcraft_engine::color::Label::ALL;
|
||||
let cur = labels.get(d.label).copied().unwrap_or_default();
|
||||
let r = egui::ComboBox::from_id_salt("dialog.marker.label").selected_text(app.session.prefs.label_name(cur)).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt("dialog.marker.label").selected_text(app.session.prefs.label_name(cur)).show_ui(ui, |ui| {
|
||||
for (i, l) in labels.into_iter().enumerate() {
|
||||
if crate::widgets::label_entry(ui, l, &app.session.prefs.label_name(l), d.label == i, &app.tokens).clicked() {
|
||||
d.label = i;
|
||||
|
||||
@@ -58,7 +58,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
super::dialogs::modal(ctx, "New 3D Primitive", vec2(420.0, 300.0), t, |ui| {
|
||||
egui::Grid::new("primitive-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| {
|
||||
ui.label(tr("Type"));
|
||||
let combo = egui::ComboBox::from_id_salt("primitive-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| {
|
||||
let combo = craft_ui::ComboBox::from_id_salt("primitive-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| {
|
||||
for (i, k) in PrimitiveKind::ALL.into_iter().enumerate() {
|
||||
let item = ui.selectable_value(&mut d.kind, k, tr(k.label()));
|
||||
app.auto.add(&format!("dialog.primitive.typeItem.{i}"), item.rect, k.label());
|
||||
|
||||
@@ -60,7 +60,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.label(slot.label());
|
||||
let cur = templates.default_name(k, slot);
|
||||
let mut pick = cur.clone();
|
||||
let r = egui::ComboBox::from_id_salt(("tpl-def", slot.label())).selected_text(&cur).width(300.0).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt(("tpl-def", slot.label())).selected_text(&cur).width(300.0).show_ui(ui, |ui| {
|
||||
for n in &list {
|
||||
ui.selectable_value(&mut pick, n.clone(), n);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.label(egui::RichText::new(tr("Settings")).font(Tokens::semibold(12.5)).color(t.text));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Settings Name:"));
|
||||
let r = egui::ComboBox::from_id_salt("tpl-select").selected_text(&st.selected).width(300.0).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt("tpl-select").selected_text(&st.selected).width(300.0).show_ui(ui, |ui| {
|
||||
for n in &list {
|
||||
ui.selectable_value(&mut st.selected, n.clone(), n);
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2,
|
||||
let mut pick: Option<usize> = None;
|
||||
ui.add_enabled_ui(w.enabled, |ui| {
|
||||
ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
|
||||
egui::ComboBox::from_id_salt(id).selected_text(cur).width(rect.width() - 8.0).show_ui(ui, |ui| {
|
||||
craft_ui::ComboBox::from_id_salt(id).selected_text(cur).width(rect.width() - 8.0).show_ui(ui, |ui| {
|
||||
for (i, it) in w.items.iter().enumerate() {
|
||||
if it == "-" {
|
||||
ui.separator();
|
||||
|
||||
@@ -78,7 +78,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(label_w, 18.0), egui::Label::new(tr("Default Output:")));
|
||||
let shown = if sel.is_empty() { tr("System Default").to_string() } else { sel.clone() };
|
||||
let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(sel.is_empty(), tr("System Default")).clicked() {
|
||||
acts.push(Act::Set(key.into(), json!("")));
|
||||
}
|
||||
@@ -157,7 +157,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
Kind::Choice(opts) => {
|
||||
let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
|
||||
egui::ComboBox::from_id_salt(("settings", key))
|
||||
craft_ui::ComboBox::from_id_salt(("settings", key))
|
||||
.selected_text(tr(&shown))
|
||||
.width(220.0)
|
||||
.show_ui(ui, |ui| {
|
||||
@@ -378,7 +378,7 @@ fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &T
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(250.0, 18.0), egui::Label::new(format!("{}:", tr("Appearance Mode"))).truncate());
|
||||
let shown = APPEARANCE_MODES.iter().find(|(_, v)| *v == mode).map_or(mode.as_str(), |(l, _)| *l);
|
||||
let r = egui::ComboBox::from_id_salt(("settings", "appearance.appearanceMode"))
|
||||
let r = craft_ui::ComboBox::from_id_salt(("settings", "appearance.appearanceMode"))
|
||||
.selected_text(tr(shown))
|
||||
.width(220.0)
|
||||
.show_ui(ui, |ui| {
|
||||
|
||||
@@ -264,7 +264,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
// ---- preset bar
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Preset:"));
|
||||
let r = egui::ComboBox::from_id_salt("sc-preset").selected_text(&active).width(220.0).show_ui(ui, |ui| {
|
||||
let r = craft_ui::ComboBox::from_id_salt("sc-preset").selected_text(&active).width(220.0).show_ui(ui, |ui| {
|
||||
for n in &presets {
|
||||
if ui.selectable_label(*n == active, n).clicked() {
|
||||
run.push(("shortcuts.preset".into(), json!({"op": "select", "name": n})));
|
||||
|
||||
@@ -635,130 +635,131 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
return None;
|
||||
}
|
||||
app.auto.add(&format!("{auto}.toggle"), arrow, "Close font list");
|
||||
let mut chosen = None;
|
||||
let screen = ui.ctx().content_rect();
|
||||
let below = screen.bottom() - rect.bottom() - 16.0;
|
||||
let above = below < 100.0 && rect.top() - screen.top() > below;
|
||||
let max_height = (if above { rect.top() - screen.top() - 16.0 } else { below }).clamp(28.0, 380.0);
|
||||
let anchor = if above { rect.left_top() } else { rect.left_bottom() };
|
||||
let pivot = if above { Align2::LEFT_BOTTOM } else { Align2::LEFT_TOP };
|
||||
widgets::note_open_popup_list(ui.ctx());
|
||||
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(anchor).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(rect.width().max(180.0));
|
||||
// Filter row: a checkbox that keeps only the variable fonts.
|
||||
let filter_h = 22.0;
|
||||
let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, ""));
|
||||
let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h));
|
||||
let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max);
|
||||
let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check"));
|
||||
ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text);
|
||||
app.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only");
|
||||
let toggled = row.clicked() || boxed.clicked();
|
||||
if toggled {
|
||||
only_variable = !only_variable;
|
||||
ui.data_mut(|d| d.insert_temp(variable_id, only_variable));
|
||||
// Typing goes on into the search field.
|
||||
ui.memory_mut(|m| m.request_focus(search_id));
|
||||
let changed = search.changed();
|
||||
let mut open = true;
|
||||
// The search lives in the field; the menu's header is the filter row, its list the fonts.
|
||||
let menu = craft_ui::combo::Menu::new(ui, &search)
|
||||
.open(&mut open)
|
||||
.height(380.0)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.style(egui::style::StyleModifier::default());
|
||||
let app_ref = &mut *app;
|
||||
let header = |ui: &mut egui::Ui| {
|
||||
ui.set_width(rect.width().max(180.0));
|
||||
// Filter row: a checkbox that keeps only the variable fonts.
|
||||
let filter_h = 22.0;
|
||||
let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, ""));
|
||||
let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h));
|
||||
let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max);
|
||||
let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check"));
|
||||
ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text);
|
||||
app_ref.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only");
|
||||
let toggled = row.clicked() || boxed.clicked();
|
||||
if toggled {
|
||||
only_variable = !only_variable;
|
||||
ui.data_mut(|d| d.insert_temp(variable_id, only_variable));
|
||||
// Typing goes on into the search field.
|
||||
ui.memory_mut(|m| m.request_focus(search_id));
|
||||
}
|
||||
let query = state.query.trim().to_lowercase();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let filtered: Vec<_> = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
!r.family.is_empty()
|
||||
&& (!only_variable || r.variable)
|
||||
&& (query.is_empty() || ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family)))
|
||||
})
|
||||
.collect();
|
||||
if fresh {
|
||||
state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0);
|
||||
}
|
||||
if changed || toggled {
|
||||
state.selected = 0;
|
||||
}
|
||||
let last = filtered.len().saturating_sub(1);
|
||||
state.selected = state.selected.min(last);
|
||||
if down {
|
||||
state.selected = state.selected.saturating_add(1).min(last);
|
||||
}
|
||||
if up {
|
||||
state.selected = state.selected.saturating_sub(1);
|
||||
}
|
||||
let entered = if enter { filtered.get(state.selected).map(|r| r.family.clone()) } else { None };
|
||||
(app_ref, filtered, query.is_empty(), state.selected, changed || toggled, entered)
|
||||
};
|
||||
let list = |ui: &mut egui::Ui,
|
||||
(app, filtered, all, selected, restart, entered): (
|
||||
&mut EffectcraftApp,
|
||||
Vec<&effectcraft_engine::FontRow>,
|
||||
bool,
|
||||
usize,
|
||||
bool,
|
||||
Option<String>,
|
||||
)| {
|
||||
if restart {
|
||||
ui.scroll_to_cursor(Some(egui::Align::TOP));
|
||||
}
|
||||
let mut chosen = entered;
|
||||
if filtered.is_empty() {
|
||||
let r = ui.label(tr("No matching fonts"));
|
||||
app.auto.add(&format!("{auto}.empty"), r.rect, "No matching fonts");
|
||||
}
|
||||
let mut was_recent = false;
|
||||
for (index, r) in filtered.iter().enumerate() {
|
||||
if all && was_recent && !r.recent {
|
||||
ui.separator();
|
||||
}
|
||||
let query = state.query.trim().to_lowercase();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let filtered: Vec<_> = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
!r.family.is_empty()
|
||||
&& (!only_variable || r.variable)
|
||||
&& (query.is_empty()
|
||||
|| ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family)))
|
||||
})
|
||||
.collect();
|
||||
if fresh {
|
||||
state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0);
|
||||
was_recent = r.recent;
|
||||
let row_width = ui.available_width();
|
||||
let label_width = if preview { (row_width - 100.0).max(40.0) } else { row_width };
|
||||
let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == selected, ""));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), index == selected, &r.display));
|
||||
let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height()));
|
||||
ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text(
|
||||
label_rect.left_center(),
|
||||
Align2::LEFT_CENTER,
|
||||
&r.display,
|
||||
Tokens::ui(12.0),
|
||||
t.text,
|
||||
);
|
||||
if ui.is_rect_visible(resp.rect) {
|
||||
app.auto.add(&format!("{auto}.option.{}", r.family), resp.rect, &r.display);
|
||||
}
|
||||
if search.changed() || toggled {
|
||||
state.selected = 0;
|
||||
if index == selected && (fresh || up || down) {
|
||||
resp.scroll_to_me(None);
|
||||
}
|
||||
let last = filtered.len().saturating_sub(1);
|
||||
state.selected = state.selected.min(last);
|
||||
if down {
|
||||
state.selected = state.selected.saturating_add(1).min(last);
|
||||
}
|
||||
if up {
|
||||
state.selected = state.selected.saturating_sub(1);
|
||||
}
|
||||
if enter {
|
||||
chosen = filtered.get(state.selected).map(|r| r.family.clone());
|
||||
}
|
||||
// Give the result list its actual height even when the previous query had
|
||||
// only one row. A cached tiny Area must not collapse a reopened long list.
|
||||
let filter_room = filter_h + ui.spacing().item_spacing.y;
|
||||
let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, (max_height - filter_room).max(28.0));
|
||||
// A minimum, from below the filter row (a maximum would move the cursor back up).
|
||||
ui.set_min_height(height);
|
||||
let mut scroll = egui::ScrollArea::vertical().id_salt(id.with("results")).max_height(height).min_scrolled_height(height);
|
||||
if search.changed() || toggled {
|
||||
scroll = scroll.vertical_scroll_offset(0.0);
|
||||
}
|
||||
scroll.show(ui, |ui| {
|
||||
if filtered.is_empty() {
|
||||
let r = ui.label(tr("No matching fonts"));
|
||||
app.auto.add(&format!("{auto}.empty"), r.rect, "No matching fonts");
|
||||
if preview && ui.is_rect_visible(resp.rect) {
|
||||
let key = egui::Id::unique(("font-preview", &r.family));
|
||||
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0));
|
||||
ui.data_mut(|d| d.insert_temp(key, l.clone()));
|
||||
l
|
||||
}
|
||||
};
|
||||
// Scaled and placed to fit the row's preview column, and clipped to the row (#577).
|
||||
let room = Rect::from_min_max(pos2(resp.rect.max.x - 95.0, resp.rect.min.y + 1.0), pos2(resp.rect.max.x - 6.0, resp.rect.max.y - 1.0));
|
||||
if let Some((k, o)) = fit_preview(&lines, room, resp.rect.center().y + 5.0) {
|
||||
let painter = ui.painter().with_clip_rect(resp.rect.intersect(ui.clip_rect()));
|
||||
for poly in lines.iter() {
|
||||
let pts: Vec<egui::Pos2> =
|
||||
poly.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| (o + vec2(p[0], p[1]) * k).to_pos2()).collect();
|
||||
painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
|
||||
}
|
||||
}
|
||||
let mut was_recent = false;
|
||||
for (index, r) in filtered.iter().enumerate() {
|
||||
if query.is_empty() && was_recent && !r.recent {
|
||||
ui.separator();
|
||||
}
|
||||
was_recent = r.recent;
|
||||
let row_width = ui.available_width();
|
||||
let label_width = if preview { (row_width - 100.0).max(40.0) } else { row_width };
|
||||
let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, ""));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), index == state.selected, &r.display));
|
||||
let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height()));
|
||||
ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text(
|
||||
label_rect.left_center(),
|
||||
Align2::LEFT_CENTER,
|
||||
&r.display,
|
||||
Tokens::ui(12.0),
|
||||
t.text,
|
||||
);
|
||||
if ui.is_rect_visible(resp.rect) {
|
||||
app.auto.add(&format!("{auto}.option.{}", r.family), resp.rect, &r.display);
|
||||
}
|
||||
if index == state.selected && (fresh || up || down) {
|
||||
resp.scroll_to_me(None);
|
||||
}
|
||||
if preview && ui.is_rect_visible(resp.rect) {
|
||||
let key = egui::Id::unique(("font-preview", &r.family));
|
||||
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0));
|
||||
ui.data_mut(|d| d.insert_temp(key, l.clone()));
|
||||
l
|
||||
}
|
||||
};
|
||||
// Scaled and placed to fit the row's preview column, and clipped to the row (#577).
|
||||
let room = Rect::from_min_max(pos2(resp.rect.max.x - 95.0, resp.rect.min.y + 1.0), pos2(resp.rect.max.x - 6.0, resp.rect.max.y - 1.0));
|
||||
if let Some((k, o)) = fit_preview(&lines, room, resp.rect.center().y + 5.0) {
|
||||
let painter = ui.painter().with_clip_rect(resp.rect.intersect(ui.clip_rect()));
|
||||
for poly in lines.iter() {
|
||||
let pts: Vec<egui::Pos2> =
|
||||
poly.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| (o + vec2(p[0], p[1]) * k).to_pos2()).collect();
|
||||
painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
chosen = Some(r.family.clone());
|
||||
}
|
||||
resp.on_hover_text(&r.display);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos().is_some_and(|p| rect.contains(p)));
|
||||
if chosen.is_some() || outside || escape {
|
||||
}
|
||||
if resp.clicked() {
|
||||
chosen = Some(r.family.clone());
|
||||
}
|
||||
resp.on_hover_text(&r.display);
|
||||
}
|
||||
chosen
|
||||
};
|
||||
let chosen = menu.show_ui_with_header(header, list).flatten();
|
||||
if chosen.is_some() || !open || escape {
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(id.with("open"), false);
|
||||
d.remove::<FontSearch>(state_id);
|
||||
|
||||
@@ -537,7 +537,7 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens)
|
||||
(LowConfidence::Adapt, "Adapt Feature"),
|
||||
];
|
||||
let cur = actions.iter().find(|(a, _)| *a == d.opts.action).map(|(_, l)| *l).unwrap_or("Adapt Feature");
|
||||
let r = egui::ComboBox::from_id_salt("track-action")
|
||||
let r = craft_ui::ComboBox::from_id_salt("track-action")
|
||||
.selected_text(cur)
|
||||
.show_ui(ui, |ui| {
|
||||
for (a, l) in actions {
|
||||
@@ -590,7 +590,7 @@ pub fn target_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens)
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::i18n::tr("Layer:"));
|
||||
let cur = d.target.and_then(|id| layers.iter().find(|(l, _)| *l == id)).map(|(_, n)| n.clone()).unwrap_or_else(|| "None".into());
|
||||
let r = egui::ComboBox::from_id_salt("track-target")
|
||||
let r = craft_ui::ComboBox::from_id_salt("track-target")
|
||||
.selected_text(cur)
|
||||
.width(240.0)
|
||||
.show_ui(ui, |ui| {
|
||||
@@ -623,7 +623,7 @@ pub fn apply_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::i18n::tr("Apply Dimensions:"));
|
||||
let labels = ["X and Y", "X only", "Y only"];
|
||||
let r = egui::ComboBox::from_id_salt("track-apply-dims")
|
||||
let r = craft_ui::ComboBox::from_id_salt("track-apply-dims")
|
||||
.selected_text(labels[d.dims.min(2)])
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, l) in labels.iter().enumerate() {
|
||||
|
||||
@@ -176,3 +176,37 @@ fn font_menu_filters_variable_fonts() {
|
||||
key(&mut h, Key::Escape);
|
||||
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "filtering changes nothing");
|
||||
}
|
||||
|
||||
/// The font menu grows back when a query that matched nothing is cleared, while the search
|
||||
/// field keeps its place and the keyboard; a click on a row then picks that font.
|
||||
#[test]
|
||||
fn font_menu_grows_back_after_an_empty_search() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
|
||||
let lid = s.execute("layer.newText", json!({"text": "Grow", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::Character);
|
||||
h.run_steps(3);
|
||||
let rows = |h: &Harness<'_, EffectcraftApp>| h.state().auto.query("character.font.option.").len();
|
||||
let search = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find("character.font.search").map(|e| e.rect);
|
||||
click(&mut h, "character.font");
|
||||
h.run_steps(2);
|
||||
let (full, field) = (rows(&h), search(&h));
|
||||
assert!(full > 3, "the open menu shows the fonts: {full} rows");
|
||||
let query = "no_such_font_123456789";
|
||||
type_text(&mut h, query);
|
||||
assert_eq!(rows(&h), 0);
|
||||
assert!(h.state().auto.find("character.font.empty").is_some());
|
||||
for pressed in query.chars().flat_map(|_| [true, false]) {
|
||||
h.input_mut().events.push(Event::Key { key: Key::Backspace, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
// (Within a row: the list scrolls to the current font on opening and to the top when cleared.)
|
||||
assert!(rows(&h) + 1 >= full, "cleared, the menu shows as many fonts as when it opened: {} of {full}", rows(&h));
|
||||
assert_eq!(search(&h), field, "the search field stays put");
|
||||
// The field still has the keyboard: typing filters again.
|
||||
type_text(&mut h, "JetBrains");
|
||||
assert!(rows(&h) > 0 && h.state().auto.query("character.font.option.").iter().all(|r| r.label.contains("JetBrains")));
|
||||
click(&mut h, "character.font.option.JetBrains Mono");
|
||||
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono", "a click on a row picks the font");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const REV: &str = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3";
|
||||
const REV: &str = "92d392cff132eaf53e13afff77f38b5c11b73aa0";
|
||||
const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("hayro-syntax", "0.7.2"), ("hayro-jbig2", "0.3.0")];
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
|
||||
Reference in New Issue
Block a user