Files
blender-portable-repo/scripts/addons/contactsheet/ops.py
T
2026-03-17 14:58:51 -06:00

267 lines
9.6 KiB
Python

# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import math
from pathlib import Path
from typing import Set, List
import bpy
from . import prefs, opsdata
from .log import LoggerFactory
from .geo_seq import SequenceRect
from .geo import Grid, NestedRectangle
logger = LoggerFactory.getLogger(name=__name__)
class CS_OT_make_contactsheet(bpy.types.Operator):
"""
This operator creates a contactsheet out of the selected sequence strips.
The contactsheet will be created in a separate scene.
"""
bl_idname = "contactsheet.make_contactsheet"
bl_label = "Make Contact Sheet"
bl_description = (
"Creates a temporary scene and arranges the previously selected sequences in a grid. "
"If no sequences were selected it takes a continuous row of the top most sequences"
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return opsdata.poll_make_contactsheet(context)
def execute(self, context: bpy.types.Context) -> Set[str]:
addon_prefs = prefs.addon_prefs_get(context)
# Gather sequences to process.
sequences = context.selected_sequences
if not sequences:
# If nothing selected take a continuous row of the top most sequences.
sequences = opsdata.get_top_level_valid_strips_continuous(context)
else:
sequences = opsdata.get_valid_cs_sequences(sequences)
# Select sequences, will remove sequences later that are not selected.
bpy.ops.sequencer.select_all(action="DESELECT")
for s in sequences:
s.select = True
row_count = None
start_frame = 1
# Get contactsheet metadata.
sqe_editor = opsdata.get_sqe_editor(context)
orig_proxy_render_size = sqe_editor.spaces.active.proxy_render_size
orig_use_proxies = sqe_editor.spaces.active.use_proxies
# Create new scene.
scene_orig = bpy.context.scene
bpy.ops.scene.new(type="FULL_COPY") # Changes active scene, makes copy.
scene_tmp = bpy.context.scene
scene_tmp.name = "contactsheet"
logger.info("Created temporary scene for contactsheet: %s", scene_tmp.name)
# Save contactsheet metadata.
sqe_editor = opsdata.get_sqe_editor(context)
sqe_editor.spaces.active.proxy_render_size = "PROXY_25"
sqe_editor.spaces.active.use_proxies = True
scene_tmp.contactsheet.is_contactsheet = True
scene_tmp.contactsheet.contactsheet_meta.scene = scene_orig
scene_tmp.contactsheet.contactsheet_meta.use_proxies = orig_use_proxies
scene_tmp.contactsheet.contactsheet_meta.proxy_render_size = (
orig_proxy_render_size
)
# Remove sequences in new scene that are not selected.
seq_rm: List["bpy.types.Strip"] = [
s for s in scene_tmp.sequence_editor.sequences_all if not s.select
]
for s in seq_rm:
scene_tmp.sequence_editor.sequences.remove(s)
# Get all sequences in new scene and sort them.
sequences = list(scene_tmp.sequence_editor.sequences_all)
sequences.sort(key=lambda strip: (strip.frame_final_start, strip.channel))
# Place black color strip in channel 1.
color_strip = context.scene.sequence_editor.sequences.new_effect(
"background", "COLOR", 1, start_frame, frame_end=start_frame + 1
)
color_strip.color = (0, 0, 0)
# Create required number of Metadata Strips to workaround the limit of 32 channels.
nr_of_metadata_strips = math.ceil(len(sequences) / 32)
metadata_strips: List["bpy.types.Strip"] = []
for i in range(nr_of_metadata_strips):
channel = i + 2
metadata_strip = context.scene.sequence_editor.sequences.new_meta(
f"contactsheet_meta_{channel-1}", channel, start_frame
)
metadata_strips.append(metadata_strip)
logger.debug("Created Metadata Strip: %s", metadata_strip.name)
# Move sequences in to Metadata Strips, place them on top of each other
# make them start at the same frame.
for idx, seq in enumerate(sequences):
# Move to Metadata Strip.
channel = idx + 1
meta_index = math.floor(idx / 32)
seq.move_to_meta(metadata_strips[meta_index])
# Set seq properties inside Metadata Strip.
seq.channel = channel - ((meta_index) * 32)
seq.frame_start = start_frame
seq.blend_type = "ALPHA_OVER"
# Elongate all strips to the strip with the longest duration.
tmp_sequences = sorted(sequences, key=lambda s: s.frame_final_end)
tmp_sequences.insert(0, color_strip)
max_end: int = tmp_sequences[-1].frame_final_end
for strip in tmp_sequences:
if strip.frame_final_end < max_end:
strip.frame_final_end = max_end
# Clip the Metadata Strip frame end at max end and set alpha over.
for strip in metadata_strips:
strip.frame_start = start_frame
strip.frame_final_end = max_end
strip.blend_type = "ALPHA_OVER"
# Scene settings.
# Change frame range and frame start.
self.set_render_settings(context)
self.set_output_path(context)
self.set_sqe_area_settings(context)
# Create content list for grid.
sqe_rects: List[SequenceRect] = [SequenceRect(seq) for seq in sequences]
content: List[NestedRectangle] = [
NestedRectangle(0, 0, srect.width, srect.height, child=srect)
for srect in sqe_rects
]
# Create grid.
if context.scene.contactsheet.use_custom_rows:
row_count = context.scene.contactsheet.rows
grid = Grid.from_content(
0,
0,
context.scene.contactsheet.contactsheet_x,
context.scene.contactsheet.contactsheet_y,
content,
row_count=row_count,
)
grid.scale_content(addon_prefs.contactsheet_scale_factor)
return {"FINISHED"}
def set_sqe_area_settings(self, context: bpy.types.Context) -> None:
sqe_editor = opsdata.get_sqe_editor(context)
sqe_editor.spaces.active.proxy_render_size = "PROXY_25"
sqe_editor.spaces.active.use_proxies = True
def set_render_settings(self, context: bpy.types.Context) -> None:
opsdata.fit_frame_range_to_strips(context)
context.scene.frame_current = context.scene.frame_start
context.scene.render.resolution_x = context.scene.contactsheet.contactsheet_x
context.scene.render.resolution_y = context.scene.contactsheet.contactsheet_y
context.scene.render.image_settings.file_format = "PNG"
context.scene.render.image_settings.color_mode = "RGB"
context.scene.render.image_settings.color_depth = "8"
context.scene.render.image_settings.compression = 15
def set_output_path(self, context: bpy.types.Context) -> None:
addon_prefs = prefs.addon_prefs_get(context)
cs_dir: Path = addon_prefs.contactsheet_dir_path
output_path: str = ""
# File not saved and cs_dir not available.
if not bpy.data.filepath and not cs_dir:
logger.warning(
"Failed to set output settings. Contactsheet Output Directory "
"not defined in addon preferences and file not saved."
)
return
# File saved and cs_dir available.
if cs_dir and bpy.data.filepath:
output_path = cs_dir.joinpath(
f"{Path(bpy.data.filepath).stem}_contactsheet.png"
).as_posix()
# File not saved but cs_dir available.
elif cs_dir:
output_path = cs_dir.joinpath(f"contactsheet.png").as_posix()
# File saved but cs_dir not available.
else:
output_path = (
Path(bpy.data.filepath)
.parent.joinpath(f"{Path(bpy.data.filepath).stem}_contactsheet.png")
.as_posix()
)
# Set output path.
context.scene.render.filepath = output_path
class CS_OT_exit_contactsheet(bpy.types.Operator):
bl_idname = "contactsheet.exit_contactsheet"
bl_label = "Exit Contact Sheet"
bl_description = (
"Exits contactsheet scene, deletes it and "
"return to original scene that was used to create the contactsheet"
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return bool(context.scene.contactsheet.is_contactsheet)
def execute(self, context: bpy.types.Context) -> Set[str]:
cs_scene = context.scene
cs_scene_name = cs_scene.name
# Change active scene to orig scene.
context.window.scene = context.scene.contactsheet.contactsheet_meta.scene
# Restore proxy settings from contactsheet.contactsheet_meta.
sqe_editor = opsdata.get_sqe_editor(context)
sqe_editor.spaces.active.proxy_render_size = (
cs_scene.contactsheet.contactsheet_meta.proxy_render_size
)
sqe_editor.spaces.active.use_proxies = (
cs_scene.contactsheet.contactsheet_meta.use_proxies
)
# Remove contactsheet scene.
bpy.data.scenes.remove(cs_scene)
self.report({"INFO"}, f"Exited and deleted scene: {cs_scene_name}")
return {"FINISHED"}
# ----------------REGISTER--------------.
classes = [
CS_OT_make_contactsheet,
CS_OT_exit_contactsheet,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)