mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
+10
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; recounted after #1774: Japanese 27% → 46%, Simplified Chinese 29% → 46%, Traditional Chinese 29% → 38%, overall 18% → 24%) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
|
||||
EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written
|
||||
from scratch in Rust. This page is the one-page summary; the evidence is in
|
||||
@@ -117,6 +117,14 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-30: Text strokes take a Line Join (Miter, Round or Bevel) in the Character panel,
|
||||
Miter for new text as in After Effects (#2077). In the Timeline, effects, masks and shape items
|
||||
drag to a new place in their stack (#2210) and Shift-click adds them to the selection, so one
|
||||
animation preset can hold several (#2552). The Composition panel pans while a mask vertex is
|
||||
held at its edge (#999). Effect buffers that pad a small layer stay within a memory budget
|
||||
(#2261); an MP4 lasts as long as its edit list shows (#2592); the Render Queue's times tick
|
||||
between frames (#2593). ScriptUI drags keep their control past the window edge with
|
||||
`MouseEvent.buttons` (#2584), and docked panels get their dock's size and `onResize` (#2585).
|
||||
- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain
|
||||
that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it
|
||||
starts off, so existing shapes behave as before (#2489).
|
||||
@@ -169,6 +177,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Progress log: text Line Join, Timeline drag-reorder and Shift multi-select of effects, viewer edge pan, effect padding budget, MP4 edit-list duration, Render Queue clock, ScriptUI drag capture and docked-panel size |
|
||||
| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) |
|
||||
| 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md |
|
||||
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.Krueels]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
|
||||
@@ -96,6 +96,7 @@ const MIXED_WRITES: &[(&str, &[&str])] = &[("templates.create", &["projectPath",
|
||||
|
||||
/// Commands that write files named by these parameters.
|
||||
const WRITES: &[(&str, &[&str])] = &[
|
||||
("camera.exportBlender", &["path"]),
|
||||
("file.save", &["path"]),
|
||||
("file.saveAs", &["path"]),
|
||||
("file.saveCopy", &["path"]),
|
||||
|
||||
@@ -1485,3 +1485,30 @@ fn automation_roots_refuse_running_script_code() {
|
||||
call_json(&mut s, "execute_command", json!({"command": "scriptui.list"}));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_requires_an_absolute_path_in_a_write_root() {
|
||||
use crate::confine::Confinement;
|
||||
let base = std::env::temp_dir().join(format!("ec-blender-confine-{}", std::process::id()));
|
||||
let (read, write) = (base.join("read"), base.join("write"));
|
||||
for dir in [&read, &write] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let confinement = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap();
|
||||
let check = |path: &std::path::Path| confinement.check_command("camera.exportBlender", &json!({"path": path}));
|
||||
assert_eq!(check(&write.join("scene.py")).unwrap(), None);
|
||||
for path in [read.join("scene.py"), base.join("outside.py"), write.join("../read/scene.py")] {
|
||||
assert!(check(&path).is_err(), "must deny {}", path.display());
|
||||
}
|
||||
for path in ["scene.py", "./scene.py", "write/scene.py", "../scene.py"] {
|
||||
let error = check(std::path::Path::new(path)).unwrap_err().to_string();
|
||||
assert!(error.contains("give an absolute path"), "{path}: {error}");
|
||||
}
|
||||
let read_only = Confinement::new(std::slice::from_ref(&write), &[]).unwrap();
|
||||
assert!(read_only.check_command("camera.exportBlender", &json!({"path": write.join("scene.py")})).is_err());
|
||||
for path in [json!(read.join("scene.py")), json!("scene.py"), json!("$1.path")] {
|
||||
let batch = json!({"steps": [{"command": "camera.exportBlender", "params": {"path": path}}]});
|
||||
assert!(confinement.check_command("engine.batch", &batch).is_err());
|
||||
}
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ pub use ocio::color_stabilizer_maps;
|
||||
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
|
||||
pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes};
|
||||
pub use ocio::{ColorOp, Straight, Tf, color_program};
|
||||
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan};
|
||||
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan};
|
||||
pub use sim2::{
|
||||
CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan,
|
||||
wave_world_plan,
|
||||
|
||||
@@ -38,7 +38,7 @@ use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys};
|
||||
pub use registry::{specs, sprite_plan};
|
||||
pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint};
|
||||
pub(crate) use sprites::{combine, raster, splat};
|
||||
pub use weather::{Bubble, bubble_list, drizzle_drops};
|
||||
pub use weather::{Bubble, bubble_list, bubble_sample_bounds, drizzle_drops};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -112,6 +112,45 @@ fn bubbles_near_edges_show_the_layer() {
|
||||
assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)");
|
||||
}
|
||||
|
||||
/// CC Bubbles on a 40 × 40 layer `img` at `scale`, in a buffer padded by 6 transparent pixels
|
||||
/// (a half-resolution preview's layout).
|
||||
fn padded_bubbles(img: Image, scale: f64, reflection: u32) -> Image {
|
||||
let size = 40.0;
|
||||
let s = crate::find("ec.sim.ccbubbles").unwrap();
|
||||
let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [size; 2]))).collect() };
|
||||
for (k, v) in [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))] {
|
||||
params.values.insert(k.into(), v);
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.3, layer_size: [size; 2], seed: 1, adjustment: false, env: EffectEnv::default() };
|
||||
let mut b = Buf { img, offset: [0.0; 2], scale };
|
||||
b.pad(6);
|
||||
crate::apply(s, &ctx, b).img
|
||||
}
|
||||
|
||||
/// #2024: the same when the buffer has transparent padding around the layer: samples past the
|
||||
/// layer's edges repeat its edge pixels, not the padding, and a clear hole inside the layer is
|
||||
/// still refracted as the pale colour.
|
||||
#[test]
|
||||
fn bubbles_near_padded_edges_show_the_layer() {
|
||||
let blue = |side| Image::filled(side, side, [0.0, 0.0, 1.0, 1.0]);
|
||||
let pale = |img: &Image| img.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count();
|
||||
for (scale, side) in [(1.0, 40), (0.5, 20)] {
|
||||
let counts = [0, 1].map(|reflection| {
|
||||
let a = padded_bubbles(blue(side), scale, reflection);
|
||||
assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn");
|
||||
pale(&a)
|
||||
});
|
||||
assert_eq!(counts, [0, 0], "pale bubble pixels at scale {scale} (liquid, metal)");
|
||||
}
|
||||
let mut holed = blue(40);
|
||||
for y in 12..28 {
|
||||
for x in 12..28 {
|
||||
holed.set(x, y, [0.0; 4]);
|
||||
}
|
||||
}
|
||||
assert!(pale(&padded_bubbles(holed, 1.0, 0)) > 0, "the hole refracts as the pale colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drizzle_ripples() {
|
||||
let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::common::{h, hs};
|
||||
use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster};
|
||||
use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul};
|
||||
use crate::util::layer_rect_at;
|
||||
|
||||
// ------------------------------------------------------------------ CC Rainfall / CC Snowfall
|
||||
|
||||
@@ -160,11 +161,21 @@ pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec<Bubble> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// CC Bubbles' refraction sample bounds in the pixels of a buffer with `offset` and `scale`: the
|
||||
/// layer rectangle's first and last pixel centres (x0, y0, x1, y1). Samples clamped to them repeat
|
||||
/// the layer's edge pixels instead of reaching transparent padding around it (#2024).
|
||||
pub fn bubble_sample_bounds(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> [f32; 4] {
|
||||
let (lx, ly, lw, lh) = layer_rect_at(ctx, offset, scale);
|
||||
let (x0, y0) = (lx + 0.5, ly + 0.5);
|
||||
[x0 as f32, y0 as f32, (lx + lw - 0.5).max(x0) as f32, (ly + lh - 0.5).max(y0) as f32]
|
||||
}
|
||||
|
||||
pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let metal = pr.e("reflectionType") == 1;
|
||||
let shading = pr.e("shadingType");
|
||||
let list = bubble_list(ctx, &b);
|
||||
let [x0, y0, x1, y1] = bubble_sample_bounds(ctx, b.offset, b.scale);
|
||||
let src = b.img.clone();
|
||||
let mut out = Image::new(src.width, src.height);
|
||||
raster(
|
||||
@@ -182,7 +193,8 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
// Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror).
|
||||
let k = if metal { 1.6 } else { -0.7 };
|
||||
let nz = (1.0 - d2).max(0.0).sqrt();
|
||||
let p = src.sample_bilinear_clamped((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
|
||||
let (sx, sy) = ((q.sx + dx * q.r * k).max(x0).min(x1), (q.sy + dy * q.r * k).max(y0).min(y1));
|
||||
let p = src.sample_bilinear_clamped(sx as f64, sy as f64);
|
||||
let (c, sa) = unpremul(p);
|
||||
let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c };
|
||||
let mut a = cov;
|
||||
|
||||
@@ -441,10 +441,10 @@ const GOLDEN: &[(&str, u64)] = &[
|
||||
("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db),
|
||||
("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84),
|
||||
("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x020755e48bdf8f43),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x4aac0f1332e6001e),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0xdc2ab5e04c4a95a2),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0x30f78adc5d1dd715),
|
||||
("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980),
|
||||
("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0),
|
||||
("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c),
|
||||
|
||||
@@ -10,6 +10,7 @@ mod animation;
|
||||
pub(crate) mod app_more;
|
||||
mod autotrace;
|
||||
mod batch;
|
||||
mod blender;
|
||||
mod camera_cmds;
|
||||
mod comp;
|
||||
pub(crate) mod comp_more;
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Camera-solve interchange through Blender's public Python API. The generated script is
|
||||
//! original MIT OR Apache-2.0 code; no Blender implementation is read or embedded.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use effectcraft_effects::camera_tracker as ct;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Uid};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::camtrack::{CameraSolve, linalg};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
mod validate;
|
||||
|
||||
use super::{b_p, bad, f_p, str_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "camera.exportBlender";
|
||||
const MAX_FRAMES: usize = 100_000;
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
const MAX_JSON: usize = 64 * 1024 * 1024;
|
||||
const MAX_FRAME: i64 = 1_048_574;
|
||||
|
||||
fn number(p: &Value, key: &str, default: f64, min: f64, max: f64) -> Result<f64> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(_) => f_p(p, key).ok_or_else(|| bad(CMD, format!("{key}: expected a number")))?,
|
||||
};
|
||||
if !v.is_finite() || !(min..=max).contains(&v) {
|
||||
return Err(bad(CMD, format!("{key}: expected {min}..{max}")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn finite(values: impl IntoIterator<Item = f64>) -> Result<()> {
|
||||
if values.into_iter().any(|v| !v.is_finite() || v.abs() > 1e12) {
|
||||
return Err(bad(CMD, "invalid or excessive numeric camera/scene data; re-analyze the tracker"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Right-handed EffectCraft world (right, down, forward) → Blender (right, forward, up).
|
||||
fn position(p: [f64; 3], scale: f64) -> [f64; 3] {
|
||||
[p[0] * scale, p[2] * scale, -p[1] * scale]
|
||||
}
|
||||
|
||||
fn media_path(s: &Session, path: &str) -> Result<String> {
|
||||
if path.is_empty() || path.contains('\0') {
|
||||
return Err(bad(CMD, "the source footage has no valid file path; relink it before exporting"));
|
||||
}
|
||||
let path = Path::new(path);
|
||||
if path.is_absolute() || cfg!(target_arch = "wasm32") {
|
||||
return Ok(path.to_string_lossy().into_owned());
|
||||
}
|
||||
let cwd = std::env::current_dir().map_err(|e| bad(CMD, format!("cannot resolve footage path: {e}")))?;
|
||||
let base = s.path.as_deref().and_then(|p| Path::new(p).parent()).map(|p| cwd.join(p)).unwrap_or(cwd);
|
||||
Ok(base.join(path).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
pub(super) fn export(s: &mut Session, p: &Value, cid: ItemId, lid: LayerId, uid: Uid) -> Result<Value> {
|
||||
if s.running_for_script() && !s.prefs.scripting.allow_scripts_write_files {
|
||||
return Err(bad(CMD, "scripts can't export files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on"));
|
||||
}
|
||||
let options = Options::parse(p)?;
|
||||
let Options { path, sensor, units, start, include_points } = options;
|
||||
if s.camera_target().is_some_and(|(c, l, u)| (c, l, u) == (cid, lid, uid)) && s.is_camera_analyzing() || s.camera_pending.contains(&(cid, lid, uid)) {
|
||||
return Err(bad(CMD, "wait for this tracker's analysis to finish before exporting"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or_else(|| bad(CMD, "tracked layer missing"))?;
|
||||
let g = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "tracker missing"))?;
|
||||
for name in [ct::SOLVE, ct::TRACKS] {
|
||||
if g.prop(name).is_some_and(|p| matches!(&p.value, effectcraft_keyframe::Value::Str(v) if v.len() > MAX_JSON)) {
|
||||
return Err(bad(CMD, "tracking data exceeds the 64 MiB export limit"));
|
||||
}
|
||||
}
|
||||
let params = crate::camera_track::static_params(g);
|
||||
let solve = ct::solve(¶ms).ok_or_else(|| bad(CMD, "the camera is not solved yet; analyze the tracker first"))?;
|
||||
let (lo, end) = validate::timing(comp, layer, &solve, start)?;
|
||||
let Source { footage, path: source_path, fps: source_fps, count: source_count } = source(s, layer, &solve, path)?;
|
||||
validate::geometry(&solve, sensor)?;
|
||||
let camera = params
|
||||
.get(ct::CAMERA)
|
||||
.map(|v| v.as_f64())
|
||||
.filter(|v| v.is_finite() && *v > 0.0 && *v <= u64::MAX as f64)
|
||||
.and_then(|id| comp.layer(LayerId(id as u64)))
|
||||
.filter(|l| l.is_camera());
|
||||
let warnings = scene_warnings(camera, &solve, footage);
|
||||
let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, lo) };
|
||||
let (frames, world) = bake_frames(&ctx, layer, &solve, camera, &options, footage, source_count)?;
|
||||
let mut points = Vec::new();
|
||||
for point in &solve.points {
|
||||
finite(point.pos.into_iter().chain([point.error as f64]))?;
|
||||
if point.first > point.last || point.last as usize >= solve.frames.len() {
|
||||
return Err(bad(CMD, "invalid solved point frame range; re-analyze the tracker"));
|
||||
}
|
||||
let location = position(world.apply(point.pos), units);
|
||||
finite(location)?;
|
||||
if include_points {
|
||||
points.push(json!({"id": point.id, "location": location, "error": point.error,
|
||||
"first": start.saturating_add(point.first as i64), "last": start.saturating_add(point.last as i64)}));
|
||||
}
|
||||
}
|
||||
let tracks = ct::tracks(¶ms);
|
||||
if tracks.as_ref().is_some_and(|t| t.tracks.len() > MAX_POINTS || t.tracks.iter().any(|t| t.pts.len() > MAX_FRAMES)) {
|
||||
return Err(bad(CMD, "2D tracks exceed export limits"));
|
||||
}
|
||||
let markers: Vec<Value> = comp
|
||||
.markers
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let frame = start.saturating_add(comp.frame_rate.frame_at(m.time).saturating_sub(comp.frame_rate.frame_at(lo)));
|
||||
if (start..=end).contains(&frame) { Some(json!({"name": m.comment, "frame": frame})) } else { None }
|
||||
})
|
||||
.collect();
|
||||
let payload = json!({"version": 1, "name": format!("{} — {}", s.project.item(cid).map(|i| i.name.as_str()).unwrap_or("Shot"), layer.name),
|
||||
"resolution": solve.size, "fpsNumerator": comp.frame_rate.num, "fpsDenominator": comp.frame_rate.den,
|
||||
"startFrame": start, "endFrame": end, "sensorWidth": sensor, "unitScale": units,
|
||||
"media": {"path": source_path, "kind": if footage.kind == FootageKind::Still {"FILE"} else {"MOVIE"},
|
||||
"fps": source_fps, "nativeRate": footage.native_rate, "pixelAspect": footage.pixel_aspect, "timecode": footage.start_timecode,
|
||||
"frameCount": source_count, "loopCount": footage.loop_count},
|
||||
"frames": frames, "points": points, "markers": markers, "ground": solve.ground.is_some(),
|
||||
"groundSize": world.s * units, "shutter": comp.shutter_angle / 360.0, "shutterPhase": comp.shutter_phase,
|
||||
"solve": solve, "tracks": if include_points { tracks.as_deref() } else { None },
|
||||
"warnings": warnings});
|
||||
let data = serde_json::to_string(&payload).map_err(|e| bad(CMD, format!("cannot serialize scene: {e}")))?;
|
||||
if data.len() > MAX_JSON {
|
||||
return Err(bad(CMD, "scene exceeds the 64 MiB script limit; disable includePoints or split the shot"));
|
||||
}
|
||||
let quoted = serde_json::to_string(&data).map_err(|e| bad(CMD, format!("cannot encode scene: {e}")))?;
|
||||
let script = include_str!("blender_scene.py").replace("__EFFECTCRAFT_DATA__", "ed);
|
||||
s.services.write_file(path, script.as_bytes()).map_err(|e| bad(CMD, format!("cannot write {path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": script.len(), "frames": frames.len(), "points": points.len(), "warnings": warnings}))
|
||||
}
|
||||
|
||||
struct Options<'a> {
|
||||
path: &'a str,
|
||||
sensor: f64,
|
||||
units: f64,
|
||||
start: i64,
|
||||
include_points: bool,
|
||||
}
|
||||
|
||||
impl<'a> Options<'a> {
|
||||
fn parse(p: &'a Value) -> Result<Self> {
|
||||
let path = str_p(p, "path").filter(|p| !p.is_empty() && !p.contains('\0')).ok_or_else(|| bad(CMD, "missing `path` (.py)"))?;
|
||||
if !Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("py")) {
|
||||
return Err(bad(CMD, "the export path must end in .py"));
|
||||
}
|
||||
let sensor = number(p, "sensorWidth", 36.0, 1.0, 1000.0)?;
|
||||
let units = number(p, "unitScale", 0.01, 0.000_001, 1000.0)?;
|
||||
let start = match p.get("startFrame") {
|
||||
None => 1,
|
||||
Some(v) => v.as_i64().filter(|n| (1..=MAX_FRAME).contains(n)).ok_or_else(|| bad(CMD, "startFrame: expected a positive Blender frame number"))?,
|
||||
};
|
||||
let include_points = match p.get("includePoints") {
|
||||
Some(v) if !v.is_boolean() => return Err(bad(CMD, "includePoints: expected a boolean")),
|
||||
_ => b_p(p, "includePoints").unwrap_or(true),
|
||||
};
|
||||
Ok(Self { path, sensor, units, start, include_points })
|
||||
}
|
||||
}
|
||||
|
||||
struct Source<'a> {
|
||||
footage: &'a Footage,
|
||||
path: String,
|
||||
fps: f64,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn source<'a>(s: &'a Session, layer: &Layer, solve: &CameraSolve, path: &str) -> Result<Source<'a>> {
|
||||
let footage = match layer.source {
|
||||
LayerSource::Footage { item } => s.project.item(item).and_then(|i| match &i.kind {
|
||||
ItemKind::Footage(f) => Some(f),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|f| f.has_video && matches!(f.kind, FootageKind::Video | FootageKind::Still));
|
||||
let footage = footage
|
||||
.ok_or_else(|| bad(CMD, "Blender background export needs a video or still-footage layer; render precomps/sequences to a video and track that video"))?;
|
||||
let source_path = media_path(s, &footage.path)?;
|
||||
if Path::new(path) == Path::new(&source_path) {
|
||||
return Err(bad(CMD, "the export path must differ from the source footage"));
|
||||
}
|
||||
if footage.width as f64 != solve.size[0] || footage.height as f64 != solve.size[1] {
|
||||
return Err(bad(CMD, "the analysis image differs from the original footage dimensions; render the tracker's input to a video and track that video"));
|
||||
}
|
||||
let source_fps = footage.frame_rate.as_f64();
|
||||
if footage.frame_rate.num <= 0 || footage.frame_rate.den <= 0 || !source_fps.is_finite() || source_fps <= 0.0 {
|
||||
return Err(bad(CMD, "invalid source footage frame rate"));
|
||||
}
|
||||
// Same frame selection as the media pool: interpreted FPS, rounded per-loop frame count,
|
||||
// clamp to the total loop range, then wrap. Native FPS does not change the frame index.
|
||||
let divisor = (effectcraft_time::TICKS_PER_SECOND as i128).saturating_mul(footage.frame_rate.den as i128);
|
||||
let source_count = (footage.duration.0 as i128).saturating_mul(footage.frame_rate.num as i128).saturating_add(divisor / 2) / divisor;
|
||||
let source_count = if footage.kind == FootageKind::Still { 1 } else { source_count };
|
||||
if !(1..=MAX_FRAME as i128).contains(&source_count) {
|
||||
return Err(bad(CMD, "invalid footage duration or footage exceeds Blender's frame range; trim/render the source video first"));
|
||||
}
|
||||
let source_count = source_count as i64;
|
||||
Ok(Source { footage, path: source_path, fps: source_fps, count: source_count })
|
||||
}
|
||||
|
||||
fn scene_warnings(camera: Option<&Layer>, solve: &CameraSolve, footage: &Footage) -> Vec<String> {
|
||||
let mut warnings = vec![
|
||||
"Sensor width is assumed (36 mm by default); monocular tracking cannot recover physical sensor size or absolute scene scale.".to_string(),
|
||||
"The original footage is referenced, not embedded or rendered: masks and effects before the tracker are not baked. Relink the video if moving this script.".to_string(),
|
||||
];
|
||||
if camera.is_none() {
|
||||
warnings
|
||||
.push("No linked tracker camera: focus uses the default scene depth, DOF is off; optical focus cannot be recovered from feature tracks.".into());
|
||||
} else {
|
||||
warnings.push("Focus/iris settings are sampled from the linked camera; pose and lens come from the tracker solve. Blender's physical DOF is an approximation of EffectCraft's blur model.".into());
|
||||
}
|
||||
if solve.distortion.is_some_and(|d| !d.is_none()) {
|
||||
warnings.push("Radial distortion is preserved as metadata (Brown k1/k2, half-diagonal normalization), not applied to Blender's pinhole camera. Use an undistorted plate for exact alignment.".into());
|
||||
}
|
||||
if (footage.pixel_aspect - 1.0).abs() > 1e-6 {
|
||||
warnings.push("The tracker solves square pixels: export uses square pixels; original footage pixel aspect is retained in metadata.".into());
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Bake the solved camera and the linked camera's optical settings at exact composition ticks.
|
||||
fn bake_frames(
|
||||
ctx0: &EvalCtx<'_>,
|
||||
layer: &Layer,
|
||||
solve: &CameraSolve,
|
||||
camera: Option<&Layer>,
|
||||
options: &Options<'_>,
|
||||
footage: &Footage,
|
||||
source_count: i64,
|
||||
) -> Result<(Vec<Value>, linalg::Similarity)> {
|
||||
let comp = ctx0.comp;
|
||||
let lo = ctx0.time;
|
||||
let fd = comp.frame_duration();
|
||||
let sensor = options.sensor;
|
||||
let units = options.units;
|
||||
let start = options.start;
|
||||
let first = solve.frames.first().ok_or_else(|| bad(CMD, "the camera solve is empty"))?;
|
||||
let m = ctx0.world_matrix(layer).0;
|
||||
let layer_scale = m[0][0].hypot(m[1][0]);
|
||||
finite([layer_scale])?;
|
||||
if layer_scale <= 1e-9 {
|
||||
return Err(bad(CMD, "the tracked layer has zero scale"));
|
||||
}
|
||||
let world = solve.world([comp.width as f64, comp.height as f64], first.focal * layer_scale);
|
||||
finite([world.s * units])?;
|
||||
let mut frames = Vec::with_capacity(solve.frames.len());
|
||||
for (i, frame) in solve.frames.iter().enumerate() {
|
||||
let time = Tick(lo.0.saturating_add(fd.0.saturating_mul(i as i64)));
|
||||
let ctx = ctx0.at(time);
|
||||
let pose = linalg::mm(&world.r, &linalg::mt(&frame.r()));
|
||||
let right = position(linalg::mv(&pose, [1.0, 0.0, 0.0]), 1.0);
|
||||
let up = position(linalg::mv(&pose, [0.0, -1.0, 0.0]), 1.0);
|
||||
let back = position(linalg::mv(&pose, [0.0, 0.0, -1.0]), 1.0);
|
||||
let location = position(world.apply(frame.center), units);
|
||||
let lens = frame.focal * sensor / solve.size[0];
|
||||
let opts = camera.and_then(|l| l.props.sub("cameraOptions").map(|g| (l, g)));
|
||||
let value = |key: &str, fallback: f64| opts.map(|(l, g)| ctx.f(l, g, key, fallback)).unwrap_or(fallback);
|
||||
let focus = value("focusDistance", world.s) * units;
|
||||
let aperture = value("aperture", 0.0);
|
||||
let zoom = value("zoom", frame.focal * layer_scale);
|
||||
// Match Camera Settings' f-stop conversion: focal_for_zoom uses a 36 mm film width,
|
||||
// and the aperture UI is mm scaled by comp_width / 1920 (not a world-space diameter).
|
||||
let aperture_mm = aperture * 1920.0 / comp.width as f64;
|
||||
let fstop = if aperture_mm > 0.0 { (zoom * 36.0 / comp.width as f64) / aperture_mm } else { 2.8 };
|
||||
let rotation = value("irisRotation", 0.0).to_radians();
|
||||
let ratio = value("irisAspectRatio", 1.0);
|
||||
let shape = opts.map(|(l, g)| ctx.e(l, g, "irisShape")).unwrap_or(0);
|
||||
let blades = match shape {
|
||||
0 => 4,
|
||||
1..=8 => shape.saturating_add(2),
|
||||
_ => return Err(bad(CMD, "invalid iris shape")),
|
||||
};
|
||||
let dof = opts.is_some_and(|(l, g)| ctx.b(l, g, "dof")) && aperture > 0.0 && value("blurLevel", 100.0) > 0.0;
|
||||
let source_time = ctx.source_time(layer);
|
||||
let source_frame = if footage.kind == FootageKind::Still {
|
||||
0
|
||||
} else {
|
||||
footage.frame_rate.frame_at(source_time).clamp(0, source_count.saturating_mul(footage.loop_count.max(1) as i64).saturating_sub(1)) % source_count
|
||||
};
|
||||
finite(location.into_iter().chain([focus, aperture, zoom, fstop, rotation, ratio, value("blurLevel", 100.0), value("irisRoundness", 0.0)]))?;
|
||||
if focus <= 0.0 || !(0.01..=100.0).contains(&ratio) || !(0.01..=1e6).contains(&fstop) {
|
||||
return Err(bad(CMD, "focus, f-stop or iris aspect is outside Blender's supported range"));
|
||||
}
|
||||
frames.push(json!({"frame": start + i as i64, "timeTicks": time.0, "sourceFrame": source_frame,
|
||||
"location": location, "rotation": [right, up, back], "lens": lens, "focus": focus, "dof": dof,
|
||||
"fstop": fstop, "blades": blades, "irisRotation": rotation, "irisRatio": ratio,
|
||||
"sourceAperture": aperture, "sourceZoom": zoom,
|
||||
"blurLevel": value("blurLevel", 100.0), "irisRoundness": value("irisRoundness", 0.0)}));
|
||||
}
|
||||
Ok((frames, world))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Validate solved geometry and its correspondence to the current layer timing.
|
||||
|
||||
use effectcraft_project::{Comp, Layer};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::camtrack::{CameraSolve, linalg};
|
||||
|
||||
use super::{CMD, MAX_FRAME, MAX_FRAMES, MAX_POINTS, bad, finite};
|
||||
use crate::Result;
|
||||
|
||||
pub(super) fn timing(comp: &Comp, layer: &Layer, solve: &CameraSolve, start: i64) -> Result<(Tick, i64)> {
|
||||
if solve.frames.len() > MAX_FRAMES || solve.points.len() > MAX_POINTS {
|
||||
return Err(bad(CMD, "export is limited to 100,000 frames and 100,000 points; split the shot"));
|
||||
}
|
||||
if solve.size.iter().any(|v| !v.is_finite() || *v < 4.0 || *v > 65_536.0 || v.fract() != 0.0) {
|
||||
return Err(bad(CMD, "invalid tracking resolution (expected 4..65536 whole pixels)"));
|
||||
}
|
||||
let fps = comp.frame_rate.as_f64();
|
||||
if comp.frame_rate.num <= 0 || comp.frame_rate.den <= 0 || !fps.is_finite() || !(1.0..=1000.0).contains(&fps) {
|
||||
return Err(bad(CMD, "invalid composition frame rate (expected 1..1000 fps)"));
|
||||
}
|
||||
let end = start
|
||||
.checked_add(solve.frames.len() as i64)
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.filter(|n| *n <= MAX_FRAME)
|
||||
.ok_or_else(|| bad(CMD, "the baked animation exceeds Blender's frame range"))?;
|
||||
finite([solve.start, solve.frame_duration, solve.average_error, layer.stretch, comp.shutter_angle, comp.shutter_phase])?;
|
||||
if layer.stretch == 0.0 || comp.width == 0 || comp.height == 0 {
|
||||
return Err(bad(CMD, "invalid composition dimensions or zero layer stretch"));
|
||||
}
|
||||
let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO));
|
||||
let hi = layer.out_point.min(comp.duration);
|
||||
let fd = comp.frame_duration();
|
||||
if fd.0 <= 0 || lo >= hi {
|
||||
return Err(bad(CMD, "the tracked layer has no active frames"));
|
||||
}
|
||||
let expected = ((hi.0 as i128 - lo.0 as i128 + fd.0 as i128 - 1) / fd.0 as i128) as u128;
|
||||
if expected != solve.frames.len() as u128 {
|
||||
return Err(bad(CMD, "the solve no longer matches the layer's frame range; re-analyze the tracker"));
|
||||
}
|
||||
let dt = (layer.layer_time(lo + fd) - layer.layer_time(lo)).seconds();
|
||||
if (solve.start - layer.layer_time(lo).seconds()).abs() > 1e-6 || solve.frames.len() > 1 && (solve.frame_duration - dt).abs() > 1e-6 {
|
||||
return Err(bad(CMD, "the solve no longer matches the layer's timing; re-analyze the tracker"));
|
||||
}
|
||||
Ok((lo, end))
|
||||
}
|
||||
|
||||
pub(super) fn geometry(solve: &CameraSolve, sensor: f64) -> Result<()> {
|
||||
for frame in &solve.frames {
|
||||
finite(frame.rot.into_iter().chain(frame.center).chain([frame.focal]))?;
|
||||
if !frame.solved || frame.focal <= 0.0 {
|
||||
return Err(bad(CMD, "the solve contains unsolved frames or invalid focal lengths; re-analyze the tracker"));
|
||||
}
|
||||
let mm = sensor * frame.focal / solve.size[0];
|
||||
if !(1.0..=5000.0).contains(&mm) {
|
||||
return Err(bad(CMD, "lens outside Blender's 1..5000 mm range; choose another sensorWidth"));
|
||||
}
|
||||
}
|
||||
if let Some(ground) = &solve.ground {
|
||||
finite(ground.origin.into_iter().chain(ground.normal).chain(ground.x_axis))?;
|
||||
if (linalg::norm(ground.normal) - 1.0).abs() > 1e-4
|
||||
|| (linalg::norm(ground.x_axis) - 1.0).abs() > 1e-4
|
||||
|| linalg::dot(ground.normal, ground.x_axis).abs() > 1e-4
|
||||
{
|
||||
return Err(bad(CMD, "invalid ground-plane basis; set the ground plane again"));
|
||||
}
|
||||
}
|
||||
if let Some(d) = &solve.distortion {
|
||||
finite([d.k1, d.k2, d.radius])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
# EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0.
|
||||
# Run in Blender's Scripting workspace (Open, Run Script), or:
|
||||
# blender --background --python "Tracked Scene.py"
|
||||
# Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself.
|
||||
# Public API types: bpy.types.Camera, bpy.types.ImageUser
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
DATA = json.loads(__EFFECTCRAFT_DATA__)
|
||||
|
||||
|
||||
def _curves(owner):
|
||||
"""Blender 4.0–4.3 legacy actions and 4.4+/5.x layered actions."""
|
||||
anim = owner.animation_data
|
||||
if anim is None or anim.action is None:
|
||||
return
|
||||
action = anim.action
|
||||
if getattr(action, "is_action_layered", False):
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
if strip.type == 'KEYFRAME':
|
||||
bag = strip.channelbag(anim.action_slot)
|
||||
if bag is not None:
|
||||
yield from bag.fcurves
|
||||
else:
|
||||
yield from action.fcurves
|
||||
|
||||
|
||||
def _interpolation(owner):
|
||||
for curve in _curves(owner):
|
||||
# Footage indices and discrete switches must never interpolate between frames.
|
||||
stepped = ("frame_offset" in curve.data_path or "use_dof" in curve.data_path
|
||||
or "aperture_blades" in curve.data_path)
|
||||
for key in curve.keyframe_points:
|
||||
key.interpolation = 'CONSTANT' if stepped else 'LINEAR'
|
||||
|
||||
|
||||
def _media_path():
|
||||
original = DATA["media"]["path"]
|
||||
candidates = [original]
|
||||
# When sharing, place the video alongside the script or the .blend and run again.
|
||||
script_path = globals().get("__file__", "")
|
||||
if script_path:
|
||||
candidates.append(os.path.join(os.path.dirname(os.path.abspath(script_path)),
|
||||
os.path.basename(original)))
|
||||
if bpy.data.filepath:
|
||||
candidates.append(os.path.join(os.path.dirname(bpy.data.filepath),
|
||||
os.path.basename(original)))
|
||||
return next((p for p in candidates if os.path.isfile(p)), None)
|
||||
|
||||
|
||||
def import_scene():
|
||||
if bpy.app.version < (4, 0, 0):
|
||||
raise RuntimeError("EffectCraft scene import requires Blender 4.0 or newer")
|
||||
scene = bpy.data.scenes.new(DATA["name"])
|
||||
scene.frame_start = DATA["startFrame"]
|
||||
scene.frame_end = DATA["endFrame"]
|
||||
fps = DATA["fpsNumerator"] / DATA["fpsDenominator"]
|
||||
scene.render.fps = max(1, round(fps))
|
||||
scene.render.fps_base = scene.render.fps / fps
|
||||
scene.render.resolution_x, scene.render.resolution_y = map(int, DATA["resolution"])
|
||||
scene.render.resolution_percentage = 100
|
||||
# The tracker assumes square pixels, regardless of the source's interpretation metadata.
|
||||
scene.render.pixel_aspect_x = scene.render.pixel_aspect_y = 1.0
|
||||
scene.render.film_transparent = True
|
||||
scene.render.motion_blur_shutter = max(0.0, min(2.0, DATA["shutter"]))
|
||||
scene.unit_settings.system = 'METRIC'
|
||||
scene.unit_settings.scale_length = 1.0
|
||||
scene["effectcraft_unit_scale"] = DATA["unitScale"]
|
||||
scene["effectcraft_scale_is_arbitrary"] = True
|
||||
scene["effectcraft_shutter_phase_degrees"] = DATA["shutterPhase"]
|
||||
|
||||
collection = bpy.data.collections.new("EffectCraft Tracking")
|
||||
scene.collection.children.link(collection)
|
||||
camera_data = bpy.data.cameras.new("3D Tracker Camera")
|
||||
camera = bpy.data.objects.new("3D Tracker Camera", camera_data)
|
||||
collection.objects.link(camera)
|
||||
scene.camera = camera
|
||||
camera.rotation_mode = 'QUATERNION'
|
||||
camera_data.type = 'PERSP'
|
||||
camera_data.sensor_fit = 'HORIZONTAL'
|
||||
camera_data.sensor_width = DATA["sensorWidth"]
|
||||
camera_data.sensor_height = (DATA["sensorWidth"] * DATA["resolution"][1]
|
||||
/ DATA["resolution"][0])
|
||||
camera_data.show_background_images = True
|
||||
camera_data.show_limits = True
|
||||
extent = max([DATA["groundSize"], 1.0]
|
||||
+ [math.dist(p["location"], f["location"])
|
||||
for p in DATA["points"][::max(1, len(DATA["points"]) // 100)]
|
||||
for f in DATA["frames"][::max(1, len(DATA["frames"]) // 10)]])
|
||||
camera_data.clip_start = max(0.00001, min(0.1, extent / 10000.0))
|
||||
camera_data.clip_end = max(1000.0, extent * 10.0)
|
||||
|
||||
previous = None
|
||||
for sample in DATA["frames"]:
|
||||
frame = sample["frame"]
|
||||
camera.location = sample["location"]
|
||||
# Exported right/up/back vectors are COLUMNS of Blender's local → world basis.
|
||||
quaternion = Matrix(sample["rotation"]).transposed().to_quaternion()
|
||||
if previous is not None and previous.dot(quaternion) < 0:
|
||||
quaternion.negate()
|
||||
camera.rotation_quaternion = quaternion
|
||||
previous = quaternion.copy()
|
||||
camera.keyframe_insert(data_path="location", frame=frame)
|
||||
camera.keyframe_insert(data_path="rotation_quaternion", frame=frame)
|
||||
camera_data.lens = sample["lens"]
|
||||
camera_data.keyframe_insert(data_path="lens", frame=frame)
|
||||
camera_data.dof.use_dof = sample["dof"]
|
||||
camera_data.dof.focus_distance = sample["focus"]
|
||||
camera_data.dof.aperture_fstop = sample["fstop"]
|
||||
camera_data.dof.aperture_blades = sample["blades"]
|
||||
camera_data.dof.aperture_rotation = (sample["irisRotation"] + math.pi) % math.tau - math.pi
|
||||
camera_data.dof.aperture_ratio = sample["irisRatio"]
|
||||
for prop in ("use_dof", "focus_distance", "aperture_fstop", "aperture_blades",
|
||||
"aperture_rotation", "aperture_ratio"):
|
||||
camera_data.keyframe_insert(data_path="dof." + prop, frame=frame)
|
||||
|
||||
path = _media_path()
|
||||
if path is not None:
|
||||
try:
|
||||
image = bpy.data.images.load(path, check_existing=False)
|
||||
image.source = DATA["media"]["kind"]
|
||||
background = camera_data.background_images.new()
|
||||
background.source = 'IMAGE'
|
||||
background.image = image
|
||||
background.alpha = 1.0
|
||||
background.display_depth = 'BACK'
|
||||
background.frame_method = 'STRETCH'
|
||||
user = background.image_user
|
||||
user.use_auto_refresh = True
|
||||
user.use_cyclic = False
|
||||
user.frame_start = DATA["startFrame"]
|
||||
user.frame_duration = max(1, min(1048574, image.frame_duration))
|
||||
if image.source == 'MOVIE':
|
||||
for sample in DATA["frames"]:
|
||||
# Blender uses 1-based image frames; sourceFrame is 0-based.
|
||||
user.frame_offset = sample["sourceFrame"] - (sample["frame"] - user.frame_start)
|
||||
camera_data.keyframe_insert(
|
||||
data_path="background_images[0].image_user.frame_offset",
|
||||
frame=sample["frame"])
|
||||
except (RuntimeError, ValueError, OSError) as error:
|
||||
DATA["warnings"].append("Blender could not load the background video: " + str(error))
|
||||
else:
|
||||
DATA["warnings"].append("Video not found: " + DATA["media"]["path"]
|
||||
+ ". Place it beside this script or relink the camera background.")
|
||||
|
||||
if DATA["points"]:
|
||||
mesh = bpy.data.meshes.new("Solved Track Points")
|
||||
mesh.from_pydata([p["location"] for p in DATA["points"]], [], [])
|
||||
mesh.update()
|
||||
cloud = bpy.data.objects.new("Solved Track Points", mesh)
|
||||
collection.objects.link(cloud)
|
||||
cloud.show_in_front = True
|
||||
cloud.hide_render = True
|
||||
for name, kind, key in (("track_id", 'INT', "id"), ("reprojection_error", 'FLOAT', "error"),
|
||||
("first_frame", 'INT', "first"), ("last_frame", 'INT', "last")):
|
||||
attr = mesh.attributes.new(name=name, type=kind, domain='POINT')
|
||||
for dest, point in zip(attr.data, DATA["points"]):
|
||||
# Track IDs are u32 in EffectCraft; keep the full IDs in the JSON text too.
|
||||
dest.value = point[key] if kind == 'FLOAT' else min(2147483647, point[key])
|
||||
|
||||
if DATA["ground"]:
|
||||
size = DATA["groundSize"]
|
||||
mesh = bpy.data.meshes.new("Solved Ground Plane")
|
||||
mesh.from_pydata([(-size, -size, 0), (size, -size, 0),
|
||||
(size, size, 0), (-size, size, 0)], [], [(0, 1, 2, 3)])
|
||||
mesh.update()
|
||||
ground = bpy.data.objects.new("Solved Ground Plane", mesh)
|
||||
collection.objects.link(ground)
|
||||
ground.display_type = 'WIRE'
|
||||
ground.hide_render = True
|
||||
for marker in DATA["markers"]:
|
||||
scene.timeline_markers.new(marker["name"], frame=marker["frame"])
|
||||
|
||||
_interpolation(camera)
|
||||
_interpolation(camera_data)
|
||||
metadata = bpy.data.texts.new("EffectCraft Tracking Metadata.json")
|
||||
metadata.write(json.dumps(DATA, ensure_ascii=False, indent=2))
|
||||
scene["effectcraft_metadata_text"] = metadata.name
|
||||
camera_data["effectcraft_average_error_pixels"] = DATA["solve"]["averageError"]
|
||||
camera_data["effectcraft_solve_method"] = DATA["solve"]["methodUsed"]
|
||||
camera_data["effectcraft_focus_source"] = "See warnings in " + metadata.name
|
||||
distortion = DATA["solve"].get("distortion")
|
||||
if distortion:
|
||||
for key in ("k1", "k2", "radius"):
|
||||
camera_data["effectcraft_distortion_" + key] = distortion[key]
|
||||
if bpy.context.window is not None:
|
||||
bpy.context.window.scene = scene
|
||||
for area in bpy.context.window.screen.areas:
|
||||
if area.type == 'VIEW_3D':
|
||||
area.spaces.active.region_3d.view_perspective = 'CAMERA'
|
||||
scene.frame_set(scene.frame_start)
|
||||
for warning in DATA["warnings"]:
|
||||
print("EffectCraft: " + warning)
|
||||
print("EffectCraft: imported", len(DATA["frames"]), "baked camera frames into", scene.name)
|
||||
return scene
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
EFFECTCRAFT_SCENE = import_scene()
|
||||
@@ -42,11 +42,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
create_from_solve
|
||||
),
|
||||
cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))),
|
||||
cmd!(
|
||||
"camera.exportBlender",
|
||||
"Export Scene to Blender...",
|
||||
[],
|
||||
None,
|
||||
"{path: .py, comp?, layer?, effect?, sensorWidth?: mm (default 36), unitScale?: Blender units per comp pixel (default 0.01), startFrame?: positive integer (default 1), includePoints?: true}",
|
||||
has_comp,
|
||||
export_blender
|
||||
),
|
||||
cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground),
|
||||
cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points),
|
||||
]
|
||||
}
|
||||
|
||||
fn export_blender(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = ct_p(s, p, "camera.exportBlender")?;
|
||||
super::blender::export(s, p, cid, lid, uid)
|
||||
}
|
||||
|
||||
fn with_kind(p: &Value, kind: &str) -> Value {
|
||||
let mut q = p.clone();
|
||||
if !q.is_object() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use effectcraft_keyframe::{ShapePath, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer_menu::target_masks;
|
||||
@@ -111,18 +112,30 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The parametric shape paths that Convert To Bezier Path replaces with an editable path.
|
||||
const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"];
|
||||
|
||||
/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the
|
||||
/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted
|
||||
/// path is the shape that was on screen rather than an approximation of it; the defaults match
|
||||
/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness).
|
||||
fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePath> {
|
||||
let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64());
|
||||
let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2());
|
||||
/// The path a parametric shape group draws, in the group's own space. This is the geometry the
|
||||
/// renderer builds from the same values (`render::shapes::collect`), expressions included, so the
|
||||
/// converted path is the shape that was on screen rather than an approximation of it; the defaults
|
||||
/// match that code for a property a group doesn't carry (a polygon has no inner radius or
|
||||
/// roundness).
|
||||
fn parametric_path(ctx: &EvalCtx, layer: &effectcraft_project::Layer, g: &PropGroup) -> Option<ShapePath> {
|
||||
let f = |id: &str, d: f64| ctx.f(layer, g, id, d);
|
||||
let v2 = |id: &str, d: [f64; 2]| ctx.v2(layer, g, id, d);
|
||||
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)),
|
||||
"rect" => {
|
||||
let (size, pos) = (v2("size", [100.0; 2]), v2("position", [0.0; 2]));
|
||||
if ctx.b(layer, g, "perCorner") {
|
||||
effectcraft_path::rect_radii(
|
||||
size,
|
||||
pos,
|
||||
[f("topLeftRoundness", 0.0), f("topRightRoundness", 0.0), f("bottomRightRoundness", 0.0), f("bottomLeftRoundness", 0.0)],
|
||||
)
|
||||
} else {
|
||||
effectcraft_path::rect(size, pos, f("roundness", 0.0))
|
||||
}
|
||||
}
|
||||
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
|
||||
"star" => effectcraft_path::polystar(effectcraft_path::Polystar {
|
||||
star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
|
||||
star: ctx.e(layer, g, "type") == 0,
|
||||
points: f("points", 5.0),
|
||||
pos: v2("position", [0.0; 2]),
|
||||
rotation: f("rotation", 0.0),
|
||||
@@ -160,28 +173,38 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let t = s.time();
|
||||
let n = shapes.len();
|
||||
// Read the geometry before editing, with the same expression-aware context the renderer
|
||||
// uses: an expression-driven corner radius, size or position must convert to what is on
|
||||
// screen, not to the property's underlying value.
|
||||
let prepared = {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
|
||||
let mut prepared = Vec::new();
|
||||
for (lid, uid) in &shapes {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
let Some(old) = l.props.sub("contents").and_then(|c| c.find_group(*uid)) else { continue };
|
||||
let Some(path) = parametric_path(&ctx, l, old) else { continue };
|
||||
// Path Direction is the shape's own, not a default: reversing is a separate command.
|
||||
prepared.push((*lid, *uid, path, ctx.e(l, old, "direction"), old.enabled));
|
||||
}
|
||||
prepared
|
||||
};
|
||||
let made = s.edit("Convert To Bezier Path", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let mut made = Vec::new();
|
||||
for (lid, uid) in &shapes {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
for (lid, uid, path, direction, enabled) in prepared {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?;
|
||||
let Some(old) = contents.find_group(*uid) else { continue };
|
||||
let Some(path) = parametric_path(old, lt) else { continue };
|
||||
// Path Direction is the shape's own, not a default: reversing is a separate command.
|
||||
let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum());
|
||||
let enabled = old.enabled;
|
||||
let mut g = build::shape_path(&mut Ids(&mut next), path);
|
||||
if let Some(pr) = g.prop_mut("direction") {
|
||||
pr.value = KV::Enum(direction);
|
||||
}
|
||||
g.enabled = enabled;
|
||||
let new_uid = g.uid;
|
||||
let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
|
||||
let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
|
||||
let parent = contents.parent_of_mut(uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
|
||||
let Some(at) = parent.children.iter().position(|c| c.uid() == uid) else { continue };
|
||||
parent.children[at] = Node::Group(g);
|
||||
made.push((*lid, new_uid));
|
||||
made.push((lid, new_uid));
|
||||
}
|
||||
proj.next_id = next;
|
||||
// The new paths are what is selected, so the vertices can be edited straight away.
|
||||
|
||||
@@ -581,6 +581,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
scriptui::mouse
|
||||
),
|
||||
cmd!(
|
||||
"scriptui.resize",
|
||||
"Resize Script Window",
|
||||
[],
|
||||
None,
|
||||
"{window?, size: [width, height] (pixels)} → the window takes the size, lays itself out again and its onResizing / onResize run (a docked ScriptUI panel gets its dock's size)",
|
||||
always,
|
||||
scriptui::resize
|
||||
),
|
||||
cmd!(
|
||||
"scriptui.close",
|
||||
"Close Script Window",
|
||||
|
||||
@@ -769,6 +769,17 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value })
|
||||
}
|
||||
|
||||
/// `scriptui.resize`: the host gave the window a new size (a docked panel's dock, #2585): the
|
||||
/// script's window takes it, lays itself out again and gets `onResizing` / `onResize`.
|
||||
pub(crate) fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "scriptui.resize";
|
||||
let window = window_p(s, p, c)?;
|
||||
let size = p.get("size").and_then(Value::as_array).ok_or_else(|| crate::commands::bad(c, "missing `size` [width, height]"))?;
|
||||
let side = |i: usize| size.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.round().clamp(1.0, 16384.0));
|
||||
let (Some(w), Some(h)) = (side(0), side(1)) else { return Err(crate::commands::bad(c, "`size` must be [width, height] in pixels")) };
|
||||
dispatch(s, ScriptUiEvent { window, widget: 0, kind: "resize".into(), value: json!([w, h]) })
|
||||
}
|
||||
|
||||
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
|
||||
pub(crate) fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let window = window_p(s, p, "scriptui.close")?;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod anim_presets;
|
||||
mod anim_tools;
|
||||
mod blender;
|
||||
mod camera_track;
|
||||
mod codec_options;
|
||||
mod color_view;
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Synthetic camera interchange: projection, timing, DOF and hostile inputs. Optional public
|
||||
//! Blender-API oracle: EFFECTCRAFT_BLENDER=/path/to/blender cargo test -p effectcraft-engine tests::blender -- --nocapture.
|
||||
|
||||
use effectcraft_effects::camera_tracker as ct;
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemKind, LayerId, LayerSource, build};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use effectcraft_track::camtrack::{CameraSolve, Distortion, Ground, ShotType, SolveMethod, SolvedFrame, SolvedPoint};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn path(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-blender-tests-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn fixture(ground: bool) -> (Session, LayerId, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Shot '\\ — câmera", "width": 640, "height": 360, "duration": 6})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let rate = FrameRate::new(30000, 1001);
|
||||
let lid = s
|
||||
.edit("fixture", None, |p, _| {
|
||||
p.comp_mut(cid).unwrap().frame_rate = rate;
|
||||
let item = p.add_item(
|
||||
"Plate",
|
||||
Default::default(),
|
||||
None,
|
||||
ItemKind::Footage(Footage {
|
||||
path: std::env::var("EFFECTCRAFT_BLENDER_PLATE").unwrap_or_else(|_| path("plate '\\ — vídeo.mp4")),
|
||||
width: 640,
|
||||
height: 360,
|
||||
frame_rate: FrameRate::FPS_30,
|
||||
duration: Tick::from_seconds_f64(6.0),
|
||||
kind: FootageKind::Video,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
let mut layer = build::layer(p, &comp, "Tracked Video", LayerSource::Footage { item }, (640, 360), None);
|
||||
layer.in_point = rate.tick_of(60);
|
||||
layer.out_point = rate.tick_of(63);
|
||||
layer.start_time = rate.tick_of(30);
|
||||
layer.stretch = 200.0;
|
||||
let id = layer.id;
|
||||
p.comp_mut(cid).unwrap().layers.push(layer);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
let uid = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [lid.0]})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let solve = CameraSolve {
|
||||
version: 1,
|
||||
size: [640.0, 360.0],
|
||||
start: rate.tick_of(30).seconds() / 2.0,
|
||||
frame_duration: rate.frame_duration().seconds() / 2.0,
|
||||
shot: ShotType::VariableZoom,
|
||||
method_used: SolveMethod::Typical,
|
||||
average_error: 0.12,
|
||||
frames: (0..3)
|
||||
.map(|i| SolvedFrame {
|
||||
rot: [0.02 * i as f64, -0.03 * i as f64, 0.01 * i as f64],
|
||||
center: [0.1 * i as f64, -0.01 * i as f64, 0.02 * i as f64],
|
||||
focal: 600.0 + i as f64 * 25.0,
|
||||
solved: true,
|
||||
})
|
||||
.collect(),
|
||||
points: vec![
|
||||
SolvedPoint { id: 7, pos: [0.1, 0.2, 2.0], error: 0.13, first: 0, last: 2 },
|
||||
SolvedPoint { id: 9, pos: [-0.3, -0.1, 1.2], error: 0.11, first: 0, last: 1 },
|
||||
],
|
||||
ground: ground.then_some(Ground { origin: [0.1, 0.2, 2.0], normal: [0.0, -1.0, 0.0], x_axis: [1.0, 0.0, 0.0] }),
|
||||
distortion: ground.then_some(Distortion { k1: 0.01, k2: -0.002, radius: 320.0_f64.hypot(180.0) }),
|
||||
};
|
||||
// Directly install a deterministic solve; no analysis or decoder is needed by these tests.
|
||||
s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap();
|
||||
s.camera_pending.clear();
|
||||
s.execute("layer.select", json!({"layers": [lid.0]})).unwrap();
|
||||
(s, lid, uid)
|
||||
}
|
||||
|
||||
fn data(script: &str) -> Value {
|
||||
let line = script.lines().find_map(|l| l.strip_prefix("DATA = json.loads(")).unwrap().strip_suffix(')').unwrap();
|
||||
let encoded: String = serde_json::from_str(line).unwrap();
|
||||
serde_json::from_str(&encoded).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_bakes_projection_focus_and_retimed_background() {
|
||||
let (mut s, lid, uid) = fixture(true);
|
||||
let result = s.execute("camera.create", json!({"layer": lid.0, "effect": uid})).unwrap();
|
||||
let camera = result["camera"].as_u64().unwrap();
|
||||
s.execute("layer.cameraSettings", json!({"layer": camera, "focusDistance": 800.0, "dof": true, "fStop": 4.0, "lockToZoom": false})).unwrap();
|
||||
for (frame, value) in [(60, 800.0), (62, 1000.0)] {
|
||||
let time = FrameRate::new(30000, 1001).tick_of(frame).seconds();
|
||||
s.execute("prop.addKey", json!({"layer": camera, "path": "cameraOptions/focusDistance", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let camera_layer = s.active_comp().unwrap().layer(LayerId(camera)).unwrap();
|
||||
assert!(camera_layer.props.prop("cameraOptions/dof").unwrap().value.as_bool());
|
||||
let output = path("oracle-scene.py");
|
||||
let before = s.project.to_json();
|
||||
let r = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "effect": uid, "path": output, "startFrame": 101})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before, "export must not edit the document");
|
||||
assert_eq!(r["frames"], 3);
|
||||
assert_eq!(r["points"], 2);
|
||||
let script = std::fs::read_to_string(&output).unwrap();
|
||||
let d = data(&script);
|
||||
assert_eq!(d["resolution"], json!([640.0, 360.0]));
|
||||
assert_eq!(d["fpsNumerator"], 30000);
|
||||
assert_eq!(d["fpsDenominator"], 1001);
|
||||
assert_eq!(d["frames"][0]["sourceFrame"], 15, "trim + 200% stretch, not scene frame 101");
|
||||
assert_eq!(d["frames"][2]["sourceFrame"], 16);
|
||||
assert_eq!(d["frames"][0]["focus"], 8.0);
|
||||
assert_eq!(d["frames"][2]["focus"], 10.0);
|
||||
assert_eq!(d["frames"][0]["dof"], true);
|
||||
assert!((d["frames"][0]["fstop"].as_f64().unwrap() - 4.0).abs() < 1e-6);
|
||||
assert_eq!(d["frames"][0]["lens"], 33.75);
|
||||
assert_eq!(d["frames"][2]["lens"], 36.5625);
|
||||
assert_eq!(d["ground"], true);
|
||||
assert_eq!(d["solve"]["distortion"]["k1"], 0.01);
|
||||
assert_eq!(d["points"][0]["location"][2], 0.0, "ground point lies on Blender XY plane");
|
||||
|
||||
// Execute the actual exported artifact in a separately installed Blender, when requested.
|
||||
if let Ok(blender) = std::env::var("EFFECTCRAFT_BLENDER") {
|
||||
let oracle = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blender_scene_oracle.py");
|
||||
let output = std::process::Command::new(blender)
|
||||
.args(["--background", "--factory-startup", "--python-exit-code", "1", "--python"])
|
||||
.arg(oracle)
|
||||
.arg("--")
|
||||
.arg(&output)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "Blender oracle failed:\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("EFFECTCRAFT_BLENDER_ORACLE_OK"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_bakes_reverse_time_remap_and_footage_loops() {
|
||||
let (mut s, lid, _) = fixture(false);
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let rate = FrameRate::new(30000, 1001);
|
||||
s.edit("retimed fixture", None, |p, _| {
|
||||
let layer = p.comp(cid).unwrap().layer(lid).unwrap();
|
||||
let item = layer.source.item().unwrap();
|
||||
let keys = [(60, 8.0), (61, 6.0), (62, -1.0)]
|
||||
.map(|(frame, time)| effectcraft_keyframe::Keyframe::new(layer.layer_time(rate.tick_of(frame)), KV::Scalar(time)));
|
||||
let mut remap = build::Ids(&mut p.next_id).prop("timeRemap", "Time Remap", KV::Scalar(8.0));
|
||||
remap.keys = keys.to_vec();
|
||||
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.children.push(remap.into());
|
||||
if let ItemKind::Footage(f) = &mut p.item_mut(item).unwrap().kind {
|
||||
f.loop_count = 2;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let output = path("remap.py");
|
||||
s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output})).unwrap();
|
||||
let d = data(&std::fs::read_to_string(output).unwrap());
|
||||
let indices: Vec<i64> = d["frames"].as_array().unwrap().iter().map(|f| f["sourceFrame"].as_i64().unwrap()).collect();
|
||||
assert_eq!(indices, vec![60, 0, 0], "reverse remap wraps the 6-second video and clamps negative source time, as the media pool does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_defaults_and_script_escaping() {
|
||||
let (mut s, lid, _) = fixture(false);
|
||||
let output = path("escaped.py");
|
||||
let r = s
|
||||
.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output, "includePoints": false, "sensorWidth": 24.0, "unitScale": 0.1}))
|
||||
.unwrap();
|
||||
assert_eq!(r["points"], 0);
|
||||
let script = std::fs::read_to_string(&output).unwrap();
|
||||
let d = data(&script);
|
||||
assert!(d["name"].as_str().unwrap().contains("'\\ — câmera"));
|
||||
assert_eq!(d["frames"][0]["lens"], 22.5);
|
||||
assert_eq!(d["frames"][0]["dof"], false);
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("cannot be recovered")));
|
||||
match std::process::Command::new("python3").args(["-c", "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())", &output]).status() {
|
||||
Ok(status) => assert!(status.success(), "generated script must parse even with quoted/Unicode paths"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => eprintln!("Python AST oracle skipped: python3 is not installed"),
|
||||
Err(e) => panic!("Python AST oracle failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_rejects_invalid_arguments_and_write_failures() {
|
||||
let (mut s, lid, _) = fixture(false);
|
||||
for params in [
|
||||
json!({"sensorWidth": 0}),
|
||||
json!({"sensorWidth": "wide"}),
|
||||
json!({"unitScale": -1}),
|
||||
json!({"startFrame": -1}),
|
||||
json!({"startFrame": 1.5}),
|
||||
json!({"startFrame": 1048574}),
|
||||
json!({"includePoints": "yes"}),
|
||||
json!({"bogus": true}),
|
||||
json!({"path": path("bad.blend")}),
|
||||
] {
|
||||
let mut p = json!({"layer": lid.0, "path": path("invalid.py")});
|
||||
p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone());
|
||||
assert!(s.execute_checked("camera.exportBlender", p.clone()).is_err(), "must reject {p}");
|
||||
}
|
||||
let blocker = path("blocker");
|
||||
std::fs::write(&blocker, b"not a directory").unwrap();
|
||||
let p = format!("{blocker}/scene.py");
|
||||
assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": p})).unwrap_err().to_string().contains("cannot write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_rejects_malformed_solves_without_panicking() {
|
||||
for variant in ["empty", "unsolved", "zero-focal", "bad-ground", "huge-resolution", "mismatched-range"] {
|
||||
let (mut s, lid, uid) = fixture(false);
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let mut solve = ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap()))
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.clone();
|
||||
match variant {
|
||||
"empty" => solve.frames.clear(),
|
||||
"unsolved" => solve.frames[0].solved = false,
|
||||
"zero-focal" => solve.frames[0].focal = 0.0,
|
||||
"bad-ground" => solve.ground = Some(Ground::default()),
|
||||
"huge-resolution" => solve.size[0] = 1e10,
|
||||
_ => {
|
||||
solve.frames.pop();
|
||||
}
|
||||
}
|
||||
s.edit("corrupt solve", None, |p, _| {
|
||||
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.find_group_mut(uid).unwrap().prop_mut(ct::SOLVE).unwrap().value = KV::Str(solve.to_json());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.camera_pending.clear();
|
||||
let result = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("corrupt.py")}));
|
||||
assert!(result.is_err(), "must reject {variant}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_respects_script_write_permission() {
|
||||
let (mut s, lid, _) = fixture(false);
|
||||
let output = path("script-permissions.py");
|
||||
let params = json!({"layer": lid.0, "path": output});
|
||||
for (from_script, allowed) in [(true, false), (true, true), (false, false), (false, true)] {
|
||||
std::fs::write(&output, b"existing file").unwrap();
|
||||
s.prefs.scripting.allow_scripts_write_files = allowed;
|
||||
let result =
|
||||
if from_script { s.execute_for_script("camera.exportBlender", params.clone()) } else { s.execute_checked("camera.exportBlender", params.clone()) };
|
||||
assert!(!s.running_for_script(), "script origin must be restored after success or error");
|
||||
let contents = std::fs::read_to_string(&output).unwrap();
|
||||
if from_script && !allowed {
|
||||
assert!(result.unwrap_err().to_string().contains("Allow Scripts to Write Files"));
|
||||
assert_eq!(contents, "existing file", "blocked scripts must not replace an existing file");
|
||||
} else {
|
||||
assert_eq!(result.unwrap()["frames"], 3);
|
||||
assert!(contents.contains("def import_scene():"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,6 +585,70 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() {
|
||||
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
|
||||
}
|
||||
|
||||
/// #1896: Convert To Bezier Path reads Per-Corner Roundness, not just the uniform Roundness, so
|
||||
/// the editable path is the shape that was on screen.
|
||||
#[test]
|
||||
fn convert_to_bezier_honours_per_corner_roundness() {
|
||||
let mut s = comp();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let (uid, _) = first_shape(&s, l);
|
||||
let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid;
|
||||
let per_corner = uid_of(&s, "perCorner");
|
||||
let top_left = uid_of(&s, "topLeftRoundness");
|
||||
let bottom_right = uid_of(&s, "bottomRightRoundness");
|
||||
for (u, v) in [(per_corner, json!(true)), (top_left, json!(20.0)), (bottom_right, json!(10.0))] {
|
||||
s.execute("prop.set", json!({"layer": l, "path": format!("@{u}"), "value": v})).unwrap();
|
||||
}
|
||||
|
||||
s.state.selected_props = vec![(LayerId(l), uid)];
|
||||
s.execute("path.convertToBezier", json!({})).unwrap();
|
||||
let (new_uid, now) = first_shape(&s, l);
|
||||
assert_eq!(now, "path");
|
||||
let path = shape_path_of(&s, l, new_uid);
|
||||
let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 10.0, 0.0])).into_iter().next().unwrap();
|
||||
assert_eq!(path, expected, "the converted path has the per-corner geometry");
|
||||
}
|
||||
|
||||
/// #1896: Convert To Bezier Path evaluates the shape's expressions, so an expression-driven
|
||||
/// Per-Corner checkbox and radius convert to the geometry on screen.
|
||||
#[test]
|
||||
fn convert_to_bezier_evaluates_corner_expressions() {
|
||||
struct Corners;
|
||||
impl effectcraft_render::ExprHost for Corners {
|
||||
fn eval(
|
||||
&self,
|
||||
_: &effectcraft_render::EvalCtx,
|
||||
_: &effectcraft_project::Layer,
|
||||
prop: &effectcraft_project::Property,
|
||||
value: &KV,
|
||||
) -> std::result::Result<KV, String> {
|
||||
Ok(match prop.match_id.as_str() {
|
||||
"perCorner" => KV::Bool(true),
|
||||
"topLeftRoundness" => KV::Scalar(20.0),
|
||||
_ => value.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut s = comp();
|
||||
s.expr = Some(Arc::new(Corners));
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let (uid, _) = first_shape(&s, l);
|
||||
let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid;
|
||||
for (id, expression) in [("perCorner", "true"), ("topLeftRoundness", "20")] {
|
||||
let u = uid_of(&s, id);
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": format!("@{u}"), "expression": expression})).unwrap();
|
||||
}
|
||||
|
||||
s.state.selected_props = vec![(LayerId(l), uid)];
|
||||
s.execute("path.convertToBezier", json!({})).unwrap();
|
||||
let (new_uid, now) = first_shape(&s, l);
|
||||
assert_eq!(now, "path");
|
||||
let path = shape_path_of(&s, l, new_uid);
|
||||
let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 0.0, 0.0])).into_iter().next().unwrap();
|
||||
assert_eq!(path, expected, "the expression-evaluated corner is converted");
|
||||
}
|
||||
|
||||
/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a
|
||||
/// shared merge key; one undo must revert the whole drag, not the last frame.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Public-API oracle for scripts generated by src/tests/blender.rs; no Blender source dependency."""
|
||||
import json
|
||||
import math
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from bpy_extras.object_utils import world_to_camera_view
|
||||
from mathutils import Quaternion, Vector
|
||||
|
||||
path = sys.argv[sys.argv.index("--") + 1]
|
||||
before = set(bpy.data.scenes)
|
||||
exported = runpy.run_path(path, run_name="__main__")
|
||||
scene = exported["EFFECTCRAFT_SCENE"]
|
||||
data = exported["DATA"]
|
||||
assert before.issubset(set(bpy.data.scenes)), "existing scenes were removed"
|
||||
assert scene not in before
|
||||
assert scene.frame_start == 101 and scene.frame_end == 103
|
||||
assert abs(scene.render.fps / scene.render.fps_base - 30000 / 1001) < 1e-4
|
||||
assert (scene.render.resolution_x, scene.render.resolution_y) == (640, 360)
|
||||
camera = scene.camera
|
||||
assert camera.animation_data.action is not None
|
||||
assert camera.data.animation_data.action is not None
|
||||
assert camera.data.sensor_fit == 'HORIZONTAL'
|
||||
metadata = json.loads(bpy.data.texts[scene["effectcraft_metadata_text"]].as_string())
|
||||
assert metadata["solve"]["averageError"] == 0.12
|
||||
cloud = scene.objects["Solved Track Points"]
|
||||
assert len(cloud.data.vertices) == 2
|
||||
assert cloud.hide_render
|
||||
assert cloud.data.attributes["track_id"].data[0].value == 7
|
||||
assert any(obj.name.startswith("Solved Ground Plane") for obj in scene.objects)
|
||||
|
||||
for i, sample in enumerate(data["frames"]):
|
||||
scene.frame_set(sample["frame"])
|
||||
bpy.context.view_layer.update()
|
||||
assert abs(camera.data.lens - sample["lens"]) < 1e-5
|
||||
assert abs(camera.data.dof.focus_distance - sample["focus"]) < 1e-5
|
||||
assert abs(camera.data.dof.aperture_fstop - sample["fstop"]) < 1e-5
|
||||
assert camera.data.dof.use_dof
|
||||
assert (camera.location - Vector(sample["location"])).length < 1e-5
|
||||
raw = data["solve"]["frames"][i]
|
||||
rot = Vector(raw["rot"])
|
||||
rotation = Quaternion(rot.normalized(), rot.length).to_matrix() if rot.length else Quaternion().to_matrix()
|
||||
for vertex, point in zip(cloud.data.vertices, data["solve"]["points"]):
|
||||
relative = rotation @ (Vector(point["pos"]) - Vector(raw["center"]))
|
||||
expected = (raw["focal"] * relative.x / relative.z + 320,
|
||||
raw["focal"] * relative.y / relative.z + 180)
|
||||
projected = world_to_camera_view(scene, camera, vertex.co)
|
||||
actual = (projected.x * 640, (1.0 - projected.y) * 360)
|
||||
assert math.dist(actual, expected) < 0.002, (sample["frame"], actual, expected)
|
||||
|
||||
if "Video not found" not in " ".join(data["warnings"]):
|
||||
background = camera.data.background_images[0]
|
||||
assert background.image.source == 'MOVIE'
|
||||
assert tuple(background.image.size) == (640, 360)
|
||||
user = background.image_user
|
||||
actual_source = sample["frame"] - user.frame_start + user.frame_offset
|
||||
assert actual_source == sample["sourceFrame"], (actual_source, sample["sourceFrame"])
|
||||
assert user.use_auto_refresh
|
||||
|
||||
print("EFFECTCRAFT_BLENDER_ORACLE_OK")
|
||||
@@ -431,12 +431,13 @@ impl<'a> Cx<'a> {
|
||||
fn crop_resize(&self, img: Image) -> Image {
|
||||
let om = self.job.output;
|
||||
let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0));
|
||||
// frame_size applies crop itself: use the original frame and the same scale as edges.
|
||||
let (w, h) = om.frame_size(img.width, img.height, scale);
|
||||
let (t, l, b, r) = om.crop.edges(img.width, img.height, scale);
|
||||
let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) };
|
||||
if !om.resize.enabled {
|
||||
return img;
|
||||
}
|
||||
let (w, h) = om.frame_size(img.width, img.height, 1.0);
|
||||
if (w, h) == (img.width, img.height) {
|
||||
return img;
|
||||
}
|
||||
@@ -1062,6 +1063,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regress the locked-aspect example: cropping 1920x1080 must resize to 1280x1440.
|
||||
#[test]
|
||||
fn crop_resize_applies_margins_once() {
|
||||
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
|
||||
let output = OutputModule {
|
||||
crop: Crop { enabled: true, left: 480, right: 480, ..Default::default() },
|
||||
resize: Resize { enabled: true, width: 1280, lock_aspect: true, quality: ResizeQuality::Low, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(output.frame_size(1920, 1080, 1.0), (1280, 1440));
|
||||
let img = run_crop_resize(Image::new(1920, 1080), &output, 1.0);
|
||||
assert_eq!((img.width, img.height), (1280, 1440));
|
||||
assert_eq!(img.data.len(), 1280 * 1440);
|
||||
}
|
||||
|
||||
/// Apply output-module geometry to a synthetic frame without rendering or writing files.
|
||||
fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image {
|
||||
use effectcraft_project::render_queue::RenderSettings;
|
||||
let project = Project::default();
|
||||
let settings = RenderSettings { resolution: scale, ..Default::default() };
|
||||
let job = Job {
|
||||
project: &project,
|
||||
footage: &effectcraft_render::NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: effectcraft_project::ItemId(0),
|
||||
settings: &settings,
|
||||
output,
|
||||
path: "unused.png",
|
||||
sink: None,
|
||||
nested_switches: true,
|
||||
options: Default::default(),
|
||||
};
|
||||
Cx::new(&job).crop_resize(img)
|
||||
}
|
||||
|
||||
/// Match predicted dimensions across crop, padding, ROI scale, aspect locks and resize qualities.
|
||||
#[test]
|
||||
fn crop_resize_dimensions_match_the_output_module() {
|
||||
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
|
||||
let edges = Crop { enabled: true, left: 2, right: 2, ..Default::default() };
|
||||
let padding = Crop { enabled: true, left: -2, bottom: -2, ..Default::default() };
|
||||
let roi = |rect| Crop { enabled: true, use_roi: true, roi: Some(rect), ..Default::default() };
|
||||
for quality in [ResizeQuality::Low, ResizeQuality::High] {
|
||||
let resize = |width| Resize { enabled: true, width, height: 1, lock_aspect: true, quality };
|
||||
for (crop, resize, scale, expected) in [
|
||||
(Crop::default(), Resize::default(), 1.0, (8, 6)),
|
||||
(edges, Resize::default(), 1.0, (4, 6)),
|
||||
(Crop::default(), resize(4), 1.0, (4, 3)),
|
||||
(edges, resize(8), 1.0, (8, 12)),
|
||||
(padding, resize(5), 1.0, (5, 4)),
|
||||
(roi([4.0, 2.0, 8.0, 6.0]), resize(8), 0.5, (8, 6)),
|
||||
(roi([1.0, 0.5, 2.0, 1.5]), resize(8), 2.0, (8, 6)),
|
||||
(edges, Resize { lock_aspect: false, height: 2, ..resize(5) }, 1.0, (5, 2)),
|
||||
] {
|
||||
let output = OutputModule { crop, resize, ..Default::default() };
|
||||
assert_eq!(output.frame_size(8, 6, scale), expected);
|
||||
let img = run_crop_resize(Image::filled(8, 6, [0.25, 0.0, 0.0, 0.5]), &output, scale);
|
||||
assert_eq!((img.width, img.height), expected, "{crop:?}, {resize:?}, scale {scale}");
|
||||
assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercise zero/empty inputs, collapsed crops and existing caps on extreme single-axis resizes.
|
||||
#[test]
|
||||
fn crop_resize_zero_and_extreme_sizes_stay_bounded() {
|
||||
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
|
||||
for quality in [ResizeQuality::Low, ResizeQuality::High] {
|
||||
for (size, crop, width, height, expected) in [
|
||||
((8, 6), Crop::default(), 0, 0, (1, 1)),
|
||||
((8, 1), Crop::default(), u32::MAX, 1, (30_000, 1)),
|
||||
((1, 8), Crop::default(), 1, u32::MAX, (1, 30_000)),
|
||||
((8, 6), Crop { enabled: true, left: 20_000, right: 20_000, ..Default::default() }, 2, 3, (2, 3)),
|
||||
((0, 0), Crop { enabled: true, left: 1, top: 1, ..Default::default() }, 2, 3, (2, 3)),
|
||||
] {
|
||||
let output = OutputModule { crop, resize: Resize { enabled: true, width, height, lock_aspect: false, quality }, ..Default::default() };
|
||||
let img = run_crop_resize(Image::new(size.0, size.1), &output, 1.0);
|
||||
assert_eq!((img.width, img.height), expected);
|
||||
assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize);
|
||||
assert!(img.data.iter().all(|p| *p == [0.0; 4]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_and_pad() {
|
||||
let mut img = Image::new(4, 3);
|
||||
|
||||
@@ -174,6 +174,31 @@ fn crop_roi_and_resize() {
|
||||
assert_eq!((z.width(), z.height()), (32, 24));
|
||||
}
|
||||
|
||||
/// Resizing to the cropped frame's own dimensions must preserve every decoded PNG pixel.
|
||||
/// Compare against crop-only output for padding and scaled ROIs with both resize qualities.
|
||||
#[test]
|
||||
fn crop_resize_preserves_pixels_when_the_cropped_size_is_requested() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
for (crop, scale, expected) in [
|
||||
(Crop { enabled: true, left: 16, right: 16, ..Default::default() }, 1.0, (32, 48)),
|
||||
(Crop { enabled: true, left: -4, top: -2, ..Default::default() }, 1.0, (68, 50)),
|
||||
(Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 0.5, (16, 12)),
|
||||
(Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 2.0, (64, 48)),
|
||||
] {
|
||||
let settings = RenderSettings { resolution: scale, ..custom(0.4, 0.44) };
|
||||
let mut output = OutputModule { crop, ..OutputModule::for_format(OutputFormat::PngSequence) };
|
||||
let reference = png(&run(&p, cid, &settings, &output, "/c/reference_[#####].png"), 0);
|
||||
assert_eq!(reference.dimensions(), expected);
|
||||
assert!(reference.pixels().any(|p| p.0[0] > 150), "the reference contains visible content");
|
||||
for quality in [ResizeQuality::Low, ResizeQuality::High] {
|
||||
output.resize = Resize { enabled: true, width: expected.0, height: 1, lock_aspect: true, quality };
|
||||
let actual = png(&run(&p, cid, &settings, &output, "/c/resized_[#####].png"), 0);
|
||||
assert_eq!(actual.dimensions(), expected);
|
||||
assert!(actual == reference, "crop {crop:?}, scale {scale}, quality {quality:?}: pixels changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_only_and_premultiplied() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
|
||||
|
||||
@@ -253,6 +253,7 @@ fn bubbles(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf
|
||||
}
|
||||
let mut p = Params::default();
|
||||
p.u[1] = [0, (ctx.params.e("reflectionType") == 1) as u32, ctx.params.e("shadingType"), 0];
|
||||
p.f[0] = effectcraft_effects::bubble_sample_bounds(ctx, b.offset, b.scale);
|
||||
let img = raster(e, items, 1, false, &b.img, None, p, w, h)?;
|
||||
Some(GBuf { img, ..b })
|
||||
}
|
||||
|
||||
@@ -165,7 +165,8 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
return FxmShade(true, vec4<f32>(rgb * a, a));
|
||||
}
|
||||
|
||||
// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type.
|
||||
// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type, f[0] = the layer
|
||||
// rectangle's first and last pixel centres (x0, y0, x1, y1), which refraction samples stay within.
|
||||
fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
let qx = data[at + 1u];
|
||||
let qy = data[at + 2u];
|
||||
@@ -180,7 +181,9 @@ fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
}
|
||||
let k = select(-0.7, 1.6, P.u[1].y != 0u);
|
||||
let nz = sqrt(max(1.0 - d2, 0.0));
|
||||
let s = fxm_unpremul(sample_bilinear_clamped(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k));
|
||||
let sx = min(max(data[at + 4u] + dx * qr * k, P.f[0].x), P.f[0].z);
|
||||
let sy = min(max(data[at + 5u] + dy * qr * k, P.f[0].y), P.f[0].w);
|
||||
let s = fxm_unpremul(sample_bilinear_clamped(src, sx, sy));
|
||||
var c = s.xyz;
|
||||
if (s.w <= 0.0) {
|
||||
c = vec3<f32>(0.6, 0.7, 0.8);
|
||||
|
||||
@@ -105,6 +105,34 @@ fn bubbles_drizzle_mercury() {
|
||||
case("ec.sim.ccmrmercury", &[("birthRate", n(3.0)), ("blobInfluence", n(60.0)), ("shading/metal", n(80.0)), ("gravity", n(2.0))], 1.5, 0.0);
|
||||
}
|
||||
|
||||
/// CC Bubbles on a buffer with transparent padding around the layer: both sides clamp the
|
||||
/// refraction samples to the layer rectangle, not to the buffer (#2024).
|
||||
#[test]
|
||||
fn bubbles_on_padded_buffers() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.sim.ccbubbles").unwrap();
|
||||
let size = [70.0, 44.0];
|
||||
for reflection in [0, 1] {
|
||||
let mut params =
|
||||
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
|
||||
for (k, v) in [("bubbleAmount", n(80.0)), ("bubbleSize", n(4.0)), ("reflectionType", e(reflection))] {
|
||||
params.values.insert(k.to_string(), v);
|
||||
}
|
||||
for scale in [1.0, 0.5] {
|
||||
let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32);
|
||||
let mut buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale };
|
||||
buf.pad(6);
|
||||
let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.5, layer_size: size, seed: 11, adjustment: false, env: Default::default() };
|
||||
let cpu = (spec.render)(&ctx(), buf.clone());
|
||||
let out =
|
||||
effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
|
||||
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "geometry");
|
||||
let d = diff(&cpu.img, &out.img, 1e-3);
|
||||
assert!(d.over == 0, "reflection {reflection} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", d.over, d.total, d.max, d.worst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hair_wave_world_foam() {
|
||||
case("ec.sim.cchair", &[("density", n(300.0)), ("length", n(10.0))], 0.0, 0.0);
|
||||
|
||||
@@ -113,6 +113,9 @@ impl Out<'_, '_> {
|
||||
fn f(&self, m: &str) -> f64 {
|
||||
self.g.get(m).map(|p| p.value.as_f64()).unwrap_or(0.0)
|
||||
}
|
||||
fn flag(&self, m: &str) -> bool {
|
||||
self.g.get(m).map(|p| p.value.as_bool()).unwrap_or(false)
|
||||
}
|
||||
fn animated(&self, m: &str) -> bool {
|
||||
self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression())
|
||||
}
|
||||
@@ -232,7 +235,20 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve
|
||||
dir(&mut o);
|
||||
o.put("s", "size", &anim::arr2);
|
||||
o.put("p", "position", &anim::arr2);
|
||||
o.put("r", "roundness", &anim::num);
|
||||
if o.flag("perCorner") {
|
||||
let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"];
|
||||
let radii = names.map(|m| o.f(m));
|
||||
// Lottie `rc` has one `r`: four equal static radii export as that value.
|
||||
// `r` is a property (`{a, k}`) like the uniform branch writes, not a bare number.
|
||||
if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 {
|
||||
o.raw("r", json!({"a": 0, "k": r(radii[0])}));
|
||||
} else {
|
||||
o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label));
|
||||
o.raw("r", json!({"a": 0, "k": 0}));
|
||||
}
|
||||
} else {
|
||||
o.put("r", "roundness", &anim::num);
|
||||
}
|
||||
}
|
||||
"el" => {
|
||||
dir(&mut o);
|
||||
@@ -447,6 +463,11 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
|
||||
set(im, &mut g, "size", j.get("s"), &anim::to_vec2);
|
||||
set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
|
||||
set(im, &mut g, "roundness", j.get("r"), &anim::to_scalar);
|
||||
// Lottie has one roundness: keep the per-corner values in step so ticking the box
|
||||
// on an imported rectangle keeps the shape.
|
||||
for m in ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"] {
|
||||
set(im, &mut g, m, j.get("r"), &anim::to_scalar);
|
||||
}
|
||||
g
|
||||
}
|
||||
"el" => {
|
||||
|
||||
@@ -896,3 +896,39 @@ fn muted_keys_are_omitted_from_the_export() {
|
||||
assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6);
|
||||
assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() {
|
||||
let shape = |radii: [f64; 4], roundness: f64, per_corner: bool| {
|
||||
let (mut p, cid) = setup();
|
||||
let l = shape_layer(&mut p, cid, move |ids| {
|
||||
let mut g = build::shape_rect(ids, [100.0, 50.0], [0.0, 0.0], roundness);
|
||||
g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner);
|
||||
for (m, v) in
|
||||
[("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])]
|
||||
{
|
||||
g.prop_mut(m).unwrap().value = Value::Scalar(v);
|
||||
}
|
||||
let fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
|
||||
vec![build::shape_group(ids, "Rectangle 1", vec![g, fill])]
|
||||
});
|
||||
push(&mut p, cid, l);
|
||||
(p, cid)
|
||||
};
|
||||
let rc = |json: &Json| -> Json { json["layers"][0]["shapes"][0]["it"].as_array().unwrap().iter().find(|i| i["ty"] == "rc").cloned().unwrap() };
|
||||
// Four equal static radii: `rc.r` carries the value, no warning.
|
||||
let (p, cid) = shape([12.0; 4], 5.0, true);
|
||||
let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||
assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 12.0}));
|
||||
assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}");
|
||||
// Mixed radii: not representable, roundness 0 exported with a warning.
|
||||
let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, true);
|
||||
let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||
assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 0}));
|
||||
assert!(warnings.iter().any(|w| w.contains("per-corner roundness")), "{warnings:?}");
|
||||
// The checkbox off exports the single Roundness and ignores the four values.
|
||||
let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, false);
|
||||
let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||
assert_eq!(rc(&json)["r"]["k"], json!(5.0));
|
||||
assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}");
|
||||
}
|
||||
|
||||
@@ -164,6 +164,52 @@ pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
|
||||
p
|
||||
}
|
||||
|
||||
/// Rectangle path centred at `pos` with independent corner radii `[top-left, top-right,
|
||||
/// bottom-right, bottom-left]`, clockwise in screen coordinates. Negative and non-finite radii
|
||||
/// are 0; radii that would overlap are scaled down as CSS `border-radius` does.
|
||||
pub fn rect_radii(size: [f64; 2], pos: [f64; 2], radii: [f64; 4]) -> BezPath {
|
||||
// Start at the end of the top-right arc on the right edge, like `rect`, and walk the corners
|
||||
// clockwise: Trim Paths measures from the first point, so ticking per-corner roundness must
|
||||
// not make a trim jump (#1896).
|
||||
let w = if size[0].is_finite() { size[0].abs() } else { 0.0 };
|
||||
let h = if size[1].is_finite() { size[1].abs() } else { 0.0 };
|
||||
let (x0, y0) = (pos[0] - w / 2.0, pos[1] - h / 2.0);
|
||||
let (x1, y1) = (pos[0] + w / 2.0, pos[1] + h / 2.0);
|
||||
let mut r = radii.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 });
|
||||
// CSS border-radius: when two radii on an edge overlap, scale every radius by the smallest
|
||||
// edge ratio (a uniform radius then clamps to half the shorter side, as before).
|
||||
let ratio = |a: f64, b: f64, edge: f64| if a + b > edge && a + b > 0.0 { (edge / (a + b)).min(1.0) } else { 1.0 };
|
||||
let f = ratio(r[0], r[1], w).min(ratio(r[3], r[2], w)).min(ratio(r[0], r[3], h)).min(ratio(r[1], r[2], h));
|
||||
if f < 1.0 {
|
||||
for v in &mut r {
|
||||
*v *= f;
|
||||
}
|
||||
}
|
||||
let [tl, tr, br, bl] = r;
|
||||
// Quarter-circle Bezier control distance.
|
||||
const K: f64 = 0.552_284_749_830_793_6;
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((x1, y0 + tr));
|
||||
p.line_to((x1, y1 - br));
|
||||
if br > 0.0 {
|
||||
p.curve_to((x1, y1 - br + K * br), (x1 - br + K * br, y1), (x1 - br, y1));
|
||||
}
|
||||
p.line_to((x0 + bl, y1));
|
||||
if bl > 0.0 {
|
||||
p.curve_to((x0 + bl - K * bl, y1), (x0, y1 - bl + K * bl), (x0, y1 - bl));
|
||||
}
|
||||
p.line_to((x0, y0 + tl));
|
||||
if tl > 0.0 {
|
||||
p.curve_to((x0, y0 + tl - K * tl), (x0 + tl - K * tl, y0), (x0 + tl, y0));
|
||||
}
|
||||
p.line_to((x1 - tr, y0));
|
||||
if tr > 0.0 {
|
||||
p.curve_to((x1 - tr + K * tr, y0), (x1, y0 + tr - K * tr), (x1, y0 + tr));
|
||||
}
|
||||
p.close_path();
|
||||
p
|
||||
}
|
||||
|
||||
pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath {
|
||||
to_kurbo(&ShapePath::ellipse(pos, size[0], size[1]))
|
||||
}
|
||||
@@ -419,6 +465,11 @@ mod tests {
|
||||
let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0);
|
||||
let (a, b) = ends(&round[0]);
|
||||
assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}");
|
||||
// Per-corner roundness starts and runs in the same order: ticking the checkbox must not
|
||||
// move a Trim Paths contour.
|
||||
let corners = ops::trim(&[rect_radii([400.0, 300.0], [0.0, 0.0], [40.0; 4])], 0.0, 25.0, 0.0);
|
||||
let (a, b) = ends(&corners[0]);
|
||||
assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "per-corner: {a:?} {b:?}");
|
||||
// Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left).
|
||||
let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0);
|
||||
let (a, b) = ends(&swapped[0]);
|
||||
@@ -456,6 +507,32 @@ mod tests {
|
||||
assert!((area - 200.0).abs() < 1.0, "{area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_radii_uniform_matches_a_rounded_rect() {
|
||||
for r in [0.0, 5.0, 25.0, 100.0] {
|
||||
let p = rect_radii([100.0, 50.0], [10.0, 20.0], [r; 4]);
|
||||
let clamped = r.min(25.0);
|
||||
let expect = 100.0 * 50.0 - (4.0 - std::f64::consts::PI) * clamped * clamped;
|
||||
assert!((p.area().abs() - expect).abs() < 1.0, "{r}: {} != {expect}", p.area());
|
||||
assert!((p.area() - rect([100.0, 50.0], [10.0, 20.0], r).area()).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_radii_mixes_corners_and_scales_overlaps() {
|
||||
// Only the top-left and bottom-right corners round.
|
||||
let p = rect_radii([100.0, 50.0], [0.0, 0.0], [10.0, 0.0, 20.0, 0.0]);
|
||||
let expect = 100.0 * 50.0 - (1.0 - std::f64::consts::PI / 4.0) * (10.0 * 10.0 + 20.0 * 20.0);
|
||||
assert!((p.area().abs() - expect).abs() < 1.0, "{}", p.area());
|
||||
// Radii wider than the top edge scale down together, like CSS border-radius.
|
||||
let p = rect_radii([50.0, 100.0], [0.0, 0.0], [40.0, 40.0, 0.0, 0.0]);
|
||||
let scaled = rect_radii([50.0, 100.0], [0.0, 0.0], [25.0, 25.0, 0.0, 0.0]);
|
||||
assert!((p.area() - scaled.area()).abs() < 1e-9);
|
||||
// Negative and non-finite radii are square corners.
|
||||
let p = rect_radii([50.0, 50.0], [0.0, 0.0], [-5.0, f64::NAN, f64::INFINITY, 0.0]);
|
||||
assert!((p.area().abs() - 2500.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_has_area() {
|
||||
let r = rect([40.0, 40.0], [50.0, 50.0], 0.0);
|
||||
|
||||
@@ -385,11 +385,18 @@ pub fn shape_group(ids: &mut Ids, name: &str, items: Vec<PropGroup>) -> PropGrou
|
||||
}
|
||||
|
||||
pub fn shape_rect(ids: &mut Ids, size: [f64; 2], position: [f64; 2], roundness: f64) -> PropGroup {
|
||||
// Per-corner radii start from the uniform Roundness, so ticking the box keeps the shape.
|
||||
let r = if roundness.is_finite() { roundness.max(0.0) } else { 0.0 };
|
||||
indexed(ids.group("rect", "Rectangle Path 1"))
|
||||
.with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden))
|
||||
.with(ids.prop("size", "Size", Value::Vec2(size)).with_ui(ParamUi::Pixels))
|
||||
.with(ids.prop("position", "Position", Value::Vec2(position)).with_ui(ParamUi::Point))
|
||||
.with(ids.prop("roundness", "Roundness", Value::Scalar(roundness)).with_ui(ParamUi::Pixels))
|
||||
.with(ids.prop("perCorner", "Per-Corner Roundness", Value::Bool(false)).with_ui(ParamUi::Checkbox))
|
||||
.with(ids.prop("topLeftRoundness", "Top Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
|
||||
.with(ids.prop("topRightRoundness", "Top Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
|
||||
.with(ids.prop("bottomRightRoundness", "Bottom Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
|
||||
.with(ids.prop("bottomLeftRoundness", "Bottom Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
|
||||
}
|
||||
|
||||
pub fn shape_ellipse(ids: &mut Ids, size: [f64; 2], position: [f64; 2]) -> PropGroup {
|
||||
|
||||
@@ -269,7 +269,20 @@ fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Ve
|
||||
"rect" => {
|
||||
let size = ctx.v2(layer, g, "size", [100.0; 2]);
|
||||
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
|
||||
let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0));
|
||||
let p = if ctx.b(layer, g, "perCorner") {
|
||||
effectcraft_path::rect_radii(
|
||||
size,
|
||||
pos,
|
||||
[
|
||||
ctx.f(layer, g, "topLeftRoundness", 0.0),
|
||||
ctx.f(layer, g, "topRightRoundness", 0.0),
|
||||
ctx.f(layer, g, "bottomRightRoundness", 0.0),
|
||||
ctx.f(layer, g, "bottomLeftRoundness", 0.0),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0))
|
||||
};
|
||||
push_shape(ctx, layer, g, p, arena, &mut live);
|
||||
}
|
||||
"ellipse" => {
|
||||
|
||||
@@ -715,3 +715,33 @@ fn miter_corner_still_fits_its_buffer() {
|
||||
let b = shape_buf(&p, cid);
|
||||
assert!(b.img.height as f64 >= 50.0 + 20.0 * 4.0 * 0.5, "{}", b.img.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_per_corner_roundness_rounds_the_named_corners() {
|
||||
let render = |per_corner: bool, radii: [f64; 4], roundness: f64| {
|
||||
let (p, cid) = shape_comp_sized(200, 100, move |ids| {
|
||||
let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], roundness);
|
||||
g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner);
|
||||
for (m, v) in
|
||||
[("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])]
|
||||
{
|
||||
g.prop_mut(m).unwrap().value = Value::Scalar(v);
|
||||
}
|
||||
vec![g, build::shape_fill(ids, RED)]
|
||||
});
|
||||
frame(&p, cid)
|
||||
};
|
||||
// Only the top-left corner rounds: its corner pixels fade, the other three stay filled.
|
||||
let img = render(true, [30.0, 0.0, 0.0, 0.0], 0.0);
|
||||
assert!(alpha(&img, 52, 2) < 0.1, "top-left rounds: {}", alpha(&img, 52, 2));
|
||||
for (x, y) in [(148, 2), (148, 98), (52, 98)] {
|
||||
assert!(alpha(&img, x, y) > 0.9, "({x},{y}) stays square: {}", alpha(&img, x, y));
|
||||
}
|
||||
// The checkbox off ignores the four values (the single Roundness still rules).
|
||||
let off = render(false, [30.0; 4], 0.0);
|
||||
assert!((coverage(&off) - 100.0 * 100.0).abs() < 20.0, "{}", coverage(&off));
|
||||
// Four equal per-corner radii render like the same Roundness.
|
||||
let per = render(true, [25.0; 4], 0.0);
|
||||
let uni = render(false, [0.0; 4], 25.0);
|
||||
assert!((coverage(&per) - coverage(&uni)).abs() < 5.0, "{} vs {}", coverage(&per), coverage(&uni));
|
||||
}
|
||||
|
||||
@@ -912,6 +912,18 @@ function __uiDispatch(winId, id, kind, valueJson) {
|
||||
w.close(v === null ? 2 : v);
|
||||
return;
|
||||
}
|
||||
// The host gave the window a new size (a docked panel's dock): take it, lay out, tell the script.
|
||||
if (kind === "resize") {
|
||||
var nw = Number(v && v[0]), nh = Number(v && v[1]);
|
||||
var b = w.__ub || w.__lb;
|
||||
if (nw > 0 && nh > 0 && (b[2] - b[0] !== nw || b[3] - b[1] !== nh)) {
|
||||
w.__ub = [b[0], b[1], b[0] + nw, b[1] + nh];
|
||||
__uiLayout(w);
|
||||
w.__fire("onResizing");
|
||||
w.__fire("onResize");
|
||||
}
|
||||
return;
|
||||
}
|
||||
var c = __uiFind(w, id);
|
||||
if (!c || c.enabled === false) return;
|
||||
var radio = function () {
|
||||
|
||||
@@ -3962,6 +3962,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
|
||||
// Brazilian Portuguese.
|
||||
const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("Export Scene to Blender...", "Exportar cena para o Blender..."),
|
||||
(" OK ", " OK "),
|
||||
(" Delete ", " Excluir "),
|
||||
(" Open Auto-Save ", " Abrir salvamento automático "),
|
||||
|
||||
@@ -562,6 +562,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
|
||||
"anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])),
|
||||
"view.exportGuides" => ("path", Ask::Save("Guides.json")),
|
||||
"view.importGuides" => ("path", Ask::Open(&["json"])),
|
||||
"camera.exportBlender" => ("path", Ask::Save("Tracked Scene.py")),
|
||||
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
|
||||
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
|
||||
"file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])),
|
||||
|
||||
@@ -430,6 +430,11 @@ pub fn editor(
|
||||
actions.push(("camera.create".into(), me.clone()));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera");
|
||||
let export = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 70.0), vec2(218.0, 22.0));
|
||||
if widgets::text_button(ui, export, tr("Export Scene to Blender..."), false, &t, egui::Id::unique(("ct-blender", g.uid))).clicked() && !busy {
|
||||
actions.push(("camera.exportBlender".into(), me.clone()));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.cameraTracker.{}.exportBlender", g.uid), export, "Export Scene to Blender...");
|
||||
}
|
||||
let status = match app.session.camera_progress() {
|
||||
Some(pr) if running => pr.banner(),
|
||||
@@ -457,4 +462,4 @@ pub fn editor(
|
||||
}
|
||||
|
||||
/// Height of [`editor`].
|
||||
pub const EDITOR_HEIGHT: f32 = 68.0;
|
||||
pub const EDITOR_HEIGHT: f32 = 100.0;
|
||||
|
||||
@@ -26,6 +26,8 @@ enum Act {
|
||||
Close(u32),
|
||||
/// A mouse event (`scriptui.mouse` parameters without window and widget).
|
||||
Mouse(u32, u32, Value),
|
||||
/// A docked panel's new size (`scriptui.resize`).
|
||||
Resize(u32, [f32; 2]),
|
||||
}
|
||||
|
||||
fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect {
|
||||
@@ -500,6 +502,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec<Act>) {
|
||||
Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})),
|
||||
Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})),
|
||||
Act::Close(w) => ("scriptui.close", json!({"window": w})),
|
||||
Act::Resize(w, [x, y]) => ("scriptui.resize", json!({"window": w, "size": [x, y]})),
|
||||
Act::Mouse(w, id, mut v) => {
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("window".into(), json!(w));
|
||||
@@ -582,6 +585,14 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) {
|
||||
// A modal script dialog blocks the panels too.
|
||||
let modal = app.session.script_ui.windows.iter().any(|w| w.modal);
|
||||
let mut acts = vec![];
|
||||
// The script learns its dock's size when it changes (#2585), as After Effects tells a
|
||||
// docked panel: its window takes the size and onResize runs.
|
||||
let dock = [rect.width().round(), rect.height().round()];
|
||||
let told = egui::Id::unique(("scriptui-dock-size", id));
|
||||
if ui.data(|d| d.get_temp::<[f32; 2]>(told)) != Some(dock) && dock[0] >= 1.0 && dock[1] >= 1.0 {
|
||||
ui.data_mut(|d| d.insert_temp(told, dock));
|
||||
acts.push(Act::Resize(id, dock));
|
||||
}
|
||||
let content = rect;
|
||||
egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover());
|
||||
|
||||
@@ -117,15 +117,20 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{}:", tr(label))).truncate());
|
||||
let r = match kind {
|
||||
Kind::Int(lo, hi, unit) => {
|
||||
let mut x = v.as_i64().unwrap_or(0);
|
||||
// A typed number applies once entered: typing 85 into UI Scale
|
||||
// doesn't resize the interface to 75 % at its first digit.
|
||||
// A dragged value applies on release, and a typed one once
|
||||
// entered: UI Scale doesn't resize the interface under the
|
||||
// pointer mid-drag (#2582) or at a typed number's first digit.
|
||||
let held = egui::Id::unique(("settings-drag", key));
|
||||
let mut x = ui.data(|d| d.get_temp::<i64>(held)).unwrap_or_else(|| v.as_i64().unwrap_or(0));
|
||||
let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false);
|
||||
let r = ui.add(crate::widgets::drag_value(drag));
|
||||
if !unit.is_empty() {
|
||||
ui.label(RichText::new(tr(unit)).color(t.text_dim));
|
||||
}
|
||||
if r.changed() {
|
||||
if r.dragged() {
|
||||
ui.data_mut(|d| d.insert_temp(held, x));
|
||||
} else if r.changed() || r.drag_stopped() {
|
||||
ui.data_mut(|d| d.remove::<i64>(held));
|
||||
acts.push(Act::Set(key.into(), json!(x)));
|
||||
}
|
||||
r
|
||||
|
||||
@@ -3591,7 +3591,15 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc:
|
||||
}
|
||||
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
|
||||
if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
|
||||
// Shift or Cmd/Ctrl adds the group to the selection, or takes a selected one out, as
|
||||
// property rows and Effect Controls headers do (#2552: several effects and styles for
|
||||
// one animation preset).
|
||||
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
|
||||
if add && app.session.state.selected_props.contains(&(layer.id, *uid)) {
|
||||
actions.push(("prop.deselect".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
} else {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false, "add": add})));
|
||||
}
|
||||
}
|
||||
if gr.double_clicked() {
|
||||
if fx.is_some() {
|
||||
|
||||
@@ -195,6 +195,11 @@ fn shape_group_inner_contents_is_elided() {
|
||||
" Size",
|
||||
" Position",
|
||||
" Roundness",
|
||||
" Per-Corner Roundness",
|
||||
" Top Left Roundness",
|
||||
" Top Right Roundness",
|
||||
" Bottom Right Roundness",
|
||||
" Bottom Left Roundness",
|
||||
" >Fill 1",
|
||||
" Composite",
|
||||
" Fill Rule",
|
||||
|
||||
@@ -132,17 +132,52 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathIn
|
||||
/// The path on screen: `sp` through `m` and the viewer map, flattened, with the closing point
|
||||
/// repeated for a closed path.
|
||||
pub(crate) fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
|
||||
let k = effectcraft_engine::render::kurbo_path(sp);
|
||||
let mut k = effectcraft_engine::render::kurbo_path(sp);
|
||||
// A path-space tolerance becomes visible corners when the viewer or a layer/group is
|
||||
// magnified. Flatten affine paths in screen space so the error stays below half a point.
|
||||
// Projective transforms must still map the flattened points: projecting Bezier control
|
||||
// points would change the curve rather than just its tessellation.
|
||||
let screen_space = m.is_affine();
|
||||
if screen_space {
|
||||
let z = f64::from(map.zoom);
|
||||
k.apply_affine(kurbo::Affine::new([
|
||||
m.0[0][0] * z,
|
||||
m.0[1][0] * z,
|
||||
m.0[0][1] * z,
|
||||
m.0[1][1] * z,
|
||||
m.0[0][2] * z + f64::from(map.origin.x),
|
||||
m.0[1][2] * z + f64::from(map.origin.y),
|
||||
]));
|
||||
}
|
||||
if !k.is_finite() {
|
||||
return Vec::new();
|
||||
}
|
||||
let bounds = k.control_box();
|
||||
if [bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().any(|v| v.abs() > f64::from(f32::MAX)) {
|
||||
return Vec::new();
|
||||
}
|
||||
// Bound subdivision work even for extreme finite scales from a malformed project. For
|
||||
// ordinary on-screen paths the tolerance remains half a point.
|
||||
let tolerance = (bounds.width().max(bounds.height()) / 1_000_000.0).max(0.5);
|
||||
let mut pts: Vec<Pos2> = vec![];
|
||||
kurbo::flatten(k.iter(), 0.5, |el| match el {
|
||||
kurbo::flatten(k.iter(), tolerance, |el| match el {
|
||||
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => {
|
||||
let c = m.apply(gv2(q.x, q.y));
|
||||
pts.push(map.to_screen([c.x, c.y]));
|
||||
let p = if screen_space {
|
||||
egui::pos2(q.x as f32, q.y as f32)
|
||||
} else {
|
||||
let c = m.apply(gv2(q.x, q.y));
|
||||
map.to_screen([c.x, c.y])
|
||||
};
|
||||
if p.is_finite() {
|
||||
pts.push(p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if sp.closed && !pts.is_empty() {
|
||||
pts.push(pts[0]);
|
||||
if sp.closed
|
||||
&& let Some(first) = pts.first().copied()
|
||||
{
|
||||
pts.push(first);
|
||||
}
|
||||
pts
|
||||
}
|
||||
@@ -685,6 +720,111 @@ mod tests {
|
||||
PathInfo { layer: LayerId(3), uid: 7, m: Mat3::IDENTITY, sp, is_mask: true, feather: Vec::new() }
|
||||
}
|
||||
|
||||
fn circle() -> ShapePath {
|
||||
let r = 38.0;
|
||||
let h = r * 0.552_284_749_830_793_6;
|
||||
ShapePath {
|
||||
vertices: vec![[0.0, -r], [r, 0.0], [0.0, r], [-r, 0.0]],
|
||||
in_tangents: vec![[-h, 0.0], [0.0, -h], [h, 0.0], [0.0, h]],
|
||||
out_tangents: vec![[h, 0.0], [0.0, h], [-h, 0.0], [0.0, -h]],
|
||||
closed: true,
|
||||
feather: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the actual screen-space error against dense samples of the original cubics,
|
||||
/// independently of the tessellator's chosen subdivisions.
|
||||
fn screen_error(sp: &ShapePath, m: &Mat3, map: &ViewerMap, points: &[Pos2]) -> f32 {
|
||||
let mut worst = 0.0_f32;
|
||||
for i in 0..sp.vertices.len() {
|
||||
let Some(c) = segment(sp, i) else { continue };
|
||||
for step in 0..=1024 {
|
||||
let q = cubic(c[0], c[1], c[2], c[3], f64::from(step) / 1024.0);
|
||||
let q = m.apply(gv2(q[0], q[1]));
|
||||
let p = map.to_screen([q.x, q.y]);
|
||||
let distance = points
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let a = pair[0];
|
||||
let v = pair[1] - a;
|
||||
let t = if v.length_sq() > 0.0 { ((p - a).dot(v) / v.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
p.distance(a + v * t)
|
||||
})
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
worst = worst.max(distance);
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_overlay_stays_smooth_at_viewer_magnifications() {
|
||||
let sp = circle();
|
||||
let mut counts = Vec::new();
|
||||
for zoom in [0.5, 1.0, 5.0] {
|
||||
let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING };
|
||||
let points = flatten(&sp, &Mat3::IDENTITY, &map);
|
||||
let error = screen_error(&sp, &Mat3::IDENTITY, &map, &points);
|
||||
assert!(error <= 0.51, "{zoom}x: {error} screen points from the curve");
|
||||
assert_eq!(points.first(), points.last(), "closed paths keep their closing edge");
|
||||
for v in &sp.vertices {
|
||||
let anchor = map.to_screen(*v);
|
||||
assert!(points.iter().any(|p| p.distance(anchor) < 0.001), "original vertex missing at {zoom}x");
|
||||
}
|
||||
counts.push(points.len());
|
||||
}
|
||||
assert!(counts[0] < counts[1] && counts[1] < counts[2], "tessellation follows visible curve size: {counts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_overlay_accounts_for_nonuniform_layer_and_group_transforms() {
|
||||
let sp = circle();
|
||||
let layer = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8));
|
||||
let group = Mat3::rotate_deg(-19.0) * Mat3::skew_deg(25.0, 11.0) * Mat3::scale(gv2(0.7, 2.5));
|
||||
let m = layer * group;
|
||||
for zoom in [0.5, 1.0, 5.0] {
|
||||
let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING };
|
||||
let points = flatten(&sp, &m, &map);
|
||||
let error = screen_error(&sp, &m, &map, &points);
|
||||
assert!(error <= 0.51, "{zoom}x transformed path: {error} screen points from the curve");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_overlay_preserves_its_transformed_endpoints() {
|
||||
let sp = info(vec![[10.0, 20.0], [50.0, 60.0]], false).sp;
|
||||
let m = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8));
|
||||
let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING };
|
||||
let points = flatten(&sp, &m, &map);
|
||||
assert_ne!(points.first(), points.last(), "an open path must not gain a closing edge");
|
||||
for (point, vertex) in [points.first(), points.last()].into_iter().zip([sp.vertices.first(), sp.vertices.last()]) {
|
||||
let q = m.apply(gv2(vertex.unwrap()[0], vertex.unwrap()[1]));
|
||||
assert!(point.unwrap().distance(map.to_screen([q.x, q.y])) < 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_overlay_coordinates_are_not_tessellated() {
|
||||
let sp = circle();
|
||||
for zoom in [f32::NAN, f32::INFINITY] {
|
||||
let map = ViewerMap { origin: Pos2::ZERO, zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING };
|
||||
assert!(flatten(&sp, &Mat3::IDENTITY, &map).is_empty());
|
||||
}
|
||||
let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING };
|
||||
assert!(flatten(&sp, &Mat3::scale(gv2(f64::INFINITY, 1.0)), &map).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_finite_overlay_scales_keep_subdivision_bounded() {
|
||||
let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING };
|
||||
let sp = circle();
|
||||
let points = flatten(&sp, &Mat3::scale(gv2(1e12, 1e12)), &map);
|
||||
assert!(!points.is_empty());
|
||||
assert!(points.len() < 10_000, "extreme scales must not allocate millions of vertices");
|
||||
assert!(points.iter().all(|p| p.is_finite()));
|
||||
assert!(flatten(&sp, &Mat3::scale(gv2(1e300, 1e300)), &map).is_empty());
|
||||
}
|
||||
|
||||
/// #912: clicking the last vertex of an open path with the Pen resumes that path.
|
||||
#[test]
|
||||
fn pen_resumes_an_open_path_at_its_last_vertex() {
|
||||
|
||||
@@ -16,8 +16,9 @@ const H: f64 = 360.0;
|
||||
|
||||
fn app() -> (EffectcraftApp, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Shot", "width": W, "height": H, "duration": 2})).unwrap();
|
||||
let plate = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Shot", "width": W as u32, "height": H as u32, "duration": 2, "frameRate": 30})).unwrap();
|
||||
let plate =
|
||||
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": W as u32, "height": H as u32})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [plate]})).unwrap();
|
||||
let uid = r["effects"][0].as_u64().unwrap();
|
||||
// A synthetic solve: a static camera (focal 600) looking at a 6 × 4 grid of points on z = 1.
|
||||
@@ -135,7 +136,11 @@ fn viewer_points_selection_and_effect_controls() {
|
||||
h.state_mut().show_panel(PanelKind::EffectControls);
|
||||
h.run_steps(4);
|
||||
let all = ids(&h);
|
||||
for want in [format!("effectControls.cameraTracker.{uid}.analyze"), format!("effectControls.cameraTracker.{uid}.createCamera")] {
|
||||
for want in [
|
||||
format!("effectControls.cameraTracker.{uid}.analyze"),
|
||||
format!("effectControls.cameraTracker.{uid}.createCamera"),
|
||||
format!("effectControls.cameraTracker.{uid}.exportBlender"),
|
||||
] {
|
||||
assert!(all.contains(&want), "missing {want}");
|
||||
}
|
||||
// Tracker panel: Track Camera is available for the selected layer.
|
||||
@@ -145,6 +150,56 @@ fn viewer_points_selection_and_effect_controls() {
|
||||
assert!(h.state().session.is_enabled("track.camera"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_button_opens_python_dialog_and_writes_scene() {
|
||||
use effectcraft_engine::project::{Footage, FootageKind, ItemKind, LayerId, LayerSource};
|
||||
let (mut a, plate, uid) = app();
|
||||
let cid = a.session.active_comp_id().unwrap();
|
||||
a.session
|
||||
.edit("video fixture", None, |p, _| {
|
||||
let item = p.add_item(
|
||||
"Video",
|
||||
Default::default(),
|
||||
None,
|
||||
ItemKind::Footage(Footage {
|
||||
path: "/tmp/effectcraft-ui-synthetic.mp4".into(),
|
||||
kind: FootageKind::Video,
|
||||
width: W as u32,
|
||||
height: H as u32,
|
||||
has_video: true,
|
||||
duration: effectcraft_engine::time::Tick::from_seconds_f64(2.0),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
p.comp_mut(cid).unwrap().layer_mut(LayerId(plate)).unwrap().source = LayerSource::Footage { item };
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
a.session.camera_pending.clear();
|
||||
let output = std::env::temp_dir().join(format!("effectcraft-ui-blender-{}.py", std::process::id()));
|
||||
let selected_path = output.clone();
|
||||
a.hooks.pick_save_file = Some(Box::new(move |name, ext| {
|
||||
assert!(name.ends_with(".py"));
|
||||
assert_eq!(ext, "py");
|
||||
Some(selected_path.to_string_lossy().into_owned())
|
||||
}));
|
||||
let shot = std::env::var("EFFECTCRAFT_BLENDER_UI_SNAPSHOT").ok();
|
||||
let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0));
|
||||
let builder = if shot.is_some() { builder.wgpu() } else { builder };
|
||||
let mut h = builder.build_eframe(|_| a);
|
||||
h.state_mut().show_panel(PanelKind::EffectControls);
|
||||
h.run_steps(5);
|
||||
let button = rect(&h, &format!("effectControls.cameraTracker.{uid}.exportBlender"));
|
||||
if let Some(path) = shot {
|
||||
h.render().expect("render export button").save(path).unwrap();
|
||||
}
|
||||
click(&mut h, button.center(), Default::default());
|
||||
assert!(output.exists(), "UI export failed: {}", h.state().ui.status);
|
||||
let script = std::fs::read_to_string(output).expect("clicking the export button saves a Python scene");
|
||||
assert!(script.contains("def import_scene():"));
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1, "export does not create a camera in the source project");
|
||||
}
|
||||
|
||||
/// Drag with `button` from `from` to `to` in steps, then release.
|
||||
fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: egui::Pos2, to: egui::Pos2) {
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(from));
|
||||
|
||||
@@ -664,3 +664,33 @@ fn transition_presets_apply_from_effects_and_presets() {
|
||||
assert_eq!(fx.name, "Radial Wipe");
|
||||
assert_eq!(fx.prop("completion").unwrap().keys.len(), 2);
|
||||
}
|
||||
|
||||
/// #2582: dragging Settings' UI Scale doesn't resize the interface under the pointer; the value
|
||||
/// applies once the button is released.
|
||||
#[test]
|
||||
fn ui_scale_applies_when_the_drag_ends() {
|
||||
let mut h = harness();
|
||||
open_settings(&mut h);
|
||||
let e = h.state().auto.find("settings.appearance.uiScale").expect("the UI Scale field");
|
||||
let from = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for k in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(10.0 * k as f32, 0.0)));
|
||||
h.step();
|
||||
assert_eq!(scale(&h), (100, 1.0), "nothing applies mid-drag");
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton {
|
||||
pos: from + egui::vec2(80.0, 0.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
h.run_steps(4);
|
||||
let (pct, zoom) = scale(&h);
|
||||
assert!(pct > 100 && (zoom - pct as f32 / 100.0).abs() < 1e-6, "applied on release: {pct} {zoom}");
|
||||
click(&mut h, "settings.cancel");
|
||||
h.run_steps(2);
|
||||
assert_eq!(scale(&h), (100, 1.0));
|
||||
}
|
||||
|
||||
@@ -341,6 +341,38 @@ fn a_drag_keeps_its_control_past_the_window_edge() {
|
||||
h.state_mut().session.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// #2585: a window the host resizes takes the size, lays out again and runs onResize; a docked
|
||||
/// ScriptUI panel gets its dock's size that way.
|
||||
#[test]
|
||||
fn script_windows_learn_the_size_they_are_given() {
|
||||
let mut h = harness();
|
||||
let code = r#"
|
||||
var w = new Window("palette", "Sized");
|
||||
var log = w.add("statictext", undefined, "", { name: "log" });
|
||||
log.preferredSize = [200, 20];
|
||||
w.onResize = function () { log.text = "resized " + w.size.width + "x" + w.size.height; };
|
||||
w.show();
|
||||
"#;
|
||||
let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "sized.jsx"})).unwrap();
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.run_steps(2);
|
||||
h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": [300, 180]})).unwrap();
|
||||
let win = h.state().session.script_ui.windows[0].clone();
|
||||
assert_eq!(win.root.lookup(&json!("log")).unwrap().text, "resized 300x180");
|
||||
let list = h.state_mut().session.execute("scriptui.list", json!({})).unwrap();
|
||||
assert_eq!(list[0]["size"], json!([300.0, 180.0]), "{list}");
|
||||
assert!(h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": ["wide", 1]})).is_err());
|
||||
h.state_mut().session.execute("scriptui.close", json!({})).unwrap();
|
||||
// A docked panel: its window is its dock's size, not its preferred size.
|
||||
h.state_mut().session.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap();
|
||||
h.run_steps(4);
|
||||
let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Layer Tools.jsx").unwrap().clone();
|
||||
let dock = rect(&h, &format!("scriptui.{}", panel.id));
|
||||
assert_eq!([panel.root.bounds[2].round(), panel.root.bounds[3].round()], [dock.width().round() as f64, dock.height().round() as f64]);
|
||||
h.state_mut().close_panel(PanelKind::ScriptPanel(panel.id));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(c));
|
||||
h.run_steps(1);
|
||||
|
||||
@@ -340,6 +340,11 @@ fn timeline_colour_eyedropper_samples_the_comp() {
|
||||
let fill = fill.expect("a fill colour");
|
||||
fn groups(g: &effectcraft_engine::project::PropGroup, out: &mut Vec<u64>) {
|
||||
for c in g.groups() {
|
||||
// The rectangle's path rows would push Fill below the rows the Timeline fits; the
|
||||
// eyedropper only needs Fill, so leave that group collapsed.
|
||||
if c.name == "Rectangle Path 1" {
|
||||
continue;
|
||||
}
|
||||
out.push(c.uid);
|
||||
groups(c, out);
|
||||
}
|
||||
@@ -448,3 +453,42 @@ fn effects_drag_to_a_new_place_in_the_timeline() {
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"], "one undo step per drop");
|
||||
}
|
||||
|
||||
/// #2552: Shift-clicking effect rows in the Timeline adds them to the selection (and a second
|
||||
/// Shift-click takes one out), so one animation preset can hold several effects.
|
||||
#[test]
|
||||
fn shift_click_selects_several_effects_for_a_preset() {
|
||||
let mut s = session();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for fx in ["Gaussian Blur", "Invert"] {
|
||||
s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap();
|
||||
}
|
||||
let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone();
|
||||
let uids: Vec<u64> = effects.groups().map(|g| g.uid).collect();
|
||||
let mut h = harness(s);
|
||||
h.state_mut().ui.timeline.open_groups.insert(effects.uid);
|
||||
open_layer(&mut h, l);
|
||||
let shift_click = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| {
|
||||
let p = center(h, &format!("timeline.group.{uid}.name"));
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT));
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT });
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
};
|
||||
let selected = |h: &Harness<'_, EffectcraftApp>| h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::<Vec<_>>();
|
||||
click(&mut h, &format!("timeline.group.{}.name", uids[0]));
|
||||
assert_eq!(selected(&h), [uids[0]]);
|
||||
shift_click(&mut h, uids[1]);
|
||||
assert_eq!(selected(&h), [uids[0], uids[1]], "Shift adds the second effect");
|
||||
let path = std::env::temp_dir().join(format!("ec-2552-{}.ffx", std::process::id()));
|
||||
let r = h.state_mut().session.execute("anim.savePreset", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["effects"], 2, "{r}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
shift_click(&mut h, uids[1]);
|
||||
assert_eq!(selected(&h), [uids[0]], "a second Shift-click takes it out");
|
||||
}
|
||||
|
||||
+6
-1
@@ -463,7 +463,12 @@ and Origin), `camera.createFromSolve {kind: text|solid|null|shadowCatcher, point
|
||||
{center, normal, size?}, multiple?}` (Create Text / Solid / Null / Shadow Catcher, Camera and Light,
|
||||
Create Multiple …), `camera.deletePoints {points?, wait?}` (re-solves; with Auto-delete Points
|
||||
Across Time the same feature's other tracks go too) and `camera.create` (the Create Camera button).
|
||||
Each is one undo step; the first create adds the one-node "3D Tracker Camera" keyed on every frame.
|
||||
Each edit is one undo step; the first create adds the one-node "3D Tracker Camera" keyed on every frame.
|
||||
|
||||
`camera.exportBlender {path: ".py", layer?, effect?, sensorWidth?: 36, unitScale?: 0.01,
|
||||
startFrame?: 1, includePoints?: true}` exports the solve as an executable Blender scene script:
|
||||
baked camera/lens/focus, synced video background, points and ground. See
|
||||
[Blender camera export](blender-camera-export.md) for framing, assumptions and limitations.
|
||||
Changing the layer's frames clears the analysis; changing `effects/#1/shotType`,
|
||||
`effects/#1/horizontalAngleOfView`, `effects/#1/advanced/solveMethod` re-solves the stored tracks.
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Exporting a tracked camera to Blender
|
||||
|
||||
After the **3D Camera Tracker** finishes solving, click **Export Scene to Blender...** in its
|
||||
Effect Controls and save the `.py` file. In Blender 4.0 or newer, open it in the Scripting
|
||||
workspace's Text Editor and choose **Run Script**. The script creates and activates a new scene;
|
||||
it preserves existing scenes and objects. Save the resulting `.blend` normally.
|
||||
|
||||
The script uses Blender's public Python API, with no add-on or Python dependencies beyond
|
||||
Blender itself. For unattended use: `blender --background --python "Tracked Scene.py"`.
|
||||
That imports the scene in memory; it does not automatically save a `.blend`.
|
||||
|
||||
## Transferred data
|
||||
|
||||
- Camera position and quaternion rotation baked on every analyzed frame, with consistent
|
||||
quaternion signs and linear interpolation; zoom/lens animation, including Variable Zoom.
|
||||
- Tracking input/video resolution, horizontal sensor fit, exact rational composition FPS,
|
||||
frame range and composition markers within the analyzed interval. The first analyzed frame
|
||||
becomes Blender frame 1 by default; the original composition timestamps remain in metadata.
|
||||
- The original video or still as the camera's background image, at full opacity, refreshed on
|
||||
frame changes. Source-frame offsets are baked too, accounting for trim, stretch, source FPS
|
||||
and Time Remap. This is a viewport background, not a render/compositor background.
|
||||
- Focus distance, DOF switch, f-stop, iris blade count, rotation and aspect sampled from the
|
||||
tracker's linked **3D Tracker Camera**, including keyframes and expressions. Create that
|
||||
camera and adjust its Camera Options before exporting to transfer custom focus settings.
|
||||
- Solved 3D points as a non-rendering vertex mesh, with point attributes for track ID,
|
||||
reprojection error and first/last visible frame. A chosen ground plane becomes Blender's XY
|
||||
plane, with its origin preserved and a non-rendering wire plane as a placement guide.
|
||||
- Shutter duration (not automatically enabled), plus the original shutter phase in metadata.
|
||||
- A Blender Text datablock named `EffectCraft Tracking Metadata.json` containing the complete
|
||||
solve, optional original 2D tracks, per-frame timings, source interpretation, diagnostics and
|
||||
export warnings. Radial distortion coefficients are also camera custom properties.
|
||||
|
||||
Coordinates are converted from EffectCraft's right/down/forward world to Blender's
|
||||
right/forward/up world, including the camera's local up and negative-Z viewing direction.
|
||||
Export uses the tracking input's framing, not the footage layer's final 2D placement in the
|
||||
composition. Camera pose and lens always come from the solve; subsequent manual pose/lens edits
|
||||
on the linked camera are not substituted for the tracked motion.
|
||||
|
||||
## What cannot be inferred
|
||||
|
||||
Monocular tracking cannot recover absolute scene scale, physical sensor size, optical focus,
|
||||
f-stop or exposure from feature tracks. The default sensor width is an assumption of **36 mm**;
|
||||
the focal length in millimeters is derived from it while preserving the solved field of view.
|
||||
The default scale is **0.01 Blender units per composition-world pixel**, not a measurement.
|
||||
Without a linked camera, DOF is disabled and focus distance uses the default solved scene depth.
|
||||
Blender's physical DOF approximates EffectCraft's blur model: Blur Level and Iris Roundness are
|
||||
retained in metadata, but have no direct Blender equivalent.
|
||||
|
||||
Brown-model radial `k1`/`k2` coefficients use radius normalized to half the image diagonal.
|
||||
They are preserved, **not** assigned to Blender's different lens-distortion model. For a precise
|
||||
match when distortion is nonzero, use an undistorted plate; the script prints a warning.
|
||||
The tracker assumes square pixels, so Blender renders square pixels; any non-square source
|
||||
interpretation is retained in metadata and flagged.
|
||||
|
||||
The `.py` references the video; it does not contain a copy of the video's bytes. When moving it,
|
||||
keep the video next to the script (same filename), or relink the background image in Blender.
|
||||
Missing/unsupported media produces a warning while retaining the imported camera and geometry.
|
||||
Masks and effects applied before the tracker are not baked into the original media. Export
|
||||
requires a direct video/still footage layer at the analyzed size; render precomps, sequences or
|
||||
size-changing preprocessing to a video and track that video first.
|
||||
|
||||
## Agent / control-channel command
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "camera.exportBlender",
|
||||
"params": {
|
||||
"layer": "#1",
|
||||
"path": "/output/Tracked Scene.py",
|
||||
"sensorWidth": 36,
|
||||
"unitScale": 0.01,
|
||||
"startFrame": 1,
|
||||
"includePoints": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`comp`, `layer` and `effect` select the tracker as with `camera.analyze`. Without explicit
|
||||
selection, the selected tracker (or first tracker in the composition) is used. `sensorWidth`,
|
||||
`unitScale`, `startFrame` and `includePoints` are optional. The reply contains file size,
|
||||
exported frame/point counts and warnings. Writing uses the host's atomic file service.
|
||||
Calls issued by scripts require **Allow Scripts to Write Files and Access Network** in
|
||||
Settings ▸ Scripting & Expressions. Export from the UI or control channel remains available
|
||||
with that preference off.
|
||||
|
||||
Export is limited to 100,000 analyzed frames, 100,000 points and 64 MiB of serialized scene data;
|
||||
invalid, stale or incomplete solves return an actionable error without modifying the document.
|
||||
|
||||
## Verification
|
||||
|
||||
Engine tests check argument validation, escaped/Unicode paths, fractional FPS, trim/stretch,
|
||||
focus/f-stop, zoom, ground placement, malformed solves and write failures. To additionally run
|
||||
the generated artifact against an installed Blender's public API:
|
||||
|
||||
```sh
|
||||
EFFECTCRAFT_BLENDER=/path/to/blender cargo test -p effectcraft-engine tests::blender -- --nocapture
|
||||
```
|
||||
|
||||
Optionally set `EFFECTCRAFT_BLENDER_PLATE` to a local synthetic 640×360 video to exercise media
|
||||
loading and source-frame offsets. The oracle checks every synthetic point's projection through
|
||||
the imported camera on every frame, as well as animation, DOF, timing and scene preservation.
|
||||
@@ -20,7 +20,7 @@ our own scripts; no real-world script library has been run against both apps.
|
||||
| Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark |
|
||||
| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) |
|
||||
| `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging |
|
||||
| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` |
|
||||
| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch; a docked panel gets its dock's size and `onResize` (`scriptui.resize`, #2585); a drag keeps reaching the control it started on past the window edge, and `MouseEvent.buttons` reports the held buttons (#2584) | good | `ui_scriptui`, `tests_ui*` |
|
||||
| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |
|
||||
| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) |
|
||||
| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) |
|
||||
|
||||
Reference in New Issue
Block a user