Merge upstream/main into camtrack-validate-input

Conflict in crates/track/src/camtrack/bundle.rs (imports): upstream replaced
the linalg glob import with an explicit list; kept that list and added the
branch's SolveError import (use super::{Distortion, SolveError}). Input
validation, size budget and Result-returning adjust/cost are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-28 15:28:34 +01:00
co-authored by Claude Sonnet 5.5
237 changed files with 9767 additions and 5363 deletions
+24
View File
@@ -22,3 +22,27 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+4 -2
View File
@@ -28,8 +28,10 @@ permissions:
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: effectcraft
app-dir: EffectCraft
install-relative-path: Programs/ArtCraft/EffectCraft
install-scope: perUser
+1
View File
@@ -22,6 +22,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde.
- **Community links** (Help menu, About, header Discord button): https://discord.gg/artcraft, https://getartcraft.com, https://getartcraft.com/apps/effectcraft, https://github.com/storytold/effectcraft.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **New workspace crates** must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- **Commits:** one task id per commit (`M6.3: trim paths`). Only green states.
## Running and looking at the app
+3 -1
View File
@@ -135,9 +135,11 @@ sys-locale = "0.3"
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
needless_range_loop = "allow"
type_complexity = "allow"
+6 -2
View File
@@ -275,9 +275,13 @@ send us right now.
| arm64 (Snapdragon and other ARM PCs) | `effectcraft-<ver>-windows-arm64.msi` | `effectcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `effectcraft-<ver>-windows-x86.msi` | `effectcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed. The installer asks where to install EffectCraft
(Program Files by default), and later versions install into the same folder. To install silently
Installers and executables are code-signed. The installer asks who to install EffectCraft for and where. By default it installs for your
account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an administrator prompt;
"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft` (`Program Files (x86)` for the 32-bit build). Later versions
install into the same folder. To install silently
into a folder of your choice: `msiexec /i effectcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`.
A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for;
add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install.
### macOS
+20 -9
View File
@@ -152,6 +152,13 @@ fn essential_graphics_template_via_script_and_commands() {
assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit);
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
invalid_template_values_fail_cleanly(&dir);
template_usage_errors(&dir);
rational_template_keeps_exact_rate(&dir, &original_template);
let _ = std::fs::remove_dir_all(&dir);
}
fn invalid_template_values_fail_cleanly(dir: &PathBuf) {
// Invalid values fail before the queue creates an output directory, or frame rendering
// overwrites an existing output. A JSON object and existing control names are required.
for (name, values, expected) in [
@@ -164,25 +171,27 @@ fn essential_graphics_template_via_script_and_commands() {
std::fs::write(dir.join(&values_file), values).unwrap();
let output_dir = dir.join(format!("failed-{name}"));
let out = output_dir.join("frame_[#####].png");
let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
let (code, error) = cli(dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
assert_eq!(code, 1, "{name}: {error}");
assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}");
assert!(!output_dir.exists(), "{name}: invalid input created render output");
std::fs::write(dir.join("untouched.png"), b"existing output").unwrap();
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
assert_eq!(code, 1, "{name}: {error}");
assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output");
}
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
assert_eq!(code, 1, "{error}");
assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}");
assert!(!dir.join("missing.png").exists());
std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap();
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
assert_eq!(code, 1, "{error}");
assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}");
assert!(!dir.join("large.png").exists());
}
fn template_usage_errors(dir: &PathBuf) {
for args in [
vec!["render-frame", "--values", "values.json"],
vec!["info", "--template", "lt.ectemplate"],
@@ -191,10 +200,12 @@ fn essential_graphics_template_via_script_and_commands() {
vec!["render", "--template", "lt.ectemplate", "--queue"],
vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"],
] {
let output = Command::new(crate::binary()).current_dir(&dir).args(&args).output().unwrap();
let output = Command::new(crate::binary()).current_dir(dir).args(&args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
}
}
fn rational_template_keeps_exact_rate(dir: &PathBuf, original_template: &[u8]) {
// File-loaded templates can use exact rational rates that comp.new's float parser rounds.
// Preserve that rate, Tick duration, work area, background and locked source layers.
let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap();
@@ -207,13 +218,13 @@ fn essential_graphics_template_via_script_and_commands() {
comp.background = [0.08, 0.16, 0.24];
comp.layers.first_mut().unwrap().switches.locked = true;
std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap();
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
ok(dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap();
let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
let rational = ok(dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}");
assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90));
let output = dir.join("rational-work-area/frame_[#####].png");
ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
ok(dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
let count = std::fs::read_dir(dir.join("rational-work-area"))
.unwrap()
.filter_map(Result::ok)
@@ -222,8 +233,8 @@ fn essential_graphics_template_via_script_and_commands() {
assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6");
assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template);
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
let _ = std::fs::remove_dir_all(&dir);
}
fn template_output_alias_fixture(case: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
+3 -2
View File
@@ -311,10 +311,11 @@ pub fn stats() -> Value {
})
}
type PendingFrameCheck = (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64);
thread_local! {
/// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame).
#[allow(clippy::type_complexity)]
static CHECKS: RefCell<std::collections::HashMap<u64, (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64)>> = RefCell::new(Default::default());
static CHECKS: RefCell<std::collections::HashMap<u64, PendingFrameCheck>> = RefCell::new(Default::default());
}
/// `effectcraft.workerFrameCheck({time?, scale?, backend?})`: render the active comp's frame in
+1 -1
View File
@@ -9,8 +9,8 @@
//!
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `effectcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
mod appearance;
-2
View File
@@ -1,6 +1,4 @@
//! The desktop binary rejects a bad command line before it starts anything (#997).
// Integration tests: unwrapping and panicking on failure is fine here.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use std::process::{Command, Stdio};
+1
View File
@@ -5,3 +5,4 @@ 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)" },
]
cognitive-complexity-threshold = 30
+39 -12
View File
@@ -7,6 +7,10 @@ use serde_json::{Value, json};
use crate::harness::{Img, Qa, coverage, luma, mean_diff};
fn lum(i: &Img, x: u32, y: u32) -> f64 {
luma(i.get_pixel(x, y))
}
fn px(img: &Img, x: u32, y: u32) -> [u8; 4] {
img.get_pixel(x, y).0
}
@@ -25,13 +29,12 @@ fn write_sequence(qa: &Qa) -> String {
qa.path("seq/ball_000.png")
}
#[test]
fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let mut qa = Qa::new("compositing");
/// Import the sequence (reinterpreted at 12 fps) and a PSD written by the app; returns the
/// sequence item and the PSD's composition.
fn import_footage(qa: &mut Qa) -> (Value, Value) {
// Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸
// Photoshop Layers) from a small comp.
let seq = write_sequence(&qa);
let seq = write_sequence(qa);
let imp = qa.exec("file.import", json!({"paths": [seq]}));
let seq_item = imp["items"][0].clone();
let project = qa.tool("get_project", json!({}));
@@ -55,10 +58,14 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let pc = qa.tool("get_comp", json!({"comp": psd_comp.clone()}));
let names: Vec<&str> = pc["layers"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Bar", "Gray"], "the PSD's layers, top first: {pc}");
(seq_item, psd_comp)
}
/// The main comp: the sequence plays, then a Screen-blended PSD; returns the Normal-mode frame.
fn main_comp_and_blend(qa: &mut Qa, seq_item: &Value, psd_comp: &Value) -> Img {
// The main comp.
let main = qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
qa.exec("layer.addItem", json!({"item": seq_item}));
qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}));
qa.exec("layer.addItem", json!({"item": seq_item.clone()}));
let a = qa.frame(None, 0.0);
let b = qa.frame(None, 0.5);
assert!(mean_diff(&a, &b) > 1.0, "the sequence plays");
@@ -75,7 +82,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
for c in 0..3 {
assert!(s[c] > n[c] && s[c] > [32, 64, 96][c], "Screen brightens channel {c}: {s:?}");
}
normal
}
fn track_matte(qa: &mut Qa) {
// Track matte: a magenta solid seen only through text.
qa.exec("layer.newText", json!({"text": "MATTE", "name": "Matte", "size": 40, "fill": "#ffffff"}));
qa.exec("layer.newSolid", json!({"name": "Fill", "color": "#ff00ff", "width": 160, "height": 90}));
@@ -91,7 +101,9 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let fill = info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Fill").unwrap().clone();
assert_eq!(fill["trackMatte"]["kind"], "Alpha Matte");
qa.exec("layer.setSwitch", json!({"layers": ["Fill", "Matte"], "switch": "video", "value": false}));
}
fn adjustment_layer(qa: &mut Qa, normal: &Img) {
// Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow.
qa.exec("layer.newAdjustment", json!({"name": "Grade"}));
let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]}));
@@ -103,7 +115,6 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let mk = qa.exec("layer.addMask", json!({"layer": "Grade", "shape": "ellipse", "rect": [40, 20, 80, 50]}));
assert!(mk["mask"].is_u64(), "{mk}");
let hard = qa.frame(None, 0.5);
let lum = |i: &Img, x, y| luma(i.get_pixel(x, y));
assert!(lum(&hard, 80, 45) > lum(&before, 80, 45) - 1.0 && lum(&hard, 5, 5) < lum(&before, 5, 5) - 10.0, "the mask limits the grade to the ellipse");
qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "expansion", "value": 8}));
let grown = qa.frame(None, 0.5);
@@ -116,7 +127,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
}
n
};
assert!(bright(&grown, &normal) > bright(&hard, &normal) + 100, "expansion grows the graded area");
assert!(bright(&grown, normal) > bright(&hard, normal) + 100, "expansion grows the graded area");
qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "feather", "value": 20}));
let soft = qa.frame(None, 0.5);
// At the ellipse's edge (x = 40 − 8): feathered is in between none and full.
@@ -125,7 +136,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
qa.exec("effect.apply", json!({"effect": "Glow", "layers": ["Grade"]}));
let glow = qa.frame(None, 0.5);
assert!(mean_diff(&soft, &glow) > 0.5, "Glow changes the frame");
}
/// The 3D stage with depth of field and a light; returns the stage comp.
fn stage_3d(qa: &mut Qa) -> Value {
// 3D stage: a near and a far card, a camera with depth of field, a light.
let stage = qa.exec("comp.new", json!({"name": "Stage", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
qa.exec("layer.newSolid", json!({"name": "Near", "color": "#ff4040", "width": 40, "height": 40}));
@@ -155,7 +169,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let advanced = qa.frame(None, 0.0);
assert!(mean_diff(&classic, &advanced) > 0.2, "Advanced 3D renders the stage its own way");
assert!(lum(&advanced, 50, 45) > 60.0, "Advanced 3D still lights the card");
stage
}
fn alpha_renders(qa: &mut Qa, stage: &Value) {
// Renders with alpha: the stage has no background layer, so most of it is transparent.
let mov = qa.path("stage.mov");
let webm = qa.path("stage.webm");
@@ -165,7 +182,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
assert!(done["items"].as_array().unwrap().iter().all(|i| i["status"] == "Done"), "{done}");
let reference = qa.tool("render_frame", json!({"comp": stage.clone(), "time": 0, "transparent": true, "inline": false}));
assert_eq!(reference["width"], 160);
let refa = alpha_frame(&mut qa, stage.clone());
let refa = alpha_frame(qa, stage.clone());
assert!(px(&refa, 2, 2)[3] < 40 && px(&refa, 50, 45)[3] == 255, "render_frame transparent keeps alpha: {:?} {:?}", px(&refa, 2, 2), px(&refa, 50, 45));
// ProRes 4444 carries alpha in the frame; WebM VP9 as a second VP9 stream in BlockAdditions
// (AlphaMode 1), which FilmCraft's Matroska reader decodes into the frame's alpha plane.
@@ -174,14 +191,24 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
assert_eq!(r["errors"], json!([]));
assert_eq!(r["unlabeledAlpha"], r["items"], "{path}: the importer sees an alpha channel");
let c = qa.exec("file.newCompFromSelection", json!({}))["comps"][0].clone();
let f = alpha_frame(&mut qa, c.clone());
let f = alpha_frame(qa, c.clone());
// The opaque card decodes to the stage's colours (lossy codecs, so loosely).
let (got, want) = (px(&f, 50, 45), px(&refa, 50, 45));
assert!((0..3).all(|c| got[c].abs_diff(want[c]) < 12), "{path} decodes to the stage: {got:?} vs {want:?}");
assert!(mean_diff(&qa.frame(Some(c.clone()), 0.0), &qa.frame(Some(stage.clone()), 0.0)) < 8.0, "{path} composites like the stage");
assert!(px(&f, 2, 2)[3] < 40 && px(&f, 50, 45)[3] > 250, "{path}: transparent corners, opaque card: {:?} {:?}", px(&f, 2, 2), px(&f, 50, 45));
}
let _ = main;
}
#[test]
fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let mut qa = Qa::new("compositing");
let (seq_item, psd_comp) = import_footage(&mut qa);
let normal = main_comp_and_blend(&mut qa, &seq_item, &psd_comp);
track_matte(&mut qa);
adjustment_layer(&mut qa, &normal);
let stage = stage_3d(&mut qa);
alpha_renders(&mut qa, &stage);
}
fn alpha_frame(qa: &mut Qa, comp: Value) -> Img {
+9 -1
View File
@@ -2,7 +2,15 @@
//! init_coeff_cdfs that its syntax uses). Layout as in filmcraft-av1: each innermost array
//! holds N cumulative values (the last is 32768) plus the adaptation counter.
use crate::tables::*;
use crate::tables::{
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DRL_MODE_CDF,
DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF, DEFAULT_EOB_PT_256_CDF,
DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_IS_INTER_CDF, DEFAULT_MV_BIT_CDF,
DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF,
DEFAULT_NEW_MV_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF, DEFAULT_PARTITION_W64_CDF, DEFAULT_REF_MV_CDF,
DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_Y_MODE_CDF,
DEFAULT_ZERO_MV_CDF,
};
#[derive(Clone)]
pub(crate) struct Cdfs {
+30 -9
View File
@@ -3,8 +3,12 @@
//! processes, so the reconstruction built here is exactly what a decoder produces; the syntax
//! is written afterwards by `writer.rs`.
use crate::pred::*;
use crate::tables::*;
use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, predict_inter, predict_intra, subpel_plane};
use crate::tables::{
AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM,
NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3,
TX_SET_INTRA_2, TX_SIZE_SQR_UP, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA,
};
use crate::transform::{forward_2d, inverse_2d};
/// Decisions for one coded block.
@@ -100,6 +104,17 @@ pub(crate) fn intra_uv_tx_type(uv_mode: usize, uv_tx: usize) -> usize {
if tx_in_set(tx_set(uv_tx, false), false, t) { t } else { DCT_DCT }
}
/// Geometry and coding mode of one square transform block.
#[derive(Clone, Copy)]
struct TransformBlock {
plane: usize,
x: usize,
y: usize,
log2: u32,
tx_type: usize,
intra: bool,
}
struct TxResult {
levels: Vec<i32>,
nonzero: bool,
@@ -362,7 +377,7 @@ impl<'a> Decider<'a> {
types.push(alt);
}
for &t in &types {
let (res, recon) = self.try_tx(0, c * 4, r * 4, log2, t, &pred[..n * n], true);
let (res, recon) = self.try_tx(TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]);
let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY {
0.0
} else if t == DCT_DCT {
@@ -422,7 +437,7 @@ impl<'a> Decider<'a> {
let uv_type = intra_uv_tx_type(uv_mode, uv_tx);
let mut code_uv = |i: usize| {
predict_intra(&ced[i], &ce[i], uv_mode, &mut pred);
let t = self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true);
let t = self.code_tx(TransformBlock { plane: 1 + i, x: c * 2, y: r * 2, log2: clog2, tx_type: uv_type, intra: true }, &pred[..cn * cn]);
self.mark_decoded(1 + i, r, c, n4);
t
};
@@ -450,13 +465,17 @@ impl<'a> Decider<'a> {
let (vw, vh) = self.g.visible(p);
let pn = n >> s;
let (x, y) = ((c * 4) >> s, (r * 4) >> s);
predict_inter(&refr.planes[p], vw as i32 - 1, vh as i32 - 1, x, y, pn, pn, mv, s, self.bd, &mut pred[..pn * pn]);
predict_inter(
&refr.planes[p],
&InterBlock { last_x: vw as i32 - 1, last_y: vh as i32 - 1, x, y, width: pn, height: pn, mv, subsampling: s, bit_depth: self.bd },
&mut pred[..pn * pn],
);
if bsize == BLOCK_64X64 {
// coded as a skip block: the prediction is the reconstruction
dist += self.sse(p, x, y, pn, &pred[..pn * pn]);
self.put(p, x, y, pn, &pred[..pn * pn]);
} else {
let t = self.code_tx(p, x, y, log2 - s as u32, DCT_DCT, &pred[..pn * pn], false);
let t = self.code_tx(TransformBlock { plane: p, x, y, log2: log2 - s as u32, tx_type: DCT_DCT, intra: false }, &pred[..pn * pn]);
dist += t.dist;
tx.push(t);
}
@@ -771,8 +790,9 @@ impl<'a> Decider<'a> {
/// Transform, quantise and reconstruct one square transform block of `plane` at (x, y)
/// over the prediction `pred`; the reconstruction is written to `rec`.
fn code_tx(&mut self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> TxResult {
let (res, recon) = self.try_tx(p, x, y, log2, tx_type, pred, intra);
fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult {
let TransformBlock { plane: p, x, y, log2, .. } = block;
let (res, recon) = self.try_tx(block, pred);
let n = 1usize << log2;
self.put(p, x, y, n, recon.as_deref().unwrap_or(pred));
res
@@ -780,7 +800,8 @@ impl<'a> Decider<'a> {
/// Transform and quantise one block; returns the result and its reconstruction (None: the
/// prediction) without writing it.
fn try_tx(&self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> (TxResult, Option<Vec<u16>>) {
fn try_tx(&self, block: TransformBlock, pred: &[u16]) -> (TxResult, Option<Vec<u16>>) {
let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block;
let n = 1usize << log2;
let tx_sz = tx_size_for(log2);
let bd = self.bd;
+17 -5
View File
@@ -3,7 +3,7 @@
//! is 0 in the frames this encoder writes.
use crate::pred::Plane;
use crate::tables::*;
use crate::tables::{MAX_LOOP_FILTER, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SUBSAMPLED_SIZE, TX_HEIGHT, TX_WIDTH};
/// Mode info the loop filter reads (per 4x4 luma unit, row stride `mi_cols`), and the
/// transform size per 4x4 unit of each plane (`LoopfilterTxSizes`, row stride `mi_cols + 32`).
@@ -39,7 +39,7 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4
}
let mut col = 0;
while col < info.mi_cols {
edge(pl, info, level, bit_depth, plane, pass, row, col);
edge(pl, info, level, bit_depth, plane, pass, (row, col));
col += step;
}
row += step;
@@ -48,7 +48,8 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4
}
}
fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, row: usize, col: usize) {
fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, position: (usize, usize)) {
let (row, col) = position;
let s = (plane > 0) as usize;
let (dx, dy) = if pass == 0 { (1usize, 0usize) } else { (0, 1) };
let x = col * 4;
@@ -88,13 +89,24 @@ fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, p
let limit = lvl.max(1);
let blimit = 2 * (lvl + 2) + limit;
let thresh = lvl >> 4;
let strength = FilterStrength { limit, blimit, thresh };
for k in 0..4 {
sample_filter(pl, xp + dy * k, yp + dx * k, plane, limit, blimit, thresh, dx as isize, dy as isize, filter_size, bd);
sample_filter(pl, (xp + dy * k, yp + dx * k), plane, &strength, (dx as isize, dy as isize), filter_size, bd);
}
}
/// Thresholds derived from the loop-filter level (spec 7.14.4).
struct FilterStrength {
limit: i32,
blimit: i32,
thresh: i32,
}
#[inline]
fn sample_filter(pl: &mut Plane, x: usize, y: usize, plane: usize, limit: i32, blimit: i32, thresh: i32, dx: isize, dy: isize, filter_size: usize, bd: u32) {
fn sample_filter(pl: &mut Plane, position: (usize, usize), plane: usize, strength: &FilterStrength, direction: (isize, isize), filter_size: usize, bd: u32) {
let (x, y) = position;
let (dx, dy) = direction;
let FilterStrength { limit, blimit, thresh } = *strength;
let stride = pl.stride as isize;
let step = dy * stride + dx;
let base = y as isize * stride + x as isize;
+23 -15
View File
@@ -2,7 +2,10 @@
//! modes plus V_PRED / H_PRED without edge filtering, 7.11.3 block inter prediction without
//! reference scaling) so that the encoder predicts exactly like a decoder.
use crate::tables::*;
use crate::tables::{
DC_PRED, EIGHTTAP, H_PRED, PAETH_PRED, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SMOOTH_H_PRED, SMOOTH_PRED,
SMOOTH_V_PRED, SUBPEL_FILTERS, V_PRED,
};
use crate::transform::round2;
/// A plane buffer (padded to whole superblocks).
@@ -184,19 +187,20 @@ pub(crate) fn predict_intra(ed: &Edges, e: &IntraEdge, mode: usize, pred: &mut [
/// final rounding is the identity) of the `w x h` block at plane position (x, y) displaced by
/// `mv` (1/8 luma sample units, [row, col]). `last_x` / `last_y` are the largest sample
/// coordinates of the reference plane; the result goes to `pred` (row stride `w`).
pub(crate) fn predict_inter(
refp: &Plane,
last_x: i32,
last_y: i32,
x: usize,
y: usize,
w: usize,
h: usize,
mv: [i32; 2],
ss: usize,
bit_depth: u32,
pred: &mut [u16],
) {
pub(crate) struct InterBlock {
pub last_x: i32,
pub last_y: i32,
pub x: usize,
pub y: usize,
pub width: usize,
pub height: usize,
pub mv: [i32; 2],
pub subsampling: usize,
pub bit_depth: u32,
}
pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16]) {
let InterBlock { last_x, last_y, x, y, width: w, height: h, mv, subsampling: ss, bit_depth } = *block;
// positions in 1/16 sample units ((startX >> 6) & 15 is the filter phase)
let px = ((x as i32) << 4) + ((2 * mv[1]) >> ss);
let py = ((y as i32) << 4) + ((2 * mv[0]) >> ss);
@@ -340,7 +344,11 @@ mod tests {
for (bx, by) in [(0i32, 0i32), (8, 8), (24, 16), (-5, 20), (30, -3)] {
let mut pred = vec![0u16; 64];
let mv = [by * 8 + fy as i32 / 2, bx * 8 + fx as i32 / 2];
predict_inter(&p, w as i32 - 1, h as i32 - 1, 0, 0, 8, 8, mv, 0, 8, &mut pred);
predict_inter(
&p,
&InterBlock { last_x: w as i32 - 1, last_y: h as i32 - 1, x: 0, y: 0, width: 8, height: 8, mv, subsampling: 0, bit_depth: 8 },
&mut pred,
);
for r in 0..8 {
for c in 0..8 {
let (x, y) = ((bx + c).clamp(0, w as i32 - 1), (by + r).clamp(0, h as i32 - 1));
+12 -1
View File
@@ -4,7 +4,7 @@
use std::sync::OnceLock;
use crate::tables::*;
use crate::tables::{ADST_ADST, ADST_DCT, COS128_LOOKUP, DCT_ADST, DCT_DCT, TRANSFORM_ROW_SHIFT, TX_WIDTH_LOG2};
#[inline(always)]
fn round2_64(x: i64, n: u32) -> i32 {
@@ -71,6 +71,12 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
for i in 0..len {
t[i] = copy[brev(n, i)];
}
inverse_dct_initial_stages(t, n, r);
inverse_dct_final_stages(t, n, r);
}
/// Rotate the high-frequency branches before the four-point core.
fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) {
if n >= 5 {
for i in 0..8 {
bf(t, 16 + i, 31 - i, 6 + ((brev(3, 7 - i) as i32) << 3), false);
@@ -103,6 +109,10 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
}
}
}
}
/// Evaluate the four-point core and merge progressively larger branches.
fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) {
for i in 0..2 {
bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0);
}
@@ -454,6 +464,7 @@ pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: u
#[cfg(test)]
mod tests {
use super::*;
use crate::tables::{TX_4X4, TX_8X8, TX_16X16, TX_32X32};
/// forward (float) then the normative inverse reproduces the residual: the forward basis
/// and scale match the decoder's inverse transforms for every supported size and type.
+30 -18
View File
@@ -8,7 +8,15 @@ use crate::cdf::Cdfs;
use crate::decide::{FrameGeom, Leaf, Node, intra_uv_tx_type, scan_for, tx_in_set, tx_set, tx_size_for};
use crate::loopfilter::LfInfo;
use crate::symbol::SymbolWriter;
use crate::tables::*;
use crate::tables::{
ADST_ADST, ADST_DCT, ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_64X64, BR_CDF_SIZE, BWDREF_FRAME, CLASS0_SIZE, COEFF_BASE_CTX_OFFSET, COEFF_BASE_RANGE,
D67_PRED, DC_PRED, DCT_ADST, DCT_DCT, DEFAULT_INTRA_FRAME_Y_MODE_CDF, GLOBALMV, GOLDEN_FRAME, INTRA_FRAME, INTRA_MODE_CONTEXT, LAST_FRAME, LAST2_FRAME,
LAST3_FRAME, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA, MAX_REF_MV_STACK_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MV_BORDER, MV_CLASS_0, NEARESTMV, NEARMV,
NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE,
PARTITION_SPLIT, PARTITION_SUBSIZE, PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, REF_CAT_LEVEL, SIG_REF_DIFF_OFFSET, SIZE_GROUP,
SUBSAMPLED_SIZE, TX_32X32, TX_CLASS_2D, TX_HEIGHT, TX_HEIGHT_LOG2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET2, TX_WIDTH,
TX_WIDTH_LOG2, V_PRED,
};
type MvP = [i32; 2];
@@ -455,23 +463,7 @@ impl<'a> TileWriter<'a> {
}
} else {
if plane == 0 {
// transform_type( )
let set = tx_set(tx_sz, b.is_inter);
let t = b.tx_type_y;
if set > 0 && self.qidx > 0 {
let sqr = TX_SIZE_SQR[tx_sz] as usize;
if b.is_inter {
// The decision only picks types of this set (DCT_DCT otherwise).
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default();
self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]);
} else {
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default();
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
}
} else {
assert_eq!(t, DCT_DCT);
}
self.set_tx_types(x4, y4, tx_sz, t);
self.write_transform_type(x4, y4, tx_sz, b);
}
let plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4, b);
debug_assert!(plane_tx_type == DCT_DCT || matches!(plane_tx_type, ADST_DCT | DCT_ADST | ADST_ADST));
@@ -571,6 +563,26 @@ impl<'a> TileWriter<'a> {
}
}
fn write_transform_type(&mut self, x4: usize, y4: usize, tx_sz: usize, b: &Leaf) {
// transform_type( )
let set = tx_set(tx_sz, b.is_inter);
let t = b.tx_type_y;
if set > 0 && self.qidx > 0 {
let sqr = TX_SIZE_SQR[tx_sz] as usize;
if b.is_inter {
// The decision only picks types of this set (DCT_DCT otherwise).
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default();
self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]);
} else {
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default();
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
}
} else {
assert_eq!(t, DCT_DCT);
}
self.set_tx_types(x4, y4, tx_sz, t);
}
fn set_tx_types(&mut self, x4: usize, y4: usize, tx_sz: usize, t: usize) {
for i in 0..(TX_WIDTH[tx_sz] as usize >> 2) {
for j in 0..(TX_HEIGHT[tx_sz] as usize >> 2) {
+28 -13
View File
@@ -692,18 +692,33 @@ impl BoltCfg<'_> {
}
}
/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random.
/// Segments go to `out` with their intensity; `mains` marks the main core's segments.
#[allow(clippy::too_many_arguments)]
fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec<Seg>, mains: &mut Vec<bool>) {
if out.len() > 20_000 {
/// One branch's geometry and recursive growth state.
struct BoltBranch {
start: (f64, f64),
end: (f64, f64),
depth: u32,
intensity: f32,
main: bool,
}
/// Segments and their matching main-core flags, kept in the same traversal order.
#[derive(Default)]
struct BoltOutput {
segments: Vec<Seg>,
mains: Vec<bool>,
}
/// Grow a branch by midpoint displacement, recursively appending its segments and forks.
fn bolt(cfg: &BoltCfg, branch: BoltBranch, rng: &mut Rng, output: &mut BoltOutput) {
let BoltBranch { start: a, end: z, depth, intensity: inten, main } = branch;
if output.segments.len() > 20_000 {
return;
}
let (dx, dy) = (z.0 - a.0, z.1 - a.1);
let len = (dx * dx + dy * dy).sqrt();
if depth == 0 || len < 1.0 {
out.push((a, z, inten));
mains.push(main);
output.segments.push((a, z, inten));
output.mains.push(main);
return;
}
let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9));
@@ -725,8 +740,8 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai
if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 {
return;
}
bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains);
bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains);
bolt(cfg, BoltBranch { start: a, end: m, depth: depth - 1, intensity: inten, main }, rng, output);
bolt(cfg, BoltBranch { start: m, end: z, depth: depth - 1, intensity: inten, main }, rng, output);
if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork {
let ang = rng.s() * 0.7;
let (s, c) = ang.sin_cos();
@@ -734,7 +749,7 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai
let k = 1.2;
// Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it.
let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 };
bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains);
bolt(cfg, BoltBranch { start: m, end: (m.0 + fx * k, m.1 + fy * k), depth: depth.saturating_sub(1), intensity: fi.min(1.0), main: false }, rng, output);
}
}
@@ -772,10 +787,9 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32,
fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32,
};
let mut segs = Vec::new();
let mut mains = Vec::new();
let mut output = BoltOutput::default();
let (h, w) = (b.img.height as f64, b.img.width as f64);
let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains);
let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, BoltBranch { start: a, end: z, depth, intensity: 1.0, main: true }, rng, &mut output);
// LIGHTNING_TYPES order.
match kind {
// Direction: travels toward the Direction point and beyond.
@@ -811,6 +825,7 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
// Strike.
_ => go(o, d, depth, &cfg, &mut rng),
}
let BoltOutput { segments: mut segs, mains } = output;
let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt();
let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max);
// Decay Main Core: the whole bolt fades with distance from the origin.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::raster::{Seg, mask_px, over, poly_segs, raster_segs};
use super::*;
use super::{Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefMutIterator, PI, ParallelIterator, poly_length, premul};
// ---------------------------------------------------------------- Audio Spectrum / Waveform
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EffectSpec, ParamUi, Value, slider};
pub(super) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true }
+4 -1
View File
@@ -1,5 +1,8 @@
use super::raster::over;
use super::*;
use super::{
Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, PI, ParallelIterator, Plane, Px, gauss_plane, hash1,
hsl_to_rgb, morph_frac, premul, rgb_to_hsl, smoothstep, unpremul,
};
pub(super) fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
+1 -1
View File
@@ -1,5 +1,5 @@
use super::raster::over;
use super::*;
use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Px, hsl_to_rgb};
// ---------------------------------------------------------------- Fractal
+4 -1
View File
@@ -1,5 +1,8 @@
use super::raster::{Seg, all_masks_px, chaikin, mask_px, over, poly_segs, raster_convex, raster_segs, scan_runs, stroke_outline, trim_poly};
use super::*;
use super::{
Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Params, Plane, gauss_plane, hash1,
layer_or_self, luminance, poly_length, smoothstep, unpremul,
};
/// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal
/// Original Image.
+1 -2
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Plane, Px, smoothstep};
// ---------------------------------------------------------------- shared rasterisation
@@ -147,7 +147,6 @@ pub(super) fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> {
// ---------------------------------------------------------------- Scribble
/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`.
#[allow(clippy::too_many_arguments)]
pub(super) fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) {
out.clear();
for (pts, _closed, inverted) in polys {
+1 -1
View File
@@ -4,7 +4,7 @@ use super::fill::{eyedropper_fill, glue_gun, paint_bucket, threads};
use super::fractal::{FRACTAL_OVERSAMPLE, FRACTAL_PALETTES, FRACTAL_SETS, fractal};
use super::paths::{scribble, stroke, vegas, write_on};
use super::raster::{END_CAPS, JOINS};
use super::*;
use super::{EffectSpec, ParamUi, Value, col, num, p, popup, slider};
// ---------------------------------------------------------------- registry
+25 -12
View File
@@ -358,20 +358,24 @@ pub enum FrameMatte {
Outside,
}
/// The matte of layer frame `f` on the layer grid of `img` (the effect's input on that frame at
/// `scale`), computing what is missing. `frame(g)`: the input on frame g on the same grid.
#[allow(clippy::too_many_arguments)]
/// The layer grid and timing used to compute one object matte.
pub struct MatteFrame<'a> {
pub layer_size: [f64; 2],
pub fps: f64,
pub frame: i64,
pub scale: f64,
pub img: &'a Image,
}
/// Compute the object's alpha for one layer frame, loading other frames on the same grid as needed.
pub fn frame_matte(
params: &Params,
layer_size: [f64; 2],
fps: f64,
f: i64,
scale: f64,
img: &Image,
input: &MatteFrame<'_>,
model: Option<Arc<dyn MaskModel>>,
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
cancel: &dyn Fn() -> bool,
) -> FrameMatte {
let MatteFrame { layer_size, fps, frame: f, scale, img } = *input;
let Some(chain) = chain(params, layer_size, fps, f, scale, model) else { return FrameMatte::NoPrompt };
if !chain.data.in_span(f) {
return FrameMatte::Outside;
@@ -408,7 +412,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
if let Some(a) = frozen_alpha(ctx.params, layer_size, fps, f, w, h, model.as_deref()) {
return compose(&buf, Some(&a), w, h, b(ctx.params, "invert"), param(ctx.params, "view").map(Value::as_enum).unwrap_or(0) == 1);
}
let alpha = match frame_matte(ctx.params, layer_size, fps, f, sc, &img, model, &mut frame, &|| false) {
let alpha = match frame_matte(ctx.params, &MatteFrame { layer_size, fps, frame: f, scale: sc, img: &img }, model, &mut frame, &|| false) {
FrameMatte::Alpha(a) if a.len() == w * h => Some(a),
FrameMatte::NoPrompt => return buf,
_ => None,
@@ -608,7 +612,11 @@ mod tests {
assert_ne!(seed(&p, [100.0, 80.0], 25.0, Some(stub.as_ref())), seed(&p, [100.0, 80.0], 25.0, None));
let img = frame_at(0);
let mut frame = |g: i64| Some(Arc::new(frame_at(g)));
let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") };
let FrameMatte::Alpha(a) =
frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false)
else {
panic!("no matte")
};
assert!(a[40 * 100 + 40] > 0.5 && a[5 * 100 + 5] < 0.5);
assert_eq!(stub.calls.load(std::sync::atomic::Ordering::Relaxed), 1);
assert_eq!(*stub.bbox.lock().unwrap(), None);
@@ -634,7 +642,11 @@ mod tests {
let stub = Arc::new(Stub::default());
let img = frame_at(0);
let mut frame = |g: i64| Some(Arc::new(frame_at(g)));
let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") };
let FrameMatte::Alpha(a) =
frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false)
else {
panic!("no matte")
};
assert_eq!(*stub.bbox.lock().unwrap(), Some([74.0, 54.0, 100.0, 80.0]));
assert!(a[70 * 100 + 90] > 0.5 && a[40 * 100 + 40] < 0.5);
// At half resolution the box is scaled with the frame (another Input Key, so the
@@ -643,7 +655,8 @@ mod tests {
p.values.insert(INPUT_KEY.into(), Value::Str("half".into()));
let half = effectcraft_raster::resample(&img, 50, 40);
let mut frame = |g: i64| Some(Arc::new(effectcraft_raster::resample(&frame_at(g), 50, 40)));
let _ = frame_matte(&p, [100.0, 80.0], 25.0, 0, 0.5, &half, Some(stub.clone()), &mut frame, &|| false);
let _ =
frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 0.5, img: &half }, Some(stub.clone()), &mut frame, &|| false);
assert_eq!(*stub.bbox.lock().unwrap(), Some([37.0, 27.0, 50.0, 40.0]));
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{ParamUi, slider};
// ---------------------------------------------------------------- ASC CDL
+1 -1
View File
@@ -1,7 +1,7 @@
use super::cdl::Cdl;
use super::lut::load_file;
use super::space::{ACES_CCT, ACES_CG, ACES2065_1, D65, IDENTITY, P3, REC709, REC2020, SPACES, Space, Tf, Xform, matrix, sp, space_names};
use super::*;
use super::{Buf, ColorSpace, EffectCtx, EffectSpec, IntoParallelRefMutIterator, ParallelIterator, Value, invert, mul, mul_vec, p, popup, unpremul};
// ---------------------------------------------------------------- displays, views and looks
+1 -1
View File
@@ -1,5 +1,5 @@
use super::cdl::{Cdl, parse_cdl_xml};
use super::*;
use super::{Arc, HashMap, Lut, Mutex, OnceLock, parse_cube};
// ---------------------------------------------------------------- LUT files
+1 -1
View File
@@ -2,7 +2,7 @@ use super::cdl::Cdl;
use super::effects::{DISPLAYS, cdl_from_params, custom_xf, looks, parse_looks, profile, space_at};
use super::lut::{FileXform, load_file};
use super::space::{ACES_CCT, D65, IDENTITY, Space, Tf, Xform, matrix};
use super::*;
use super::{Arc, EffectCtx, Lut, invert, mul};
// ---- Colour programs (the GPU compositor's colour-management kernels)
+1 -1
View File
@@ -4,7 +4,7 @@ use super::effects::{
space_param, spec,
};
use super::space::SPACES;
use super::*;
use super::{EffectSpec, ParamUi, Value, num, p, popup, slider};
pub fn specs() -> Vec<EffectSpec> {
let cc = "Color Correction";
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{invert, mul, mul_vec, rgb_to_xyz};
pub(super) type M3 = [[f64; 3]; 3];
+1 -1
View File
@@ -634,7 +634,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
let (w, h) = (img.width as usize, img.height as usize);
let mp = matte_params(ctx.params);
let mut rgb = rb::rgb_of(&img);
let map = refine::decontaminate(&mut rgb, &alpha, &band, w, h, &mp, rr * sc, sc);
let map = refine::decontaminate(&mut rgb, &alpha, &band, (w, h), &mp, rr * sc, sc);
let view_map = b(ctx.params, "refineEdgeMatte/decontamination/viewDecontaminationMap");
let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64);
for y in 0..h {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EffectSpec, ParamUi, Value, hash1, slider};
pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true }
+3 -2
View File
@@ -1,7 +1,7 @@
use super::common::{h, hs};
use super::particle_fx::{PS2_CACHE, PW_CACHE, ps2_phys, pw_phys};
use super::sprites::{Shape, Sprite};
use super::*;
use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, SimCache, params_key_except, value_noise};
// ------------------------------------------------------------------ particle engine
@@ -263,7 +263,8 @@ pub(super) fn particle_look(q: &Particle, birth: [f32; 4], death: [f32; 4], bsiz
(c, size.max(0.0))
}
pub(super) fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, rot: f32) -> Sprite {
pub(super) fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], velocity: [f32; 2], rot: f32) -> Sprite {
let [vx, vy] = velocity;
let vl = (vx * vx + vy * vy).sqrt().max(1e-6);
let shape = match kind {
0 => Shape::Line { dx: -vx / vl * r * 2.0, dy: -vy / vl * r * 2.0 },
+3 -3
View File
@@ -1,6 +1,6 @@
use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, sim_key, simulate, type_sprite};
use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat};
use super::*;
use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, params_key, unpremul};
// ------------------------------------------------------------------ CC Particle World
@@ -169,7 +169,7 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan
let PwSeen { depth, x, y, k, v } = cam.see(q.p, q.v, lw, lh, b)?;
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
let r = size * 0.5 * lw * k * s * 0.25;
let sp = type_sprite(kind, x, y, r, c, v[0] * lw * k * s, v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
let sp = type_sprite(kind, x, y, r, c, [v[0] * lw * k * s, v[1] * lw * k * s], (q.id % 628) as f32 * 0.01 + q.age * 2.0);
Some((depth, sp))
})
.collect();
@@ -242,7 +242,7 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let (bx, by) = b.to_px([(q.p[0] * unit) as f64, (q.p[1] * unit) as f64]);
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
let r = size * unit * 0.02 * s;
type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * unit * s, q.v[1] * unit * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0)
type_sprite(kind, bx as f32, by as f32, r, c, [q.v[0] * unit * s, q.v[1] * unit * s], (q.id % 628) as f32 * 0.01 + q.age * 2.0)
})
.collect();
particle_sprites(sprites, pr.e("particle/transferMode"))
+1 -1
View File
@@ -3,7 +3,7 @@ use super::engine::{PARTICLE_TYPES, PW_ANIMS};
use super::particle_fx::{PW_FOV, mr_mercury, particle_systems2, particle_systems2_plan, particle_world, particle_world_plan};
use super::sprites::SpritePlan;
use super::weather::{bubbles, drizzle, rainfall, rainfall_plan, snowfall, snowfall_plan, star_burst, star_burst_plan};
use super::*;
use super::{Buf, EffectCtx, EffectSpec, ParamUi, Value, col, num, p, popup, slider};
/// The sprite plan of a sprite-drawing simulation effect for buffer geometry `b` (its pixels
/// are not read), `None` for other effects or settings drawn otherwise (the GPU compositor's
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Buf, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, ParallelSliceMut, Px, unpremul};
// ------------------------------------------------------------------ band rasteriser
+1 -1
View File
@@ -1,6 +1,6 @@
use super::common::{h, hs};
use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster};
use super::*;
use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul};
// ------------------------------------------------------------------ CC Rainfall / CC Snowfall
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Plane, Px, Shape, SimCache,
Sprite, Value, hs, params_key, splat, unpremul, value_noise,
};
// ------------------------------------------------------------------ Foam
+1 -1
View File
@@ -1,5 +1,5 @@
use super::shatter::{ShatterPlan, card_dance_plan, shatter_impl};
use super::*;
use super::{Acc, Buf, EffectCtx, Image, Lighting, Proj, raster, unpremul};
// ------------------------------------------------------------------ 3D pieces
+1 -1
View File
@@ -1,6 +1,6 @@
use super::pieces::{Cam, Piece, PiecePlan, PieceTex, rot_axis, sort_far_first};
use super::shatter::{ShatterPlan, shatter_impl};
use super::*;
use super::{Acc, Buf, EffectCtx, IntoParallelIterator, ParallelIterator, Post, Shape, Sprite, SpritePlan, h, hs, unpremul};
// ------------------------------------------------------------------ CC Ball Action
+1 -1
View File
@@ -2,7 +2,7 @@ use super::foam::{FOAM_TEXTURES, foam};
use super::pixels::{ball_action, pixel_polly, scatterize};
use super::shatter::{CD_PROPS, CD_SOURCES, SHATTER_PATTERNS, SHATTER_TEXTURE_MODES, card_dance, shatter};
use super::surfaces::{caustics, wave_world};
use super::*;
use super::{EffectSpec, ParamUi, Value, col, num, p, pct, popup, pt, slider, spec};
// ------------------------------------------------------------------ specs
+4 -1
View File
@@ -1,5 +1,8 @@
use super::pieces::{M3, Piece, PiecePlan, PieceTex, inv3, rot_axis, rot_xyz, sort_far_first};
use super::*;
use super::{
Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelIterator, IntoParallelRefIterator, Lighting, ParallelIterator, Post, Shape, Sprite,
SpritePlan, Value, h, hs, layer_or_self, unpremul,
};
// ------------------------------------------------------------------ Card Dance
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Acc, Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, Plane, Px, Shape, SimCache, Sprite, Value, gauss_plane, params_key, unpremul};
// ------------------------------------------------------------------ Caustics
+3 -2
View File
@@ -575,7 +575,8 @@ fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option<Mapper>
/// Draw a map layer frame as a particle: centred on (x, y) buffer px, rotated `angle` degrees
/// and scaled, over `out`.
fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, x: f64, y: f64, angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) {
fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, position: [f64; 2], angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) {
let [x, y] = position;
let (w, h) = (lp.size[0] * scale[0].abs() * buf_scale, lp.size[1] * scale[1].abs() * buf_scale);
let r = (w * w + h * h).sqrt() * 0.5 + 1.0;
let (x0, x1) = ((x - r).floor().max(0.0) as i64, (x + r).ceil().min(out.width as f64) as i64);
@@ -925,7 +926,7 @@ impl PgPlan {
pub fn finish(&self, mut b: Buf) -> Buf {
let mut fx = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc);
for q in &self.blits {
blit_layer(&mut fx, &self.frames[q.frame], q.x, q.y, q.angle, q.scale, b.scale, q.alpha);
blit_layer(&mut fx, &self.frames[q.frame], [q.x, q.y], q.angle, q.scale, b.scale, q.alpha);
}
// Particles replace the layer's own pixels.
b.img = combine(&b.img, &fx, 5);
+11 -14
View File
@@ -453,7 +453,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
});
let need = need_m.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
if synth && need.iter().any(|w| *w > 0.0) {
synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need);
synthesize(ctx, &a, k, &SynthesisGrid { src_of: &src_of, crop: &crop, scale: sc }, &mut out, &need);
}
if b(ctx.params, "advanced/showTrackPoints") {
let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5);
@@ -465,19 +465,16 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
Buf { img: out, offset: [0.0; 2], scale: sc }
}
/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from
/// neighbouring frames, nearest first.
#[allow(clippy::too_many_arguments)]
fn synthesize(
ctx: &EffectCtx,
a: &WarpAnalysis,
k: usize,
src_of: &(dyn Fn([f64; 2]) -> [f64; 2] + Sync),
crop: &[f64; 4],
sc: f64,
out: &mut Image,
need: &[f32],
) {
/// Maps an output sample into the uncropped source grid shared by neighbouring frames.
struct SynthesisGrid<'a> {
src_of: &'a (dyn Fn([f64; 2]) -> [f64; 2] + Sync),
crop: &'a [f64; 4],
scale: f64,
}
/// Fill the pixels flagged in `need` from neighbouring frames, nearest first.
fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, grid: &SynthesisGrid<'_>, out: &mut Image, need: &[f32]) {
let SynthesisGrid { src_of, crop, scale: sc } = *grid;
let Some(host) = ctx.env.host else { return };
let n = a.frames.len();
let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60);
+12 -1
View File
@@ -450,7 +450,18 @@ async fn run_work(w: Work, shared: &RotoShared) {
(RotoTask::Propagate, true) => om::ensure(&w.params, w.size, w.fps, *f, model.clone(), &mut get, &cancel).is_some(),
(RotoTask::Freeze, true) => {
let img = get(*f);
match img.map(|img| (om::frame_matte(&w.params, w.size, w.fps, *f, 1.0, &img, model.clone(), &mut get, &cancel), img)) {
match img.map(|img| {
(
om::frame_matte(
&w.params,
&om::MatteFrame { layer_size: w.size, fps: w.fps, frame: *f, scale: 1.0, img: &img },
model.clone(),
&mut get,
&cancel,
),
img,
)
}) {
Some((om::FrameMatte::Alpha(a), img)) => {
frozen.size = [img.width as usize, img.height as usize];
frozen.frames.insert(*f, fx::freeze_frame(&a, &[]));
+1 -1
View File
@@ -156,7 +156,7 @@ mod enabled;
mod params;
mod schema;
use super::*;
use super::commands;
pub use enabled::always;
pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run};
pub use params::match_path_of;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EngineError, Result, Session, Value};
// ---------- enablement ----------
+4 -1
View File
@@ -1,5 +1,8 @@
use super::create::{apply_text_params, text_range_p};
use super::*;
use super::{
EngineError, GroupKind, Ids, ItemId, KV, LayerId, MaskMode, Node, Project, PropGroup, Result, Session, TextDoc, Value, b_p, bad, build, json, layer_mut,
layer_p, merge_p, str_p,
};
pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.addMask")?;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
EngineError, Ids, ItemId, ItemKind, Justify, KV, Label, Layer, LayerId, LayerSource, Project, Result, Session, Solid, TextDoc, Tick, Value, b_p, bad,
build, comp_id, f_p, json, limits, str_p,
};
pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
match p.get(k)? {
+2 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer};
/// Re-express a layer in its new parent space without moving its anchor.
pub(crate) struct ParentFix {
@@ -336,6 +336,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod parent_chain_tests {
use super::super::json;
use super::*;
use std::sync::Arc;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
EngineError, FootageKind, Ids, ItemId, ItemKind, Layer, LayerSource, Project, Result, Session, Value, bad, build, has_comp, json, layer_mut, layers_p,
unlocked,
};
/// A layer whose source is an item (footage, precomposition or solid), as in After Effects'
/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EngineError, LayerId, Result, Session, Value, b_p, comp_id, json, resolve_layer};
pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p,
layers_p, merge_p, resolve_layer, str_p,
};
pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
EngineError, ItemKind, Label, Layer, LayerId, LayerSource, Result, Session, Tick, Value, b_p, bad, build, f_p, json, layer_mut, layers_p, merge_p,
resolve_layer, str_p, unlocked, with,
};
pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
+4 -1
View File
@@ -1,5 +1,8 @@
use super::create::{color_p, pixel_aspect_p};
use super::*;
use super::{
EngineError, ItemKind, KV, LayerId, LayerSource, PropGroup, Result, Session, Tick, Value, bad, has_layers, json, layer_mut, layer_p, layers_p, limits,
str_p, unlocked,
};
/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well
/// as the combined property the channels were split from.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Comp, EngineError, ItemId, Layer, LayerId, Result, Session, Tick, Value};
/// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]).
pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
+10 -10
View File
@@ -121,16 +121,16 @@ fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePat
let path = match g.match_id.as_str() {
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
"star" => effectcraft_path::polystar(
g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
f("points", 5.0),
v2("position", [0.0; 2]),
f("rotation", 0.0),
f("innerRadius", 50.0),
f("outerRadius", 100.0),
f("innerRoundness", 0.0),
f("outerRoundness", 0.0),
),
"star" => effectcraft_path::polystar(effectcraft_path::Polystar {
star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
points: f("points", 5.0),
pos: v2("position", [0.0; 2]),
rotation: f("rotation", 0.0),
inner: f("innerRadius", 50.0),
outer: f("outerRadius", 100.0),
inner_round: f("innerRoundness", 0.0),
outer_round: f("outerRoundness", 0.0),
}),
_ => return None,
};
// A closed parametric shape is one subpath; anything else would not be this shape.
+50 -32
View File
@@ -205,23 +205,8 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
/// The `format` parameter's values.
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff";
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?;
apply_om_template(om, &t);
any = true;
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
om.set_format(f);
}
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
any |= apply_codec_options(om, p, cmd)?;
/// `channels` (refusing RGBA where the format has no alpha) and `color`/`alphaMode`.
fn apply_channels_and_alpha(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
if let Some(c) = str_p(p, "channels") {
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
"rgb" => Channels::Rgb,
@@ -244,21 +229,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
_ => return Err(bad(cmd, "color: straight|premultiplied")),
};
}
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
}
if let Some(b) = f_p(p, "bitrate") {
om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32;
}
if let Some(b) = b_p(p, "webmBitrate") {
om.webm_bitrate = b;
}
if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) {
om.keyframe_interval = k.min(100_000) as u32;
}
if let Some(pp) = str_p(p, "proresProfile") {
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
}
Ok(())
}
/// `crop` plus the flat dialog keys (`cropTop`… and `resizeWidth`/`resizeHeight`).
fn apply_crop(om: &mut OutputModule, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<()> {
match p.get("crop") {
Some(Value::Bool(b)) => om.crop.enabled = *b,
Some(Value::Object(c)) => {
@@ -298,6 +273,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
om.resize.enabled = true;
}
}
Ok(())
}
/// `resize` (bool or object).
fn apply_resize(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
match p.get("resize") {
Some(Value::Bool(b)) => om.resize.enabled = *b,
Some(Value::Object(r)) => {
@@ -328,6 +308,44 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
Some(Value::Null) | None => {}
Some(_) => return Err(bad(cmd, "resize: bool or {enabled?, preset?, width?, height?, lockAspect?, quality?: low|high}")),
}
Ok(())
}
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?;
apply_om_template(om, &t);
any = true;
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
om.set_format(f);
}
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
any |= apply_codec_options(om, p, cmd)?;
apply_channels_and_alpha(om, p, cmd)?;
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
}
if let Some(b) = f_p(p, "bitrate") {
om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32;
}
if let Some(b) = b_p(p, "webmBitrate") {
om.webm_bitrate = b;
}
if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) {
om.keyframe_interval = k.min(100_000) as u32;
}
if let Some(pp) = str_p(p, "proresProfile") {
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
}
apply_crop(om, roi, p, cmd)?;
apply_resize(om, p, cmd)?;
if let Some(a) = p.get("audio") {
om.audio = match a {
Value::Bool(true) => AudioOutput::On,
+1 -1
View File
@@ -1,5 +1,5 @@
use super::params::bad;
use super::*;
use super::{CommandSpec, Result, Value};
// ---------- parameter validation (agents) ----------
+10 -1
View File
@@ -221,7 +221,16 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePa
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)),
k => {
let (star, points, outer, inner) = polystar_of(k, size)?;
effectcraft_path::polystar(star, points, centre, 0.0, inner, outer, 0.0, 0.0)
effectcraft_path::polystar(effectcraft_path::Polystar {
star,
points,
pos: centre,
rotation: 0.0,
inner,
outer,
inner_round: 0.0,
outer_round: 0.0,
})
}
};
effectcraft_path::from_kurbo(&bez).into_iter().next()
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, ItemId, Layer, LayerId, Serialize, Tick, Uid, commands, mask_track, media_browser, roto, viewer};
/// A keyframe reference: layer, property uid, key time (layer time).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{ItemId, Serialize, Value};
/// Events for frontends (drained each frame).
#[derive(Clone, Debug, PartialEq, Serialize)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::prefs;
pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
+17 -5
View File
@@ -130,8 +130,20 @@ fn solid_sized(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, color:
build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None)
}
/// What `card` draws: name, gradient end colors (hex), size, position and Y rotation.
#[derive(Clone, Copy)]
struct CardSpec<'a> {
name: &'a str,
a: &'a str,
b: &'a str,
size: (u32, u32),
pos: [f64; 3],
rot_y: f64,
}
/// A 3D card: a solid with a diagonal gradient and a thin border grid.
fn card(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, a: &str, b: &str, size: (u32, u32), pos: [f64; 3], rot_y: f64) -> Layer {
fn card(p: &mut Project, comp: &Comp, folder: ItemId, spec: &CardSpec) -> Layer {
let CardSpec { name, a, b, size, pos, rot_y } = *spec;
let mut l = solid_sized(p, comp, folder, name, [1.0, 1.0, 1.0], size.0, size.1);
let (w, h) = (size.0 as f64, size.1 as f64);
effect(
@@ -192,11 +204,11 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp {
set(&mut floor, "materialOptions/specularIntensity", Value::Scalar(10.0));
// Cards at different depths and angles; the centre pair intersects.
let left = card(p, &c, solids, "Card Blue", "#2E7BF0", "#7A4DFF", (520, 340), [520.0, 560.0, 120.0], 28.0);
let right = card(p, &c, solids, "Card Coral", "#FF6A5C", "#FFB347", (520, 340), [1420.0, 560.0, 260.0], -32.0);
let mut x1 = card(p, &c, solids, "Cross A", "#1FD1A5", "#2E7BF0", (460, 460), [960.0, 470.0, 520.0], 40.0);
let left = card(p, &c, solids, &CardSpec { name: "Card Blue", a: "#2E7BF0", b: "#7A4DFF", size: (520, 340), pos: [520.0, 560.0, 120.0], rot_y: 28.0 });
let right = card(p, &c, solids, &CardSpec { name: "Card Coral", a: "#FF6A5C", b: "#FFB347", size: (520, 340), pos: [1420.0, 560.0, 260.0], rot_y: -32.0 });
let mut x1 = card(p, &c, solids, &CardSpec { name: "Cross A", a: "#1FD1A5", b: "#2E7BF0", size: (460, 460), pos: [960.0, 470.0, 520.0], rot_y: 40.0 });
set(&mut x1, "materialOptions/lightTransmission", Value::Scalar(30.0));
let x2 = card(p, &c, solids, "Cross B", "#F0E14A", "#FF6A5C", (460, 460), [960.0, 470.0, 520.0], -40.0);
let x2 = card(p, &c, solids, &CardSpec { name: "Cross B", a: "#F0E14A", b: "#FF6A5C", size: (460, 460), pos: [960.0, 470.0, 520.0], rot_y: -40.0 });
// Hero title in 3D space, gently turning.
let mut title = text_layer(
p,
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::config;
/// Host services (file access) injected by the frontend.
pub trait Services: Send + Sync {
+3 -1
View File
@@ -1,7 +1,9 @@
use super::doc_state::{CompSelection, EditorState};
use super::error::{EngineError, Result};
use super::event::Event;
use super::*;
use super::{
Arc, Comp, History, Image, ItemId, LayerId, Project, RenderOpts, Renderer, Session, ThumbnailJob, Tick, Value, camera_track, commands, guard, roto, warp,
};
mod comps;
mod dispatch;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Comp, CompSelection, Event, ItemId, LayerId, Result, Session, Tick, Value, commands};
/// `t` as a current time of `comp`: a frame within its duration.
fn comp_frame_at(comp: &Comp, t: Tick) -> Tick {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EngineError, Event, Result, Session, Value, commands, guard};
impl Session {
pub fn new() -> Session {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, EditorState, EngineError, Event, History, ItemId, Project, Result, Session, camera_track, commands, roto, warp};
impl Session {
/// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EngineError, Image, ItemId, RenderOpts, Renderer, Result, Session, ThumbnailJob, Tick};
impl Session {
/// Render a comp frame with the session's footage + expression hosts.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, WINDOW_GRAPHICS};
use super::*;
use super::{Value, json};
// ---------------------------------------------------------------- schema (Settings dialog)
+1 -1
View File
@@ -1,7 +1,7 @@
use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES, WINDOW_GRAPHICS, choices, is_choice, theme_family};
use super::recent::MAX_EFFECTS_FAVORITES;
use super::sections::{CompPreset, default_labels, parse_hex};
use super::*;
use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json};
/// Bring an older settings document up to [`PREFS_VERSION`].
///
+1 -1
View File
@@ -1,5 +1,5 @@
use super::sections::parse_hex;
use super::*;
use super::{Label, Prefs, Serialize, Tick};
impl Prefs {
/// Display name of a label (Labels settings).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::Prefs;
impl Prefs {
/// Remember a project in File ▸ Open Recent.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::choices::theme_family;
use super::*;
use super::{BTreeMap, Deserialize, Label, Serialize, Value};
/// Unknown keys inside a page, kept so a newer version's settings survive a round trip.
pub(super) type Extra = BTreeMap<String, Value>;
+71 -45
View File
@@ -3,7 +3,7 @@
use effectcraft_effects::camera_tracker as ct;
use effectcraft_geom::vec3;
use effectcraft_project::LayerId;
use effectcraft_project::{Comp, Layer, LayerId, PropGroup};
use effectcraft_raster::Image;
use effectcraft_render::EvalCtx;
use effectcraft_render::three_d::camera::active_camera;
@@ -11,6 +11,7 @@ use effectcraft_track::camtrack::linalg::{self, M3, V3};
use serde_json::{Value, json};
use crate::Session;
use crate::camera_track::Placed;
use crate::tests::track::{H, W, frame_time, setup, texture};
const F: f64 = 260.0;
@@ -77,10 +78,9 @@ fn status(s: &mut Session) -> Value {
s.execute("camera.solveStatus", json!({})).unwrap()
}
#[test]
fn track_camera_solves_and_creates_layers_on_the_plane() {
let (mut s, clip) = tracked(json!({}));
let st = status(&mut s);
/// Solved with a sane error and focal length; returns the Camera Tracker effect group.
fn assert_solved(s: &mut Session, clip: LayerId) -> PropGroup {
let st = status(s);
assert_eq!(st["solved"], true, "{st}");
let err = st["averageError"].as_f64().unwrap();
assert!(err < 0.5, "average error {err}");
@@ -90,9 +90,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
// Method Used / Average Error are reported in the effect.
let g = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(g.prop(ct::METHOD_USED).map(|p| &p.value), Some(effectcraft_keyframe::Value::Str(m)) if !m.is_empty()));
g
}
// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
// ones seen below the horizon at the bottom of the frame).
/// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
/// ones seen below the horizon at the bottom of the frame).
fn select_floor_points(s: &mut Session) -> Vec<u64> {
s.execute("time.set", json!({"time": frame_time(12).seconds()})).unwrap();
let pts = s.execute("camera.points", json!({})).unwrap();
let list = pts["points"].as_array().unwrap();
@@ -101,32 +104,19 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
assert!(floor.len() >= 3, "{floor:?}");
s.execute("camera.selectPoints", json!({"points": floor})).unwrap();
assert_eq!(s.state.camera_points.len(), floor.len());
floor
}
// Create Solid and Camera: one undo step, camera + solid.
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
let cam = LayerId(r["camera"].as_u64().unwrap());
let solid = LayerId(r["layers"][0].as_u64().unwrap());
let comp = s.active_comp().unwrap().clone();
assert!(comp.layer(cam).unwrap().is_camera());
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
assert_eq!(comp.layers[0].id, cam);
let sl = comp.layer(solid).unwrap();
assert!(sl.switches.three_d);
assert!(sl.name.starts_with("Track Solid"));
// The camera is keyed on every frame.
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
// Every solved point projects through the created camera exactly where the solve puts it,
// and onto its tracked position (the footage fills the comp, so layer pixels are comp
// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
// features on occlusion edges), 0.5 px on average.
/// Every solved point projects through the created camera exactly where the solve puts it,
/// and onto its tracked position (the footage fills the comp, so layer pixels are comp
/// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
/// features on occlusion edges), 0.5 px on average.
fn assert_reprojection(s: &Session, comp: &Comp, pl: &Placed, g: &PropGroup) {
let cid = s.active_comp_id().unwrap();
let uid = g.uid;
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
let tracks = ct::tracks(&crate::camera_track::static_params(&g)).unwrap();
let tracks = ct::tracks(&crate::camera_track::static_params(g)).unwrap();
let mut errs = vec![];
for k in [0u32, 6, 12, 18, 24] {
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(k));
let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(k));
let camst = active_camera(&ctx);
for sp in pl.solve.visible(k as usize) {
let Some(obs) = tracks.track(sp.id).and_then(|t| t.at(k)) else { continue };
@@ -142,34 +132,31 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
let p95 = errs[errs.len() * 95 / 100];
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
assert!(p95 < 1.0 && mean < 0.5, "p95 {p95} px, mean {mean} px");
}
// The solid lies on the selected points' plane.
/// The solid lies on the selected points' plane and faces the camera.
fn assert_solid_on_plane(s: &Session, comp: &Comp, sl: &Layer, pl: &Placed, size: f64) {
let cid = s.active_comp_id().unwrap();
let tr = sl.props.sub("transform").unwrap();
let pos = tr.get("position").unwrap().value.as_vec3();
let ori = tr.get("orientation").unwrap().value.as_vec3();
let normal = linalg::mv(&linalg::from_euler_xyz(ori), [0.0, 0.0, -1.0]);
let size = r["target"]["size"].as_f64().unwrap();
for id in &s.state.camera_points {
let w = pl.to_world(pl.solve.point(*id).unwrap().pos);
let d = linalg::dot(linalg::sub(w, pos), normal).abs();
assert!(d < 0.05 * size.max(1.0) + 1.0, "point {id} is {d} px off the plane");
}
// The solid faces the camera.
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(12));
let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(12));
let eye = active_camera(&ctx).eye;
assert!(linalg::dot(normal, linalg::sub([eye.x, eye.y, eye.z], pos)) > 0.0);
}
// Undo removes both layers; redo brings them back.
let before = comp.layers.len();
assert!(s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
assert!(s.redo());
assert_eq!(s.active_comp().unwrap().layers.len(), before);
// A second create reuses the camera: Create Multiple Nulls and Camera.
/// A second create reuses the camera (Create Multiple Nulls and Camera), then Text and
/// Shadow Catcher (+ light).
fn assert_create_kinds(s: &mut Session, cam: LayerId, n_floor: usize) {
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null", "multiple": true})).unwrap();
assert_eq!(r["camera"].as_u64().unwrap(), cam.0);
assert_eq!(r["layers"].as_array().unwrap().len(), floor.len());
assert_eq!(r["layers"].as_array().unwrap().len(), n_floor);
// Text and Shadow Catcher (+ light).
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "text"})).unwrap();
assert_eq!(r["layers"].as_array().unwrap().len(), 1);
@@ -180,11 +167,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
assert_eq!(comp.layer(ids[0]).unwrap().props.prop("materialOptions/acceptsShadows").unwrap().value.as_enum(), 2);
assert!(comp.layer(ids[1]).unwrap().is_light());
assert!(s.execute_checked("camera.createFromSolve", json!({"kind": "teapot"})).is_err());
}
// Set Ground Plane and Origin: the target becomes the origin; a null created there sits at
// (0, 0, 0) lying on the X-Z plane (its front facing up, −y).
/// Set Ground Plane and Origin, then an explicit target.
fn assert_ground_and_target(s: &mut Session) {
s.execute_checked("camera.setGroundPlane", json!({})).unwrap();
assert_eq!(status(&mut s)["groundPlane"], true);
assert_eq!(status(s)["groundPlane"], true);
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null"})).unwrap();
let null = LayerId(r["layers"][0].as_u64().unwrap());
let tr = s.active_comp().unwrap().layer(null).unwrap().props.sub("transform").unwrap().clone();
@@ -203,6 +191,44 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
assert!(linalg::norm(linalg::sub(pos, [10.0, 0.0, 20.0])) < 1e-6, "{pos:?}");
let front = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]);
assert!(linalg::norm(linalg::sub(front, [0.0, 0.0, -1.0])) < 1e-6, "{front:?}");
}
#[test]
fn track_camera_solves_and_creates_layers_on_the_plane() {
let (mut s, clip) = tracked(json!({}));
let g = assert_solved(&mut s, clip);
let floor = select_floor_points(&mut s);
// Create Solid and Camera: one undo step, camera + solid.
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
let cam = LayerId(r["camera"].as_u64().unwrap());
let solid = LayerId(r["layers"][0].as_u64().unwrap());
let comp = s.active_comp().unwrap().clone();
assert!(comp.layer(cam).unwrap().is_camera());
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
assert_eq!(comp.layers[0].id, cam);
let sl = comp.layer(solid).unwrap();
assert!(sl.switches.three_d);
assert!(sl.name.starts_with("Track Solid"));
// The camera is keyed on every frame.
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
let cid = s.active_comp_id().unwrap();
let uid = g.uid;
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
assert_reprojection(&s, &comp, &pl, &g);
assert_solid_on_plane(&s, &comp, sl, &pl, r["target"]["size"].as_f64().unwrap());
// Undo removes both layers; redo brings them back.
let before = comp.layers.len();
assert!(s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
assert!(s.redo());
assert_eq!(s.active_comp().unwrap().layers.len(), before);
assert_create_kinds(&mut s, cam, floor.len());
assert_ground_and_target(&mut s);
// Serde keeps the analysis.
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
+23 -19
View File
@@ -239,6 +239,28 @@ fn set_param(s: &mut Session, layer: LayerId, path: &str, v: serde_json::Value)
s.execute("prop.set", json!({"layer": layer.0, "path": format!("effects/#1/{path}"), "value": v})).unwrap();
}
/// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre.
fn assert_crop_framing(s: &mut Session, clip: LayerId) {
set_param(s, clip, "borders/framing", json!(2));
let st = s.execute("warp.status", json!({})).unwrap();
assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}");
for f in 0..25 {
assert_eq!(transparent(&render(s, f)), 0, "frame {f}");
}
set_param(s, clip, "borders/framing", json!(1));
let st = s.execute("warp.status", json!({})).unwrap();
let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap();
for f in [0, 9, 24] {
let img = render(s, f);
for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) {
for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) {
assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})");
}
}
assert!(img.get(0, 0)[3] < 0.01);
}
}
#[test]
fn warp_stabilizer_analyzes_and_stabilizes() {
let (mut s, clip, solid) = setup(shaky);
@@ -289,25 +311,7 @@ fn warp_stabilizer_analyzes_and_stabilizes() {
}
set_param(&mut s, clip, "stabilization/result", json!(0));
// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre.
set_param(&mut s, clip, "borders/framing", json!(2));
let st = s.execute("warp.status", json!({})).unwrap();
assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}");
for f in 0..25 {
assert_eq!(transparent(&render(&s, f)), 0, "frame {f}");
}
set_param(&mut s, clip, "borders/framing", json!(1));
let st = s.execute("warp.status", json!({})).unwrap();
let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap();
for f in [0, 9, 24] {
let img = render(&s, f);
for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) {
for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) {
assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})");
}
}
assert!(img.get(0, 0)[3] < 0.01);
}
assert_crop_framing(&mut s, clip);
// Synthesize Edges fills most of the borders from neighbouring frames.
set_param(&mut s, clip, "borders/framing", json!(3));
let synth: usize = (0..25).map(|f| transparent(&render(&s, f))).sum();
+43 -33
View File
@@ -49,45 +49,38 @@ fn camera(s: &mut Session) -> u64 {
s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap()
}
#[test]
fn stereo_rig_builds_eye_comps_and_glasses() {
let mut s = session();
let src = cid(&s);
let cam = camera(&mut s);
let items_before = s.project.items.len();
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps");
assert_eq!(r["master"].as_u64(), Some(cam));
// Controls on a hidden null in the source comp.
let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap());
/// Controls on a hidden null in the source comp.
fn assert_stereo_controls(ctl: &Layer) {
assert_eq!(ctl.name, "Stereo 3D Controls");
assert!(!ctl.switches.video);
let fx = ctl.effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS));
assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0));
// Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp.
let d = 0.04 * 640.0;
for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] {
let ec = ItemId(r[key].as_u64().unwrap());
assert_eq!(s.project.item(ec).unwrap().name, name);
let c = s.project.comp(ec).unwrap();
assert_eq!((c.width, c.height), (640, 360));
assert_eq!(c.layers.len(), 2);
let cam = &c.layers[0];
assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam));
assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position"));
// Parallel eyes (no convergence): each looks straight ahead.
assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi"));
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0));
for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] {
let e = cam.props.prop(p).unwrap().expr.clone().unwrap();
assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text);
}
let nest = &c.layers[1];
assert_eq!(nest.source, LayerSource::Comp { item: src });
assert!(nest.switches.collapse && nest.switches.three_d);
}
/// An eye comp: an eye camera (two-node, linked by expressions) at `x` and the collapsed source comp.
fn assert_eye_comp(s: &Session, src: ItemId, ec: ItemId, name: &str, x: f64) {
assert_eq!(s.project.item(ec).unwrap().name, name);
let c = s.project.comp(ec).unwrap();
assert_eq!((c.width, c.height), (640, 360));
assert_eq!(c.layers.len(), 2);
let cam = &c.layers[0];
assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam));
assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position"));
// Parallel eyes (no convergence): each looks straight ahead.
assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi"));
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0));
for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] {
let e = cam.props.prop(p).unwrap().expr.clone().unwrap();
assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text);
}
// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden.
let nest = &c.layers[1];
assert_eq!(nest.source, LayerSource::Comp { item: src });
assert!(nest.switches.collapse && nest.switches.three_d);
}
/// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden.
fn assert_stereo_comp(s: &Session, r: &serde_json::Value) {
let oc = ItemId(r["stereoComp"].as_u64().unwrap());
let out = s.project.comp(oc).unwrap();
assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D");
@@ -98,6 +91,23 @@ fn stereo_rig_builds_eye_comps_and_glasses() {
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses"));
assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l)));
assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr)));
}
#[test]
fn stereo_rig_builds_eye_comps_and_glasses() {
let mut s = session();
let src = cid(&s);
let cam = camera(&mut s);
let items_before = s.project.items.len();
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps");
assert_eq!(r["master"].as_u64(), Some(cam));
assert_stereo_controls(&layer_in(&s, src, r["controls"].as_u64().unwrap()));
let d = 0.04 * 640.0;
for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] {
assert_eye_comp(&s, src, ItemId(r[key].as_u64().unwrap()), name, x);
}
assert_stereo_comp(&s, &r);
// One undo step; redo; serde.
let json = serde_json::to_string(&*s.project).unwrap();
let back: Project = serde_json::from_str(&json).unwrap();
+40 -25
View File
@@ -2,7 +2,7 @@
use effectcraft_color::BlendMode;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{ItemKind, LayerSource, MaskMode};
use effectcraft_project::{Comp, ItemKind, Layer, LayerSource, MaskMode};
use effectcraft_psd::write::*;
use effectcraft_psd::{DValue, Descriptor, Rect};
use serde_json::json;
@@ -74,22 +74,8 @@ pub(crate) fn sample_psd() -> Vec<u8> {
write(&d)
}
#[test]
fn psd_imports_as_composition() {
let path = tmp("sample.psd");
std::fs::write(&path, sample_psd()).unwrap();
let mut s = Session::default();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
assert_eq!(s.state.active_comp, Some(cid));
assert_eq!(s.project.item(cid).unwrap().name, "sample");
let c = s.project.comp(cid).unwrap().clone();
assert_eq!((c.width, c.height), (120, 80));
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]);
// Text layer: editable text, positioned at the type origin.
let title = &c.layers[0];
/// Text layer: editable text, positioned at the type origin.
fn assert_title_layer(title: &Layer) {
assert!(matches!(title.source, LayerSource::Text));
let Some(KV::Text(doc)) = title.props.prop("text/sourceText").map(|p| p.value.clone()) else { panic!("text") };
assert_eq!(doc.text, "Hello PSD");
@@ -97,14 +83,18 @@ fn psd_imports_as_composition() {
assert_eq!(doc.font, "Inter");
assert_eq!(doc.style, "Bold");
assert_eq!(title.props.prop("transform/position").unwrap().value.as_vec2(), [8.0, 70.0]);
// Adjustment layer with Hue/Saturation.
let hue = &c.layers[1];
}
/// Adjustment layer with Hue/Saturation.
fn assert_hue_layer(hue: &Layer) {
assert!(hue.switches.adjustment);
let fx = hue.effects().unwrap().groups().next().unwrap();
assert_eq!(fx.get("hue").unwrap().value.as_f64(), 40.0);
assert_eq!(fx.get("saturation").unwrap().value.as_f64(), 10.0);
// Vector mask → mask; Stroke style.
let vm = &c.layers[2];
}
/// Vector mask → mask; Stroke style.
fn assert_vector_mask_layer(vm: &Layer) {
let m = vm.masks().unwrap().groups().next().unwrap();
assert!(matches!(m.kind, effectcraft_project::GroupKind::Mask { mode: MaskMode::Add, .. }));
let Some(KV::Path(p)) = m.get("path").map(|p| p.value.clone()) else { panic!("mask path") };
@@ -113,19 +103,23 @@ fn psd_imports_as_composition() {
let st = vm.props.group("layerStyles/stroke").unwrap();
assert_eq!(st.get("size").unwrap().value.as_f64(), 2.0);
assert_eq!(st.get("position").unwrap().value, KV::Enum(1));
// Clipped → preserve transparency; opacity and blend mode.
}
/// Clipped → preserve transparency (opacity, blend mode); drop shadow.
fn assert_clipped_and_shadow(c: &Comp) {
let clipped = &c.layers[3];
assert!(clipped.preserve_transparency);
assert_eq!(clipped.blend_mode, BlendMode::Screen);
assert!((clipped.props.prop("transform/opacity").unwrap().value.as_f64() - 128.0 / 255.0 * 100.0).abs() < 1e-9);
// Drop shadow.
let ds = c.layers[4].props.group("layerStyles/dropShadow").unwrap();
assert_eq!(ds.get("distance").unwrap().value.as_f64(), 9.0);
assert_eq!(ds.get("angle").unwrap().value.as_f64(), 45.0);
assert_eq!(ds.get("useGlobalLight").unwrap().value, KV::Bool(false));
assert_eq!(ds.get("opacity").unwrap().value.as_f64(), 60.0);
// Group → collapsed precomp (pass through) with its layers.
let g = &c.layers[5];
}
/// Group → collapsed precomp (pass through) with its layers; footage items name their Photoshop layer.
fn assert_group_precomp(s: &Session, g: &Layer) {
assert!(g.switches.collapse);
let LayerSource::Comp { item } = g.source else { panic!("precomp") };
let inner = s.project.comp(item).unwrap();
@@ -140,6 +134,27 @@ fn psd_imports_as_composition() {
assert_eq!((sl.name.as_str(), sl.layer_size), ("Masked Square", false));
assert_eq!((f.width, f.height), (120, 80));
assert_eq!(s.project.item(item).unwrap().name, "Masked Square/sample.psd");
}
#[test]
fn psd_imports_as_composition() {
let path = tmp("sample.psd");
std::fs::write(&path, sample_psd()).unwrap();
let mut s = Session::default();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
assert_eq!(s.state.active_comp, Some(cid));
assert_eq!(s.project.item(cid).unwrap().name, "sample");
let c = s.project.comp(cid).unwrap().clone();
assert_eq!((c.width, c.height), (120, 80));
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]);
assert_title_layer(&c.layers[0]);
assert_hue_layer(&c.layers[1]);
assert_vector_mask_layer(&c.layers[2]);
assert_clipped_and_shadow(&c);
assert_group_precomp(&s, &c.layers[5]);
// One undo step removes everything.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.comp(cid).is_none());
+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];
+8 -11
View File
@@ -270,7 +270,7 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>)
let path = sequence_path(cx.path, f0 + k as i64);
// Encode in memory first: the storage hook decides where it fits.
let mut buf = std::io::Cursor::new(Vec::new());
write_still(cx, &mut buf, fmt, &img, comp, channels, w, h, cx.output.quality)?;
write_still(cx, &mut buf, &img, comp, StillOutput { format: fmt, channels, size: (w, h), quality: cx.output.quality })?;
let data = buf.into_inner();
let path = cx.place(&path, data.len() as u64);
let mut f = cx.create(&path)?;
@@ -287,18 +287,15 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>)
Ok(Report { path: sequence_path(cx.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] })
}
#[allow(clippy::too_many_arguments)]
fn write_still<W: std::io::Write + std::io::Seek>(
cx: &Cx,
file: &mut W,
fmt: OutputFormat,
img: &Image,
comp: &Comp,
struct StillOutput {
format: OutputFormat,
channels: Channels,
w: u32,
h: u32,
size: (u32, u32),
quality: u8,
) -> Result<()> {
}
fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &Image, comp: &Comp, output: StillOutput) -> Result<()> {
let StillOutput { format: fmt, channels, size: (w, h), quality } = output;
use image::{ExtendedColorType, ImageEncoder, ImageFormat};
match fmt {
OutputFormat::ExrSequence => {
+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");
}
+4 -4
View File
@@ -45,7 +45,7 @@ fn custom(start: f64, end: f64) -> RenderSettings {
}
fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result<Report, String>, Files) {
run_on(p, cid, s, om, path, options, None, cancel)
run_on(p, cid, s, om, path, options, (None, cancel))
}
/// [`run_with`] with an accelerator.
@@ -56,9 +56,9 @@ fn run_on(
om: &OutputModule,
path: &str,
options: JobOptions,
accel: Option<&dyn Accelerator>,
cancel: bool,
execution: (Option<&dyn Accelerator>, bool),
) -> (Result<Report, String>, Files) {
let (accel, cancel) = execution;
let files: Files = Default::default();
let f = files.clone();
let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| f.lock().unwrap().push((path.to_string(), data)));
@@ -456,7 +456,7 @@ fn a_job_renders_on_one_compositor() {
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = custom(0.0, 0.4);
let render = |p: &Project, accel: &GreenAccel, path: &str| {
let (r, files) = run_on(p, cid, &s, &om, path, JobOptions::default(), Some(accel), false);
let (r, files) = run_on(p, cid, &s, &om, path, JobOptions::default(), (Some(accel), false));
r.expect("export");
(0..10).map(|i| png(&files, i)).collect::<Vec<_>>()
};
+27 -24
View File
@@ -313,6 +313,32 @@ impl<'a> Resolver<'a> {
self.try_resolve(req).unwrap_or(Resp::Null)
}
/// Resolve an ancestor from its UID path while preserving child indexing.
fn ancestor(&self, comp: u64, layer: u64, path: &str, up: i64) -> Option<Resp> {
let l = self.layer(comp, layer)?;
// The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes.
let uid = match self.prop(comp, layer, path) {
Some((_, p)) => p.uid,
None => l.props.node(path)?.uid(),
};
let full = l.props.path_of(uid)?;
let segs: Vec<&str> = full.split('/').collect();
let up = usize::try_from(up).ok().filter(|u| *u >= 1 && *u <= segs.len())?;
if up == segs.len() {
return Some(Resp::Str("layer".into()));
}
let keep = segs.len() - up;
let group_path = segs.get(..keep)?.join("/");
let parent = match keep {
1 => &l.props,
_ => l.props.group(&segs.get(..keep - 1)?.join("/"))?,
};
let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::<u64>().ok()?;
let index = parent.children.iter().position(|c| c.uid() == uid)?;
let n = parent.children.get(index)?;
Some(node_resp(group_path, n, index + 1))
}
fn try_resolve(&self, req: &Req) -> Option<Resp> {
let project = self.ctx.project;
Some(match req {
@@ -373,30 +399,7 @@ impl<'a> Resolver<'a> {
let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) };
node_resp(path, n, index)
}
Req::Ancestor { comp, layer, path, up } => {
let l = self.layer(*comp, *layer)?;
// The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes.
let uid = match self.prop(*comp, *layer, path) {
Some((_, p)) => p.uid,
None => l.props.node(path)?.uid(),
};
let full = l.props.path_of(uid)?;
let segs: Vec<&str> = full.split('/').collect();
let up = usize::try_from(*up).ok().filter(|u| *u >= 1 && *u <= segs.len())?;
if up == segs.len() {
return Some(Resp::Str("layer".into()));
}
let keep = segs.len() - up;
let group_path = segs.get(..keep)?.join("/");
let parent = match keep {
1 => &l.props,
_ => l.props.group(&segs.get(..keep - 1)?.join("/"))?,
};
let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::<u64>().ok()?;
let index = parent.children.iter().position(|c| c.uid() == uid)?;
let n = parent.children.get(index)?;
node_resp(group_path, n, index + 1)
}
Req::Ancestor { comp, layer, path, up } => return self.ancestor(*comp, *layer, path, *up),
Req::PropInfo { comp, layer, path } => {
let (l, p) = self.prop(*comp, *layer, path)?;
let dims = prop_dims(l, p);
+11 -7
View File
@@ -93,7 +93,7 @@ impl ExprHost for Expressions {
return Ok([v.first().copied().unwrap_or(100.0), v.get(1).copied().unwrap_or(100.0), v.get(2).copied().unwrap_or(100.0)]);
};
let value = prop.value_at(layer.layer_time(ctx.time));
let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, [index as f64 + 1.0, total as f64], selector)?;
let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, TextSelector { index_total: [index as f64 + 1.0, total as f64], value: selector })?;
let num = |o: &runtime::Out| match o {
runtime::Out::Num(x) => Some(*x),
runtime::Out::Bool(b) => Some(if *b { 100.0 } else { 0.0 }),
@@ -124,13 +124,17 @@ impl Drop for FrameGuard {
/// Evaluate `text` as the expression of `prop` on `layer` at `ctx.time`, given its keyframed
/// `value`. Other properties' expressions are evaluated through `host`.
pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value, text: &str) -> Result<Value, String> {
let out = evaluate_out(host, ctx, layer, prop, value, text, [1.0, 1.0], [100.0; 3])?;
let out = evaluate_out(host, ctx, layer, prop, value, text, TextSelector { index_total: [1.0, 1.0], value: [100.0; 3] })?;
runtime::to_value(&out, value)
}
/// Run an expression and return the raw script result. `text_sel` / `selector` are the text
/// Expression Selector's `[textIndex, textTotal]` and `selectorValue`.
#[allow(clippy::too_many_arguments)]
/// Expression Selector state: `[textIndex, textTotal]` and `selectorValue`.
struct TextSelector {
index_total: [f64; 2],
value: [f64; 3],
}
/// Run an expression and return the raw script result.
fn evaluate_out(
host: &dyn ExprHost,
ctx: &EvalCtx,
@@ -138,9 +142,9 @@ fn evaluate_out(
prop: &Property,
value: &Value,
text: &str,
text_sel: [f64; 2],
selector: [f64; 3],
text_selector: TextSelector,
) -> Result<runtime::Out, String> {
let TextSelector { index_total: text_sel, value: selector } = text_selector;
let owner = Owner { comp: ctx.comp_id.0, layer: layer.id.0, uid: prop.uid, time: ctx.time, text: text.to_string() };
let (depth, cycle) = FRAMES.with(|f| {
let f = f.borrow();
+1 -1
View File
@@ -26,7 +26,7 @@ pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option<GpuImag
padded
};
let prefix = e.image(w + 1, h);
e.dispatch("bokeh_prefix", &Params::default(), &boosted, None, &prefix, None, (h.div_ceil(64), 1));
e.dispatch("bokeh_prefix", &Params::default(), crate::context::KernelImages { src: &boosted, aux: None, out: &prefix }, None, (h.div_ceil(64), 1));
// Radius map, then every level's spans.
let mut data: Vec<f32> = d.radius.as_ref().clone();
let mut levels = vec![];
+8 -1
View File
@@ -214,7 +214,14 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option<Gp
let mut p = Params::default();
p.u[0] = [run.planes.len() as u32, run.lights.len() as u32, run.casters.len() as u32, 0];
let out = e.scratch(w, h);
e.dispatch_ext("classic3d", &p, &atlas, Some(canvas), &out, Some(&mb), (w.div_ceil(16), h.div_ceil(16)), Some([&pb, &gb, &lb, &cb]));
e.dispatch_ext(
"classic3d",
&p,
crate::context::KernelImages { src: &atlas, aux: Some(canvas), out: &out },
Some(&mb),
(w.div_ceil(16), h.div_ceil(16)),
Some([&pb, &gb, &lb, &cb]),
);
Some(out)
}
+12 -16
View File
@@ -1089,6 +1089,13 @@ pub(crate) struct Enc<'g> {
owner: u64,
}
/// Images bound to a compute kernel's source, optional auxiliary and output slots.
pub(crate) struct KernelImages<'a> {
pub src: &'a GpuImage,
pub aux: Option<&'a GpuImage>,
pub out: &'a GpuImage,
}
impl Drop for Enc<'_> {
fn drop(&mut self) {
// Unsubmitted work is discarded, so nothing recorded will read pooled textures.
@@ -1162,32 +1169,21 @@ impl<'g> Enc<'g> {
}
/// Run kernel `entry` writing `out` (dispatched over `groups` workgroups).
pub fn dispatch(
&mut self,
entry: &str,
p: &Params,
src: &GpuImage,
aux: Option<&GpuImage>,
out: &GpuImage,
data: Option<&wgpu::Buffer>,
groups: (u32, u32),
) {
self.dispatch_ext(entry, p, src, aux, out, data, groups, None);
pub fn dispatch(&mut self, entry: &str, p: &Params, images: KernelImages<'_>, data: Option<&wgpu::Buffer>, groups: (u32, u32)) {
self.dispatch_ext(entry, p, images, data, groups, None);
}
/// [`Enc::dispatch`] for the kernels in `EXT_ENTRIES`, with their group 1 storage buffers.
#[allow(clippy::too_many_arguments)]
pub fn dispatch_ext(
&mut self,
entry: &str,
p: &Params,
src: &GpuImage,
aux: Option<&GpuImage>,
out: &GpuImage,
images: KernelImages<'_>,
data: Option<&wgpu::Buffer>,
groups: (u32, u32),
ext: Option<[&wgpu::Buffer; 4]>,
) {
let KernelImages { src, aux, out } = images;
let g = self.g;
let Some(pipe) = g.pipelines.get(entry) else {
if !g.unavailable.iter().any(|(e, _)| *e == entry) {
@@ -1243,7 +1239,7 @@ impl<'g> Enc<'g> {
/// Per-pixel kernel over `out`'s size (16×16 workgroups).
pub fn pixels(&mut self, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, out: &GpuImage, data: Option<&wgpu::Buffer>) {
let groups = (out.width.div_ceil(16), out.height.div_ceil(16));
self.dispatch(entry, p, src, aux, out, data, groups);
self.dispatch(entry, p, KernelImages { src, aux, out }, data, groups);
}
/// Copy `src` into a larger zeroed image at (`x`, `y`) (Buf::pad).
+8 -2
View File
@@ -234,7 +234,13 @@ pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize]
let mut p = Params::default();
p.u[0] = [r as u32, repeat as u32, block, 0];
let out = e.scratch(cur.width, cur.height);
e.dispatch(if vertical { "box_v" } else { "box_h" }, &p, &cur, None, &out, None, (lines.div_ceil(64), n.div_ceil(block)));
e.dispatch(
if vertical { "box_v" } else { "box_h" },
&p,
crate::context::KernelImages { src: &cur, aux: None, out: &out },
None,
(lines.div_ceil(64), n.div_ceil(block)),
);
cur = out;
}
cur
@@ -390,7 +396,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
* Mat3::translate(vec2(-anchor.0, -anchor.1));
let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear };
let empty = e.image(b.img.width, b.img.height);
b.img = ops::warp(e, &empty, &b.img, &m, sampling, effectcraft_color::BlendMode::Normal, opacity, 0, None);
b.img = ops::warp(e, &empty, &b.img, &m, ops::WarpOptions { sampling, mode: effectcraft_color::BlendMode::Normal, opacity, seed: 0 }, None);
Some(b)
}
+1 -1
View File
@@ -165,7 +165,7 @@ fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
return None;
}
let pre = e.image(w + 1, h);
e.dispatch("gen_row_prefix", &Params::default(), &src, None, &pre, None, (h.div_ceil(64), 1));
e.dispatch("gen_row_prefix", &Params::default(), crate::context::KernelImages { src: &src, aux: None, out: &pre }, None, (h.div_ceil(64), 1));
Some(pre)
} else {
None
+1 -1
View File
@@ -214,7 +214,7 @@ pub(crate) fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool
let seg = (4 * r as u32).clamp(32, 512).min(q.width);
let out = e.scratch(q.width, q.height);
let groups = (q.height.div_ceil(e.g.median_kernel.rows_per_workgroup()), q.width.div_ceil(seg));
e.dispatch("fxn_median_huang", &with_u0([r as u32, seg, 0, 0]), &q, None, &out, Some(&levels), groups);
e.dispatch("fxn_median_huang", &with_u0([r as u32, seg, 0, 0]), crate::context::KernelImages { src: &q, aux: None, out: &out }, Some(&levels), groups);
out
}
+2 -2
View File
@@ -879,7 +879,7 @@ fn corner_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let o = b.to_px([0.0, 0.0]);
let m = quad * Mat3::scale(vec2(1.0 / (w * b.scale), 1.0 / (h * b.scale))) * Mat3::translate(vec2(-o.0, -o.1));
let empty = e.image(b.img.width, b.img.height);
b.img = ops::warp(e, &empty, &b.img, &m, Sampling::Bilinear, BlendMode::Normal, 1.0, 0, None);
b.img = ops::warp(e, &empty, &b.img, &m, ops::WarpOptions { sampling: Sampling::Bilinear, mode: BlendMode::Normal, opacity: 1.0, seed: 0 }, None);
Some(b)
}
@@ -989,7 +989,7 @@ pub(crate) fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBu
let Some(pr) = host.params_at(t) else { continue };
let op = pr.f("opacity") as f32 / 100.0;
let empty = e.image(w, h);
let one = ops::warp(e, &empty, &b.img, &transform_matrix(&pr, &b), sampling, BlendMode::Normal, op, 0, None);
let one = ops::warp(e, &empty, &b.img, &transform_matrix(&pr, &b), ops::WarpOptions { sampling, mode: BlendMode::Normal, opacity: op, seed: 0 }, None);
let sum = e.image(w, h);
e.pixels("fxs_add", &Params::default(), &acc, Some(&one), &sum, None);
acc = sum;
+14 -2
View File
@@ -181,7 +181,13 @@ fn sphere_blur(e: &mut Enc, img: &GpuImage, sigma: f64) -> GpuImage {
let mut p = Params::default();
p.u[0][0] = 256;
let out = e.scratch(cur.width, cur.height);
e.dispatch("fxv_box_h", &p, &cur, None, &out, Some(&buf), (cur.height.div_ceil(64), cur.width.div_ceil(256)));
e.dispatch(
"fxv_box_h",
&p,
crate::context::KernelImages { src: &cur, aux: None, out: &out },
Some(&buf),
(cur.height.div_ceil(64), cur.width.div_ceil(256)),
);
cur = out;
}
for &r in &radii {
@@ -192,7 +198,13 @@ fn sphere_blur(e: &mut Enc, img: &GpuImage, sigma: f64) -> GpuImage {
let mut p = Params::default();
p.u[0] = [block, r as u32, 0, 0];
let out = e.scratch(cur.width, cur.height);
e.dispatch("fxv_box_v", &p, &cur, None, &out, None, (cur.width.div_ceil(64), cur.height.div_ceil(block)));
e.dispatch(
"fxv_box_v",
&p,
crate::context::KernelImages { src: &cur, aux: None, out: &out },
None,
(cur.width.div_ceil(64), cur.height.div_ceil(block)),
);
cur = out;
}
cur
+13 -27
View File
@@ -32,37 +32,23 @@ pub fn half(e: &mut Enc, src: &GpuImage) -> GpuImage {
out
}
/// `raster::composite_warp` (and, with `accumulate = Some(weight)`, `accumulate_warp`): draw
/// `src` transformed by `m` (src pixels → dst pixels) onto `dst`.
pub fn warp(
e: &mut Enc,
dst: &GpuImage,
src: &GpuImage,
m: &Mat3,
sampling: Sampling,
mode: BlendMode,
opacity: f32,
seed: u32,
accumulate: Option<f32>,
) -> GpuImage {
warp_q(e, dst, src, m, sampling, mode, opacity, seed, accumulate, None)
/// Sampling and blending policy for a transformed GPU image.
pub struct WarpOptions {
pub sampling: Sampling,
pub mode: BlendMode,
pub opacity: f32,
pub seed: u32,
}
/// Draw `src` transformed by `m` onto `dst`, or accumulate it with a sample weight.
pub fn warp(e: &mut Enc, dst: &GpuImage, src: &GpuImage, m: &Mat3, opts: WarpOptions, accumulate: Option<f32>) -> GpuImage {
warp_q(e, dst, src, m, opts, accumulate, None)
}
/// [`warp`], clamping and quantising the result to `levels` (8/16 bpc: the comp after a layer,
/// in the same pass).
#[allow(clippy::too_many_arguments)]
pub fn warp_q(
e: &mut Enc,
dst: &GpuImage,
src: &GpuImage,
m: &Mat3,
sampling: Sampling,
mode: BlendMode,
opacity: f32,
seed: u32,
accumulate: Option<f32>,
levels: Option<f32>,
) -> GpuImage {
pub fn warp_q(e: &mut Enc, dst: &GpuImage, src: &GpuImage, m: &Mat3, opts: WarpOptions, accumulate: Option<f32>, levels: Option<f32>) -> GpuImage {
let WarpOptions { sampling, mode, opacity, seed } = opts;
if src.width == 0 || src.height == 0 || opacity <= 0.0 {
return dst.clone();
}
-1
View File
@@ -2,7 +2,6 @@
//! Set EFFECTCRAFT_REQUIRE_GPU=1 for acceptance: unavailable adapters/devices then fail
//! instead of taking the explicitly logged headless-CI skip path.
#![cfg(not(target_arch = "wasm32"))]
#![allow(clippy::expect_used)] // Test helpers require successful setup to exercise rejection.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

Some files were not shown because too many files have changed in this diff Show More