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.
- **[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_<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. |
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
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.
---
+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
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, "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,
+17 -34
View File
@@ -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,