#986: add Blender scene export for 3D Camera Tracker

This commit is contained in:
Krueels
2026-10-29 20:15:25 -03:00
parent 77bea50f0b
commit dcbb806139
15 changed files with 1086 additions and 5 deletions
+261
View File
@@ -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():"));
}
}
}