Merge branch 'feat/2100-instance3d-layer-create' (synced with main)

This commit is contained in:
Minher0
2026-10-29 10:18:43 +00:00
31 changed files with 1182 additions and 299 deletions
Generated
+191 -6
View File
@@ -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
View File
@@ -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" }
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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]);
+1
View File
@@ -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;
+104
View File
@@ -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");
}
+1 -1
View File
@@ -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.
+36 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+49 -7
View File
@@ -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) }
}
}
}
+1 -1
View File
@@ -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::*;
+1 -1
View File
@@ -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);
+5 -5
View File
@@ -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"));
+1 -1
View File
@@ -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;
+4 -4
View File
@@ -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);
}
+5 -5
View File
@@ -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})));
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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());
+2 -2
View File
@@ -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);
}
+1 -1
View File
@@ -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();
+3 -3
View File
@@ -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| {
+1 -1
View File
@@ -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})));
+120 -119
View File
@@ -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);
+3 -3
View File
@@ -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() {
+34
View File
@@ -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");
}
+1 -1
View File
@@ -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> {