Browse / github.com/storytold/effectcraft / patch
Add Blender scene export for the 3D Camera Tracker
Add Blender scene export for the 3D Camera Tracker ## Summary Related to #986. This is a tracker-camera handoff, not a general composition/3D-layer exporter, so it does not close the broader request. The 3D Camera Tracker can create a camera inside EffectCraft, but cannot currently transfer its solved motion to Blender. Add **Export Scene to Blender...** to the effect controls and the agent-drivable `camera.exportBlender` command. The export is a standalone Python script using Blender's public API; running it creates a new scene while preserving existing scenes and objects. - Bake camera position, quaternion rotation, and focal length on ev
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Krueels (imported from pull request #2442, open)
- Base
e821feeec4f7e3148a31ea24e6a11a36fac08337- Size
- +994 −8 in 15 files
- Review
- pending
- Shared
- 2026-10-28
- Id
9acec5347893d3a96078c99c
How it went
No other operator has reported using it yet.
Diff
raw · contributors/people.toml crates/engine/src/commands/blender.rs crates/engine/src/commands/blender_scene.py crates/engine/src/commands/camera_cmds.rs crates/engine/src/commands/mod.rs crates/engine/src/lib.rs crates/engine/src/tests_blender.rs crates/engine/tests/blender_scene_oracle.py crates/ui-egui/src/i18n/ui.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/panels/camera_tracker_ui.rs crates/ui-egui/tests/it/ui_camera_tracker.rs crates/ui-egui/tests/it/ui_preview_align.rs docs/agents.md docs/blender-camera-export.md
@@ -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"new file mode 100644@@ -0,0 +1,290 @@+//! 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::{FootageKind, ItemId, ItemKind, LayerId, LayerSource, Uid};+use effectcraft_render::EvalCtx;+use effectcraft_time::Tick;+use effectcraft_track::camtrack::linalg;+use serde_json::{Value, json};++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> {+ 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),+ };+ 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"))?;+ 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"));+ }+ let first = solve.frames.first().ok_or_else(|| bad(CMD, "the camera solve is empty"))?;+ 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"));+ }+ 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;+ let ctx0 = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, lo) };+ 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"));+ }+ 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])?;+ }+ let world = solve.world([comp.width as f64, comp.height as f64], first.focal * layer_scale);+ finite([world.s * units])?;+ 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 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());+ }+ 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 = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, 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)}));+ }+ 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}))+}new file mode 100644@@ -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()@@ -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() {@@ -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;@@ -1364,6 +1364,8 @@ mod tests_anim_presets; #[cfg(test)] mod tests_anim_tools; #[cfg(test)]+mod tests_blender;+#[cfg(test)] mod tests_camera_track; #[cfg(test)] mod tests_codec_options;new file mode 100644@@ -0,0 +1,239 @@+//! 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}");+ }+}new file mode 100644@@ -0,0 +1,61 @@+"""Public-API oracle for scripts generated by 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")@@ -2906,6 +2906,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 "),@@ -1406,6 +1406,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res "view.exportGuides" => ("path", Ask::Save("Guides.json")), "view.importGuides" => ("path", Ask::Open(&["json"])), "file.exportLottie" => ("path", Ask::Save("Animation.json")),+ "camera.exportBlender" => ("path", Ask::Save("Tracked Scene.py")), "file.importLottie" => ("path", Ask::Open(&["json", "lottie"])), "file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])), "file.importTimeline" => ("path", Ask::Open(&["xml", "fcpxml", "otio", "edl", "aaf", "omf"])),@@ -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;@@ -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));@@ -284,18 +284,24 @@ fn playback_prefetches_past_the_cached_frames_ahead() { h.run_steps(2); assert!(h.state().playback.playing); let cur = h.state().session.time();- assert!(cur < Tick::from_seconds_f64(1.0), "{cur:?}");+ // `run_steps` advances playback using wall-clock time. Under a loaded test suite, even these+ // two steps can take over a second; the cached window is five seconds, so keep the test's+ // useful bound at its end instead of failing on scheduler delay.+ assert!(cur < Tick::from_seconds_f64(5.0), "{cur:?}"); for _ in 0..4000 { if h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120) { break; } h.step(); std::thread::sleep(std::time::Duration::from_millis(1));- if h.state().session.time() >= Tick::from_seconds_f64(1.0) {+ if h.state().session.time() >= Tick::from_seconds_f64(5.0) { break; } }- assert!(h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120), "frames past the cached run render while it plays the first second");+ assert!(+ h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120),+ "frames past the cached run render before playback reaches the end of the cached window"+ ); } #[test]@@ -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. new file mode 100644@@ -0,0 +1,97 @@+# 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.+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.