mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 14:08:38 -06:00
#986: add Blender scene export for 3D Camera Tracker
This commit is contained in:
@@ -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():"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user