Files
wtd-libreflow-addon/shot_viewer.py
T

649 lines
25 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,
and builds the shots of a Kitsu sequence (Libreflow, through the Scene Manager).
"""
import os
import re
import bpy
from bpy.app.handlers import persistent
from bpy.props import BoolProperty, EnumProperty, IntProperty, PointerProperty, StringProperty
from . import config
from .scene_manager import ipc
from .scene_manager import operators as sm
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 sequence_number(name):
"""Number of a sequence name written with SEQUENCE_NAME_FORMAT, else None ("sq0040" -> 40)."""
head, _sep, rest = config.SEQUENCE_NAME_FORMAT.partition("{")
match = re.fullmatch(re.escape(head) + r"(\d+)" + re.escape(rest.partition("}")[2]), name)
return int(match.group(1)) if match else None
def camera_name(scene, shot, sequence=None):
"""Name of a new shot camera (config SHOT_CAMERA_NAME_FORMAT), in the scene's sequence by default."""
return config.SHOT_CAMERA_NAME_FORMAT.format(
prefix=config.OBJECT_PREFIXES.get("CAMERA", ""), sequence=sequence or 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, sequence=None):
"""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, sequence)
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 or sequence_name(scene), shot=shot
)
return camera
def append_shot(context, name, camera, duration):
"""Camera marker for the shot after the last one (at SHOT_FRAME_START in an empty scene),
the scene end moved to its last frame. A marker of that name without camera is reused."""
scene = context.scene
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.get(name)
if marker is None or marker.camera is not None:
marker = scene.timeline_markers.new(name, frame=start)
marker.frame = start
marker.camera = camera
scene.frame_end = start + duration - 1
return marker
# --------------------------------------------------------------------------- #
# Kitsu sequence
# --------------------------------------------------------------------------- #
# Shots of the sequence picked for Shots from Kitsu (wtd_get_sequence_shots result).
KITSU = {}
def kitsu_sequence_shots(uid, oid):
"""wtd_get_sequence_shots for the sequence of the oid. With a wtdcmds too old to have it,
built from wtd_list_shots and the casting of every shot, without frame counts: the shots
Kitsu does not know are left out."""
try:
return ipc.call("wtd_get_sequence_shots", uid, oid=oid)
except ipc.LibreflowError as exc:
if exc.status != 404:
raise
context = ipc.call("wtd_get_context", uid, oid=oid)
result = {key: context.get(key) for key in ("film", "sequence", "sequence_oid")}
result["shots"] = []
for shot in ipc.call("wtd_list_shots", uid, oid=oid, film=context.get("film"), sequence=context.get("sequence")):
try:
casting = ipc.call("wtd_get_casting", uid, oid=shot["oid"])
except ipc.LibreflowError as exc:
if exc.status is None:
raise
continue
result["shots"].append(
{
"shot": shot["shot"],
"oid": shot["oid"],
"nb_frames": None,
"casting": [
{"name": a["name"], "asset_type": a["asset_type"], "nb_occurences": a["nb_occurences"]}
for a in casting["assets"]
],
}
)
return result
def kitsu_shot_state(scene, shot, only_cast):
""""EXISTS" (a camera marker has its name), "UNCAST" (no casting, skipped with only_cast) or "NEW"."""
if any(m.name == shot["shot"] for m in shots(scene)):
return "EXISTS"
if only_cast and not shot["casting"]:
return "UNCAST"
return "NEW"
def create_kitsu_shots(context, data, is_3d, only_cast, duration):
"""A camera rig and a shot for every NEW shot of the sequence, in Kitsu order, after the
shots of the scene. Kitsu's frame count is the shot length, else duration. Returns the markers."""
scene = context.scene
number = sequence_number(data["sequence"])
if number is not None:
scene.wtd_sequence = number
markers = []
for shot in data["shots"]:
if kitsu_shot_state(scene, shot, only_cast) != "NEW":
continue
camera = _new_camera_rig(context, shot["shot"], is_3d, data["sequence"])
markers.append(append_shot(context, shot["shot"], camera, shot.get("nb_frames") or duration))
return markers
# --------------------------------------------------------------------------- #
# 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"}
marker = append_shot(context, name, camera, self.duration)
show_shot(context, marker)
self.report({"INFO"}, f"Shot '{marker.name}' added at frame {marker.frame} ({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"}
def _sequence_items(self, context):
return _SEQUENCE_ITEMS
_SEQUENCE_ITEMS = []
class WTD_OT_shot_viewer_kitsu_sequence(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_kitsu_sequence"
bl_label = "Shots from Kitsu"
bl_description = (
"Pick a sequence, then build a camera rig and a shot for each of its Kitsu shots "
"(Libreflow, connected in the Scene Manager)"
)
bl_property = "sequence"
sequence: EnumProperty(items=_sequence_items)
@classmethod
def poll(cls, context):
return sm.connected()
def invoke(self, context, event):
oid = sm.project_oid(context.scene)
if not oid:
self.report({"WARNING"}, "Set a context oid (or open the project in Libreflow) first")
return {"CANCELLED"}
context.window.cursor_set("WAIT")
try:
listed = ipc.call("wtd_list_shots", sm.session_uid(), oid=oid)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"Sequences: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
sequences = {s["sequence_oid"]: f"{s.get('film', '')} / {s['sequence']}" for s in listed if s.get("sequence_oid")}
# The sequence of the scene's context first.
current = (sm.context_oid(context.scene) or "").split("/shots/")[0]
_SEQUENCE_ITEMS[:] = sorted(
((oid, label, oid) for oid, label in sequences.items()), key=lambda item: (item[0] != current, item[1])
)
if not _SEQUENCE_ITEMS:
self.report({"WARNING"}, "No sequence in the project")
return {"CANCELLED"}
context.window_manager.invoke_search_popup(self)
return {"RUNNING_MODAL"}
def execute(self, context):
context.window.cursor_set("WAIT")
try:
data = kitsu_sequence_shots(sm.session_uid(), self.sequence)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"Kitsu shots: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
if not data["shots"]:
self.report({"WARNING"}, f"No Kitsu shot in {data['sequence']}")
return {"CANCELLED"}
KITSU.clear()
KITSU.update(data)
bpy.ops.wtd_tools.shot_viewer_kitsu_shots("INVOKE_DEFAULT")
return {"FINISHED"}
KITSU_STATE_ICONS = {"NEW": "CAMERA_DATA", "EXISTS": "CHECKMARK", "UNCAST": "BLANK1"}
class WTD_OT_shot_viewer_kitsu_shots(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_kitsu_shots"
bl_label = "Build Kitsu Shots"
bl_description = "Build a camera rig and a shot for each Kitsu shot of the picked sequence not in the scene yet"
bl_options = {"REGISTER", "UNDO"}
only_cast: BoolProperty(
name="Only Cast Shots",
description="Leave out the shots with nothing cast in Kitsu",
default=True,
)
camera_3d: BoolProperty(name="3D Cameras", description="Build 3D crane rigs. Otherwise 2D camera rigs")
duration: IntProperty(
name="Default Duration",
description="Length of the shots without a frame count in Kitsu",
min=1,
default=48,
)
@classmethod
def poll(cls, context):
return bool(KITSU.get("shots"))
def invoke(self, context, event):
self.duration = config.SHOT_DEFAULT_DURATION
return context.window_manager.invoke_props_dialog(self, width=420)
def draw(self, context):
layout = self.layout
scene = context.scene
layout.label(text=f"{KITSU.get('film') or ''} / {KITSU['sequence']}", icon="SEQUENCE")
col = layout.column(align=True)
for shot in KITSU["shots"]:
state = kitsu_shot_state(scene, shot, self.only_cast)
row = col.row()
row.active = state == "NEW"
row.label(text=shot["shot"], icon=KITSU_STATE_ICONS[state])
row.label(text=f"{shot['nb_frames']} fr" if shot.get("nb_frames") else "? fr")
names = ", ".join(c["name"] for c in shot["casting"])
row.label(text=names or "nothing cast")
layout.separator()
layout.use_property_split = True
layout.use_property_decorate = False
layout.prop(self, "only_cast")
layout.prop(self, "camera_3d")
layout.prop(self, "duration")
new = [s for s in KITSU["shots"] if kitsu_shot_state(scene, s, self.only_cast) == "NEW"]
if not new:
layout.label(text="Every shot is in the scene already", icon="INFO")
else:
first = camera_name(scene, new[0]["shot"], KITSU["sequence"])
layout.label(text=f"{len(new)} shot(s) to build: {first}...", icon="CAMERA_DATA")
if not camera_rig_available():
layout.label(text="Enable the Add Camera Rigs add-on", icon="ERROR")
def execute(self, context):
if not camera_rig_available():
self.report({"WARNING"}, "Enable the Add Camera Rigs add-on to build the shot cameras")
return {"CANCELLED"}
markers = create_kitsu_shots(context, KITSU, self.camera_3d, self.only_cast, self.duration)
if not markers:
self.report({"INFO"}, "Every shot is in the scene already")
return {"CANCELLED"}
show_shot(context, markers[0])
self.report({"INFO"}, f"{len(markers)} shot(s) built from {KITSU['sequence']}: " + ", ".join(m.name for m in markers))
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_kitsu_sequence,
WTD_OT_shot_viewer_kitsu_shots,
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)