Files
blender-portable-repo/scripts/addons/MARI_Advanced/__init__.py
T
2026-04-20 11:57:06 -05:00

6830 lines
269 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
bl_info = {
"name": "MARI Advanced",
"author": "HP Park",
"version": (1, 0),
"blender": (3, 0),
"location": "View3D > SidePanel > MARI",
"description": "MARI Advanced",
"warning": "",
"doc_url": "https://holomari.com/info/pages/Blender",
"category": "Render",
}
# Build marker to verify the active code at runtime (printed once).
MARI_BUILD_TAG = "sandbox-temp-render-v1"
import glob
import blf
import bpy
import os
import re
import sys
from mathutils import Vector, Euler, Matrix
from mathutils.bvhtree import BVHTree
from bpy.app.handlers import persistent
from bpy.props import *
from bpy.types import Operator, Menu, WindowManager, Panel, AddonPreferences, PropertyGroup
import bmesh
import math
from bpy_extras.object_utils import world_to_camera_view
from bpy_extras.io_utils import ImportHelper
import numpy as np
import json
import shutil
import uuid
# Optional dependency: allow the addon to load without cv2 so Preferences stay available.
_MARI_HAS_CV2 = False
_MARI_CV2_IMPORT_ERROR = None
cv2 = None
def _mari_cv2_deps_dir():
try:
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "_mari_pydeps")
except Exception:
return ""
def _mari_cv2_candidate_paths():
paths = []
dep = _mari_cv2_deps_dir()
if dep:
paths.append(dep)
try:
import site
user_site = site.getusersitepackages()
if isinstance(user_site, (list, tuple)):
for p in user_site:
if p:
paths.append(p)
elif user_site:
paths.append(user_site)
except Exception:
pass
uniq = []
seen = set()
for p in paths:
norm = os.path.normcase(os.path.normpath(str(p)))
if norm in seen:
continue
seen.add(norm)
uniq.append(str(p))
return uniq
def _mari_ensure_cv2_paths_on_syspath():
try:
for p in _mari_cv2_candidate_paths():
if p and p not in sys.path:
sys.path.insert(0, p)
except Exception:
pass
def _mari_try_import_cv2():
"""Attempt to import cv2; return True if available."""
global cv2, _MARI_HAS_CV2, _MARI_CV2_IMPORT_ERROR
_mari_ensure_cv2_paths_on_syspath()
try:
import cv2 as _cv2
cv2 = _cv2
_MARI_HAS_CV2 = True
_MARI_CV2_IMPORT_ERROR = None
return True
except Exception as exc:
cv2 = None
_MARI_HAS_CV2 = False
_MARI_CV2_IMPORT_ERROR = str(exc)
return False
_mari_try_import_cv2()
import subprocess
print(sys.version)
def _mari_cv2_admin_hint():
base = "Installer uses addon-local/user-site paths first (admin is usually not required)."
if sys.platform.startswith("win"):
return base + " If blocked by system policy, reopen Blender as Administrator and retry."
if sys.platform == "darwin":
return base + " If blocked by system policy, rerun Blender with elevated privileges and retry."
return base + " If blocked by system policy, rerun Blender with elevated privileges and retry."
def _mari_cv2_install_instructions():
return (
"cv2 (opencv-python) is required for FRAME rendering. "
"Open Blender Preferences > Add-ons > MARI Advanced > Install/Repair cv2. "
+ _mari_cv2_admin_hint()
)
def _mari_require_cv2_for_frame(operator=None):
if not (_MARI_HAS_CV2 and cv2 is not None):
_mari_try_import_cv2()
if _MARI_HAS_CV2 and cv2 is not None:
return True
msg = _mari_cv2_install_instructions()
if operator is not None:
try:
operator.report({'ERROR'}, msg)
except Exception:
pass
print(f"[MARI] {msg}")
if _MARI_CV2_IMPORT_ERROR:
print(f"[MARI] cv2 import error: {_MARI_CV2_IMPORT_ERROR}")
return False
addon_prefix = "mari"
_MARI_MODE_FRAME = "FRAME"
_MARI_MODE_CIRCLE = "CRICLE" # Enum identifier kept for backward compatibility.
# --- HoloMARI Multi-Instance integration helpers ---
import addon_utils
def _mari_find_multirender_module():
"""Return the module name for the Multi-Render add-on, or None if not present."""
try:
for m in addon_utils.modules():
bi = getattr(m, "bl_info", {}) or {}
nm = (bi.get("name") or "").lower()
if (
"multi-headless instance renderer" in nm
or "multi-gpu frames" in nm
or "multi-render" in nm
or "multirender" in nm
):
return m.__name__
except Exception:
pass
return None
def _mari_has_multi():
mod = _mari_find_multirender_module()
if not mod:
return False
try:
enabled, _loaded = addon_utils.check(mod)
return bool(enabled)
except Exception:
return False
def _mari_normalize_mode_id(value, fallback=_MARI_MODE_CIRCLE):
key = str(value or "").upper()
if key == _MARI_MODE_FRAME:
return _MARI_MODE_FRAME
if key in {_MARI_MODE_CIRCLE, "CIRCLE"}:
return _MARI_MODE_CIRCLE
return fallback
def _mari_scene_mode_from_objects(scene, fallback=_MARI_MODE_CIRCLE):
if not scene:
return fallback
has_frame = False
has_circle = False
frame_parented = 0
circle_parented = 0
try:
has_frame = scene.objects.get("MARI_FrameCenter") is not None
except Exception:
has_frame = any(obj.name == "MARI_FrameCenter" for obj in scene.objects)
try:
has_circle = scene.objects.get("MARI_CircleCenter") is not None
except Exception:
has_circle = any(obj.name == "MARI_CircleCenter" for obj in scene.objects)
try:
for obj in scene.objects:
if not obj.name.startswith("MARI_CAMERA"):
continue
parent_name = getattr(getattr(obj, "parent", None), "name", "")
if parent_name == "MARI_FrameCenter":
frame_parented += 1
elif parent_name == "MARI_CircleCenter":
circle_parented += 1
except Exception:
pass
if has_frame and not has_circle:
return _MARI_MODE_FRAME
if has_circle and not has_frame:
return _MARI_MODE_CIRCLE
if has_frame and has_circle:
if circle_parented > frame_parented:
return _MARI_MODE_CIRCLE
return _MARI_MODE_FRAME
return fallback
def _mari_sync_mode_for_all_scenes():
try:
scenes = list(getattr(bpy.data, "scenes", []) or [])
except Exception:
scenes = []
for scene in scenes:
try:
mari_detect_mode_from_scene(scene)
except Exception:
pass
def _mari_mode_sync_timer():
_mari_sync_mode_for_all_scenes()
return None
def _mari_schedule_mode_sync(delay=0.0):
try:
bpy.app.timers.unregister(_mari_mode_sync_timer)
except Exception:
pass
try:
bpy.app.timers.register(_mari_mode_sync_timer, first_interval=max(0.0, float(delay)))
except Exception:
pass
handle = None
class mari:
generated = False
last_radius = 5
cameras = []
next_cam_index = 0
did_focal = False
res_x = 0
res_y = 0
render_action = "STILL"
is_normalized = False
planes = []
data = {}
area = None
context = None
og_type = None
c = 0
cam_num = 0
is_txt_on = 0
ffmpeg_format = None
ffmpeg_codec = None
ffmpeg_color_mode = None
try:
mari.cancel_all
except AttributeError:
mari.cancel_all = False
try:
mari._prev_render_display_holder
except AttributeError:
mari._prev_render_display_holder = (None, None, None) # (obj, attr_name, prev_value)
def _mari__get_display_target():
"""
Detect which render-display setting exists and return (obj, attr_name, allowed_set).
A) prefs.view.render_display_type -> {'NONE','SCREEN','AREA','WINDOW'}
- 'SCREEN' shows the Render View inside Blender's main window (no OS pop-up).
- 'AREA' also keeps it in-UI but depends on the active area.
B) scene.render.display_mode -> {'IMAGE_EDITOR','SCREEN','WINDOW','KEEP_UI'}
- 'SCREEN' or 'IMAGE_EDITOR' are both in-UI (no OS pop-up).
"""
prefs_view = getattr(bpy.context.preferences, "view", None)
if prefs_view and hasattr(prefs_view, "render_display_type"):
allowed = {'NONE', 'SCREEN', 'AREA', 'WINDOW'}
return (prefs_view, "render_display_type", allowed)
sr = getattr(bpy.context.scene, "render", None)
if sr and hasattr(sr, "display_mode"):
allowed = {'IMAGE_EDITOR', 'SCREEN', 'WINDOW', 'KEEP_UI'}
return (sr, "display_mode", allowed)
return (None, None, set())
def _mari_unlock_interface():
"""
Ensure Blender is allowed to update the UI while rendering.
Some builds expose scene.render.use_lock_interface; some place it in preferences.system.
"""
try:
bpy.context.scene.render.use_lock_interface = False
except Exception:
pass
try:
# legacy / alternate location on some versions
bpy.context.preferences.system.use_lock_interface = False
except Exception:
pass
def _mari_set_render_display_for_preview():
"""
Force renders to use the separate OS pop-up window (same as F12).
We save the user's old setting and restore it later.
"""
holder, attr, allowed = _mari__get_display_target()
if holder is None:
mari._prev_render_display_holder = (None, None, None)
return
prev = getattr(holder, attr)
mari._prev_render_display_holder = (holder, attr, prev)
# Prefer OS pop-up window
if 'WINDOW' in allowed:
setattr(holder, attr, 'WINDOW')
elif 'SCREEN' in allowed:
# Fallback (older builds). Still shows a separate render "Screen".
setattr(holder, attr, 'SCREEN')
_mari_unlock_interface()
def _mari_restore_render_display():
"""Restore the user's render display preference if changed."""
holder, attr, prev = mari._prev_render_display_holder
if holder is not None and attr is not None:
try:
setattr(holder, attr, prev)
except Exception:
pass
mari._prev_render_display_holder = (None, None, None)
def _mari_activate_object(obj):
"""Make 'obj' the active object even in background/headless sessions."""
if obj is None:
return False
try:
view_layer = bpy.context.view_layer
except Exception:
return False
try:
for other in view_layer.objects:
other.select_set(False)
except Exception:
pass
try:
obj.select_set(True)
except Exception:
pass
try:
view_layer.objects.active = obj
return True
except Exception:
return False
def _mari_new_plane(name="MARI_TempFramePlane"):
"""Create a simple plane mesh without relying on 3D View operators."""
try:
mesh = bpy.data.meshes.new(name)
obj = bpy.data.objects.new(name, mesh)
verts = [(-0.5, -0.5, 0.0), (0.5, -0.5, 0.0), (0.5, 0.5, 0.0), (-0.5, 0.5, 0.0)]
mesh.from_pydata(verts, [], [(0, 1, 2, 3)])
mesh.update(calc_edges=True)
collection = bpy.context.scene.collection
collection.objects.link(obj)
return obj
except Exception:
return None
def _mari_get_collection(scene, name):
if scene is None:
return None
coll = bpy.data.collections.get(name)
if coll is None:
coll = bpy.data.collections.new(name)
if coll.name not in [c.name for c in scene.collection.children]:
scene.collection.children.link(coll)
return coll
def _mari_link_object_to_collection(obj, coll):
if obj is None or coll is None:
return
if obj.name not in coll.objects:
coll.objects.link(obj)
for other in list(obj.users_collection):
if other != coll:
try:
other.objects.unlink(obj)
except Exception:
pass
def _mari_cleanup_temp_collection(scene):
coll = bpy.data.collections.get("MARI_Cameras_Temp")
if coll is None:
return
target = _mari_get_collection(scene, "MARI_Cameras")
for obj in list(coll.objects):
_mari_link_object_to_collection(obj, target)
try:
coll.objects.unlink(obj)
except Exception:
pass
for scn in bpy.data.scenes:
try:
if coll in scn.collection.children:
scn.collection.children.unlink(coll)
except Exception:
pass
for parent in bpy.data.collections:
try:
if coll in parent.children:
parent.children.unlink(coll)
except Exception:
pass
try:
bpy.data.collections.remove(coll)
except Exception:
pass
def _mari_purge_orphan_mari_camera_data():
for cam_data in list(bpy.data.cameras):
try:
if cam_data.name.startswith("MARI_Camera") and cam_data.users == 0:
bpy.data.cameras.remove(cam_data)
except Exception:
pass
_MARI_VIDEO_FORMATS = {"FFMPEG", "AVI_JPEG", "AVI_RAW", "FRAME_SERVER"}
_MARI_IMAGE_EXTENSIONS = {
"jpeg": "jpeg",
"jpeg_2000": "jpeg",
"iris": "rgb",
"targa": "tga",
"targa_raw": "tga",
"cineon": "cin",
"open_exr": "exr",
"open_exr_multilayer": "exr",
"tiff": "tif",
"avi_jpeg": "avi",
"avi_raw": "avi",
"png": "png",
"bmp": "bmp",
}
_MARI_FFMPEG_EXTENSIONS = {
"MPEG1": "mpeg1",
"MPEG2": "mpeg2",
"MPEG4": "mp4",
"AVI": "avi",
"QUICKTIME": "mov",
"DV": "dv",
"OGG": "ogg",
"MKV": "mkv",
"FLASH": "flv",
"WEBM": "webm",
}
def _mari_ff_type_from_format(fmt: str) -> str:
fmt_upper = (fmt or "").upper()
return "ANIM" if fmt_upper in _MARI_VIDEO_FORMATS else "STILL"
def _mari_video_extension(scene=None) -> str:
scene = scene or bpy.context.scene
ffmpeg = getattr(scene.render, "ffmpeg", None)
fmt = getattr(ffmpeg, "format", "MKV") if ffmpeg else "MKV"
return _MARI_FFMPEG_EXTENSIONS.get(fmt, "mkv").lower()
def _mari_extension_from_format(fmt: str, scene=None) -> str:
fmt_lower = (fmt or "").lower()
if fmt_lower == "ffmpeg":
return _mari_video_extension(scene)
return _MARI_IMAGE_EXTENSIONS.get(fmt_lower, fmt_lower)
def _mari_current_extension(scene=None) -> str:
scene = scene or bpy.context.scene
fmt = getattr(scene.render.image_settings, "file_format", "PNG")
return _mari_extension_from_format(fmt, scene)
def _mari_current_ff_type(scene=None) -> str:
scene = scene or bpy.context.scene
fmt = getattr(scene.render.image_settings, "file_format", "PNG")
return _mari_ff_type_from_format(fmt)
# --- PERSPFLAG helpers: one per H#_V# (and H#_V#_TEMP) folder ---
def _persp_flag_path(dirpath: str) -> str:
"""
Return the path to the PERSPFLAG marker for a given directory.
One PERSPFLAG.txt per H#_V# (and H#_V#_TEMP) folder.
"""
return os.path.join(dirpath, "PERSPFLAG.txt")
def _ensure_persp_flag(dirpath: str) -> None:
"""
Create PERSPFLAG.txt in this directory if it does not exist.
Used when we BEGIN rendering frames that still need cv2 warp.
"""
try:
os.makedirs(dirpath, exist_ok=True)
flag_path = _persp_flag_path(dirpath)
if not os.path.exists(flag_path):
with open(flag_path, "w", encoding="utf-8") as f:
f.write("perspective warp pending\n")
except Exception as e:
print(f"[MARI] WARN: could not create PERSPFLAG in {dirpath}: {e}")
def _clear_persp_flag(dirpath: str) -> None:
"""
Remove PERSPFLAG.txt for this directory once the cv2 warp has
successfully finished on all frames in that folder.
"""
try:
flag_path = _persp_flag_path(dirpath)
if os.path.exists(flag_path):
os.remove(flag_path)
except Exception as e:
print(f"[MARI] WARN: could not remove PERSPFLAG in {dirpath}: {e}")
def _mari_write_media_zip(prop):
"""Zip the current render directory when 'Save as MARI Media' is enabled."""
try:
if not getattr(prop, "mari_save_media", False):
return
base = bpy.path.abspath(getattr(prop, "render_settings_filepath", ""))
name = getattr(prop, "render_settings_name", "").strip()
if not (base and name):
return
root_dir = os.path.join(base, name)
if not os.path.isdir(root_dir):
print(f"[MARI] WARN: cannot create media zip; missing folder {root_dir}")
return
# Zip lives one level above the render folder, alongside it.
zip_path = os.path.join(base, f"{name}.zip")
from zipfile import ZipFile, ZIP_DEFLATED
with ZipFile(zip_path, 'w', ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(root_dir):
for file in files:
full = os.path.join(root, file)
# Avoid adding the zip into itself if it already exists.
if os.path.normpath(full) == os.path.normpath(zip_path):
continue
rel = os.path.relpath(full, start=root_dir)
# Keep the project folder at the archive root.
arcname = os.path.join(name, rel)
zipf.write(full, arcname=arcname)
print(f"[MARI] Wrote media zip: {zip_path}")
except Exception as e:
print(f"[MARI] WARN: failed to create media zip: {e}")
def _mari_poly_area_2d(points):
"""Helper to calc 2D area for normalization."""
area = 0.0
for i in range(len(points)):
j = (i + 1) % len(points)
area += points[i][0] * points[j][1]
area -= points[j][0] * points[i][1]
return abs(area) / 2.0
def _mari_order_quad(points):
"""Legacy ordering: Swap indices 2 and 3."""
pts = [list(p) for p in points]
if len(pts) >= 4:
pts = pts[:4]
pts[2], pts[3] = pts[3], pts[2]
return pts
def _mari_frame_local_corners(prop):
"""Return deterministic FRAME corners in local space as [BL, BR, TL, TR]."""
half_w = float(prop.frame_dimensions[0]) * 0.5
half_h = float(prop.frame_dimensions[1]) * 0.5
return [
Vector((-half_w, 0.0, -half_h)),
Vector((half_w, 0.0, -half_h)),
Vector((-half_w, 0.0, half_h)),
Vector((half_w, 0.0, half_h)),
]
def _mari_frame_world_matrix(prop):
"""Match the original built-in FRAME helper orientation exactly."""
rx, ry, rz = prop.frame_rotation
return (
Matrix.Translation(Vector(prop.frame_center))
@ Euler((-rx, -ry, rz + math.pi)).to_matrix().to_4x4()
)
def _mari_frame_display_normal(prop):
"""
Return the display-facing side of the FRAME plane in world space.
The historical MARI output convention matches the +Y side of the local frame plane.
"""
normal = _mari_frame_world_matrix(prop).to_3x3() @ Vector((0.0, 1.0, 0.0))
if normal.length == 0.0:
return Vector((0.0, 1.0, 0.0))
return normal.normalized()
def _mari_project_frame_local_point(scene, cam, prop, real_x, real_y, local_point):
"""Project one local-space FRAME point into image-space pixel coordinates."""
co_world = _mari_frame_world_matrix(prop) @ local_point
co_ndc = world_to_camera_view(scene, cam, co_world)
if not (0.0 < co_ndc.x < 1.0 and 0.0 < co_ndc.y < 1.0):
return None
return [real_x * co_ndc.x, real_y - (real_y * co_ndc.y)]
def _mari_frame_probe_local_points(prop):
"""
Probe points along the local horizontal and vertical axes.
These let us verify the actual homography orientation instead of trusting corner winding.
"""
half_w = float(prop.frame_dimensions[0]) * 0.5
half_h = float(prop.frame_dimensions[1]) * 0.5
px = max(half_w * 0.35, 1e-4)
pz = max(half_h * 0.35, 1e-4)
return {
"left": Vector((-px, 0.0, 0.0)),
"right": Vector((px, 0.0, 0.0)),
"bottom": Vector((0.0, 0.0, -pz)),
"top": Vector((0.0, 0.0, pz)),
}
def _mari_warp_destination_quad(real_x, real_y, flip_x=False, flip_y=False):
"""Destination quad for [BL, BR, TL, TR] with optional axis flips baked in."""
x_l = float(real_x) if flip_x else 0.0
x_r = 0.0 if flip_x else float(real_x)
y_t = float(real_y) if flip_y else 0.0
y_b = 0.0 if flip_y else float(real_y)
return np.array([
[x_l, y_b], # BL
[x_r, y_b], # BR
[x_l, y_t], # TL
[x_r, y_t], # TR
], dtype=np.float32)
def _mari_frame_expected_x_positive_to_right(cam, prop):
"""
Keep the output aligned to the same physical display side of the FRAME.
Viewing the opposite side reverses handedness, so only then does +X move right.
"""
try:
cam_vec = Vector(cam.matrix_world.translation) - Vector(prop.frame_center)
if cam_vec.length == 0.0:
return False
cam_vec.normalize()
return _mari_frame_display_normal(prop).dot(cam_vec) < 0.0
except Exception:
return False
def _mari_frame_warp_flip_flags(scene, cam, prop, quad, real_x, real_y):
"""
Decide the final horizontal/vertical flips from projected frame-local probe points.
This makes the output orientation independent of camera pose and frame rotation.
"""
try:
src = np.array(quad, dtype=np.float32).reshape(4, 2)
base_dst = _mari_warp_destination_quad(real_x, real_y, flip_x=False, flip_y=False)
M = cv2.getPerspectiveTransform(src, base_dst)
probes = _mari_frame_probe_local_points(prop)
projected = {}
for key, local_point in probes.items():
img_pt = _mari_project_frame_local_point(scene, cam, prop, real_x, real_y, local_point)
if img_pt is None:
return False, False
arr = np.array([[img_pt]], dtype=np.float32)
projected[key] = cv2.perspectiveTransform(arr, M)[0][0]
x_positive_to_right = float(projected["right"][0]) > float(projected["left"][0])
z_positive_to_up = float(projected["top"][1]) < float(projected["bottom"][1])
desired_x_positive_to_right = _mari_frame_expected_x_positive_to_right(cam, prop)
desired_z_positive_to_up = True
return (
x_positive_to_right != desired_x_positive_to_right,
z_positive_to_up != desired_z_positive_to_up,
)
except Exception:
return False, False
def _mari_project_frame_quad(scene, cam, prop, real_x, real_y):
"""
Project FRAME corners for one camera using deterministic local corner identities.
Output order is always [BL, BR, TL, TR] in image space.
"""
world_matrix = _mari_frame_world_matrix(prop)
quad = []
for local_corner in _mari_frame_local_corners(prop):
co_world = world_matrix @ local_corner
co_ndc = world_to_camera_view(scene, cam, co_world)
if not (0.0 < co_ndc.x < 1.0 and 0.0 < co_ndc.y < 1.0):
return quad
quad.append([real_x * co_ndc.x, real_y - (real_y * co_ndc.y)])
return quad
def _mari_quad_area_bl_br_tl_tr(points):
"""Area helper for quads stored in [BL, BR, TL, TR] order."""
if not points or len(points) < 4:
return 0.0
cyc = [points[0], points[1], points[3], points[2]]
return _mari_poly_area_2d(cyc)
def _mari_quad_valid(points, min_area=1.0):
if not points or len(points) < 4:
return False
try:
pts = [[float(p[0]), float(p[1])] for p in points[:4]]
except Exception:
return False
uniq = {(round(x, 6), round(y, 6)) for x, y in pts}
if len(uniq) < 4:
return False
return _mari_poly_area_2d(pts) > float(min_area)
def _mari_frame_border_message(camera_name=None):
cam_txt = f" for camera {camera_name}" if camera_name else ""
return (
f"The FRAME exceeds the camera borders{cam_txt}. Regenerate the FRAME. "
"Only use the add-on UI to change the Frame Translation/Rotation; "
"do not move or rotate the FRAME manually in the viewport."
)
def _mari_stabilize_quad_bl_br_tl_tr(points):
"""
Normalize any 4-corner quad into [BL, BR, TL, TR] in image-space coordinates.
This guards against platform-dependent vertex/corner ordering differences.
"""
try:
import numpy as np
pts = np.array(points[:4], dtype=np.float32).reshape(4, 2)
s = pts.sum(axis=1)
d = (pts[:, 1] - pts[:, 0])
tl = pts[int(np.argmin(s))]
br = pts[int(np.argmax(s))]
tr = pts[int(np.argmin(d))]
bl = pts[int(np.argmax(d))]
out = [[float(bl[0]), float(bl[1])],
[float(br[0]), float(br[1])],
[float(tl[0]), float(tl[1])],
[float(tr[0]), float(tr[1])]]
if not _mari_quad_valid(out):
return points
return out
except Exception:
return points
def _mari_refresh_frame_quads(context, prop, real_x, real_y):
"""
Recompute cam["verts"] from deterministic FRAME local corners.
Stored order is always [BL, BR, TL, TR] at the frame-ratio resolution.
"""
scene = context.scene
try:
for cam in bpy.data.objects:
if not cam.name.startswith("MARI_CAMERA"):
continue
quad = _mari_project_frame_quad(scene, cam, prop, real_x, real_y)
cam["verts"] = quad
cam["area"] = _mari_quad_area_bl_br_tl_tr(quad)
except Exception as exc:
return False, f"Failed to project FRAME corners: {exc}"
return True, ""
def _mari_cam_quad(cam, scn, prop):
"""
Retrieve the stored FRAME quad and scale it to the active render resolution.
"""
verts = cam.get("verts") or []
if len(verts) < 4:
return None
real_x = int(prop.frame_ratio[0])
real_y = int(prop.frame_ratio[1])
arr = []
for i in range(4):
vx, vy = verts[i]
if getattr(prop, "render_settings_normalize", False):
sx = scn.render.resolution_x / float(real_x)
sy = scn.render.resolution_y / float(real_y)
arr.append([vx * sx, vy * sy])
else:
pct = scn.render.resolution_percentage / 100.0
arr.append([vx * pct, vy * pct])
return arr
def _mari_prepare_frame_quad(cam, scn, prop):
quad = _mari_cam_quad(cam, scn, prop)
if not quad or len(quad) < 4:
return None
return quad
def _mari_warp_image(img, quad, real_x, real_y, flip=False, flip_y=False):
"""
Warp image using standard cv2.
flip: horizontal flip.
flip_y: vertical flip.
"""
import cv2
import numpy as np
if img is None: return img
quad_np = np.array(quad, dtype=np.float32).reshape(-1, 2)
dst = _mari_warp_destination_quad(real_x, real_y, flip_x=flip, flip_y=flip_y)
try:
M = cv2.getPerspectiveTransform(quad_np, dst)
warped = cv2.warpPerspective(img, M, (int(real_x), int(real_y)))
return warped
except Exception:
return img
def _mari_capture_render_settings(scene):
"""Snapshot key render settings so we can safely restore them later."""
rd = scene.render
return {
"filepath": rd.filepath,
"res_x": rd.resolution_x,
"res_y": rd.resolution_y,
"res_pct": rd.resolution_percentage,
}
def _mari_restore_render_settings(scene, state):
"""Restore settings captured via _mari_capture_render_settings."""
rd = scene.render
try:
rd.filepath = state.get("filepath", rd.filepath)
except Exception:
pass
try:
rd.resolution_x = state.get("res_x", rd.resolution_x)
rd.resolution_y = state.get("res_y", rd.resolution_y)
rd.resolution_percentage = state.get("res_pct", rd.resolution_percentage)
except Exception:
pass
def _mari_apply_frame_resolution(scene, prop):
"""
Align the scene resolution to the FRAME ratio exactly like the main
MARI pipeline: bake in the percentage and set percentage to 100.
Returns a snapshot suitable for _mari_restore_render_settings.
"""
state = _mari_capture_render_settings(scene)
try:
real_x = int(prop.frame_ratio[0])
real_y = int(prop.frame_ratio[1])
pct = scene.render.resolution_percentage
scene.render.resolution_x = int(real_x * pct / 100)
scene.render.resolution_y = int(real_y * pct / 100)
scene.render.resolution_percentage = 100
except Exception:
pass
return state
def _mari_output_path_set(prop):
try:
return bool((prop.render_settings_filepath or "").strip())
except Exception:
return False
def _mari_default_prop_value(prop_name, fallback):
try:
return MARI_Props.bl_rna.properties[prop_name].default
except Exception:
try:
return bpy.types.Scene.mari_props.bl_rna.properties[prop_name].default
except Exception:
return fallback
def _mari_is_valid_render_output(path):
try:
size_bytes = os.path.getsize(path)
if size_bytes <= 0:
return False
except Exception:
return False
ext = os.path.splitext(path)[1].lower().lstrip(".")
if ext in _MARI_IMAGE_EXTENSIONS.values():
# Avoid noisy loader errors for tiny/placeholder files.
if size_bytes < 256:
return False
img = None
try:
img = bpy.data.images.load(path, check_existing=False)
size = getattr(img, "size", None)
return bool(size and size[0] > 0 and size[1] > 0)
except Exception:
return False
finally:
if img is not None:
try:
bpy.data.images.remove(img)
except Exception:
pass
return True
def _mari_image_matches_expected_size(path, expected_size):
expected = None
try:
if expected_size:
expected = (int(expected_size[0]), int(expected_size[1]))
except Exception:
expected = None
if not expected or expected[0] <= 0 or expected[1] <= 0:
return True
img = None
try:
img = bpy.data.images.load(path, check_existing=False)
size = getattr(img, "size", None)
if not size:
return False
return int(size[0]) == expected[0] and int(size[1]) == expected[1]
except Exception:
return False
finally:
if img is not None:
try:
bpy.data.images.remove(img)
except Exception:
pass
def _mari_scene_final_render_size(scene):
try:
pct = float(getattr(scene.render, "resolution_percentage", 100) or 100.0)
w = int(round(float(scene.render.resolution_x) * pct / 100.0))
h = int(round(float(scene.render.resolution_y) * pct / 100.0))
if w > 0 and h > 0:
return (w, h)
except Exception:
pass
return None
def _mari_expected_image_output_size(scene, prop, mode):
mode_id = _mari_normalize_mode_id(mode)
if mode_id == _MARI_MODE_FRAME:
try:
return (int(prop.frame_ratio[0]), int(prop.frame_ratio[1]))
except Exception:
return None
return _mari_scene_final_render_size(scene)
def _mari_expected_frame_numbers(scene):
try:
frame_start = int(getattr(scene, "frame_start", 1))
frame_end = int(getattr(scene, "frame_end", frame_start))
frame_step = max(1, int(getattr(scene, "frame_step", 1) or 1))
except Exception:
return []
if frame_end < frame_start:
return []
return list(range(frame_start, frame_end + 1, frame_step))
def _mari_output_ext_variants(ext):
ext_clean = str(ext or "").lower().lstrip(".")
if not ext_clean:
return set()
if ext_clean in {"jpg", "jpeg"}:
return {"jpg", "jpeg"}
if ext_clean in {"tif", "tiff"}:
return {"tif", "tiff"}
return {ext_clean}
def _mari_image_output_complete(path, expected_size=None):
try:
size_bytes = os.path.getsize(path)
if size_bytes < 256:
return False
except Exception:
return False
expected = None
try:
if expected_size:
expected = (int(expected_size[0]), int(expected_size[1]))
except Exception:
expected = None
img = None
try:
img = bpy.data.images.load(path, check_existing=False)
size = getattr(img, "size", None)
if not size:
return False
width = int(size[0])
height = int(size[1])
if width <= 0 or height <= 0:
return False
if expected and expected[0] > 0 and expected[1] > 0:
return width == expected[0] and height == expected[1]
return True
except Exception:
return False
finally:
if img is not None:
try:
bpy.data.images.remove(img)
except Exception:
pass
def _mari_collect_scene_camera_jobs(scene):
try:
obj_iter = list(getattr(scene, "objects", []) or bpy.data.objects)
except Exception:
obj_iter = list(bpy.data.objects)
ordered_keys = []
best_by_key = {}
def _name_score(name, h_val, v_val):
exact = f"MARI_CAMERA_H{h_val}_V{v_val}"
if name == exact:
return 0
if name.startswith(exact) and "." not in name:
return 1
if name.startswith("MARI_CAMERA"):
return 2
return 3
for obj in obj_iter:
name = getattr(obj, "name", "")
if not name.startswith("MARI_CAMERA"):
continue
try:
h_val = int(obj.get("H"))
v_val = int(obj.get("V"))
except Exception:
continue
key = (h_val, v_val)
rec = {"cam_name": name, "H": h_val, "V": v_val}
prev = best_by_key.get(key)
if prev is None:
ordered_keys.append(key)
best_by_key[key] = rec
continue
if _name_score(rec["cam_name"], h_val, v_val) < _name_score(prev["cam_name"], h_val, v_val):
best_by_key[key] = rec
return [dict(best_by_key[key]) for key in ordered_keys]
def _mari_collect_valid_sequence_frames(seq_dir, prefix, ext, expected_size=None, require_clean_warp=False):
valid_frames = set()
if not seq_dir or not os.path.isdir(seq_dir):
return valid_frames
if require_clean_warp and os.path.exists(_persp_flag_path(seq_dir)):
return valid_frames
ext_variants = _mari_output_ext_variants(ext)
if not ext_variants:
return valid_frames
try:
for entry in os.scandir(seq_dir):
if not entry.is_file():
continue
stem, file_ext = os.path.splitext(entry.name)
if file_ext.lower().lstrip(".") not in ext_variants:
continue
if not stem.startswith(prefix):
continue
suffix = stem[len(prefix):]
if not suffix.isdigit():
continue
if _mari_image_output_complete(entry.path, expected_size=expected_size):
valid_frames.add(int(suffix))
except Exception:
return valid_frames
return valid_frames
def _mari_find_valid_still_output(root_dir, base_stem, ext, expected_size=None):
if not root_dir or not os.path.isdir(root_dir):
return ""
ext_variants = _mari_output_ext_variants(ext)
if not ext_variants:
return ""
try:
for entry in os.scandir(root_dir):
if not entry.is_file():
continue
stem, file_ext = os.path.splitext(entry.name)
if file_ext.lower().lstrip(".") not in ext_variants:
continue
if stem == base_stem:
if _mari_image_output_complete(entry.path, expected_size=expected_size):
return entry.path
continue
if not stem.startswith(base_stem):
continue
suffix = stem[len(base_stem):]
if len(suffix) >= 3 and suffix.isdigit():
if _mari_image_output_complete(entry.path, expected_size=expected_size):
return entry.path
except Exception:
return ""
return ""
def _mari_plan_multi_resume_jobs(scene, prop, mode, action):
mode_id = _mari_normalize_mode_id(mode)
action_id = str(action or "STILL").upper()
cameras = _mari_collect_scene_camera_jobs(scene)
total_cameras = len(cameras)
summary = {
"jobs": [],
"cameras": cameras,
"total_cameras": total_cameras,
"complete_cameras": 0,
"pending_cameras": total_cameras,
"ready_frames": 0,
"pending_frames": 0,
"expected_frames": 0,
"root_dir": "",
"is_video_output": (_mari_current_ff_type(scene) == "ANIM"),
"action": action_id,
"mode": mode_id,
}
if not cameras:
return summary
base_dir = bpy.path.abspath(getattr(prop, "render_settings_filepath", ""))
name = str(getattr(prop, "render_settings_name", "") or "").strip()
summary["root_dir"] = os.path.join(base_dir, name) if (base_dir and name) else ""
if getattr(scene.render, "use_overwrite", True):
summary["jobs"] = [dict(cam) for cam in cameras]
return summary
expected_size = _mari_expected_image_output_size(scene, prop, mode_id)
if action_id == "STILL":
ext = _mari_current_extension(scene)
jobs = []
complete = 0
for cam in cameras:
stem = f"{name}_H{cam['H']}_V{cam['V']}"
found = _mari_find_valid_still_output(summary["root_dir"], stem, ext, expected_size=expected_size)
if found:
complete += 1
else:
jobs.append(dict(cam))
summary["jobs"] = jobs
summary["complete_cameras"] = complete
summary["pending_cameras"] = len(jobs)
return summary
if summary["is_video_output"]:
video_ext = _mari_video_extension(scene)
jobs = []
complete = 0
for cam in cameras:
final_path = os.path.join(summary["root_dir"], f"{name}_H{cam['H']}_V{cam['V']}.{video_ext}")
if _mari_is_valid_render_output(final_path):
complete += 1
else:
jobs.append(dict(cam))
summary["jobs"] = jobs
summary["complete_cameras"] = complete
summary["pending_cameras"] = len(jobs)
return summary
frames = _mari_expected_frame_numbers(scene)
ext = _mari_current_extension(scene)
prefix = name + "_"
require_clean_warp = (mode_id == _MARI_MODE_FRAME)
jobs = []
ready_frames = 0
complete = 0
pending_frames = 0
for cam in cameras:
seq_dir = os.path.join(summary["root_dir"], f"{name}_H{cam['H']}_V{cam['V']}")
valid_frames = _mari_collect_valid_sequence_frames(
seq_dir,
prefix,
ext,
expected_size=expected_size,
require_clean_warp=require_clean_warp,
)
missing_frames = [frame for frame in frames if frame not in valid_frames]
ready_frames += len(valid_frames)
pending_frames += len(missing_frames)
if not missing_frames:
complete += 1
continue
if not valid_frames:
jobs.append(dict(cam))
continue
for frame in missing_frames:
job = dict(cam)
job["frame"] = int(frame)
jobs.append(job)
summary["jobs"] = jobs
summary["complete_cameras"] = complete
summary["pending_cameras"] = total_cameras - complete
summary["ready_frames"] = ready_frames
summary["pending_frames"] = pending_frames
summary["expected_frames"] = len(frames) * total_cameras
return summary
_MARI_RENDER_ONE_STATUS_KEY = "_mari_render_one_status"
def _mari_set_render_one_status(scene, status):
try:
scene[_MARI_RENDER_ONE_STATUS_KEY] = str(status or "")
except Exception:
pass
def _mari_get_render_one_status(scene, default=""):
try:
return str(scene.get(_MARI_RENDER_ONE_STATUS_KEY, default) or default)
except Exception:
return str(default or "")
def _mari_maybe_write_placeholder(scene, path):
try:
if getattr(scene.render, "use_placeholder", False) and not os.path.exists(path):
with open(path, "wb"):
pass
except Exception:
pass
def _mari_seq_frame_path(seq_dir, prefix, frame, ext):
return os.path.join(seq_dir, f"{prefix}{frame:04d}.{ext}")
def _mari_missing_sequence_frames(seq_dir, prefix, ext, frame_start, frame_end):
missing = []
for frame in range(frame_start, frame_end + 1):
out_path = _mari_seq_frame_path(seq_dir, prefix, frame, ext)
if not _mari_is_valid_render_output(out_path):
missing.append(frame)
return missing
def _mari_render_sequence_frames(scene, seq_dir, prefix, ext, frames):
orig_frame = scene.frame_current
orig_filepath = scene.render.filepath
orig_use_ext = getattr(scene.render, "use_file_extension", True)
orig_use_overwrite = getattr(scene.render, "use_overwrite", True)
try:
try:
scene.render.use_overwrite = True
except Exception:
pass
for frame in frames:
if getattr(mari, "cancel_all", False):
break
scene.frame_set(frame)
base_path = os.path.join(seq_dir, f"{prefix}{frame:04d}")
scene.render.filepath = base_path
try:
scene.render.use_file_extension = True
except Exception:
pass
_mari_maybe_write_placeholder(scene, base_path + "." + ext)
try:
bpy.ops.render.render('EXEC_DEFAULT', write_still=True, animation=False, use_viewport=False)
except TypeError:
bpy.ops.render.render('EXEC_DEFAULT', write_still=True, animation=False)
except Exception:
try:
_mari_render_to_render_result(scene, write_still=True)
except Exception:
_mari_invoke_render(write_still=True)
finally:
scene.render.filepath = orig_filepath
try:
scene.render.use_file_extension = orig_use_ext
except Exception:
pass
try:
scene.render.use_overwrite = orig_use_overwrite
except Exception:
pass
scene.frame_set(orig_frame)
def _mari_apply_camera_visual_scale(scene):
try:
base_scale = _mari_calc_camera_display_size(scene)
except Exception:
base_scale = 1.0
try:
default_display = bpy.types.Camera.bl_rna.properties["display_size"].default
except Exception:
default_display = 0.5
target = None
for obj in bpy.data.objects:
if not obj.name.startswith("MARI_CAMERA"):
continue
try:
obj.show_axis = False
except Exception:
pass
try:
obj.data.display_size = default_display
except Exception:
pass
try:
obj.scale = (base_scale, base_scale, base_scale)
except Exception:
pass
if obj.get("H") == 0 and obj.get("V") == 0:
target = obj
if target is not None:
try:
target.scale = (base_scale * 1.5, base_scale * 1.5, base_scale * 1.5)
target.show_axis = True
except Exception:
pass
def _mari_calc_camera_display_size(scene):
from mathutils import Vector
prop = scene.mari_props
try:
default_size = bpy.types.Camera.bl_rna.properties["display_size"].default
except Exception:
default_size = 0.5
# This function returns a scale factor for camera object scale.
# Keep default at 0.7x Blender's nominal camera size.
default_scale = 0.7
mode = _mari_normalize_mode_id(getattr(prop, "frame", _MARI_MODE_CIRCLE))
has_frame = bpy.data.objects.get("MARI_FrameCenter") is not None
has_circle = bpy.data.objects.get("MARI_CircleCenter") is not None
if has_circle and not has_frame:
mode = _MARI_MODE_CIRCLE
elif has_frame and not has_circle:
mode = _MARI_MODE_FRAME
if mode == _MARI_MODE_FRAME:
def_dist = float(_mari_default_prop_value("frame_uniform_radius", 5.0))
center_obj = bpy.data.objects.get("MARI_FrameCenter")
center = center_obj.matrix_world.translation if center_obj else Vector(getattr(prop, "frame_center", (0.0, 0.0, 0.0)))
# Prefer actual camera distances to center (most accurate for advanced & non-locked modes).
base_dist = 0.0
count = 0
try:
for obj in scene.objects:
if obj.name.startswith("MARI_CAMERA"):
try:
base_dist += (obj.matrix_world.translation - center).length
count += 1
except Exception:
pass
except Exception:
count = 0
if count > 0:
base_dist = base_dist / float(count)
# Fallback if no cameras found yet.
if base_dist <= 0.0:
if getattr(prop, "frame_advanced_possition", False) or getattr(prop, "frame_radius_lock", False):
base_dist = float(getattr(prop, "frame_uniform_radius", 0.0) or 0.0)
else:
base_dist = float(getattr(mari, "last_radius", 0.0) or 0.0)
else:
# Circle mode: size should primarily follow Circle Radius.
def_circle = float(_mari_default_prop_value("circle_radius", 3.0))
def_dist = def_circle * 3.0
base_dist = float(getattr(prop, "circle_radius", 0.0) or 0.0) * 3.0
# Minor influence from uniform radius when locked.
if getattr(prop, "frame_radius_lock", False):
try:
base_dist = (base_dist * 0.85) + (float(getattr(prop, "frame_uniform_radius", 0.0) or 0.0) * 0.15)
except Exception:
pass
if base_dist <= 0.0:
base_dist = float(getattr(mari, "last_radius", 0.0) or 0.0)
if def_dist <= 0.0:
def_dist = base_dist if base_dist > 0.0 else 1.0
ratio = base_dist / def_dist if def_dist else 1.0
if ratio <= 0.0:
ratio = 1.0
# Increase responsiveness: smaller radii shrink more, larger radii grow more.
if mode == _MARI_MODE_CIRCLE:
# Piecewise curve: stronger shrink for small radii, gentler growth for large radii.
ratio = ratio ** (1.27 if ratio < 1.0 else 0.854322775)
else:
ratio = ratio ** 1.35
# Return scale factor; display_size is left at Blender default.
_ = default_size # keep local for clarity; not used for scale output
return default_scale * ratio
def _mari_mark_render_active(scene):
mari._render_active = True
try:
mari._render_state = _mari_capture_render_settings(scene)
except Exception:
mari._render_state = None
def _mari_clear_render_active():
mari._render_active = False
mari._render_state = None
def _mari_invoke_render(**kwargs):
"""
Use INVOKE_DEFAULT so Blender shows the Render window;
fall back to EXEC_DEFAULT if invoke is unavailable.
"""
try:
bpy.ops.render.render('INVOKE_DEFAULT', **kwargs)
return
except Exception:
bpy.ops.render.render('EXEC_DEFAULT', **kwargs)
def _mari_render_to_render_result(scene, write_still=False):
"""
Run a render with an explicit UI override so it behaves like pressing F12
(Render Image), even when the current area/context is not a 3D View.
Leaves the output in the Render Result; does not depend on file_format.
"""
import bpy
wm = bpy.context.window_manager
override = None
created_window = None
# Force render display to a window so Blender actually shows and executes the render.
prefs_view = getattr(bpy.context.preferences, "view", None)
prev_pref_display = getattr(prefs_view, "render_display_type", None) if prefs_view else None
prev_scene_display = getattr(scene.render, "display_mode", None)
try:
if prefs_view and hasattr(prefs_view, "render_display_type"):
prefs_view.render_display_type = 'WINDOW'
if hasattr(scene.render, "display_mode"):
scene.render.display_mode = 'WINDOW'
except Exception:
pass
# Try to find a reasonable area to host the render operator.
for window in wm.windows:
screen = window.screen
for area in screen.areas:
if area.type in {'VIEW_3D', 'IMAGE_EDITOR', 'SEQUENCE_EDITOR', 'NODE_EDITOR', 'PROPERTIES'}:
for region in area.regions:
if region.type == 'WINDOW':
override = {
"window": window,
"screen": screen,
"area": area,
"region": region,
"scene": scene,
"blend_data": bpy.context.blend_data,
}
break
if override:
break
if override:
break
def _run(op_ctx):
# Use EXEC_DEFAULT to block until the render finishes; INVOKE can return immediately.
return bpy.ops.render.render(op_ctx, 'EXEC_DEFAULT', write_still=write_still, animation=False, use_viewport=False)
# First try existing override if found.
if override:
try:
return _run(override)
except Exception:
pass
# If no suitable area found, create a temporary window to host the render (like a pop-up Render window).
try:
bpy.ops.wm.window_new() # creates a duplicate window
created_window = wm.windows[-1]
screen = created_window.screen
area = screen.areas[0] if screen.areas else None
if area:
area.ui_type = 'IMAGE_EDITOR'
override = {
"window": created_window,
"screen": screen,
"area": area,
"scene": scene,
}
return _run(override)
finally:
if created_window:
try:
bpy.ops.wm.window_close({"window": created_window})
except Exception:
pass
# Restore display settings
try:
if prefs_view and prev_pref_display is not None:
prefs_view.render_display_type = prev_pref_display
except Exception:
pass
try:
if prev_scene_display is not None:
scene.render.display_mode = prev_scene_display
except Exception:
pass
# Fallback to current context if everything else failed.
return _run({})
def _mari_render_frame_via_sandbox(main_scene, filepath, frame):
"""
Render a single frame to 'filepath' using a temporary sandbox scene.
This bypasses the current file_format (FFMPEG) by forcing PNG in the sandbox
while leaving the user's original scene untouched.
"""
import bpy
window = bpy.context.window
orig_window_scene = window.scene if window else None
sandbox_name = f"MARI_Sandbox_{uuid.uuid4().hex[:8]}"
sandbox = bpy.data.scenes.new(sandbox_name)
try:
# Link master collection if not already linked
if main_scene.collection.name not in [c.name for c in sandbox.collection.children]:
sandbox.collection.children.link(main_scene.collection)
sandbox.world = main_scene.world
sandbox.camera = main_scene.camera
# Copy render settings
sandbox.render.engine = main_scene.render.engine
sandbox.render.resolution_x = main_scene.render.resolution_x
sandbox.render.resolution_y = main_scene.render.resolution_y
sandbox.render.resolution_percentage = main_scene.render.resolution_percentage
sandbox.frame_set(frame)
# Force PNG output
sandbox.render.image_settings.file_format = 'PNG'
sandbox.render.image_settings.color_mode = 'RGBA'
sandbox.render.image_settings.color_depth = '16'
sandbox.render.image_settings.compression = 0
try:
sandbox.render.use_overwrite = True
except Exception:
pass
try:
sandbox.render.use_file_extension = True
except Exception:
pass
sandbox.render.filepath = filepath
# Switch window to sandbox so Blender renders it
if window:
window.scene = sandbox
bpy.ops.render.render('EXEC_DEFAULT', write_still=True, animation=False)
finally:
# Restore window and remove sandbox
if window and orig_window_scene:
window.scene = orig_window_scene
bpy.data.scenes.remove(sandbox, do_unlink=True)
def _mari_apply_temp_still_format(scene):
"""
Switch the render output to a still-capable format for temp frames.
Returns the chosen format identifier, or None if no still format is available.
"""
# No-op: we render temp frames through a sandbox scene that forces PNG.
return "PNG"
def _mari_render_animation_to_temp_png(scene, temp_dir, prefix, frame_start=None, frame_end=None):
"""
Render the current scene's animation range as a PNG image sequence
into temp_dir by stepping frames manually. This avoids relying on
bpy.ops.render.render(animation=True), which is fragile in the video path.
"""
import os
import bpy
os.makedirs(temp_dir, exist_ok=True)
# Debug marker so users can confirm this codepath is active
try:
print(f"[MARI] Using sandbox temp renderer ({MARI_BUILD_TAG}) -> {temp_dir}")
except Exception:
pass
if frame_start is None:
frame_start = scene.frame_start
if frame_end is None:
frame_end = scene.frame_end
# Preserve current settings
orig_frame = scene.frame_current
orig_filepath = scene.render.filepath
orig_format = scene.render.image_settings.file_format
orig_color_mode = scene.render.image_settings.color_mode
orig_color_depth = scene.render.image_settings.color_depth
orig_compression = scene.render.image_settings.compression
try:
_mari_unlock_interface()
# One still render per frame (always use write_still; we override format visibly to PNG)
for frame in range(frame_start, frame_end + 1):
if getattr(mari, "cancel_all", False):
break
scene.frame_set(frame)
frame_path = os.path.join(temp_dir, f"{prefix}{frame:04d}")
frame_png = frame_path + ".png"
if not scene.render.use_overwrite:
if _mari_is_valid_render_output(frame_png):
continue
_mari_maybe_write_placeholder(scene, frame_png)
scene.render.filepath = frame_path
_mari_render_frame_via_sandbox(scene, frame_path, frame)
finally:
# Restore original settings
scene.render.filepath = orig_filepath
scene.render.image_settings.file_format = orig_format
scene.render.image_settings.color_mode = orig_color_mode
scene.render.image_settings.color_depth = orig_color_depth
scene.render.image_settings.compression = orig_compression
scene.frame_set(orig_frame)
def _mari_fix_video_output_name(final_path):
try:
if not final_path:
return
final_path = os.path.normpath(final_path)
if os.path.isfile(final_path) and _mari_is_valid_render_output(final_path):
return
folder = os.path.dirname(final_path)
base = os.path.splitext(os.path.basename(final_path))[0]
ext = os.path.splitext(final_path)[1].lower()
if not os.path.isdir(folder):
return
candidates = []
for fname in os.listdir(folder):
if not fname.lower().endswith(ext):
continue
stem = os.path.splitext(fname)[0]
if not stem.startswith(base):
continue
suffix = stem[len(base):]
if suffix and (len(suffix) < 3 or not suffix.isdigit()):
continue
candidates.append(fname)
if candidates:
candidates.sort(key=lambda n: os.path.getmtime(os.path.join(folder, n)), reverse=True)
for cand in candidates:
src = os.path.join(folder, cand)
if os.path.normcase(os.path.normpath(src)) == os.path.normcase(final_path):
continue
os.replace(src, final_path)
break
except Exception:
pass
def _mari_fix_still_output_name(final_path):
"""
Ensure still outputs keep the canonical camera-id name:
<NAME>_H#_V#.<ext>
If Blender emits <NAME>_H#_V#0001.<ext>, rename it back.
"""
try:
if not final_path:
return
final_path = os.path.normpath(final_path)
folder = os.path.dirname(final_path)
if not os.path.isdir(folder):
return
base = os.path.splitext(os.path.basename(final_path))[0]
ext = os.path.splitext(final_path)[1].lower()
candidates = []
for fname in os.listdir(folder):
full = os.path.join(folder, fname)
if not os.path.isfile(full):
continue
stem, fext = os.path.splitext(fname)
if fext.lower() != ext:
continue
if not stem.startswith(base):
continue
suffix = stem[len(base):]
if suffix and (len(suffix) >= 3 and suffix.isdigit()):
candidates.append(full)
if os.path.isfile(final_path) and _mari_is_valid_render_output(final_path):
for extra in candidates:
try:
os.remove(extra)
except Exception:
pass
return
if not candidates:
return
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
os.replace(candidates[0], final_path)
for extra in candidates[1:]:
try:
os.remove(extra)
except Exception:
pass
except Exception:
pass
def _mari_cleanup_temp_dirs(root_dir):
try:
if not root_dir or not os.path.isdir(root_dir):
return
to_remove = []
for entry in os.scandir(root_dir):
if entry.is_dir() and entry.name.upper().endswith("_TEMP"):
to_remove.append(entry.path)
for p in to_remove:
shutil.rmtree(p, ignore_errors=True)
except Exception:
pass
def _mari_build_video_from_temp(context, temp_dir, frames, final_path):
"""
Build a video file at ``final_path`` from a sequence of image frames
stored in ``temp_dir`` using the Video Sequence Editor.
Assumes the scene's render settings (FFMPEG/codec/etc.) are already
configured by the user. This function only:
- Creates an IMAGE strip from the image sequence
- Sets frame range
- Renders the animation to the user's chosen video format
"""
import bpy
import os
scene = context.scene
# --- Basic validation -------------------------------------------------
if not frames:
raise RuntimeError("No frames provided to build video.")
# Normalize folder & sort frames to get a stable playback order
temp_dir = os.path.normpath(temp_dir)
frames = sorted(frames)
# Build absolute path to the first frame and sanity check
first_name = frames[0]
first_path = os.path.join(temp_dir, first_name)
if not os.path.isfile(first_path):
raise RuntimeError(f"First frame not found: {first_path!r}")
# --- Save render / VSE state so we can restore it later ---------------
old_se = scene.sequence_editor
old_frame_start = scene.frame_start
old_frame_end = scene.frame_end
old_filepath = scene.render.filepath
old_use_seq = scene.render.use_sequencer
old_use_cmp = scene.render.use_compositing
old_use_ext = getattr(scene.render, "use_file_extension", True)
# Create or reuse a SequenceEditor
se = old_se if old_se is not None else scene.sequence_editor_create()
# Blender 5.0+ uses 'strips'; pre-5.0 used 'sequences'
strip_coll = getattr(se, "strips", None)
if strip_coll is None:
strip_coll = getattr(se, "sequences", None)
if strip_coll is None:
strip_coll = getattr(se, "sequences_all", None)
if strip_coll is None:
raise RuntimeError(
"SequenceEditor has no 'strips' or 'sequences' collection; "
"cannot create VSE strip."
)
# Clear any existing strips in this editor (so we only render our seq)
try:
for s in list(strip_coll):
strip_coll.remove(s)
except Exception:
# Non-fatal; worst case some old strips remain
pass
strip = None
frame_count = len(frames)
try:
# --- Create the IMAGE strip via data API (no bpy.ops) -------------
try:
# Preferred call signature (works in modern Blender, including 5.0)
strip = strip_coll.new_image(
name="MARI_TEMP_SEQ",
filepath=first_path,
channel=1,
frame_start=old_frame_start,
)
except TypeError:
# Fallback for older versions that only accept positional args
strip = strip_coll.new_image("MARI_TEMP_SEQ", first_path, 1, old_frame_start)
# --- Wire the strip to our folder and all frames -------------------
# In the VSE, strip.directory is a folder path; elements[].filename
# are just basenames.
if not temp_dir.endswith(os.sep):
directory = temp_dir + os.sep
else:
directory = temp_dir
strip.directory = directory
# Ensure first element uses our first frame name
if strip.elements:
strip.elements[0].filename = first_name
else:
strip.elements.append(first_name)
# Append the rest of the frames as elements (preserve order)
for name in frames[1:]:
strip.elements.append(name)
# Make sure the strip length matches the number of images
strip.frame_start = old_frame_start
strip.frame_final_duration = frame_count
# --- Configure scene frame range & render settings -----------------
scene.frame_start = old_frame_start
scene.frame_end = old_frame_start + frame_count - 1
# Use the sequencer output only (no compositor)
scene.render.use_sequencer = True
scene.render.use_compositing = False
# Use the user's video format, but force the filepath
scene.render.filepath = os.path.splitext(final_path)[0]
try:
scene.render.use_file_extension = True
except Exception:
pass
# --- Render to video ----------------------------------------------
bpy.ops.render.render(animation=True)
finally:
# --- Restore previous render / VSE state --------------------------
scene.render.filepath = old_filepath
scene.frame_start = old_frame_start
scene.frame_end = old_frame_end
scene.render.use_sequencer = old_use_seq
scene.render.use_compositing = old_use_cmp
try:
scene.render.use_file_extension = old_use_ext
except Exception:
pass
# Remove the temporary strip we created (if any)
try:
if strip is not None and strip_coll is not None and hasattr(strip_coll, "remove"):
strip_coll.remove(strip)
except Exception:
# Non-fatal; at worst your VSE keeps one stray strip
pass
_mari_fix_video_output_name(final_path)
def _mari_get_vse_strip_collection(se):
"""
Return the top-level strip collection for the SequenceEditor.
- Blender 2.74.x: SequenceEditor.sequences / sequences_all
- Blender 5.x: SequenceEditor.strips / strips_all
We try all known names so this stays as future-proof as possible.
"""
# Newer builds (5.x) preferred
if hasattr(se, "strips"):
return se.strips
if hasattr(se, "strips_all"):
return se.strips_all
# Older builds fallback
if hasattr(se, "sequences"):
return se.sequences
if hasattr(se, "sequences_all"):
return se.sequences_all
return None
def on_cancel(scene):
"""
Called when the user presses Esc.
Stops any queued renders and restores preview preference.
"""
print("[MARI] Render canceled by user (Esc). Halting pipeline.")
mari.cancel_all = True
# Don't let any lingering completion handler queue more work
for _h in list(bpy.app.handlers.render_complete):
try:
bpy.app.handlers.render_complete.remove(_h)
except Exception:
pass
# Also restore the user's render display preference
try:
_mari_restore_render_display()
except Exception:
pass
def _mari_register_cancel_handler_once():
# avoid duplicates across reloads
if on_cancel not in bpy.app.handlers.render_cancel:
bpy.app.handlers.render_cancel.append(on_cancel)
_mari_register_cancel_handler_once()
def _mari_start_cancel_watcher(context):
try:
if getattr(mari, "_cancel_watch_active", False):
return
bpy.ops.mari.cancel_watcher('INVOKE_DEFAULT')
except Exception:
try:
bpy.ops.mari.cancel_watcher('EXEC_DEFAULT')
except Exception:
pass
class MARI_OT_CancelWatcher(Operator):
bl_idname = f"{addon_prefix}.cancel_watcher"
bl_label = "MARI Cancel Watcher"
bl_options = {'INTERNAL'}
_timer = None
def execute(self, context):
if getattr(mari, "_cancel_watch_active", False):
return {'CANCELLED'}
mari._cancel_watch_active = True
wm = context.window_manager
try:
self._timer = wm.event_timer_add(0.1, window=context.window)
except Exception:
self._timer = None
wm.modal_handler_add(self)
return {'RUNNING_MODAL'}
def _finish(self, context):
try:
if self._timer:
context.window_manager.event_timer_remove(self._timer)
except Exception:
pass
mari._cancel_watch_active = False
def modal(self, context, event):
if not getattr(mari, "_render_active", False) or getattr(mari, "cancel_all", False):
self._finish(context)
return {'FINISHED'}
if event.type == 'ESC':
mari.cancel_all = True
try:
bpy.ops.render.cancel()
except Exception:
pass
self._finish(context)
return {'FINISHED'}
return {'PASS_THROUGH'}
def FrameRatio(self, context, type):
ratio = (self.frame_ratio[0]/self.frame_ratio[1], self.frame_ratio[1]/self.frame_ratio[0])
self.frame_ratio = (self.frame_ratio[0] * ratio[0], self.frame_ratio[1] * ratio[1])
'''
def register():
bpy.utils.register_class(MARI_OP_Install)
def unregister():
bpy.utils.unregister_class(MARI_OP_Install)
'''
class MARI_CollisionPlaneItem(PropertyGroup):
name: StringProperty(name="Object Name")
class MARI_Props(PropertyGroup):
def updateHandaler(self, context):
from mathutils import Euler, Matrix, Vector
import math
# 1) Move frame to new center if it exists
frame = bpy.data.objects.get("MARI_FrameCenter")
if not frame:
return
frame.location = self.frame_center
# 2) Track previous applied UI rotation in global 'mari' stash
prev = getattr(mari, "_last_ui_rot", (0.0, 0.0, 0.0))
cur = tuple(self.frame_rotation)
# Build world-space target eulers (the frame uses -rx, -ry, rz+pi)
prev_R = Euler((-prev[0], -prev[1], prev[2] + math.pi)).to_matrix().to_4x4()
cur_R = Euler((-cur[0], -cur[1], cur[2] + math.pi)).to_matrix().to_4x4()
# 3) Delta rotation about the frame center
T = Matrix.Translation(Vector(self.frame_center))
Tinv= Matrix.Translation(Vector(self.frame_center) * -1.0)
R_delta = T @ (cur_R @ prev_R.inverted()) @ Tinv
# Apply to frame + every MARI camera together
frame.matrix_world = R_delta @ frame.matrix_world
for obj in bpy.data.objects:
if obj.name.startswith("MARI_CAMERA"):
obj.matrix_world = R_delta @ obj.matrix_world
# 4) Remember this rotation for the next update
mari._last_ui_rot = cur
def updateSize(self, context):
if "MARI_FrameCenter" in bpy.data.objects:
bpy.ops.mari.make_frame()
def updateRadius(self, context):
bpy.ops.object.select_all(action='DESELECT')
bpy.context.scene.cursor.location = self.frame_center
bpy.context.scene.tool_settings.transform_pivot_point = 'CURSOR'
for cam in bpy.data.objects:
if cam.name.startswith("MARI_CAMERA"):
cam.select_set(True)
bpy.ops.transform.resize(value=(1/mari.last_radius, 1/mari.last_radius, 1/mari.last_radius), orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), orient_matrix_type='GLOBAL', mirror=False, use_proportional_edit=False, proportional_edit_falloff='SMOOTH', proportional_size=3.7975, use_proportional_connected=False, use_proportional_projected=False)
bpy.ops.transform.resize(value=(self.frame_uniform_radius, self.frame_uniform_radius, self.frame_uniform_radius), orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), orient_matrix_type='GLOBAL', mirror=False, use_proportional_edit=False, proportional_edit_falloff='SMOOTH', proportional_size=3.7975, use_proportional_connected=False, use_proportional_projected=False)
bpy.context.scene.tool_settings.transform_pivot_point = 'INDIVIDUAL_ORIGINS'
bpy.ops.transform.resize(value=(mari.last_radius/self.frame_uniform_radius, mari.last_radius/self.frame_uniform_radius, mari.last_radius/self.frame_uniform_radius), orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), orient_matrix_type='GLOBAL', mirror=False, use_proportional_edit=False, proportional_edit_falloff='SMOOTH', proportional_size=3.7975, use_proportional_connected=False, use_proportional_projected=False)
mari.last_radius = self.frame_uniform_radius
bpy.ops.view3d.snap_cursor_to_center()
def updateCameras(self, context):
for cam in bpy.data.cameras:
if cam.name.startswith("MARI_Camera"):
if not mari.did_focal:
cam.lens = self.camera_settings_lens
cam.dof.use_dof = self.camera_settings_dof_use
cam.dof.focus_object = self.camera_settings_dof_object
cam.dof.focus_distance = self.camera_settings_dof_distance
cam.clip_start = self.camera_settings_clip_start
cam.clip_end = self.camera_settings_clip_end
cam.dof.aperture_fstop = self.camera_settings_dof_fstop
cam.dof.aperture_blades = self.camera_settings_dof_blades
cam.dof.aperture_rotation = self.camera_settings_dof_rotation
cam.dof.aperture_ratio = self.camera_settings_dof_ratio
def circleUpdate(self, context):
if "MARI_CircleCenter" in bpy.data.objects:
obj = bpy.data.objects["MARI_CircleCenter"]
obj.location = self.circle_location
obj.rotation_euler = Euler([-self.circle_rotation[0], -self.circle_rotation[1], self.circle_rotation[2] + math.pi])
def updateAdvancedFrameMode(self, context):
if not self.frame_advanced_possition:
self.mari_save_planes = False
auto_fov_margin: FloatProperty(
name="Auto FOV Margin",
description="Percent to widen beyond the tight fit to prevent edge clipping",
default=0.15, min=0.0, max=2.0, subtype='PERCENTAGE'
)
frame: EnumProperty(name="Type", default=_MARI_MODE_CIRCLE, items={("FRAME", "Frame", ""),
("CRICLE", "Circle", ""),})
frame_ratio: FloatVectorProperty(size=2, name="Aspect Ratio", precision=2, default = (1920, 1080))
frame_dimensions: FloatVectorProperty(size=2, name="Dimension", precision=2, unit="LENGTH", default = (16, 9))
frame_center: FloatVectorProperty(name="Center Location", default=(0, 0, 0), unit="LENGTH", update=updateHandaler)
frame_rotation: FloatVectorProperty(name="Center Rotation", default=(0, 0, 0), unit="ROTATION", update=updateHandaler)
frame_density: FloatVectorProperty(name="Interval", size=2, unit="ROTATION", default=(0.08726646, 0.08726646), min=0, max=30, description="Horizontal/Vertical")
frame_camera_limit_lr: FloatVectorProperty(name="Left/Right", size=2, default=(1.57079633, 1.57079633), unit="ROTATION", min=-3.14159265, soft_max=1.55334303, max=3.14159265)
frame_camera_limit_ud: FloatVectorProperty(name="Up/Down", size=2, default=(1.57079633, 1.57079633), unit="ROTATION", min=-1.57079633, max=1.57079633)
frame_radius_lock: BoolProperty(name="Lock", default=False)
frame_uniform_radius: FloatProperty(name="Uniform Radius", default=5, update=updateRadius, unit="LENGTH")
frame_use_dynamic_cam: BoolProperty(name="Dynamic Camera Array")
frame_advanced_possition: BoolProperty(name="Advanced", default=False, update=updateAdvancedFrameMode)
frame_advanced_offset: FloatProperty(name="Offset", default=0)
frame_advenced_leftover_angle: BoolProperty(name="Leftover Angles", default=True)
render_settings_show: BoolProperty(name="Render Settings", default=False)
render_settings_filepath: StringProperty(name="Output Folder", subtype="DIR_PATH")
render_settings_name: StringProperty(name="Name", default="MARI")
render_settings_normalize: BoolProperty(name="Normalize Resolution", description="It will upscale render resolution so the end image has constant quality. Takes more time")
render_settings_limit_res: IntVectorProperty(name="Max resolution", min=0, default=(1920*2, 1080*2), size=2)
render_settings_limit_res_factor: FloatProperty(name="Limit Factor", min=1, soft_max=10, default=2)
mari_use_blend_resolution: BoolProperty(name="Use Current File Output Resolution", default=False)
camera_settings_lens: FloatProperty(name="Focal Length", default=50, min=0.1, max=5000000, precision=3, step=10, update=updateCameras, unit="CAMERA")
camera_settings_dof_use: BoolProperty(name="Depth of Field", default=False, update=updateCameras)
camera_settings_dof_object: PointerProperty(type=bpy.types.Object, update=updateCameras)
camera_settings_dof_distance: FloatProperty(name="Distance", default=10, unit="LENGTH", min=0, update=updateCameras)
camera_settings_clip_start: FloatProperty(name="Clip Start", default=0.1, min=0.001, max=1000.0, update=updateCameras)
camera_settings_clip_end: FloatProperty(name="Clip End", default=1000.0, min=1.0, max=1000000.0, update=updateCameras)
camera_settings_dof_fstop: FloatProperty(name="F-Stop", default=2.8, min=0.1, update=updateCameras, precision=1)
camera_settings_dof_blades: IntProperty(name="Blades", default=0, min=0, update=updateCameras)
camera_settings_dof_rotation: FloatProperty(name="Rotation", default=0, update=updateCameras, unit="ROTATION")
camera_settings_dof_ratio: FloatProperty(name="Ratio", default=1, min=1, max=2, update=updateCameras, precision=3)
#mari_save_media: BoolProperty(name="Save as MARI Media", default=False) ##NOT .marijpeg
mari_save_media: BoolProperty(name="Save as MARI Media (.zip)", default=True)
mari_save_sequence: BoolProperty(name="Save as Image Sequence", default=True)
mari_save_planes: BoolProperty(name="Export Camera Collision Planes", default=False)
frame_collision_planes: CollectionProperty(type=MARI_CollisionPlaneItem)
circle_location: FloatVectorProperty(name="Location", default=(0, 0, 0), unit="LENGTH", update=circleUpdate)
circle_rotation: FloatVectorProperty(name="Rotation", unit="ROTATION", default=(0, 0, 0), update=circleUpdate)
circle_radius: FloatProperty(name="Circle Radius", unit="LENGTH", default=3)
circle_interval: FloatVectorProperty(name="Interval", size=2, min=0.00174533, default=(0.08726646, 0.08726646), unit="ROTATION")
circle_max_angle_lr: FloatVectorProperty(name="Left/Right", size=2, min=-3.14159265, max=3.14159265, default=(3.14159265, 3.14159265), unit="ROTATION")
circle_max_angle_ud: FloatVectorProperty(name="Up/Down", size=2, min=-(3.14159265/2), max=3.14159265/2, default=(1.04719755, 1.04719755), unit="ROTATION")
cam_num: IntProperty()
class MARI_AP_Preferences(AddonPreferences):
bl_idname = __name__
def draw(self, context):
layout = self.layout
if _MARI_HAS_CV2 and cv2 is not None:
layout.label(text=f"cv2: installed (v{cv2.__version__})", icon='INFO')
else:
layout.label(text="cv2: missing (required for render)", icon='ERROR')
layout.label(text=_mari_cv2_admin_hint())
if _MARI_CV2_IMPORT_ERROR:
layout.label(text=f"Import error: {_MARI_CV2_IMPORT_ERROR}")
layout.operator(MARI_OP_Install.bl_idname, text="Install/Repair cv2")
class MARI_OP_Install(Operator):
bl_idname = "mari_op.install_dependencies"
bl_label = "Install Dependencies (REQUIRED!!)"
def execute(self, context):
import sys
import subprocess
import os
import importlib
hint = _mari_cv2_admin_hint()
python_exe = sys.executable
deps_dir = _mari_cv2_deps_dir()
if deps_dir:
try:
os.makedirs(deps_dir, exist_ok=True)
except Exception as exc:
print(f"[MARI] WARN: could not create local deps folder {deps_dir}: {exc}")
try:
subprocess.run(
[python_exe, "-m", "ensurepip", "--upgrade"],
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
print(f"[MARI] WARN: ensurepip bootstrap skipped: {exc}")
try:
import pip
except Exception as exc:
self.report({'ERROR'}, f"pip is not available in Blender Python: {exc}. {hint}")
return {"CANCELLED"}
try:
subprocess.run(
[
python_exe, "-m", "pip", "install",
"--disable-pip-version-check", "--no-input",
"--upgrade", "pip", "setuptools", "wheel"
],
check=False,
capture_output=True,
text=True,
)
except Exception:
pass
common = [
python_exe, "-m", "pip", "install",
"--disable-pip-version-check", "--no-input", "--upgrade"
]
attempts = []
if deps_dir:
attempts.append(("opencv-python (addon local)", common + ["--target", deps_dir, "opencv-python"]))
attempts.append(("opencv-python-headless (addon local)", common + ["--target", deps_dir, "opencv-python-headless"]))
attempts.append(("opencv-python (user site)", common + ["--user", "opencv-python"]))
attempts.append(("opencv-python-headless (user site)", common + ["--user", "opencv-python-headless"]))
attempts.append(("opencv-python (default env)", common + ["opencv-python"]))
attempts.append(("opencv-python-headless (default env)", common + ["opencv-python-headless"]))
if not sys.platform.startswith("win"):
attempts.append(("opencv-python (user + break-system-packages)", common + ["--break-system-packages", "--user", "opencv-python"]))
attempts.append(("opencv-python-headless (user + break-system-packages)", common + ["--break-system-packages", "--user", "opencv-python-headless"]))
errors = []
for label, cmd in attempts:
try:
print(f"[MARI] CV2 install attempt: {label}")
print(f"[MARI] CMD: {' '.join(cmd)}")
proc = subprocess.run(cmd, capture_output=True, text=True)
except Exception as exc:
errors.append(f"{label}: failed to launch pip ({exc})")
continue
if proc.returncode != 0:
details = (proc.stderr or proc.stdout or "").strip()
if len(details) > 700:
details = details[-700:]
errors.append(f"{label}: pip exit {proc.returncode}: {details}")
continue
_mari_ensure_cv2_paths_on_syspath()
importlib.invalidate_caches()
if _mari_try_import_cv2():
self.report({'INFO'}, f"cv2 installed and accessible! Version: {cv2.__version__}")
return {"FINISHED"}
imp = (_MARI_CV2_IMPORT_ERROR or "").strip()
if len(imp) > 400:
imp = imp[:400] + "..."
errors.append(f"{label}: install succeeded but import failed: {imp}")
_mari_ensure_cv2_paths_on_syspath()
importlib.invalidate_caches()
if _mari_try_import_cv2():
self.report({'INFO'}, f"cv2 installed and accessible! Version: {cv2.__version__}")
return {"FINISHED"}
print("[MARI] CV2 installation attempts failed.")
for entry in errors:
print(f"[MARI] - {entry}")
reason = errors[-1] if errors else (_MARI_CV2_IMPORT_ERROR or "Unknown install/import failure.")
if len(reason) > 260:
reason = reason[:260] + "..."
self.report({'ERROR'}, f"Failed to install/import cv2. {reason} {hint}")
return {"CANCELLED"}
class MARI_OT_GenerateFrame(Operator):
"""Generate the Frame"""
bl_label = "Generate Frame"
bl_idname = f"{addon_prefix}.make_frame"
def _rotate_rig_about_center(self, center, target_euler):
"""Rotate MARI_FrameCenter and all MARI_CAMERA* objects together about 'center'
so the frame ends up at Euler(-rx, -ry, rz + pi) in world space."""
from mathutils import Matrix, Euler, Vector
# Current frame orientation is assumed to be neutral: (0, 0, pi).
R_current = Euler((0.0, 0.0, math.pi)).to_matrix().to_4x4()
rx, ry, rz = target_euler
R_target = Euler((-rx, -ry, rz + math.pi)).to_matrix().to_4x4()
# Delta (world space), pivoting about 'center'
T = Matrix.Translation(Vector(center))
Tinv = Matrix.Translation(Vector(center) * -1.0)
R_delta = T @ (R_target @ R_current.inverted()) @ Tinv
# Rotate frame + every MARI camera (including dotted duplicates)
names = ["MARI_FrameCenter"] + [o.name for o in bpy.data.objects if o.name.startswith("MARI_CAMERA")]
for name in names:
obj = bpy.data.objects.get(name)
if not obj:
continue
obj.matrix_world = R_delta @ obj.matrix_world
def AddFrame(self, do=False):
prop = bpy.context.scene.mari_props
bpy.ops.mesh.primitive_plane_add(enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
obj = bpy.context.object
obj.dimensions = (prop.frame_dimensions[0] + 0.2, prop.frame_dimensions[1] + 0.2, 0)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.inset(thickness=0.1, depth=0)
bpy.ops.mesh.delete(type='FACE')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.transform.rotate(value=1.5708, orient_axis='X', orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), orient_matrix_type='GLOBAL', constraint_axis=(True, False, False), mirror=True, use_proportional_edit=False, proportional_edit_falloff='SMOOTH', proportional_size=1, use_proportional_connected=False, use_proportional_projected=False)
bpy.ops.object.mode_set(mode='OBJECT')
obj.name = "MARI_FrameCenter"
obj.hide_render = True
_mari_link_object_to_collection(obj, _mari_get_collection(bpy.context.scene, "MARI_Cameras"))
return obj
def AddCameras(self, frame, event, selected_objs):
"""Fast camera lattice generator with preflight, skip-before-create, no constraints,
and optional BVH-accelerated projection for Advanced Position."""
context = bpy.context
scene = context.scene
prop = scene.mari_props
_mari_cleanup_temp_collection(scene)
_mari_get_collection(scene, "MARI_Cameras")
# Degrees per step (ensure > 0 to avoid zero-step)
RAD2DEG = 57.29577951308232
min_step = 1e-4
step_h = max(min_step, abs(prop.frame_density[0] * RAD2DEG))
step_v = max(min_step, abs(prop.frame_density[1] * RAD2DEG))
# Signed limits (degrees). Positive left/up values stay on their side;
# negative values cross the zero line into the opposite side.
h_min_deg = -float(prop.frame_camera_limit_lr[0] * RAD2DEG)
h_max_deg = float(prop.frame_camera_limit_lr[1] * RAD2DEG)
v_min_deg = -float(prop.frame_camera_limit_ud[1] * RAD2DEG)
v_max_deg = float(prop.frame_camera_limit_ud[0] * RAD2DEG)
def _signed_index_range(min_deg, max_deg, step_deg, max_abs_deg=90.0):
if step_deg <= 0.0:
return []
lo = max(-max_abs_deg, min(float(min_deg), float(max_deg)))
hi = min(max_abs_deg, max(float(min_deg), float(max_deg)))
start_idx = int(math.ceil((lo - 1e-6) / step_deg))
end_idx = int(math.floor((hi + 1e-6) / step_deg))
if start_idx > end_idx:
return []
return list(range(start_idx, end_idx + 1))
# Preflight estimated count (conservative upper bound)
H_range = _signed_index_range(h_min_deg, h_max_deg, step_h)
V_range = _signed_index_range(v_min_deg, v_max_deg, step_v)
est = len(H_range) * len(V_range)
MAX_CAMS = 20000
if est > MAX_CAMS:
self.report({'ERROR'}, f"Too many cameras requested (~{est}). Increase step size or tighten limits. (Max {MAX_CAMS})")
_mari_cleanup_temp_collection(scene)
return
# Set working radius
if prop.frame_radius_lock:
mari.last_radius = prop.frame_uniform_radius
elif prop.frame_advanced_possition:
mari.last_radius = 1 # will snap to geometry
# else: keep existing last_radius until end (where we may set uniform radius)
# Prepare detached collection to avoid per-object UI/depsgraph churn
coll_name = "MARI_Cameras_Temp"
coll = bpy.data.collections.get(coll_name)
if coll is None:
coll = bpy.data.collections.new(coll_name)
# Link collection to scene only once at the end (if not already linked)
if coll.name not in [c.name for c in scene.collection.children]:
# don't link yet; link after populating to reduce redraws
pass
created = []
cam_seq = 0
frame_loc = frame.matrix_world.translation
use_dynamic = bool(prop.frame_use_dynamic_cam)
# Camera creation (data API only; no constraints)
for H in H_range:
for V in V_range:
# Compute angles from indices to exactly match original naming semantics
if not use_dynamic:
v_deg = 90 + H * step_h
h_deg = V * step_v
else:
v_deg = 90 + (-V) * step_h
h_deg = -H * step_v
# Skip any camera whose H/V index would land at or beyond 90°.
# This avoids side/perpendicular views even when floating steps round down.
side_tol = 1e-2
if (abs(H) * step_h) >= (90.0 - side_tol):
continue
if (abs(V) * step_v) >= (90.0 - side_tol):
continue
# Extra guard using computed spherical angles.
if abs(v_deg - 90.0) >= (90.0 - side_tol):
continue
if abs(h_deg) >= (90.0 - side_tol):
continue
# Spherical placement
R = mari.last_radius
v_rad = math.radians(v_deg)
h_rad = math.radians(h_deg)
if not use_dynamic:
x = R * math.cos(v_rad)
y = R * math.cos(h_rad) * math.sin(v_rad)
z = R * math.sin(h_rad) * math.sin(v_rad)
else:
z = R * math.cos(v_rad)
y = R * math.cos(h_rad) * math.sin(v_rad)
x = R * math.sin(h_rad) * math.sin(v_rad)
cam_data_name = f"MARI_Camera_{cam_seq}"
cam_obj_name = f"MARI_CAMERA_H{H}_V{V}"
cam_data = bpy.data.cameras.get(cam_data_name) or bpy.data.cameras.new(cam_data_name)
cam_obj = bpy.data.objects.new(cam_obj_name, cam_data)
# Set location immediately; rotation will be set to look at frame
cam_obj.matrix_world.translation = (x, y, z)
# Custom indices for later logic
cam_obj["H"] = H
cam_obj["V"] = V
cam_obj["INTR"] = 0
created.append(cam_obj)
cam_seq += 1
# Batch link to collection and then to scene, then parent + rotate to face frame
# Link objs to temp collection
for obj in created:
if obj.name not in coll.objects:
coll.objects.link(obj)
# Link the temp collection to the scene (if not linked yet)
if coll.name not in [c.name for c in scene.collection.children]:
scene.collection.children.link(coll)
# Parent + orient
for j, obj in enumerate(created):
# Parent
obj.parent = frame
# Visual orientation: point -Z axis toward frame center
direction = (frame_loc - obj.matrix_world.translation).normalized()
quat = direction.to_track_quat('-Z', 'Y')
obj.matrix_world = Matrix.Translation(obj.matrix_world.translation) @ quat.to_matrix().to_4x4()
# Advanced snapping with BVH (optional)
if prop.frame_advanced_possition:
# Temporarily offset selected meshes
for plane in selected_objs:
plane.location.z += prop.frame_advanced_offset
for obj in created:
self.Intersect(context, event, obj, selected_objs)
# Restore offsets
for plane in selected_objs:
plane.location.z -= prop.frame_advanced_offset
# If not using advanced placement and radius isn't locked, set a default uniform radius
if not prop.frame_advanced_possition and not prop.frame_radius_lock:
prop.frame_uniform_radius = prop.frame_dimensions[0] + prop.frame_dimensions[1] + 1
_mari_cleanup_temp_collection(scene)
def _mark_center_camera(self):
_mari_apply_camera_visual_scale(bpy.context.scene)
def _auto_fit_all_cameras(self, context, frame_obj):
"""Fast & exact: replicate the original temp-plane geometry,
then binary-search cam.data.angle using Blender's own projection."""
import math
from mathutils import Euler
scene = context.scene
prop = scene.mari_props
# --- Build the same FILLED inner rectangle the original used ---
bpy.ops.mesh.primitive_plane_add(enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
frame_inside = bpy.context.object
frame_inside.dimensions = (prop.frame_dimensions[0], prop.frame_dimensions[1], 0.0)
frame_inside.location = prop.frame_center
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
# Rotate mesh +90° about X in EDIT mode (yes, EDIT exactly like the original)
bpy.ops.object.mode_set(mode='EDIT')
bpy.context.scene.tool_settings.transform_pivot_point = 'MEDIAN_POINT'
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.transform.rotate(value=1.57079632679, orient_axis='X', orient_type='GLOBAL',
constraint_axis=(True, False, False))
bpy.ops.object.mode_set(mode='OBJECT')
# Then set object rotation to (-rx, -ry, rz + pi) again, exactly like original
rx, ry, rz = prop.frame_rotation
frame_inside.rotation_euler = Euler((-rx, -ry, rz + math.pi))
# Cache world-space vertices once (no per-iteration bmesh/edit-mode churn)
corners_world = [frame_inside.matrix_world @ v.co for v in frame_inside.data.vertices]
def inside_strict(cam_obj) -> bool:
uv = [world_to_camera_view(scene, cam_obj, p) for p in corners_world]
# strict 0..1 gate: even a hair outside fails
for u, v, _ in uv:
if (u < 0.0) or (u > 1.0) or (v < 0.0) or (v > 1.0):
return False
return True
# binary search settings (angles in radians)
ANGLE_MIN = math.radians(0.05) # practical floor (very narrow)
ANGLE_MAX = math.radians(170.0) # very wide but below singularity
FINAL_PAD = 1.0 + (prop.auto_fov_margin / 100.0) # +0.15% wider than tight fit (prevents edge-kissing)
for cam in (o for o in bpy.data.objects if o.name.startswith("MARI_CAMERA")):
cdata = cam.data
if getattr(cdata, "type", "PERSP") != 'PERSP':
continue # only meaningful for perspective cameras
# Ensure there exists a fitting angle
cdata.angle = ANGLE_MAX
if not inside_strict(cam):
# Can't fully see the rectangle from this camera (highly unlikely given your rig)
continue
# Establish invariant: lo = outside, hi = inside
lo = ANGLE_MIN
hi = ANGLE_MAX
# If at floor the rect already fits (rare), force lo to be outside
cdata.angle = lo
if inside_strict(cam):
for _ in range(20):
new_lo = max(1e-6, 0.5 * lo)
cdata.angle = new_lo
if not inside_strict(cam):
lo = new_lo
break
lo = new_lo
# Standard monotonic bisection to get **minimal** angle that still fits
for _ in range(24): # ~1e-5 rad precision
mid = 0.5 * (lo + hi)
cdata.angle = mid
if inside_strict(cam):
hi = mid # still fits → try narrower
else:
lo = mid # too narrow → widen
# Set a hair wider than the tight bound so float/AA never clips a corner
cdata.angle = min(ANGLE_MAX, hi * FINAL_PAD)
# cleanup helper geometry
bpy.data.objects.remove(frame_inside, do_unlink=True)
mari.did_focal = True
def Intersect(self, context, event, cam, objs):
"""Snap camera to nearest intersection along its -Z axis using a cached BVH per mesh."""
if not objs:
self.report({'INFO'}, "No Objects Selected. Nothing to Project on!")
return
# Build (or reuse) BVH cache
deps = context.evaluated_depsgraph_get()
cache = mari.data.get("_bvh_cache")
cache_key = (tuple(o.name for o in objs), id(deps))
if mari.data.get("_bvh_cache_key") != cache_key or cache is None:
cache = []
for obj in objs:
if obj.type != 'MESH':
continue
eval_obj = obj.evaluated_get(deps)
me = eval_obj.to_mesh(preserve_all_data_layers=False, depsgraph=deps)
try:
# Extract vertices and polygon data for BVHTree.FromPolygons
vertices = [v.co for v in me.vertices]
polygons = [p.vertices for p in me.polygons]
bvh = BVHTree.FromPolygons(vertices, polygons)
M = eval_obj.matrix_world.copy()
Minv = M.inverted()
cache.append((bvh, M, Minv))
finally:
eval_obj.to_mesh_clear()
mari.data["_bvh_cache"] = cache
mari.data["_bvh_cache_key"] = cache_key
# Ray in world
origin = cam.matrix_world.translation
# Cameras face toward frame center (-Z), so cast ray in opposite direction (+Z) to hit objects beyond frame
ray_dir_world = cam.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))
hit_world = None
nearest_dist = float("inf")
for bvh, M, Minv in cache:
# Transform ray to local
o_local = Minv @ origin
d_local = (Minv.to_3x3() @ ray_dir_world).normalized()
res = bvh.ray_cast(o_local, d_local)
if res is None:
continue
loc_local, normal_local, face_index, dist = res
if loc_local is None:
continue
if dist < nearest_dist:
nearest_dist = dist
hit_world = M @ loc_local
if hit_world is None:
return
# If a dotted copy already exists, reuse it; else duplicate
siblings = [o for o in bpy.data.objects if o.name.startswith(cam.name + ".")]
if len(siblings) == 1:
old_cam = siblings[0]
# Move only if closer to frame center
if (old_cam.matrix_world.translation - context.scene.mari_props.frame_center).length > (hit_world - context.scene.mari_props.frame_center).length:
# Preserve rotation while updating position
old_cam.matrix_world = Matrix.Translation(hit_world) @ cam.matrix_world.to_quaternion().to_matrix().to_4x4()
old_cam["INTR"] = 1
else:
new_cam = cam.copy()
if cam.data:
new_cam.data = cam.data.copy()
_mari_link_object_to_collection(new_cam, _mari_get_collection(context.scene, "MARI_Cameras"))
# Preserve rotation while setting new position
new_cam.matrix_world = Matrix.Translation(hit_world) @ cam.matrix_world.to_quaternion().to_matrix().to_4x4()
new_cam["INTR"] = 1
def modal(self, context, event):
prop = context.scene.mari_props
mari.generated = True
mari.did_focal = False
mari.cameras = []
for obj in bpy.data.objects:
if obj.name.startswith("MARI_"):
bpy.data.objects.remove(obj)
selected_objs = bpy.context.selected_objects
try:
prop.frame_collision_planes.clear()
except Exception:
pass
mari.planes = []
last_location = []
last_rotation = []
last_scale = []
for slected_obj in selected_objs:
last_location.append(Vector([slected_obj.matrix_world.translation.x, slected_obj.matrix_world.translation.y, slected_obj.matrix_world.translation.z]))
last_rotation.append(Vector([slected_obj.matrix_world.to_euler().x, slected_obj.matrix_world.to_euler().y, slected_obj.matrix_world.to_euler().z]))
last_scale.append(Vector([slected_obj.scale.x, slected_obj.scale.y, slected_obj.scale.z]))
if prop.frame_advanced_possition:
mari.planes.append(slected_obj)
try:
item = prop.frame_collision_planes.add()
item.name = slected_obj.name
except Exception:
pass
frame = self.AddFrame()
frame.location = prop.frame_center
frame.rotation_euler = Euler((0.0, 0.0, math.pi))
self.AddCameras(frame, event, selected_objs)
def updateRadius(cams):
self = context.scene.mari_props
bpy.context.scene.cursor.location = self.frame_center
bpy.context.scene.tool_settings.transform_pivot_point = 'CURSOR'
bpy.ops.transform.resize(value=(1/mari.last_radius, 1/mari.last_radius, 1/mari.last_radius), orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), orient_matrix_type='GLOBAL', mirror=False, use_proportional_edit=False, proportional_edit_falloff='SMOOTH', proportional_size=3.7975, use_proportional_connected=False, use_proportional_projected=False)
bpy.ops.transform.resize(value=(self.frame_uniform_radius, self.frame_uniform_radius, self.frame_uniform_radius), orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), orient_matrix_type='GLOBAL', mirror=False, use_proportional_edit=False, proportional_edit_falloff='SMOOTH', proportional_size=3.7975, use_proportional_connected=False, use_proportional_projected=False)
bpy.context.scene.tool_settings.transform_pivot_point = 'INDIVIDUAL_ORIGINS'
bpy.ops.transform.resize(value=(mari.last_radius/self.frame_uniform_radius, mari.last_radius/self.frame_uniform_radius, mari.last_radius/self.frame_uniform_radius), orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), orient_matrix_type='GLOBAL', mirror=False, use_proportional_edit=False, proportional_edit_falloff='SMOOTH', proportional_size=3.7975, use_proportional_connected=False, use_proportional_projected=False)
mari.last_radius = self.frame_uniform_radius
bpy.ops.view3d.snap_cursor_to_center()
if prop.frame_advanced_possition == True and prop.frame_advenced_leftover_angle == False:
for obj in bpy.data.objects:
if "." not in obj.name and obj.name.startswith("MARI_CAMERA"):
bpy.data.objects.remove(obj)
elif prop.frame_advanced_possition == True and prop.frame_advenced_leftover_angle == True:
bpy.ops.object.select_all(action='DESELECT')
for obj in bpy.data.objects:
if "." not in obj.name and obj.name.startswith("MARI_CAMERA"):
x_cam = filter(lambda n: n.name.startswith(obj.name + "."), bpy.data.objects)
list_xcam = list(x_cam)
if len(list_xcam) == 1:
bpy.data.objects.remove(obj)
for obj in bpy.data.objects:
if "." not in obj.name and obj.name.startswith("MARI_CAMERA"):
obj.select_set(True)
updateRadius(obj)
_mari_rename_cameras_by_indices(context.scene)
prop.cam_num = 0
mari.cam_num = 0
for obj_cam in bpy.data.objects:
if obj_cam.name.startswith("MARI_CAMERA"):
prop.cam_num += 1
mari.cameras.append(obj_cam)
self._mark_center_camera()
# Auto-fit FOVs for all MARI cameras (fast & exact, replaces the old button)
self._auto_fit_all_cameras(context, frame)
desired_rot = tuple(prop.frame_rotation) # (rx, ry, rz) in radians from the UI
self._rotate_rig_about_center(prop.frame_center, desired_rot)
frame.select_set(False)
# Note: Intersection objects are already restored in AddCameras (offset added then removed)`r`n # No additional restoration needed here`r`n
return {'CANCELLED'}
def invoke(self, context, event):
if context.space_data.type == 'VIEW_3D':
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
####### Render ########
m = mari()
class MARI_OT_Frame_render(Operator):
"""Render All Cameras"""
bl_label = "Render"
bl_idname = f"{addon_prefix}.frame_render"
action: StringProperty(default="STILL")
def area(self, points):
sum = 0
for i in range(len(points)):
if i == len(points)-1:
sum += points[i][0] * points[0][1] - points[i][1] * points[0][0]
else:
sum += points[i][0] * points[i+1][1] - points[i][1] * points[i+1][0]
return abs(sum/2)
def execute(self, context):
try:
mari_detect_mode_from_scene(context.scene)
except Exception:
pass
mari.render_action = self.action
mari.area = None
prop = context.scene.mari_props
if not _mari_output_path_set(prop):
self.report({'ERROR'}, "Please set the Output Folder in MARI Render Settings before rendering.")
_mari_clear_render_active()
return {'CANCELLED'}
if getattr(prop, "frame", "") != "FRAME":
self.report({'ERROR'}, "Scene has a Frame; switch mode to FRAME in the panel before rendering.")
return {'CANCELLED'}
if not _mari_require_cv2_for_frame(self):
return {'CANCELLED'}
_mari_mark_render_active(context.scene)
_mari_start_cancel_watcher(context)
mari.cancel_all = False
_mari_set_render_display_for_preview() # ensures SCREEN/AREA/IMAGE_EDITOR and unlocks UI
# start with a clean queue each run
mari.render_queue = None
mari._manual_seq_render = False
mari._manual_video_render = False
mari._last_still_target = None
mari.cancel_all = False
# show renders inside the Blender UI (no pop-up OS window stealing focus)
_mari_set_render_display_for_preview()
context.scene.render.resolution_x = int(prop.frame_ratio[0])
context.scene.render.resolution_y = int(prop.frame_ratio[1])
f__ = context.scene.render.resolution_percentage
context.scene.render.resolution_x = int(context.scene.render.resolution_x * context.scene.render.resolution_percentage/100)
context.scene.render.resolution_y = int(context.scene.render.resolution_y * context.scene.render.resolution_percentage/100)
context.scene.render.resolution_percentage = 100
mari.res_x = int(prop.frame_ratio[0])
mari.res_y = int(prop.frame_ratio[1])
real_x = int(prop.frame_ratio[0])
real_y = int(prop.frame_ratio[1])
if prop.frame == "FRAME":
ok, err = _mari_refresh_frame_quads(context, prop, real_x, real_y)
if not ok:
self.report({'ERROR'}, err)
_mari_clear_render_active()
return {'CANCELLED'}
prop.render_settings_limit_res = (int(real_x * prop.render_settings_limit_res_factor), int(real_y * prop.render_settings_limit_res_factor))
prop.render_settings_filepath = bpy.path.abspath(prop.render_settings_filepath)
frames_total = context.scene.frame_end - context.scene.frame_start + 1
fmt_type = _mari_ff_type_from_format(context.scene.render.image_settings.file_format)
is_video_mode = fmt_type == "ANIM"
mari.og_type = context.scene.render.image_settings.file_format
mari.ffmpeg_format = context.scene.render.ffmpeg.format
mari.ffmpeg_codec = context.scene.render.ffmpeg.codec
mari.ffmpeg_color_mode = context.scene.render.image_settings.color_mode
def _cleanup_and_restore():
"""
Restore formats and preview preference. Safe to call multiple times.
Returning None makes this usable from timers to stop further scheduling.
"""
try:
context.scene.render.image_settings.file_format = mari.og_type
context.scene.render.ffmpeg.format = mari.ffmpeg_format
context.scene.render.ffmpeg.codec = mari.ffmpeg_codec
context.scene.render.image_settings.color_mode = mari.ffmpeg_color_mode
except Exception:
pass
try:
_mari_restore_render_display()
except Exception:
pass
try:
mari.render_queue = None
except Exception:
pass
if not mari.cancel_all:
_mari_write_media_zip(prop)
# Restore the user's chosen format at the very end
try:
context.scene.render.image_settings.file_format = mari.og_type
except Exception:
pass
try:
out_root = os.path.join(bpy.path.abspath(prop.render_settings_filepath), prop.render_settings_name)
_mari_cleanup_temp_dirs(out_root)
except Exception:
pass
try:
_mari_clear_render_active()
except Exception:
pass
return None
if self.action == "STILL" and is_video_mode:
self.report({'ERROR'}, "Cannot render STILL directly to video. Switch to an image format or use ANIM.")
_mari_clear_render_active()
return {'CANCELLED'}
# All ANIM cases (image or video) continue through render_start/next_render (snapshot quad pipeline).
if mari.cameras == []:
for c in bpy.data.objects:
if c.name.startswith("MARI_CAMERA"):
mari.cameras.append(c)
for cam in bpy.data.objects:
if cam.name.startswith("MARI_CAMERA"):
array = _mari_cam_quad(cam, context.scene, prop)
if not array or len(array) < 4:
self.report({'ERROR'}, _mari_frame_border_message(cam.name))
_mari_clear_render_active()
return {"CANCELLED"}
cam["area"] = _mari_quad_area_bl_br_tl_tr(array)
def get_ff(ff):
return _mari_extension_from_format(ff, context.scene)
def ff_type(ff):
return _mari_ff_type_from_format(ff)
def get_rendered_type(folder):
"""Return (extension_without_dot, type) for an existing render in `folder`.
type is:
- "NA" for single files directly in `folder` (still image or video)
- "SEQ" for image sequences stored in subfolders
If no valid rendered files exist yet, return (None, None).
Marker/flag files like .txt or .mari3d are ignored on purpose so that
overwrite/resume logic does not treat them as an output format.
"""
valid_exts = set(_MARI_IMAGE_EXTENSIONS.values()) | set(_MARI_FFMPEG_EXTENSIONS.values())
# 1) Direct files in base folder (non-sequence outputs)
try:
entries = os.listdir(folder + "\\")
except FileNotFoundError:
return None, None
for file in entries:
full = os.path.join(folder, file)
if not os.path.isfile(full):
continue
ext = os.path.splitext(file)[1].lower().lstrip(".")
# Ignore .mari3d, .txt, and anything not in our known image/video extensions
if not ext or ext in ("mari3d", "txt") or ext not in valid_exts:
continue
return ext, "NA"
# 2) Subfolders treated as image sequences
for subname in entries:
sub = os.path.join(folder, subname)
if not os.path.isdir(sub):
continue
try:
for f in os.listdir(sub):
ext = os.path.splitext(f)[1].lower().lstrip(".")
if not ext or ext in ("mari3d", "txt") or ext not in valid_exts:
continue
return ext, "SEQ"
except FileNotFoundError:
continue
# No previous renders found
return None, None
fmt_type_current = ff_type(context.scene.render.image_settings.file_format)
is_video_mode = fmt_type_current == "ANIM"
frames_total = context.scene.frame_end - context.scene.frame_start + 1
if self.action == "STILL" and is_video_mode:
self.report({'ERROR'}, "Video formats can only be rendered via Render MARI Animation.")
_mari_clear_render_active()
return {'CANCELLED'}
return None, None
fmt_type_current = ff_type(context.scene.render.image_settings.file_format)
is_video_mode = fmt_type_current == "ANIM"
frames_total = context.scene.frame_end - context.scene.frame_start + 1
if self.action == "STILL" and is_video_mode:
self.report({'ERROR'}, "Video formats can only be rendered via Render MARI Animation.")
_mari_clear_render_active()
return {'CANCELLED'}
import time
def _wait_for_image(path, timeout=3.0, tick=0.05):
last = -1
t0 = time.time()
while time.time() - t0 < timeout:
if os.path.exists(path):
size = os.path.getsize(path)
if size > 0 and size == last: # size stable for one tick
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
if img is not None:
return img
last = size
time.sleep(tick)
return None
def _count_frames(dirpath, exts):
if not os.path.isdir(dirpath):
return 0
return len([f for f in os.listdir(dirpath) if f.lower().endswith(exts)])
def _frames_complete(dirpath, exts):
return _count_frames(dirpath, exts) >= frames_total
mari.c = 0
def next_render(*_args, **_kwargs):
if mari.cancel_all:
_cleanup_and_restore()
return None
# Restore settings
try:
context.scene.render.image_settings.file_format = mari.og_type
context.scene.render.ffmpeg.format = mari.ffmpeg_format
context.scene.render.ffmpeg.codec = mari.ffmpeg_codec
context.scene.render.image_settings.color_mode = mari.ffmpeg_color_mode
except Exception:
pass
queue = mari.render_queue if getattr(mari, "render_queue", None) else mari.cameras
if not queue or mari.next_cam_index >= len(queue):
_cleanup_and_restore()
return None
cam = queue[mari.next_cam_index]
base_dir = os.path.join(prop.render_settings_filepath, prop.render_settings_name)
cam_base = f"{prop.render_settings_name}_H{cam['H']}_V{cam['V']}"
video_ext = _mari_video_extension(context.scene)
fmt_type_current = ff_type(mari.og_type)
is_video_mode_local = (fmt_type_current == "ANIM")
real_x = int(prop.frame_ratio[0])
real_y = int(prop.frame_ratio[1])
# --- RETRIEVE SNAPSHOT QUAD ---
# 1. Try to use the Snapshot (Video Mode) - Guaranteed correct scale
quad = getattr(mari, "active_video_quad", None)
flip_x = getattr(mari, "active_video_flip_x", False)
flip_y = getattr(mari, "active_video_flip_y", False)
# 2. If not found (Still/Seq Mode), calculate fresh
if not quad:
_mari_apply_frame_resolution(context.scene, prop)
quad = _mari_cam_quad(cam, context.scene, prop)
if not quad:
print(f"[MARI] WARN: no quad data for {cam.name}, skipping warp.")
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return None
flip_x, flip_y = _mari_frame_warp_flip_flags(context.scene, cam, prop, quad, real_x, real_y)
if mari.render_action == "STILL":
ext = get_ff(context.scene.render.image_settings.file_format.lower())
filepath = os.path.join(base_dir, f"{cam_base}.{ext}")
_mari_fix_still_output_name(filepath)
img = _wait_for_image(filepath)
if img is None:
_mari_fix_still_output_name(filepath)
img = _wait_for_image(filepath, timeout=1.0)
if img is None:
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return None
out = _mari_warp_image(img, quad, real_x, real_y, flip=flip_x, flip_y=flip_y)
save_ext = os.path.splitext(filepath)[1].lower()
save_args = [cv2.IMWRITE_JPEG_QUALITY, 100] if save_ext in {".jpg", ".jpeg"} else []
if save_args:
cv2.imwrite(filepath, out, save_args)
else:
cv2.imwrite(filepath, out)
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return None
elif mari.render_action == "ANIM" and not is_video_mode_local:
dirpath = os.path.join(base_dir, cam_base)
if not os.path.isdir(dirpath):
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return None
# Only warp if the PERSPFLAG exists (prevents double-warp on resume)
if not os.path.exists(_persp_flag_path(dirpath)):
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return None
frames = sorted(f for f in os.listdir(dirpath) if f.lower().endswith((".png", ".jpg", ".jpeg")))
for fname in frames:
fpath = os.path.join(dirpath, fname)
img = _wait_for_image(fpath)
if img is None: continue
out = _mari_warp_image(img, quad, real_x, real_y, flip=flip_x, flip_y=flip_y)
cv2.imwrite(fpath, out)
_clear_persp_flag(dirpath)
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return None
else:
# --- ANIMATION AS VIDEO (Warp Phase) ---
temp_dir = os.path.join(base_dir, cam_base + "_TEMP")
final_base = os.path.join(base_dir, cam_base)
final_path = final_base + "." + video_ext
if not os.path.isdir(temp_dir):
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return None
frames = sorted(f for f in os.listdir(temp_dir) if f.lower().endswith(".png"))
# Warp using the Snapshot Quad only if PERSPFLAG exists
if os.path.exists(_persp_flag_path(temp_dir)):
for fname in frames:
fpath = os.path.join(temp_dir, fname)
img = _wait_for_image(fpath)
if img is None:
continue
out = _mari_warp_image(img, quad, real_x, real_y, flip=flip_x, flip_y=flip_y)
cv2.imwrite(fpath, out)
_clear_persp_flag(temp_dir)
# Build Video
_mari_build_video_from_temp(context, temp_dir, frames, final_path)
# Cleanup
shutil.rmtree(temp_dir, ignore_errors=True)
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return None
def render_complete(scene):
if getattr(mari, "_manual_video_render", False) or getattr(mari, "_manual_seq_render", False):
return
# remove ourselves safely
try:
bpy.app.handlers.render_complete.remove(render_complete)
except Exception:
pass
# if canceled, stop here and restore settings
if mari.cancel_all:
_cleanup_and_restore()
return
# otherwise continue to warp/queue the next camera
bpy.app.timers.register(next_render, first_interval=0.15)
# (Re)register the handler cleanly
for _h in list(bpy.app.handlers.render_complete):
if getattr(_h, "__name__", "") == "render_complete":
try:
bpy.app.handlers.render_complete.remove(_h)
except Exception:
pass
bpy.app.handlers.render_complete.append(render_complete)
def render_start(*_args, **_kwargs):
queue = mari.render_queue if getattr(mari, "render_queue", None) else mari.cameras
if mari.cancel_all or mari.next_cam_index >= len(queue):
_cleanup_and_restore()
return None
try:
cam = queue[mari.next_cam_index]
# 1. Force 100% Resolution (CRITICAL for correct coordinates)
# We must do this BEFORE calculating the quad.
_mari_apply_frame_resolution(context.scene, prop)
base_dir = os.path.join(prop.render_settings_filepath, prop.render_settings_name)
cam_base = f"{prop.render_settings_name}_H{cam['H']}_V{cam['V']}"
video_ext = _mari_video_extension(context.scene)
# Determine Mode
fmt_type_current = ff_type(mari.og_type)
is_video_mode_local = (fmt_type_current == "ANIM")
context.scene.camera = cam
# --- SNAPSHOT THE QUAD (Fixes Bowtie) ---
# We calculate this NOW while resolution is definitely 100%.
# We store it in 'mari.active_video_quad' so next_render uses THIS exact data.
quad = _mari_cam_quad(cam, context.scene, prop)
if quad:
mari.active_video_quad = quad
mari.active_video_flip_x, mari.active_video_flip_y = _mari_frame_warp_flip_flags(
context.scene, cam, prop, quad, real_x, real_y
)
else:
mari.active_video_quad = None
mari.active_video_flip_x = False
mari.active_video_flip_y = False
print(f"[MARI] WARN: no quad data for {cam.name}")
flip_x = getattr(mari, "active_video_flip_x", False)
flip_y = getattr(mari, "active_video_flip_y", False)
# --- EXPORT MARI3D DATA (Restored) ---
if mari.render_action == "ANIM":
bpy.ops.mari.export_mari(action="RENDER", type="FRAME", format="ANIM")
else:
bpy.ops.mari.export_mari(action="RENDER", type="FRAME", format="STILL")
if mari.render_action == "STILL":
ext = get_ff(context.scene.render.image_settings.file_format.lower())
filepath = os.path.join(base_dir, f"{cam_base}.{ext}")
context.scene.render.filepath = filepath
if os.path.isfile(filepath) and not context.scene.render.use_overwrite:
if _mari_is_valid_render_output(filepath):
print("[MARI] STILL: skipped existing", filepath)
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return
_mari_maybe_write_placeholder(context.scene, filepath)
_mari_invoke_render(write_still=True)
elif mari.render_action == "ANIM" and not is_video_mode_local:
seq_dir = os.path.join(base_dir, cam_base + "\\")
os.makedirs(seq_dir, exist_ok=True)
prefix = prop.render_settings_name + "_"
ext = get_ff(context.scene.render.image_settings.file_format.lower())
if not context.scene.render.use_overwrite:
missing = _mari_missing_sequence_frames(seq_dir, prefix, ext, context.scene.frame_start, context.scene.frame_end)
flag_exists = os.path.exists(_persp_flag_path(seq_dir))
if not missing:
if flag_exists:
bpy.app.timers.register(next_render, first_interval=0.2)
return
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return
if flag_exists:
_ensure_persp_flag(seq_dir)
mari._manual_seq_render = True
_mari_render_sequence_frames(context.scene, seq_dir, prefix, ext, missing)
mari._manual_seq_render = False
bpy.app.timers.register(next_render, first_interval=0.2)
return
# Already-warped frames exist; only warp newly rendered frames.
mari._manual_seq_render = True
_mari_render_sequence_frames(context.scene, seq_dir, prefix, ext, missing)
mari._manual_seq_render = False
for frame in missing:
fpath = _mari_seq_frame_path(seq_dir, prefix, frame, ext)
img = _wait_for_image(fpath)
if img is None:
continue
out = _mari_warp_image(img, quad, real_x, real_y, flip=flip_x, flip_y=flip_y)
cv2.imwrite(fpath, out)
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return
_ensure_persp_flag(seq_dir)
context.scene.render.filepath = seq_dir + prop.render_settings_name + "_"
_mari_invoke_render(animation=True)
else:
# --- ANIMATION AS VIDEO (Render Phase) ---
# We simply render the Temp PNGs here using Blender's native loop.
# This prevents the UI hang. Warping happens in 'next_render'.
start_f = context.scene.frame_start
end_f = context.scene.frame_end
final_base = os.path.join(base_dir, cam_base)
final_path = final_base + "." + video_ext
temp_dir = os.path.join(base_dir, cam_base + "_TEMP")
_mari_fix_video_output_name(final_path)
if os.path.isfile(final_path) and not context.scene.render.use_overwrite:
if _mari_is_valid_render_output(final_path):
print("[MARI] ANIM-VIDEO: final exists, skipping", final_path)
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return
_mari_maybe_write_placeholder(context.scene, final_path)
if context.scene.render.use_overwrite and os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
os.makedirs(temp_dir, exist_ok=True)
prefix = prop.render_settings_name + "_"
if not context.scene.render.use_overwrite:
missing = _mari_missing_sequence_frames(temp_dir, prefix, "png", start_f, end_f)
flag_exists = os.path.exists(_persp_flag_path(temp_dir))
if not missing:
bpy.app.timers.register(next_render, first_interval=0.2)
return
if not flag_exists:
# Render only missing frames, warp them now, then build video.
mari._manual_video_render = True
_mari_render_animation_to_temp_png(
context.scene,
temp_dir,
prefix,
frame_start=start_f,
frame_end=end_f,
)
mari._manual_video_render = False
for frame in missing:
fpath = os.path.join(temp_dir, f"{prefix}{frame:04d}.png")
img = _wait_for_image(fpath)
if img is None:
continue
out = _mari_warp_image(img, quad, real_x, real_y, flip=flip_x, flip_y=flip_y)
cv2.imwrite(fpath, out)
frames = sorted(f for f in os.listdir(temp_dir) if f.lower().endswith(".png"))
_mari_build_video_from_temp(context, temp_dir, frames, final_path)
shutil.rmtree(temp_dir, ignore_errors=True)
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return
_ensure_persp_flag(temp_dir)
# Render temp PNG sequence via sandboxed per-frame renders
mari._manual_video_render = True
_mari_render_animation_to_temp_png(
context.scene,
temp_dir,
prefix,
frame_start=start_f,
frame_end=end_f,
)
mari._manual_video_render = False
bpy.app.timers.register(next_render, first_interval=0.2)
return
except Exception as e:
print("[MARI] ERROR in render_start:", e)
_cleanup_and_restore()
return None
# Register Handler
for _h in list(bpy.app.handlers.render_complete):
if getattr(_h, "__name__", "") == "render_complete":
try: bpy.app.handlers.render_complete.remove(_h)
except: pass
bpy.app.handlers.render_complete.append(render_complete)
mari.next_cam_index = 0
mari.is_normalized = prop.render_settings_normalize
mari.active_video_quad = None
mari.active_video_flip_x = False
mari.active_video_flip_y = False
if not _mari_output_path_set(prop):
self.report({'ERROR'}, "Please set the Output Folder in MARI Render Settings before rendering.")
_mari_clear_render_active()
return {'CANCELLED'}
# Normalise the base project directory once, but do NOT
# pre-create any per-camera H_V folders here.
base_dir = prop.render_settings_filepath + prop.render_settings_name + "\\"
if context.scene.render.use_overwrite:
# Overwrite ON: wipe the whole project folder and recreate it.
if os.path.isdir(base_dir):
shutil.rmtree(base_dir)
os.makedirs(base_dir, exist_ok=True)
else:
# Overwrite OFF: ensure the project folder exists, but do not
# pre-create H_V folders and do not try to interpret existing
# files by extension. Resume/skip logic is handled later
# inside render_start(), per camera.
if not os.path.isdir(base_dir):
os.makedirs(base_dir, exist_ok=True)
try:
mari.render_queue = None
if not context.scene.render.use_overwrite:
base_dir = bpy.path.abspath(prop.render_settings_filepath + prop.render_settings_name + "\\")
# Only accelerate when output is image-based (STILL frames)
if ff_type(context.scene.render.image_settings.file_format) == "STILL":
import re
ext = get_ff(context.scene.render.image_settings.file_format.lower())
existing = set()
if self.action == "STILL":
# Files: <name>_H#_V#.<ext> in base_dir
pat = re.compile(rf"^{re.escape(prop.render_settings_name)}_H(-?\d+)_V(-?\d+)\.{ext}$", re.IGNORECASE)
if os.path.isdir(base_dir):
for fn in os.listdir(base_dir):
m = pat.match(fn)
if m:
full = os.path.join(base_dir, fn)
if _mari_is_valid_render_output(full):
existing.add((int(m.group(1)), int(m.group(2))))
mari.render_queue = [cam for cam in mari.cameras if (cam.get('H'), cam.get('V')) not in existing]
elif self.action == "ANIM":
# Image sequence subfolders: <name>_H#_V#\ with frames inside
frames_needed = context.scene.frame_end - context.scene.frame_start + 1
subpat = re.compile(rf"^{re.escape(prop.render_settings_name)}_H(-?\d+)_V(-?\d+)$", re.IGNORECASE)
if os.path.isdir(base_dir):
for entry in os.scandir(base_dir):
if entry.is_dir():
m = subpat.match(entry.name)
if m:
hv = (int(m.group(1)), int(m.group(2)))
cnt = 0
for subf in os.scandir(entry.path):
if subf.is_file() and subf.name.lower().endswith("." + ext):
if _mari_is_valid_render_output(subf.path):
cnt += 1
if cnt >= frames_needed:
existing.add(hv)
mari.render_queue = [cam for cam in mari.cameras if (cam.get('H'), cam.get('V')) not in existing]
except Exception:
mari.render_queue = None
bpy.ops.mari.export_mari(action="RENDER", type="FRAME", format=self.action)
render_start(context.area)
context.scene.render.resolution_percentage = f__
print(context.scene.render.resolution_x / f__, context.scene.render.resolution_x, f__/100)
context.scene.render.resolution_x = int(context.scene.render.resolution_x / (f__/100))
context.scene.render.resolution_y = int(context.scene.render.resolution_y / (f__/100))
return {'FINISHED'}
def invoke(self, context, event):
import os
mari.og_type = context.scene.render.image_settings.file_format
mari.ffmpeg_format = context.scene.render.ffmpeg.format
mari.ffmpeg_codec = context.scene.render.ffmpeg.codec
mari.ffmpeg_color_mode = context.scene.render.image_settings.color_mode
wm = context.window_manager
prop = context.scene.mari_props
# Check overwrite folder state exactly as execute() uses it
base = bpy.path.abspath(prop.render_settings_filepath + prop.render_settings_name + "\\")
needs_confirm = False
if context.scene.render.use_overwrite and os.path.isdir(base):
try:
# Non-empty directory?
needs_confirm = any(os.scandir(base))
except Exception:
needs_confirm = False
if needs_confirm:
# flag only for draw() to show warning text
self._overwrite_warning = True
return wm.invoke_props_dialog(self, width=520)
# Fall back to your existing prompts/confirm
if context.scene.mari_props.render_settings_normalize:
return wm.invoke_props_dialog(self)
if mari.did_focal == False:
return wm.invoke_props_dialog(self)
return wm.invoke_confirm(self, event)
def draw(self, context):
layout = self.layout
prop = context.scene.mari_props
# Only shows when invoke() set the flag
if getattr(self, "_overwrite_warning", False):
col = layout.column()
col.alert = True
col.label(text="Overwrite is ON and the output folder contains files.", icon='ERROR')
col.label(text="All contents in this folder will be deleted before rendering.")
col.separator()
col.label(text=f"Folder: {bpy.path.abspath(prop.render_settings_filepath)}{prop.render_settings_name}\\", icon='FILE_FOLDER')
layout.separator()
if context.scene.mari_props.render_settings_normalize:
layout.label(text="Normalize is on; Rendering may take up more time and memory!")
if mari.did_focal == False:
layout.label(text="Did not run AutoFocalLength; Are all Focal Lengths Optimized? & Are you in the right rendering mode?)")
##############################
##### CIRCLE ##############
class MARI_OT_GenerateCircle(Operator):
"""Generate the Center Circle"""
bl_label = "Generate Center"
bl_idname = f"{addon_prefix}.make_circle"
def AddCircle(self):
prop = bpy.context.scene.mari_props
bpy.ops.mesh.primitive_circle_add(radius=prop.circle_radius, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
obj = bpy.context.object
obj.name = "MARI_CircleCenter"
obj.hide_render = True
_mari_link_object_to_collection(obj, _mari_get_collection(bpy.context.scene, "MARI_Cameras"))
return obj
def AddCameras(self, frame):
context = bpy.context
prop = bpy.context.scene.mari_props
cam_coll = _mari_get_collection(context.scene, "MARI_Cameras")
RAD2DEG = 57.29577951308232
eps = 1e-6
i = prop.circle_interval
step_v = max(1e-4, abs(i[1] * RAD2DEG))
step_h = max(1e-4, abs(i[0] * RAD2DEG))
u = float(prop.circle_max_angle_ud[0] * RAD2DEG)
d = float(prop.circle_max_angle_ud[1] * RAD2DEG)
l = float(prop.circle_max_angle_lr[0] * RAD2DEG)
r = float(prop.circle_max_angle_lr[1] * RAD2DEG)
c = prop.frame_center
mari.last_radius = prop.circle_radius * 3
if prop.frame_radius_lock == True:
mari.last_radius = prop.frame_uniform_radius
def CameraLocation(v, h, j, use_z = True, lr = True):
if use_z == True:
z = mari.last_radius * math.cos(math.radians(v))
y = mari.last_radius * math.cos(math.radians(h)) * math.sin(math.radians(v))
x = mari.last_radius * math.sin(math.radians(h)) * math.sin(math.radians(v))
else:
x = mari.last_radius * math.cos(math.radians(v))
y = mari.last_radius * math.sin(math.radians(v))
H_idx = int(round(-h / step_h))
V_idx = int(round(-(v - 90.0) / step_v))
cam_data = bpy.data.cameras.new(f"MARI_Camera_{j}")
if not mari.did_focal:
cam_data.lens = prop.camera_settings_lens
cam_data.dof.use_dof = prop.camera_settings_dof_use
cam_data.dof.focus_object = prop.camera_settings_dof_object
cam_data.dof.focus_distance = prop.camera_settings_dof_distance
cam_data.clip_start = prop.camera_settings_clip_start
cam_data.clip_end = prop.camera_settings_clip_end
cam_data.dof.aperture_fstop = prop.camera_settings_dof_fstop
cam_data.dof.aperture_blades = prop.camera_settings_dof_blades
cam_data.dof.aperture_rotation = prop.camera_settings_dof_rotation
cam_data.dof.aperture_ratio = prop.camera_settings_dof_ratio
obj = bpy.data.objects.new(f"MARI_CAMERA_H{H_idx}_V{V_idx}", cam_data)
obj["H"] = H_idx
obj["V"] = V_idx
_mari_link_object_to_collection(obj, cam_coll)
obj["INTR"] = 0
obj.matrix_world.translation = (x, y, z if use_z == True else 0)
obj.parent = frame
if j == 0:
obj.show_axis = True
cam_damped_track=obj.constraints.new(type="TRACK_TO")
cam_damped_track.target=frame
cam_damped_track.track_axis="TRACK_NEGATIVE_Z"
cam_damped_track.up_axis="UP_Y"
cam_damped_track.use_target_z = True
def _signed_index_range(min_deg, max_deg, step_deg, max_abs_deg):
if step_deg <= 0.0:
return []
lo = max(-max_abs_deg, min(float(min_deg), float(max_deg)))
hi = min(max_abs_deg, max(float(min_deg), float(max_deg)))
start_idx = int(math.ceil((lo - eps) / step_deg))
end_idx = int(math.floor((hi + eps) / step_deg))
if start_idx > end_idx:
return []
return list(range(start_idx, end_idx + 1))
v_indices = _signed_index_range(-d, u, step_v, 90.0)
h_indices = _signed_index_range(-l, r, step_h, 180.0)
if h_indices and (h_indices[0] * step_h) <= (-180.0 + 1e-4) and (h_indices[-1] * step_h) >= (180.0 - 1e-4):
h_indices = h_indices[1:]
j = 0
for V in v_indices:
v = 90.0 - (V * step_v)
for H in h_indices:
h = -H * step_h
CameraLocation(v, h, j)
j += 1
pole_index = None
has_poles = False
if step_v > 0.0:
pole_index = int(round(90.0 / step_v))
has_poles = abs(90.0 - (pole_index * step_v)) <= 1e-4
for obj in list(bpy.data.objects): #Change of top/bottom ends of Circle
if obj.name.startswith("MARI_CAMERA"):
if has_poles and obj.get("V") in (pole_index, -pole_index):
bpy.context.view_layer.objects.active = obj
for constraint in obj.constraints:
obj.constraints.remove(constraint)
h_value = int(obj.get("H", 0))
if obj.get("V", 0) > 0: # Top end
if h_value != 0: # H is not 0
bpy.context.view_layer.objects.active = obj
obj.rotation_euler.x = 0
obj.rotation_euler.y = 0
obj.rotation_euler.z = math.radians(step_h * h_value + 180)
else: # H is 0
bpy.context.view_layer.objects.active = obj
obj.rotation_euler.x = 0
obj.rotation_euler.y = 0
obj.rotation_euler.z = math.pi
else: # Bottom end
if h_value != 0: # H is not 0
bpy.context.view_layer.objects.active = obj
obj.rotation_euler.x = 0
obj.rotation_euler.y = math.pi
obj.rotation_euler.z = math.radians(step_h * h_value)
else: # H is 0
bpy.context.view_layer.objects.active = obj
obj.rotation_euler.x = 0
obj.rotation_euler.y = math.pi
obj.rotation_euler.z = 0
'''
if 90%step_v == 0:
if f"H0" not in obj.name:
bpy.data.objects.remove(obj)
print("UpDown")
elif "-" not in obj.name:
bpy.context.view_layer.objects.active = obj
bpy.ops.constraint.apply(constraint="Track To", owner='OBJECT')
obj.rotation_euler.z = math.pi
print("UpDown")
else:
bpy.context.view_layer.objects.active = obj
bpy.ops.constraint.apply(constraint="Track To", owner='OBJECT')
obj.rotation_euler.z = math.pi
obj.rotation_euler.x = math.pi
obj.rotation_euler.y = 0
print("UpDown")
'''
if d < 0:
mark = abs(d) / step_v
elif u < 0:
mark = abs(u) / step_v
try:
for ma in range(math.floor(mark)):
if f"V{ma}" in obj.name:
bpy.data.objects.remove(obj)
elif f"V{-ma}" in obj.name:
bpy.data.objects.remove(obj)
except:
pass
def execute(self, context):
mari.cancel_all = False
_mari_set_render_display_for_preview()
_mari_cleanup_temp_collection(context.scene)
prop = context.scene.mari_props
mari.did_focal = False
mari.generated = True
mari.cameras = []
for obj in list(bpy.data.objects):
if obj.name.startswith("MARI_"):
bpy.data.objects.remove(obj)
_mari_purge_orphan_mari_camera_data()
def _cleanup_and_restore():
# --- Restore user's render settings if we temporarily changed them ---
if hasattr(mari, "_restore_format"):
context.scene.render.image_settings.file_format = mari._restore_format
context.scene.render.ffmpeg.codec = mari._restore_codec
context.scene.render.ffmpeg.format = mari._restore_format_ffmpeg
del mari._restore_format
del mari._restore_codec
del mari._restore_format_ffmpeg
try:
context.scene.render.image_settings.file_format = mari.og_type
context.scene.render.ffmpeg.format = mari.ffmpeg_format
context.scene.render.ffmpeg.codec = mari.ffmpeg_codec
context.scene.render.image_settings.color_mode = mari.ffmpeg_color_mode
except Exception:
pass
try:
_mari_restore_render_display()
except Exception:
pass
if not mari.cancel_all:
_mari_write_media_zip(prop)
try:
_mari_clear_render_active()
except Exception:
pass
return None
circle = self.AddCircle()
circle.location = prop.circle_location
circle.rotation_euler = Euler([-prop.circle_rotation[0], -prop.circle_rotation[1], prop.circle_rotation[2] + math.pi])
self.AddCameras(circle)
try:
prop.updateCameras(context)
except Exception:
pass
prop.frame_uniform_radius = mari.last_radius
prop.cam_num = 0
for obj_cam in bpy.data.objects:
if obj_cam.name.startswith("MARI_CAMERA"):
mari.cameras.append(obj_cam)
prop.cam_num += 1
try:
_mari_apply_camera_visual_scale(context.scene)
except Exception:
pass
return {'FINISHED'}
class MARI_OT_Circle_render(Operator):
bl_idname = f"{addon_prefix}.circle_render"
bl_label = "Render"
action: StringProperty(default="STILL")
def execute(self, context):
try:
mari_detect_mode_from_scene(context.scene)
except Exception:
pass
prop = context.scene.mari_props
if not _mari_output_path_set(prop):
self.report({'ERROR'}, "Please set the Output Folder in MARI Render Settings before rendering.")
return {'CANCELLED'}
if getattr(prop, "frame", "") != "CRICLE":
self.report({'ERROR'}, "Scene has a Circle; switch mode to CIRCLE in the panel before rendering.")
return {'CANCELLED'}
_mari_mark_render_active(context.scene)
_mari_start_cancel_watcher(context)
mari.render_action = self.action
mari.render_queue = None
mari.cancel_all = False
_mari_set_render_display_for_preview()
mari._manual_seq_render = False
mari._manual_video_render = False
mari._last_still_target = None
prop = context.scene.mari_props
prop.render_settings_filepath = bpy.path.abspath(prop.render_settings_filepath)
frames_total = context.scene.frame_end - context.scene.frame_start + 1
is_video_mode = _mari_ff_type_from_format(context.scene.render.image_settings.file_format) == "ANIM"
mari.og_type = context.scene.render.image_settings.file_format
mari.ffmpeg_format = context.scene.render.ffmpeg.format
mari.ffmpeg_codec = context.scene.render.ffmpeg.codec
mari.ffmpeg_color_mode = context.scene.render.image_settings.color_mode
if self.action == "STILL" and is_video_mode:
self.report({'ERROR'}, "Cannot render STILL directly to video. Switch to an image format or use ANIM.")
try:
_mari_clear_render_active()
except Exception:
pass
return {'CANCELLED'}
# CIRCLE mode keeps the user's scene resolution exactly as configured.
def _cleanup_and_restore():
try:
context.scene.render.image_settings.file_format = mari.og_type
context.scene.render.ffmpeg.format = mari.ffmpeg_format
context.scene.render.ffmpeg.codec = mari.ffmpeg_codec
context.scene.render.image_settings.color_mode = mari.ffmpeg_color_mode
except Exception:
pass
try:
mari.render_queue = None
except Exception:
pass
try:
_mari_restore_render_display()
except Exception:
pass
if not mari.cancel_all:
_mari_write_media_zip(prop)
try:
out_root = os.path.join(bpy.path.abspath(prop.render_settings_filepath), prop.render_settings_name)
_mari_cleanup_temp_dirs(out_root)
except Exception:
pass
try:
_mari_clear_render_active()
except Exception:
pass
return None
if mari.cameras == []:
for c in bpy.data.objects:
if c.name.startswith("MARI_CAMERA"):
mari.cameras.append(c)
def get_ff(ff):
return _mari_extension_from_format(ff, context.scene)
def ff_type(ff):
return _mari_ff_type_from_format(ff)
def get_rendered_type(folder):
for file in os.listdir(folder + "\\"):
if os.path.splitext(file)[1] != "" and not file.endswith(".mari3d"):
return os.path.splitext(file)[1].replace(".", ""), "NA"
elif os.path.splitext(file)[1] == "":
for file in os.listdir(folder + file + "\\"):
return os.path.splitext(file)[1].replace(".", ""), "SEQ"
def next_render(*_args, **_kwargs):
if mari.cancel_all:
_cleanup_and_restore()
return None
if mari.render_action == "STILL":
_mari_fix_still_output_name(getattr(mari, "_last_still_target", None))
mari._last_still_target = None
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
def render_complete(scene):
if getattr(mari, "_manual_seq_render", False):
return
try:
bpy.app.handlers.render_complete.remove(render_complete)
except Exception:
pass
if mari.cancel_all:
_cleanup_and_restore()
return
if mari.render_action == "ANIM" and is_video_mode:
_mari_fix_video_output_name(getattr(mari, "_last_video_target", None))
mari._last_video_target = None
bpy.app.timers.register(next_render, first_interval=0.15)
# (Re)register the handler cleanly
for _h in list(bpy.app.handlers.render_complete):
if getattr(_h, "__name__", "") == "render_complete":
try:
bpy.app.handlers.render_complete.remove(_h)
except Exception:
pass
bpy.app.handlers.render_complete.append(render_complete)
def render_start(*_args, **_kwargs):
queue = mari.render_queue if getattr(mari, "render_queue", None) else mari.cameras
if mari.cancel_all or mari.next_cam_index >= len(queue):
_cleanup_and_restore()
return None
try:
cam = queue[mari.next_cam_index]
base_dir = os.path.join(prop.render_settings_filepath, prop.render_settings_name)
cam_base = f"{prop.render_settings_name}_H{cam['H']}_V{cam['V']}"
# Use the shared helper that already knows how to extract the video extension
# from the scene's FFMPEG settings.
video_ext = _mari_video_extension(context.scene)
ftype = get_ff(context.scene.render.image_settings.file_format.lower())
context.scene.camera = cam
if mari.render_action == "STILL":
filepath = os.path.join(base_dir, f"{cam_base}.{ftype}")
context.scene.render.filepath = filepath
mari._last_still_target = filepath
if os.path.isfile(filepath) and not context.scene.render.use_overwrite:
if _mari_is_valid_render_output(filepath):
print(f"[MARI] CIRCLE STILL: skipped existing {filepath}")
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return
_mari_maybe_write_placeholder(context.scene, filepath)
_mari_invoke_render(write_still=True)
elif mari.render_action == "ANIM" and not is_video_mode:
seq_dir = os.path.join(base_dir, cam_base)
os.makedirs(seq_dir, exist_ok=True)
prefix = prop.render_settings_name + "_"
if not context.scene.render.use_overwrite:
missing = _mari_missing_sequence_frames(seq_dir, prefix, ftype, context.scene.frame_start, context.scene.frame_end)
if not missing:
print(f"[MARI] CIRCLE ANIM-SEQ: skipped (frames already present) {seq_dir}")
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return
mari._manual_seq_render = True
_mari_render_sequence_frames(context.scene, seq_dir, prefix, ftype, missing)
mari._manual_seq_render = False
bpy.app.timers.register(next_render, first_interval=0.2)
return
context.scene.render.filepath = os.path.join(seq_dir, prop.render_settings_name + "_")
_mari_invoke_render(animation=True)
else:
start_f = context.scene.frame_start
end_f = context.scene.frame_end
final_base = os.path.join(base_dir, cam_base)
final_path = f"{final_base}.{video_ext}"
context.scene.render.filepath = final_base
_mari_fix_video_output_name(final_path)
if os.path.isfile(final_path) and not context.scene.render.use_overwrite:
if _mari_is_valid_render_output(final_path):
print(f"[MARI] CIRCLE ANIM-VIDEO: skipped existing {final_path}")
mari.next_cam_index += 1
bpy.app.timers.register(render_start, first_interval=0.5)
return
_mari_maybe_write_placeholder(context.scene, final_path)
mari._last_video_target = final_path
_mari_invoke_render(animation=True)
for _h in list(bpy.app.handlers.render_complete):
if getattr(_h, "__name__", "") == "render_complete":
try:
bpy.app.handlers.render_complete.remove(_h)
except Exception:
pass
bpy.app.handlers.render_complete.append(render_complete)
except Exception as ex:
print(ex)
if prop.mari_save_media:
from zipfile import ZipFile, ZIP_DEFLATED
filepath = prop.render_settings_name
os.chdir(prop.render_settings_filepath + filepath + "\\")
_mari_write_media_zip(prop)
mari.next_cam_index = 0
if not _mari_output_path_set(prop):
self.report({'ERROR'}, "Please set the Output Folder in MARI Render Settings before rendering.")
return {'CANCELLED'}
# Normalise the base project directory once, but do NOT
# pre-create any per-camera H_V folders here.
base_dir = prop.render_settings_filepath + prop.render_settings_name + "\\"
if context.scene.render.use_overwrite:
# Overwrite ON: wipe the whole project folder and recreate it.
if os.path.isdir(base_dir):
shutil.rmtree(base_dir)
os.makedirs(base_dir)
# Per-camera folders are created on-demand inside render_start()
# when that specific camera actually starts rendering.
else:
# Overwrite OFF: ensure the project folder exists, but do not
# pre-create H_V folders and do not try to interpret existing
# files by extension. Resume/skip logic is handled later
# inside render_start(), per camera.
if not os.path.isdir(base_dir):
os.makedirs(base_dir)
try:
mari.render_queue = None
if not context.scene.render.use_overwrite:
base_dir = bpy.path.abspath(prop.render_settings_filepath + prop.render_settings_name + "\\")
if ff_type(context.scene.render.image_settings.file_format) == "STILL":
import re
ext = get_ff(context.scene.render.image_settings.file_format.lower())
existing = set()
if self.action == "STILL":
pat = re.compile(rf"^{re.escape(prop.render_settings_name)}_H(-?\d+)_V(-?\d+)\.{ext}$", re.IGNORECASE)
if os.path.isdir(base_dir):
for fn in os.listdir(base_dir):
m = pat.match(fn)
if m:
full = os.path.join(base_dir, fn)
if _mari_is_valid_render_output(full):
existing.add((int(m.group(1)), int(m.group(2))))
mari.render_queue = [cam for cam in mari.cameras if (cam.get('H'), cam.get('V')) not in existing]
elif self.action == "ANIM":
frames_needed = context.scene.frame_end - context.scene.frame_start + 1
subpat = re.compile(rf"^{re.escape(prop.render_settings_name)}_H(-?\d+)_V(-?\d+)$", re.IGNORECASE)
if os.path.isdir(base_dir):
for entry in os.scandir(base_dir):
if entry.is_dir():
m = subpat.match(entry.name)
if m:
hv = (int(m.group(1)), int(m.group(2)))
cnt = 0
for subf in os.scandir(entry.path):
if subf.is_file() and subf.name.lower().endswith("." + ext):
if _mari_is_valid_render_output(subf.path):
cnt += 1
if cnt >= frames_needed:
existing.add(hv)
mari.render_queue = [cam for cam in mari.cameras if (cam.get('H'), cam.get('V')) not in existing]
except Exception:
mari.render_queue = None
bpy.ops.mari.export_mari(action="RENDER", type="CIRCLE", format=self.action)
render_start()
return {'FINISHED'}
def invoke(self, context, event):
import os
wm = context.window_manager
prop = context.scene.mari_props
base = bpy.path.abspath(prop.render_settings_filepath + prop.render_settings_name + "\\")
needs_confirm = False
if context.scene.render.use_overwrite and os.path.isdir(base):
try:
needs_confirm = any(os.scandir(base))
except Exception:
needs_confirm = False
if needs_confirm:
self._overwrite_warning = True
return wm.invoke_props_dialog(self, width=520)
return wm.invoke_confirm(self, event)
def draw(self, context):
if getattr(self, "_overwrite_warning", False):
layout = self.layout
prop = context.scene.mari_props
col = layout.column()
col.alert = True
col.label(text="Overwrite is ON and the output folder contains files.", icon='ERROR')
col.label(text="All contents in this folder will be deleted before rendering.")
col.separator()
col.label(text=f"Folder: {bpy.path.abspath(prop.render_settings_filepath)}{prop.render_settings_name}\\", icon='FILE_FOLDER')
class MARI_OT_RenderOne(bpy.types.Operator):
"""Render exactly one MARI camera (and optionally one frame) with identical behavior to the main MARI pipeline."""
bl_idname = f"{addon_prefix}.render_one"
bl_label = "Render One MARI Camera"
bl_options = {'INTERNAL'}
camera_name: bpy.props.StringProperty(name="Camera Name")
action: bpy.props.EnumProperty(items=[("STILL","STILL",""),("ANIM","ANIM","")], default="STILL")
frame: bpy.props.IntProperty(name="Frame", default=-1) # -1 means "current"
def execute(self, context):
import traceback
try:
return self._execute_impl(context)
except Exception as exc:
tb = traceback.format_exc()
print(f"[MARI] [RenderOne] FATAL error on camera {self.camera_name}: {exc}")
print(tb)
self.report({'ERROR'}, str(exc))
return {'CANCELLED'}
def _execute_impl(self, context):
import numpy as np, time, glob, re, bpy_extras
print(f"[MARI] [RenderOne] camera={self.camera_name} action={self.action} frame={self.frame}")
scn = context.scene
_mari_set_render_one_status(scn, "CANCELLED")
def _return_status(status, result):
_mari_set_render_one_status(scn, status)
return result
prop = scn.mari_props
mode = _mari_normalize_mode_id(getattr(prop, "frame", _MARI_MODE_CIRCLE))
if mode == "FRAME" and not _mari_require_cv2_for_frame(self):
return {'CANCELLED'}
cam = bpy.data.objects.get(self.camera_name)
if not cam:
self.report({'ERROR'}, f"Camera not found: {self.camera_name}")
print(f"[MARI] [RenderOne] ERROR: camera not found: {self.camera_name}")
return {'CANCELLED'}
# Ensure camera is active
scn.camera = cam
# Keep original formats to restore on exit (matches your pipeline)
og_ff = scn.render.image_settings.file_format
og_fmt = scn.render.ffmpeg.format
og_codec = scn.render.ffmpeg.codec
og_col = scn.render.image_settings.color_mode
og_use_ext = getattr(scn.render, "use_file_extension", True)
render_state = _mari_capture_render_settings(scn)
is_video_output = (_mari_ff_type_from_format(og_ff) == "ANIM")
# FRAME mode uses frame ratio. CIRCLE mode keeps current scene resolution.
if mode == "FRAME":
_mari_apply_frame_resolution(scn, prop)
def _restore_formats():
try:
scn.render.image_settings.file_format = og_ff
scn.render.ffmpeg.format = og_fmt
scn.render.ffmpeg.codec = og_codec
scn.render.image_settings.color_mode = og_col
scn.render.use_file_extension = og_use_ext
except Exception:
pass
try:
_mari_restore_render_settings(scn, render_state)
except Exception:
pass
if self.action == "STILL" and is_video_output:
self.report({'ERROR'}, "Cannot render STILL directly to video. Switch to an image format or use ANIM.")
_restore_formats()
return {'CANCELLED'}
# ---- file naming identical to your operator ----
def _ff(ext):
return _mari_extension_from_format(ext, scn)
H = int(cam.get("H", 0)); V = int(cam.get("V", 0))
real_x = int(prop.frame_ratio[0]); real_y = int(prop.frame_ratio[1])
expected_image_size = _mari_expected_image_output_size(scn, prop, mode)
# Build base paths just like your Frame/Circle render ops
base_dir = bpy.path.abspath(prop.render_settings_filepath)
name = prop.render_settings_name
ext = _mari_extension_from_format(scn.render.image_settings.file_format, scn)
root_dir = os.path.join(base_dir, name)
os.makedirs(root_dir, exist_ok=True)
still_path = os.path.join(root_dir, f"{name}_H{H}_V{V}.{ext}")
seq_dir = os.path.join(root_dir, f"{name}_H{H}_V{V}")
frame_inside = None
# Select Frame-vs-Circle behavior
if mode == "FRAME":
try:
real_x = int(prop.frame_ratio[0])
real_y = int(prop.frame_ratio[1])
ok, err = _mari_refresh_frame_quads(context, prop, real_x, real_y)
if not ok:
self.report({'ERROR'}, err or _mari_frame_border_message(cam.name))
return {'CANCELLED'}
array = _mari_cam_quad(cam, scn, prop)
if not array or len(array) < 4:
msg = _mari_frame_border_message(cam.name)
self.report({'ERROR'}, msg)
print(f"[MARI] [RenderOne] ERROR: {msg}")
return {'CANCELLED'}
flip_x, flip_y = _mari_frame_warp_flip_flags(scn, cam, prop, array, real_x, real_y)
# Render
if self.action == "ANIM" and is_video_output:
# Video output path mirrors the main pipeline: temp PNGs -> warp -> VSE build
os.makedirs(root_dir, exist_ok=True)
fstart, fend = scn.frame_start, scn.frame_end
cam_id = f"{name}_H{H}_V{V}"
temp_tag = f"{name}_{fstart:04d}-{fend:04d}_H{H}_V{V}"
video_ext = _mari_video_extension(scn)
temp_dir = os.path.join(root_dir, temp_tag + "_TEMP")
final_path = os.path.join(root_dir, cam_id + f".{video_ext}")
_mari_fix_video_output_name(final_path)
if os.path.isfile(final_path) and not scn.render.use_overwrite:
if _mari_is_valid_render_output(final_path):
print(f"[MARI] [RenderOne] ANIM-VIDEO skip existing {final_path}")
return _return_status("SKIPPED", {'FINISHED'})
_mari_maybe_write_placeholder(scn, final_path)
if scn.render.use_overwrite and os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
os.makedirs(temp_dir, exist_ok=True)
prefix = name + "_"
if not scn.render.use_overwrite:
missing = _mari_missing_sequence_frames(temp_dir, prefix, "png", fstart, fend)
if not missing:
frames = sorted(f for f in os.listdir(temp_dir) if f.lower().endswith(".png"))
if os.path.exists(_persp_flag_path(temp_dir)):
for fname in frames:
fpath = os.path.join(temp_dir, fname)
img = cv2.imread(fpath, cv2.IMREAD_UNCHANGED)
if img is None:
continue
try:
out = _mari_warp_image(img, array, real_x, real_y, flip=flip_x, flip_y=flip_y)
cv2.imwrite(fpath, out)
except Exception as e:
print(f"[MARI] [RenderOne] WARN: warp failed for {fpath}: {e}")
_clear_persp_flag(temp_dir)
_mari_build_video_from_temp(bpy.context, temp_dir, frames, final_path)
shutil.rmtree(temp_dir, ignore_errors=True)
print(f"[MARI] [RenderOne] FINISHED ANIM-VIDEO H{H} V{V} -> {final_path}")
return _return_status("SKIPPED", {'FINISHED'})
_ensure_persp_flag(temp_dir)
_mari_render_animation_to_temp_png(scn, temp_dir, prefix, frame_start=fstart, frame_end=fend)
frames = sorted(f for f in os.listdir(temp_dir) if f.lower().endswith(".png"))
if not frames:
_clear_persp_flag(temp_dir)
shutil.rmtree(temp_dir, ignore_errors=True)
self.report({'ERROR'}, "No frames rendered for video build.")
return {'CANCELLED'}
for fname in frames:
fpath = os.path.join(temp_dir, fname)
img = cv2.imread(fpath, cv2.IMREAD_UNCHANGED)
if img is None:
continue
try:
out = _mari_warp_image(img, array, real_x, real_y, flip=flip_x, flip_y=flip_y)
cv2.imwrite(fpath, out)
except Exception as e:
print(f"[MARI] [RenderOne] WARN: warp failed for {fpath}: {e}")
_clear_persp_flag(temp_dir)
_mari_build_video_from_temp(bpy.context, temp_dir, frames, final_path)
shutil.rmtree(temp_dir, ignore_errors=True)
print(f"[MARI] [RenderOne] FINISHED ANIM-VIDEO H{H} V{V} -> {final_path}")
elif self.action == "STILL":
if not scn.render.use_overwrite and os.path.isfile(still_path):
if _mari_is_valid_render_output(still_path) and _mari_image_matches_expected_size(still_path, expected_image_size):
print(f"[MARI] [RenderOne] STILL skip existing {still_path}")
return _return_status("SKIPPED", {'FINISHED'})
_mari_maybe_write_placeholder(scn, still_path)
scn.render.filepath = still_path
t0 = time.time()
print(f"[MARI] [RenderOne] START STILL H{H} V{V} -> {still_path}")
bpy.ops.render.render(animation=False, write_still=True, use_viewport=False)
_mari_fix_still_output_name(still_path)
# Read back and warp/flip in-place
def _wait(path, timeout=3.0, tick=0.05):
last = -1; t = time.time()
while time.time() - t < timeout:
if os.path.exists(path):
s = os.path.getsize(path)
if s > 0 and s == last:
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
if img is not None:
return img
last = s
time.sleep(tick)
return None
img = _wait(still_path)
if img is None:
_mari_fix_still_output_name(still_path)
img = _wait(still_path, timeout=1.0)
if img is None:
self.report({'ERROR'}, "Rendered image not found/ready for warp.")
print(f"[MARI] [RenderOne] ERROR: still not found for H{H} V{V}")
return {'CANCELLED'}
try:
out = _mari_warp_image(img, array, real_x, real_y, flip=flip_x, flip_y=flip_y)
save_args = [cv2.IMWRITE_JPEG_QUALITY, 100] if ext in {"jpeg", "jpg"} else []
if save_args:
cv2.imwrite(still_path, out, save_args)
else:
cv2.imwrite(still_path, out)
except Exception as e:
print(f"[MARI] [RenderOne] WARN: warp failed for {still_path}: {e}")
print(f"[MARI] [RenderOne] FINISHED STILL H{H} V{V} ({time.time()-t0:.2f}s)")
else: # ANIM — render exactly one frame to the per-camera subfolder
if self.frame >= 0:
scn.frame_set(self.frame)
os.makedirs(seq_dir, exist_ok=True)
tmp_ff = og_ff
if _mari_ff_type_from_format(og_ff) == "ANIM":
tmp_ff = "PNG"
scn.render.image_settings.file_format = tmp_ff
# Force a clean load from disk to avoid writing an old buffer
try:
bpy.ops.image.new(name="__mari_tmp_flush__", width=1, height=1)
except Exception:
pass
frame_val = scn.frame_current
ext_for_seq = _mari_extension_from_format(tmp_ff, scn)
# Bake the frame number directly into the filepath; disable auto-extension to avoid double suffix.
scn.render.filepath = os.path.join(seq_dir, f"{name}_{frame_val:04d}.{ext_for_seq}")
try:
scn.render.use_file_extension = False
except Exception:
pass
t0 = time.time()
outp = scn.render.filepath
print(f"[MARI] [RenderOne] START ANIM H{H} V{V} f{scn.frame_current} -> {outp}")
if not scn.render.use_overwrite and os.path.isfile(outp):
if _mari_is_valid_render_output(outp) and _mari_image_matches_expected_size(outp, expected_image_size):
print(f"[MARI] [RenderOne] ANIM skip existing {outp}")
return _return_status("SKIPPED", {'FINISHED'})
_mari_maybe_write_placeholder(scn, outp)
bpy.ops.render.render(animation=False, write_still=True, use_viewport=False)
if os.path.isfile(outp):
img = cv2.imread(outp, cv2.IMREAD_UNCHANGED)
if img is not None:
try:
out = _mari_warp_image(img, array, real_x, real_y, flip=flip_x, flip_y=flip_y)
cv2.imwrite(outp, out)
except Exception as e:
print(f"[MARI] [RenderOne] WARN: warp failed for {outp}: {e}")
print(f"[MARI] [RenderOne] FINISHED ANIM H{H} V{V} f{scn.frame_current} ({time.time()-t0:.2f}s)")
return _return_status("RENDERED", {'FINISHED'})
finally:
try:
if frame_inside:
bpy.data.objects.remove(frame_inside, do_unlink=True)
except Exception:
pass
# CIRCLE mode no corner checks, no cv2
if self.action == "ANIM" and is_video_output:
os.makedirs(root_dir, exist_ok=True)
fstart, fend = scn.frame_start, scn.frame_end
cam_id = f"{name}_H{H}_V{V}"
temp_tag = f"{name}_{fstart:04d}-{fend:04d}_H{H}_V{V}"
video_ext = _mari_video_extension(scn)
temp_dir = os.path.join(root_dir, temp_tag + "_TEMP")
final_path = os.path.join(root_dir, cam_id + f".{video_ext}")
_mari_fix_video_output_name(final_path)
if os.path.isfile(final_path) and not scn.render.use_overwrite:
if _mari_is_valid_render_output(final_path):
print(f"[MARI] [RenderOne] ANIM-VIDEO (CIRCLE) skip existing {final_path}")
_restore_formats()
return _return_status("SKIPPED", {'FINISHED'})
_mari_maybe_write_placeholder(scn, final_path)
if scn.render.use_overwrite and os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
os.makedirs(temp_dir, exist_ok=True)
prefix = name + "_"
if not scn.render.use_overwrite:
missing = _mari_missing_sequence_frames(temp_dir, prefix, "png", fstart, fend)
if not missing:
frames = sorted(f for f in os.listdir(temp_dir) if f.lower().endswith(".png"))
if frames:
_mari_build_video_from_temp(bpy.context, temp_dir, frames, final_path)
shutil.rmtree(temp_dir, ignore_errors=True)
print(f"[MARI] [RenderOne] FINISHED ANIM-VIDEO (CIRCLE) -> {final_path}")
_restore_formats()
return _return_status("SKIPPED", {'FINISHED'})
_mari_render_animation_to_temp_png(scn, temp_dir, prefix, frame_start=fstart, frame_end=fend)
frames = sorted(f for f in os.listdir(temp_dir) if f.lower().endswith(".png"))
if not frames:
self.report({'ERROR'}, "No frames rendered for video build.")
_restore_formats()
return {'CANCELLED'}
_mari_build_video_from_temp(bpy.context, temp_dir, frames, final_path)
shutil.rmtree(temp_dir, ignore_errors=True)
print(f"[MARI] [RenderOne] FINISHED ANIM-VIDEO (CIRCLE) -> {final_path}")
_restore_formats()
return {'FINISHED'}
if self.action == "STILL":
if not scn.render.use_overwrite and os.path.isfile(still_path):
if _mari_is_valid_render_output(still_path) and _mari_image_matches_expected_size(still_path, expected_image_size):
print(f"[MARI] [RenderOne] STILL (CIRCLE) skip existing {still_path}")
_restore_formats()
return _return_status("SKIPPED", {'FINISHED'})
_mari_maybe_write_placeholder(scn, still_path)
scn.render.filepath = still_path
try:
scn.render.use_file_extension = True
except Exception:
pass
t0 = time.time()
print(f"[MARI] [RenderOne] START STILL (CIRCLE) H{H} V{V} -> {still_path}")
bpy.ops.render.render(animation=False, write_still=True, use_viewport=False)
_mari_fix_still_output_name(still_path)
print(f"[MARI] [RenderOne] FINISHED STILL (CIRCLE) H{H} V{V} ({time.time()-t0:.2f}s)")
else:
if self.frame >= 0:
scn.frame_set(self.frame)
os.makedirs(seq_dir, exist_ok=True)
tmp_ff = og_ff
if _mari_ff_type_from_format(og_ff) == "ANIM":
tmp_ff = "PNG"
scn.render.image_settings.file_format = tmp_ff
frame_val = scn.frame_current
ext_for_seq = _mari_extension_from_format(tmp_ff, scn)
# Bake frame number directly to avoid literal #### on workers
scn.render.filepath = os.path.join(seq_dir, f"{name}_{frame_val:04d}.{ext_for_seq}")
try:
scn.render.use_file_extension = False
except Exception:
pass
t0 = time.time()
outp = scn.render.filepath
print(f"[MARI] [RenderOne] START ANIM (CIRCLE) H{H} V{V} f{scn.frame_current} -> {outp}")
if not scn.render.use_overwrite and os.path.isfile(outp):
if _mari_is_valid_render_output(outp) and _mari_image_matches_expected_size(outp, expected_image_size):
print(f"[MARI] [RenderOne] ANIM (CIRCLE) skip existing {outp}")
_restore_formats()
return _return_status("SKIPPED", {'FINISHED'})
_mari_maybe_write_placeholder(scn, outp)
bpy.ops.render.render(animation=False, write_still=True, use_viewport=False)
print(f"[MARI] [RenderOne] FINISHED ANIM (CIRCLE) H{H} V{V} f{scn.frame_current} ({time.time()-t0:.2f}s)")
_restore_formats()
return _return_status("RENDERED", {'FINISHED'})
class MARI_OT_Multi_render(bpy.types.Operator):
bl_idname = f"{addon_prefix}.multi_render"
bl_label = "Render MARI Image (MULTI)"
bl_options = {'REGISTER', 'INTERNAL'}
action: bpy.props.EnumProperty(items=[("STILL","STILL",""),("ANIM","ANIM","")], default="STILL")
# transient flags for invoke -> execute flow
_ask_type: str = ""
_wipe_target: str = ""
_missing_multi: bool = False
def _target_dir(self, context):
prop = context.scene.mari_props
base = bpy.path.abspath(getattr(prop, "render_settings_filepath", ""))
name = getattr(prop, "render_settings_name", "").strip()
return os.path.join(base, name)
def _dir_has_files(self, path):
try:
with os.scandir(path) as it:
for e in it:
return True
except Exception:
pass
return False
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
if self._ask_type == "OPEN_URL":
col.label(text="This operation requires the HoloMARI MULTIRENDER addon,", icon='INFO')
col.label(text="which can improve render times.")
col.separator()
col.operator("wm.url_open", text="Download Addon", icon='URL').url = "https://holomari.com/info/pages/MultiRender"
elif self._ask_type == "WIPE":
col.label(text="Overwrite is ON. This will DELETE all files in:", icon='ERROR')
col.label(text=self._wipe_target, icon='FILE_FOLDER')
def invoke(self, context, event=None):
wm = context.window_manager
prop = context.scene.mari_props
try:
mari_detect_mode_from_scene(context.scene)
except Exception:
pass
if not _mari_output_path_set(prop):
self.report({'ERROR'}, "Please set the Output Folder in MARI Render Settings before rendering.")
return {'CANCELLED'}
# (3) MultiRender missing -> note + confirm to open URL
if not _mari_has_multi():
self._missing_multi = True
self._ask_type = "OPEN_URL"
return wm.invoke_props_dialog(self, width=460)
# Ensure directory exists (1)
tgt = self._target_dir(context)
try:
os.makedirs(tgt, exist_ok=True)
except Exception as e:
self.report({'ERROR'}, f"Failed to prepare folder: {tgt} ({e})")
return {'CANCELLED'}
# (2/5) If overwrite is ON and directory already has files -> confirm wipe
if context.scene.render.use_overwrite and self._dir_has_files(tgt):
self._wipe_target = tgt
self._ask_type = "WIPE"
return wm.invoke_props_dialog(self, width=520)
# No confirmations needed, just run
return self.execute(context)
def execute(self, context):
import json
prop = context.scene.mari_props
try:
mari_detect_mode_from_scene(context.scene)
except Exception:
pass
if not _mari_output_path_set(prop):
self.report({'ERROR'}, "Please set the Output Folder in MARI Render Settings before rendering.")
return {'CANCELLED'}
# Handle "open URL" confirmation
if self._missing_multi and self._ask_type == "OPEN_URL":
return {'CANCELLED'}
mode = "FRAME" if _mari_normalize_mode_id(getattr(prop, "frame", _MARI_MODE_CIRCLE)) == _MARI_MODE_FRAME else "CIRCLE"
if mode == "FRAME" and not _mari_require_cv2_for_frame(self):
return {'CANCELLED'}
# (5) If user confirmed wipe, empty the target folder now
if self._ask_type == "WIPE" and self._wipe_target:
try:
# remove files & subfolders but keep the root folder
for entry in os.scandir(self._wipe_target):
p = os.path.join(self._wipe_target, entry.name)
if entry.is_file() or entry.is_symlink():
try: os.remove(p)
except Exception: pass
else:
try: shutil.rmtree(p)
except Exception: pass
print(f"[MARI] Cleared folder: {self._wipe_target}")
except Exception as e:
self.report({'ERROR'}, f"Failed to clear folder: {e}")
return {'CANCELLED'}
# Gather MARI cameras exactly like the main pipeline
cams = _mari_collect_scene_camera_jobs(context.scene)
if not cams:
self.report({'ERROR'}, "No MARI cameras found. Please generate cameras first.")
return {'CANCELLED'}
if mode == "FRAME":
real_x = int(prop.frame_ratio[0])
real_y = int(prop.frame_ratio[1])
ok, err = _mari_refresh_frame_quads(context, prop, real_x, real_y)
if not ok:
self.report({'ERROR'}, err)
return {'CANCELLED'}
for cam_obj in bpy.data.objects:
if not cam_obj.name.startswith("MARI_CAMERA"):
continue
if len(cam_obj.get("verts") or []) < 4:
self.report({'ERROR'}, _mari_frame_border_message(cam_obj.name))
return {'CANCELLED'}
plan = _mari_plan_multi_resume_jobs(context.scene, prop, mode, self.action)
jobs = plan.get("jobs") or []
if self.action == "ANIM" and not plan.get("is_video_output", False) and plan.get("expected_frames", 0) <= 0:
self.report({'ERROR'}, "No frames to render (check frame start/end).")
return {'CANCELLED'}
if not context.scene.render.use_overwrite:
if self.action == "ANIM" and not plan.get("is_video_output", False):
print(
"[MARI] Multi resume plan:"
f" cameras={plan.get('total_cameras', 0)}"
f" complete_cameras={plan.get('complete_cameras', 0)}"
f" ready_frames={plan.get('ready_frames', 0)}"
f" pending_frames={plan.get('pending_frames', 0)}"
f" root='{plan.get('root_dir', '')}'"
)
else:
print(
"[MARI] Multi resume plan:"
f" cameras={plan.get('total_cameras', 0)}"
f" complete_cameras={plan.get('complete_cameras', 0)}"
f" pending_cameras={plan.get('pending_cameras', 0)}"
f" root='{plan.get('root_dir', '')}'"
)
if not jobs:
if self.action == "ANIM" and not plan.get("is_video_output", False):
self.report({'INFO'}, "All requested MARI sequence frames already exist for the current scene cameras.")
else:
self.report({'INFO'}, "All requested MARI outputs already exist for the current scene cameras.")
return {'FINISHED'}
job_json = json.dumps({"jobs": jobs})
print(f"[MARI] Launching Multi-Instance in MARI mode. jobs={len(jobs)} mode={mode} action={self.action}")
try:
return bpy.ops.render.multi_gpu_mari(
'INVOKE_DEFAULT',
job_json=job_json,
mode=("CIRCLE" if mode != "FRAME" else "FRAME"),
action=self.action
)
except Exception as e:
self.report({'ERROR'}, f"Failed to call multi_gpu_mari: {e}")
print(f"[MARI] ERROR invoking multi_gpu_mari: {e}")
return {'CANCELLED'}
class MARI_OT_GenerateMedia(Operator, ImportHelper):
"""Generate Media File from Directory Files"""
bl_idname = f"{addon_prefix}.generate_media"
#bl_label = "Construct MARI Media from folder" ##NOT .marijpeg
bl_label = "Construct MARI (.zip) from folder"
filename_ext = "*.mari3d"
filter_glob: StringProperty(
default="*.mari3d",
options={'HIDDEN'},
maxlen=255,
)
def execute(self, context):
import json
import re
import bpy
# Delay heavy work until after the file browser closes to avoid Blender crashes.
try:
if not hasattr(mari, "media_deferred"):
mari.media_deferred = False
except Exception:
pass
def _has_file_browser():
try:
ctx = bpy.context
if ctx.area and ctx.area.type == 'FILE_BROWSER':
return True
wm = ctx.window_manager
for window in wm.windows:
screen = window.screen
if not screen:
continue
for area in screen.areas:
if area.type == 'FILE_BROWSER':
return True
except Exception:
return True
return False
if not getattr(mari, "media_deferred", False) and _has_file_browser():
filepath = self.filepath
try:
self.report({'INFO'}, "Waiting for file browser to close before converting.")
except Exception:
pass
print(f"[MARI] GenerateMedia deferred until file browser closes: {filepath}")
def _delayed():
ctx = bpy.context
if _has_file_browser():
return 0.2
mari.media_deferred = True
try:
print(f"[MARI] GenerateMedia running deferred: {filepath}")
bpy.ops.mari.generate_media(filepath=filepath)
except Exception as exc:
print(f"[MARI] GenerateMedia deferred run failed: {exc}")
finally:
mari.media_deferred = False
return None
bpy.app.timers.register(_delayed, first_interval=0.2)
return {"FINISHED"}
scene = context.scene
prop = context.scene.mari_props
print(f"[MARI] GenerateMedia start: {self.filepath}")
try:
self._upscale_warning = False
except Exception:
pass
try:
with open(self.filepath, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as exc:
self.report({'ERROR'}, f"Failed to read .mari3d: {exc}")
return {"CANCELLED"}
default_size = None
default_fps = None
try:
requires_frame = int(data.get("requiresframe", 0)) == 1
if requires_frame:
rx = data.get("ratiox")
ry = data.get("ratioy")
if rx and ry:
default_size = (int(rx), int(ry))
else:
framex = data.get("framex")
framey = data.get("framey")
if framex and framey:
default_size = (int(framex), int(framey))
else:
framex = data.get("framex")
framey = data.get("framey")
if framex and framey:
default_size = (int(framex), int(framey))
else:
rx = data.get("ratiox")
ry = data.get("ratioy")
if rx and ry:
default_size = (int(rx), int(ry))
except Exception:
pass
try:
if data.get("framerate"):
default_fps = int(data.get("framerate"))
except Exception:
pass
project_name = os.path.splitext(os.path.basename(self.filepath))[0]
src_root = os.path.dirname(self.filepath)
video_exts = set(_MARI_FFMPEG_EXTENSIONS.values())
image_exts = set(_MARI_IMAGE_EXTENSIONS.values()) - video_exts
image_exts.add("jpg")
image_exts.add("tiff")
def _scan_source(root):
has_seq = False
has_still = False
has_video = False
ext = None
for entry in os.scandir(root):
name = entry.name.lower()
if name.endswith(".mari3d") or name.endswith(".txt"):
continue
if entry.is_dir():
for sub in os.scandir(entry.path):
if not sub.is_file():
continue
s_ext = os.path.splitext(sub.name)[1].lower().lstrip(".")
if s_ext in image_exts:
has_seq = True
ext = s_ext
break
elif entry.is_file():
s_ext = os.path.splitext(entry.name)[1].lower().lstrip(".")
if s_ext in video_exts:
has_video = True
ext = s_ext
elif s_ext in image_exts:
has_still = True
ext = s_ext
if has_seq:
return "SEQ", ext
if has_video:
return "VIDEO", ext
if has_still:
return "STILL", ext
return None, None
src_kind, src_ext = _scan_source(src_root)
if not src_kind:
self.report({'ERROR'}, "No MARI media files found in the selected folder.")
return {"CANCELLED"}
target_fmt = scene.render.image_settings.file_format
try:
if hasattr(scene.render.image_settings, "media_type") and scene.render.image_settings.media_type == "VIDEO":
if _mari_ff_type_from_format(target_fmt) != "ANIM":
target_fmt = "FFMPEG"
except Exception:
pass
target_is_video = _mari_ff_type_from_format(target_fmt) == "ANIM"
target_ext = _mari_extension_from_format(target_fmt, scene)
if target_is_video:
video_exts.add(target_ext)
else:
image_exts.add(target_ext)
if target_is_video and src_kind == "STILL":
self.report({'ERROR'}, "Cannot convert a still-image MARI into a video MARI.")
return {"CANCELLED"}
if target_is_video:
target_kind = "VIDEO"
else:
target_kind = "SEQ" if src_kind in {"SEQ", "VIDEO"} else "STILL"
cam_keys = []
for item in data.get("angledata", []):
try:
cam_keys.append(str(item[0]))
except Exception:
pass
cam_keys = sorted(set(cam_keys), key=len, reverse=True)
def _match_key(name):
for key in cam_keys:
if f"_{key}" in name:
return key
return None
def _frame_index(name):
m = re.search(r"(\d+)(?=\.[^.]+$)", name)
return int(m.group(1)) if m else None
def _frame_prefix(name):
stem = os.path.splitext(name)[0]
m = re.search(r"(.*?)(\d+)$", stem)
if m:
return m.group(1)
return stem + "_"
def _seq_files(folder_path, ext):
files = []
for f in os.scandir(folder_path):
if not f.is_file():
continue
if os.path.splitext(f.name)[1].lower().lstrip(".") == ext:
files.append(f.path)
files.sort(key=lambda p: _frame_index(os.path.basename(p)) or 0)
return files
def _ext_matches(ext, target):
ext = (ext or "").lower().lstrip(".")
if target == "jpeg":
return ext in {"jpeg", "jpg"}
return ext == target
def _find_output_file(folder, base_name, allowed_exts, allow_no_ext=False):
for f in os.scandir(folder):
if not f.is_file():
continue
name, ext = os.path.splitext(f.name)
if name != base_name and not name.startswith(base_name):
continue
ext_clean = ext.lower().lstrip(".")
if not ext_clean and allow_no_ext:
return f.path
if ext_clean in allowed_exts:
return f.path
return None
def _dir_has_ext(folder, allowed_exts, allow_no_ext=False):
for f in os.scandir(folder):
if not f.is_file():
continue
ext_clean = os.path.splitext(f.name)[1].lower().lstrip(".")
if not ext_clean and allow_no_ext:
return True
if ext_clean in allowed_exts:
return True
return False
def _list_media_files(folder, allowed_exts, allow_no_ext=False):
results = set()
for f in os.scandir(folder):
if not f.is_file():
continue
ext_clean = os.path.splitext(f.name)[1].lower().lstrip(".")
if not ext_clean and allow_no_ext:
results.add(f.name)
elif ext_clean in allowed_exts:
results.add(f.name)
return results
strip_prefix = f"MARI_CONVERT_{uuid.uuid4().hex[:8]}"
def _capture_attr_dict(obj, attrs):
data = {}
for attr in attrs:
if hasattr(obj, attr):
try:
data[attr] = getattr(obj, attr)
except Exception:
pass
return data
def _restore_attr_dict(obj, data):
for attr, value in data.items():
if hasattr(obj, attr):
try:
setattr(obj, attr, value)
except Exception:
pass
def _restore_image_settings(image_settings, state):
if not state:
return
if "media_type" in state and hasattr(image_settings, "media_type"):
try:
image_settings.media_type = state["media_type"]
except Exception:
pass
if "file_format" in state and hasattr(image_settings, "file_format"):
try:
image_settings.file_format = state["file_format"]
except Exception:
pass
for attr, value in state.items():
if attr in {"media_type", "file_format"}:
continue
if hasattr(image_settings, attr):
try:
setattr(image_settings, attr, value)
except Exception:
pass
image_attrs = (
"media_type",
"file_format",
"color_mode",
"color_depth",
"compression",
"quality",
"exr_codec",
"tiff_codec",
"jpeg2k_codec",
"use_preview",
)
ffmpeg_attrs = (
"format",
"codec",
"use_autosplit",
"audio_codec",
"audio_channels",
"audio_mixrate",
"audio_bitrate",
"audio_volume",
"use_lossless_output",
"constant_rate_factor",
"ffmpeg_preset",
"gopsize",
"max_b_frames",
"use_max_b_frames",
"video_bitrate",
"minrate",
"maxrate",
"buffersize",
"muxrate",
"packetsize",
)
def _capture_render_state(scene_obj):
return {
"filepath": scene_obj.render.filepath,
"use_sequencer": scene_obj.render.use_sequencer,
"use_compositing": scene_obj.render.use_compositing,
"use_overwrite": scene_obj.render.use_overwrite,
"use_file_extension": scene_obj.render.use_file_extension,
"res_x": scene_obj.render.resolution_x,
"res_y": scene_obj.render.resolution_y,
"res_pct": scene_obj.render.resolution_percentage,
"fps": scene_obj.render.fps,
"fps_base": scene_obj.render.fps_base,
"frame_start": scene_obj.frame_start,
"frame_end": scene_obj.frame_end,
"frame_current": scene_obj.frame_current,
}
def _restore_render_state(scene_obj, state):
try:
scene_obj.render.filepath = state.get("filepath", scene_obj.render.filepath)
except Exception:
pass
try:
scene_obj.render.use_sequencer = state.get("use_sequencer", scene_obj.render.use_sequencer)
except Exception:
pass
try:
scene_obj.render.use_compositing = state.get("use_compositing", scene_obj.render.use_compositing)
except Exception:
pass
try:
scene_obj.render.use_overwrite = state.get("use_overwrite", scene_obj.render.use_overwrite)
except Exception:
pass
try:
scene_obj.render.use_file_extension = state.get("use_file_extension", scene_obj.render.use_file_extension)
except Exception:
pass
try:
scene_obj.render.resolution_x = state.get("res_x", scene_obj.render.resolution_x)
scene_obj.render.resolution_y = state.get("res_y", scene_obj.render.resolution_y)
scene_obj.render.resolution_percentage = state.get("res_pct", scene_obj.render.resolution_percentage)
except Exception:
pass
try:
scene_obj.render.fps = state.get("fps", scene_obj.render.fps)
scene_obj.render.fps_base = state.get("fps_base", scene_obj.render.fps_base)
except Exception:
pass
try:
scene_obj.frame_start = state.get("frame_start", scene_obj.frame_start)
scene_obj.frame_end = state.get("frame_end", scene_obj.frame_end)
except Exception:
pass
try:
scene_obj.frame_set(state.get("frame_current", scene_obj.frame_current))
except Exception:
pass
def _set_file_format(scene_obj, fmt):
image_settings = scene_obj.render.image_settings
try:
if hasattr(image_settings, "media_type"):
desired_media = "VIDEO" if (_mari_ff_type_from_format(fmt) == "ANIM" or target_is_video) else "IMAGE"
if image_settings.media_type != desired_media:
image_settings.media_type = desired_media
except Exception:
pass
try:
enum_items = image_settings.bl_rna.properties["file_format"].enum_items
if fmt not in enum_items.keys():
if (target_is_video or _mari_ff_type_from_format(fmt) == "ANIM") and "FFMPEG" in enum_items.keys():
fmt = "FFMPEG"
else:
return False
except Exception:
pass
try:
image_settings.file_format = fmt
return True
except Exception:
return False
def _valid_size(size):
if not size:
return None
try:
w = int(size[0])
h = int(size[1])
except Exception:
return None
if w < 8 or h < 8:
return None
return (w, h)
def _set_resolution(scene_obj, size):
size = _valid_size(size) or _valid_size(default_size)
if not size:
size = _valid_size((scene_obj.render.resolution_x, scene_obj.render.resolution_y))
if not size:
return
if target_is_video:
w, h = size
if w % 2:
w -= 1
if h % 2:
h -= 1
if w < 16 or h < 16:
fallback = _valid_size(default_size)
if fallback:
w, h = fallback
if w >= 2 and h >= 2:
size = (w, h)
try:
scene_obj.render.resolution_x = int(size[0])
scene_obj.render.resolution_y = int(size[1])
scene_obj.render.resolution_percentage = 100
except Exception:
pass
def _image_size(path):
img = None
try:
try:
img = bpy.data.images.load(path, check_existing=False)
except TypeError:
img = bpy.data.images.load(path)
if not img:
return None
return _valid_size((int(img.size[0]), int(img.size[1])))
finally:
if img:
try:
bpy.data.images.remove(img, do_unlink=True)
except Exception:
pass
def _video_size(path):
clip = None
try:
try:
clip = bpy.data.movieclips.load(path, check_existing=False)
except TypeError:
clip = bpy.data.movieclips.load(path)
if not clip:
return None
return _valid_size((int(clip.size[0]), int(clip.size[1])))
finally:
if clip:
try:
bpy.data.movieclips.remove(clip, do_unlink=True)
except Exception:
pass
def _release_media_cache(paths):
norm_paths = []
for p in paths:
if not p:
continue
try:
norm_paths.append(os.path.normcase(os.path.abspath(p)))
except Exception:
pass
if not norm_paths:
return
def _in_paths(filepath):
if not filepath:
return False
try:
abs_path = os.path.abspath(bpy.path.abspath(filepath))
except Exception:
abs_path = os.path.abspath(filepath)
abs_path = os.path.normcase(abs_path)
return any(abs_path.startswith(root) for root in norm_paths)
for img in list(bpy.data.images):
try:
if img.users == 0 and _in_paths(getattr(img, "filepath", "")):
bpy.data.images.remove(img, do_unlink=True)
except Exception:
pass
for clip in list(bpy.data.movieclips):
try:
if clip.users == 0 and _in_paths(getattr(clip, "filepath", "")):
bpy.data.movieclips.remove(clip, do_unlink=True)
except Exception:
pass
for snd in list(getattr(bpy.data, "sounds", [])):
try:
if snd.users == 0 and _in_paths(getattr(snd, "filepath", "")):
bpy.data.sounds.remove(snd, do_unlink=True)
except Exception:
pass
use_blend_resolution = bool(getattr(prop, "mari_use_blend_resolution", False))
def _blend_target_size():
if not use_blend_resolution:
return None
try:
pct = float(scene.render.resolution_percentage or 100.0)
w = int(round(scene.render.resolution_x * pct / 100.0))
h = int(round(scene.render.resolution_y * pct / 100.0))
return _valid_size((w, h))
except Exception:
return None
blend_size = _blend_target_size()
def _target_size_from_source(src_size):
if use_blend_resolution and blend_size:
return blend_size
return src_size or default_size
def _first_source_size():
if src_kind == "SEQ":
for d in os.scandir(src_root):
if not d.is_dir():
continue
files = _seq_files(d.path, src_ext)
if files:
return _image_size(files[0])
elif src_kind == "STILL":
for f in os.scandir(src_root):
if f.is_file() and os.path.splitext(f.name)[1].lower().lstrip(".") == src_ext:
return _image_size(f.path)
elif src_kind == "VIDEO":
for f in os.scandir(src_root):
if f.is_file() and os.path.splitext(f.name)[1].lower().lstrip(".") in video_exts:
return _video_size(f.path)
return None
source_size = _first_source_size()
resize_needed = False
if use_blend_resolution and blend_size and source_size:
resize_needed = (blend_size[0] != source_size[0]) or (blend_size[1] != source_size[1])
if (blend_size[0] > source_size[0]) or (blend_size[1] > source_size[1]):
if not getattr(self, "_upscale_confirmed", False):
self._upscale_warning = True
self._upscale_source = source_size
self._upscale_target = blend_size
self._upscale_confirmed = True
self.report({'WARNING'}, "Target resolution is larger than source.")
try:
return context.window_manager.invoke_props_dialog(self, width=460)
except Exception:
self.report({'ERROR'}, "Upscale confirmation could not be shown.")
return {"CANCELLED"}
needs_conversion = (src_kind != target_kind) or (src_ext != target_ext) or resize_needed
out_root = src_root
if needs_conversion:
out_root = src_root + f"_to_{target_ext}"
if os.path.isdir(out_root):
_release_media_cache([out_root])
shutil.rmtree(out_root)
os.makedirs(out_root, exist_ok=True)
try:
shutil.copy2(self.filepath, os.path.join(out_root, os.path.basename(self.filepath)))
except Exception:
pass
def _ensure_strip_collection(scene_obj):
se = scene_obj.sequence_editor
if se is None:
se = scene_obj.sequence_editor_create()
strip_coll = _mari_get_vse_strip_collection(se)
if strip_coll is None:
raise RuntimeError("SequenceEditor has no strips collection.")
return strip_coll
def _clear_mari_strips(strip_coll):
try:
for s in list(strip_coll):
if s.name.startswith("MARI_CONVERT_") or s.name.startswith(strip_prefix):
strip_coll.remove(s)
except Exception:
pass
def _build_image_strip(strip_coll, files, frame_start):
first_path = files[0]
try:
strip = strip_coll.new_image(name=f"{strip_prefix}_SEQ", filepath=first_path, channel=1, frame_start=frame_start)
except TypeError:
strip = strip_coll.new_image(f"{strip_prefix}_SEQ", first_path, 1, frame_start)
directory = os.path.dirname(first_path)
if not directory.endswith(os.sep):
directory += os.sep
strip.directory = directory
first_name = os.path.basename(first_path)
if strip.elements:
strip.elements[0].filename = first_name
else:
strip.elements.append(first_name)
for fpath in files[1:]:
strip.elements.append(os.path.basename(fpath))
strip.frame_start = frame_start
try:
strip.frame_final_duration = len(files)
except Exception:
pass
return strip
def _build_movie_strip(strip_coll, filepath, frame_start):
try:
strip = strip_coll.new_movie(name=f"{strip_prefix}_MOV", filepath=filepath, channel=1, frame_start=frame_start)
except TypeError:
strip = strip_coll.new_movie(f"{strip_prefix}_MOV", filepath, 1, frame_start)
strip.frame_start = frame_start
frame_count = getattr(strip, "frame_final_duration", 0)
if not frame_count:
try:
frame_count = int(strip.frame_final_end - strip.frame_final_start)
except Exception:
frame_count = 0
return strip, frame_count
def _apply_strip_fill(strip, src_size, dst_size):
if not strip or not src_size or not dst_size:
return
try:
sw, sh = float(src_size[0]), float(src_size[1])
dw, dh = float(dst_size[0]), float(dst_size[1])
except Exception:
return
if sw <= 0 or sh <= 0:
return
scale = max(dw / sw, dh / sh)
try:
strip.transform.scale_x = scale
strip.transform.scale_y = scale
strip.transform.offset_x = 0.0
strip.transform.offset_y = 0.0
except Exception:
try:
strip.transform.scale = scale
except Exception:
pass
def _render_scene(scene_obj, animation=False, write_still=False):
try:
scene_obj.render.use_sequencer = True
scene_obj.render.use_compositing = False
except Exception:
pass
result = None
try:
override = {"scene": scene_obj}
if scene_obj.view_layers:
override["view_layer"] = scene_obj.view_layers[0]
with bpy.context.temp_override(**override):
try:
result = bpy.ops.render.render(animation=animation, write_still=write_still, use_viewport=False)
except TypeError:
result = bpy.ops.render.render(animation=animation, write_still=write_still)
except Exception:
result = None
if result is None:
try:
result = bpy.ops.render.render(animation=animation, write_still=write_still, use_viewport=False)
except TypeError:
result = bpy.ops.render.render(animation=animation, write_still=write_still)
if isinstance(result, set) and "FINISHED" in result:
return result
raise RuntimeError(f"Render failed: {result}")
if needs_conversion:
sandbox = scene
render_state = _capture_render_state(sandbox)
image_state = _capture_attr_dict(sandbox.render.image_settings, image_attrs)
ffmpeg_state = _capture_attr_dict(sandbox.render.ffmpeg, ffmpeg_attrs)
strip_coll = None
try:
strip_coll = _ensure_strip_collection(sandbox)
sandbox.render.use_sequencer = True
sandbox.render.use_compositing = False
sandbox.render.use_overwrite = True
sandbox.render.use_file_extension = True
if default_fps:
try:
sandbox.render.fps = int(default_fps)
sandbox.render.fps_base = 1.0
except Exception:
pass
if not _set_file_format(sandbox, target_fmt):
raise RuntimeError("Output format not supported in this Blender build.")
_restore_attr_dict(sandbox.render.ffmpeg, ffmpeg_state)
if src_kind == "SEQ":
seq_dirs = [d for d in os.scandir(src_root) if d.is_dir()]
if target_kind == "SEQ":
if src_ext != target_ext:
for d in seq_dirs:
files = _seq_files(d.path, src_ext)
if not files:
continue
dst_dir = os.path.join(out_root, d.name)
os.makedirs(dst_dir, exist_ok=True)
prefix = _frame_prefix(os.path.basename(files[0]))
frame_start = _frame_index(os.path.basename(files[0])) or 1
src_size = _image_size(files[0]) or default_size
target_size = _target_size_from_source(src_size)
_set_resolution(sandbox, target_size)
dst_size = (sandbox.render.resolution_x, sandbox.render.resolution_y)
strip_coll = _ensure_strip_collection(sandbox)
_clear_mari_strips(strip_coll)
strip = _build_image_strip(strip_coll, files, frame_start)
if use_blend_resolution:
_apply_strip_fill(strip, src_size, dst_size)
sandbox.frame_start = frame_start
sandbox.frame_end = frame_start + len(files) - 1
sandbox.render.filepath = os.path.join(dst_dir, prefix)
if not _set_file_format(sandbox, target_fmt):
raise RuntimeError("Output format not supported in this Blender build.")
_restore_attr_dict(sandbox.render.ffmpeg, ffmpeg_state)
_render_scene(sandbox, animation=True)
if not any(
f.is_file() and _ext_matches(os.path.splitext(f.name)[1], target_ext)
for f in os.scandir(dst_dir)
):
raise RuntimeError(f"No output frames written for {d.name}.")
elif target_kind == "VIDEO":
for d in seq_dirs:
files = _seq_files(d.path, src_ext)
if not files:
continue
start = _frame_index(os.path.basename(files[0])) or 1
end = _frame_index(os.path.basename(files[-1])) or (start + len(files) - 1)
key = _match_key(d.name)
if key:
base_name = f"{project_name}_{start:04d}-{end:04d}_{key}"
else:
base_name = f"{d.name}_{start:04d}-{end:04d}"
out_path = os.path.join(out_root, f"{base_name}.{target_ext}")
src_size = _image_size(files[0]) or default_size
target_size = _target_size_from_source(src_size)
_set_resolution(sandbox, target_size)
dst_size = (sandbox.render.resolution_x, sandbox.render.resolution_y)
strip_coll = _ensure_strip_collection(sandbox)
_clear_mari_strips(strip_coll)
strip = _build_image_strip(strip_coll, files, start)
if use_blend_resolution:
_apply_strip_fill(strip, src_size, dst_size)
sandbox.frame_start = start
sandbox.frame_end = start + len(files) - 1
sandbox.render.filepath = os.path.splitext(out_path)[0]
if not _set_file_format(sandbox, target_fmt):
raise RuntimeError("Output format not supported in this Blender build.")
_restore_attr_dict(sandbox.render.ffmpeg, ffmpeg_state)
before = _list_media_files(out_root, video_exts, allow_no_ext=True)
_render_scene(sandbox, animation=True)
after = _list_media_files(out_root, video_exts, allow_no_ext=True)
if not _find_output_file(out_root, base_name, video_exts, allow_no_ext=True) and not (after - before):
raise RuntimeError(f"No output video written for {base_name}.")
elif src_kind == "STILL":
if target_kind == "STILL" and src_ext != target_ext:
for f in os.scandir(src_root):
if not f.is_file():
continue
if os.path.splitext(f.name)[1].lower().lstrip(".") != src_ext:
continue
base = os.path.splitext(f.name)[0]
out_path = os.path.join(out_root, f"{base}.{target_ext}")
src_size = _image_size(f.path) or default_size
target_size = _target_size_from_source(src_size)
_set_resolution(sandbox, target_size)
dst_size = (sandbox.render.resolution_x, sandbox.render.resolution_y)
strip_coll = _ensure_strip_collection(sandbox)
_clear_mari_strips(strip_coll)
strip = _build_image_strip(strip_coll, [f.path], 1)
if use_blend_resolution:
_apply_strip_fill(strip, src_size, dst_size)
sandbox.frame_start = 1
sandbox.frame_end = 1
try:
sandbox.frame_set(1)
except Exception:
pass
sandbox.render.filepath = os.path.splitext(out_path)[0]
if not _set_file_format(sandbox, target_fmt):
raise RuntimeError("Output format not supported in this Blender build.")
_restore_attr_dict(sandbox.render.ffmpeg, ffmpeg_state)
before = _list_media_files(out_root, image_exts)
_render_scene(sandbox, animation=False, write_still=True)
after = _list_media_files(out_root, image_exts)
if not _find_output_file(out_root, base, image_exts) and not (after - before):
raise RuntimeError(f"No output image written for {base}.")
elif src_kind == "VIDEO":
video_files = [f for f in os.scandir(src_root) if f.is_file() and os.path.splitext(f.name)[1].lower().lstrip(".") in video_exts]
if target_kind == "SEQ":
for f in video_files:
key = _match_key(f.name)
if key:
folder_name = f"{project_name}_{key}"
else:
folder_name = os.path.splitext(f.name)[0]
dst_dir = os.path.join(out_root, folder_name)
os.makedirs(dst_dir, exist_ok=True)
src_size = _video_size(f.path) or default_size
target_size = _target_size_from_source(src_size)
_set_resolution(sandbox, target_size)
dst_size = (sandbox.render.resolution_x, sandbox.render.resolution_y)
strip_coll = _ensure_strip_collection(sandbox)
_clear_mari_strips(strip_coll)
_strip, frame_count = _build_movie_strip(strip_coll, f.path, 1)
if use_blend_resolution:
_apply_strip_fill(_strip, src_size, dst_size)
if not frame_count:
frame_count = 1
sandbox.frame_start = 1
sandbox.frame_end = 1 + frame_count - 1
sandbox.render.filepath = os.path.join(dst_dir, f"{project_name}_")
if not _set_file_format(sandbox, target_fmt):
raise RuntimeError("Output format not supported in this Blender build.")
_restore_attr_dict(sandbox.render.ffmpeg, ffmpeg_state)
_render_scene(sandbox, animation=True)
if not any(
f2.is_file() and _ext_matches(os.path.splitext(f2.name)[1], target_ext)
for f2 in os.scandir(dst_dir)
):
raise RuntimeError(f"No output frames written for {folder_name}.")
elif target_kind == "VIDEO" and src_ext != target_ext:
for f in video_files:
base = os.path.splitext(f.name)[0]
out_path = os.path.join(out_root, f"{base}.{target_ext}")
src_size = _video_size(f.path) or default_size
target_size = _target_size_from_source(src_size)
_set_resolution(sandbox, target_size)
dst_size = (sandbox.render.resolution_x, sandbox.render.resolution_y)
strip_coll = _ensure_strip_collection(sandbox)
_clear_mari_strips(strip_coll)
_strip, frame_count = _build_movie_strip(strip_coll, f.path, 1)
if use_blend_resolution:
_apply_strip_fill(_strip, src_size, dst_size)
if not frame_count:
frame_count = 1
sandbox.frame_start = 1
sandbox.frame_end = 1 + frame_count - 1
sandbox.render.filepath = os.path.splitext(out_path)[0]
if not _set_file_format(sandbox, target_fmt):
raise RuntimeError("Output format not supported in this Blender build.")
_restore_attr_dict(sandbox.render.ffmpeg, ffmpeg_state)
before = _list_media_files(out_root, video_exts, allow_no_ext=True)
_render_scene(sandbox, animation=True)
after = _list_media_files(out_root, video_exts, allow_no_ext=True)
if not _find_output_file(out_root, base, video_exts, allow_no_ext=True) and not (after - before):
raise RuntimeError(f"No output video written for {base}.")
except Exception as exc:
self.report({'ERROR'}, f"Failed to convert MARI media: {exc}")
return {"CANCELLED"}
finally:
try:
if strip_coll is not None:
_clear_mari_strips(strip_coll)
except Exception:
pass
_restore_image_settings(sandbox.render.image_settings, image_state)
_restore_attr_dict(sandbox.render.ffmpeg, ffmpeg_state)
_restore_render_state(sandbox, render_state)
_release_media_cache([src_root, out_root])
def _has_media(root):
for entry in os.scandir(root):
if entry.is_dir():
for sub in os.scandir(entry.path):
ext = os.path.splitext(sub.name)[1].lower().lstrip(".")
if ext in image_exts:
return True
elif entry.is_file():
ext = os.path.splitext(entry.name)[1].lower().lstrip(".")
if ext in image_exts or ext in video_exts:
return True
return False
if not _has_media(out_root):
self.report({'ERROR'}, "No output media was generated.")
return {"CANCELLED"}
zip_base = os.path.dirname(src_root)
zip_path = os.path.join(zip_base, f"{project_name}.zip")
from zipfile import ZipFile, ZIP_DEFLATED
with ZipFile(zip_path, 'w', ZIP_DEFLATED) as zipf:
zip_abs = os.path.abspath(zip_path)
for root, _dirs, files in os.walk(out_root):
for file in files:
full = os.path.join(root, file)
if os.path.normcase(os.path.abspath(full)) == os.path.normcase(zip_abs):
continue
rel = os.path.relpath(full, start=out_root)
arcname = rel
zipf.write(full, arcname=arcname)
mari_name = f"{project_name}.mari3d"
mari_path = os.path.join(out_root, mari_name)
if not os.path.exists(mari_path):
src_mari = os.path.join(src_root, mari_name)
if os.path.exists(src_mari):
zipf.write(src_mari, arcname=mari_name)
print(f"[MARI] Constructed MARI zip: {zip_path}")
self.report({'INFO'}, f"Constructed MARI zip: {zip_path}")
return {"FINISHED"}
def draw(self, context):
layout = self.layout
if getattr(self, "_upscale_warning", False):
col = layout.column()
col.alert = True
col.label(text="Target resolution is larger than source.", icon='ERROR')
src = getattr(self, "_upscale_source", None)
tgt = getattr(self, "_upscale_target", None)
if src and tgt:
col.label(text=f"Source: {src[0]} x {src[1]} Target: {tgt[0]} x {tgt[1]}")
col.label(text="Press OK to proceed and upscale, or Cancel to stop.")
return
layout.template_image_settings(context.scene.render.image_settings, color_management=False)
layout.use_property_split = True
layout.use_property_decorate = False
if context.scene.render.image_settings.file_format == "FFMPEG":
layout.separator()
rd = context.scene.render
ffmpeg = rd.ffmpeg
layout.prop(rd.ffmpeg, "format")
layout.prop(ffmpeg, "use_autosplit")
layout.separator()
ffmpeg = context.scene.render.ffmpeg
needs_codec = ffmpeg.format in {'AVI', 'QUICKTIME', 'MKV', 'OGG', 'MPEG4', 'WEBM'}
if needs_codec:
layout.prop(ffmpeg, "codec")
if needs_codec and ffmpeg.codec == 'NONE':
return
if ffmpeg.codec == 'DNXHD':
layout.prop(ffmpeg, "use_lossless_output")
# Output quality
use_crf = needs_codec and ffmpeg.codec in {'H264', 'MPEG4', 'WEBM'}
if use_crf:
layout.prop(ffmpeg, "constant_rate_factor")
# Encoding speed
layout.prop(ffmpeg, "ffmpeg_preset")
# I-frames
layout.prop(ffmpeg, "gopsize")
# B-Frames
row = layout.row(align=True, heading="Max B-frames")
row.prop(ffmpeg, "use_max_b_frames", text="")
sub = row.row(align=True)
sub.active = ffmpeg.use_max_b_frames
sub.prop(ffmpeg, "max_b_frames", text="")
if not use_crf or ffmpeg.constant_rate_factor == 'NONE':
col = layout.column()
sub = col.column(align=True)
sub.prop(ffmpeg, "video_bitrate")
sub.prop(ffmpeg, "minrate", text="Minimum")
sub.prop(ffmpeg, "maxrate", text="Maximum")
col.prop(ffmpeg, "buffersize", text="Buffer")
col.separator()
col.prop(ffmpeg, "muxrate", text="Mux Rate")
col.prop(ffmpeg, "packetsize", text="Mux Packet Size")
layout.separator()
rd = context.scene.render
ffmpeg = rd.ffmpeg
if ffmpeg.format != 'MP3':
layout.prop(ffmpeg, "audio_codec", text="Audio Codec")
if ffmpeg.audio_codec != 'NONE':
layout.prop(ffmpeg, "audio_channels")
layout.prop(ffmpeg, "audio_mixrate", text="Sample Rate")
layout.prop(ffmpeg, "audio_bitrate")
layout.prop(ffmpeg, "audio_volume", slider=True)
class MARI_OT_Export_MARI(Operator):
"""Automaticly done when rendering"""
bl_idname = f"{addon_prefix}.export_mari"
bl_label = "Export Camera Setup / Settings as .mari3d"
type: StringProperty(default="FRAME")
action: StringProperty(default="PRESS")
format: StringProperty(default="STILL")
def execute(self, context):
prop = context.scene.mari_props
try:
mari_detect_mode_from_scene(context.scene)
except Exception:
pass
has_cams = any(obj.name.startswith("MARI_CAMERA") for obj in bpy.data.objects)
if mari.generated == False and self.action != "RENDER":
if not has_cams:
self.report({'ERROR'}, "(Re)Generate Cameras Before Exporting/Rendering!")
return {"FINISHED"}
mari.generated = True
if prop.frame == "FRAME":
self.type = "FRAME"
else:
self.type = "CIRCLE"
prop.render_settings_filepath = bpy.path.abspath(prop.render_settings_filepath)
export_dir = prop.render_settings_filepath
if not export_dir:
self.report({'ERROR'}, "Output Folder of MARI media cannot be found. Please Render MARI media first!")
return {"CANCELLED"}
if self.action == "PRESS":
filepath = os.path.join(export_dir, prop.render_settings_name)
else:
filepath = os.path.join(export_dir, prop.render_settings_name, prop.render_settings_name)
angledata = []
for cam in bpy.data.objects:
if cam.name.startswith("MARI_CAMERA"):
angledata.append((f"H{cam['H']}_V{cam['V']}", round((cam.location[0]**2+cam.location[1]**2+cam.location[2]**2)**0.5,4), cam.data.lens, cam["INTR"]))
if self.type == "FRAME":
if self.action == "PRESS":
export_planes = bool(prop.frame_advanced_possition and prop.mari_save_planes)
if export_planes:
plane_objs = []
for item in prop.frame_collision_planes:
obj = bpy.data.objects.get(item.name)
if obj and obj.type == 'MESH':
plane_objs.append(obj)
if plane_objs:
prev_active = context.view_layer.objects.active
prev_selected = [o for o in bpy.data.objects if o.select_get()]
bpy.ops.object.select_all(action='DESELECT')
for obj in plane_objs:
obj.select_set(True)
context.view_layer.objects.active = plane_objs[0]
try:
bpy.ops.wm.obj_export(filepath=filepath + ".obj", check_existing=True, filter_blender=False, filter_backup=False, filter_image=False, filter_movie=False, filter_python=False, filter_font=False, filter_sound=False, filter_text=False, filter_archive=False, filter_btx=False, filter_alembic=False, filter_usd=False, filter_obj=False, filter_volume=False, filter_folder=True, filter_blenlib=False, filemode=8, display_type='DEFAULT', sort_method='FILE_SORT_ALPHA', export_animation=False, start_frame=-2147483648, end_frame=2147483647, forward_axis='NEGATIVE_Z', up_axis='Y', scaling_factor=1, apply_modifiers=True, export_eval_mode='DAG_EVAL_VIEWPORT', export_selected_objects=True, export_uv=True, export_normals=True, export_colors=False, export_materials=True, path_mode='AUTO', export_triangulated_mesh=False, export_curves_as_nurbs=False, export_object_groups=False, export_material_groups=False, export_vertex_groups=False, export_smooth_groups=False, smooth_group_bitflags=False, filter_glob="*.obj;*.mtl")
except TypeError:
bpy.ops.wm.obj_export(filepath=filepath + ".obj", export_selected_objects=True, apply_modifiers=True, forward_axis='NEGATIVE_Z', up_axis='Y')
try:
if os.path.exists(filepath + ".mtl"):
os.remove(filepath + ".mtl")
except Exception:
pass
bpy.ops.object.select_all(action='DESELECT')
for obj in prev_selected:
obj.select_set(True)
context.view_layer.objects.active = prev_active
mari_data = {
"requiresframe": 1,
"isflatscreen": 1,
"framex":prop.frame_dimensions[0],
"framey":prop.frame_dimensions[1],
"ratiox":prop.frame_ratio[0],
"ratioy":prop.frame_ratio[1],
"frameloc":[round(prop.frame_center[0],4),round(prop.frame_center[1],4),round(prop.frame_center[2],4)],
"framerot":[round(prop.frame_rotation[0]*57.2957795,4),round(prop.frame_rotation[1]*57.2957795,4),round((prop.frame_rotation[2])*57.2957795,4)],
"maxangleh":[round(prop.frame_camera_limit_lr[0]*57.2957795, 2), round(prop.frame_camera_limit_lr[1]*57.2957795, 2)],
"maxanglev":[round(prop.frame_camera_limit_ud[0]*57.2957795,2), round(prop.frame_camera_limit_ud[1]*57.2957795,2)],
"angleinterval":[round(prop.frame_density[0]*57.2957795, 2), round(prop.frame_density[1]*57.2957795, 2)],
"issimplesetting":1 if not prop.frame_advanced_possition else 0,
"usedynamiccam":1 if prop.frame_use_dynamic_cam else 0,
"uniformradius":round(prop.frame_uniform_radius,4),
"offset": prop.frame_advanced_offset,
"leftoverangles": 1 if prop.frame_advenced_leftover_angle else 0,
"centerviewangle":90,
"camcount":prop.cam_num,
"centerviewloc":[0, 0, 0],
"angledata":angledata,
"futureedits":1 if self.action == "PRESS" else 0,
"isvideo":1 if self.format == "ANIM" else 0,
"framerate":bpy.context.scene.render.fps if self.format == "ANIM" else 0,
"filetype":"blender_frame"
}
else:
mari_data = {
"requiresframe": 0,
"isflatscreen": 1,
"framex":context.scene.render.resolution_x,
"framey":context.scene.render.resolution_y,
"ratiox":context.scene.render.resolution_x / math.gcd(context.scene.render.resolution_x, context.scene.render.resolution_y),
"ratioy":context.scene.render.resolution_y / math.gcd(context.scene.render.resolution_x, context.scene.render.resolution_y),
"frameloc":[round(prop.circle_location[0],4),round(prop.circle_location[1],4),round(prop.circle_location[2],4)],
"framerot":[round(prop.circle_rotation[0]*57.2957795,4),round(prop.circle_rotation[1]*57.2957795,4),round((prop.circle_rotation[2])*57.2957795,4)],
"maxangleh":[round(prop.circle_max_angle_lr[0]*57.2957795, 2), round(prop.circle_max_angle_lr[1]*57.2957795, 2)],
"maxanglev":[round(prop.circle_max_angle_ud[0]*57.2957795,2), round(prop.circle_max_angle_ud[1]*57.2957795,2)],
"angleinterval":[round(prop.circle_interval[0]*57.2957795, 2), round(prop.circle_interval[1]*57.2957795, 2)],
"issimplesetting":1,
"usedynamiccam":0,
"uniformradius":round(prop.frame_uniform_radius,4),
"offset": 0,
"leftoverangles": 0,
"centerviewangle":90,
"camcount":prop.cam_num,
"centerviewloc":[0, 0, 0],
"angledata":angledata,
"futureedits":1 if self.action == "PRESS" else 0,
"isvideo":1 if self.format == "ANIM" else 0,
"framerate":bpy.context.scene.render.fps if self.format == "ANIM" else 0,
"filetype":"blender_render"
}
try:
f = open(os.path.join(filepath + ".mari3d"), "w")
f.write(json.dumps(mari_data,indent=4))
f.close()
except Exception as ex:
self.report({'ERROR'}, "Output Folder of MARI media cannot be found. Please Render MARI media first!")
self.action = "PRESS"
return {"FINISHED"}
def _mari_angledata_map(data):
angledata = data.get("angledata", [])
out = {}
for entry in angledata:
if not entry or len(entry) < 3:
continue
key = str(entry[0])
parts = key.split("_")
if len(parts) != 2 or not parts[0].startswith("H") or not parts[1].startswith("V"):
continue
try:
h_val = int(parts[0][1:])
v_val = int(parts[1][1:])
except Exception:
continue
try:
dist = float(entry[1])
except Exception:
dist = None
try:
lens = float(entry[2])
except Exception:
lens = None
intr = 0
if len(entry) > 3:
try:
intr = int(entry[3])
except Exception:
intr = 0
prev = out.get((h_val, v_val))
if prev is None or (intr and not prev.get("intr", 0)):
out[(h_val, v_val)] = {"dist": dist, "lens": lens, "intr": intr}
return out
def _mari_angledata_intr_pairs(data):
angledata = data.get("angledata", [])
pairs = set()
for entry in angledata:
if not entry or len(entry) < 3:
continue
key = str(entry[0])
parts = key.split("_")
if len(parts) != 2 or not parts[0].startswith("H") or not parts[1].startswith("V"):
continue
try:
h_val = int(parts[0][1:])
v_val = int(parts[1][1:])
except Exception:
continue
intr = 0
if len(entry) > 3:
try:
intr = int(entry[3])
except Exception:
intr = 0
if intr:
pairs.add((h_val, v_val))
return pairs
def _mari_angledata_pairs(data):
angledata = data.get("angledata", [])
pairs = set()
for entry in angledata:
if not entry or len(entry) < 3:
continue
key = str(entry[0])
parts = key.split("_")
if len(parts) != 2 or not parts[0].startswith("H") or not parts[1].startswith("V"):
continue
try:
h_val = int(parts[0][1:])
v_val = int(parts[1][1:])
except Exception:
continue
pairs.add((h_val, v_val))
return pairs
def _mari_prune_cameras_for_angledata(scene, data):
if not scene:
return
if not data or not data.get("angledata"):
return
valid_pairs = _mari_angledata_pairs(data)
if not valid_pairs:
return
intr_pairs = _mari_angledata_intr_pairs(data)
cams_by_pair = {}
to_remove = []
for cam in scene.objects:
if not cam.name.startswith("MARI_CAMERA"):
continue
try:
h_val = int(cam.get("H"))
v_val = int(cam.get("V"))
except Exception:
continue
if (h_val, v_val) not in valid_pairs:
to_remove.append(cam)
continue
bucket = cams_by_pair.get((h_val, v_val))
if bucket is None:
bucket = {"base": [], "dotted": []}
cams_by_pair[(h_val, v_val)] = bucket
if "." in cam.name:
bucket["dotted"].append(cam)
else:
bucket["base"].append(cam)
for key, bucket in cams_by_pair.items():
base_list = sorted(bucket["base"], key=lambda o: o.name)
dotted_list = sorted(bucket["dotted"], key=lambda o: o.name)
if len(base_list) > 1:
to_remove.extend(base_list[1:])
base_list = base_list[:1]
if len(dotted_list) > 1:
to_remove.extend(dotted_list[1:])
dotted_list = dotted_list[:1]
if base_list and dotted_list:
if key in intr_pairs:
to_remove.extend(base_list)
else:
to_remove.extend(dotted_list)
for cam in to_remove:
try:
bpy.data.objects.remove(cam, do_unlink=True)
except Exception:
pass
_mari_rename_cameras_by_indices(scene)
def _mari_rename_cameras_by_indices(scene):
if not scene:
return
cams = []
for cam in scene.objects:
if not cam.name.startswith("MARI_CAMERA"):
continue
try:
h_val = int(cam.get("H"))
v_val = int(cam.get("V"))
except Exception:
continue
cams.append((cam, h_val, v_val))
if not cams:
return
# Two-pass rename to avoid Blender auto-suffixing
for idx, (cam, _h, _v) in enumerate(cams):
cam.name = f"MARI_CAMERA_TMP_{idx}"
for cam, h_val, v_val in cams:
target = f"MARI_CAMERA_H{h_val}_V{v_val}"
if bpy.data.objects.get(target) in (None, cam):
cam.name = target
else:
# Fallback: keep a stable unique name without .001
cam.name = f"{target}_INTR"
def _mari_apply_angledata(scene, data, mode):
records = _mari_angledata_map(data)
if not records:
return False
center_obj = None
if mode == "FRAME":
center_obj = bpy.data.objects.get("MARI_FrameCenter")
else:
center_obj = bpy.data.objects.get("MARI_CircleCenter")
center_loc = center_obj.matrix_world.translation.copy() if center_obj else Vector((0.0, 0.0, 0.0))
for cam in scene.objects:
if not cam.name.startswith("MARI_CAMERA"):
continue
try:
h_val = int(cam.get("H"))
v_val = int(cam.get("V"))
except Exception:
continue
rec = records.get((h_val, v_val))
if not rec:
continue
if rec.get("lens") is not None and cam.data:
try:
cam.data.lens = rec["lens"]
except Exception:
pass
try:
cam["INTR"] = int(rec.get("intr", 0))
except Exception:
pass
dist = rec.get("dist")
if dist is None or dist <= 0:
continue
loc_world = cam.matrix_world.translation.copy()
dir_vec = (loc_world - center_loc)
if dir_vec.length < 1e-6:
dir_vec = cam.location.copy()
if dir_vec.length < 1e-6:
dir_vec = Vector((0.0, 0.0, 1.0))
dir_vec.normalize()
new_loc = center_loc + (dir_vec * dist)
if mode == "FRAME":
try:
direction = (center_loc - new_loc).normalized()
quat = direction.to_track_quat('-Z', 'Y')
cam.matrix_world = Matrix.Translation(new_loc) @ quat.to_matrix().to_4x4()
except Exception:
cam.matrix_world.translation = new_loc
else:
cam.matrix_world.translation = new_loc
return True
class MARI_OT_Import_mari(Operator, ImportHelper):
"""Select a .mari3d data file and import it"""
bl_idname = f"{addon_prefix}.import"
bl_label = "Import .mari3d Camera Settings"
filename_ext = ".mari3d"
filter_glob: StringProperty(
default="*.mari3d",
options={'HIDDEN'},
maxlen=255,
)
def execute(self, context):
prop = context.scene.mari_props
_mari_cleanup_temp_collection(context.scene)
f = open(self.filepath)
data = json.load(f)
print(data)
is_frame = data.get("filetype") == "render_anamorphic" or int(data.get("requiresframe", 0)) == 1
if is_frame:
prop.frame = "FRAME"
prop.frame_ratio[0] = data["ratiox"]
prop.frame_ratio[1] = data["ratioy"]
prop.frame_dimensions[0] = data["framex"]
prop.frame_dimensions[1] = data["framey"]
prop.frame_center = data["frameloc"]
prop.frame_rotation = [math.radians(data["framerot"][0]), math.radians(data["framerot"][1]), math.radians(data["framerot"][2])]
prop.frame_camera_limit_lr = [math.radians(data["maxangleh"][0]), math.radians(data["maxangleh"][1])]
prop.frame_camera_limit_ud = [math.radians(data["maxanglev"][0]), math.radians(data["maxanglev"][1])]
prop.frame_density = [math.radians(data["angleinterval"][0]), math.radians(data["angleinterval"][1])]
prop.frame_advanced_possition = True if data["issimplesetting"] == 0 else False
prop.frame_advanced_offset = data["offset"]
prop.frame_advenced_leftover_angle = bool(int(data.get("leftoverangles", 1)))
prop.frame_use_dynamic_cam = True if data["usedynamiccam"] == 1 else False
bpy.context.scene.render.fps = data["framerate"]
# Import reconstruction should not depend on live collision objects.
import_adv = prop.frame_advanced_possition
import_leftover = prop.frame_advenced_leftover_angle
prop.frame_advanced_possition = False
bpy.ops.mari.make_frame("INVOKE_DEFAULT")
mari.data = data
else:
prop.frame = "CRICLE"
prop.circle_location = data["frameloc"]
prop.circle_rotation = [math.radians(data["framerot"][0]), math.radians(data["framerot"][1]), math.radians(data["framerot"][2])]
prop.circle_interval = [math.radians(data["angleinterval"][0]), math.radians(data["angleinterval"][1])]
prop.circle_max_angle_lr = [math.radians(data["maxangleh"][0]), math.radians(data["maxangleh"][1])]
prop.circle_max_angle_ud = [math.radians(data["maxanglev"][0]), math.radians(data["maxanglev"][1])]
bpy.context.scene.render.fps = data["framerate"]
bpy.ops.mari.make_circle("INVOKE_DEFAULT")
prop.frame_uniform_radius = data["uniformradius"]
prop.frame_radius_lock = True
mari.data = data
attempts = {"count": 0}
def _apply_when_ready():
attempts["count"] += 1
cams = [obj for obj in bpy.data.objects if obj.name.startswith("MARI_CAMERA")]
if not cams:
return 0.2 if attempts["count"] < 50 else None
expected = int(data.get("camcount") or 0)
if expected and len(cams) < expected and attempts["count"] < 50:
return 0.2
mode = "FRAME" if is_frame else "CIRCLE"
if is_frame:
prop.frame_advanced_possition = import_adv
prop.frame_advenced_leftover_angle = import_leftover
_mari_prune_cameras_for_angledata(bpy.context.scene, data)
_mari_apply_angledata(bpy.context.scene, data, mode)
return None
bpy.app.timers.register(_apply_when_ready, first_interval=0.2)
try:
bpy.ops.wm.obj_import(filepath=str(self.filepath).replace("mari3d", "obj"), filter_blender=False, filter_backup=False, filter_image=False, filter_movie=False, filter_python=False, filter_font=False, filter_sound=False, filter_text=False, filter_archive=False, filter_btx=False, filter_alembic=False, filter_usd=False, filter_obj=False, filter_volume=False, filter_folder=True, filter_blenlib=False, filemode=8, display_type='DEFAULT', sort_method='FILE_SORT_ALPHA', clamp_size=0, forward_axis='NEGATIVE_Z', up_axis='Y', import_vertex_groups=False, validate_meshes=False, filter_glob="*.obj;*.mtl")
except Exception:
pass
return {"FINISHED"}
class MARI_OT_Cam_set(Operator):
"""Sets camera settings from imported file. (eg. focal length)"""
bl_idname = f"{addon_prefix}.update_cameras"
bl_label = ""
def execute(self, context):
data = getattr(mari, "data", None)
if not data or "angledata" not in data:
self.report({'ERROR'}, "No imported MARI data found.")
return {"CANCELLED"}
mode = "FRAME" if data.get("filetype") == "render_anamorphic" or data.get("requiresframe") == 1 else "CIRCLE"
_mari_prune_cameras_for_angledata(context.scene, data)
_mari_apply_angledata(context.scene, data, mode)
return {"FINISHED"}
##########################
######## PANEL ###########
class MARI_panel_settings:
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "MARI"
class MARI_PT_Panel(MARI_panel_settings, Panel):
bl_label = "MARI Camera Generator"
bl_idname = f"{addon_prefix}.panel_main"
def draw(self, context):
layout = self.layout
prop = context.scene.mari_props
row = layout.row()
row.label(text="Upload MARI Media")
row = layout.row()
row.scale_y = 2.0
row.alert = True
row.operator("wm.url_open", text="holomari.com", icon='URL').url = "https://holomari.com/dashboard"
row = layout.row()
row.operator("wm.url_open", text="MARI Image Viewer", icon='URL').url = "https://holomari.com/info/viewer/index"
layout.separator()
# MARIJPEG ONLY NOTICE
notice = layout.column(align=True)
notice.alert = True
notice.label(text="Tip: As of now, the HoloMARI")
notice.label(text="website only supports images!")
box = layout.box()
row = box.row()
row.prop(prop, "frame", expand=True)
if prop.frame == "FRAME":
box.separator()
row = box.row()
row.scale_y = .2
row.label(text="Ratio")
row = box.row()
row.prop(prop, "frame_ratio", text="")
row = box.row()
row.scale_y = .2
row.label(text="Dimensions")
row = box.row()
row.prop(prop, "frame_dimensions", text="")
box.prop(prop, "frame_center")
box.prop(prop, "frame_rotation")
row = box.row()
row.prop(prop, "frame_density")
row = box.row()
row.prop(prop, "frame_camera_limit_lr")
row = box.row()
row.prop(prop, "frame_camera_limit_ud")
row = box.row()
#row.prop(prop, "frame_use_dynamic_cam")
row = box.row(align=True)
row.prop(prop, "frame_uniform_radius")
row = row.column(align=True)
row.prop(prop, "frame_radius_lock", icon="UNLOCKED" if prop.frame_radius_lock == False else "LOCKED", text="")
row = box.row()
row.label(text="Select Camera Collision Objects for Advanced Generation")
row = box.row()
row.prop(prop, "frame_advanced_possition", text="Use Advanced")
adv_col = box.column()
adv_col.enabled = prop.frame_advanced_possition
adv_col.prop(prop, "frame_advenced_leftover_angle")
adv_col.prop(prop, "frame_advanced_offset", text="Offset")
box.separator()
row = box.row()
row.scale_y = 1.5
row.operator(MARI_OT_GenerateFrame.bl_idname)
row = box.row()
else:
box.separator()
box.prop(prop, "circle_radius")
box.prop(prop, "circle_location", text="Center Location")
box.prop(prop, "circle_rotation", text="Center Rotation")
row = box.row()
row.prop(prop, "circle_interval")
row = box.row()
row.prop(prop, "circle_max_angle_lr")
row = box.row()
row.prop(prop, "circle_max_angle_ud")
row = box.row(align=True)
row.prop(prop, "frame_uniform_radius")
row.prop(prop, "frame_radius_lock", icon="UNLOCKED" if prop.frame_radius_lock == False else "LOCKED", text="")
box.separator()
row = box.row()
row.scale_y = 1.5
row.operator(MARI_OT_GenerateCircle.bl_idname)
mari.cam_num = prop.cam_num
mari.is_txt_on = 1
row.label(text=f"MARI Camera Count: {prop.cam_num}")
class MARI_PT_CameraSettings(MARI_panel_settings, Panel):
bl_label = "Camera Settings"
bl_idname = f"{addon_prefix}.cam_settings"
def draw(self, context):
layout = self.layout
prop = context.scene.mari_props
row = layout.row()
row.prop(prop, "camera_settings_clip_start")
layout.separator()
row = layout.row()
row.prop(prop, "camera_settings_clip_end")
layout.separator()
row = layout.row()
row.active = not mari.did_focal
row.prop(prop, "camera_settings_lens")
layout.separator()
self.layout.prop(prop, "camera_settings_dof_use", text="Depth of field")
layout = self.layout
layout.use_property_split = True
split = layout.split()
col = split.column()
col.active = prop.camera_settings_dof_use
col.prop(prop, "camera_settings_dof_object", text="Focus Object")
sub = col.row()
sub.active = prop.camera_settings_dof_object is None
sub.prop(prop, "camera_settings_dof_distance", text="Distance")
class MARI_PT_CAM_DOF_APERATURE(MARI_panel_settings, Panel):
bl_label = "Aperture"
bl_parent_id = f"{addon_prefix}.cam_settings"
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
layout = self.layout
layout.use_property_split = True
prop = context.scene.mari_props
layout.active = prop.camera_settings_dof_use
flow = layout.grid_flow(row_major=True, columns=0, even_columns=True, even_rows=False, align=False)
col = flow.column()
col.prop(prop, "camera_settings_dof_fstop")
col.prop(prop, "camera_settings_dof_blades")
col.prop(prop, "camera_settings_dof_rotation")
col.prop(prop, "camera_settings_dof_ratio")
class MARI_PT_RenderSettings(MARI_panel_settings, Panel):
bl_label = "Render Settings"
bl_idname = f"{addon_prefix}.render_settings"
def draw(self, context):
layout = self.layout
prop = context.scene.mari_props
if prop.frame == "FRAME":
row = layout.row()
row.prop(prop, "render_settings_normalize", translate=False)
if prop.render_settings_normalize:
layout.row().prop(prop, "render_settings_limit_res_factor")
row = layout.row()
row.prop(prop, "render_settings_filepath", translate=False)
row = layout.row()
row.prop(prop, "render_settings_name", translate=False)
row = layout.row()
row.prop(prop, "mari_save_media")
plane_row = layout.row()
plane_row.enabled = (prop.frame == "FRAME" and prop.frame_advanced_possition)
plane_row.prop(prop, "mari_save_planes")
# MARIJPEG ONLY NOTICE
notice = layout.column(align=True)
notice.alert = True
notice.label(text="Tip: As of now, the HoloMARI")
notice.label(text="website only supports images!")
row = layout.row()
row.template_image_settings(context.scene.render.image_settings, color_management=False)
if context.scene.render.image_settings.file_format == 'FFMPEG':
ffmpeg = context.scene.render.ffmpeg
box = layout.box()
box.label(text="Video Encoding", icon='SEQ_STRIP_META')
box.prop(ffmpeg, "format", text="Container")
box.prop(ffmpeg, "codec", text="Video Codec")
box.prop(ffmpeg, "constant_rate_factor", text="Rate Factor")
box.prop(ffmpeg, "video_bitrate")
box.prop(ffmpeg, "ffmpeg_preset", text="Encoding Speed")
box.prop(ffmpeg, "use_lossless_output")
row = layout.row()
row.alignment='RIGHT'
row.prop(context.scene.render, "use_overwrite")
cam_box = layout.box()
cam_box.label(text=".mari3d Camera Settings")
if prop.frame == "FRAME":
row = cam_box.row()
row.operator(MARI_OT_Export_MARI.bl_idname).type = "FRAME"
else:
row = cam_box.row()
row.operator(MARI_OT_Export_MARI.bl_idname).type = "CIRCLE"
row = cam_box.row(align=True)
row.operator(MARI_OT_Import_mari.bl_idname)
row.operator(MARI_OT_Cam_set.bl_idname, icon="OUTLINER_OB_CAMERA")
pkg_box = layout.box()
pkg_box.label(text="Package .zip from .mari3d")
pkg_box.label(text="& convert MARI File formats")
row = pkg_box.row()
row.operator(MARI_OT_GenerateMedia.bl_idname)
row = pkg_box.row()
row.prop(prop, "mari_use_blend_resolution")
# MARIJPEG ONLY NOTICE
notice = layout.column(align=True)
notice.alert = True
notice.label(text="Tip: As of now, the HoloMARI")
notice.label(text="website only supports images!")
row = layout.row()
row.scale_y = 1.5
if prop.frame == "FRAME":
multi_box = layout.box()
multi_box.label(text="FASTER MULTI-RENDER")
multi_box.label(text="Requires Multirender Addon!")
multi_box.label(text="holomari.com/info/MultiRender")
multi_row = multi_box.row()
multi_row.alert = True
multi_row.operator(MARI_OT_Multi_render.bl_idname, text="Render MARI Image (MULTI)", icon='RENDER_STILL').action = "STILL"
multi_row = multi_box.row()
multi_row.alert = True
multi_row.operator(MARI_OT_Multi_render.bl_idname, text="Render MARI Animation (MULTI)", icon='RENDER_ANIMATION').action = "ANIM"
multi_row = multi_box.row()
multi_row.operator("render.multi_gpu_frames_cancel", text="Stop Multi-Instance", icon='CANCEL')
built_box = layout.box()
built_box.label(text="BUILT-IN RENDER (slow)")
built_box.operator(MARI_OT_Frame_render.bl_idname, text="Render MARI Image", icon='RENDER_STILL').action = "STILL"
built_box.operator(MARI_OT_Frame_render.bl_idname, text="Render MARI Animation", icon='RENDER_ANIMATION').action = "ANIM"
else:
multi_box = layout.box()
multi_box.label(text="FASTER MULTI-RENDER")
multi_box.label(text="Requires Multirender Addon!")
multi_row = multi_box.row()
multi_row.alert = True
multi_row.operator(MARI_OT_Multi_render.bl_idname, text="Render MARI Image (MULTI)", icon='RENDER_STILL').action = "STILL"
multi_row = multi_box.row()
multi_row.alert = True
multi_row.operator(MARI_OT_Multi_render.bl_idname, text="Render MARI Animation (MULTI)", icon='RENDER_ANIMATION').action = "ANIM"
multi_row = multi_box.row()
multi_row.operator("render.multi_gpu_frames_cancel", text="Stop Multi-Instance", icon='CANCEL')
built_box = layout.box()
built_box.label(text="BUILT-IN RENDER (slow)")
built_box.operator(MARI_OT_Circle_render.bl_idname, text="Render MARI Image", icon='RENDER_STILL').action = "STILL"
built_box.operator(MARI_OT_Circle_render.bl_idname, text="Render MARI Animation", icon='RENDER_ANIMATION').action = "ANIM"
layout.separator()
row = layout.row()
row.label(text="Upload MARI Media")
row = layout.row()
row.scale_y = 2.0
row.alert = True
row.operator("wm.url_open", text="holomari.com", icon='URL').url = "https://holomari.com/dashboard"
row = layout.row()
row.operator("wm.url_open", text="MARI Image Viewer", icon='URL').url = "https://holomari.com/info/viewer/index"
def draw_text_callback(data):
if mari.is_txt_on == 1:
blf.position(0, 15, 30, 0)
blf.size(0, 20, 72)
blf.color(0, 1, 1, 1, 0.7)
blf.draw(0, f"MARI Camera Count: {mari.cam_num}")
'''
handle = bpy.types.SpaceView3D.draw_handler_add(
draw_text_callback, (2,),
'WINDOW', 'POST_PIXEL')
'''
def on_cancel(scene):
"""
Called by Blender when the user cancels a render (Esc).
Flip our cancel flag and make sure no queued steps continue.
"""
if not getattr(mari, "_render_active", False):
return
print("[MARI] Render canceled by user (Esc). Halting pipeline.")
mari.cancel_all = True
# Be extra safe: don't let any lingering 'render_complete' handlers queue more work.
for _h in list(bpy.app.handlers.render_complete):
try:
bpy.app.handlers.render_complete.remove(_h)
except Exception:
pass
# Restore user render settings captured at the start of a MARI render.
try:
if getattr(mari, "_render_state", None):
_mari_restore_render_settings(scene, mari._render_state)
except Exception:
pass
try:
_mari_clear_render_active()
except Exception:
pass
# Also restore the user's render display preference
try:
_mari_restore_render_display()
except Exception:
pass
def mari_detect_mode_from_scene(scene):
"""Auto-select FRAME vs CIRCLE based on existing helper objects."""
if not scene:
return _MARI_MODE_CIRCLE
prop = getattr(scene, "mari_props", None)
has_cams = False
try:
has_cams = any(obj.name.startswith("MARI_CAMERA") for obj in scene.objects)
except Exception:
has_cams = False
mode = _mari_scene_mode_from_objects(scene, fallback=_MARI_MODE_CIRCLE)
if prop is not None:
try:
if _mari_normalize_mode_id(getattr(prop, "frame", _MARI_MODE_CIRCLE)) != mode:
prop.frame = mode
except Exception:
pass
mari.generated = bool(has_cams)
return mode
@persistent
def mari_on_load_post(*_args):
_mari_sync_mode_for_all_scenes()
_mari_schedule_mode_sync(0.05)
classes = [
MARI_AP_Preferences,
MARI_CollisionPlaneItem,
MARI_Props,
MARI_OP_Install,
MARI_OT_GenerateFrame,
MARI_OT_GenerateCircle,
MARI_OT_Frame_render,
MARI_OT_Circle_render,
MARI_OT_CancelWatcher,
MARI_OT_Export_MARI,
MARI_OT_GenerateMedia,
MARI_OT_Import_mari,
MARI_OT_Cam_set,
MARI_OT_RenderOne,
MARI_OT_Multi_render,
MARI_PT_Panel,
MARI_PT_CameraSettings,
MARI_PT_CAM_DOF_APERATURE,
MARI_PT_RenderSettings,
]
def register():
from bpy.props import PointerProperty
global handle
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.mari_props = PointerProperty(type=MARI_Props)
if handle is None:
handle = bpy.types.SpaceView3D.draw_handler_add(
draw_text_callback, (2,), 'WINDOW', 'POST_PIXEL'
)
# add render_cancel once
for _h in list(bpy.app.handlers.render_cancel):
if getattr(_h, "__module__", "") == __name__ and getattr(_h, "__name__", "") == "on_cancel" and _h is not on_cancel:
try:
bpy.app.handlers.render_cancel.remove(_h)
except Exception:
pass
if on_cancel not in bpy.app.handlers.render_cancel:
bpy.app.handlers.render_cancel.append(on_cancel)
for _h in list(bpy.app.handlers.load_post):
if getattr(_h, "__module__", "") == __name__ and getattr(_h, "__name__", "") == "mari_on_load_post" and _h is not mari_on_load_post:
try:
bpy.app.handlers.load_post.remove(_h)
except Exception:
pass
if mari_on_load_post not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(mari_on_load_post)
# Keep UI mode aligned on startup and after new files finish loading.
try:
_mari_sync_mode_for_all_scenes()
_mari_schedule_mode_sync(0.0)
except Exception:
pass
def unregister():
global handle
# remove draw handler first
if handle is not None:
try:
bpy.types.SpaceView3D.draw_handler_remove(handle, 'WINDOW')
except Exception:
pass
handle = None
# remove render_cancel if present
try:
bpy.app.handlers.render_cancel.remove(on_cancel)
except ValueError:
pass
try:
bpy.app.handlers.load_post.remove(mari_on_load_post)
except ValueError:
pass
try:
bpy.app.timers.unregister(_mari_mode_sync_timer)
except Exception:
pass
try:
del bpy.types.Scene.mari_props
except AttributeError:
pass
for cls in reversed(classes):
try:
bpy.utils.unregister_class(cls)
except Exception:
pass
if __name__ == "__main__":
register()