New Scene Manager panel (WTD tab) talking to a running Libreflow through its IPC server and the wtdcmds commands: pick the shot context, read the casting, link the latest available publishes (overrides for chars and props, collection instances otherwise), flag out-of-date versions in red, update / roll back / remove loaded assets, and publish local collections as assets. Requests run in the background. Settings in config.py (SCENE_MANAGER_*, LIBREFLOW_*). The Shot Viewer groups rigs loaded by the Scene Manager by asset type. The manifest declares the network permission. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
425 lines
16 KiB
Python
425 lines
16 KiB
Python
"""Shot viewer: 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)
|