mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 20:47:18 -06:00
Merge main into feat/recent-script-files
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+36
-36
@@ -1329,7 +1329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"craft-update",
|
||||
@@ -1355,7 +1355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
@@ -1367,14 +1367,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
@@ -1390,7 +1390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1398,7 +1398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"effectcraft-color",
|
||||
@@ -1416,7 +1416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -1451,7 +1451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
@@ -1481,7 +1481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
@@ -1498,7 +1498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1506,7 +1506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
@@ -1528,14 +1528,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -1560,7 +1560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1579,7 +1579,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1591,7 +1591,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
@@ -1609,7 +1609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-model",
|
||||
@@ -1638,7 +1638,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
@@ -1650,14 +1650,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1669,7 +1669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"effectcraft-raster",
|
||||
@@ -1685,7 +1685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1697,7 +1697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1710,14 +1710,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1726,7 +1726,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1748,7 +1748,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
@@ -1766,7 +1766,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.9.1",
|
||||
"ndarray",
|
||||
@@ -1775,7 +1775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
@@ -1787,7 +1787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
@@ -1799,7 +1799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1807,7 +1807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
@@ -1819,7 +1819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
@@ -1853,11 +1853,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -7183,7 +7183,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
|
||||
+4
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,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,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 }
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
use super::raster::over;
|
||||
use super::*;
|
||||
use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Px, hsl_to_rgb};
|
||||
|
||||
// ---------------------------------------------------------------- Fractal
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{ParamUi, slider};
|
||||
|
||||
// ---------------------------------------------------------------- ASC CDL
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
use super::cdl::{Cdl, parse_cdl_xml};
|
||||
use super::*;
|
||||
use super::{Arc, HashMap, Lut, Mutex, OnceLock, parse_cube};
|
||||
|
||||
// ---------------------------------------------------------------- LUT files
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{invert, mul, mul_vec, rgb_to_xyz};
|
||||
|
||||
pub(super) type M3 = [[f64; 3]; 3];
|
||||
|
||||
|
||||
@@ -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,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 }
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Buf, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, ParallelSliceMut, Px, unpremul};
|
||||
|
||||
// ------------------------------------------------------------------ band rasteriser
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, &[]));
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{EngineError, Result, Session, Value};
|
||||
|
||||
// ---------- enablement ----------
|
||||
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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)? {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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)?;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
use super::params::bad;
|
||||
use super::*;
|
||||
use super::{CommandSpec, Result, Value};
|
||||
|
||||
// ---------- parameter validation (agents) ----------
|
||||
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{ItemId, Serialize, Value};
|
||||
|
||||
/// Events for frontends (drained each frame).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::prefs;
|
||||
|
||||
pub fn text_families() -> Vec<String> {
|
||||
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::config;
|
||||
|
||||
/// Host services (file access) injected by the frontend.
|
||||
pub trait Services: Send + Sync {
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{EngineError, Event, Result, Session, Value, commands, guard};
|
||||
|
||||
impl Session {
|
||||
pub fn new() -> Session {
|
||||
|
||||
@@ -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,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,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,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::recent::{MAX_EFFECTS_FAVORITES, MAX_RECENT_SCRIPTS};
|
||||
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,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,4 +1,6 @@
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::Prefs;
|
||||
|
||||
impl Prefs {
|
||||
/// Remember a project in File ▸ Open Recent.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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,6 +91,24 @@ pub(crate) fn map_frames_while<I: Send, T: Send>(
|
||||
out
|
||||
}
|
||||
|
||||
/// Threads that encode one batch of frames while the next renders ([`Cx::frames_beside`]).
|
||||
/// Their own pool: the encoder's parallel steps (colour conversion) would otherwise queue behind
|
||||
/// the render's work in the global pool. `None` where threads can't be started (a browser
|
||||
/// without them): encoding and rendering then take turns.
|
||||
pub(crate) struct Beside(Option<rayon::ThreadPool>);
|
||||
|
||||
impl Beside {
|
||||
fn new() -> Beside {
|
||||
Beside(rayon::ThreadPoolBuilder::new().thread_name(|i| format!("export-encode-{i}")).build().ok())
|
||||
}
|
||||
|
||||
/// The process's encode threads, started by the first movie export and shared by all.
|
||||
pub fn shared() -> &'static Beside {
|
||||
static SHARED: std::sync::OnceLock<Beside> = std::sync::OnceLock::new();
|
||||
SHARED.get_or_init(Beside::new)
|
||||
}
|
||||
}
|
||||
|
||||
/// A job being exported: the job, the project with the Render Settings overrides applied, the
|
||||
/// job's layer cache and per-frame timing for the render log.
|
||||
pub(crate) struct Cx<'a> {
|
||||
@@ -214,6 +232,40 @@ impl<'a> Cx<'a> {
|
||||
map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f)
|
||||
}
|
||||
|
||||
/// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the
|
||||
/// render threads don't wait for the (largely sequential) encoder of the previous batch.
|
||||
/// With deferred readbacks, or where no encode thread could be started, `encode` runs first.
|
||||
pub async fn frames_beside<T: Send, A: Send>(
|
||||
&self,
|
||||
beside: &Beside,
|
||||
comp: &Comp,
|
||||
ks: Vec<u64>,
|
||||
f: impl Fn(u64, Image) -> T + Sync + Send,
|
||||
encode: impl FnOnce() -> A + Send,
|
||||
) -> crate::Result<(A, Vec<T>)> {
|
||||
match &beside.0 {
|
||||
Some(pool) if !self.deferred() => {
|
||||
let mut done = None;
|
||||
let frames = pool.in_place_scope(|s| {
|
||||
s.spawn(|_| done = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(encode))));
|
||||
effectcraft_render::passes::block_on(self.frames(comp, ks, f))
|
||||
});
|
||||
match done {
|
||||
Some(Ok(a)) => Ok((a, frames)),
|
||||
Some(Err(panic)) => {
|
||||
let why = panic.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| panic.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
Err(crate::ExportError::Encode(format!("the encoder stopped unexpectedly: {why}")))
|
||||
}
|
||||
None => Err(crate::ExportError::Encode("the encoder stopped unexpectedly".into())),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let a = encode();
|
||||
Ok((a, self.frames(comp, ks, f).await))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every
|
||||
/// frame in flight holds its own working textures, so a whole batch rendered at once can
|
||||
/// exhaust VRAM (or shared memory, which crashes instead of falling back). `None` when the
|
||||
@@ -706,6 +758,45 @@ mod tests {
|
||||
assert!(stats.hits >= 9, "{stats:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_render_beside_the_encoder() {
|
||||
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::FrameRate;
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(32, 16, FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into()));
|
||||
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::H264));
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &output,
|
||||
path: "/c/out.mp4",
|
||||
sink: None,
|
||||
nested_switches: true,
|
||||
options: Default::default(),
|
||||
};
|
||||
let cx = Cx::new(&job);
|
||||
let comp = cx.comp().unwrap();
|
||||
use effectcraft_render::passes::block_on;
|
||||
let want = block_on(cx.frames(comp, (0..6).collect(), |k, img| (k, img.width, img.height)));
|
||||
// On threads of their own, and taking turns (no threads): the same frames, in order.
|
||||
for beside in [Beside::shared(), &Beside(None)] {
|
||||
let (a, frames) = block_on(cx.frames_beside(beside, comp, (0..6).collect(), |k, img| (k, img.width, img.height), || 7)).unwrap();
|
||||
assert_eq!((a, &frames), (7, &want));
|
||||
}
|
||||
// A panicking encoder fails the export instead of taking the render thread down.
|
||||
let r = block_on(cx.frames_beside(Beside::shared(), comp, vec![0], |k, _| k, || -> u32 { panic!("encoder bug") }));
|
||||
match r {
|
||||
Err(crate::ExportError::Encode(e)) => assert!(e.contains("encoder bug"), "{e}"),
|
||||
other => panic!("{:?}", other.map(|(a, f)| (a, f.len()))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleave_takes_alternate_lines() {
|
||||
let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
@@ -4,6 +4,7 @@ mod audio_sample_positions;
|
||||
mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod mov_audio_sample_positions;
|
||||
mod movie_bytes;
|
||||
mod ms_rounded_footage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_audio_sample_positions;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Movie exports pinned byte for byte: a comp longer than one render batch, with a rotating
|
||||
//! stroked ring (mostly transparent, bilinear), a fading half-transparent solid at a fractional
|
||||
//! position and a Screen-mode shape, in 8 bpc. The comp is at most four macroblock rows high,
|
||||
//! so the H.264 encoder writes one slice per frame on any number of cores. The hashes are those of the files written before
|
||||
//! frames rendered while the previous batch encoded, before transparent source tiles were
|
||||
//! skipped and before blended pixels were quantised as they were drawn: those changes must not
|
||||
//! move a single byte.
|
||||
|
||||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::{Job, Sink, export};
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
fn fnv1a(data: &[u8]) -> u64 {
|
||||
data.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3))
|
||||
}
|
||||
|
||||
fn project() -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(160, 64, FrameRate::new(30, 1), Tick::from_seconds_f64(2.0));
|
||||
comp.background = [0.05, 0.1, 0.3];
|
||||
let t = Tick::from_seconds_f64;
|
||||
|
||||
let sid = p.add_item("Half", Label::Red, None, ItemKind::Solid(Solid { color: [0.9, 0.5, 0.1], width: 70, height: 40, pixel_aspect: 1.0 }));
|
||||
let mut solid = build::layer(&mut p, &comp, "Half", LayerSource::Solid { item: sid }, (70, 40), None);
|
||||
if let Some(pr) = solid.props.prop_mut("transform/position") {
|
||||
pr.value = Value::Vec3([60.25, 36.5, 0.0]);
|
||||
}
|
||||
if let Some(pr) = solid.props.prop_mut("transform/opacity") {
|
||||
pr.keys = vec![Keyframe::new(t(0.0), Value::Scalar(20.0)), Keyframe::new(t(2.0), Value::Scalar(70.0))];
|
||||
}
|
||||
|
||||
let mut ring = build::layer(&mut p, &comp, "Ring", LayerSource::Shape, (160, 64), None);
|
||||
let mut box_ = build::layer(&mut p, &comp, "Box", LayerSource::Shape, (160, 64), None);
|
||||
let mut next = p.next_id;
|
||||
{
|
||||
let mut ids = Ids(&mut next);
|
||||
let e = build::shape_ellipse(&mut ids, [56.0, 56.0], [0.0, 0.0]);
|
||||
let s = build::shape_stroke(&mut ids, [0.3, 0.8, 1.0, 1.0], 3.0);
|
||||
let g = build::shape_group(&mut ids, "Ring", vec![e, s]);
|
||||
if let Some(c) = ring.props.sub_mut("contents") {
|
||||
c.children.push(g.into());
|
||||
}
|
||||
let r = build::shape_rect(&mut ids, [30.0, 20.0], [25.0, -10.0], 4.0);
|
||||
let f = build::shape_fill(&mut ids, [0.2, 1.0, 0.4, 0.8]);
|
||||
let g = build::shape_group(&mut ids, "Box", vec![r, f]);
|
||||
if let Some(c) = box_.props.sub_mut("contents") {
|
||||
c.children.push(g.into());
|
||||
}
|
||||
}
|
||||
p.next_id = next;
|
||||
if let Some(pr) = ring.props.prop_mut("transform/rotation") {
|
||||
pr.keys = vec![Keyframe::new(t(0.0), Value::Scalar(0.0)), Keyframe::new(t(2.0), Value::Scalar(130.0))];
|
||||
}
|
||||
if let Some(pr) = box_.props.prop_mut("transform/position") {
|
||||
pr.keys = vec![Keyframe::new(t(0.0), Value::Vec3([70.0, 30.0, 0.0])), Keyframe::new(t(2.0), Value::Vec3([95.5, 38.25, 0.0]))];
|
||||
}
|
||||
box_.blend_mode = BlendMode::Screen;
|
||||
|
||||
comp.layers = vec![box_, ring, solid];
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn export_bytes(format: OutputFormat, name: &str) -> Vec<u8> {
|
||||
let (p, cid) = project();
|
||||
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let om = OutputModule::for_format(format);
|
||||
let out = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let file = out.clone();
|
||||
let sink: &Sink = &move |_: &str, data: Vec<u8>| *file.lock().unwrap() = data;
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &om,
|
||||
path: name,
|
||||
sink: Some(sink),
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
let r = export(&job, &mut |_| true).expect("export");
|
||||
assert_eq!(r.frames, 60);
|
||||
std::mem::take(&mut *out.lock().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_movie_bytes_are_pinned() {
|
||||
let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4");
|
||||
assert_eq!((data.len(), fnv1a(&data)), (104_161, 10_466_446_817_305_548_344), "H.264 MP4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prores_movie_bytes_are_pinned() {
|
||||
let data = export_bytes(OutputFormat::ProRes, "/out/pinned.mov");
|
||||
assert_eq!((data.len(), fnv1a(&data)), (270_094, 8_038_709_743_204_075_800), "ProRes MOV");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user