Files
wtd-libreflow-addon/shot_viewer.py
T
NDemaerschalkandClaude Opus 5.5 bac7a6f5da Scene Manager: LOD switching; Shot Viewer renamed Shot Manager
LODs are child collections of the asset collection (COL_{asset}_LOD{n}).
Overrides hide the other LOD collections (viewport and render), keeping
the rig, pose and animation; collection instances instance the LOD
collection. Each instance has its own LOD, set per instance or for the
selection in the Loaded panel, kept through save, reload and updates.
Loading shows SCENE_MANAGER_DEFAULT_LOD (0).

The Shot Viewer panel is now "Shot Manager" and starts closed. Code
names and SHOT_VIEWER_* config keys are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:42:24 +01:00

425 lines
16 KiB
Python

"""Shot manager: switch, add and delete the shots of the scene from the sidebar.
Shots are the timeline markers bound to a camera (Ctrl+B in the timeline). A
shot runs from its marker to the frame before the next camera marker, the last
one to the scene end. Showing a shot puts the 3D view the panel is used from
in that shot's camera view. The panel also selects the character / prop rigs.
"""
import os
import re
import bpy
from bpy.app.handlers import persistent
from bpy.props import BoolProperty, IntProperty, PointerProperty, StringProperty
from . import config
OTHER_GROUP = "Other"
# --------------------------------------------------------------------------- #
# Shots
# --------------------------------------------------------------------------- #
def shots(scene):
"""Camera markers of the scene, in time order."""
if scene is None:
return []
return sorted((m for m in scene.timeline_markers if m.camera is not None), key=lambda m: m.frame)
def shot_range(marker):
"""(first, last) frame of the shot: up to the next camera marker, the last shot up to the scene end."""
scene = marker.id_data
later = [m.frame for m in shots(scene) if m.frame > marker.frame]
end = min(later) - 1 if later else max(marker.frame, scene.frame_end)
return marker.frame, end
def shot_at(scene, frame):
"""The shot showing at the frame (the last camera marker at or before it), or None."""
return next((m for m in reversed(shots(scene)) if m.frame <= frame), None)
def current_shot(context):
return shot_at(context.scene, context.scene.frame_current)
def _marker_index(marker):
return list(marker.id_data.timeline_markers).index(marker)
def apply_loop(context, marker):
"""With Loop Shot on, set the preview range to the shot so playback loops on it."""
if not context.window_manager.wtd_shot_viewer.loop_shot:
return
scene = marker.id_data
start, end = shot_range(marker)
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, marker):
"""Jump to the shot and look through its camera in the current 3D view."""
scene = marker.id_data
# The camera markers switch the camera on frame change; set it right away.
scene.camera = marker.camera
scene.frame_current = marker.frame
# Item assignment skips the update callback, which would call this again.
context.window_manager.wtd_shot_viewer["shot_index"] = _marker_index(marker)
apply_loop(context, marker)
space = context.space_data
if space is not None and space.type == "VIEW_3D":
space.use_local_camera = False
space.region_3d.view_perspective = "CAMERA"
_syncing = False
@persistent
def _on_frame_change(scene, _depsgraph=None):
"""Keep the shot list (and the loop range) on the shot under the playhead."""
global _syncing
context = bpy.context
wm = context.window_manager
if _syncing or wm is None:
return
marker = shot_at(scene, scene.frame_current)
settings = wm.wtd_shot_viewer
if marker is None or _marker_index(marker) == settings.shot_index:
return
_syncing = True
try:
settings["shot_index"] = _marker_index(marker)
apply_loop(context, marker)
finally:
_syncing = False
def next_shot_name(scene):
"""Next free shot name (config SHOT_NAME_FORMAT), after the highest one of the markers."""
head, _sep, rest = config.SHOT_NAME_FORMAT.partition("{")
pattern = re.compile(re.escape(head) + r"(\d+)" + re.escape(rest.partition("}")[2]))
numbers = [int(match.group(1)) for m in scene.timeline_markers if (match := pattern.search(m.name))]
step = config.SHOT_NUMBER_STEP
return config.SHOT_NAME_FORMAT.format((max(numbers, default=0) // step + 1) * step)
def sequence_name(scene):
return config.SEQUENCE_NAME_FORMAT.format(scene.wtd_sequence)
def camera_name(scene, shot):
"""Name of a new shot camera (config SHOT_CAMERA_NAME_FORMAT)."""
return config.SHOT_CAMERA_NAME_FORMAT.format(
prefix=config.OBJECT_PREFIXES.get("CAMERA", ""), sequence=sequence_name(scene), shot=shot
)
def camera_rig_available():
"""True when the Add Camera Rigs add-on (bpy.ops.object.build_camera_rig) is enabled."""
return hasattr(bpy.types, "OBJECT_OT_build_camera_rig")
def _new_camera_rig(context, shot, is_3d):
"""Camera rig built by the Add Camera Rigs add-on at the 3D cursor, named for the shot.
Returns the camera object."""
scene = context.scene
if context.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
before = {ob.as_pointer() for ob in bpy.data.objects}
bpy.ops.object.build_camera_rig(mode=config.SHOT_CAMERA_RIG_MODES["3D" if is_3d else "2D"])
# Found by difference: the add-on may also add widget objects for the bone shapes.
new = [ob for ob in bpy.data.objects if ob.as_pointer() not in before]
camera = next((ob for ob in new if ob.type == "CAMERA"), None)
if camera is None:
raise RuntimeError("build_camera_rig created no camera")
camera.name = camera.data.name = camera_name(scene, shot)
rig = camera.parent
if rig is not None and rig in new:
rig.name = rig.data.name = config.SHOT_CAMERA_RIG_NAME_FORMAT.format(
prefix=config.OBJECT_PREFIXES.get("ARMATURE", ""), sequence=sequence_name(scene), shot=shot
)
return camera
# --------------------------------------------------------------------------- #
# Rigs
# --------------------------------------------------------------------------- #
def _rig_library(ob):
reference = ob.override_library.reference if ob.override_library else None
return ob.library or (reference.library if reference else None)
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)]
library = _rig_library(ob)
if library is not None:
names.append(os.path.basename(library.filepath))
return names
def _rig_group(ob, patterns):
library = _rig_library(ob)
# Loaded by the Scene Manager: its Libreflow asset type decides.
asset_type = library.get("wtd_asset_type") if library is not None else None
if asset_type in config.SHOT_VIEWER_ASSET_TYPE_GROUPS:
return config.SHOT_VIEWER_ASSET_TYPE_GROUPS[asset_type]
names = _rig_names(ob)
return next((label for label, pattern in patterns if any(pattern.search(name) for name in names)), OTHER_GROUP)
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.update({label: [] for label in config.SHOT_VIEWER_ASSET_TYPE_GROUPS.values() if label not in groups})
groups[OTHER_GROUP] = []
# Camera rigs (armatures carrying a camera) are left out: one per shot.
rigs = sorted(
(
ob
for ob in view_layer.objects
if ob.type == "ARMATURE" and not any(child.type == "CAMERA" for child in ob.children)
),
key=lambda ob: ob.name.lower(),
)
for ob in rigs:
groups[_rig_group(ob, patterns)].append(ob)
return {label: rigs for label, rigs in groups.items() if rigs}
# --------------------------------------------------------------------------- #
# Settings / operators
# --------------------------------------------------------------------------- #
def _shot_index_update(self, context):
markers = context.scene.timeline_markers
if 0 <= self.shot_index < len(markers) and markers[self.shot_index].camera is not None:
show_shot(context, markers[self.shot_index])
def _loop_shot_update(self, context):
marker = current_shot(context)
if self.loop_shot:
if marker is not None:
apply_loop(context, marker)
else:
context.scene.use_preview_range = False
class WTD_ShotViewerSettings(bpy.types.PropertyGroup):
shot_index: IntProperty(name="Shot", update=_shot_index_update)
loop_shot: BoolProperty(
name="Loop Shot",
description="Set the preview range to the shot under the playhead, so playback loops on it",
default=False,
update=_loop_shot_update,
)
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"
step: IntProperty(default=1)
def execute(self, context):
ordered = shots(context.scene)
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))
show_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 (camera marker) at the end of the scene"
bl_options = {"REGISTER", "UNDO"}
name: StringProperty(name="Name", description="Name of the marker")
duration: IntProperty(name="Duration", description="Length of the shot in frames", min=1, default=48)
new_camera: BoolProperty(
name="New Camera",
description="Build a camera rig for this shot (Add Camera Rigs add-on). Otherwise pick an existing camera",
default=True,
)
camera_3d: BoolProperty(
name="3D Camera",
description="Build a 3D crane rig. Otherwise a 2D camera rig",
)
camera_name: StringProperty(name="Camera")
def invoke(self, context, event):
self.name = next_shot_name(context.scene)
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, "duration")
layout.prop(self, "new_camera")
if self.new_camera:
layout.prop(self, "camera_3d")
name = self.name.strip() or next_shot_name(context.scene)
layout.label(text=camera_name(context.scene, name), icon="CAMERA_DATA")
if not camera_rig_available():
layout.label(text="Enable the Add Camera Rigs add-on", icon="ERROR")
else:
layout.prop_search(self, "camera_name", context.scene, "objects", icon="CAMERA_DATA")
def execute(self, context):
scene = context.scene
name = self.name.strip() or next_shot_name(scene)
if self.new_camera:
if not camera_rig_available():
self.report({"WARNING"}, "Enable the Add Camera Rigs add-on to build the shot camera")
return {"CANCELLED"}
camera = _new_camera_rig(context, name, self.camera_3d)
else:
camera = scene.objects.get(self.camera_name)
if camera is None or camera.type != "CAMERA":
self.report({"WARNING"}, "Pick a camera of the scene, or tick New Camera")
return {"CANCELLED"}
ordered = shots(scene)
if ordered:
start = shot_range(ordered[-1])[1] + 1
else:
start = config.SHOT_FRAME_START
scene.frame_start = start
marker = scene.timeline_markers.new(name, frame=start)
marker.camera = camera
scene.frame_end = start + self.duration - 1
show_shot(context, marker)
self.report({"INFO"}, f"Shot '{marker.name}' added at frame {start} ({camera.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 shot under the playhead (its camera marker)"
bl_options = {"REGISTER", "UNDO"}
delete_camera: BoolProperty(
name="Delete Camera", description="Also delete its camera 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
marker = current_shot(context)
layout.label(text=f"Delete shot '{marker.name}'?", icon="ERROR")
if marker != shots(context.scene)[-1]:
layout.label(text="Its frames go to the previous shot, the animation is not moved.")
layout.prop(self, "delete_camera")
def execute(self, context):
scene = context.scene
marker = current_shot(context)
ordered = shots(scene)
position = ordered.index(marker)
name, camera = marker.name, marker.camera
start = marker.frame
is_last = position == len(ordered) - 1
scene.timeline_markers.remove(marker)
if is_last and position > 0:
# The frames were only this shot's.
scene.frame_end = start - 1
deleted = ""
if self.delete_camera and not any(m.camera == camera for m in scene.timeline_markers):
data = camera.data
bpy.data.objects.remove(camera)
if data.users == 0:
bpy.data.cameras.remove(data)
deleted = " with its camera"
remaining = shots(scene)
if remaining:
show_shot(context, remaining[min(position, len(remaining) - 1)])
self.report({"INFO"}, f"Shot '{name}' deleted{deleted}")
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_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.types.Scene.wtd_sequence = IntProperty(
name="Sequence",
description="Sequence number of the scene, used in the names of new cameras (config SEQUENCE_NAME_FORMAT)",
min=0,
)
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.Scene.wtd_sequence
del bpy.types.WindowManager.wtd_shot_viewer
for cls in reversed(classes):
bpy.utils.unregister_class(cls)