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: _H#_V#. If Blender emits _H#_V#0001., 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: _H#_V#. 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: _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()