vse_toolbox/operators/sequencer.py
Joseph HENRY d2d64dc8a8 Blender 5.0 compat + STB XML import + sequence/stamps improvements
- Fix Blender 5.0 API: active_sequence_strip → active_strip, bracket
  property access on AddonPreferences, _RestrictContext during register()
- Fix new_effect() frame_end → length for Blender 5.0
- Fix OTIO adapter kwargs (rate/ignore_timecode_mismatch only for cmx_3600)
- Fix OTIO global_start_time RationalTime → int conversion
- Add Import STB XML operator with movie strip import and XML patching
  for Storyboard Pro transitions missing <alignment>
- Add Create Sequence Strip operator (select shots → create sequence)
- Improve Set Stamps: channel at top, no conflict with existing strips,
  use raw strip names in templates
- TVSHOW episode fixes: sequence loading, episode creation
- Kitsu: new_asset, new_episode, admin_connect fix, gazu version pin
- Fix escape warnings in file_utils regex patterns
- Guard handler registration against duplicates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:09:03 +01:00

916 lines
31 KiB
Python

import re
from os.path import expandvars, abspath
from pathlib import Path
import bpy
from bpy.types import Operator
from bpy.props import (BoolProperty, StringProperty, FloatProperty,
IntProperty, EnumProperty)
from vse_toolbox.sequencer_utils import (get_strips, rename_strips, set_channels,
get_channel_index, new_text_strip, get_strip_at, get_channel_name,
create_shot_strip)
from vse_toolbox.scene_cut_detection import detect_scene_change
from vse_toolbox.bl_utils import get_scene_settings, get_strip_settings, get_addon_prefs
from shutil import copy2
class VSETB_OT_rename(Operator):
bl_idname = "vse_toolbox.strips_rename"
bl_label = "Rename Strips"
bl_description = "Rename Strips"
bl_options = {"REGISTER", "UNDO"}
#template : StringProperty(name="Strip Name", default="")
#increment : IntProperty(name="Increment", default=0)
selected_only : BoolProperty(name="Selected Only", default=True)
#start_number : IntProperty(name="Start Number", default=0, min=0)
#by_sequence : BoolProperty(
# name="Reset By Sequence",
# description="Reset Start Number for each sequence",
# default=False
#)
@classmethod
def poll(cls, context):
settings = get_scene_settings()
strip = context.active_strip
return settings.active_project and get_channel_name(strip) in ('Shots', 'Sequences')
def invoke(self, context, event):
scn = context.scene
settings = get_scene_settings()
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
scn = context.scene
settings = get_scene_settings()
project = settings.active_project
episode = project.episode_name
sequence = str(project.sequence_start_number).zfill(project.sequence_padding)
shot = str(project.shot_start_number).zfill(project.shot_padding)
strip = context.active_strip
channel_name = get_channel_name(strip)
col = layout.column()
col.use_property_split = True
col.use_property_decorate = False
if channel_name == 'Shots':
col.prop(project, 'shot_template', text='Shot Name')
col.prop(project, 'shot_start_number', text='Start Number')
col.prop(project, 'shot_increment', text='Increment')
col.prop(project, 'shot_padding', text='Padding')
col.prop(project, 'reset_by_sequence')
elif channel_name == 'Sequences':
col.prop(project, 'sequence_template' ,text='Sequence Name')
col.prop(project, 'sequence_start_number', text='Start Number')
col.prop(project, 'sequence_increment', text='Increment')
col.prop(project, 'sequence_padding', text='Padding')
col.prop(self, 'selected_only')
if channel_name == 'Shots':
label = project.shot_template.format(episode=episode, sequence=sequence, shot=shot)
elif channel_name == 'Sequences':
label = project.sequence_template.format(episode=episode, sequence=sequence)
col.label(text=f'Renaming {label}')
def execute(self, context):
scn = context.scene
settings = get_scene_settings()
project = settings.active_project
strip = context.active_strip
channel_name = get_channel_name(strip)
strips = get_strips(channel=channel_name, selected_only=self.selected_only)
if channel_name == 'Shots':
rename_strips(strips,
template=project.shot_template,
increment=project.shot_increment, start_number=project.shot_start_number,
by_sequence=project.reset_by_sequence,
padding=project.shot_padding
)
if channel_name == 'Sequences':
rename_strips(strips,
template=project.sequence_template,
increment=project.sequence_increment, start_number=project.sequence_start_number,
padding=project.sequence_padding
)
return {"FINISHED"}
class VSETB_OT_show_waveform(Operator):
bl_idname = "vse_toolbox.show_waveform"
bl_label = "Show Waveform"
bl_description = "Show Waveform of all audio strips"
bl_options = {"REGISTER", "UNDO"}
enabled : BoolProperty(default=True)
@classmethod
def poll(cls, context):
return True
def execute(self, context):
scn = context.scene
for strip in get_strips(channel='Audio'):
strip.show_waveform = self.enabled
return {"FINISHED"}
class VSETB_OT_set_sequencer(Operator):
bl_idname = "vse_toolbox.set_sequencer"
bl_label = "Set Sequencer"
bl_description = "Set resolution, frame end and channel names"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
scn = context.scene
set_channels()
movies = get_strips(channel='Movie')
movie = None
if movies:
movie = movies[0]
movie.transform.scale_x = movie.transform.scale_y = 1
elem = movie.strip_elem_from_frame(scn.frame_current)
scn.render.resolution_x = elem.orig_width
scn.render.resolution_y = elem.orig_height
else:
self.report({'INFO'}, f'Cannot set Resolution. No Movie Found.')
scn.view_settings.view_transform = 'Standard'
scn.render.image_settings.file_format = 'FFMPEG'
scn.render.ffmpeg.gopsize = 8
scn.render.ffmpeg.constant_rate_factor = 'HIGH'
scn.render.ffmpeg.format = 'QUICKTIME'
scn.render.ffmpeg.audio_codec = 'AAC'
scn.render.ffmpeg.audio_codec = 'MP3'
scn.render.ffmpeg.audio_mixrate = 44100
scn.render.ffmpeg.audio_bitrate = 128
shots = get_strips(channel='Shots')
if shots:
scn.frame_end = shots[-1].frame_final_end -1
elif movie:
scn.frame_end = movie.frame_final_end -1
return {"FINISHED"}
class VSETB_OT_scene_cut_detection(Operator):
"""Launch subprocess with ffmpeg and python to find and create each
shots strips from video source"""
bl_idname = "vse_toolbox.scene_cut_detection"
bl_label = "Scene Cut Detection"
bl_description = "Detect scene change and create strips on the destination channel, use crop to restric detection"
bl_options = {"REGISTER", "UNDO"}
animated_threshold: FloatProperty(name="Threshold", default=0.5, min=0, max=1,
description='Probability for the current frame to introduce a new scene')
still_threshold: FloatProperty(name="Threshold", default=0.001, min=0, max=1, precision=4,
description="Noise tolerance, difference ratio between 0 and 1")
frame_start: IntProperty(name='Start Split')
frame_end: IntProperty(name='End Split')
source_channel_name: EnumProperty(
items=lambda self, ctx: ((c.name, c.name, '') for c in ctx.scene.sequence_editor.channels),
name='Source Channel')
destination_channel_name: EnumProperty(
items=lambda self, ctx: ((c.name, c.name, '') for c in ctx.scene.sequence_editor.channels),
name='Destination Channel')
movie_type: EnumProperty(
items=[('ANIMATED', 'Animated', 'Use select filter from ffmpeg, best for animated frame'),
('STILL', 'Still', 'Use freezedetect filter from ffmpeg, best for board or text')],
name='Movie Type')
def invoke(self, context, event):
if context.scene.sequence_editor.channels.get('Shots'):
self.destination_channel_name = 'Shots'
# Select active channel by default
if (strip := context.active_strip) and get_channel_name(strip) != 'Shots':
self.source_channel_name = get_channel_name(strip)
self.frame_start = context.scene.frame_start
self.frame_end = context.scene.frame_end
if context.selected_strips:
self.frame_start = min([s.frame_final_start for s in context.selected_strips])
self.frame_end = max([s.frame_final_end for s in context.selected_strips])
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
col = layout.column()
col.use_property_split = True
col.use_property_decorate = False
row = col.row(align=True)
row.prop(self, 'movie_type', expand=True)
if self.movie_type == 'ANIMATED':
col.prop(self, 'animated_threshold')
else:
col.prop(self, 'still_threshold')
col.prop(self, 'source_channel_name')
col.prop(self, 'destination_channel_name')
split_col = col.column(align=True)
split_col.prop(self, 'frame_start', text='Frame Split First')
split_col.prop(self, 'frame_end', text='Last')
def execute(self, context):
if self.source_channel_name == self.destination_channel_name:
self.report({"ERROR"}, 'Source and Destination cannot be the same channel')
return {'CANCELLED'}
context.window_manager.modal_handler_add(self)
return {'PASS_THROUGH'}
def modal(self, context, event):
scn = context.scene
strips = get_strips(channel=self.source_channel_name)
i = 1
frame_start = self.frame_start
for strip in strips:
if strip.type != 'MOVIE':
continue
# Skip strip outside the frame range to create shot from.
if strip.frame_final_start >= self.frame_end or strip.frame_final_end <= self.frame_start:
continue
threshold = self.animated_threshold if self.movie_type == 'ANIMATED' else self.still_threshold
params = dict(strip=strip, movie_type= self.movie_type, threshold=threshold,
frame_start=self.frame_start, frame_end=self.frame_end,
crop=(strip.crop.min_x, strip.crop.max_x, strip.crop.min_y, strip.crop.max_y))
for frame_end in detect_scene_change(**params):
if not frame_end:
continue
# Convert movie frame to strips frame
if frame_start+int(strip.frame_start) < self.frame_start:
frame_start = self.frame_start
frame_end += int(strip.frame_final_start)
if frame_end > self.frame_end:
frame_end = self.frame_end
create_shot_strip(
f'tmp_shot_{str(i).zfill(3)}',
start=frame_start,
end=frame_end,
channel=self.destination_channel_name
)
i += 1
frame_start = frame_end
bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
#process.wait()
# Last strip:
if frame_start < self.frame_end:
create_shot_strip(
f'tmp_shot_{str(i).zfill(3)}',
start=frame_start,
end=self.frame_end,
channel=self.destination_channel_name
)
return {'FINISHED'}
class VSETB_OT_set_stamps(Operator):
bl_idname = "vse_toolbox.set_stamps"
bl_label = "Set Stamps"
bl_description = "Set Stamps on Video"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
scn = context.scene
settings = get_scene_settings()
project = settings.active_project
# Remove existing stamps
for strip in get_strips('Stamps'):
if strip.type == 'META':
scn.sequence_editor.strips.remove(strip)
# Ensure a Stamps channel exists at the top
stamps_channel = get_channel_index('Stamps')
if stamps_channel == 0:
# Find the highest used channel and place Stamps above it
all_strips = list(scn.sequence_editor.strips)
max_channel = max((s.channel for s in all_strips), default=0)
stamps_channel = max_channel + 1
channels = scn.sequence_editor.channels
if stamps_channel < len(channels):
channels[stamps_channel].name = 'Stamps'
bpy.ops.sequencer.select_all(action='DESELECT')
height = scn.render.resolution_y
width = scn.render.resolution_x
ratio = (height / 1080)
margin = 0.01
box_margin = 0.005
font_size = int(24*ratio)
crop_x = int(width * 0.4)
crop_max_y = int(height - font_size*2)
# Use temporary high channels for text strips before meta grouping
tmp_base = stamps_channel + 1
stamp_params = dict(start=scn.frame_start, end=scn.frame_end,
font_size=font_size, y=margin, box_margin=box_margin, select=True, box_color=(0, 0, 0, 0.5))
# Project Name
project_text = '{project}'
if project.type == 'TVSHOW':
project_text = '{project} / ep {episode}'
project_strip_stamp = new_text_strip('project_stamp', channel=tmp_base, **stamp_params,
text=project_text, x=0.01, anchor_x='LEFT', anchor_y='BOTTOM')
project_strip_stamp.crop.max_x = crop_x * 2
project_strip_stamp.crop.max_y = crop_max_y
# Shot Name
shot_strip_stamp = new_text_strip('shot_stamp', channel=tmp_base + 1, **stamp_params,
text='{sequence_strip} / {strip}', anchor_y='BOTTOM')
shot_strip_stamp.crop.min_x = crop_x
shot_strip_stamp.crop.max_x = crop_x
shot_strip_stamp.crop.max_y = crop_max_y
# Frame
frame_strip_stamp = new_text_strip('frame_stamp', channel=tmp_base + 2, **stamp_params,
text='{shot_frame} / {shot_duration} {timecode}', x=0.99, anchor_x='RIGHT', anchor_y='BOTTOM')
frame_strip_stamp.crop.min_x = crop_x *2
frame_strip_stamp.crop.max_y = crop_max_y
bpy.ops.sequencer.meta_make()
stamps_strip = context.active_strip
stamps_strip.name = 'Stamps'
stamps_strip.channel = stamps_channel
#stamps_strip = scn.sequence_editor.sequences.new_meta('Stamps', scn.frame_start, scn.frame_end)
#stamps_strip.channel = get_channel_index('Stamps')
scn.frame_set(scn.frame_current) # For update stamps
return {"FINISHED"}
class VSETB_OT_previous_shot(Operator):
bl_idname = "vse_toolbox.previous_shot"
bl_label = "Jump to Previous Shot"
bl_description = "Jump to Previous Shot"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
strips = get_strips('Shots')
if not strips:
return {"CANCELLED"}
active_strip = get_strip_at('Shots')
if active_strip is strips[0]:
return {"CANCELLED"}
active_strip_index = strips.index(active_strip)
next_shot = strips[active_strip_index - 1]
context.scene.frame_set(next_shot.frame_final_start)
bpy.ops.sequencer.select_all(action="DESELECT")
next_shot.select = True
context.scene.sequence_editor.active_strip = next_shot
return {"FINISHED"}
class VSETB_OT_next_shot(Operator):
bl_idname = "vse_toolbox.next_shot"
bl_label = "Jump to Next Shot"
bl_description = "Jump to Next Shot"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
strips = get_strips('Shots')
if not strips:
return {"CANCELLED"}
active_strip = get_strip_at('Shots')
if active_strip is strips[-1]:
return {"CANCELLED"}
active_strip_index = strips.index(active_strip)
next_shot = strips[active_strip_index + 1]
context.scene.frame_set(next_shot.frame_final_start)
bpy.ops.sequencer.select_all(action="DESELECT")
next_shot.select = True
context.scene.sequence_editor.active_strip = next_shot
return {"FINISHED"}
class VSETB_OT_open_strip_folder(Operator):
bl_idname = "vse_toolbox.open_strip_folder"
bl_label = "Open Strip Folder"
bl_description = "Open selected strip folder"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
strip = context.active_strip
if not strip:
cls.poll_message_set('No active')
return
if not any(p in get_channel_name(strip) for p in ('Movie', 'Video', 'Audio', 'Sound')):
cls.poll_message_set('No active Movie or Audio strip')
return
return True
def execute(self, context):
tpl_by_channel = {
'Shots': 'shot_dir',
'Sequences': 'sequence_dir'
}
strip = context.active_strip
settings = get_scene_settings()
project = settings.active_project
strip_settings = get_strip_settings()
format_data = {**settings.format_data, **project.format_data, **strip_settings.format_data}
channel_name = get_channel_name(strip)
if strip.type in ('MOVIE', 'IMAGE'):
path = Path(strip.filepath)
elif strip.type in ('SOUND'):
path = Path(strip.sound.filepath)
else:
folder_template = expandvars(project.templates[tpl_by_channel[channel_name]].value)
path = Path(folder_template.format(**format_data))
if not path.is_dir():
path = path.parent
bpy.ops.wm.path_open(filepath=str(path))
return {"FINISHED"}
class VSETB_OT_collect_files(Operator):
bl_idname = "vse_toolbox.collect_files"
bl_label = "Collect Files"
bl_description = "Collect Files"
bl_options = {"REGISTER", "UNDO"}
collect_folder: StringProperty(
name="Collect Folder", default="//sources", subtype='DIR_PATH')
@classmethod
def poll(cls, context):
if bpy.data.is_saved:
return True
cls.poll_message_set('Save the blend to collect files')
def execute(self, context):
strip = context.active_strip
settings = get_scene_settings()
project = settings.active_project
strips = [s for s in context.scene.sequence_editor.strips_all if s.type in ('MOVIE', 'SOUND')]
context.window_manager.progress_begin(0, len(strips))
for i, strip in enumerate(strips):
context.window_manager.progress_update(i)
if strip.type == 'MOVIE':
src_path = strip.filepath
elif strip.type == 'SOUND':
src_path = strip.sound.filepath
src_path = Path(abspath(bpy.path.abspath(src_path)))
dst_path = Path(bpy.path.abspath(self.collect_folder), src_path.name)
if src_path == dst_path:
continue
dst_path.parent.mkdir(exist_ok=True, parents=True)
print(f'Copy file from {src_path} to {dst_path}')
copy2(str(src_path), str(dst_path))
rel_path = bpy.path.relpath(str(dst_path))
if len(Path(rel_path).as_posix()) < len(dst_path.as_posix()):
dst_path = rel_path
if strip.type == 'MOVIE':
strip.filepath = str(dst_path)
elif strip.type == 'SOUND':
strip.sound.filepath = str(dst_path)
context.window_manager.progress_end()
return {"FINISHED"}
def invoke(self, context, event):
scn = context.scene
settings = get_scene_settings()
return context.window_manager.invoke_props_dialog(self)
class VSETB_OT_insert_channel(Operator):
bl_idname = "vse_toolbox.insert_channel"
bl_label = "Insert Channel"
bl_description = "Insert a Channel bellow the active strip"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.active_strip
def execute(self, context):
scn = context.scene
channel_index = context.active_strip.channel
strips = list(scn.sequence_editor.strips)
for strip in sorted(strips, key=lambda x: x.channel, reverse=True):
if strip.channel >= channel_index:
strip.channel += 1
channels = {i: (c.name, c.lock, c.mute) for i, c in enumerate(scn.sequence_editor.channels)}
for i in sorted(channels.keys()):
channel = scn.sequence_editor.channels[i]
#i = list(scn.sequence_editor.channels).index(i)
prev_channel = channels.get(i-1)
if i == channel_index:
channel.name = "Channel"
channel.lock = False
channel.mute = False
elif i >= channel_index and prev_channel is not None:
prev_name, prev_lock, prev_mute = prev_channel
channel.name = prev_name
if channel.lock != prev_lock:
channel.lock = prev_lock
if channel.mute != prev_mute:
channel.mute = prev_mute
return {'FINISHED'}
class VSETB_OT_remove_channel(Operator):
bl_idname = "vse_toolbox.remove_channel"
bl_label = "Remove Channel"
bl_description = "Remove Channel bellow the active strip"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.active_strip
def execute(self, context):
scn = context.scene
channel_index = context.active_strip.channel
if [s for s in scn.sequence_editor.strips if s.channel == channel_index-1]:
self.report({"WARNING"}, "Channel Bellow not empty")
strips = list(scn.sequence_editor.strips)
for strip in sorted(strips, key=lambda x: x.channel):
if strip.channel >= channel_index:
strip.channel -= 1
channels = {i: (c.name, c.lock, c.mute) for i, c in enumerate(scn.sequence_editor.channels)}
for i in sorted(channels.keys(), reverse=True):
channel = scn.sequence_editor.channels[i]
#i = list(scn.sequence_editor.channels).index(i)
prev_channel = channels.get(i+1)
if i >= channel_index-1 and prev_channel is not None:
prev_name, prev_lock, prev_mute = prev_channel
channel.name = prev_name
if channel.lock != prev_lock:
channel.lock = prev_lock
if channel.mute != prev_mute:
channel.mute = prev_mute
return {'FINISHED'}
class WM_OT_split_view(Operator):
"""Toggle Split sequencer view"""
bl_idname = "wm.split_view"
bl_label = "Split View"
def get_preview_areas(self, context):
return [
x for x in context.screen.areas
if x.type == "SEQUENCE_EDITOR" and x.spaces[0].view_type == "PREVIEW"
]
def invoke(self, context, event):
preview_areas = self.get_preview_areas(context)
if not preview_areas:
return {"CANCELLED"}
scn = context.scene
video_channels = [i for i, c in enumerate(scn.sequence_editor.channels) if 'Video' in c.name]
if len(video_channels) < 2:
self.report({"ERROR"}, 'You need two channels to split the view')
return {"CANCELLED"}
if len(preview_areas) == 1:
# Split area
with bpy.context.temp_override(area=preview_areas[0]):
bpy.ops.screen.area_split(direction="VERTICAL")
# Disable toolbar on right panel
# Update areas
preview_areas = self.get_preview_areas(context)
preview_areas[-1].spaces[0].display_channel = 0 #video_channels[-1]
preview_areas[0].spaces[0].display_channel = video_channels[-2]
preview_areas[0].spaces[0].show_gizmo_navigate = False
preview_areas[-1].spaces[0].show_gizmo_navigate = False
# Hide toolbar
#preview_areas[0].spaces[0].show_region_toolbar = False
else:
# Give the remaining area to have the left area's channel displayed
# Show toolbar of remaning area
#preview_areas[0].spaces[0].show_region_toolbar = True
# Join areas
# Simulates the mouse position as being between the two areas horizontally
# and a bit above the bottom corner vertically
cursor_x = int(preview_areas[0].x - (preview_areas[0].x - preview_areas[1].width) / 2)
cursor_y = preview_areas[0].y + 10
bpy.ops.screen.area_join(cursor=(cursor_x, cursor_y))
preview_areas = self.get_preview_areas(context)
preview_areas[0].spaces[0].display_channel = 0
# Force UI update, due to Blender bug, delete when fixed -> https://developer.blender.org/T65529
bpy.ops.screen.area_swap(cursor=(preview_areas[0].x, preview_areas[0].y))
bpy.ops.screen.area_swap(cursor=(preview_areas[0].x, preview_areas[0].y))
return {"FINISHED"}
class VSETB_OT_merge_shot_strips(Operator):
"""Merge selected shots strips (required at least two strips)."""
bl_idname = "vse_toolbox.merge_shot_strips"
bl_label = "Merge Shot Strips"
@classmethod
def poll(cls, context):
selected_strips = bpy.context.selected_strips
if len(selected_strips) <= 1:
return False
return all(get_channel_name(strip) == 'Shots' for strip in selected_strips)
def execute(self, context):
selected_strips = bpy.context.selected_strips
last_frame = selected_strips[-1].frame_final_end
for i in range(1, len(selected_strips)):
context.scene.sequence_editor.strips.remove(selected_strips[i])
selected_strips[0].frame_final_end = last_frame
return {"FINISHED"}
class VSETB_OT_create_sequence_strip(Operator):
"""Create a sequence strip spanning the selected shot strips."""
bl_idname = "vse_toolbox.create_sequence_strip"
bl_label = "Create Sequence Strip"
bl_options = {"REGISTER", "UNDO"}
sequence_name: StringProperty(
name="Sequence Name",
description="Name of the sequence (e.g. SC010)",
default="SC010",
)
@classmethod
def poll(cls, context):
selected = context.selected_strips
if not selected:
cls.poll_message_set('No strips selected')
return False
if not any(get_channel_name(s) == 'Shots' for s in selected):
cls.poll_message_set('Select strips on the Shots channel')
return False
return True
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
shot_strips = sorted(
[s for s in context.selected_strips if get_channel_name(s) == 'Shots'],
key=lambda s: s.frame_final_start,
)
if not shot_strips:
self.report({'ERROR'}, "No shot strips selected")
return {'CANCELLED'}
scn = context.scene
seq_channel = get_channel_index('Sequences')
if seq_channel == 0:
# Insert a Sequences channel below the Shots channel
shots_channel = get_channel_index('Shots')
if not shots_channel:
self.report({'ERROR'}, "No 'Shots' channel found")
return {'CANCELLED'}
seq_channel = shots_channel
# Push all strips at or above this channel up by one
all_strips = list(scn.sequence_editor.strips)
for s in sorted(all_strips, key=lambda x: x.channel, reverse=True):
if s.channel >= seq_channel:
s.channel += 1
# Shift channel names up
channels = scn.sequence_editor.channels
chan_data = {i: (c.name, c.lock, c.mute) for i, c in enumerate(channels)}
for i in sorted(chan_data.keys(), reverse=True):
if i >= seq_channel:
prev = chan_data[i]
ch = channels[i + 1] if (i + 1) < len(channels) else None
if ch:
ch.name, ch.lock, ch.mute = prev
channels[seq_channel].name = 'Sequences'
channels[seq_channel].lock = False
channels[seq_channel].mute = False
frame_start = shot_strips[0].frame_final_start
frame_end = shot_strips[-1].frame_final_end
strip = scn.sequence_editor.strips.new_effect(
name=self.sequence_name,
type='COLOR',
channel=seq_channel,
frame_start=frame_start,
length=frame_end - frame_start,
)
strip.blend_alpha = 0
strip.color_tag = 'COLOR_07' # Purple
self.report({'INFO'}, f"Created sequence '{self.sequence_name}' ({len(shot_strips)} shots)")
return {"FINISHED"}
class VSETB_OT_update_media(Operator):
bl_idname = "vse_toolbox.update_media"
bl_label = "Update Media"
bl_description = "Update selected source"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not (strip := context.active_strip) or strip.type not in ('MOVIE', 'SOUND'):
cls.poll_message_set('No active AUDIO or MOVIE strips')
return
prefs = get_addon_prefs()
if prefs.tracker:
return True
def execute(self, context):
for strip in context.selected_strips:
current_movie = Path(abspath(bpy.path.abspath(strip.filepath)))
pattern_name = re.sub(r'[^\s._\?]+', '*', current_movie.name)
latest_file = sorted(list(current_movie.parent.glob(pattern_name)))[-1]
if latest_file != current_movie:
print(latest_file)
strip.filepath = str(latest_file)
return {'FINISHED'}
addon_keymaps = []
def register_keymaps():
addon = bpy.context.window_manager.keyconfigs.addon
if not addon:
return
#print('VSE Toolbox Keymaps Register')
km = addon.keymaps.new(name="Sequencer", space_type="SEQUENCE_EDITOR")
kmi = km.keymap_items.new('vse_toolbox.previous_shot', type='LEFT_ARROW', value='PRESS', ctrl=True)
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new('vse_toolbox.next_shot', type='RIGHT_ARROW', value='PRESS', ctrl=True)
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new('vse_toolbox.merge_shot_strips', type='J', value='PRESS', ctrl=True)
addon_keymaps.append((km, kmi))
def unregister_keymaps():
#print('unregister_keymaps', addon_keymaps)
for km, kmi in addon_keymaps:
if kmi in list(km.keymap_items):
km.keymap_items.remove(kmi)
addon_keymaps.clear()
classes = (
VSETB_OT_rename,
VSETB_OT_set_sequencer,
VSETB_OT_scene_cut_detection,
VSETB_OT_set_stamps,
VSETB_OT_show_waveform,
VSETB_OT_previous_shot,
VSETB_OT_next_shot,
VSETB_OT_open_strip_folder,
VSETB_OT_collect_files,
VSETB_OT_insert_channel,
VSETB_OT_remove_channel,
VSETB_OT_merge_shot_strips,
VSETB_OT_create_sequence_strip,
VSETB_OT_update_media,
WM_OT_split_view,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
register_keymaps()
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
unregister_keymaps()