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 <noreply@anthropic.com>
This commit is contained in:
Nero Frame 13andClaude Opus 5.5 committed 2026-10-20 15:56:28 +02:00
1 parent a83bc959d0
commit c376a92591
3 files changed
+170 -498

No files matched your search

+39 -48
View File
@@ -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. - **[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. - **[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. - **[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). 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 ## 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`. **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 | | Panel | Editors |
|---|---| |---|---|
| **Sanity Check** | 3D Viewport, Video Sequencer | | **Sanity Check** | 3D Viewport, Video Sequencer |
| **Tools** (Get Oid, Shot Viewer button) | 3D Viewport | | **Tools** (Get Oid) | 3D Viewport |
| **Rename** | 3D Viewport, Shader Editor | | **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. 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 ## Rename
@@ -237,40 +233,39 @@ The box under the **Rename** button previews every change (`old name → new nam
## Shot Viewer ## 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 ### How shots are stored
- The **edit** is a scene whose Video Sequencer holds the sequence. The whole sequence lives in **one scene**:
- 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.
### 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. *3D Viewport sidebar (**N**) > WTD tab > **Shot Viewer***.
- **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.
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… | | 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**. | | Go to the previous / next shot | **Previous** / **Next**. |
| Find a shot by name | The filter field at the bottom of the shot list. | | 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. | | Scrub the sequence | Drag the playhead in the Timeline. The camera and the shot list follow. |
| 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 one shot | Tick **Loop Shot** and press play: playback loops on the shot under the playhead. |
| Play the whole sequence | Press play with the mouse over the **Sequencer**. The 3D view follows from shot to shot. | | 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 ### Adding a shot
@@ -278,29 +273,28 @@ Click **+** next to the shot list:
| Setting | Use | | Setting | Use |
|---|---| |---|---|
| **Name** | Pre-filled with the next free shot number (`sh0010`, `sh0020`…). | | **Name** | Pre-filled with the next free shot number (`sh0010`, `sh0020`…). This is the marker name. |
| **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. | | **Duration** | Length of the shot in frames. |
| **Scene: New, Empty** | A blank scene with the resolution and frame rate of the edit. | | **New Camera** | On: creates the camera `CAM_<shot name>`, copying the lens and position of the current camera, so you start from the current framing. Off: pick an existing camera of the scene. |
| **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. |
The new shot is placed at the end of the edit and gets its own camera `CAM_<shot name>`. 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 ### Deleting a shot
Select it and click **−**: Put the playhead on the shot (or select it in the list) and click **−**:
| Option | Default | Use | - The shot's camera marker is removed.
|---|---|---| - **Delete Camera** (off by default): also deletes its camera, unless another shot uses it.
| **Close Gap** | On | Moves the following strips back so the edit has no hole. | - **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.
| **Delete Camera** | Off | Also deletes the shot camera, unless another shot uses it. | - **Deleting the last shot:** the scene's **End** frame moves back to the end of the previous shot.
| **Delete Scene** | Off | Also deletes the shot scene, unless another shot uses it. The edit scene is never deleted. |
**Ctrl+Z** undoes adding and deleting shots. **Ctrl+Z** undoes adding and deleting shots.
### Selecting rigs ### 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. - **Click** a rig: selects it and enters Pose Mode.
- **Shift+click**: adds it to the selection, to pose several rigs at once. - **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. 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 ## 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. 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.** **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.** **Shot Viewer: a shot is missing from the list.**
Check that **Sync Scene Time** is ticked at the bottom of the Sequencer in the viewer window. 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.
--- ---
+114 -416
View File
@@ -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 Shots are the timeline markers bound to a camera (Ctrl+B in the timeline). A
separate window showing the scene of a strip through the strip's camera, with shot runs from its marker to the frame before the next camera marker, the last
a sidebar panel to switch shots and quickly select the character / prop rigs. 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 os
@@ -10,100 +11,51 @@ import re
import bpy import bpy
from bpy.app.handlers import persistent 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 mathutils import Euler, Vector
from . import config 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" OTHER_GROUP = "Other"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Shots # 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): def shots(scene):
"""Scene strips of the edit in edit order (muted strips and strips without a scene left out).""" """Camera markers of the scene, in time order."""
if scene is None or scene.sequence_editor is None: if scene is None:
return [] return []
strips = [ return sorted((m for m in scene.timeline_markers if m.camera is not None), key=lambda m: m.frame)
strip
for strip in scene.sequence_editor.strips_all
if strip.type == "SCENE" and strip.scene is not None and not strip.mute def shot_range(marker):
] """(first, last) frame of the shot: up to the next camera marker, the last shot up to the scene end."""
return sorted(strips, key=lambda strip: (strip_range(strip)[0], strip.channel)) 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): def current_shot(context):
"""The shot selected in the viewer, or None.""" return shot_at(context.scene, context.scene.frame_current)
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
def shot_camera(strip): def _marker_index(marker):
return strip.scene_camera or strip.scene.camera return list(marker.id_data.timeline_markers).index(marker)
def shot_frame_range(strip): def apply_loop(context, marker):
"""Frame range of the strip in its own scene (the strip content starts at the scene start).""" """With Loop Shot on, set the preview range to the shot so playback loops on it."""
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."""
if not context.window_manager.wtd_shot_viewer.loop_shot: if not context.window_manager.wtd_shot_viewer.loop_shot:
return return
scene = strip.scene scene = marker.id_data
start, end = shot_frame_range(strip) start, end = shot_range(marker)
scene.use_preview_range = True scene.use_preview_range = True
# Setting start past the old end (or end before the old start) pushes the other one. # Setting start past the old end (or end before the old start) pushes the other one.
scene.frame_preview_start = start scene.frame_preview_start = start
@@ -111,33 +63,19 @@ def apply_loop(context, strip):
scene.frame_preview_start = start scene.frame_preview_start = start
def show_shot(context, strip): def show_shot(context, marker):
"""Show the shot in every viewer window and move the edit playhead to it.""" """Jump to the shot and look through its camera in the current 3D view."""
scene = strip.scene scene = marker.id_data
camera = shot_camera(strip) # The camera markers switch the camera on frame change; set it right away.
start, end = shot_frame_range(strip) scene.camera = marker.camera
scene.frame_current = start scene.frame_current = marker.frame
apply_loop(context, strip) # Item assignment skips the update callback, which would call this again.
strip.id_data.frame_current = strip_range(strip)[0] context.window_manager.wtd_shot_viewer["shot_index"] = _marker_index(marker)
apply_loop(context, marker)
for window, area in viewer_areas(context): space = context.space_data
window.scene = scene if space is not None and space.type == "VIEW_3D":
space = area.spaces.active space.use_local_camera = False
space.use_local_camera = camera is not None
space.camera = camera
space.region_3d.view_perspective = "CAMERA" 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 _syncing = False
@@ -145,93 +83,35 @@ _syncing = False
@persistent @persistent
def _on_frame_change(scene, _depsgraph=None): def _on_frame_change(scene, _depsgraph=None):
"""Keep the shot list and the edit playhead in step with the viewer. """Keep the shot list (and the loop range) on the shot under the playhead."""
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.
"""
global _syncing global _syncing
context = bpy.context context = bpy.context
wm = context.window_manager 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 return
marker = shot_at(scene, scene.frame_current)
settings = wm.wtd_shot_viewer settings = wm.wtd_shot_viewer
edit = settings.edit_scene if marker is None or _marker_index(marker) == settings.shot_index:
if edit is None or edit.sequence_editor is None:
return return
_syncing = True _syncing = True
try: try:
# Edit scrubbed (or played): the shot under the playhead, already shown settings["shot_index"] = _marker_index(marker)
# by the workspace time sync, becomes the selected one. apply_loop(context, marker)
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()
finally: finally:
_syncing = False _syncing = False
def select_shot(context, strip): def next_shot_name(scene):
"""Make the strip the selected shot and show it.""" """Next free shot name (config SHOT_NAME_FORMAT), after the highest one of the markers."""
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."""
head, _sep, rest = config.SHOT_NAME_FORMAT.partition("{") head, _sep, rest = config.SHOT_NAME_FORMAT.partition("{")
pattern = re.compile(re.escape(head) + r"(\d+)" + re.escape(rest.partition("}")[2])) pattern = re.compile(re.escape(head) + r"(\d+)" + re.escape(rest.partition("}")[2]))
names = [scene.name for scene in bpy.data.scenes] numbers = [int(match.group(1)) for m in scene.timeline_markers if (match := pattern.search(m.name))]
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))]
step = config.SHOT_NUMBER_STEP step = config.SHOT_NUMBER_STEP
return config.SHOT_NAME_FORMAT.format((max(numbers, default=0) // step + 1) * 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): 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 camera_name = config.OBJECT_PREFIXES.get("CAMERA", "") + name
if source is not None and source.type == "CAMERA": if source is not None and source.type == "CAMERA":
data = source.data.copy() 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.location = Vector((0.0, -10.0, 1.6))
camera.rotation_euler = Euler((1.5708, 0.0, 0.0)) camera.rotation_euler = Euler((1.5708, 0.0, 0.0))
scene.collection.objects.link(camera) scene.collection.objects.link(camera)
if scene.camera is None:
scene.camera = camera
return 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 # Rigs
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -305,206 +162,65 @@ def rig_groups(view_layer):
# Settings / operators # Settings / operators
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _shot_index_update(self, context): def _shot_index_update(self, context):
scene = edit_scene(context) markers = context.scene.timeline_markers
if scene is None or not 0 <= self.shot_index < len(scene.sequence_editor.strips_all): if 0 <= self.shot_index < len(markers) and markers[self.shot_index].camera is not None:
return show_shot(context, markers[self.shot_index])
strip = scene.sequence_editor.strips_all[self.shot_index]
if strip.type == "SCENE" and strip.scene is not None:
show_shot(context, strip)
def _loop_shot_update(self, context): def _loop_shot_update(self, context):
marker = current_shot(context)
if self.loop_shot: if self.loop_shot:
strip = current_shot(context) if marker is not None:
if strip is not None: apply_loop(context, marker)
apply_loop(context, strip)
else: else:
for strip in shots(edit_scene(context)): context.scene.use_preview_range = False
strip.scene.use_preview_range = False
class WTD_ShotViewerSettings(bpy.types.PropertyGroup): 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) shot_index: IntProperty(name="Shot", update=_shot_index_update)
loop_shot: BoolProperty( loop_shot: BoolProperty(
name="Loop Shot", name="Loop Shot",
description="Set the preview range of the shot scene to the shot, so playback loops on it", description="Set the preview range to the shot under the playhead, so playback loops on it",
default=True, default=False,
update=_loop_shot_update, 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): class WTD_OT_shot_viewer_step(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_step" bl_idname = "wtd_tools.shot_viewer_step"
bl_label = "Previous / Next Shot" 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) step: IntProperty(default=1)
def execute(self, context): def execute(self, context):
ordered = shots(edit_scene(context)) ordered = shots(context.scene)
if not ordered: if not ordered:
return {"CANCELLED"} return {"CANCELLED"}
current = current_shot(context) current = current_shot(context)
position = ordered.index(current) if current is not None else -1 position = ordered.index(current) if current is not None else -1
position = max(0, min(len(ordered) - 1, position + self.step)) position = max(0, min(len(ordered) - 1, position + self.step))
select_shot(context, ordered[position]) show_shot(context, ordered[position])
return {"FINISHED"} return {"FINISHED"}
class WTD_OT_shot_viewer_new_shot(bpy.types.Operator): class WTD_OT_shot_viewer_new_shot(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_new_shot" bl_idname = "wtd_tools.shot_viewer_new_shot"
bl_label = "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"} bl_options = {"REGISTER", "UNDO"}
name: StringProperty(name="Name", description="Name of the strip and of the new scene") name: StringProperty(name="Name", description="Name of the marker")
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")
duration: IntProperty(name="Duration", description="Length of the shot in frames", min=1, default=48) duration: IntProperty(name="Duration", description="Length of the shot in frames", min=1, default=48)
new_camera: BoolProperty( new_camera: BoolProperty(
name="New Camera", 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, default=True,
) )
camera_name: StringProperty(name="Camera")
@classmethod
def poll(cls, context):
return edit_scene(context) is not None
def invoke(self, context, event): 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 self.duration = config.SHOT_DEFAULT_DURATION
return context.window_manager.invoke_props_dialog(self) 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_split = True
layout.use_property_decorate = False layout.use_property_decorate = False
layout.prop(self, "name") 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): def execute(self, context):
edit = edit_scene(context) scene = context.scene
name = self.name.strip() or next_shot_name(edit) name = self.name.strip() or next_shot_name(scene)
source_camera = _viewed_camera(context) if self.new_camera:
if self.scene_mode == "EXISTING": camera = _new_camera(scene, name, scene.camera)
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"}
else: else:
source = context.scene if self.scene_mode == "SHARED" else None camera = scene.objects.get(self.camera_name)
scene = _new_shot_scene(name, edit, source, self.duration) if camera is None or camera.type != "CAMERA":
camera = None self.report({"WARNING"}, "Pick a camera of the scene, or tick New Camera")
if self.scene_mode != "EXISTING" or self.new_camera or scene.camera is None: return {"CANCELLED"}
camera = _new_camera(scene, name, source_camera)
editor = edit.sequence_editor_create() ordered = shots(scene)
start = max((strip_range(strip)[1] for strip in editor.strips), default=edit.frame_start) if ordered:
strip = editor.strips.new_scene(name, scene, channel=1, frame_start=start) start = shot_range(ordered[-1])[1] + 1
if camera is not None: else:
strip.scene_camera = camera start = config.SHOT_FRAME_START
edit.frame_end = max(edit.frame_end, strip_range(strip)[1] - 1) scene.frame_start = start
select_shot(context, strip) marker = scene.timeline_markers.new(name, frame=start)
self.report({"INFO"}, f"Shot '{strip.name}' added (scene '{scene.name}')") 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"} return {"FINISHED"}
class WTD_OT_shot_viewer_delete_shot(bpy.types.Operator): class WTD_OT_shot_viewer_delete_shot(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_delete_shot" bl_idname = "wtd_tools.shot_viewer_delete_shot"
bl_label = "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"} bl_options = {"REGISTER", "UNDO"}
close_gap: BoolProperty(name="Close Gap", description="Move the following strips back", default=True)
delete_camera: BoolProperty( delete_camera: BoolProperty(
name="Delete Camera", description="Also delete its camera when no other shot uses it" 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 @classmethod
def poll(cls, context): def poll(cls, context):
@@ -570,46 +278,37 @@ class WTD_OT_shot_viewer_delete_shot(bpy.types.Operator):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
layout.label(text=f"Delete shot '{current_shot(context).name}'?", icon="ERROR") marker = current_shot(context)
layout.prop(self, "close_gap") 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_camera")
layout.prop(self, "delete_scene")
def execute(self, context): def execute(self, context):
strip = current_shot(context) scene = context.scene
edit = strip.id_data marker = current_shot(context)
editor = edit.sequence_editor ordered = shots(scene)
name, scene, camera = strip.name, strip.scene, shot_camera(strip) position = ordered.index(marker)
position = shots(edit).index(strip) name, camera = marker.name, marker.camera
start, end = strip_range(strip) start = marker.frame
is_last = position == len(ordered) - 1
editor.strips.remove(strip) scene.timeline_markers.remove(marker)
if self.close_gap: if is_last and position > 0:
_close_gap(editor, start, end) # The frames were only this shot's.
scene.frame_end = start - 1
remaining = shots(edit) deleted = ""
if remaining: if self.delete_camera and not any(m.camera == camera for m in scene.timeline_markers):
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):
data = camera.data data = camera.data
bpy.data.objects.remove(camera) bpy.data.objects.remove(camera)
if data.users == 0: if data.users == 0:
bpy.data.cameras.remove(data) bpy.data.cameras.remove(data)
deleted.append("camera") deleted = " with its 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: remaining = shots(scene)
if window.scene == scene: if remaining:
window.scene = edit show_shot(context, remaining[min(position, len(remaining) - 1)])
bpy.data.scenes.remove(scene) self.report({"INFO"}, f"Shot '{name}' deleted{deleted}")
deleted.append("scene")
extra = f" with its {' and '.join(deleted)}" if deleted else ""
self.report({"INFO"}, f"Shot '{name}' deleted{extra}")
return {"FINISHED"} return {"FINISHED"}
@@ -648,7 +347,6 @@ class WTD_OT_shot_viewer_select_rig(bpy.types.Operator):
classes = ( classes = (
WTD_ShotViewerSettings, WTD_ShotViewerSettings,
WTD_OT_shot_viewer_open,
WTD_OT_shot_viewer_step, WTD_OT_shot_viewer_step,
WTD_OT_shot_viewer_new_shot, WTD_OT_shot_viewer_new_shot,
WTD_OT_shot_viewer_delete_shot, WTD_OT_shot_viewer_delete_shot,
+17 -34
View File
@@ -188,8 +188,6 @@ class _ToolsPanel:
# Adds a separator line # Adds a separator line
layout.separator() layout.separator()
layout.operator(shot_viewer.WTD_OT_shot_viewer_open.bl_idname, icon="CAMERA_DATA")
class _RenamePanel: class _RenamePanel:
bl_region_type = "UI" bl_region_type = "UI"
@@ -241,32 +239,31 @@ class _RenamePanel:
class WTD_UL_shots(bpy.types.UIList): 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): 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.shot_range(item)
start, end = shot_viewer.strip_range(item)
split = layout.split(factor=0.4) 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 = split.split(factor=0.6)
split.label(text=camera.name if camera else "(no camera)") split.label(text=item.camera.name)
split.label(text=f"{start}-{end - 1}") split.label(text=f"{start}-{end}")
def filter_items(self, context, data, propname): def filter_items(self, context, data, propname):
strips = getattr(data, propname) markers = getattr(data, propname)
ordered = [strip.as_pointer() for strip in shot_viewer.shots(data.id_data)] ordered = [marker.as_pointer() for marker in shot_viewer.shots(data)]
position = {pointer: index for index, pointer in enumerate(ordered)} 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: if self.filter_name:
by_name = bpy.types.UI_UL_list.filter_items_by_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)] 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 = [] order = []
hidden = len(ordered) hidden = len(ordered)
for strip in strips: for marker in markers:
pointer = strip.as_pointer() pointer = marker.as_pointer()
if pointer in position: if pointer in position:
order.append(position[pointer]) order.append(position[pointer])
else: else:
@@ -280,18 +277,10 @@ class _ShotViewerPanel:
bl_category = "WTD" bl_category = "WTD"
bl_label = "Shot Viewer" bl_label = "Shot Viewer"
@classmethod
def poll(cls, context):
return shot_viewer.is_viewer(context.screen)
def draw(self, context) -> None: def draw(self, context) -> None:
layout = self.layout layout = self.layout
settings = context.window_manager.wtd_shot_viewer settings = context.window_manager.wtd_shot_viewer
scene = shot_viewer.edit_scene(context) scene = context.scene
layout.prop(settings, "edit_scene", icon="SEQUENCE")
if scene is None:
layout.label(text="Pick the scene holding the edit", icon="ERROR")
return
has_shots = bool(shot_viewer.shots(scene)) has_shots = bool(shot_viewer.shots(scene))
row = layout.row(align=True) 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="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.operator(shot_viewer.WTD_OT_shot_viewer_step.bl_idname, text="Next", icon="TRIA_RIGHT").step = 1
row = layout.row() row = layout.row()
if scene.sequence_editor is not None: row.template_list("WTD_UL_shots", "", scene, "timeline_markers", settings, "shot_index", rows=5)
row.template_list(
"WTD_UL_shots", "", scene.sequence_editor, "strips_all", settings, "shot_index", rows=5
)
else:
row.label(text="")
col = row.column(align=True) 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_new_shot.bl_idname, text="", icon="ADD")
col.operator(shot_viewer.WTD_OT_shot_viewer_delete_shot.bl_idname, text="", icon="REMOVE") col.operator(shot_viewer.WTD_OT_shot_viewer_delete_shot.bl_idname, text="", icon="REMOVE")
if not has_shots: 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") layout.prop(settings, "loop_shot")
groups = shot_viewer.rig_groups(context.view_layer) groups = shot_viewer.rig_groups(context.view_layer)
if not groups: 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(): for label, rigs in groups.items():
box = layout.box() box = layout.box()
box.label(text=label, icon="ARMATURE_DATA") box.label(text=label, icon="ARMATURE_DATA")
@@ -360,10 +344,9 @@ class WTD_PT_sanity_sequencer(_SanityPanel, bpy.types.Panel):
classes = ( classes = (
WTD_UL_shots, 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_sanity_view3d,
WTD_PT_tools_view3d, WTD_PT_tools_view3d,
WTD_PT_shot_viewer_view3d,
WTD_PT_rename_view3d, WTD_PT_rename_view3d,
WTD_PT_rename_node_editor, WTD_PT_rename_node_editor,
WTD_PT_sanity_sequencer, WTD_PT_sanity_sequencer,