mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 14:08:38 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
//! Camera-solve interchange through Blender's public Python API. The generated script is
|
||||
//! original MIT OR Apache-2.0 code; no Blender implementation is read or embedded.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use effectcraft_effects::camera_tracker as ct;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Uid};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::camtrack::{CameraSolve, linalg};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
mod validate;
|
||||
|
||||
use super::{b_p, bad, f_p, str_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "camera.exportBlender";
|
||||
const MAX_FRAMES: usize = 100_000;
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
const MAX_JSON: usize = 64 * 1024 * 1024;
|
||||
const MAX_FRAME: i64 = 1_048_574;
|
||||
|
||||
fn number(p: &Value, key: &str, default: f64, min: f64, max: f64) -> Result<f64> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(_) => f_p(p, key).ok_or_else(|| bad(CMD, format!("{key}: expected a number")))?,
|
||||
};
|
||||
if !v.is_finite() || !(min..=max).contains(&v) {
|
||||
return Err(bad(CMD, format!("{key}: expected {min}..{max}")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn finite(values: impl IntoIterator<Item = f64>) -> Result<()> {
|
||||
if values.into_iter().any(|v| !v.is_finite() || v.abs() > 1e12) {
|
||||
return Err(bad(CMD, "invalid or excessive numeric camera/scene data; re-analyze the tracker"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Right-handed EffectCraft world (right, down, forward) → Blender (right, forward, up).
|
||||
fn position(p: [f64; 3], scale: f64) -> [f64; 3] {
|
||||
[p[0] * scale, p[2] * scale, -p[1] * scale]
|
||||
}
|
||||
|
||||
fn media_path(s: &Session, path: &str) -> Result<String> {
|
||||
if path.is_empty() || path.contains('\0') {
|
||||
return Err(bad(CMD, "the source footage has no valid file path; relink it before exporting"));
|
||||
}
|
||||
let path = Path::new(path);
|
||||
if path.is_absolute() || cfg!(target_arch = "wasm32") {
|
||||
return Ok(path.to_string_lossy().into_owned());
|
||||
}
|
||||
let cwd = std::env::current_dir().map_err(|e| bad(CMD, format!("cannot resolve footage path: {e}")))?;
|
||||
let base = s.path.as_deref().and_then(|p| Path::new(p).parent()).map(|p| cwd.join(p)).unwrap_or(cwd);
|
||||
Ok(base.join(path).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
pub(super) fn export(s: &mut Session, p: &Value, cid: ItemId, lid: LayerId, uid: Uid) -> Result<Value> {
|
||||
if s.running_for_script() && !s.prefs.scripting.allow_scripts_write_files {
|
||||
return Err(bad(CMD, "scripts can't export files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on"));
|
||||
}
|
||||
let options = Options::parse(p)?;
|
||||
let Options { path, sensor, units, start, include_points } = options;
|
||||
if s.camera_target().is_some_and(|(c, l, u)| (c, l, u) == (cid, lid, uid)) && s.is_camera_analyzing() || s.camera_pending.contains(&(cid, lid, uid)) {
|
||||
return Err(bad(CMD, "wait for this tracker's analysis to finish before exporting"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or_else(|| bad(CMD, "tracked layer missing"))?;
|
||||
let g = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "tracker missing"))?;
|
||||
for name in [ct::SOLVE, ct::TRACKS] {
|
||||
if g.prop(name).is_some_and(|p| matches!(&p.value, effectcraft_keyframe::Value::Str(v) if v.len() > MAX_JSON)) {
|
||||
return Err(bad(CMD, "tracking data exceeds the 64 MiB export limit"));
|
||||
}
|
||||
}
|
||||
let params = crate::camera_track::static_params(g);
|
||||
let solve = ct::solve(¶ms).ok_or_else(|| bad(CMD, "the camera is not solved yet; analyze the tracker first"))?;
|
||||
let (lo, end) = validate::timing(comp, layer, &solve, start)?;
|
||||
let Source { footage, path: source_path, fps: source_fps, count: source_count } = source(s, layer, &solve, path)?;
|
||||
validate::geometry(&solve, sensor)?;
|
||||
let camera = params
|
||||
.get(ct::CAMERA)
|
||||
.map(|v| v.as_f64())
|
||||
.filter(|v| v.is_finite() && *v > 0.0 && *v <= u64::MAX as f64)
|
||||
.and_then(|id| comp.layer(LayerId(id as u64)))
|
||||
.filter(|l| l.is_camera());
|
||||
let warnings = scene_warnings(camera, &solve, footage);
|
||||
let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, lo) };
|
||||
let (frames, world) = bake_frames(&ctx, layer, &solve, camera, &options, footage, source_count)?;
|
||||
let mut points = Vec::new();
|
||||
for point in &solve.points {
|
||||
finite(point.pos.into_iter().chain([point.error as f64]))?;
|
||||
if point.first > point.last || point.last as usize >= solve.frames.len() {
|
||||
return Err(bad(CMD, "invalid solved point frame range; re-analyze the tracker"));
|
||||
}
|
||||
let location = position(world.apply(point.pos), units);
|
||||
finite(location)?;
|
||||
if include_points {
|
||||
points.push(json!({"id": point.id, "location": location, "error": point.error,
|
||||
"first": start.saturating_add(point.first as i64), "last": start.saturating_add(point.last as i64)}));
|
||||
}
|
||||
}
|
||||
let tracks = ct::tracks(¶ms);
|
||||
if tracks.as_ref().is_some_and(|t| t.tracks.len() > MAX_POINTS || t.tracks.iter().any(|t| t.pts.len() > MAX_FRAMES)) {
|
||||
return Err(bad(CMD, "2D tracks exceed export limits"));
|
||||
}
|
||||
let markers: Vec<Value> = comp
|
||||
.markers
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let frame = start.saturating_add(comp.frame_rate.frame_at(m.time).saturating_sub(comp.frame_rate.frame_at(lo)));
|
||||
if (start..=end).contains(&frame) { Some(json!({"name": m.comment, "frame": frame})) } else { None }
|
||||
})
|
||||
.collect();
|
||||
let payload = json!({"version": 1, "name": format!("{} — {}", s.project.item(cid).map(|i| i.name.as_str()).unwrap_or("Shot"), layer.name),
|
||||
"resolution": solve.size, "fpsNumerator": comp.frame_rate.num, "fpsDenominator": comp.frame_rate.den,
|
||||
"startFrame": start, "endFrame": end, "sensorWidth": sensor, "unitScale": units,
|
||||
"media": {"path": source_path, "kind": if footage.kind == FootageKind::Still {"FILE"} else {"MOVIE"},
|
||||
"fps": source_fps, "nativeRate": footage.native_rate, "pixelAspect": footage.pixel_aspect, "timecode": footage.start_timecode,
|
||||
"frameCount": source_count, "loopCount": footage.loop_count},
|
||||
"frames": frames, "points": points, "markers": markers, "ground": solve.ground.is_some(),
|
||||
"groundSize": world.s * units, "shutter": comp.shutter_angle / 360.0, "shutterPhase": comp.shutter_phase,
|
||||
"solve": solve, "tracks": if include_points { tracks.as_deref() } else { None },
|
||||
"warnings": warnings});
|
||||
let data = serde_json::to_string(&payload).map_err(|e| bad(CMD, format!("cannot serialize scene: {e}")))?;
|
||||
if data.len() > MAX_JSON {
|
||||
return Err(bad(CMD, "scene exceeds the 64 MiB script limit; disable includePoints or split the shot"));
|
||||
}
|
||||
let quoted = serde_json::to_string(&data).map_err(|e| bad(CMD, format!("cannot encode scene: {e}")))?;
|
||||
let script = include_str!("blender_scene.py").replace("__EFFECTCRAFT_DATA__", "ed);
|
||||
s.services.write_file(path, script.as_bytes()).map_err(|e| bad(CMD, format!("cannot write {path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": script.len(), "frames": frames.len(), "points": points.len(), "warnings": warnings}))
|
||||
}
|
||||
|
||||
struct Options<'a> {
|
||||
path: &'a str,
|
||||
sensor: f64,
|
||||
units: f64,
|
||||
start: i64,
|
||||
include_points: bool,
|
||||
}
|
||||
|
||||
impl<'a> Options<'a> {
|
||||
fn parse(p: &'a Value) -> Result<Self> {
|
||||
let path = str_p(p, "path").filter(|p| !p.is_empty() && !p.contains('\0')).ok_or_else(|| bad(CMD, "missing `path` (.py)"))?;
|
||||
if !Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("py")) {
|
||||
return Err(bad(CMD, "the export path must end in .py"));
|
||||
}
|
||||
let sensor = number(p, "sensorWidth", 36.0, 1.0, 1000.0)?;
|
||||
let units = number(p, "unitScale", 0.01, 0.000_001, 1000.0)?;
|
||||
let start = match p.get("startFrame") {
|
||||
None => 1,
|
||||
Some(v) => v.as_i64().filter(|n| (1..=MAX_FRAME).contains(n)).ok_or_else(|| bad(CMD, "startFrame: expected a positive Blender frame number"))?,
|
||||
};
|
||||
let include_points = match p.get("includePoints") {
|
||||
Some(v) if !v.is_boolean() => return Err(bad(CMD, "includePoints: expected a boolean")),
|
||||
_ => b_p(p, "includePoints").unwrap_or(true),
|
||||
};
|
||||
Ok(Self { path, sensor, units, start, include_points })
|
||||
}
|
||||
}
|
||||
|
||||
struct Source<'a> {
|
||||
footage: &'a Footage,
|
||||
path: String,
|
||||
fps: f64,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn source<'a>(s: &'a Session, layer: &Layer, solve: &CameraSolve, path: &str) -> Result<Source<'a>> {
|
||||
let footage = match layer.source {
|
||||
LayerSource::Footage { item } => s.project.item(item).and_then(|i| match &i.kind {
|
||||
ItemKind::Footage(f) => Some(f),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|f| f.has_video && matches!(f.kind, FootageKind::Video | FootageKind::Still));
|
||||
let footage = footage
|
||||
.ok_or_else(|| bad(CMD, "Blender background export needs a video or still-footage layer; render precomps/sequences to a video and track that video"))?;
|
||||
let source_path = media_path(s, &footage.path)?;
|
||||
if Path::new(path) == Path::new(&source_path) {
|
||||
return Err(bad(CMD, "the export path must differ from the source footage"));
|
||||
}
|
||||
if footage.width as f64 != solve.size[0] || footage.height as f64 != solve.size[1] {
|
||||
return Err(bad(CMD, "the analysis image differs from the original footage dimensions; render the tracker's input to a video and track that video"));
|
||||
}
|
||||
let source_fps = footage.frame_rate.as_f64();
|
||||
if footage.frame_rate.num <= 0 || footage.frame_rate.den <= 0 || !source_fps.is_finite() || source_fps <= 0.0 {
|
||||
return Err(bad(CMD, "invalid source footage frame rate"));
|
||||
}
|
||||
// Same frame selection as the media pool: interpreted FPS, rounded per-loop frame count,
|
||||
// clamp to the total loop range, then wrap. Native FPS does not change the frame index.
|
||||
let divisor = (effectcraft_time::TICKS_PER_SECOND as i128).saturating_mul(footage.frame_rate.den as i128);
|
||||
let source_count = (footage.duration.0 as i128).saturating_mul(footage.frame_rate.num as i128).saturating_add(divisor / 2) / divisor;
|
||||
let source_count = if footage.kind == FootageKind::Still { 1 } else { source_count };
|
||||
if !(1..=MAX_FRAME as i128).contains(&source_count) {
|
||||
return Err(bad(CMD, "invalid footage duration or footage exceeds Blender's frame range; trim/render the source video first"));
|
||||
}
|
||||
let source_count = source_count as i64;
|
||||
Ok(Source { footage, path: source_path, fps: source_fps, count: source_count })
|
||||
}
|
||||
|
||||
fn scene_warnings(camera: Option<&Layer>, solve: &CameraSolve, footage: &Footage) -> Vec<String> {
|
||||
let mut warnings = vec![
|
||||
"Sensor width is assumed (36 mm by default); monocular tracking cannot recover physical sensor size or absolute scene scale.".to_string(),
|
||||
"The original footage is referenced, not embedded or rendered: masks and effects before the tracker are not baked. Relink the video if moving this script.".to_string(),
|
||||
];
|
||||
if camera.is_none() {
|
||||
warnings
|
||||
.push("No linked tracker camera: focus uses the default scene depth, DOF is off; optical focus cannot be recovered from feature tracks.".into());
|
||||
} else {
|
||||
warnings.push("Focus/iris settings are sampled from the linked camera; pose and lens come from the tracker solve. Blender's physical DOF is an approximation of EffectCraft's blur model.".into());
|
||||
}
|
||||
if solve.distortion.is_some_and(|d| !d.is_none()) {
|
||||
warnings.push("Radial distortion is preserved as metadata (Brown k1/k2, half-diagonal normalization), not applied to Blender's pinhole camera. Use an undistorted plate for exact alignment.".into());
|
||||
}
|
||||
if (footage.pixel_aspect - 1.0).abs() > 1e-6 {
|
||||
warnings.push("The tracker solves square pixels: export uses square pixels; original footage pixel aspect is retained in metadata.".into());
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Bake the solved camera and the linked camera's optical settings at exact composition ticks.
|
||||
fn bake_frames(
|
||||
ctx0: &EvalCtx<'_>,
|
||||
layer: &Layer,
|
||||
solve: &CameraSolve,
|
||||
camera: Option<&Layer>,
|
||||
options: &Options<'_>,
|
||||
footage: &Footage,
|
||||
source_count: i64,
|
||||
) -> Result<(Vec<Value>, linalg::Similarity)> {
|
||||
let comp = ctx0.comp;
|
||||
let lo = ctx0.time;
|
||||
let fd = comp.frame_duration();
|
||||
let sensor = options.sensor;
|
||||
let units = options.units;
|
||||
let start = options.start;
|
||||
let first = solve.frames.first().ok_or_else(|| bad(CMD, "the camera solve is empty"))?;
|
||||
let m = ctx0.world_matrix(layer).0;
|
||||
let layer_scale = m[0][0].hypot(m[1][0]);
|
||||
finite([layer_scale])?;
|
||||
if layer_scale <= 1e-9 {
|
||||
return Err(bad(CMD, "the tracked layer has zero scale"));
|
||||
}
|
||||
let world = solve.world([comp.width as f64, comp.height as f64], first.focal * layer_scale);
|
||||
finite([world.s * units])?;
|
||||
let mut frames = Vec::with_capacity(solve.frames.len());
|
||||
for (i, frame) in solve.frames.iter().enumerate() {
|
||||
let time = Tick(lo.0.saturating_add(fd.0.saturating_mul(i as i64)));
|
||||
let ctx = ctx0.at(time);
|
||||
let pose = linalg::mm(&world.r, &linalg::mt(&frame.r()));
|
||||
let right = position(linalg::mv(&pose, [1.0, 0.0, 0.0]), 1.0);
|
||||
let up = position(linalg::mv(&pose, [0.0, -1.0, 0.0]), 1.0);
|
||||
let back = position(linalg::mv(&pose, [0.0, 0.0, -1.0]), 1.0);
|
||||
let location = position(world.apply(frame.center), units);
|
||||
let lens = frame.focal * sensor / solve.size[0];
|
||||
let opts = camera.and_then(|l| l.props.sub("cameraOptions").map(|g| (l, g)));
|
||||
let value = |key: &str, fallback: f64| opts.map(|(l, g)| ctx.f(l, g, key, fallback)).unwrap_or(fallback);
|
||||
let focus = value("focusDistance", world.s) * units;
|
||||
let aperture = value("aperture", 0.0);
|
||||
let zoom = value("zoom", frame.focal * layer_scale);
|
||||
// Match Camera Settings' f-stop conversion: focal_for_zoom uses a 36 mm film width,
|
||||
// and the aperture UI is mm scaled by comp_width / 1920 (not a world-space diameter).
|
||||
let aperture_mm = aperture * 1920.0 / comp.width as f64;
|
||||
let fstop = if aperture_mm > 0.0 { (zoom * 36.0 / comp.width as f64) / aperture_mm } else { 2.8 };
|
||||
let rotation = value("irisRotation", 0.0).to_radians();
|
||||
let ratio = value("irisAspectRatio", 1.0);
|
||||
let shape = opts.map(|(l, g)| ctx.e(l, g, "irisShape")).unwrap_or(0);
|
||||
let blades = match shape {
|
||||
0 => 4,
|
||||
1..=8 => shape.saturating_add(2),
|
||||
_ => return Err(bad(CMD, "invalid iris shape")),
|
||||
};
|
||||
let dof = opts.is_some_and(|(l, g)| ctx.b(l, g, "dof")) && aperture > 0.0 && value("blurLevel", 100.0) > 0.0;
|
||||
let source_time = ctx.source_time(layer);
|
||||
let source_frame = if footage.kind == FootageKind::Still {
|
||||
0
|
||||
} else {
|
||||
footage.frame_rate.frame_at(source_time).clamp(0, source_count.saturating_mul(footage.loop_count.max(1) as i64).saturating_sub(1)) % source_count
|
||||
};
|
||||
finite(location.into_iter().chain([focus, aperture, zoom, fstop, rotation, ratio, value("blurLevel", 100.0), value("irisRoundness", 0.0)]))?;
|
||||
if focus <= 0.0 || !(0.01..=100.0).contains(&ratio) || !(0.01..=1e6).contains(&fstop) {
|
||||
return Err(bad(CMD, "focus, f-stop or iris aspect is outside Blender's supported range"));
|
||||
}
|
||||
frames.push(json!({"frame": start + i as i64, "timeTicks": time.0, "sourceFrame": source_frame,
|
||||
"location": location, "rotation": [right, up, back], "lens": lens, "focus": focus, "dof": dof,
|
||||
"fstop": fstop, "blades": blades, "irisRotation": rotation, "irisRatio": ratio,
|
||||
"sourceAperture": aperture, "sourceZoom": zoom,
|
||||
"blurLevel": value("blurLevel", 100.0), "irisRoundness": value("irisRoundness", 0.0)}));
|
||||
}
|
||||
Ok((frames, world))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Validate solved geometry and its correspondence to the current layer timing.
|
||||
|
||||
use effectcraft_project::{Comp, Layer};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::camtrack::{CameraSolve, linalg};
|
||||
|
||||
use super::{CMD, MAX_FRAME, MAX_FRAMES, MAX_POINTS, bad, finite};
|
||||
use crate::Result;
|
||||
|
||||
pub(super) fn timing(comp: &Comp, layer: &Layer, solve: &CameraSolve, start: i64) -> Result<(Tick, i64)> {
|
||||
if solve.frames.len() > MAX_FRAMES || solve.points.len() > MAX_POINTS {
|
||||
return Err(bad(CMD, "export is limited to 100,000 frames and 100,000 points; split the shot"));
|
||||
}
|
||||
if solve.size.iter().any(|v| !v.is_finite() || *v < 4.0 || *v > 65_536.0 || v.fract() != 0.0) {
|
||||
return Err(bad(CMD, "invalid tracking resolution (expected 4..65536 whole pixels)"));
|
||||
}
|
||||
let fps = comp.frame_rate.as_f64();
|
||||
if comp.frame_rate.num <= 0 || comp.frame_rate.den <= 0 || !fps.is_finite() || !(1.0..=1000.0).contains(&fps) {
|
||||
return Err(bad(CMD, "invalid composition frame rate (expected 1..1000 fps)"));
|
||||
}
|
||||
let end = start
|
||||
.checked_add(solve.frames.len() as i64)
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.filter(|n| *n <= MAX_FRAME)
|
||||
.ok_or_else(|| bad(CMD, "the baked animation exceeds Blender's frame range"))?;
|
||||
finite([solve.start, solve.frame_duration, solve.average_error, layer.stretch, comp.shutter_angle, comp.shutter_phase])?;
|
||||
if layer.stretch == 0.0 || comp.width == 0 || comp.height == 0 {
|
||||
return Err(bad(CMD, "invalid composition dimensions or zero layer stretch"));
|
||||
}
|
||||
let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO));
|
||||
let hi = layer.out_point.min(comp.duration);
|
||||
let fd = comp.frame_duration();
|
||||
if fd.0 <= 0 || lo >= hi {
|
||||
return Err(bad(CMD, "the tracked layer has no active frames"));
|
||||
}
|
||||
let expected = ((hi.0 as i128 - lo.0 as i128 + fd.0 as i128 - 1) / fd.0 as i128) as u128;
|
||||
if expected != solve.frames.len() as u128 {
|
||||
return Err(bad(CMD, "the solve no longer matches the layer's frame range; re-analyze the tracker"));
|
||||
}
|
||||
let dt = (layer.layer_time(lo + fd) - layer.layer_time(lo)).seconds();
|
||||
if (solve.start - layer.layer_time(lo).seconds()).abs() > 1e-6 || solve.frames.len() > 1 && (solve.frame_duration - dt).abs() > 1e-6 {
|
||||
return Err(bad(CMD, "the solve no longer matches the layer's timing; re-analyze the tracker"));
|
||||
}
|
||||
Ok((lo, end))
|
||||
}
|
||||
|
||||
pub(super) fn geometry(solve: &CameraSolve, sensor: f64) -> Result<()> {
|
||||
for frame in &solve.frames {
|
||||
finite(frame.rot.into_iter().chain(frame.center).chain([frame.focal]))?;
|
||||
if !frame.solved || frame.focal <= 0.0 {
|
||||
return Err(bad(CMD, "the solve contains unsolved frames or invalid focal lengths; re-analyze the tracker"));
|
||||
}
|
||||
let mm = sensor * frame.focal / solve.size[0];
|
||||
if !(1.0..=5000.0).contains(&mm) {
|
||||
return Err(bad(CMD, "lens outside Blender's 1..5000 mm range; choose another sensorWidth"));
|
||||
}
|
||||
}
|
||||
if let Some(ground) = &solve.ground {
|
||||
finite(ground.origin.into_iter().chain(ground.normal).chain(ground.x_axis))?;
|
||||
if (linalg::norm(ground.normal) - 1.0).abs() > 1e-4
|
||||
|| (linalg::norm(ground.x_axis) - 1.0).abs() > 1e-4
|
||||
|| linalg::dot(ground.normal, ground.x_axis).abs() > 1e-4
|
||||
{
|
||||
return Err(bad(CMD, "invalid ground-plane basis; set the ground plane again"));
|
||||
}
|
||||
}
|
||||
if let Some(d) = &solve.distortion {
|
||||
finite([d.k1, d.k2, d.radius])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
# EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0.
|
||||
# Run in Blender's Scripting workspace (Open, Run Script), or:
|
||||
# blender --background --python "Tracked Scene.py"
|
||||
# Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself.
|
||||
# Public API types: bpy.types.Camera, bpy.types.ImageUser
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
DATA = json.loads(__EFFECTCRAFT_DATA__)
|
||||
|
||||
|
||||
def _curves(owner):
|
||||
"""Blender 4.0–4.3 legacy actions and 4.4+/5.x layered actions."""
|
||||
anim = owner.animation_data
|
||||
if anim is None or anim.action is None:
|
||||
return
|
||||
action = anim.action
|
||||
if getattr(action, "is_action_layered", False):
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
if strip.type == 'KEYFRAME':
|
||||
bag = strip.channelbag(anim.action_slot)
|
||||
if bag is not None:
|
||||
yield from bag.fcurves
|
||||
else:
|
||||
yield from action.fcurves
|
||||
|
||||
|
||||
def _interpolation(owner):
|
||||
for curve in _curves(owner):
|
||||
# Footage indices and discrete switches must never interpolate between frames.
|
||||
stepped = ("frame_offset" in curve.data_path or "use_dof" in curve.data_path
|
||||
or "aperture_blades" in curve.data_path)
|
||||
for key in curve.keyframe_points:
|
||||
key.interpolation = 'CONSTANT' if stepped else 'LINEAR'
|
||||
|
||||
|
||||
def _media_path():
|
||||
original = DATA["media"]["path"]
|
||||
candidates = [original]
|
||||
# When sharing, place the video alongside the script or the .blend and run again.
|
||||
script_path = globals().get("__file__", "")
|
||||
if script_path:
|
||||
candidates.append(os.path.join(os.path.dirname(os.path.abspath(script_path)),
|
||||
os.path.basename(original)))
|
||||
if bpy.data.filepath:
|
||||
candidates.append(os.path.join(os.path.dirname(bpy.data.filepath),
|
||||
os.path.basename(original)))
|
||||
return next((p for p in candidates if os.path.isfile(p)), None)
|
||||
|
||||
|
||||
def import_scene():
|
||||
if bpy.app.version < (4, 0, 0):
|
||||
raise RuntimeError("EffectCraft scene import requires Blender 4.0 or newer")
|
||||
scene = bpy.data.scenes.new(DATA["name"])
|
||||
scene.frame_start = DATA["startFrame"]
|
||||
scene.frame_end = DATA["endFrame"]
|
||||
fps = DATA["fpsNumerator"] / DATA["fpsDenominator"]
|
||||
scene.render.fps = max(1, round(fps))
|
||||
scene.render.fps_base = scene.render.fps / fps
|
||||
scene.render.resolution_x, scene.render.resolution_y = map(int, DATA["resolution"])
|
||||
scene.render.resolution_percentage = 100
|
||||
# The tracker assumes square pixels, regardless of the source's interpretation metadata.
|
||||
scene.render.pixel_aspect_x = scene.render.pixel_aspect_y = 1.0
|
||||
scene.render.film_transparent = True
|
||||
scene.render.motion_blur_shutter = max(0.0, min(2.0, DATA["shutter"]))
|
||||
scene.unit_settings.system = 'METRIC'
|
||||
scene.unit_settings.scale_length = 1.0
|
||||
scene["effectcraft_unit_scale"] = DATA["unitScale"]
|
||||
scene["effectcraft_scale_is_arbitrary"] = True
|
||||
scene["effectcraft_shutter_phase_degrees"] = DATA["shutterPhase"]
|
||||
|
||||
collection = bpy.data.collections.new("EffectCraft Tracking")
|
||||
scene.collection.children.link(collection)
|
||||
camera_data = bpy.data.cameras.new("3D Tracker Camera")
|
||||
camera = bpy.data.objects.new("3D Tracker Camera", camera_data)
|
||||
collection.objects.link(camera)
|
||||
scene.camera = camera
|
||||
camera.rotation_mode = 'QUATERNION'
|
||||
camera_data.type = 'PERSP'
|
||||
camera_data.sensor_fit = 'HORIZONTAL'
|
||||
camera_data.sensor_width = DATA["sensorWidth"]
|
||||
camera_data.sensor_height = (DATA["sensorWidth"] * DATA["resolution"][1]
|
||||
/ DATA["resolution"][0])
|
||||
camera_data.show_background_images = True
|
||||
camera_data.show_limits = True
|
||||
extent = max([DATA["groundSize"], 1.0]
|
||||
+ [math.dist(p["location"], f["location"])
|
||||
for p in DATA["points"][::max(1, len(DATA["points"]) // 100)]
|
||||
for f in DATA["frames"][::max(1, len(DATA["frames"]) // 10)]])
|
||||
camera_data.clip_start = max(0.00001, min(0.1, extent / 10000.0))
|
||||
camera_data.clip_end = max(1000.0, extent * 10.0)
|
||||
|
||||
previous = None
|
||||
for sample in DATA["frames"]:
|
||||
frame = sample["frame"]
|
||||
camera.location = sample["location"]
|
||||
# Exported right/up/back vectors are COLUMNS of Blender's local → world basis.
|
||||
quaternion = Matrix(sample["rotation"]).transposed().to_quaternion()
|
||||
if previous is not None and previous.dot(quaternion) < 0:
|
||||
quaternion.negate()
|
||||
camera.rotation_quaternion = quaternion
|
||||
previous = quaternion.copy()
|
||||
camera.keyframe_insert(data_path="location", frame=frame)
|
||||
camera.keyframe_insert(data_path="rotation_quaternion", frame=frame)
|
||||
camera_data.lens = sample["lens"]
|
||||
camera_data.keyframe_insert(data_path="lens", frame=frame)
|
||||
camera_data.dof.use_dof = sample["dof"]
|
||||
camera_data.dof.focus_distance = sample["focus"]
|
||||
camera_data.dof.aperture_fstop = sample["fstop"]
|
||||
camera_data.dof.aperture_blades = sample["blades"]
|
||||
camera_data.dof.aperture_rotation = (sample["irisRotation"] + math.pi) % math.tau - math.pi
|
||||
camera_data.dof.aperture_ratio = sample["irisRatio"]
|
||||
for prop in ("use_dof", "focus_distance", "aperture_fstop", "aperture_blades",
|
||||
"aperture_rotation", "aperture_ratio"):
|
||||
camera_data.keyframe_insert(data_path="dof." + prop, frame=frame)
|
||||
|
||||
path = _media_path()
|
||||
if path is not None:
|
||||
try:
|
||||
image = bpy.data.images.load(path, check_existing=False)
|
||||
image.source = DATA["media"]["kind"]
|
||||
background = camera_data.background_images.new()
|
||||
background.source = 'IMAGE'
|
||||
background.image = image
|
||||
background.alpha = 1.0
|
||||
background.display_depth = 'BACK'
|
||||
background.frame_method = 'STRETCH'
|
||||
user = background.image_user
|
||||
user.use_auto_refresh = True
|
||||
user.use_cyclic = False
|
||||
user.frame_start = DATA["startFrame"]
|
||||
user.frame_duration = max(1, min(1048574, image.frame_duration))
|
||||
if image.source == 'MOVIE':
|
||||
for sample in DATA["frames"]:
|
||||
# Blender uses 1-based image frames; sourceFrame is 0-based.
|
||||
user.frame_offset = sample["sourceFrame"] - (sample["frame"] - user.frame_start)
|
||||
camera_data.keyframe_insert(
|
||||
data_path="background_images[0].image_user.frame_offset",
|
||||
frame=sample["frame"])
|
||||
except (RuntimeError, ValueError, OSError) as error:
|
||||
DATA["warnings"].append("Blender could not load the background video: " + str(error))
|
||||
else:
|
||||
DATA["warnings"].append("Video not found: " + DATA["media"]["path"]
|
||||
+ ". Place it beside this script or relink the camera background.")
|
||||
|
||||
if DATA["points"]:
|
||||
mesh = bpy.data.meshes.new("Solved Track Points")
|
||||
mesh.from_pydata([p["location"] for p in DATA["points"]], [], [])
|
||||
mesh.update()
|
||||
cloud = bpy.data.objects.new("Solved Track Points", mesh)
|
||||
collection.objects.link(cloud)
|
||||
cloud.show_in_front = True
|
||||
cloud.hide_render = True
|
||||
for name, kind, key in (("track_id", 'INT', "id"), ("reprojection_error", 'FLOAT', "error"),
|
||||
("first_frame", 'INT', "first"), ("last_frame", 'INT', "last")):
|
||||
attr = mesh.attributes.new(name=name, type=kind, domain='POINT')
|
||||
for dest, point in zip(attr.data, DATA["points"]):
|
||||
# Track IDs are u32 in EffectCraft; keep the full IDs in the JSON text too.
|
||||
dest.value = point[key] if kind == 'FLOAT' else min(2147483647, point[key])
|
||||
|
||||
if DATA["ground"]:
|
||||
size = DATA["groundSize"]
|
||||
mesh = bpy.data.meshes.new("Solved Ground Plane")
|
||||
mesh.from_pydata([(-size, -size, 0), (size, -size, 0),
|
||||
(size, size, 0), (-size, size, 0)], [], [(0, 1, 2, 3)])
|
||||
mesh.update()
|
||||
ground = bpy.data.objects.new("Solved Ground Plane", mesh)
|
||||
collection.objects.link(ground)
|
||||
ground.display_type = 'WIRE'
|
||||
ground.hide_render = True
|
||||
for marker in DATA["markers"]:
|
||||
scene.timeline_markers.new(marker["name"], frame=marker["frame"])
|
||||
|
||||
_interpolation(camera)
|
||||
_interpolation(camera_data)
|
||||
metadata = bpy.data.texts.new("EffectCraft Tracking Metadata.json")
|
||||
metadata.write(json.dumps(DATA, ensure_ascii=False, indent=2))
|
||||
scene["effectcraft_metadata_text"] = metadata.name
|
||||
camera_data["effectcraft_average_error_pixels"] = DATA["solve"]["averageError"]
|
||||
camera_data["effectcraft_solve_method"] = DATA["solve"]["methodUsed"]
|
||||
camera_data["effectcraft_focus_source"] = "See warnings in " + metadata.name
|
||||
distortion = DATA["solve"].get("distortion")
|
||||
if distortion:
|
||||
for key in ("k1", "k2", "radius"):
|
||||
camera_data["effectcraft_distortion_" + key] = distortion[key]
|
||||
if bpy.context.window is not None:
|
||||
bpy.context.window.scene = scene
|
||||
for area in bpy.context.window.screen.areas:
|
||||
if area.type == 'VIEW_3D':
|
||||
area.spaces.active.region_3d.view_perspective = 'CAMERA'
|
||||
scene.frame_set(scene.frame_start)
|
||||
for warning in DATA["warnings"]:
|
||||
print("EffectCraft: " + warning)
|
||||
print("EffectCraft: imported", len(DATA["frames"]), "baked camera frames into", scene.name)
|
||||
return scene
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
EFFECTCRAFT_SCENE = import_scene()
|
||||
@@ -42,11 +42,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
create_from_solve
|
||||
),
|
||||
cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))),
|
||||
cmd!(
|
||||
"camera.exportBlender",
|
||||
"Export Scene to Blender...",
|
||||
[],
|
||||
None,
|
||||
"{path: .py, comp?, layer?, effect?, sensorWidth?: mm (default 36), unitScale?: Blender units per comp pixel (default 0.01), startFrame?: positive integer (default 1), includePoints?: true}",
|
||||
has_comp,
|
||||
export_blender
|
||||
),
|
||||
cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground),
|
||||
cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points),
|
||||
]
|
||||
}
|
||||
|
||||
fn export_blender(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = ct_p(s, p, "camera.exportBlender")?;
|
||||
super::blender::export(s, p, cid, lid, uid)
|
||||
}
|
||||
|
||||
fn with_kind(p: &Value, kind: &str) -> Value {
|
||||
let mut q = p.clone();
|
||||
if !q.is_object() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use effectcraft_keyframe::{ShapePath, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer_menu::target_masks;
|
||||
@@ -111,18 +112,30 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The parametric shape paths that Convert To Bezier Path replaces with an editable path.
|
||||
const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"];
|
||||
|
||||
/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the
|
||||
/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted
|
||||
/// path is the shape that was on screen rather than an approximation of it; the defaults match
|
||||
/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness).
|
||||
fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePath> {
|
||||
let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64());
|
||||
let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2());
|
||||
/// The path a parametric shape group draws, in the group's own space. This is the geometry the
|
||||
/// renderer builds from the same values (`render::shapes::collect`), expressions included, so the
|
||||
/// converted path is the shape that was on screen rather than an approximation of it; the defaults
|
||||
/// match that code for a property a group doesn't carry (a polygon has no inner radius or
|
||||
/// roundness).
|
||||
fn parametric_path(ctx: &EvalCtx, layer: &effectcraft_project::Layer, g: &PropGroup) -> Option<ShapePath> {
|
||||
let f = |id: &str, d: f64| ctx.f(layer, g, id, d);
|
||||
let v2 = |id: &str, d: [f64; 2]| ctx.v2(layer, g, id, d);
|
||||
let path = match g.match_id.as_str() {
|
||||
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
|
||||
"rect" => {
|
||||
let (size, pos) = (v2("size", [100.0; 2]), v2("position", [0.0; 2]));
|
||||
if ctx.b(layer, g, "perCorner") {
|
||||
effectcraft_path::rect_radii(
|
||||
size,
|
||||
pos,
|
||||
[f("topLeftRoundness", 0.0), f("topRightRoundness", 0.0), f("bottomRightRoundness", 0.0), f("bottomLeftRoundness", 0.0)],
|
||||
)
|
||||
} else {
|
||||
effectcraft_path::rect(size, pos, f("roundness", 0.0))
|
||||
}
|
||||
}
|
||||
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
|
||||
"star" => effectcraft_path::polystar(effectcraft_path::Polystar {
|
||||
star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
|
||||
star: ctx.e(layer, g, "type") == 0,
|
||||
points: f("points", 5.0),
|
||||
pos: v2("position", [0.0; 2]),
|
||||
rotation: f("rotation", 0.0),
|
||||
@@ -160,28 +173,38 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let t = s.time();
|
||||
let n = shapes.len();
|
||||
// Read the geometry before editing, with the same expression-aware context the renderer
|
||||
// uses: an expression-driven corner radius, size or position must convert to what is on
|
||||
// screen, not to the property's underlying value.
|
||||
let prepared = {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
|
||||
let mut prepared = Vec::new();
|
||||
for (lid, uid) in &shapes {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
let Some(old) = l.props.sub("contents").and_then(|c| c.find_group(*uid)) else { continue };
|
||||
let Some(path) = parametric_path(&ctx, l, old) else { continue };
|
||||
// Path Direction is the shape's own, not a default: reversing is a separate command.
|
||||
prepared.push((*lid, *uid, path, ctx.e(l, old, "direction"), old.enabled));
|
||||
}
|
||||
prepared
|
||||
};
|
||||
let made = s.edit("Convert To Bezier Path", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let mut made = Vec::new();
|
||||
for (lid, uid) in &shapes {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
for (lid, uid, path, direction, enabled) in prepared {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?;
|
||||
let Some(old) = contents.find_group(*uid) else { continue };
|
||||
let Some(path) = parametric_path(old, lt) else { continue };
|
||||
// Path Direction is the shape's own, not a default: reversing is a separate command.
|
||||
let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum());
|
||||
let enabled = old.enabled;
|
||||
let mut g = build::shape_path(&mut Ids(&mut next), path);
|
||||
if let Some(pr) = g.prop_mut("direction") {
|
||||
pr.value = KV::Enum(direction);
|
||||
}
|
||||
g.enabled = enabled;
|
||||
let new_uid = g.uid;
|
||||
let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
|
||||
let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
|
||||
let parent = contents.parent_of_mut(uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
|
||||
let Some(at) = parent.children.iter().position(|c| c.uid() == uid) else { continue };
|
||||
parent.children[at] = Node::Group(g);
|
||||
made.push((*lid, new_uid));
|
||||
made.push((lid, new_uid));
|
||||
}
|
||||
proj.next_id = next;
|
||||
// The new paths are what is selected, so the vertices can be edited straight away.
|
||||
|
||||
@@ -581,6 +581,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
scriptui::mouse
|
||||
),
|
||||
cmd!(
|
||||
"scriptui.resize",
|
||||
"Resize Script Window",
|
||||
[],
|
||||
None,
|
||||
"{window?, size: [width, height] (pixels)} → the window takes the size, lays itself out again and its onResizing / onResize run (a docked ScriptUI panel gets its dock's size)",
|
||||
always,
|
||||
scriptui::resize
|
||||
),
|
||||
cmd!(
|
||||
"scriptui.close",
|
||||
"Close Script Window",
|
||||
|
||||
Reference in New Issue
Block a user