Added functionality in Shot Manager to create all cameras for a Kitsu Sequence

This commit is contained in:
Nero Frame 13 committed 2026-10-28 13:35:54 +01:00
1 parent 4161ec4ad5
commit 8dcd4daad1
3 files changed
+271 -18

No files matched your search

+24
View File
@@ -295,6 +295,27 @@ The shot is added at the end of the sequence and the scene's **End** frame grows
The rig is built at the **3D cursor**, in the active collection: place the cursor and pick the collection first if you have a camera collection. The add-on keeps the rig's bone shapes in a hidden **Widgets** collection. If the add-on is not enabled, the dialog says so and no shot is added. The rig is built at the **3D cursor**, in the active collection: place the cursor and pick the collection first if you have a camera collection. The add-on keeps the rig's bone shapes in a hidden **Widgets** collection. If the add-on is not enabled, the dialog says so and no shot is added.
### Shots from Kitsu
**Shots from Kitsu...** builds every camera a sequence needs in one go, from the shots and casting in Kitsu. It needs Libreflow connected in the [Scene Manager](#connecting).
1. Click **Shots from Kitsu...** and pick the sequence. The sequence of the Scene Manager's shot is at the top of the list.
2. The dialog lists the Kitsu shots of the sequence in name order. Each line shows its frame count and what is cast in it:
- camera icon: to build;
- check mark: a shot of that name is in the scene already, left as it is;
- greyed out: nothing is cast in it (see **Only Cast Shots**).
3. Choose the settings and click **OK**.
| Setting | Use |
|---|---|
| **Only Cast Shots** | On (default): shots with nothing cast in Kitsu get no camera. |
| **3D Cameras** | On: 3D **crane** rigs. Off: **2D** camera rigs. |
| **Default Duration** | Length of the shots without a frame count in Kitsu. |
Each shot is built as with **+**: an Add Camera Rigs rig at the 3D cursor, `CAM_<sequence>_<shot>` / `RIG_<sequence>_<shot>`, named with the Kitsu sequence name, and a camera marker named after the Kitsu shot. The shots go one after the other after the shots of the scene, in Kitsu order. Each one is as long as its Kitsu frame count (**Nb Frames**). When the sequence name follows `SEQUENCE_NAME_FORMAT`, the **Sequence** field is set from it. Running it again only adds the shots that are still missing, so it also picks up shots added in Kitsu later. **Ctrl+Z** undoes it.
The frame counts and the casting of the whole sequence come from the wtdcmds command `wtd_get_sequence_shots`. With an older wtdcmds the shots are found shot by shot from their casting: it is slower, the shots that are not in Kitsu are left out, and every shot gets the **Default Duration**.
### Deleting a shot ### Deleting a shot
Put the playhead on the shot (or select it in the list) and click **−**: Put the playhead on the shot (or select it in the list) and click **−**:
@@ -517,6 +538,9 @@ The newer version is published but not downloaded on this site yet. Request it (
**Scene Manager: "...its wtdcmds extension is too old".** **Scene Manager: "...its wtdcmds extension is too old".**
Libreflow runs an older `wtdcmds` without the command (e.g. requests). Update the extension and restart Libreflow. Libreflow runs an older `wtdcmds` without the command (e.g. requests). Update the extension and restart Libreflow.
**Shot Manager: Shots from Kitsu lists no shot.**
The sequence has no shot in Kitsu, or its shots have Kitsu names that differ from the Libreflow ones. `melvile_test / sq00A` is such a sequence.
**Shot Manager: playback only plays one shot.** **Shot Manager: playback only plays one shot.**
**Loop Shot** is on. Untick it to play the whole sequence. **Loop Shot** is on. Untick it to play the whole sequence.
+242 -18
View File
@@ -3,7 +3,8 @@
Shots are the timeline markers bound to a camera (Ctrl+B in the timeline). A 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 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 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. in that shot's camera view. The panel also selects the character / prop rigs,
and builds the shots of a Kitsu sequence (Libreflow, through the Scene Manager).
""" """
import os import os
@@ -11,9 +12,11 @@ import re
import bpy import bpy
from bpy.app.handlers import persistent from bpy.app.handlers import persistent
from bpy.props import BoolProperty, IntProperty, PointerProperty, StringProperty from bpy.props import BoolProperty, EnumProperty, IntProperty, PointerProperty, StringProperty
from . import config from . import config
from .scene_manager import ipc
from .scene_manager import operators as sm
OTHER_GROUP = "Other" OTHER_GROUP = "Other"
@@ -113,10 +116,17 @@ def sequence_name(scene):
return config.SEQUENCE_NAME_FORMAT.format(scene.wtd_sequence) return config.SEQUENCE_NAME_FORMAT.format(scene.wtd_sequence)
def camera_name(scene, shot): def sequence_number(name):
"""Name of a new shot camera (config SHOT_CAMERA_NAME_FORMAT).""" """Number of a sequence name written with SEQUENCE_NAME_FORMAT, else None ("sq0040" -> 40)."""
head, _sep, rest = config.SEQUENCE_NAME_FORMAT.partition("{")
match = re.fullmatch(re.escape(head) + r"(\d+)" + re.escape(rest.partition("}")[2]), name)
return int(match.group(1)) if match else None
def camera_name(scene, shot, sequence=None):
"""Name of a new shot camera (config SHOT_CAMERA_NAME_FORMAT), in the scene's sequence by default."""
return config.SHOT_CAMERA_NAME_FORMAT.format( return config.SHOT_CAMERA_NAME_FORMAT.format(
prefix=config.OBJECT_PREFIXES.get("CAMERA", ""), sequence=sequence_name(scene), shot=shot prefix=config.OBJECT_PREFIXES.get("CAMERA", ""), sequence=sequence or sequence_name(scene), shot=shot
) )
@@ -125,7 +135,7 @@ def camera_rig_available():
return hasattr(bpy.types, "OBJECT_OT_build_camera_rig") return hasattr(bpy.types, "OBJECT_OT_build_camera_rig")
def _new_camera_rig(context, shot, is_3d): def _new_camera_rig(context, shot, is_3d, sequence=None):
"""Camera rig built by the Add Camera Rigs add-on at the 3D cursor, named for the shot. """Camera rig built by the Add Camera Rigs add-on at the 3D cursor, named for the shot.
Returns the camera object.""" Returns the camera object."""
scene = context.scene scene = context.scene
@@ -138,15 +148,99 @@ def _new_camera_rig(context, shot, is_3d):
camera = next((ob for ob in new if ob.type == "CAMERA"), None) camera = next((ob for ob in new if ob.type == "CAMERA"), None)
if camera is None: if camera is None:
raise RuntimeError("build_camera_rig created no camera") raise RuntimeError("build_camera_rig created no camera")
camera.name = camera.data.name = camera_name(scene, shot) camera.name = camera.data.name = camera_name(scene, shot, sequence)
rig = camera.parent rig = camera.parent
if rig is not None and rig in new: if rig is not None and rig in new:
rig.name = rig.data.name = config.SHOT_CAMERA_RIG_NAME_FORMAT.format( rig.name = rig.data.name = config.SHOT_CAMERA_RIG_NAME_FORMAT.format(
prefix=config.OBJECT_PREFIXES.get("ARMATURE", ""), sequence=sequence_name(scene), shot=shot prefix=config.OBJECT_PREFIXES.get("ARMATURE", ""), sequence=sequence or sequence_name(scene), shot=shot
) )
return camera return camera
def append_shot(context, name, camera, duration):
"""Camera marker for the shot after the last one (at SHOT_FRAME_START in an empty scene),
the scene end moved to its last frame. A marker of that name without camera is reused."""
scene = context.scene
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.get(name)
if marker is None or marker.camera is not None:
marker = scene.timeline_markers.new(name, frame=start)
marker.frame = start
marker.camera = camera
scene.frame_end = start + duration - 1
return marker
# --------------------------------------------------------------------------- #
# Kitsu sequence
# --------------------------------------------------------------------------- #
# Shots of the sequence picked for Shots from Kitsu (wtd_get_sequence_shots result).
KITSU = {}
def kitsu_sequence_shots(uid, oid):
"""wtd_get_sequence_shots for the sequence of the oid. With a wtdcmds too old to have it,
built from wtd_list_shots and the casting of every shot, without frame counts: the shots
Kitsu does not know are left out."""
try:
return ipc.call("wtd_get_sequence_shots", uid, oid=oid)
except ipc.LibreflowError as exc:
if exc.status != 404:
raise
context = ipc.call("wtd_get_context", uid, oid=oid)
result = {key: context.get(key) for key in ("film", "sequence", "sequence_oid")}
result["shots"] = []
for shot in ipc.call("wtd_list_shots", uid, oid=oid, film=context.get("film"), sequence=context.get("sequence")):
try:
casting = ipc.call("wtd_get_casting", uid, oid=shot["oid"])
except ipc.LibreflowError as exc:
if exc.status is None:
raise
continue
result["shots"].append(
{
"shot": shot["shot"],
"oid": shot["oid"],
"nb_frames": None,
"casting": [
{"name": a["name"], "asset_type": a["asset_type"], "nb_occurences": a["nb_occurences"]}
for a in casting["assets"]
],
}
)
return result
def kitsu_shot_state(scene, shot, only_cast):
""""EXISTS" (a camera marker has its name), "UNCAST" (no casting, skipped with only_cast) or "NEW"."""
if any(m.name == shot["shot"] for m in shots(scene)):
return "EXISTS"
if only_cast and not shot["casting"]:
return "UNCAST"
return "NEW"
def create_kitsu_shots(context, data, is_3d, only_cast, duration):
"""A camera rig and a shot for every NEW shot of the sequence, in Kitsu order, after the
shots of the scene. Kitsu's frame count is the shot length, else duration. Returns the markers."""
scene = context.scene
number = sequence_number(data["sequence"])
if number is not None:
scene.wtd_sequence = number
markers = []
for shot in data["shots"]:
if kitsu_shot_state(scene, shot, only_cast) != "NEW":
continue
camera = _new_camera_rig(context, shot["shot"], is_3d, data["sequence"])
markers.append(append_shot(context, shot["shot"], camera, shot.get("nb_frames") or duration))
return markers
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Rigs # Rigs
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -294,17 +388,9 @@ class WTD_OT_shot_viewer_new_shot(bpy.types.Operator):
self.report({"WARNING"}, "Pick a camera of the scene, or tick New Camera") self.report({"WARNING"}, "Pick a camera of the scene, or tick New Camera")
return {"CANCELLED"} return {"CANCELLED"}
ordered = shots(scene) marker = append_shot(context, name, camera, self.duration)
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) show_shot(context, marker)
self.report({"INFO"}, f"Shot '{marker.name}' added at frame {start} ({camera.name})") self.report({"INFO"}, f"Shot '{marker.name}' added at frame {marker.frame} ({camera.name})")
return {"FINISHED"} return {"FINISHED"}
@@ -361,6 +447,142 @@ class WTD_OT_shot_viewer_delete_shot(bpy.types.Operator):
return {"FINISHED"} return {"FINISHED"}
def _sequence_items(self, context):
return _SEQUENCE_ITEMS
_SEQUENCE_ITEMS = []
class WTD_OT_shot_viewer_kitsu_sequence(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_kitsu_sequence"
bl_label = "Shots from Kitsu"
bl_description = (
"Pick a sequence, then build a camera rig and a shot for each of its Kitsu shots "
"(Libreflow, connected in the Scene Manager)"
)
bl_property = "sequence"
sequence: EnumProperty(items=_sequence_items)
@classmethod
def poll(cls, context):
return sm.connected()
def invoke(self, context, event):
oid = sm.project_oid(context.scene)
if not oid:
self.report({"WARNING"}, "Set a context oid (or open the project in Libreflow) first")
return {"CANCELLED"}
context.window.cursor_set("WAIT")
try:
listed = ipc.call("wtd_list_shots", sm.session_uid(), oid=oid)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"Sequences: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
sequences = {s["sequence_oid"]: f"{s.get('film', '')} / {s['sequence']}" for s in listed if s.get("sequence_oid")}
# The sequence of the scene's context first.
current = (sm.context_oid(context.scene) or "").split("/shots/")[0]
_SEQUENCE_ITEMS[:] = sorted(
((oid, label, oid) for oid, label in sequences.items()), key=lambda item: (item[0] != current, item[1])
)
if not _SEQUENCE_ITEMS:
self.report({"WARNING"}, "No sequence in the project")
return {"CANCELLED"}
context.window_manager.invoke_search_popup(self)
return {"RUNNING_MODAL"}
def execute(self, context):
context.window.cursor_set("WAIT")
try:
data = kitsu_sequence_shots(sm.session_uid(), self.sequence)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"Kitsu shots: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
if not data["shots"]:
self.report({"WARNING"}, f"No Kitsu shot in {data['sequence']}")
return {"CANCELLED"}
KITSU.clear()
KITSU.update(data)
bpy.ops.wtd_tools.shot_viewer_kitsu_shots("INVOKE_DEFAULT")
return {"FINISHED"}
KITSU_STATE_ICONS = {"NEW": "CAMERA_DATA", "EXISTS": "CHECKMARK", "UNCAST": "BLANK1"}
class WTD_OT_shot_viewer_kitsu_shots(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_kitsu_shots"
bl_label = "Build Kitsu Shots"
bl_description = "Build a camera rig and a shot for each Kitsu shot of the picked sequence not in the scene yet"
bl_options = {"REGISTER", "UNDO"}
only_cast: BoolProperty(
name="Only Cast Shots",
description="Leave out the shots with nothing cast in Kitsu",
default=True,
)
camera_3d: BoolProperty(name="3D Cameras", description="Build 3D crane rigs. Otherwise 2D camera rigs")
duration: IntProperty(
name="Default Duration",
description="Length of the shots without a frame count in Kitsu",
min=1,
default=48,
)
@classmethod
def poll(cls, context):
return bool(KITSU.get("shots"))
def invoke(self, context, event):
self.duration = config.SHOT_DEFAULT_DURATION
return context.window_manager.invoke_props_dialog(self, width=420)
def draw(self, context):
layout = self.layout
scene = context.scene
layout.label(text=f"{KITSU.get('film') or ''} / {KITSU['sequence']}", icon="SEQUENCE")
col = layout.column(align=True)
for shot in KITSU["shots"]:
state = kitsu_shot_state(scene, shot, self.only_cast)
row = col.row()
row.active = state == "NEW"
row.label(text=shot["shot"], icon=KITSU_STATE_ICONS[state])
row.label(text=f"{shot['nb_frames']} fr" if shot.get("nb_frames") else "? fr")
names = ", ".join(c["name"] for c in shot["casting"])
row.label(text=names or "nothing cast")
layout.separator()
layout.use_property_split = True
layout.use_property_decorate = False
layout.prop(self, "only_cast")
layout.prop(self, "camera_3d")
layout.prop(self, "duration")
new = [s for s in KITSU["shots"] if kitsu_shot_state(scene, s, self.only_cast) == "NEW"]
if not new:
layout.label(text="Every shot is in the scene already", icon="INFO")
else:
first = camera_name(scene, new[0]["shot"], KITSU["sequence"])
layout.label(text=f"{len(new)} shot(s) to build: {first}...", icon="CAMERA_DATA")
if not camera_rig_available():
layout.label(text="Enable the Add Camera Rigs add-on", icon="ERROR")
def execute(self, context):
if not camera_rig_available():
self.report({"WARNING"}, "Enable the Add Camera Rigs add-on to build the shot cameras")
return {"CANCELLED"}
markers = create_kitsu_shots(context, KITSU, self.camera_3d, self.only_cast, self.duration)
if not markers:
self.report({"INFO"}, "Every shot is in the scene already")
return {"CANCELLED"}
show_shot(context, markers[0])
self.report({"INFO"}, f"{len(markers)} shot(s) built from {KITSU['sequence']}: " + ", ".join(m.name for m in markers))
return {"FINISHED"}
class WTD_OT_shot_viewer_select_rig(bpy.types.Operator): class WTD_OT_shot_viewer_select_rig(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_select_rig" bl_idname = "wtd_tools.shot_viewer_select_rig"
bl_label = "Select Rig" bl_label = "Select Rig"
@@ -399,6 +621,8 @@ classes = (
WTD_OT_shot_viewer_step, WTD_OT_shot_viewer_step,
WTD_OT_shot_viewer_new_shot, WTD_OT_shot_viewer_new_shot,
WTD_OT_shot_viewer_delete_shot, WTD_OT_shot_viewer_delete_shot,
WTD_OT_shot_viewer_kitsu_sequence,
WTD_OT_shot_viewer_kitsu_shots,
WTD_OT_shot_viewer_select_rig, WTD_OT_shot_viewer_select_rig,
) )
+5
View File
@@ -356,6 +356,11 @@ class _ShotViewerPanel:
) )
if not has_shots: if not has_shots:
layout.label(text="No camera marker yet: add a shot with +", icon="INFO") layout.label(text="No camera marker yet: add a shot with +", icon="INFO")
layout.operator(
shot_viewer.WTD_OT_shot_viewer_kitsu_sequence.bl_idname,
text="Shots from Kitsu...",
icon="OUTLINER_OB_CAMERA",
)
layout.prop(settings, "loop_shot") layout.prop(settings, "loop_shot")
groups = shot_viewer.rig_groups(context.view_layer) groups = shot_viewer.rig_groups(context.view_layer)