From dcbb806139fc3ab2425bb77c52e4142741af52be Mon Sep 17 00:00:00 2001 From: Krueels <90205419+Krueels@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:44:05 -0300 Subject: [PATCH 1/3] #986: add Blender scene export for 3D Camera Tracker --- contributors/people.toml | 3 + crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/blender.rs | 295 ++++++++++++++++++ crates/engine/src/cmd/blender/validate.rs | 71 +++++ crates/engine/src/cmd/blender_scene.py | 206 ++++++++++++ crates/engine/src/cmd/camera_cmds.rs | 14 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/blender.rs | 261 ++++++++++++++++ crates/engine/tests/blender_scene_oracle.py | 61 ++++ crates/ui-egui/src/i18n/ui.rs | 1 + crates/ui-egui/src/menus/frontend.rs | 1 + .../ui-egui/src/panels/camera_tracker_ui.rs | 7 +- crates/ui-egui/tests/it/ui_camera_tracker.rs | 61 +++- docs/agents.md | 7 +- docs/blender-camera-export.md | 101 ++++++ 15 files changed, 1086 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/blender.rs create mode 100644 crates/engine/src/cmd/blender/validate.rs create mode 100644 crates/engine/src/cmd/blender_scene.py create mode 100644 crates/engine/src/tests/blender.rs create mode 100644 crates/engine/tests/blender_scene_oracle.py create mode 100644 docs/blender-camera-export.md diff --git a/contributors/people.toml b/contributors/people.toml index 8654c7a10..df9d7fd80 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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" diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index cba6df6ef..bd089b52b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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; diff --git a/crates/engine/src/cmd/blender.rs b/crates/engine/src/cmd/blender.rs new file mode 100644 index 000000000..e6c678101 --- /dev/null +++ b/crates/engine/src/cmd/blender.rs @@ -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 { + 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) -> 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 { + 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 { + 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 = 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 { + 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> { + 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 { + 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, 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)) +} diff --git a/crates/engine/src/cmd/blender/validate.rs b/crates/engine/src/cmd/blender/validate.rs new file mode 100644 index 000000000..a9454861f --- /dev/null +++ b/crates/engine/src/cmd/blender/validate.rs @@ -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(()) +} diff --git a/crates/engine/src/cmd/blender_scene.py b/crates/engine/src/cmd/blender_scene.py new file mode 100644 index 000000000..7240cb1ca --- /dev/null +++ b/crates/engine/src/cmd/blender_scene.py @@ -0,0 +1,206 @@ +# EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0. +# Blender scene export contributor: Krueels — https://github.com/Krueels +# 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 references: https://docs.blender.org/api/4.5/bpy.types.Camera.html +# https://docs.blender.org/api/4.5/bpy.types.ImageUser.html +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() diff --git a/crates/engine/src/cmd/camera_cmds.rs b/crates/engine/src/cmd/camera_cmds.rs index 68cd14fb9..e717d51cc 100644 --- a/crates/engine/src/cmd/camera_cmds.rs +++ b/crates/engine/src/cmd/camera_cmds.rs @@ -42,11 +42,25 @@ pub fn specs() -> Vec { 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 { + 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() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25d05b903..ac887f438 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,5 +1,6 @@ mod anim_presets; mod anim_tools; +mod blender; mod camera_track; mod codec_options; mod color_view; diff --git a/crates/engine/src/tests/blender.rs b/crates/engine/src/tests/blender.rs new file mode 100644 index 000000000..84e939055 --- /dev/null +++ b/crates/engine/src/tests/blender.rs @@ -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 = 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():")); + } + } +} diff --git a/crates/engine/tests/blender_scene_oracle.py b/crates/engine/tests/blender_scene_oracle.py new file mode 100644 index 000000000..3d1cbeda7 --- /dev/null +++ b/crates/engine/tests/blender_scene_oracle.py @@ -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") diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 061090871..23926864d 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -2996,6 +2996,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 "), diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index 5d65ffb8f..a5d616714 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -560,6 +560,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"])), diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index bca98ea4b..481577398 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -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; diff --git a/crates/ui-egui/tests/it/ui_camera_tracker.rs b/crates/ui-egui/tests/it/ui_camera_tracker.rs index f7d349342..8c4be61a7 100644 --- a/crates/ui-egui/tests/it/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/it/ui_camera_tracker.rs @@ -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)); diff --git a/docs/agents.md b/docs/agents.md index 172e5c4c6..8c2d49a45 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -446,7 +446,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. diff --git a/docs/blender-camera-export.md b/docs/blender-camera-export.md new file mode 100644 index 000000000..d102eef4f --- /dev/null +++ b/docs/blender-camera-export.md @@ -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. From e2b56f1ba52b654c716107fdd75715f8a60d506a Mon Sep 17 00:00:00 2001 From: Krueels <90205419+Krueels@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:05:17 -0300 Subject: [PATCH 2/3] #986: remove personal credit from generated Blender scripts --- crates/engine/src/cmd/blender_scene.py | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/src/cmd/blender_scene.py b/crates/engine/src/cmd/blender_scene.py index 7240cb1ca..9f8a522c5 100644 --- a/crates/engine/src/cmd/blender_scene.py +++ b/crates/engine/src/cmd/blender_scene.py @@ -1,5 +1,4 @@ # EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0. -# Blender scene export contributor: Krueels — https://github.com/Krueels # 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. From 73817dc3f0c37ccbf639add2f54925322dc40bea Mon Sep 17 00:00:00 2001 From: Krueels <90205419+Krueels@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:28:04 -0300 Subject: [PATCH 3/3] #986: confine Blender exports and trim generated header --- crates/automation/src/confine.rs | 1 + crates/automation/src/tests.rs | 27 ++++++++++++++++++++++++++ crates/engine/src/cmd/blender_scene.py | 3 +-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 67e5aef98..dc64084d3 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -97,6 +97,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"]), diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 560a527c7..49f0389f6 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1473,3 +1473,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(); +} diff --git a/crates/engine/src/cmd/blender_scene.py b/crates/engine/src/cmd/blender_scene.py index 9f8a522c5..0a9700a00 100644 --- a/crates/engine/src/cmd/blender_scene.py +++ b/crates/engine/src/cmd/blender_scene.py @@ -2,8 +2,7 @@ # 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 references: https://docs.blender.org/api/4.5/bpy.types.Camera.html -# https://docs.blender.org/api/4.5/bpy.types.ImageUser.html +# Public API types: bpy.types.Camera, bpy.types.ImageUser import json import math import os