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 <noreply@anthropic.com>
This commit is contained in:
Nero Frame 13andClaude Opus 5.5 committed 2026-10-19 14:30:00 +02:00
1 parent b800e5a68b
commit 1a1767c984
5 files changed
+1173 -3

No files matched your search

+13 -1
View File
@@ -8,7 +8,7 @@ for _name in [name for name in sys.modules if name.startswith(__name__ + ".")]:
import bpy # noqa: E402 import bpy # noqa: E402
from bpy.app.handlers import persistent # 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 from .core import runner # noqa: E402
api = core api = core
@@ -43,6 +43,12 @@ def register() -> None:
# Register tool operators # Register tool operators
tools_ops.register() 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 # Load all the sanity checks into the sanity REGISTRY
checks.load_all() checks.load_all()
@@ -68,6 +74,12 @@ def unregister() -> None:
# Unregister all panels # Unregister all panels
ui.unregister() ui.unregister()
# Unregister the rename tool (after its panel)
rename.unregister()
# Unregister the shot viewer (after its panel)
shot_viewer.unregister()
runner.clear() runner.clear()
# The check registry is plain Python and is rebuilt with the modules on # 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). # re-import, so it is kept as is (enable/disable without re-import).
+43
View File
@@ -207,6 +207,49 @@ DUPLICATE_NAME_TYPES = [
"node_groups", "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 # UI
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+281
View File
@@ -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)
+671
View File
@@ -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)
+165 -2
View File
@@ -2,7 +2,7 @@ import textwrap
import bpy 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 import CheckContext, Severity, checks_for_context, contexts, runner
from ..core.runner import Status from ..core.runner import Status
from .operators import EXPANDED from .operators import EXPANDED
@@ -188,6 +188,145 @@ class _ToolsPanel:
# Adds a separator line # Adds a separator line
layout.separator() 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): class WTD_PT_sanity_view3d(_SanityPanel, bpy.types.Panel):
bl_idname = "WTD_PT_sanity_view3d" bl_idname = "WTD_PT_sanity_view3d"
@@ -199,12 +338,36 @@ class WTD_PT_tools_view3d(_ToolsPanel, bpy.types.Panel):
bl_space_type = "VIEW_3D" 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): class WTD_PT_sanity_sequencer(_SanityPanel, bpy.types.Panel):
bl_idname = "WTD_PT_sanity_sequencer" bl_idname = "WTD_PT_sanity_sequencer"
bl_space_type = "SEQUENCE_EDITOR" 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(): def register():