From c376a9259168e25a7d83d6ef256c238ca7cead2f Mon Sep 17 00:00:00 2001 From: Nero Frame 13 Date: Tue, 20 Oct 2026 15:56:28 +0200 Subject: [PATCH] Shot Viewer: camera markers in a sidebar panel Shots are now timeline markers bound to a camera instead of sequencer scene strips, all in one scene. The Shot Viewer is a fixed panel in the WTD tab instead of a separate window: choosing a shot moves the playhead and puts the current 3D view in its camera. Adding a shot creates a marker and camera at the end of the scene, deleting removes the marker (and optionally the camera). Loop Shot is now off by default. Co-Authored-By: Claude Opus 5.5 --- README.md | 87 ++++---- shot_viewer.py | 532 +++++++++++-------------------------------------- ui/panel.py | 51 ++--- 3 files changed, 171 insertions(+), 499 deletions(-) diff --git a/README.md b/README.md index 3713161..cf001b3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Blender add-on for the Melvile pipeline (Walking The Dog). It brings together: - **[Sanity Check](#sanity-check)**: checks a file against the production specs and fixes what can be fixed. - **[Rename](#rename)**: batch renames objects and materials to the naming convention, with consistent numbering. -- **[Shot Viewer](#shot-viewer)**: a window to review, switch, create and delete the shots of a sequence, with quick access to the character and prop rigs. +- **[Shot Viewer](#shot-viewer)**: switch, create and delete the shots of a sequence (camera markers) from the sidebar, with quick access to the character and prop rigs. - **[Get Oid](#get-oid-for-open-scene)**: asks Libreflow for the id of the open file. Most values the tools rely on (prefixes, shot naming, frame rate…) live in `config.py` and can be overridden per studio without touching the code. See [Configuration](#configuration). @@ -27,7 +27,7 @@ Most values the tools rely on (prefixes, shot naming, frame rate…) live in `co ## Installation -Tested on **Blender 5.2**. The Shot Viewer relies on sequencer features added in Blender 5. +Tested on **Blender 5.2**. **As an extension (artists):** build the package, then in Blender go to *Edit > Preferences > Get Extensions > Install from Disk…* and pick the `.zip`. @@ -55,9 +55,9 @@ Open the sidebar of the 3D Viewport (**N**) and click the **WTD** tab. | Panel | Editors | |---|---| | **Sanity Check** | 3D Viewport, Video Sequencer | -| **Tools** (Get Oid, Shot Viewer button) | 3D Viewport | +| **Tools** (Get Oid) | 3D Viewport | | **Rename** | 3D Viewport, Shader Editor | -| **Shot Viewer** | Only in the Shot Viewer window | +| **Shot Viewer** | 3D Viewport | --- @@ -178,10 +178,6 @@ The character checks compare each character to a **master character**: Asks Libreflow for the object id (oid) of the open file and shows it in the status bar. Blender must have been started from Libreflow, so that the Kabaret connection is available. -### Shot Viewer - -Opens the [Shot Viewer](#shot-viewer) window. - --- ## Rename @@ -237,40 +233,39 @@ The box under the **Rename** button previews every change (`old name → new nam ## Shot Viewer -A separate window to review the shots of a sequence through their camera. You can switch shots, add and delete them, and grab the character and prop rigs. +A sidebar panel to go through the shots of a sequence in their camera. You can switch shots, add and delete them, and grab the character and prop rigs. ### How shots are stored -- The **edit** is a scene whose Video Sequencer holds the sequence. -- Each **shot** is a **scene strip** in that sequencer. The strip points to a scene (where the set and characters live) and optionally to a camera (*Strip > Scene > Camera*). Without a camera on the strip, the scene's active camera is used. -- Several shots can use the same scene with different cameras and frame ranges. +The whole sequence lives in **one scene**: -### Opening it +- Each **shot** is a **timeline marker bound to a camera**. To bind one by hand: select the camera, put the playhead on the marker, then *Marker > Bind Camera to Markers* (**Ctrl+B**) in the Timeline. +- A shot runs from its marker to the frame before the next camera marker. The last shot runs to the scene's **End** frame. +- Markers without a camera are ignored, so you can keep using them as notes. +- Several shots can use the same camera. -*WTD tab > Tools > **Shot Viewer***. +During playback, Blender itself switches to the camera of each marker, so playing the scene plays the cut. -This opens a new window with its own **Shot Viewer** workspace (a tab in the top bar): +### Where it is -- **Top:** the 3D view, looking through the shot camera. -- **Bottom:** the Sequencer showing the **edit**, with each shot as a strip and the playhead showing where you are in the sequence. -- **Sidebar (WTD tab):** the **Shot Viewer** panel. +*3D Viewport sidebar (**N**) > WTD tab > **Shot Viewer***. -The window starts on the shot under the edit playhead. The file does not need any shots yet: the current scene becomes the edit, and you can add shots right away. +Choosing a shot in the panel moves the playhead to it and puts **the 3D view you clicked in** into that shot's camera view. Keep a Timeline open (for example at the bottom of the Layout workspace) to see the camera markers and where you are in the sequence. -Your main window stays on the edit scene. You can keep working there, or put the viewer on a second screen. +The list always highlights the shot under the playhead. The file does not need any shots yet: add the first one with **+**. -### Moving through the edit +### Moving through the sequence | To… | Do… | |---|---| -| Jump to a shot | Click it in the shot list (name, camera, frames in the edit). | +| Jump to a shot | Click it in the shot list (name, camera, frames). The playhead jumps to the first frame of the shot. | | Go to the previous / next shot | **Previous** / **Next**. | | Find a shot by name | The filter field at the bottom of the shot list. | -| Scrub the sequence | Drag the playhead in the Sequencer at the bottom. The 3D view switches to the shot under the playhead (scene, camera and frame), and the list follows. This is Blender's **Sync Scene Time** option, the checkbox at the bottom of the Sequencer. | -| Play one shot | Press play with the mouse over the **3D view**. With **Loop Shot** on, playback loops on the shot. The edit playhead follows along. | -| Play the whole sequence | Press play with the mouse over the **Sequencer**. The 3D view follows from shot to shot. | +| Scrub the sequence | Drag the playhead in the Timeline. The camera and the shot list follow. | +| Play one shot | Tick **Loop Shot** and press play: playback loops on the shot under the playhead. | +| Play the whole sequence | Press play with **Loop Shot** unticked (the default): the cameras switch at each marker. | -**Loop Shot** works by setting the preview range of the shot's scene to the shot frames. Unticking it removes that preview range from the shot scenes. +**Loop Shot** works by setting the scene's preview range to the shot frames (the shaded part of the Timeline). It follows when you change shot. Unticking it turns the preview range off. ### Adding a shot @@ -278,29 +273,28 @@ Click **+** next to the shot list: | Setting | Use | |---|---| -| **Name** | Pre-filled with the next free shot number (`sh0010`, `sh0020`…). | -| **Scene: New, Shared Content** | A new scene using the **same collections** as the scene shown in the viewer (set, characters…), without copying them. Cameras placed directly in that scene's top collection are not carried over. | -| **Scene: New, Empty** | A blank scene with the resolution and frame rate of the edit. | -| **Scene: Existing** | Reuse an existing scene for its whole frame range. **New Camera** decides whether the shot gets its own camera or uses the scene's. | -| **Duration** | Length in frames, for new scenes. New scenes start at frame 1001. | +| **Name** | Pre-filled with the next free shot number (`sh0010`, `sh0020`…). This is the marker name. | +| **Duration** | Length of the shot in frames. | +| **New Camera** | On: creates the camera `CAM_`, copying the lens and position of the current camera, so you start from the current framing. Off: pick an existing camera of the scene. | -The new shot is placed at the end of the edit and gets its own camera `CAM_`. The camera copies the lens and position of the camera you were looking through, so you start from the current framing. The viewer then jumps to the new shot. +The shot is added at the end of the sequence and the scene's **End** frame grows to include it. The first shot starts at frame 1001, and the scene **Start** moves there too. The playhead and the 3D view then jump to the new shot. + +New cameras go in the scene's top collection: move them into your camera collection if you have one. ### Deleting a shot -Select it and click **−**: +Put the playhead on the shot (or select it in the list) and click **−**: -| Option | Default | Use | -|---|---|---| -| **Close Gap** | On | Moves the following strips back so the edit has no hole. | -| **Delete Camera** | Off | Also deletes the shot camera, unless another shot uses it. | -| **Delete Scene** | Off | Also deletes the shot scene, unless another shot uses it. The edit scene is never deleted. | +- The shot's camera marker is removed. +- **Delete Camera** (off by default): also deletes its camera, unless another shot uses it. +- **Deleting a shot in the middle:** its frames go to the previous shot. **Nothing is moved in time**: the animation stays where it is, so cut or shift it by hand if the frames must disappear. +- **Deleting the last shot:** the scene's **End** frame moves back to the end of the previous shot. **Ctrl+Z** undoes adding and deleting shots. ### Selecting rigs -The bottom of the panel lists the armatures of the shot, grouped into **Characters**, **Props** and **Other**: +The bottom of the panel lists the armatures of the scene, grouped into **Characters**, **Props** and **Other**: - **Click** a rig: selects it and enters Pose Mode. - **Shift+click**: adds it to the selection, to pose several rigs at once. @@ -308,12 +302,6 @@ The bottom of the panel lists the armatures of the shot, grouped into **Characte A rig counts as a character or a prop when its name, one of its collections, or the library file it comes from starts with `chr_` / `prp_` (see `SHOT_VIEWER_RIG_GROUPS` in [Configuration](#configuration)). Hidden rigs cannot be selected. -### Good to know - -- The **Shot Viewer** workspace is saved with the file. Opening the viewer again reuses it. -- After reopening a file, check the **Edit** field at the top of the panel if the edit is not found automatically. -- Reversed strips and strips with a changed speed are not supported. - --- ## Configuration @@ -367,10 +355,13 @@ Either no character reference is set (see [Character reference](#character-refer Nothing is selected, the selection is linked from a library, or (in *Materials* mode) the selected objects have no local material. **Shot Viewer: a rig is not in the list.** -Only armatures in the shot's view layer are listed. A collection instance that has not been overridden does not expose its armature: make a library override first. +Only armatures in the scene's view layer are listed. A collection instance that has not been overridden does not expose its armature: make a library override first. -**Shot Viewer: the 3D view does not follow when I scrub.** -Check that **Sync Scene Time** is ticked at the bottom of the Sequencer in the viewer window. +**Shot Viewer: a shot is missing from the list.** +Only markers bound to a camera are shots. Select the marker, select its camera, and press **Ctrl+B** in the Timeline. + +**Shot Viewer: playback only plays one shot.** +**Loop Shot** is on. Untick it to play the whole sequence. --- diff --git a/shot_viewer.py b/shot_viewer.py index d9cc5de..86494f5 100644 --- a/shot_viewer.py +++ b/shot_viewer.py @@ -1,8 +1,9 @@ -"""Shot viewer: a window showing the shots of the edit through their camera. +"""Shot viewer: switch, add and delete the shots of the scene from the sidebar. -Shots are the scene strips of the edit scene's sequencer. The viewer is a -separate window showing the scene of a strip through the strip's camera, with -a sidebar panel to switch shots and quickly select the character / prop rigs. +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 @@ -10,100 +11,51 @@ import re import bpy from bpy.app.handlers import persistent -from bpy.props import BoolProperty, EnumProperty, IntProperty, PointerProperty, StringProperty +from bpy.props import BoolProperty, IntProperty, PointerProperty, StringProperty from mathutils import Euler, Vector from . import config -# Custom property marking the screen of a viewer window (saved with the file). -VIEWER_TAG = "wtd_shot_viewer" -VIEWER_WORKSPACE = "Shot Viewer" -# Height of the edit sequencer at the bottom of the viewer, as a part of the window. -VIEWER_TIMELINE_FACTOR = 0.25 OTHER_GROUP = "Other" # --------------------------------------------------------------------------- # # Shots # --------------------------------------------------------------------------- # -def has_shots(scene): - editor = scene.sequence_editor - return editor is not None and any(strip.type == "SCENE" for strip in editor.strips_all) - - -def edit_scene(context): - """The scene holding the edit: the one set in the settings, else the first with scene strips.""" - settings = context.window_manager.wtd_shot_viewer - if settings.edit_scene is not None: - return settings.edit_scene - return next((scene for scene in bpy.data.scenes if has_shots(scene)), None) - - -def strip_range(strip): - """(first frame, last frame + 1) of the strip in the edit.""" - if hasattr(strip, "left_handle"): # Blender 5.1+ names, the old ones go in 6.0 - return int(strip.left_handle), int(strip.right_handle) - return int(strip.frame_final_start), int(strip.frame_final_end) - - -def _content_start(strip): - return strip.content_start if hasattr(strip, "content_start") else strip.frame_start - - def shots(scene): - """Scene strips of the edit in edit order (muted strips and strips without a scene left out).""" - if scene is None or scene.sequence_editor is None: + """Camera markers of the scene, in time order.""" + if scene is None: return [] - strips = [ - strip - for strip in scene.sequence_editor.strips_all - if strip.type == "SCENE" and strip.scene is not None and not strip.mute - ] - return sorted(strips, key=lambda strip: (strip_range(strip)[0], strip.channel)) + 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): - """The shot selected in the viewer, or None.""" - scene = edit_scene(context) - if scene is None or scene.sequence_editor is None: - return None - strips = scene.sequence_editor.strips_all - index = context.window_manager.wtd_shot_viewer.shot_index - if not 0 <= index < len(strips): - return None - strip = strips[index] - return strip if strip in shots(scene) else None + return shot_at(context.scene, context.scene.frame_current) -def shot_camera(strip): - return strip.scene_camera or strip.scene.camera +def _marker_index(marker): + return list(marker.id_data.timeline_markers).index(marker) -def shot_frame_range(strip): - """Frame range of the strip in its own scene (the strip content starts at the scene start).""" - start, end = strip_range(strip) - offset = strip.scene.frame_start - int(_content_start(strip)) - return start + offset, end - 1 + offset - - -def is_viewer(screen): - return screen is not None and bool(screen.get(VIEWER_TAG)) - - -def viewer_areas(context): - for window in context.window_manager.windows: - if is_viewer(window.screen): - for area in window.screen.areas: - if area.type == "VIEW_3D": - yield window, area - - -def apply_loop(context, strip): - """With Loop Shot on, set the preview range of the shot scene to the shot so playback loops on it.""" +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 = strip.scene - start, end = shot_frame_range(strip) + 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 @@ -111,33 +63,19 @@ def apply_loop(context, strip): scene.frame_preview_start = start -def show_shot(context, strip): - """Show the shot in every viewer window and move the edit playhead to it.""" - scene = strip.scene - camera = shot_camera(strip) - start, end = shot_frame_range(strip) - scene.frame_current = start - apply_loop(context, strip) - strip.id_data.frame_current = strip_range(strip)[0] - - for window, area in viewer_areas(context): - window.scene = scene - space = area.spaces.active - space.use_local_camera = camera is not None - space.camera = camera +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" - area.tag_redraw() - - -def shot_at(edit, frame): - """The shot under the frame of the edit (top channel first), or None.""" - covering = [strip for strip in shots(edit) if strip_range(strip)[0] <= frame < strip_range(strip)[1]] - return max(covering, key=lambda strip: strip.channel, default=None) - - -def _shot_frame(strip, edit_frame): - """Frame of the strip's scene shown at a frame of the edit.""" - return edit_frame + shot_frame_range(strip)[0] - strip_range(strip)[0] _syncing = False @@ -145,93 +83,35 @@ _syncing = False @persistent def _on_frame_change(scene, _depsgraph=None): - """Keep the shot list and the edit playhead in step with the viewer. - - Scrubbing the edit selects the shot under the playhead (the workspace time - sync shows it). Playing a shot in the 3D view moves the edit playhead along. - """ + """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 or not any(is_viewer(window.screen) for window in wm.windows): + if _syncing or wm is None: return + marker = shot_at(scene, scene.frame_current) settings = wm.wtd_shot_viewer - edit = settings.edit_scene - if edit is None or edit.sequence_editor is None: + if marker is None or _marker_index(marker) == settings.shot_index: return _syncing = True try: - # Edit scrubbed (or played): the shot under the playhead, already shown - # by the workspace time sync, becomes the selected one. - under = shot_at(edit, edit.frame_current) - if under is not None and (scene == edit or _shot_frame(under, edit.frame_current) == scene.frame_current): - index = list(edit.sequence_editor.strips_all).index(under) - if index != settings.shot_index: - settings["shot_index"] = index - apply_loop(context, under) - else: - # Shot played in the 3D view: the edit playhead follows. - strip = current_shot(context) - if strip is not None and strip.scene == scene: - start, end = strip_range(strip) - frame = start + scene.frame_current - shot_frame_range(strip)[0] - edit.frame_current = max(start, min(end - 1, frame)) - for _window, area in viewer_areas(context): - area.tag_redraw() + settings["shot_index"] = _marker_index(marker) + apply_loop(context, marker) finally: _syncing = False -def select_shot(context, strip): - """Make the strip the selected shot and show it.""" - index = list(strip.id_data.sequence_editor.strips_all).index(strip) - # Item assignment skips the update callback: the shot is shown just below, - # also when the index does not change (e.g. after a deletion). - context.window_manager.wtd_shot_viewer["shot_index"] = index - show_shot(context, strip) - - -def next_shot_name(edit): - """Next free shot name (config SHOT_NAME_FORMAT), after the highest one of the strips and scenes.""" +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])) - names = [scene.name for scene in bpy.data.scenes] - if edit.sequence_editor is not None: - names += [strip.name for strip in edit.sequence_editor.strips_all] - numbers = [int(match.group(1)) for name in names if (match := pattern.search(name))] + 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 _new_shot_scene(name, edit, source, duration): - """New shot scene: a linked copy of ``source`` (same collections) or, without source, an empty - scene with the format of the edit.""" - if source is not None: - scene = source.copy() - scene.name = name - scene.sequence_editor_clear() - # The cameras of the source shot are not shared. - for ob in [ob for ob in scene.collection.objects if ob.type == "CAMERA"]: - scene.collection.objects.unlink(ob) - else: - scene = bpy.data.scenes.new(name) - for attr in ( - "fps", - "fps_base", - "resolution_x", - "resolution_y", - "resolution_percentage", - "pixel_aspect_x", - "pixel_aspect_y", - ): - setattr(scene.render, attr, getattr(edit.render, attr)) - scene.frame_start = config.SHOT_FRAME_START - scene.frame_end = config.SHOT_FRAME_START + duration - 1 - return scene - - def _new_camera(scene, name, source=None): - """New camera in the scene's own collection, copying ``source`` (lens and placement) when given.""" + """New camera in the scene collection, copying ``source`` (lens and placement) when given.""" camera_name = config.OBJECT_PREFIXES.get("CAMERA", "") + name if source is not None and source.type == "CAMERA": data = source.data.copy() @@ -246,32 +126,9 @@ def _new_camera(scene, name, source=None): camera.location = Vector((0.0, -10.0, 1.6)) camera.rotation_euler = Euler((1.5708, 0.0, 0.0)) scene.collection.objects.link(camera) - if scene.camera is None: - scene.camera = camera return camera -def _viewed_camera(context): - space = context.space_data - if space is not None and space.type == "VIEW_3D" and space.use_local_camera and space.camera: - return space.camera - return context.scene.camera - - -def _move(strip, delta): - if hasattr(strip, "content_start"): # Blender 5.1+ - strip.content_start += delta - else: - strip.frame_start += delta - - -def _close_gap(editor, start, end): - """Move the strips starting after a removed one back by its length (all channels).""" - later = [strip for strip in editor.strips if strip_range(strip)[0] >= end] - for strip in sorted(later, key=lambda strip: strip_range(strip)[0]): - _move(strip, start - end) - - # --------------------------------------------------------------------------- # # Rigs # --------------------------------------------------------------------------- # @@ -305,206 +162,65 @@ def rig_groups(view_layer): # Settings / operators # --------------------------------------------------------------------------- # def _shot_index_update(self, context): - scene = edit_scene(context) - if scene is None or not 0 <= self.shot_index < len(scene.sequence_editor.strips_all): - return - strip = scene.sequence_editor.strips_all[self.shot_index] - if strip.type == "SCENE" and strip.scene is not None: - show_shot(context, strip) + 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: - strip = current_shot(context) - if strip is not None: - apply_loop(context, strip) + if marker is not None: + apply_loop(context, marker) else: - for strip in shots(edit_scene(context)): - strip.scene.use_preview_range = False + context.scene.use_preview_range = False class WTD_ShotViewerSettings(bpy.types.PropertyGroup): - edit_scene: PointerProperty( - name="Edit", - description="Scene whose sequencer holds the shots (scene strips)", - type=bpy.types.Scene, - ) shot_index: IntProperty(name="Shot", update=_shot_index_update) loop_shot: BoolProperty( name="Loop Shot", - description="Set the preview range of the shot scene to the shot, so playback loops on it", - default=True, + description="Set the preview range to the shot under the playhead, so playback loops on it", + default=False, update=_loop_shot_update, ) -def _setup_viewer_screen(window, edit): - """Lay out the viewer screen: the 3D view in camera view, the edit's sequencer at the bottom. - Returns False while the window is not ready yet (its sidebar is not drawn).""" - screen = window.screen - view3d = max((a for a in screen.areas if a.type == "VIEW_3D"), key=lambda a: a.width * a.height, default=None) - if view3d is None: - view3d = max(screen.areas, key=lambda a: a.width * a.height) - view3d.type = "VIEW_3D" - keep = {view3d.as_pointer()} - sequencer = next((a for a in screen.areas if a.type == "SEQUENCE_EDITOR"), None) - if sequencer is not None: - keep.add(sequencer.as_pointer()) - for other in [a for a in screen.areas if a.as_pointer() not in keep]: - with bpy.context.temp_override(window=window, screen=screen, area=other): - bpy.ops.screen.area_close() - if sequencer is None: - with bpy.context.temp_override(window=window, screen=screen, area=view3d): - bpy.ops.screen.area_split(direction="HORIZONTAL", factor=VIEWER_TIMELINE_FACTOR) - sequencer = min(screen.areas, key=lambda a: a.y) - sequencer.type = "SEQUENCE_EDITOR" - view3d = max(screen.areas, key=lambda a: a.y) - sequencer.spaces.active.view_type = "SEQUENCER" - sequencer.spaces.active.show_seconds = False # frames, like the shot list - screen[VIEWER_TAG] = True - - view3d.spaces.active.show_region_ui = True - try: - ui = next(r for r in view3d.regions if r.type == "UI") - ui.active_panel_category = "WTD" - except (AttributeError, StopIteration, TypeError): - return False - - strip = current_shot(bpy.context) - if strip is not None: - show_shot(bpy.context, strip) - if view3d.spaces.active.region_3d.view_perspective == "CAMERA": - region = next(r for r in view3d.regions if r.type == "WINDOW") - with bpy.context.temp_override(window=window, screen=screen, area=view3d, region=region): - bpy.ops.view3d.view_center_camera() - region = next(r for r in sequencer.regions if r.type == "WINDOW") - with bpy.context.temp_override(window=window, screen=screen, area=sequencer, region=region): - try: - bpy.ops.sequencer.view_all() - except RuntimeError: # framing only, the sequencer may not be drawn yet - pass - return True - - -def _finish_viewer(window, previous_workspace, edit, tries=30): - """Finish the viewer once Blender has switched its workspace (done on the next event loop).""" - - def finish(): - nonlocal tries - tries -= 1 - try: - workspace = window.workspace - if workspace.as_pointer() == previous_workspace: - return 0.1 if tries > 0 else None - workspace.name = VIEWER_WORKSPACE - workspace.sequencer_scene = edit - # Scrubbing the edit in the sequencer shows the shot under the playhead. - workspace.use_scene_time_sync = True - ready = _setup_viewer_screen(window, edit) - except ReferenceError: # window closed meanwhile - return None - return None if ready or tries <= 0 else 0.1 - - bpy.app.timers.register(finish, first_interval=0.1) - - -class WTD_OT_shot_viewer_open(bpy.types.Operator): - bl_idname = "wtd_tools.shot_viewer_open" - bl_label = "Shot Viewer" - bl_description = "Open a window showing the shots of the edit through their camera, with the edit below" - - @classmethod - def poll(cls, context): - return context.area is not None - - def execute(self, context): - # Without shots anywhere, the current scene becomes the edit. - scene = edit_scene(context) or context.scene - scene.sequence_editor_create() - settings = context.window_manager.wtd_shot_viewer - settings.edit_scene = scene - - # A main window, not a child one (screen.area_dupli): child windows - # always show the scene of their parent. - windows = context.window_manager.windows - before = {window.as_pointer() for window in windows} - bpy.ops.wm.window_new_main() - window = next((window for window in windows if window.as_pointer() not in before), None) - if window is None: - self.report({"ERROR"}, "Could not open the viewer window") - return {"CANCELLED"} - - # The viewer gets its own workspace: the scene time sync is a workspace - # setting, and the window's current layout must stay untouched as other - # windows may reuse it. Both switches happen on the next event loop. - previous = window.workspace.as_pointer() - workspace = bpy.data.workspaces.get(VIEWER_WORKSPACE) - if workspace is not None: - window.workspace = workspace - else: - with context.temp_override(window=window, screen=window.screen, area=window.screen.areas[0]): - bpy.ops.workspace.duplicate() - - # Start on the shot under the edit playhead. - ordered = shots(scene) - strip = next( - (s for s in ordered if strip_range(s)[0] <= scene.frame_current < strip_range(s)[1]), - ordered[0] if ordered else None, - ) - if strip is not None: - index = list(scene.sequence_editor.strips_all).index(strip) - settings["shot_index"] = index - _finish_viewer(window, previous, scene) - return {"FINISHED"} - - 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 of the edit" + bl_description = "Show the previous or next shot" step: IntProperty(default=1) def execute(self, context): - ordered = shots(edit_scene(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)) - select_shot(context, ordered[position]) + 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 (scene strip) with its own camera at the end of the edit" + 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 strip and of the new scene") - scene_mode: EnumProperty( - name="Scene", - items=[ - ("SHARED", "New, Shared Content", "New scene using the same collections as the scene shown in the viewer (linked copy)"), - ("EMPTY", "New, Empty", "New empty scene with the format of the edit"), - ("EXISTING", "Existing", "Use an existing scene, for its whole frame range"), - ], - ) - scene_name: StringProperty(name="Scene Name") + 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 (otherwise the scene camera is used)", + description="Add a camera for this shot, copying the current one. Otherwise pick an existing camera", default=True, ) - - @classmethod - def poll(cls, context): - return edit_scene(context) is not None + camera_name: StringProperty(name="Camera") def invoke(self, context, event): - self.name = next_shot_name(edit_scene(context)) + self.name = next_shot_name(context.scene) self.duration = config.SHOT_DEFAULT_DURATION return context.window_manager.invoke_props_dialog(self) @@ -513,53 +229,45 @@ class WTD_OT_shot_viewer_new_shot(bpy.types.Operator): layout.use_property_split = True layout.use_property_decorate = False layout.prop(self, "name") - layout.prop(self, "scene_mode") - if self.scene_mode == "EXISTING": - layout.prop_search(self, "scene_name", bpy.data, "scenes") - layout.prop(self, "new_camera") - else: - layout.prop(self, "duration") + layout.prop(self, "duration") + layout.prop(self, "new_camera") + if not self.new_camera: + layout.prop_search(self, "camera_name", context.scene, "objects", icon="CAMERA_DATA") def execute(self, context): - edit = edit_scene(context) - name = self.name.strip() or next_shot_name(edit) - source_camera = _viewed_camera(context) - if self.scene_mode == "EXISTING": - scene = bpy.data.scenes.get(self.scene_name) - if scene is None or scene == edit: - self.report({"WARNING"}, "Pick an existing scene other than the edit") - return {"CANCELLED"} + scene = context.scene + name = self.name.strip() or next_shot_name(scene) + if self.new_camera: + camera = _new_camera(scene, name, scene.camera) else: - source = context.scene if self.scene_mode == "SHARED" else None - scene = _new_shot_scene(name, edit, source, self.duration) - camera = None - if self.scene_mode != "EXISTING" or self.new_camera or scene.camera is None: - camera = _new_camera(scene, name, source_camera) + 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"} - editor = edit.sequence_editor_create() - start = max((strip_range(strip)[1] for strip in editor.strips), default=edit.frame_start) - strip = editor.strips.new_scene(name, scene, channel=1, frame_start=start) - if camera is not None: - strip.scene_camera = camera - edit.frame_end = max(edit.frame_end, strip_range(strip)[1] - 1) - select_shot(context, strip) - self.report({"INFO"}, f"Shot '{strip.name}' added (scene '{scene.name}')") + 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 selected shot from the edit" + bl_description = "Remove the shot under the playhead (its camera marker)" bl_options = {"REGISTER", "UNDO"} - close_gap: BoolProperty(name="Close Gap", description="Move the following strips back", default=True) delete_camera: BoolProperty( name="Delete Camera", description="Also delete its camera when no other shot uses it" ) - delete_scene: BoolProperty( - name="Delete Scene", description="Also delete its scene when no other shot uses it" - ) @classmethod def poll(cls, context): @@ -570,46 +278,37 @@ class WTD_OT_shot_viewer_delete_shot(bpy.types.Operator): def draw(self, context): layout = self.layout - layout.label(text=f"Delete shot '{current_shot(context).name}'?", icon="ERROR") - layout.prop(self, "close_gap") + 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") - layout.prop(self, "delete_scene") def execute(self, context): - strip = current_shot(context) - edit = strip.id_data - editor = edit.sequence_editor - name, scene, camera = strip.name, strip.scene, shot_camera(strip) - position = shots(edit).index(strip) - start, end = strip_range(strip) + 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 - editor.strips.remove(strip) - if self.close_gap: - _close_gap(editor, start, end) - - remaining = shots(edit) - if remaining: - select_shot(context, remaining[min(position, len(remaining) - 1)]) - else: - for window, _area in viewer_areas(context): - window.scene = edit - - others = [s for s in editor.strips_all if s.type == "SCENE" and s.scene is not None] - deleted = [] - if self.delete_camera and camera is not None and not any(shot_camera(s) == camera for s in others): + 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.append("camera") - if self.delete_scene and scene != edit and not any(s.scene == scene for s in others): - for window in context.window_manager.windows: - if window.scene == scene: - window.scene = edit - bpy.data.scenes.remove(scene) - deleted.append("scene") - extra = f" with its {' and '.join(deleted)}" if deleted else "" - self.report({"INFO"}, f"Shot '{name}' deleted{extra}") + 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"} @@ -648,7 +347,6 @@ class WTD_OT_shot_viewer_select_rig(bpy.types.Operator): classes = ( WTD_ShotViewerSettings, - WTD_OT_shot_viewer_open, WTD_OT_shot_viewer_step, WTD_OT_shot_viewer_new_shot, WTD_OT_shot_viewer_delete_shot, diff --git a/ui/panel.py b/ui/panel.py index f3021dd..f480932 100644 --- a/ui/panel.py +++ b/ui/panel.py @@ -188,8 +188,6 @@ class _ToolsPanel: # Adds a separator line layout.separator() - layout.operator(shot_viewer.WTD_OT_shot_viewer_open.bl_idname, icon="CAMERA_DATA") - class _RenamePanel: bl_region_type = "UI" @@ -241,32 +239,31 @@ class _RenamePanel: class WTD_UL_shots(bpy.types.UIList): - """Scene strips of the edit, in edit order.""" + """Camera markers of the scene, in time order.""" def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0): - camera = shot_viewer.shot_camera(item) - start, end = shot_viewer.strip_range(item) + start, end = shot_viewer.shot_range(item) split = layout.split(factor=0.4) - split.label(text=item.name, icon="SEQUENCE") + split.label(text=item.name, icon="MARKER_HLT") split = split.split(factor=0.6) - split.label(text=camera.name if camera else "(no camera)") - split.label(text=f"{start}-{end - 1}") + split.label(text=item.camera.name) + split.label(text=f"{start}-{end}") def filter_items(self, context, data, propname): - strips = getattr(data, propname) - ordered = [strip.as_pointer() for strip in shot_viewer.shots(data.id_data)] + markers = getattr(data, propname) + ordered = [marker.as_pointer() for marker in shot_viewer.shots(data)] position = {pointer: index for index, pointer in enumerate(ordered)} - flags = [self.bitflag_filter_item if strip.as_pointer() in position else 0 for strip in strips] + flags = [self.bitflag_filter_item if marker.as_pointer() in position else 0 for marker in markers] if self.filter_name: by_name = bpy.types.UI_UL_list.filter_items_by_name( - self.filter_name, self.bitflag_filter_item, strips, "name" + self.filter_name, self.bitflag_filter_item, markers, "name" ) flags = [flag & name_flag for flag, name_flag in zip(flags, by_name)] - # Shots in edit order, the other strips after them. + # Shots in time order, the markers without camera after them. order = [] hidden = len(ordered) - for strip in strips: - pointer = strip.as_pointer() + for marker in markers: + pointer = marker.as_pointer() if pointer in position: order.append(position[pointer]) else: @@ -280,18 +277,10 @@ class _ShotViewerPanel: bl_category = "WTD" bl_label = "Shot Viewer" - @classmethod - def poll(cls, context): - return shot_viewer.is_viewer(context.screen) - def draw(self, context) -> None: layout = self.layout settings = context.window_manager.wtd_shot_viewer - scene = shot_viewer.edit_scene(context) - layout.prop(settings, "edit_scene", icon="SEQUENCE") - if scene is None: - layout.label(text="Pick the scene holding the edit", icon="ERROR") - return + scene = context.scene has_shots = bool(shot_viewer.shots(scene)) row = layout.row(align=True) @@ -300,22 +289,17 @@ class _ShotViewerPanel: row.operator(shot_viewer.WTD_OT_shot_viewer_step.bl_idname, text="Previous", icon="TRIA_LEFT").step = -1 row.operator(shot_viewer.WTD_OT_shot_viewer_step.bl_idname, text="Next", icon="TRIA_RIGHT").step = 1 row = layout.row() - if scene.sequence_editor is not None: - row.template_list( - "WTD_UL_shots", "", scene.sequence_editor, "strips_all", settings, "shot_index", rows=5 - ) - else: - row.label(text="") + row.template_list("WTD_UL_shots", "", scene, "timeline_markers", settings, "shot_index", rows=5) col = row.column(align=True) col.operator(shot_viewer.WTD_OT_shot_viewer_new_shot.bl_idname, text="", icon="ADD") col.operator(shot_viewer.WTD_OT_shot_viewer_delete_shot.bl_idname, text="", icon="REMOVE") if not has_shots: - layout.label(text="No shots yet: add one with +", icon="INFO") + layout.label(text="No camera marker yet: add a shot with +", icon="INFO") layout.prop(settings, "loop_shot") groups = shot_viewer.rig_groups(context.view_layer) if not groups: - layout.label(text="No rig in this shot", icon="INFO") + layout.label(text="No rig in the scene", icon="INFO") for label, rigs in groups.items(): box = layout.box() box.label(text=label, icon="ARMATURE_DATA") @@ -360,10 +344,9 @@ class WTD_PT_sanity_sequencer(_SanityPanel, bpy.types.Panel): classes = ( WTD_UL_shots, - # First, so it tops the WTD tab of the viewer window. - WTD_PT_shot_viewer_view3d, WTD_PT_sanity_view3d, WTD_PT_tools_view3d, + WTD_PT_shot_viewer_view3d, WTD_PT_rename_view3d, WTD_PT_rename_node_editor, WTD_PT_sanity_sequencer,