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>
425 lines
16 KiB
Python
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)
|