From 1a1767c984c0801995a9291816ff2697d04408a2 Mon Sep 17 00:00:00 2001 From: Nero Frame 13 Date: Mon, 19 Oct 2026 14:30:00 +0200 Subject: [PATCH] Added Rename and Shot Viewer tools Rename panel: batch rename selected objects (or their materials) as prefix_name_side_number_suffix. Numbering continues after existing names, object data follows its object, and a preview shows the result. Shot Viewer (button in the Tools panel): opens a window with its own workspace showing the shots (sequencer scene strips) through their camera, with the edit's sequencer at the bottom. Scrubbing the edit switches shots, playing a shot moves the edit playhead, Loop Shot loops playback on the shot. Shots can be added and deleted, and character / prop rigs selected in pose mode from the sidebar. Co-Authored-By: Claude Opus 5.5 --- __init__.py | 14 +- config.py | 43 ++++ rename.py | 281 +++++++++++++++++++++ shot_viewer.py | 671 +++++++++++++++++++++++++++++++++++++++++++++++++ ui/panel.py | 167 +++++++++++- 5 files changed, 1173 insertions(+), 3 deletions(-) create mode 100644 rename.py create mode 100644 shot_viewer.py diff --git a/__init__.py b/__init__.py index 07f86da..445d4ec 100644 --- a/__init__.py +++ b/__init__.py @@ -8,7 +8,7 @@ for _name in [name for name in sys.modules if name.startswith(__name__ + ".")]: import bpy # noqa: E402 from bpy.app.handlers import persistent # noqa: E402 -from . import checks, core, tools_ops, ui # noqa: E402 +from . import checks, core, rename, shot_viewer, tools_ops, ui # noqa: E402 from .core import runner # noqa: E402 api = core @@ -43,6 +43,12 @@ def register() -> None: # Register tool operators tools_ops.register() + # Register the rename tool (settings and operator, before its panel) + rename.register() + + # Register the shot viewer (settings and operators, before its panel) + shot_viewer.register() + # Load all the sanity checks into the sanity REGISTRY checks.load_all() @@ -68,6 +74,12 @@ def unregister() -> None: # Unregister all panels ui.unregister() + # Unregister the rename tool (after its panel) + rename.unregister() + + # Unregister the shot viewer (after its panel) + shot_viewer.unregister() + runner.clear() # The check registry is plain Python and is rebuilt with the modules on # re-import, so it is kept as is (enable/disable without re-import). diff --git a/config.py b/config.py index 4a1876c..a3e1804 100644 --- a/config.py +++ b/config.py @@ -207,6 +207,49 @@ DUPLICATE_NAME_TYPES = [ "node_groups", ] +# --------------------------------------------------------------------------- # +# Rename tool +# --------------------------------------------------------------------------- # +# Names are built as {prefix}_{name}_{side}_{number}_{suffix}, empty parts dropped. +# "By Type" prefixes come from OBJECT_PREFIXES / DATABLOCK_PREFIXES. +RENAME_SEPARATOR = "_" +RENAME_PADDING = 3 + +# Orientation tokens. TODO: confirm with production. +RENAME_SIDES = ["center", "left", "right", "front", "back", "top", "bottom"] + +# "By Type" suffix per object type ("MATERIAL" for materials). TODO: confirm with production. +RENAME_TYPE_SUFFIXES = { + "MESH": "mesh", + "CURVE": "curve", + "SURFACE": "surface", + "FONT": "text", + "EMPTY": "empty", + "ARMATURE": "rig", + "LATTICE": "lattice", + "GREASEPENCIL": "gp", + "CAMERA": "cam", + "LIGHT": "light", + "MATERIAL": "mat", +} + +# --------------------------------------------------------------------------- # +# Shot viewer +# --------------------------------------------------------------------------- # +# Groups of the rig quick selection: an armature goes to the first group whose +# pattern matches its name, one of its collections or its library file name. +# Unmatched armatures are listed under "Other". TODO: confirm with production. +SHOT_VIEWER_RIG_GROUPS = { + "Characters": r"(?i)^(?:chr|char)[_-]", + "Props": r"(?i)^(?:prp|prop)[_-]", +} + +# Shots added by the shot viewer. TODO: confirm with production. +SHOT_NAME_FORMAT = "sh{:04d}" +SHOT_NUMBER_STEP = 10 +SHOT_FRAME_START = 101 +SHOT_DEFAULT_DURATION = 48 + # --------------------------------------------------------------------------- # # UI # --------------------------------------------------------------------------- # diff --git a/rename.py b/rename.py new file mode 100644 index 0000000..acfc47b --- /dev/null +++ b/rename.py @@ -0,0 +1,281 @@ +"""Batch rename tool. + +Names are built as ``{prefix}_{name}_{side}_{number}_{suffix}`` (empty parts are +dropped). Numbering continues after the highest number already used by a +datablock with the same pattern: renaming more 'pillar' objects later gives +pillar_004, pillar_005... +""" + +import re + +import bpy +from bpy.props import BoolProperty, EnumProperty, PointerProperty, StringProperty + +from . import config +from .core import is_local + +_DUPLICATE_RE = re.compile(r"\.\d{3,}$") +_TMP_PREFIX = "__wtd_rename_" + +AFFIX_MODES = [ + ("NONE", "None", "No affix"), + ("TYPE", "By Type", "Affix of the production config for the type of each item"), + ("CUSTOM", "Custom", "Custom text"), +] + + +# --------------------------------------------------------------------------- # +# Name building +# --------------------------------------------------------------------------- # +def clean(text): + """ASCII only, every run of other characters replaced by the separator.""" + sep = config.RENAME_SEPARATOR + text = text.encode("ascii", "ignore").decode() + return re.sub(r"[^A-Za-z0-9]+", lambda _m: sep, text).strip(sep) + + +def _natural_key(name): + return [int(token) if token.isdigit() else token.lower() for token in re.split(r"(\d+)", name)] + + +def _type_key(id_block): + return id_block.type if isinstance(id_block, bpy.types.Object) else "MATERIAL" + + +def _prefix(id_block, settings): + if settings.prefix_mode == "CUSTOM": + return clean(settings.prefix_custom) + if settings.prefix_mode == "TYPE": + if isinstance(id_block, bpy.types.Object): + return clean(config.OBJECT_PREFIXES.get(id_block.type, "")) + return clean(config.DATABLOCK_PREFIXES.get("materials", "")) + return "" + + +def _suffix(id_block, settings): + if settings.suffix_mode == "CUSTOM": + return clean(settings.suffix_custom) + if settings.suffix_mode == "TYPE": + return clean(config.RENAME_TYPE_SUFFIXES.get(_type_key(id_block), "")) + return "" + + +def current_base(name, settings): + """Base name of an existing name: 'GEO_box_center_002_mesh' -> 'box', 'box2' -> 'box'.""" + body = _DUPLICATE_RE.sub("", name) + prefixes = {*config.OBJECT_PREFIXES.values(), *config.DATABLOCK_PREFIXES.values()} + if settings.prefix_custom: + prefixes.add(clean(settings.prefix_custom) + config.RENAME_SEPARATOR) + for prefix in sorted(prefixes, key=len, reverse=True): + if prefix and body.upper().startswith(prefix.upper()): + body = body[len(prefix):] + break + + suffixes = {suffix.lower() for suffix in config.RENAME_TYPE_SUFFIXES.values()} + if settings.suffix_custom: + suffixes.add(clean(settings.suffix_custom).lower()) + sides = {side.lower() for side in config.RENAME_SIDES} + + tokens = [token for token in re.split(r"[^A-Za-z0-9]+", body) if token] + # Peel the parts this tool adds, from the end: suffix, number, side. + if len(tokens) > 1 and tokens[-1].lower() in suffixes: + tokens.pop() + if len(tokens) > 1 and tokens[-1].isdigit(): + tokens.pop() + if len(tokens) > 1 and tokens[-1].lower() in sides: + tokens.pop() + if tokens: + tokens[-1] = tokens[-1].rstrip("0123456789") + return clean(config.RENAME_SEPARATOR.join(tokens)) + + +def targets(context, settings): + """Local datablocks to rename, from the selected objects.""" + objects = [ob for ob in context.selected_objects if is_local(ob)] + if settings.target == "OBJECTS": + return objects + materials = [] + for ob in objects: + for slot in ob.material_slots: + material = slot.material + if material is not None and is_local(material) and material not in materials: + materials.append(material) + return materials + + +def _sort_key(id_block, order): + if order != "NAME" and isinstance(id_block, bpy.types.Object): + return (id_block.matrix_world.translation["XYZ".index(order)], _natural_key(id_block.name)) + return (0.0, _natural_key(id_block.name)) + + +def plan_renames(context, settings): + """Return ([(id_block, new_name)], [names skipped because no base name could be found]).""" + sep = config.RENAME_SEPARATOR + custom_base = clean(settings.base_name) + side = "" if settings.side == "NONE" else clean(settings.side) + + groups = {} # (prefix, base, suffix) -> [id_block] + skipped = [] + for id_block in sorted(targets(context, settings), key=lambda i: _sort_key(i, settings.sort)): + base = custom_base or current_base(id_block.name, settings) + if not base: + skipped.append(id_block.name) + continue + key = (_prefix(id_block, settings), base, _suffix(id_block, settings)) + groups.setdefault(key, []).append(id_block) + + collection = bpy.data.objects if settings.target == "OBJECTS" else bpy.data.materials + renamed = {id_block.as_pointer() for members in groups.values() for id_block in members} + plan = [] + for (prefix, base, suffix), members in groups.items(): + head = sep.join(part for part in (prefix, base, side) if part) + sep + tail = sep + suffix if suffix else "" + pattern = re.compile(re.escape(head) + r"(\d+)" + re.escape(tail)) + # Linked datablocks count too, so local names never repeat a library's numbers. + used = [ + int(match.group(1)) + for other in collection + if other.as_pointer() not in renamed and (match := pattern.fullmatch(other.name)) + ] + # Numbers the selection already holds are reused first, so renaming + # again is stable, then numbering continues after the highest one. + owned = {int(match.group(1)) for id_block in members if (match := pattern.fullmatch(id_block.name))} + if settings.sort == "NAME": + # Conforming names first, in their number order, so they keep their number. + members.sort(key=lambda i: (0, int(m.group(1))) if (m := pattern.fullmatch(i.name)) else (1, 0)) + numbers = sorted(owned - set(used)) + number = max([*used, *owned], default=0) + for id_block in members: + if numbers: + current = numbers.pop(0) + else: + number += 1 + current = number + plan.append((id_block, f"{head}{current:0{config.RENAME_PADDING}d}{tail}")) + return plan, skipped + + +def apply_renames(plan, rename_data): + """Rename the datablocks of the plan. Returns the messages of failed renames.""" + datas = [] + if rename_data: + owners = {} # data pointer -> (data, [objects of the plan using it]) + for id_block, _name in plan: + data = getattr(id_block, "data", None) + if data is not None and is_local(data): + owners.setdefault(data.as_pointer(), (data, []))[1].append(id_block) + # Shared data (instances) is renamed only when all its users are renamed, + # after the first of them in numbering order. + datas = [(data, objects[0]) for data, objects in owners.values() if data.users == len(objects)] + + # Temporary names first, so items of the selection can swap numbers + # without Blender adding '.001' to the ones not renamed yet. + for index, id_block in enumerate([id_block for id_block, _ in plan] + [data for data, _ in datas]): + id_block.name = f"{_TMP_PREFIX}{index}" + + failed = [] + for id_block, name in plan: + id_block.name = name + if id_block.name != name: + failed.append(f"'{name}' is taken, got '{id_block.name}'") + for data, owner in datas: + data.name = owner.name + return failed + + +# --------------------------------------------------------------------------- # +# Settings / operator +# --------------------------------------------------------------------------- # +_SIDE_ITEMS = {} + + +def _side_items(self, context): + # Cached: Blender needs the enum strings to stay alive. + key = tuple(config.RENAME_SIDES) + if key not in _SIDE_ITEMS: + _SIDE_ITEMS[key] = [("NONE", "None", "No orientation")] + [ + (side, side.capitalize(), f"Add '{side}' after the name") for side in key + ] + return _SIDE_ITEMS[key] + + +class WTD_RenameSettings(bpy.types.PropertyGroup): + """Per-file settings of the rename tool, saved in the .blend.""" + + target: EnumProperty( + name="Target", + items=[ + ("OBJECTS", "Objects", "Rename the selected objects"), + ("MATERIALS", "Materials", "Rename the materials of the selected objects"), + ], + ) + base_name: StringProperty( + name="Name", + description="New name. Leave empty to keep the current base name of each item (box2 -> box)", + ) + prefix_mode: EnumProperty(name="Prefix", items=AFFIX_MODES, default="TYPE") + prefix_custom: StringProperty(name="Custom Prefix") + side: EnumProperty(name="Side", description="Orientation token", items=_side_items) + suffix_mode: EnumProperty(name="Suffix", items=AFFIX_MODES, default="NONE") + suffix_custom: StringProperty(name="Custom Suffix") + sort: EnumProperty( + name="Order", + description="Order in which the items are numbered", + items=[ + ("NAME", "Name", "Current name"), + ("X", "X", "World X location"), + ("Y", "Y", "World Y location"), + ("Z", "Z", "World Z location"), + ], + ) + rename_data: BoolProperty( + name="Rename Data", + description=( + "Give the object data (mesh, curve, light..., Maya's shape node) the name of its object. " + "Data shared by several objects takes the name of the first one, when they are all renamed" + ), + default=True, + ) + + +class WTD_OT_batch_rename(bpy.types.Operator): + bl_idname = "wtd_tools.batch_rename" + bl_label = "Rename" + bl_description = "Rename the selected objects (or their materials), numbering after the existing ones" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return bool(context.selected_objects) + + def execute(self, context): + settings = context.scene.wtd_rename + plan, skipped = plan_renames(context, settings) + if not plan: + self.report({"WARNING"}, "Nothing to rename (set a name, or select local items)") + return {"CANCELLED"} + failed = apply_renames(plan, settings.target == "OBJECTS" and settings.rename_data) + messages = failed + [f"'{name}': no base name, set a name" for name in skipped] + for message in messages: + print(f"[WTD Rename] {message}") + if messages: + self.report({"WARNING"}, f"Renamed {len(plan) - len(failed)}, {len(messages)} issue(s): {messages[0]}") + else: + self.report({"INFO"}, f"Renamed {len(plan)} item(s)") + return {"FINISHED"} + + +classes = (WTD_RenameSettings, WTD_OT_batch_rename) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.wtd_rename = PointerProperty(type=WTD_RenameSettings) + + +def unregister(): + del bpy.types.Scene.wtd_rename + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/shot_viewer.py b/shot_viewer.py new file mode 100644 index 0000000..d9cc5de --- /dev/null +++ b/shot_viewer.py @@ -0,0 +1,671 @@ +"""Shot viewer: a window showing the shots of the edit through their camera. + +Shots are the scene strips of the edit scene's sequencer. The viewer is a +separate window showing the scene of a strip through the strip's camera, with +a sidebar panel to switch shots and quickly select the character / prop rigs. +""" + +import os +import re + +import bpy +from bpy.app.handlers import persistent +from bpy.props import BoolProperty, EnumProperty, IntProperty, PointerProperty, StringProperty +from mathutils import Euler, Vector + +from . import config + +# Custom property marking the screen of a viewer window (saved with the file). +VIEWER_TAG = "wtd_shot_viewer" +VIEWER_WORKSPACE = "Shot Viewer" +# Height of the edit sequencer at the bottom of the viewer, as a part of the window. +VIEWER_TIMELINE_FACTOR = 0.25 +OTHER_GROUP = "Other" + + +# --------------------------------------------------------------------------- # +# Shots +# --------------------------------------------------------------------------- # +def has_shots(scene): + editor = scene.sequence_editor + return editor is not None and any(strip.type == "SCENE" for strip in editor.strips_all) + + +def edit_scene(context): + """The scene holding the edit: the one set in the settings, else the first with scene strips.""" + settings = context.window_manager.wtd_shot_viewer + if settings.edit_scene is not None: + return settings.edit_scene + return next((scene for scene in bpy.data.scenes if has_shots(scene)), None) + + +def strip_range(strip): + """(first frame, last frame + 1) of the strip in the edit.""" + if hasattr(strip, "left_handle"): # Blender 5.1+ names, the old ones go in 6.0 + return int(strip.left_handle), int(strip.right_handle) + return int(strip.frame_final_start), int(strip.frame_final_end) + + +def _content_start(strip): + return strip.content_start if hasattr(strip, "content_start") else strip.frame_start + + +def shots(scene): + """Scene strips of the edit in edit order (muted strips and strips without a scene left out).""" + if scene is None or scene.sequence_editor is None: + return [] + strips = [ + strip + for strip in scene.sequence_editor.strips_all + if strip.type == "SCENE" and strip.scene is not None and not strip.mute + ] + return sorted(strips, key=lambda strip: (strip_range(strip)[0], strip.channel)) + + +def current_shot(context): + """The shot selected in the viewer, or None.""" + scene = edit_scene(context) + if scene is None or scene.sequence_editor is None: + return None + strips = scene.sequence_editor.strips_all + index = context.window_manager.wtd_shot_viewer.shot_index + if not 0 <= index < len(strips): + return None + strip = strips[index] + return strip if strip in shots(scene) else None + + +def shot_camera(strip): + return strip.scene_camera or strip.scene.camera + + +def shot_frame_range(strip): + """Frame range of the strip in its own scene (the strip content starts at the scene start).""" + start, end = strip_range(strip) + offset = strip.scene.frame_start - int(_content_start(strip)) + return start + offset, end - 1 + offset + + +def is_viewer(screen): + return screen is not None and bool(screen.get(VIEWER_TAG)) + + +def viewer_areas(context): + for window in context.window_manager.windows: + if is_viewer(window.screen): + for area in window.screen.areas: + if area.type == "VIEW_3D": + yield window, area + + +def apply_loop(context, strip): + """With Loop Shot on, set the preview range of the shot scene to the shot so playback loops on it.""" + if not context.window_manager.wtd_shot_viewer.loop_shot: + return + scene = strip.scene + start, end = shot_frame_range(strip) + scene.use_preview_range = True + # Setting start past the old end (or end before the old start) pushes the other one. + scene.frame_preview_start = start + scene.frame_preview_end = end + scene.frame_preview_start = start + + +def show_shot(context, strip): + """Show the shot in every viewer window and move the edit playhead to it.""" + scene = strip.scene + camera = shot_camera(strip) + start, end = shot_frame_range(strip) + scene.frame_current = start + apply_loop(context, strip) + strip.id_data.frame_current = strip_range(strip)[0] + + for window, area in viewer_areas(context): + window.scene = scene + space = area.spaces.active + space.use_local_camera = camera is not None + space.camera = camera + space.region_3d.view_perspective = "CAMERA" + area.tag_redraw() + + +def shot_at(edit, frame): + """The shot under the frame of the edit (top channel first), or None.""" + covering = [strip for strip in shots(edit) if strip_range(strip)[0] <= frame < strip_range(strip)[1]] + return max(covering, key=lambda strip: strip.channel, default=None) + + +def _shot_frame(strip, edit_frame): + """Frame of the strip's scene shown at a frame of the edit.""" + return edit_frame + shot_frame_range(strip)[0] - strip_range(strip)[0] + + +_syncing = False + + +@persistent +def _on_frame_change(scene, _depsgraph=None): + """Keep the shot list and the edit playhead in step with the viewer. + + Scrubbing the edit selects the shot under the playhead (the workspace time + sync shows it). Playing a shot in the 3D view moves the edit playhead along. + """ + global _syncing + context = bpy.context + wm = context.window_manager + if _syncing or wm is None or not any(is_viewer(window.screen) for window in wm.windows): + return + settings = wm.wtd_shot_viewer + edit = settings.edit_scene + if edit is None or edit.sequence_editor is None: + return + _syncing = True + try: + # Edit scrubbed (or played): the shot under the playhead, already shown + # by the workspace time sync, becomes the selected one. + under = shot_at(edit, edit.frame_current) + if under is not None and (scene == edit or _shot_frame(under, edit.frame_current) == scene.frame_current): + index = list(edit.sequence_editor.strips_all).index(under) + if index != settings.shot_index: + settings["shot_index"] = index + apply_loop(context, under) + else: + # Shot played in the 3D view: the edit playhead follows. + strip = current_shot(context) + if strip is not None and strip.scene == scene: + start, end = strip_range(strip) + frame = start + scene.frame_current - shot_frame_range(strip)[0] + edit.frame_current = max(start, min(end - 1, frame)) + for _window, area in viewer_areas(context): + area.tag_redraw() + finally: + _syncing = False + + +def select_shot(context, strip): + """Make the strip the selected shot and show it.""" + index = list(strip.id_data.sequence_editor.strips_all).index(strip) + # Item assignment skips the update callback: the shot is shown just below, + # also when the index does not change (e.g. after a deletion). + context.window_manager.wtd_shot_viewer["shot_index"] = index + show_shot(context, strip) + + +def next_shot_name(edit): + """Next free shot name (config SHOT_NAME_FORMAT), after the highest one of the strips and scenes.""" + head, _sep, rest = config.SHOT_NAME_FORMAT.partition("{") + pattern = re.compile(re.escape(head) + r"(\d+)" + re.escape(rest.partition("}")[2])) + names = [scene.name for scene in bpy.data.scenes] + if edit.sequence_editor is not None: + names += [strip.name for strip in edit.sequence_editor.strips_all] + numbers = [int(match.group(1)) for name in names if (match := pattern.search(name))] + step = config.SHOT_NUMBER_STEP + return config.SHOT_NAME_FORMAT.format((max(numbers, default=0) // step + 1) * step) + + +def _new_shot_scene(name, edit, source, duration): + """New shot scene: a linked copy of ``source`` (same collections) or, without source, an empty + scene with the format of the edit.""" + if source is not None: + scene = source.copy() + scene.name = name + scene.sequence_editor_clear() + # The cameras of the source shot are not shared. + for ob in [ob for ob in scene.collection.objects if ob.type == "CAMERA"]: + scene.collection.objects.unlink(ob) + else: + scene = bpy.data.scenes.new(name) + for attr in ( + "fps", + "fps_base", + "resolution_x", + "resolution_y", + "resolution_percentage", + "pixel_aspect_x", + "pixel_aspect_y", + ): + setattr(scene.render, attr, getattr(edit.render, attr)) + scene.frame_start = config.SHOT_FRAME_START + scene.frame_end = config.SHOT_FRAME_START + duration - 1 + return scene + + +def _new_camera(scene, name, source=None): + """New camera in the scene's own collection, copying ``source`` (lens and placement) when given.""" + camera_name = config.OBJECT_PREFIXES.get("CAMERA", "") + name + if source is not None and source.type == "CAMERA": + data = source.data.copy() + data.name = camera_name + else: + source = None + data = bpy.data.cameras.new(camera_name) + camera = bpy.data.objects.new(camera_name, data) + if source is not None: + camera.matrix_world = source.matrix_world.copy() + else: + camera.location = Vector((0.0, -10.0, 1.6)) + camera.rotation_euler = Euler((1.5708, 0.0, 0.0)) + scene.collection.objects.link(camera) + if scene.camera is None: + scene.camera = camera + return camera + + +def _viewed_camera(context): + space = context.space_data + if space is not None and space.type == "VIEW_3D" and space.use_local_camera and space.camera: + return space.camera + return context.scene.camera + + +def _move(strip, delta): + if hasattr(strip, "content_start"): # Blender 5.1+ + strip.content_start += delta + else: + strip.frame_start += delta + + +def _close_gap(editor, start, end): + """Move the strips starting after a removed one back by its length (all channels).""" + later = [strip for strip in editor.strips if strip_range(strip)[0] >= end] + for strip in sorted(later, key=lambda strip: strip_range(strip)[0]): + _move(strip, start - end) + + +# --------------------------------------------------------------------------- # +# Rigs +# --------------------------------------------------------------------------- # +def _rig_names(ob): + """Names a rig is classified by: object, its collections and its library file.""" + names = [ob.name, *(collection.name for collection in ob.users_collection)] + reference = ob.override_library.reference if ob.override_library else None + library = ob.library or (reference.library if reference else None) + if library is not None: + names.append(os.path.basename(library.filepath)) + return names + + +def rig_groups(view_layer): + """{group label: [armature objects]} for the armatures of the view layer, empty groups left out.""" + patterns = [(label, re.compile(pattern)) for label, pattern in config.SHOT_VIEWER_RIG_GROUPS.items()] + groups = {label: [] for label, _pattern in patterns} + groups[OTHER_GROUP] = [] + rigs = sorted((ob for ob in view_layer.objects if ob.type == "ARMATURE"), key=lambda ob: ob.name.lower()) + for ob in rigs: + names = _rig_names(ob) + label = next( + (label for label, pattern in patterns if any(pattern.search(name) for name in names)), + OTHER_GROUP, + ) + groups[label].append(ob) + return {label: rigs for label, rigs in groups.items() if rigs} + + +# --------------------------------------------------------------------------- # +# Settings / operators +# --------------------------------------------------------------------------- # +def _shot_index_update(self, context): + scene = edit_scene(context) + if scene is None or not 0 <= self.shot_index < len(scene.sequence_editor.strips_all): + return + strip = scene.sequence_editor.strips_all[self.shot_index] + if strip.type == "SCENE" and strip.scene is not None: + show_shot(context, strip) + + +def _loop_shot_update(self, context): + if self.loop_shot: + strip = current_shot(context) + if strip is not None: + apply_loop(context, strip) + else: + for strip in shots(edit_scene(context)): + strip.scene.use_preview_range = False + + +class WTD_ShotViewerSettings(bpy.types.PropertyGroup): + edit_scene: PointerProperty( + name="Edit", + description="Scene whose sequencer holds the shots (scene strips)", + type=bpy.types.Scene, + ) + shot_index: IntProperty(name="Shot", update=_shot_index_update) + loop_shot: BoolProperty( + name="Loop Shot", + description="Set the preview range of the shot scene to the shot, so playback loops on it", + default=True, + update=_loop_shot_update, + ) + + +def _setup_viewer_screen(window, edit): + """Lay out the viewer screen: the 3D view in camera view, the edit's sequencer at the bottom. + Returns False while the window is not ready yet (its sidebar is not drawn).""" + screen = window.screen + view3d = max((a for a in screen.areas if a.type == "VIEW_3D"), key=lambda a: a.width * a.height, default=None) + if view3d is None: + view3d = max(screen.areas, key=lambda a: a.width * a.height) + view3d.type = "VIEW_3D" + keep = {view3d.as_pointer()} + sequencer = next((a for a in screen.areas if a.type == "SEQUENCE_EDITOR"), None) + if sequencer is not None: + keep.add(sequencer.as_pointer()) + for other in [a for a in screen.areas if a.as_pointer() not in keep]: + with bpy.context.temp_override(window=window, screen=screen, area=other): + bpy.ops.screen.area_close() + if sequencer is None: + with bpy.context.temp_override(window=window, screen=screen, area=view3d): + bpy.ops.screen.area_split(direction="HORIZONTAL", factor=VIEWER_TIMELINE_FACTOR) + sequencer = min(screen.areas, key=lambda a: a.y) + sequencer.type = "SEQUENCE_EDITOR" + view3d = max(screen.areas, key=lambda a: a.y) + sequencer.spaces.active.view_type = "SEQUENCER" + sequencer.spaces.active.show_seconds = False # frames, like the shot list + screen[VIEWER_TAG] = True + + view3d.spaces.active.show_region_ui = True + try: + ui = next(r for r in view3d.regions if r.type == "UI") + ui.active_panel_category = "WTD" + except (AttributeError, StopIteration, TypeError): + return False + + strip = current_shot(bpy.context) + if strip is not None: + show_shot(bpy.context, strip) + if view3d.spaces.active.region_3d.view_perspective == "CAMERA": + region = next(r for r in view3d.regions if r.type == "WINDOW") + with bpy.context.temp_override(window=window, screen=screen, area=view3d, region=region): + bpy.ops.view3d.view_center_camera() + region = next(r for r in sequencer.regions if r.type == "WINDOW") + with bpy.context.temp_override(window=window, screen=screen, area=sequencer, region=region): + try: + bpy.ops.sequencer.view_all() + except RuntimeError: # framing only, the sequencer may not be drawn yet + pass + return True + + +def _finish_viewer(window, previous_workspace, edit, tries=30): + """Finish the viewer once Blender has switched its workspace (done on the next event loop).""" + + def finish(): + nonlocal tries + tries -= 1 + try: + workspace = window.workspace + if workspace.as_pointer() == previous_workspace: + return 0.1 if tries > 0 else None + workspace.name = VIEWER_WORKSPACE + workspace.sequencer_scene = edit + # Scrubbing the edit in the sequencer shows the shot under the playhead. + workspace.use_scene_time_sync = True + ready = _setup_viewer_screen(window, edit) + except ReferenceError: # window closed meanwhile + return None + return None if ready or tries <= 0 else 0.1 + + bpy.app.timers.register(finish, first_interval=0.1) + + +class WTD_OT_shot_viewer_open(bpy.types.Operator): + bl_idname = "wtd_tools.shot_viewer_open" + bl_label = "Shot Viewer" + bl_description = "Open a window showing the shots of the edit through their camera, with the edit below" + + @classmethod + def poll(cls, context): + return context.area is not None + + def execute(self, context): + # Without shots anywhere, the current scene becomes the edit. + scene = edit_scene(context) or context.scene + scene.sequence_editor_create() + settings = context.window_manager.wtd_shot_viewer + settings.edit_scene = scene + + # A main window, not a child one (screen.area_dupli): child windows + # always show the scene of their parent. + windows = context.window_manager.windows + before = {window.as_pointer() for window in windows} + bpy.ops.wm.window_new_main() + window = next((window for window in windows if window.as_pointer() not in before), None) + if window is None: + self.report({"ERROR"}, "Could not open the viewer window") + return {"CANCELLED"} + + # The viewer gets its own workspace: the scene time sync is a workspace + # setting, and the window's current layout must stay untouched as other + # windows may reuse it. Both switches happen on the next event loop. + previous = window.workspace.as_pointer() + workspace = bpy.data.workspaces.get(VIEWER_WORKSPACE) + if workspace is not None: + window.workspace = workspace + else: + with context.temp_override(window=window, screen=window.screen, area=window.screen.areas[0]): + bpy.ops.workspace.duplicate() + + # Start on the shot under the edit playhead. + ordered = shots(scene) + strip = next( + (s for s in ordered if strip_range(s)[0] <= scene.frame_current < strip_range(s)[1]), + ordered[0] if ordered else None, + ) + if strip is not None: + index = list(scene.sequence_editor.strips_all).index(strip) + settings["shot_index"] = index + _finish_viewer(window, previous, scene) + return {"FINISHED"} + + +class WTD_OT_shot_viewer_step(bpy.types.Operator): + bl_idname = "wtd_tools.shot_viewer_step" + bl_label = "Previous / Next Shot" + bl_description = "Show the previous or next shot of the edit" + + step: IntProperty(default=1) + + def execute(self, context): + ordered = shots(edit_scene(context)) + if not ordered: + return {"CANCELLED"} + current = current_shot(context) + position = ordered.index(current) if current is not None else -1 + position = max(0, min(len(ordered) - 1, position + self.step)) + select_shot(context, ordered[position]) + return {"FINISHED"} + + +class WTD_OT_shot_viewer_new_shot(bpy.types.Operator): + bl_idname = "wtd_tools.shot_viewer_new_shot" + bl_label = "New Shot" + bl_description = "Add a shot (scene strip) with its own camera at the end of the edit" + bl_options = {"REGISTER", "UNDO"} + + name: StringProperty(name="Name", description="Name of the strip and of the new scene") + scene_mode: EnumProperty( + name="Scene", + items=[ + ("SHARED", "New, Shared Content", "New scene using the same collections as the scene shown in the viewer (linked copy)"), + ("EMPTY", "New, Empty", "New empty scene with the format of the edit"), + ("EXISTING", "Existing", "Use an existing scene, for its whole frame range"), + ], + ) + scene_name: StringProperty(name="Scene Name") + duration: IntProperty(name="Duration", description="Length of the shot in frames", min=1, default=48) + new_camera: BoolProperty( + name="New Camera", + description="Add a camera for this shot (otherwise the scene camera is used)", + default=True, + ) + + @classmethod + def poll(cls, context): + return edit_scene(context) is not None + + def invoke(self, context, event): + self.name = next_shot_name(edit_scene(context)) + self.duration = config.SHOT_DEFAULT_DURATION + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False + layout.prop(self, "name") + layout.prop(self, "scene_mode") + if self.scene_mode == "EXISTING": + layout.prop_search(self, "scene_name", bpy.data, "scenes") + layout.prop(self, "new_camera") + else: + layout.prop(self, "duration") + + def execute(self, context): + edit = edit_scene(context) + name = self.name.strip() or next_shot_name(edit) + source_camera = _viewed_camera(context) + if self.scene_mode == "EXISTING": + scene = bpy.data.scenes.get(self.scene_name) + if scene is None or scene == edit: + self.report({"WARNING"}, "Pick an existing scene other than the edit") + return {"CANCELLED"} + else: + source = context.scene if self.scene_mode == "SHARED" else None + scene = _new_shot_scene(name, edit, source, self.duration) + camera = None + if self.scene_mode != "EXISTING" or self.new_camera or scene.camera is None: + camera = _new_camera(scene, name, source_camera) + + editor = edit.sequence_editor_create() + start = max((strip_range(strip)[1] for strip in editor.strips), default=edit.frame_start) + strip = editor.strips.new_scene(name, scene, channel=1, frame_start=start) + if camera is not None: + strip.scene_camera = camera + edit.frame_end = max(edit.frame_end, strip_range(strip)[1] - 1) + select_shot(context, strip) + self.report({"INFO"}, f"Shot '{strip.name}' added (scene '{scene.name}')") + return {"FINISHED"} + + +class WTD_OT_shot_viewer_delete_shot(bpy.types.Operator): + bl_idname = "wtd_tools.shot_viewer_delete_shot" + bl_label = "Delete Shot" + bl_description = "Remove the selected shot from the edit" + bl_options = {"REGISTER", "UNDO"} + + close_gap: BoolProperty(name="Close Gap", description="Move the following strips back", default=True) + delete_camera: BoolProperty( + name="Delete Camera", description="Also delete its camera when no other shot uses it" + ) + delete_scene: BoolProperty( + name="Delete Scene", description="Also delete its scene when no other shot uses it" + ) + + @classmethod + def poll(cls, context): + return current_shot(context) is not None + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + layout.label(text=f"Delete shot '{current_shot(context).name}'?", icon="ERROR") + layout.prop(self, "close_gap") + layout.prop(self, "delete_camera") + layout.prop(self, "delete_scene") + + def execute(self, context): + strip = current_shot(context) + edit = strip.id_data + editor = edit.sequence_editor + name, scene, camera = strip.name, strip.scene, shot_camera(strip) + position = shots(edit).index(strip) + start, end = strip_range(strip) + + editor.strips.remove(strip) + if self.close_gap: + _close_gap(editor, start, end) + + remaining = shots(edit) + if remaining: + select_shot(context, remaining[min(position, len(remaining) - 1)]) + else: + for window, _area in viewer_areas(context): + window.scene = edit + + others = [s for s in editor.strips_all if s.type == "SCENE" and s.scene is not None] + deleted = [] + if self.delete_camera and camera is not None and not any(shot_camera(s) == camera for s in others): + data = camera.data + bpy.data.objects.remove(camera) + if data.users == 0: + bpy.data.cameras.remove(data) + deleted.append("camera") + if self.delete_scene and scene != edit and not any(s.scene == scene for s in others): + for window in context.window_manager.windows: + if window.scene == scene: + window.scene = edit + bpy.data.scenes.remove(scene) + deleted.append("scene") + extra = f" with its {' and '.join(deleted)}" if deleted else "" + self.report({"INFO"}, f"Shot '{name}' deleted{extra}") + return {"FINISHED"} + + +class WTD_OT_shot_viewer_select_rig(bpy.types.Operator): + bl_idname = "wtd_tools.shot_viewer_select_rig" + bl_label = "Select Rig" + bl_description = "Select this rig in pose mode. Shift: add it to the selection" + bl_options = {"REGISTER", "UNDO"} + + name: StringProperty() + extend: BoolProperty(name="Extend", options={"SKIP_SAVE"}) + + def invoke(self, context, event): + self.extend = event.shift + return self.execute(context) + + def execute(self, context): + view_layer = context.view_layer + rig = view_layer.objects.get(self.name) + if rig is None or not rig.visible_get(view_layer=view_layer): + self.report({"WARNING"}, f"'{self.name}' is hidden or not in this view layer") + return {"CANCELLED"} + if context.mode != "OBJECT": + bpy.ops.object.mode_set(mode="OBJECT") + if not self.extend: + for ob in context.selected_objects: + ob.select_set(False) + rig.select_set(True) + view_layer.objects.active = rig + try: + bpy.ops.object.mode_set(mode="POSE") + except RuntimeError as exc: + self.report({"WARNING"}, f"'{rig.name}' selected, but not in pose mode: {exc}") + return {"FINISHED"} + + +classes = ( + WTD_ShotViewerSettings, + WTD_OT_shot_viewer_open, + WTD_OT_shot_viewer_step, + WTD_OT_shot_viewer_new_shot, + WTD_OT_shot_viewer_delete_shot, + WTD_OT_shot_viewer_select_rig, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.WindowManager.wtd_shot_viewer = PointerProperty(type=WTD_ShotViewerSettings) + bpy.app.handlers.frame_change_post.append(_on_frame_change) + + +def unregister(): + if _on_frame_change in bpy.app.handlers.frame_change_post: + bpy.app.handlers.frame_change_post.remove(_on_frame_change) + del bpy.types.WindowManager.wtd_shot_viewer + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/ui/panel.py b/ui/panel.py index b10b6b0..f3021dd 100644 --- a/ui/panel.py +++ b/ui/panel.py @@ -2,7 +2,7 @@ import textwrap import bpy -from .. import config, tools_ops +from .. import config, rename, shot_viewer, tools_ops from ..core import CheckContext, Severity, checks_for_context, contexts, runner from ..core.runner import Status from .operators import EXPANDED @@ -188,6 +188,145 @@ class _ToolsPanel: # Adds a separator line layout.separator() + layout.operator(shot_viewer.WTD_OT_shot_viewer_open.bl_idname, icon="CAMERA_DATA") + + +class _RenamePanel: + bl_region_type = "UI" + bl_category = "WTD" + bl_label = "Rename" + bl_options = {"DEFAULT_CLOSED"} # panel starts collapsed + + PREVIEW_COUNT = 10 + + def draw(self, context) -> None: + layout = self.layout + settings = context.scene.wtd_rename + layout.row().prop(settings, "target", expand=True) + + col = layout.column() + col.use_property_split = True + col.use_property_decorate = False + col.prop(settings, "base_name") + col.prop(settings, "prefix_mode") + if settings.prefix_mode == "CUSTOM": + col.prop(settings, "prefix_custom", text=" ") + col.prop(settings, "side") + col.prop(settings, "suffix_mode") + if settings.suffix_mode == "CUSTOM": + col.prop(settings, "suffix_custom", text=" ") + col.prop(settings, "sort") + if settings.target == "OBJECTS": + col.prop(settings, "rename_data") + + plan, skipped = rename.plan_renames(context, settings) + row = layout.row() + row.scale_y = 1.4 + row.operator( + rename.WTD_OT_batch_rename.bl_idname, + text=f"Rename {len(plan)} Item(s)" if plan else "Rename", + icon="SORTALPHA", + ) + if not plan and not skipped: + return + box = layout.box() + col = box.column(align=True) + for id_block, name in plan[: self.PREVIEW_COUNT]: + col.label(text=f"{id_block.name} → {name}") + hidden = len(plan) - self.PREVIEW_COUNT + if hidden > 0: + col.label(text=f"... {hidden} more") + for name in skipped: + col.label(text=f"{name}: no base name, set a name", icon="ERROR") + + +class WTD_UL_shots(bpy.types.UIList): + """Scene strips of the edit, in edit order.""" + + def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0): + camera = shot_viewer.shot_camera(item) + start, end = shot_viewer.strip_range(item) + split = layout.split(factor=0.4) + split.label(text=item.name, icon="SEQUENCE") + split = split.split(factor=0.6) + split.label(text=camera.name if camera else "(no camera)") + split.label(text=f"{start}-{end - 1}") + + def filter_items(self, context, data, propname): + strips = getattr(data, propname) + ordered = [strip.as_pointer() for strip in shot_viewer.shots(data.id_data)] + position = {pointer: index for index, pointer in enumerate(ordered)} + flags = [self.bitflag_filter_item if strip.as_pointer() in position else 0 for strip in strips] + if self.filter_name: + by_name = bpy.types.UI_UL_list.filter_items_by_name( + self.filter_name, self.bitflag_filter_item, strips, "name" + ) + flags = [flag & name_flag for flag, name_flag in zip(flags, by_name)] + # Shots in edit order, the other strips after them. + order = [] + hidden = len(ordered) + for strip in strips: + pointer = strip.as_pointer() + if pointer in position: + order.append(position[pointer]) + else: + order.append(hidden) + hidden += 1 + return flags, order + + +class _ShotViewerPanel: + bl_region_type = "UI" + bl_category = "WTD" + bl_label = "Shot Viewer" + + @classmethod + def poll(cls, context): + return shot_viewer.is_viewer(context.screen) + + def draw(self, context) -> None: + layout = self.layout + settings = context.window_manager.wtd_shot_viewer + scene = shot_viewer.edit_scene(context) + layout.prop(settings, "edit_scene", icon="SEQUENCE") + if scene is None: + layout.label(text="Pick the scene holding the edit", icon="ERROR") + return + has_shots = bool(shot_viewer.shots(scene)) + + row = layout.row(align=True) + row.scale_y = 1.4 + row.enabled = has_shots + row.operator(shot_viewer.WTD_OT_shot_viewer_step.bl_idname, text="Previous", icon="TRIA_LEFT").step = -1 + row.operator(shot_viewer.WTD_OT_shot_viewer_step.bl_idname, text="Next", icon="TRIA_RIGHT").step = 1 + row = layout.row() + if scene.sequence_editor is not None: + row.template_list( + "WTD_UL_shots", "", scene.sequence_editor, "strips_all", settings, "shot_index", rows=5 + ) + else: + row.label(text="") + col = row.column(align=True) + col.operator(shot_viewer.WTD_OT_shot_viewer_new_shot.bl_idname, text="", icon="ADD") + col.operator(shot_viewer.WTD_OT_shot_viewer_delete_shot.bl_idname, text="", icon="REMOVE") + if not has_shots: + layout.label(text="No shots yet: add one with +", icon="INFO") + layout.prop(settings, "loop_shot") + + groups = shot_viewer.rig_groups(context.view_layer) + if not groups: + layout.label(text="No rig in this shot", icon="INFO") + for label, rigs in groups.items(): + box = layout.box() + box.label(text=label, icon="ARMATURE_DATA") + col = box.column(align=True) + for rig in rigs: + col.operator( + shot_viewer.WTD_OT_shot_viewer_select_rig.bl_idname, + text=rig.name, + depress=rig.select_get(), + ).name = rig.name + class WTD_PT_sanity_view3d(_SanityPanel, bpy.types.Panel): bl_idname = "WTD_PT_sanity_view3d" @@ -199,12 +338,36 @@ class WTD_PT_tools_view3d(_ToolsPanel, bpy.types.Panel): bl_space_type = "VIEW_3D" +class WTD_PT_rename_view3d(_RenamePanel, bpy.types.Panel): + bl_idname = "WTD_PT_rename_view3d" + bl_space_type = "VIEW_3D" + + +class WTD_PT_rename_node_editor(_RenamePanel, bpy.types.Panel): + bl_idname = "WTD_PT_rename_node_editor" + bl_space_type = "NODE_EDITOR" + + +class WTD_PT_shot_viewer_view3d(_ShotViewerPanel, bpy.types.Panel): + bl_idname = "WTD_PT_shot_viewer_view3d" + bl_space_type = "VIEW_3D" + + class WTD_PT_sanity_sequencer(_SanityPanel, bpy.types.Panel): bl_idname = "WTD_PT_sanity_sequencer" bl_space_type = "SEQUENCE_EDITOR" -classes = (WTD_PT_sanity_view3d, WTD_PT_tools_view3d, WTD_PT_sanity_sequencer) +classes = ( + WTD_UL_shots, + # First, so it tops the WTD tab of the viewer window. + WTD_PT_shot_viewer_view3d, + WTD_PT_sanity_view3d, + WTD_PT_tools_view3d, + WTD_PT_rename_view3d, + WTD_PT_rename_node_editor, + WTD_PT_sanity_sequencer, +) def register():