6830 lines
269 KiB
Python
6830 lines
269 KiB
Python
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.7–4.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()
|