"""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 mathutils import Euler, Vector 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 _new_camera(scene, shot, source=None): """New camera in the scene collection, copying ``source`` (lens and placement) when given.""" name = camera_name(scene, shot) if source is not None and source.type == "CAMERA": data = source.data.copy() data.name = name else: source = None data = bpy.data.cameras.new(name) camera = bpy.data.objects.new(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) return camera # --------------------------------------------------------------------------- # # 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): 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="Add a camera for this shot, copying the current one. Otherwise pick an existing camera", default=True, ) 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: name = self.name.strip() or next_shot_name(context.scene) layout.label(text=camera_name(context.scene, name), icon="CAMERA_DATA") 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: camera = _new_camera(scene, name, scene.camera) 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)