Added Rename and Shot Viewer tools
Rename panel: batch rename selected objects (or their materials) as prefix_name_side_number_suffix. Numbering continues after existing names, object data follows its object, and a preview shows the result. Shot Viewer (button in the Tools panel): opens a window with its own workspace showing the shots (sequencer scene strips) through their camera, with the edit's sequencer at the bottom. Scrubbing the edit switches shots, playing a shot moves the edit playhead, Loop Shot loops playback on the shot. Shots can be added and deleted, and character / prop rigs selected in pose mode from the sidebar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b800e5a68b
commit
1a1767c984
5 files changed
+1173
-3
No files matched your search
+13
-1
@@ -8,7 +8,7 @@ for _name in [name for name in sys.modules if name.startswith(__name__ + ".")]:
|
|||||||
import bpy # noqa: E402
|
import bpy # noqa: E402
|
||||||
from bpy.app.handlers import persistent # noqa: E402
|
from bpy.app.handlers import persistent # noqa: E402
|
||||||
|
|
||||||
from . import checks, core, tools_ops, ui # noqa: E402
|
from . import checks, core, rename, shot_viewer, tools_ops, ui # noqa: E402
|
||||||
from .core import runner # noqa: E402
|
from .core import runner # noqa: E402
|
||||||
|
|
||||||
api = core
|
api = core
|
||||||
@@ -43,6 +43,12 @@ def register() -> None:
|
|||||||
# Register tool operators
|
# Register tool operators
|
||||||
tools_ops.register()
|
tools_ops.register()
|
||||||
|
|
||||||
|
# Register the rename tool (settings and operator, before its panel)
|
||||||
|
rename.register()
|
||||||
|
|
||||||
|
# Register the shot viewer (settings and operators, before its panel)
|
||||||
|
shot_viewer.register()
|
||||||
|
|
||||||
# Load all the sanity checks into the sanity REGISTRY
|
# Load all the sanity checks into the sanity REGISTRY
|
||||||
checks.load_all()
|
checks.load_all()
|
||||||
|
|
||||||
@@ -68,6 +74,12 @@ def unregister() -> None:
|
|||||||
# Unregister all panels
|
# Unregister all panels
|
||||||
ui.unregister()
|
ui.unregister()
|
||||||
|
|
||||||
|
# Unregister the rename tool (after its panel)
|
||||||
|
rename.unregister()
|
||||||
|
|
||||||
|
# Unregister the shot viewer (after its panel)
|
||||||
|
shot_viewer.unregister()
|
||||||
|
|
||||||
runner.clear()
|
runner.clear()
|
||||||
# The check registry is plain Python and is rebuilt with the modules on
|
# The check registry is plain Python and is rebuilt with the modules on
|
||||||
# re-import, so it is kept as is (enable/disable without re-import).
|
# re-import, so it is kept as is (enable/disable without re-import).
|
||||||
@@ -207,6 +207,49 @@ DUPLICATE_NAME_TYPES = [
|
|||||||
"node_groups",
|
"node_groups",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Rename tool
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Names are built as {prefix}_{name}_{side}_{number}_{suffix}, empty parts dropped.
|
||||||
|
# "By Type" prefixes come from OBJECT_PREFIXES / DATABLOCK_PREFIXES.
|
||||||
|
RENAME_SEPARATOR = "_"
|
||||||
|
RENAME_PADDING = 3
|
||||||
|
|
||||||
|
# Orientation tokens. TODO: confirm with production.
|
||||||
|
RENAME_SIDES = ["center", "left", "right", "front", "back", "top", "bottom"]
|
||||||
|
|
||||||
|
# "By Type" suffix per object type ("MATERIAL" for materials). TODO: confirm with production.
|
||||||
|
RENAME_TYPE_SUFFIXES = {
|
||||||
|
"MESH": "mesh",
|
||||||
|
"CURVE": "curve",
|
||||||
|
"SURFACE": "surface",
|
||||||
|
"FONT": "text",
|
||||||
|
"EMPTY": "empty",
|
||||||
|
"ARMATURE": "rig",
|
||||||
|
"LATTICE": "lattice",
|
||||||
|
"GREASEPENCIL": "gp",
|
||||||
|
"CAMERA": "cam",
|
||||||
|
"LIGHT": "light",
|
||||||
|
"MATERIAL": "mat",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Shot viewer
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Groups of the rig quick selection: an armature goes to the first group whose
|
||||||
|
# pattern matches its name, one of its collections or its library file name.
|
||||||
|
# Unmatched armatures are listed under "Other". TODO: confirm with production.
|
||||||
|
SHOT_VIEWER_RIG_GROUPS = {
|
||||||
|
"Characters": r"(?i)^(?:chr|char)[_-]",
|
||||||
|
"Props": r"(?i)^(?:prp|prop)[_-]",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shots added by the shot viewer. TODO: confirm with production.
|
||||||
|
SHOT_NAME_FORMAT = "sh{:04d}"
|
||||||
|
SHOT_NUMBER_STEP = 10
|
||||||
|
SHOT_FRAME_START = 101
|
||||||
|
SHOT_DEFAULT_DURATION = 48
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# UI
|
# UI
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Batch rename tool.
|
||||||
|
|
||||||
|
Names are built as ``{prefix}_{name}_{side}_{number}_{suffix}`` (empty parts are
|
||||||
|
dropped). Numbering continues after the highest number already used by a
|
||||||
|
datablock with the same pattern: renaming more 'pillar' objects later gives
|
||||||
|
pillar_004, pillar_005...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from bpy.props import BoolProperty, EnumProperty, PointerProperty, StringProperty
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .core import is_local
|
||||||
|
|
||||||
|
_DUPLICATE_RE = re.compile(r"\.\d{3,}$")
|
||||||
|
_TMP_PREFIX = "__wtd_rename_"
|
||||||
|
|
||||||
|
AFFIX_MODES = [
|
||||||
|
("NONE", "None", "No affix"),
|
||||||
|
("TYPE", "By Type", "Affix of the production config for the type of each item"),
|
||||||
|
("CUSTOM", "Custom", "Custom text"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Name building
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def clean(text):
|
||||||
|
"""ASCII only, every run of other characters replaced by the separator."""
|
||||||
|
sep = config.RENAME_SEPARATOR
|
||||||
|
text = text.encode("ascii", "ignore").decode()
|
||||||
|
return re.sub(r"[^A-Za-z0-9]+", lambda _m: sep, text).strip(sep)
|
||||||
|
|
||||||
|
|
||||||
|
def _natural_key(name):
|
||||||
|
return [int(token) if token.isdigit() else token.lower() for token in re.split(r"(\d+)", name)]
|
||||||
|
|
||||||
|
|
||||||
|
def _type_key(id_block):
|
||||||
|
return id_block.type if isinstance(id_block, bpy.types.Object) else "MATERIAL"
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix(id_block, settings):
|
||||||
|
if settings.prefix_mode == "CUSTOM":
|
||||||
|
return clean(settings.prefix_custom)
|
||||||
|
if settings.prefix_mode == "TYPE":
|
||||||
|
if isinstance(id_block, bpy.types.Object):
|
||||||
|
return clean(config.OBJECT_PREFIXES.get(id_block.type, ""))
|
||||||
|
return clean(config.DATABLOCK_PREFIXES.get("materials", ""))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _suffix(id_block, settings):
|
||||||
|
if settings.suffix_mode == "CUSTOM":
|
||||||
|
return clean(settings.suffix_custom)
|
||||||
|
if settings.suffix_mode == "TYPE":
|
||||||
|
return clean(config.RENAME_TYPE_SUFFIXES.get(_type_key(id_block), ""))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def current_base(name, settings):
|
||||||
|
"""Base name of an existing name: 'GEO_box_center_002_mesh' -> 'box', 'box2' -> 'box'."""
|
||||||
|
body = _DUPLICATE_RE.sub("", name)
|
||||||
|
prefixes = {*config.OBJECT_PREFIXES.values(), *config.DATABLOCK_PREFIXES.values()}
|
||||||
|
if settings.prefix_custom:
|
||||||
|
prefixes.add(clean(settings.prefix_custom) + config.RENAME_SEPARATOR)
|
||||||
|
for prefix in sorted(prefixes, key=len, reverse=True):
|
||||||
|
if prefix and body.upper().startswith(prefix.upper()):
|
||||||
|
body = body[len(prefix):]
|
||||||
|
break
|
||||||
|
|
||||||
|
suffixes = {suffix.lower() for suffix in config.RENAME_TYPE_SUFFIXES.values()}
|
||||||
|
if settings.suffix_custom:
|
||||||
|
suffixes.add(clean(settings.suffix_custom).lower())
|
||||||
|
sides = {side.lower() for side in config.RENAME_SIDES}
|
||||||
|
|
||||||
|
tokens = [token for token in re.split(r"[^A-Za-z0-9]+", body) if token]
|
||||||
|
# Peel the parts this tool adds, from the end: suffix, number, side.
|
||||||
|
if len(tokens) > 1 and tokens[-1].lower() in suffixes:
|
||||||
|
tokens.pop()
|
||||||
|
if len(tokens) > 1 and tokens[-1].isdigit():
|
||||||
|
tokens.pop()
|
||||||
|
if len(tokens) > 1 and tokens[-1].lower() in sides:
|
||||||
|
tokens.pop()
|
||||||
|
if tokens:
|
||||||
|
tokens[-1] = tokens[-1].rstrip("0123456789")
|
||||||
|
return clean(config.RENAME_SEPARATOR.join(tokens))
|
||||||
|
|
||||||
|
|
||||||
|
def targets(context, settings):
|
||||||
|
"""Local datablocks to rename, from the selected objects."""
|
||||||
|
objects = [ob for ob in context.selected_objects if is_local(ob)]
|
||||||
|
if settings.target == "OBJECTS":
|
||||||
|
return objects
|
||||||
|
materials = []
|
||||||
|
for ob in objects:
|
||||||
|
for slot in ob.material_slots:
|
||||||
|
material = slot.material
|
||||||
|
if material is not None and is_local(material) and material not in materials:
|
||||||
|
materials.append(material)
|
||||||
|
return materials
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key(id_block, order):
|
||||||
|
if order != "NAME" and isinstance(id_block, bpy.types.Object):
|
||||||
|
return (id_block.matrix_world.translation["XYZ".index(order)], _natural_key(id_block.name))
|
||||||
|
return (0.0, _natural_key(id_block.name))
|
||||||
|
|
||||||
|
|
||||||
|
def plan_renames(context, settings):
|
||||||
|
"""Return ([(id_block, new_name)], [names skipped because no base name could be found])."""
|
||||||
|
sep = config.RENAME_SEPARATOR
|
||||||
|
custom_base = clean(settings.base_name)
|
||||||
|
side = "" if settings.side == "NONE" else clean(settings.side)
|
||||||
|
|
||||||
|
groups = {} # (prefix, base, suffix) -> [id_block]
|
||||||
|
skipped = []
|
||||||
|
for id_block in sorted(targets(context, settings), key=lambda i: _sort_key(i, settings.sort)):
|
||||||
|
base = custom_base or current_base(id_block.name, settings)
|
||||||
|
if not base:
|
||||||
|
skipped.append(id_block.name)
|
||||||
|
continue
|
||||||
|
key = (_prefix(id_block, settings), base, _suffix(id_block, settings))
|
||||||
|
groups.setdefault(key, []).append(id_block)
|
||||||
|
|
||||||
|
collection = bpy.data.objects if settings.target == "OBJECTS" else bpy.data.materials
|
||||||
|
renamed = {id_block.as_pointer() for members in groups.values() for id_block in members}
|
||||||
|
plan = []
|
||||||
|
for (prefix, base, suffix), members in groups.items():
|
||||||
|
head = sep.join(part for part in (prefix, base, side) if part) + sep
|
||||||
|
tail = sep + suffix if suffix else ""
|
||||||
|
pattern = re.compile(re.escape(head) + r"(\d+)" + re.escape(tail))
|
||||||
|
# Linked datablocks count too, so local names never repeat a library's numbers.
|
||||||
|
used = [
|
||||||
|
int(match.group(1))
|
||||||
|
for other in collection
|
||||||
|
if other.as_pointer() not in renamed and (match := pattern.fullmatch(other.name))
|
||||||
|
]
|
||||||
|
# Numbers the selection already holds are reused first, so renaming
|
||||||
|
# again is stable, then numbering continues after the highest one.
|
||||||
|
owned = {int(match.group(1)) for id_block in members if (match := pattern.fullmatch(id_block.name))}
|
||||||
|
if settings.sort == "NAME":
|
||||||
|
# Conforming names first, in their number order, so they keep their number.
|
||||||
|
members.sort(key=lambda i: (0, int(m.group(1))) if (m := pattern.fullmatch(i.name)) else (1, 0))
|
||||||
|
numbers = sorted(owned - set(used))
|
||||||
|
number = max([*used, *owned], default=0)
|
||||||
|
for id_block in members:
|
||||||
|
if numbers:
|
||||||
|
current = numbers.pop(0)
|
||||||
|
else:
|
||||||
|
number += 1
|
||||||
|
current = number
|
||||||
|
plan.append((id_block, f"{head}{current:0{config.RENAME_PADDING}d}{tail}"))
|
||||||
|
return plan, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def apply_renames(plan, rename_data):
|
||||||
|
"""Rename the datablocks of the plan. Returns the messages of failed renames."""
|
||||||
|
datas = []
|
||||||
|
if rename_data:
|
||||||
|
owners = {} # data pointer -> (data, [objects of the plan using it])
|
||||||
|
for id_block, _name in plan:
|
||||||
|
data = getattr(id_block, "data", None)
|
||||||
|
if data is not None and is_local(data):
|
||||||
|
owners.setdefault(data.as_pointer(), (data, []))[1].append(id_block)
|
||||||
|
# Shared data (instances) is renamed only when all its users are renamed,
|
||||||
|
# after the first of them in numbering order.
|
||||||
|
datas = [(data, objects[0]) for data, objects in owners.values() if data.users == len(objects)]
|
||||||
|
|
||||||
|
# Temporary names first, so items of the selection can swap numbers
|
||||||
|
# without Blender adding '.001' to the ones not renamed yet.
|
||||||
|
for index, id_block in enumerate([id_block for id_block, _ in plan] + [data for data, _ in datas]):
|
||||||
|
id_block.name = f"{_TMP_PREFIX}{index}"
|
||||||
|
|
||||||
|
failed = []
|
||||||
|
for id_block, name in plan:
|
||||||
|
id_block.name = name
|
||||||
|
if id_block.name != name:
|
||||||
|
failed.append(f"'{name}' is taken, got '{id_block.name}'")
|
||||||
|
for data, owner in datas:
|
||||||
|
data.name = owner.name
|
||||||
|
return failed
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Settings / operator
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
_SIDE_ITEMS = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _side_items(self, context):
|
||||||
|
# Cached: Blender needs the enum strings to stay alive.
|
||||||
|
key = tuple(config.RENAME_SIDES)
|
||||||
|
if key not in _SIDE_ITEMS:
|
||||||
|
_SIDE_ITEMS[key] = [("NONE", "None", "No orientation")] + [
|
||||||
|
(side, side.capitalize(), f"Add '{side}' after the name") for side in key
|
||||||
|
]
|
||||||
|
return _SIDE_ITEMS[key]
|
||||||
|
|
||||||
|
|
||||||
|
class WTD_RenameSettings(bpy.types.PropertyGroup):
|
||||||
|
"""Per-file settings of the rename tool, saved in the .blend."""
|
||||||
|
|
||||||
|
target: EnumProperty(
|
||||||
|
name="Target",
|
||||||
|
items=[
|
||||||
|
("OBJECTS", "Objects", "Rename the selected objects"),
|
||||||
|
("MATERIALS", "Materials", "Rename the materials of the selected objects"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
base_name: StringProperty(
|
||||||
|
name="Name",
|
||||||
|
description="New name. Leave empty to keep the current base name of each item (box2 -> box)",
|
||||||
|
)
|
||||||
|
prefix_mode: EnumProperty(name="Prefix", items=AFFIX_MODES, default="TYPE")
|
||||||
|
prefix_custom: StringProperty(name="Custom Prefix")
|
||||||
|
side: EnumProperty(name="Side", description="Orientation token", items=_side_items)
|
||||||
|
suffix_mode: EnumProperty(name="Suffix", items=AFFIX_MODES, default="NONE")
|
||||||
|
suffix_custom: StringProperty(name="Custom Suffix")
|
||||||
|
sort: EnumProperty(
|
||||||
|
name="Order",
|
||||||
|
description="Order in which the items are numbered",
|
||||||
|
items=[
|
||||||
|
("NAME", "Name", "Current name"),
|
||||||
|
("X", "X", "World X location"),
|
||||||
|
("Y", "Y", "World Y location"),
|
||||||
|
("Z", "Z", "World Z location"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
rename_data: BoolProperty(
|
||||||
|
name="Rename Data",
|
||||||
|
description=(
|
||||||
|
"Give the object data (mesh, curve, light..., Maya's shape node) the name of its object. "
|
||||||
|
"Data shared by several objects takes the name of the first one, when they are all renamed"
|
||||||
|
),
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WTD_OT_batch_rename(bpy.types.Operator):
|
||||||
|
bl_idname = "wtd_tools.batch_rename"
|
||||||
|
bl_label = "Rename"
|
||||||
|
bl_description = "Rename the selected objects (or their materials), numbering after the existing ones"
|
||||||
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
return bool(context.selected_objects)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
settings = context.scene.wtd_rename
|
||||||
|
plan, skipped = plan_renames(context, settings)
|
||||||
|
if not plan:
|
||||||
|
self.report({"WARNING"}, "Nothing to rename (set a name, or select local items)")
|
||||||
|
return {"CANCELLED"}
|
||||||
|
failed = apply_renames(plan, settings.target == "OBJECTS" and settings.rename_data)
|
||||||
|
messages = failed + [f"'{name}': no base name, set a name" for name in skipped]
|
||||||
|
for message in messages:
|
||||||
|
print(f"[WTD Rename] {message}")
|
||||||
|
if messages:
|
||||||
|
self.report({"WARNING"}, f"Renamed {len(plan) - len(failed)}, {len(messages)} issue(s): {messages[0]}")
|
||||||
|
else:
|
||||||
|
self.report({"INFO"}, f"Renamed {len(plan)} item(s)")
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
classes = (WTD_RenameSettings, WTD_OT_batch_rename)
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
for cls in classes:
|
||||||
|
bpy.utils.register_class(cls)
|
||||||
|
bpy.types.Scene.wtd_rename = PointerProperty(type=WTD_RenameSettings)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
del bpy.types.Scene.wtd_rename
|
||||||
|
for cls in reversed(classes):
|
||||||
|
bpy.utils.unregister_class(cls)
|
||||||
+671
@@ -0,0 +1,671 @@
|
|||||||
|
"""Shot viewer: a window showing the shots of the edit through their camera.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from bpy.app.handlers import persistent
|
||||||
|
from bpy.props import BoolProperty, EnumProperty, 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:
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def shot_camera(strip):
|
||||||
|
return strip.scene_camera or strip.scene.camera
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
if not context.window_manager.wtd_shot_viewer.loop_shot:
|
||||||
|
return
|
||||||
|
scene = strip.scene
|
||||||
|
start, end = shot_frame_range(strip)
|
||||||
|
scene.use_preview_range = True
|
||||||
|
# Setting start past the old end (or end before the old start) pushes the other one.
|
||||||
|
scene.frame_preview_start = start
|
||||||
|
scene.frame_preview_end = end
|
||||||
|
scene.frame_preview_start = start
|
||||||
|
|
||||||
|
|
||||||
|
def show_shot(context, 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
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
return
|
||||||
|
settings = wm.wtd_shot_viewer
|
||||||
|
edit = settings.edit_scene
|
||||||
|
if edit is None or edit.sequence_editor is None:
|
||||||
|
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()
|
||||||
|
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."""
|
||||||
|
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))]
|
||||||
|
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."""
|
||||||
|
camera_name = config.OBJECT_PREFIXES.get("CAMERA", "") + name
|
||||||
|
if source is not None and source.type == "CAMERA":
|
||||||
|
data = source.data.copy()
|
||||||
|
data.name = camera_name
|
||||||
|
else:
|
||||||
|
source = None
|
||||||
|
data = bpy.data.cameras.new(camera_name)
|
||||||
|
camera = bpy.data.objects.new(camera_name, data)
|
||||||
|
if source is not None:
|
||||||
|
camera.matrix_world = source.matrix_world.copy()
|
||||||
|
else:
|
||||||
|
camera.location = Vector((0.0, -10.0, 1.6))
|
||||||
|
camera.rotation_euler = Euler((1.5708, 0.0, 0.0))
|
||||||
|
scene.collection.objects.link(camera)
|
||||||
|
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
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _rig_names(ob):
|
||||||
|
"""Names a rig is classified by: object, its collections and its library file."""
|
||||||
|
names = [ob.name, *(collection.name for collection in ob.users_collection)]
|
||||||
|
reference = ob.override_library.reference if ob.override_library else None
|
||||||
|
library = ob.library or (reference.library if reference else None)
|
||||||
|
if library is not None:
|
||||||
|
names.append(os.path.basename(library.filepath))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def rig_groups(view_layer):
|
||||||
|
"""{group label: [armature objects]} for the armatures of the view layer, empty groups left out."""
|
||||||
|
patterns = [(label, re.compile(pattern)) for label, pattern in config.SHOT_VIEWER_RIG_GROUPS.items()]
|
||||||
|
groups = {label: [] for label, _pattern in patterns}
|
||||||
|
groups[OTHER_GROUP] = []
|
||||||
|
rigs = sorted((ob for ob in view_layer.objects if ob.type == "ARMATURE"), key=lambda ob: ob.name.lower())
|
||||||
|
for ob in rigs:
|
||||||
|
names = _rig_names(ob)
|
||||||
|
label = next(
|
||||||
|
(label for label, pattern in patterns if any(pattern.search(name) for name in names)),
|
||||||
|
OTHER_GROUP,
|
||||||
|
)
|
||||||
|
groups[label].append(ob)
|
||||||
|
return {label: rigs for label, rigs in groups.items() if rigs}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Settings / operators
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _shot_index_update(self, context):
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _loop_shot_update(self, context):
|
||||||
|
if self.loop_shot:
|
||||||
|
strip = current_shot(context)
|
||||||
|
if strip is not None:
|
||||||
|
apply_loop(context, strip)
|
||||||
|
else:
|
||||||
|
for strip in shots(edit_scene(context)):
|
||||||
|
strip.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,
|
||||||
|
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"
|
||||||
|
|
||||||
|
step: IntProperty(default=1)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
ordered = shots(edit_scene(context))
|
||||||
|
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])
|
||||||
|
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_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")
|
||||||
|
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)",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
return edit_scene(context) is not None
|
||||||
|
|
||||||
|
def invoke(self, context, event):
|
||||||
|
self.name = next_shot_name(edit_scene(context))
|
||||||
|
self.duration = config.SHOT_DEFAULT_DURATION
|
||||||
|
return context.window_manager.invoke_props_dialog(self)
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
layout = self.layout
|
||||||
|
layout.use_property_split = True
|
||||||
|
layout.use_property_decorate = False
|
||||||
|
layout.prop(self, "name")
|
||||||
|
layout.prop(self, "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")
|
||||||
|
|
||||||
|
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"}
|
||||||
|
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)
|
||||||
|
|
||||||
|
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}')")
|
||||||
|
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_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):
|
||||||
|
return current_shot(context) is not None
|
||||||
|
|
||||||
|
def invoke(self, context, event):
|
||||||
|
return context.window_manager.invoke_props_dialog(self)
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
layout = self.layout
|
||||||
|
layout.label(text=f"Delete shot '{current_shot(context).name}'?", icon="ERROR")
|
||||||
|
layout.prop(self, "close_gap")
|
||||||
|
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)
|
||||||
|
|
||||||
|
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):
|
||||||
|
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}")
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class WTD_OT_shot_viewer_select_rig(bpy.types.Operator):
|
||||||
|
bl_idname = "wtd_tools.shot_viewer_select_rig"
|
||||||
|
bl_label = "Select Rig"
|
||||||
|
bl_description = "Select this rig in pose mode. Shift: add it to the selection"
|
||||||
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
name: StringProperty()
|
||||||
|
extend: BoolProperty(name="Extend", options={"SKIP_SAVE"})
|
||||||
|
|
||||||
|
def invoke(self, context, event):
|
||||||
|
self.extend = event.shift
|
||||||
|
return self.execute(context)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
view_layer = context.view_layer
|
||||||
|
rig = view_layer.objects.get(self.name)
|
||||||
|
if rig is None or not rig.visible_get(view_layer=view_layer):
|
||||||
|
self.report({"WARNING"}, f"'{self.name}' is hidden or not in this view layer")
|
||||||
|
return {"CANCELLED"}
|
||||||
|
if context.mode != "OBJECT":
|
||||||
|
bpy.ops.object.mode_set(mode="OBJECT")
|
||||||
|
if not self.extend:
|
||||||
|
for ob in context.selected_objects:
|
||||||
|
ob.select_set(False)
|
||||||
|
rig.select_set(True)
|
||||||
|
view_layer.objects.active = rig
|
||||||
|
try:
|
||||||
|
bpy.ops.object.mode_set(mode="POSE")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
self.report({"WARNING"}, f"'{rig.name}' selected, but not in pose mode: {exc}")
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
classes = (
|
||||||
|
WTD_ShotViewerSettings,
|
||||||
|
WTD_OT_shot_viewer_open,
|
||||||
|
WTD_OT_shot_viewer_step,
|
||||||
|
WTD_OT_shot_viewer_new_shot,
|
||||||
|
WTD_OT_shot_viewer_delete_shot,
|
||||||
|
WTD_OT_shot_viewer_select_rig,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
for cls in classes:
|
||||||
|
bpy.utils.register_class(cls)
|
||||||
|
bpy.types.WindowManager.wtd_shot_viewer = PointerProperty(type=WTD_ShotViewerSettings)
|
||||||
|
bpy.app.handlers.frame_change_post.append(_on_frame_change)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
if _on_frame_change in bpy.app.handlers.frame_change_post:
|
||||||
|
bpy.app.handlers.frame_change_post.remove(_on_frame_change)
|
||||||
|
del bpy.types.WindowManager.wtd_shot_viewer
|
||||||
|
for cls in reversed(classes):
|
||||||
|
bpy.utils.unregister_class(cls)
|
||||||
+165
-2
@@ -2,7 +2,7 @@ import textwrap
|
|||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
from .. import config, tools_ops
|
from .. import config, rename, shot_viewer, tools_ops
|
||||||
from ..core import CheckContext, Severity, checks_for_context, contexts, runner
|
from ..core import CheckContext, Severity, checks_for_context, contexts, runner
|
||||||
from ..core.runner import Status
|
from ..core.runner import Status
|
||||||
from .operators import EXPANDED
|
from .operators import EXPANDED
|
||||||
@@ -188,6 +188,145 @@ 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:
|
||||||
|
bl_region_type = "UI"
|
||||||
|
bl_category = "WTD"
|
||||||
|
bl_label = "Rename"
|
||||||
|
bl_options = {"DEFAULT_CLOSED"} # panel starts collapsed
|
||||||
|
|
||||||
|
PREVIEW_COUNT = 10
|
||||||
|
|
||||||
|
def draw(self, context) -> None:
|
||||||
|
layout = self.layout
|
||||||
|
settings = context.scene.wtd_rename
|
||||||
|
layout.row().prop(settings, "target", expand=True)
|
||||||
|
|
||||||
|
col = layout.column()
|
||||||
|
col.use_property_split = True
|
||||||
|
col.use_property_decorate = False
|
||||||
|
col.prop(settings, "base_name")
|
||||||
|
col.prop(settings, "prefix_mode")
|
||||||
|
if settings.prefix_mode == "CUSTOM":
|
||||||
|
col.prop(settings, "prefix_custom", text=" ")
|
||||||
|
col.prop(settings, "side")
|
||||||
|
col.prop(settings, "suffix_mode")
|
||||||
|
if settings.suffix_mode == "CUSTOM":
|
||||||
|
col.prop(settings, "suffix_custom", text=" ")
|
||||||
|
col.prop(settings, "sort")
|
||||||
|
if settings.target == "OBJECTS":
|
||||||
|
col.prop(settings, "rename_data")
|
||||||
|
|
||||||
|
plan, skipped = rename.plan_renames(context, settings)
|
||||||
|
row = layout.row()
|
||||||
|
row.scale_y = 1.4
|
||||||
|
row.operator(
|
||||||
|
rename.WTD_OT_batch_rename.bl_idname,
|
||||||
|
text=f"Rename {len(plan)} Item(s)" if plan else "Rename",
|
||||||
|
icon="SORTALPHA",
|
||||||
|
)
|
||||||
|
if not plan and not skipped:
|
||||||
|
return
|
||||||
|
box = layout.box()
|
||||||
|
col = box.column(align=True)
|
||||||
|
for id_block, name in plan[: self.PREVIEW_COUNT]:
|
||||||
|
col.label(text=f"{id_block.name} → {name}")
|
||||||
|
hidden = len(plan) - self.PREVIEW_COUNT
|
||||||
|
if hidden > 0:
|
||||||
|
col.label(text=f"... {hidden} more")
|
||||||
|
for name in skipped:
|
||||||
|
col.label(text=f"{name}: no base name, set a name", icon="ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
class WTD_UL_shots(bpy.types.UIList):
|
||||||
|
"""Scene strips of the edit, in edit 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)
|
||||||
|
split = layout.split(factor=0.4)
|
||||||
|
split.label(text=item.name, icon="SEQUENCE")
|
||||||
|
split = split.split(factor=0.6)
|
||||||
|
split.label(text=camera.name if camera else "(no camera)")
|
||||||
|
split.label(text=f"{start}-{end - 1}")
|
||||||
|
|
||||||
|
def filter_items(self, context, data, propname):
|
||||||
|
strips = getattr(data, propname)
|
||||||
|
ordered = [strip.as_pointer() for strip in shot_viewer.shots(data.id_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]
|
||||||
|
if self.filter_name:
|
||||||
|
by_name = bpy.types.UI_UL_list.filter_items_by_name(
|
||||||
|
self.filter_name, self.bitflag_filter_item, strips, "name"
|
||||||
|
)
|
||||||
|
flags = [flag & name_flag for flag, name_flag in zip(flags, by_name)]
|
||||||
|
# Shots in edit order, the other strips after them.
|
||||||
|
order = []
|
||||||
|
hidden = len(ordered)
|
||||||
|
for strip in strips:
|
||||||
|
pointer = strip.as_pointer()
|
||||||
|
if pointer in position:
|
||||||
|
order.append(position[pointer])
|
||||||
|
else:
|
||||||
|
order.append(hidden)
|
||||||
|
hidden += 1
|
||||||
|
return flags, order
|
||||||
|
|
||||||
|
|
||||||
|
class _ShotViewerPanel:
|
||||||
|
bl_region_type = "UI"
|
||||||
|
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
|
||||||
|
has_shots = bool(shot_viewer.shots(scene))
|
||||||
|
|
||||||
|
row = layout.row(align=True)
|
||||||
|
row.scale_y = 1.4
|
||||||
|
row.enabled = has_shots
|
||||||
|
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="")
|
||||||
|
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.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")
|
||||||
|
for label, rigs in groups.items():
|
||||||
|
box = layout.box()
|
||||||
|
box.label(text=label, icon="ARMATURE_DATA")
|
||||||
|
col = box.column(align=True)
|
||||||
|
for rig in rigs:
|
||||||
|
col.operator(
|
||||||
|
shot_viewer.WTD_OT_shot_viewer_select_rig.bl_idname,
|
||||||
|
text=rig.name,
|
||||||
|
depress=rig.select_get(),
|
||||||
|
).name = rig.name
|
||||||
|
|
||||||
|
|
||||||
class WTD_PT_sanity_view3d(_SanityPanel, bpy.types.Panel):
|
class WTD_PT_sanity_view3d(_SanityPanel, bpy.types.Panel):
|
||||||
bl_idname = "WTD_PT_sanity_view3d"
|
bl_idname = "WTD_PT_sanity_view3d"
|
||||||
@@ -199,12 +338,36 @@ class WTD_PT_tools_view3d(_ToolsPanel, bpy.types.Panel):
|
|||||||
bl_space_type = "VIEW_3D"
|
bl_space_type = "VIEW_3D"
|
||||||
|
|
||||||
|
|
||||||
|
class WTD_PT_rename_view3d(_RenamePanel, bpy.types.Panel):
|
||||||
|
bl_idname = "WTD_PT_rename_view3d"
|
||||||
|
bl_space_type = "VIEW_3D"
|
||||||
|
|
||||||
|
|
||||||
|
class WTD_PT_rename_node_editor(_RenamePanel, bpy.types.Panel):
|
||||||
|
bl_idname = "WTD_PT_rename_node_editor"
|
||||||
|
bl_space_type = "NODE_EDITOR"
|
||||||
|
|
||||||
|
|
||||||
|
class WTD_PT_shot_viewer_view3d(_ShotViewerPanel, bpy.types.Panel):
|
||||||
|
bl_idname = "WTD_PT_shot_viewer_view3d"
|
||||||
|
bl_space_type = "VIEW_3D"
|
||||||
|
|
||||||
|
|
||||||
class WTD_PT_sanity_sequencer(_SanityPanel, bpy.types.Panel):
|
class WTD_PT_sanity_sequencer(_SanityPanel, bpy.types.Panel):
|
||||||
bl_idname = "WTD_PT_sanity_sequencer"
|
bl_idname = "WTD_PT_sanity_sequencer"
|
||||||
bl_space_type = "SEQUENCE_EDITOR"
|
bl_space_type = "SEQUENCE_EDITOR"
|
||||||
|
|
||||||
|
|
||||||
classes = (WTD_PT_sanity_view3d, WTD_PT_tools_view3d, WTD_PT_sanity_sequencer)
|
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_rename_view3d,
|
||||||
|
WTD_PT_rename_node_editor,
|
||||||
|
WTD_PT_sanity_sequencer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
|
|||||||
Reference in new issue
Block a user